Compare commits

..
11 Commits
Author SHA1 Message Date
James Wampler aa611feaff WIP
CI / build-and-push (push) Has been cancelled
CI / deploy-qa (push) Has been cancelled
CI / smoke-qa (push) Has been cancelled
2026-07-22 14:02:59 -07:00
James Wampler 3eebef56a0 Add MicCheck.Client library with FeatureClient.IsEnabledAsync
Client-side SDK for checking feature flag state against the flags API,
with unit tests mocking HttpMessageHandler.
2026-07-09 12:48:21 -07:00
James Wampler 8262cd2f61 Drop docker-compose from dev workflow; run full build/test and Aspire locally
CI / build-and-push (push) Successful in 1m57s
CI / deploy-qa (push) Successful in 14s
CI / smoke-qa (push) Successful in 22s
dev-build.sh now builds the whole solution and runs .NET + Jest unit
tests instead of restarting docker-compose services. readme documents
the aspire run command for local orchestration; slnx solution-items
reference to the removed docker-compose.yml is gone.
2026-07-06 10:44:11 -07:00
James Wampler 83441b6c69 Removing old docker-compose file. Use Aspire instead
CI / build-and-push (push) Successful in 51s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 22s
2026-07-06 10:33:02 -07:00
James Wampler 283ca4f148 Move QA Postgres password out of docker-compose into Gitea secret
Follows the same pattern already used for JWT_SECRET_KEY: interpolated
from a required env var, sourced from a Gitea Actions secret in CI.
2026-07-06 10:32:04 -07:00
James Wamplerandmiccheck-ci 7a3e2167c2 Remove FluentValidation; validator/DI cleanup (#7)
CI / build-and-push (push) Successful in 1m0s
CI / deploy-qa (push) Successful in 11s
CI / smoke-qa (push) Successful in 26s
## Summary
- Remove FluentValidation dependency; replace with plain IModelValidator<T>/ValidationResult pattern (ported from BuyEngine's Guard/validation approach), wired via a ModelValidationActionFilter
- Move FeatureUsage code into MicCheck.Api.Features.Usage namespace
- Convert logic-free data classes to records per CLAUDE.md (WebhookEvent, FeatureStateResult, ProjectPermissionRequirement, AuditLog, Tag, FeatureUsageDaily)
- Extract IAuditService interface, drop virtual-method mock seam on AuditService
- Split Program.cs DI registrations into per-namespace DependencyRegistration classes

## Test plan
- [x] dotnet build (API + tests) clean
- [x] dotnet test MicCheck.Api.Tests.Unit — 646/646 passing
- [x] pre-push hook (full solution build/test + admin build) passed

Co-authored-by: miccheck-ci <ci@miccheck.local>
Reviewed-on: #7
2026-07-05 22:54:30 -07:00
James Wampler 127aefc020 Fix npm audit vulnerabilities in admin (form-data high, js-yaml moderate)
CI / build-and-push (push) Successful in 1m0s
CI / deploy-qa (push) Successful in 15s
CI / smoke-qa (push) Successful in 30s
Both are transitive (axios->form-data, babel-jest->js-yaml); npm audit
fix bumped them non-breaking, package.json unchanged. Full jest suite
still green (306/306).
2026-07-05 16:14:33 -07:00
miccheck-ci 9d445aca67 chore: refresh coverage badge [skip ci] 2026-07-05 22:08:11 +00:00
James Wampler 87113ccdcd Add unit tests for Environments, Audit, and Webhooks namespaces (#6)
CI / build-and-push (push) Successful in 54s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 24s
## Summary
- Add missing unit test coverage for the Environments, Audit, and Webhooks namespaces (raises them from ~0-62% to 91-100%)
- Exclude WebhookBackgroundService/WebhookRetryBackgroundService from coverage (require live DI/DB, disallowed by CLAUDE.md's no-InMemory/WebApplicationFactory rule)

## Test plan
- [x] `dotnet test` full suite passes (646/646)
- [x] Coverage report confirms Environments ~99.5%, Audit 100%, Webhooks ~91%

Reviewed-on: #6
2026-07-05 15:07:26 -07:00
James Wampler cae55e5737 Add coverage reporting/badges and split CI between Gitea and GitHub (#5)
CI / build-and-push (push) Successful in 46s
CI / deploy-qa (push) Successful in 13s
CI / smoke-qa (push) Successful in 35s
Merge dotnet+jest coverage via reportgenerator, publish a self-hosted
coverage badge and build-status badges on the readme. Gitea remains the
full pipeline (build/test/docker push/deploy-qa/smoke); GitHub only
builds and tests since it has no registry secrets or qa runner.

Reviewed-on: #5
2026-07-05 10:14:30 -07:00
James Wampler 20188c61a2 Refactor nunit tests off InMemory/WebApplicationFactory, remove tuple returns (#4)
CI / build-and-push (push) Successful in 48s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 20s
## Summary
- Extract `IMicCheckDbContext` and mock DB access with Moq instead of the EF InMemory provider, per CLAUDE.md testing guidelines.
- Rewrite HTTP-pipeline tests to exercise controllers/auth handlers directly instead of `WebApplicationFactory`.
- Remove tuple return types across the API in favor of named records (`PagedResult<T>`, `ProfileUpdateResult`, `ApiKeyCreationResult`).

## Test plan
- [x] `dotnet build` (full solution)
- [x] `dotnet test tests/api/MicCheck.Api.Tests.Unit` — 187/187 pass
- [x] `dotnet test tests/api/MicCheck.Api.Tests.Integration` — 8/8 pass (live API + Postgres via Aspire)
- [x] Verified Aspire AppHost/dashboard starts and API responds

Reviewed-on: #4
2026-07-04 21:40:47 -07:00
206 changed files with 9080 additions and 2942 deletions
+25 -1
View File
@@ -2,15 +2,25 @@
# (both look under .github/workflows/). Every non-checkout step just invokes a # (both look under .github/workflows/). Every non-checkout step just invokes a
# bash script under scripts/ci/, so the entire pipeline is reproducible by # bash script under scripts/ci/, so the entire pipeline is reproducible by
# running the same scripts locally - no marketplace build/test/push actions. # running the same scripts locally - no marketplace build/test/push actions.
#
# Gitea (origin) is the internal/testing remote and runs the full pipeline:
# build, test, docker push, deploy-to-qa, smoke test. GitHub is the public
# mirror and only needs to prove the code builds and tests pass - it has no
# registry secrets and no [self-hosted, qa] runner, so the docker push and
# deploy/smoke jobs are skipped there via the `github.server_url` check
# below (identical on both engines: https://github.com on GitHub, the Gitea
# instance URL on Gitea).
name: CI name: CI
on: on:
push: push:
branches: [main, build-runner-fix] paths-ignore: [badges/**]
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
env: env:
REGISTRY: ${{ secrets.REGISTRY }} REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }} REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
@@ -25,14 +35,25 @@ jobs:
- name: Test - name: Test
run: ./scripts/ci/test.sh run: ./scripts/ci/test.sh
- name: Coverage report
run: ./scripts/ci/coverage.sh
- name: Publish coverage badge
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/ci/publish-coverage-badge.sh
- name: Build Docker images - name: Build Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-build.sh run: ./scripts/ci/docker-build.sh
- name: Push Docker images - name: Push Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-push.sh run: ./scripts/ci/docker-push.sh
deploy-qa: deploy-qa:
needs: build-and-push needs: build-and-push
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
runs-on: [self-hosted, qa] runs-on: [self-hosted, qa]
env: env:
REGISTRY: ${{ secrets.REGISTRY }} REGISTRY: ${{ secrets.REGISTRY }}
@@ -40,6 +61,7 @@ jobs:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }} QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
steps: steps:
# actions/checkout@v4 is a Node-based action; this runner has no node # actions/checkout@v4 is a Node-based action; this runner has no node
@@ -56,9 +78,11 @@ jobs:
smoke-qa: smoke-qa:
needs: deploy-qa needs: deploy-qa
if: github.server_url != 'https://github.com'
runs-on: [self-hosted, qa] runs-on: [self-hosted, qa]
env: env:
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }} QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
steps: steps:
# actions/checkout@v4 is a Node-based action; this runner has no node # actions/checkout@v4 is a Node-based action; this runner has no node
# in PATH, so checkout plain git instead of via marketplace action. # in PATH, so checkout plain git instead of via marketplace action.
+3
View File
@@ -476,3 +476,6 @@ ehthumbs.db
# Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK) # Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK)
/.dotnet/ /.dotnet/
# Self-installed reportgenerator CLI (scripts/ci/lib.sh ensure_reportgenerator)
/.dotnet-tools/
+18 -15
View File
@@ -1,10 +1,10 @@
# CLAUDE.md # CLAUDE.md
Guidance for Claude Code (claude.ai/code) when working in this repo. Guidance for Claude Code (claude.ai/code) in this repo.
## Project ## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments. MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments.
## Planned Structure ## Planned Structure
@@ -18,27 +18,30 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
- Use latest LTS .NET + latest supported nuget packages for that version - Use latest LTS .NET + latest supported nuget packages for that version
- Set `langVersion` to latest in all csproj files; enable nullable - Set `langVersion` to latest in all csproj files; enable nullable
- Organize code by feature/area, not type (e.g. `features` namespace) - Organize code by feature/area, not type (e.g. `features` namespace)
- New features need unit tests covering as much logic as possible (both nunit and jest) - New features need unit tests covering logic as much as possible (both nunit and jest)
- Any modified file: evaluate for missing test coverage and that all tests pass - Modified file: check missing test coverage, all tests pass
# Coding # Coding
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper - Descriptive names all classes/methods. No generic: Provider, Manager, Helper
- Match formatting/style from `.editorconfig` - Match formatting/style from `.editorconfig`
- Wrap lines at 220 characters, leave single line if fewer - Wrap lines at 220 chars, single line if fewer
- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file. - Interfaces implemented by single class bottom of class file. Interface w/ multiple implementations separate file.
- Do not use tuples for return types. Prefer records or classes for multiple values - No tuples for return types. Prefer records or classes for multiple values
- Do not use `sealed` - No `sealed`
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible. - Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
## Testing ## Testing
- Min 70% code coverage, target 90%. Unit tests focus end-user scenarios first.
- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user.
- Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears` - BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
- Mock external deps with Moq - Mock external deps w/ Moq
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider. - Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider.
- New features need unit tests covering as much logic as possible - New features need unit tests covering logic as much as possible
- Any modified file: evaluate for missing test coverage- - Modified file: check missing test coverage
- No "Mock" in mocked object names - No "Mock" in mocked object names
- No Arrange/Act/Assert comments - No Arrange/Act/Assert comments
- All tests should pass before commit - All tests pass before commit
## Claude ## Claude
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/` - Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/`
@@ -47,4 +50,4 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md` - Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
## Stack ## Stack
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes. `.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change.
Executable → Regular
+32 -23
View File
@@ -1,44 +1,53 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Guidance for Claude Code (claude.ai/code) when working in this repo.
## Project ## Project
MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments. MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
## Planned Structure ## Planned Structure
- `src/admin/`administrative components in VueJS - `src/admin/`VueJS admin components
- `src/api/` - api and restful endpoints in .NET - `src/api/` - .NET API + REST endpoints
- `tests/` — test suite - `tests/` — test suite
- `docs/` — documentation - `docs/` — documentation
## Best Practices ## Best Practices
- Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version - Use latest LTS .NET + latest supported nuget packages for that version
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled - Set `langVersion` to latest in all csproj files; enable nullable
- Code in all projects should be organized by feature or area instead of type. (e.g. a features namespace with all feature related code in it or in child namespaces of it) - Organize code by feature/area, not type (e.g. `features` namespace)
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible. - New features need unit tests covering as much logic as possible (both nunit and jest)
- Any file modified should be evaluated for potential test cases and missing coverage areas. - Any modified file: evaluate for missing test coverage and that all tests pass
# Coding # Coding
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper - Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
- Coding should match formating and style rules in the .editorconfig file - Match formatting/style from `.editorconfig`
- Wrap lines at 220 characters, leave single line if fewer
- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file.
- Do not use tuples for return types. Prefer records or classes for multiple values
- Do not use `sealed`
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
## Testing ## Testing
- Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears) - Require a minimum of 70% code coverage with a target of 90%. Unit tests should focus on end-user scenarios first.
- Mock any external dependencies using Moq. - Do not write tests for just to increase code coverage. Call out lack of test coverage rather than covering something that isn't valuable to the end user.
- Do not name any mocked objects with the word Mock in them - Code that can not be cleanly unit tested should be marked with [ExcludeFromCodeCoverage] or have it's namespace excluded from code coverage.
- Do not include any Arrange / Act / Assert comments in the code - BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
- Mock external deps with Moq
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider.
- New features need unit tests covering as much logic as possible
- Any modified file: evaluate for missing test coverage-
- No "Mock" in mocked object names
- No Arrange/Act/Assert comments
- All tests should pass before commit
## Claude ## Claude
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/` - Plans = `.md` files in `docs/plans/`. Admin `docs/plans/admin/`, API `docs/plans/api/`
- Divide up large plans into discrete chucks of functionality so each can be built and committed independently. - Split large plans into discrete chunks — each buildable + committable independently
- When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end - Plan generated from `docs/plans/<name>.md` save as `docs/plans/<name>_plan.md`
- When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end - Plan implemented from `docs/plans/<name>.md` save summary as `docs/plans/<name>_output.md`
## Stack ## Stack
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.
+2 -1
View File
@@ -3,7 +3,6 @@
<File Path=".editorconfig" /> <File Path=".editorconfig" />
<File Path=".gitignore" /> <File Path=".gitignore" />
<File Path="CLAUDE.md" /> <File Path="CLAUDE.md" />
<File Path="docker-compose.yml" />
<File Path="readme.md" /> <File Path="readme.md" />
</Folder> </Folder>
<Folder Name="/src/"> <Folder Name="/src/">
@@ -13,10 +12,12 @@
</Project> </Project>
<Project Path="src/api/MicCheck.Api/MicCheck.Api.csproj" /> <Project Path="src/api/MicCheck.Api/MicCheck.Api.csproj" />
<Project Path="src/MicCheck.AppHost/MicCheck.AppHost.csproj" /> <Project Path="src/MicCheck.AppHost/MicCheck.AppHost.csproj" />
<Project Path="src/MicCheck.Client/MicCheck.Client.csproj" />
<Project Path="src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj" /> <Project Path="src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" /> <Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" />
<Project Path="tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj" /> <Project Path="tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj" />
<Project Path="tests/client/MicCheck.Client.Tests.Unit/MicCheck.Client.Tests.Unit.csproj" />
</Folder> </Folder>
</Solution> </Solution>
+138
View File
@@ -0,0 +1,138 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="155" height="20">
<style type="text/css">
<![CDATA[
@keyframes fade1 {
0% { visibility: visible; opacity: 1; }
23% { visibility: visible; opacity: 1; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: visible; opacity: 1; }
}
@keyframes fade2 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: visible; opacity: 1; }
48% { visibility: visible; opacity: 1; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade3 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: visible; opacity: 1; }
73% { visibility: visible; opacity: 1; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade4 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: visible; opacity: 1; }
98% { visibility: visible; opacity: 1; }
100% { visibility: hidden; opacity: 0; }
}
.linecoverage {
animation-duration: 15s;
animation-name: fade1;
animation-iteration-count: infinite;
}
.branchcoverage {
animation-duration: 15s;
animation-name: fade2;
animation-iteration-count: infinite;
}
.methodcoverage {
animation-duration: 15s;
animation-name: fade3;
animation-iteration-count: infinite;
}
.fullmethodcoverage {
animation-duration: 15s;
animation-name: fade4;
animation-iteration-count: infinite;
}
]]>
</style>
<title>Code coverage</title>
<defs>
<linearGradient id="gradient" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<linearGradient id="c">
<stop offset="0" stop-color="#d40000"/>
<stop offset="1" stop-color="#ff2a2a"/>
</linearGradient>
<linearGradient id="a">
<stop offset="0" stop-color="#e0e0de"/>
<stop offset="1" stop-color="#fff"/>
</linearGradient>
<linearGradient id="b">
<stop offset="0" stop-color="#37c837"/>
<stop offset="1" stop-color="#217821"/>
</linearGradient>
<linearGradient xlink:href="#a" id="e" x1="106.44" x2="69.96" y1="-11.96" y2="-46.84" gradientTransform="matrix(-.8426 -.00045 -.00045 -.8426 -94.27 -75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="f" x1="56.19" x2="77.97" y1="-23.45" y2="10.62" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#c" id="g" x1="79.98" x2="132.9" y1="10.79" y2="10.79" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<mask id="mask">
<rect width="155" height="20" rx="3" fill="#fff"/>
</mask>
<g id="icon" transform="matrix(.04486 0 0 .04481 -.48 -.63)">
<rect width="52.92" height="52.92" x="-109.72" y="-27.13" fill="url(#e)" transform="rotate(-135)"/>
<rect width="52.92" height="52.92" x="70.19" y="-39.18" fill="url(#f)" transform="rotate(45)"/>
<rect width="52.92" height="52.92" x="80.05" y="-15.74" fill="url(#g)" transform="rotate(45)"/>
</g>
</defs>
<g mask="url(#mask)">
<rect x="0" y="0" width="90" height="20" fill="#444"/>
<rect x="90" y="0" width="20" height="20" fill="#c00"/>
<rect x="110" y="0" width="45" height="20" fill="#00B600"/>
<rect x="0" y="0" width="155" height="20" fill="url(#gradient)"/>
</g>
<g>
<path class="" stroke="#fff" d="M94 6.5 h12 M94 10.5 h12 M94 14.5 h12"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Arial,Geneva,sans-serif" font-size="11">
<a xlink:href="https://github.com/danielpalme/ReportGenerator" target="_top">
<title>Generated by: ReportGenerator 5.5.10.0</title>
<use xlink:href="#icon" transform="translate(3,1) scale(3.5)"/>
</a>
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
<text x="53" y="14" fill="#fff">Coverage</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">68.9%</text><text class="" x="132.5" y="14">68.9%</text>
</g>
<g>
<rect class="" x="90" y="0" width="65" height="20" fill-opacity="0"><title>Line coverage</title></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

+1
View File
@@ -9,4 +9,5 @@ REGISTRY_TOKEN=changeme
# QA environment # QA environment
JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret
POSTGRES_PASSWORD=change-this-to-a-random-password
QA_ADMIN_PORT=3001 QA_ADMIN_PORT=3001
+2 -2
View File
@@ -13,7 +13,7 @@ services:
environment: environment:
POSTGRES_DB: miccheck POSTGRES_DB: miccheck
POSTGRES_USER: miccheck POSTGRES_USER: miccheck
POSTGRES_PASSWORD: password POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
ports: ports:
- "${DB_BIND_HOST:-127.0.0.1}:55432:5432" - "${DB_BIND_HOST:-127.0.0.1}:55432:5432"
volumes: volumes:
@@ -31,7 +31,7 @@ services:
environment: environment:
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=password" ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD}"
Jwt__SecretKey: ${JWT_SECRET_KEY} Jwt__SecretKey: ${JWT_SECRET_KEY}
Jwt__Issuer: MicCheck Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck Jwt__Audience: MicCheck
+13 -21
View File
@@ -3,27 +3,19 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
build_api() { echo "Building solution..."
echo "Building API..." dotnet build "$SCRIPT_DIR/MicCheck.slnx"
dotnet publish "$SCRIPT_DIR/src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o "$SCRIPT_DIR/src/api/MicCheck.Api/publish"
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart api
echo "API done."
}
build_admin() { echo "Installing admin dependencies..."
echo "Building admin..."
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
npm --prefix "$SCRIPT_DIR/src/admin" run build
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart admin
echo "Admin done."
}
case "${1:-all}" in echo "Building admin..."
api) build_api ;; npm --prefix "$SCRIPT_DIR/src/admin" run build
admin) build_admin ;;
all) build_api && build_admin ;; echo "Running .NET unit tests..."
*) dotnet test "$SCRIPT_DIR/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj"
echo "Usage: $0 [api|admin|all]"
exit 1 echo "Running Jest tests..."
;; npm --prefix "$SCRIPT_DIR/src/admin" test
esac
echo "Build and tests complete."
-60
View File
@@ -1,60 +0,0 @@
services:
# ── PostgreSQL ───────────────────────────────────────────────────────────────
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: miccheck
POSTGRES_USER: miccheck
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U miccheck -d miccheck"]
interval: 5s
timeout: 5s
retries: 10
# ── .NET API ─────────────────────────────────────────────────────────────────
api:
build:
context: .
dockerfile: src/api/MicCheck.Api/Dockerfile
ports:
- "8080:8080"
volumes:
- ./src/api/MicCheck.Api/publish:/app
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: "Host=db;Database=miccheck;Username=miccheck;Password=password"
Jwt__SecretKey: "miccheck-dev-secret-key-change-in-production!!"
Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/health 2>/dev/null || exit 0"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# ── Vue Admin Site (nginx) ───────────────────────────────────────────────────
admin:
build:
context: src/admin
dockerfile: Dockerfile
ports:
- "3000:80"
volumes:
- ./src/admin/dist:/usr/share/nginx/html
depends_on:
api:
condition: service_started
volumes:
postgres_data:
+59
View File
@@ -0,0 +1,59 @@
# System Design — QA Physical Architecture
Physical deployment topology for the QA environment. Source: `deploy/qa/docker-compose.qa.yml`, `scripts/ci/*.sh`, `.github/workflows/ci.yml`.
```mermaid
flowchart TB
subgraph CI["Self-hosted CI runner (qa)"]
Pipeline["deploy-qa.sh / smoke-qa.sh"]
end
subgraph Registry["Gitea Container Registry"]
ApiImage["miccheck-api:qa"]
AdminImage["miccheck-admin:qa"]
end
subgraph QAHost["QA Docker host — network: miccheck-qa-net (bridge)"]
subgraph AdminC["admin container<br/>nginx:1.27-alpine"]
Nginx["nginx<br/>serves Vue SPA<br/>proxies /api/, /health"]
end
subgraph ApiC["api container<br/>aspnet:10.0"]
Api["MicCheck.Api<br/>ASPNETCORE_URLS=http://+:8080"]
end
subgraph DbC["db container<br/>postgres:16-alpine"]
Db[("miccheck DB")]
end
end
Browser["Browser / QA tester"]
Pipeline -->|docker build/push| Registry
Registry -->|pull :qa| AdminC
Registry -->|pull :qa| ApiC
Browser -->|":3001 (QA_ADMIN_PORT)"| Nginx
Nginx -->|"http://api:8080 (internal)"| Api
Api -->|"Host=db;5432 (internal)"| Db
Pipeline -.->|"127.0.0.1:55432 (bridge-gateway bound)"| Db
```
## Components
| Component | Image | Exposure | Notes |
|---|---|---|---|
| `admin` | `nginx:1.27-alpine` (built from `src/admin/Dockerfile.ci`, `node:22-alpine` build stage) | `${QA_ADMIN_PORT:-3001}:80` published on host | Serves Vue/Vite SPA; nginx (`src/admin/nginx.conf`) reverse-proxies `/api/` and `/health` to `api:8080` |
| `api` | `mcr.microsoft.com/dotnet/aspnet:10.0` (built from `src/api/MicCheck.Api/Dockerfile.ci`, `sdk:10.0` build stage) | no published host port — internal only, reached via `admin`'s nginx proxy | `ASPNETCORE_URLS=http://+:8080`; JWT config (`Jwt__SecretKey`/`Issuer=MicCheck`/`Audience=MicCheck`) from `JWT_SECRET_KEY` secret; healthcheck `curl localhost:8080/health` |
| `db` | `postgres:16-alpine` | `${DB_BIND_HOST:-127.0.0.1}:55432 → 5432`, bound to docker bridge gateway IP (not `0.0.0.0`) — reachable only from sibling CI containers, not off-box | DB `miccheck`, user `miccheck`, password from `POSTGRES_PASSWORD` secret; volume `miccheck-qa-pgdata` |
All three services run on an isolated bridge network `miccheck-qa-net` (project `miccheck-qa`), separate from the local dev Aspire stack.
## Deploy flow
1. `build-and-push` job builds `api` and `admin` images, tags with git SHA and `qa`, pushes to Gitea registry as `$REGISTRY/$REGISTRY_OWNER/miccheck-api` / `miccheck-admin`.
2. `deploy-qa` job (self-hosted runner, `main` branch only) pulls `:qa` images and runs `docker-compose.qa.yml` via `deploy-qa.sh`.
3. `smoke-qa` job hits the deployed stack (`smoke-qa.sh`) to verify health.
Secrets used: `REGISTRY`, `REGISTRY_OWNER`, `REGISTRY_USER`, `REGISTRY_TOKEN`, `JWT_SECRET_KEY`, `POSTGRES_PASSWORD`. Vars: `QA_ADMIN_PORT`.
+6 -1
View File
@@ -1,5 +1,9 @@
# MicCheck # MicCheck
[![GitHub CI](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml)
[![Gitea CI](https://git.wampler.us/wamplerj/mic-check/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.wampler.us/wamplerj/mic-check/actions?workflow=ci.yml)
![Coverage](badges/coverage.svg)
Open source feature flag management platform. Manage projects, environments, feature flags, segments, and identities across your apps. Open source feature flag management platform. Manage projects, environments, feature flags, segments, and identities across your apps.
Built with .NET (API) and Vue.js + Vuetify (admin UI). Built with .NET (API) and Vue.js + Vuetify (admin UI).
@@ -25,10 +29,11 @@ Code in the API is organized by feature area (e.g. `Features`, `Segments`, `Iden
## Running locally ## Running locally
`docker-compose.yml` provides supporting services. `MicCheck.AppHost` (.NET Aspire) orchestrates the API and admin app for local development. `MicCheck.AppHost` (.NET Aspire) orchestrates the API, admin app, and supporting services for local development.
```sh ```sh
./dev-build.sh ./dev-build.sh
aspire run --project src/MicCheck.AppHost
``` ```
## Testing ## Testing
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Merges the .NET (coverlet/Cobertura) and admin (Jest/lcov) coverage output
# produced by test.sh into one report via reportgenerator, prints a summary,
# appends a build-report summary when running under Actions, and refreshes
# the coverage badge committed at badges/coverage.svg. Readme embeds that
# badge via a relative path, which resolves on both GitHub and Gitea since
# the same repo content is pushed to both remotes.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
ensure_reportgenerator
REPORT_DIR="$CI_ROOT/coverage/report"
log "Merging coverage reports with reportgenerator"
reportgenerator \
-reports:"coverage/dotnet/**/coverage.cobertura.xml;src/admin/coverage/lcov.info" \
-targetdir:"$REPORT_DIR" \
-reporttypes:"Badges;MarkdownSummaryGithub;TextSummary"
cat "$REPORT_DIR/Summary.txt"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
cat "$REPORT_DIR/SummaryGithub.md" >> "$GITHUB_STEP_SUMMARY"
fi
mkdir -p "$CI_ROOT/badges"
cp "$REPORT_DIR/badge_linecoverage.svg" "$CI_ROOT/badges/coverage.svg"
log "coverage.sh complete"
+1
View File
@@ -11,6 +11,7 @@ registry_login
export API_IMAGE ADMIN_IMAGE export API_IMAGE ADMIN_IMAGE
export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}" export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}"
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}"
export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}" export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
# Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable # Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable
+16
View File
@@ -125,6 +125,22 @@ ensure_node() {
export PATH="$install_dir/bin:$PATH" export PATH="$install_dir/bin:$PATH"
} }
# Installs the dotnet-reportgenerator-globaltool CLI (merges coverlet/Jest
# coverage output into badges + build-summary markdown) into $CI_ROOT/.dotnet-tools
# if it isn't already on PATH. Mirrors ensure_dotnet()/ensure_node() above.
ensure_reportgenerator() {
if command -v reportgenerator > /dev/null 2>&1; then
return 0
fi
local tool_dir="$CI_ROOT/.dotnet-tools"
if [[ ! -x "$tool_dir/reportgenerator" ]]; then
log "reportgenerator not found on PATH; installing dotnet-reportgenerator-globaltool"
dotnet tool install dotnet-reportgenerator-globaltool --tool-path "$tool_dir"
fi
export PATH="$tool_dir:$PATH"
}
# The IP address on which a container published on 0.0.0.0/<gateway-ip> is # The IP address on which a container published on 0.0.0.0/<gateway-ip> is
# reachable from a sibling container on docker's default bridge network (i.e. # reachable from a sibling container on docker's default bridge network (i.e.
# the docker host's bridge-side address, not its public interface). Used to # the docker host's bridge-side address, not its public interface). Used to
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Commits the coverage badge refreshed by coverage.sh straight back to the
# branch that triggered this run, so readme.md's relative badges/coverage.svg
# link stays current. GITHUB_SERVER_URL/GITHUB_REPOSITORY/GITHUB_REF_NAME are
# default context env vars on both GitHub Actions and Gitea Actions (Gitea's
# engine is GitHub-Actions-compatible); GITHUB_TOKEN must be passed in
# explicitly from the workflow (${{ github.token }}) on both platforms.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
[[ -n "${GITHUB_TOKEN:-}" ]] || fail "GITHUB_TOKEN env var is required to push the badge commit"
[[ -n "${GITHUB_SERVER_URL:-}" ]] || fail "GITHUB_SERVER_URL env var is required to push the badge commit"
[[ -n "${GITHUB_REPOSITORY:-}" ]] || fail "GITHUB_REPOSITORY env var is required to push the badge commit"
[[ -n "${GITHUB_REF_NAME:-}" ]] || fail "GITHUB_REF_NAME env var is required to push the badge commit"
if git diff --quiet -- badges/coverage.svg; then
log "badges/coverage.svg unchanged; nothing to publish"
exit 0
fi
git config user.name "miccheck-ci"
git config user.email "ci@miccheck.local"
git add badges/coverage.svg
git commit -m "chore: refresh coverage badge [skip ci]"
remote_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${GITHUB_TOKEN}" push "$remote_url" "HEAD:${GITHUB_REF_NAME}"
log "publish-coverage-badge.sh complete"
+1 -1
View File
@@ -22,7 +22,7 @@ BASE_URL="http://${CI_HOST}:${QA_ADMIN_PORT}"
log "Resolved docker host as $CI_HOST for reaching the QA stack's published ports" log "Resolved docker host as $CI_HOST for reaching the QA stack's published ports"
export MICCHECK_API_BASE_URL="$BASE_URL" export MICCHECK_API_BASE_URL="$BASE_URL"
export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=password}" export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}}"
log "Running API integration suite against $BASE_URL" log "Running API integration suite against $BASE_URL"
dotnet test tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj -c Release --logger trx dotnet test tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj -c Release --logger trx
+11 -2
View File
@@ -8,10 +8,19 @@ cd "$CI_ROOT"
ensure_dotnet ensure_dotnet
# --results-directory doesn't clear prior runs - it adds a new GUID folder
# alongside old ones every time. On a runner that reuses its workspace
# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past
# runs would otherwise get merged in by coverage.sh and silently skew the
# combined percentage.
rm -rf "$CI_ROOT/coverage/dotnet"
log "Running MicCheck.Api.Tests.Unit" log "Running MicCheck.Api.Tests.Unit"
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx \
--collect:"XPlat Code Coverage" --results-directory "$CI_ROOT/coverage/dotnet" \
--settings tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings
log "Running admin Jest tests" log "Running admin Jest tests"
npm --prefix src/admin test npm --prefix src/admin test -- --coverage --coverageReporters=lcov --coverageReporters=text-summary
log "test.sh complete" log "test.sh complete"
+40
View File
@@ -0,0 +1,40 @@
namespace MicCheck.Common;
internal static class Guard
{
public static void Null<T>(T t, string parameterName) where T : class
{
if (t is null)
throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null");
}
public static void Empty(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Empty<T>(IEnumerable<T> collection, string parameterName)
{
if (collection == null || !collection.Any())
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Negative(int value, string parameterName)
{
if (value < 0)
throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero");
}
public static void NegativeOrZero(int value, string parameterName)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero");
}
public static void Default<T>(T value, string parameterName)
{
if (EqualityComparer<T>.Default.Equals(value, default))
throw new ArgumentException($"{parameterName} can not be a default value", parameterName);
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Net.Http.Json;
using MicCheck.Common;
namespace MicCheck;
public class FeatureClient(HttpClient httpClient) : IFeatureClient
{
public async Task<bool> IsEnabledAsync(string featureName, bool defaultValue = false, CancellationToken cancellationToken = default)
{
Guard.Empty(featureName, nameof(featureName));
var flags = await httpClient.GetFromJsonAsync<List<FlagResult>>("api/v1/flags", cancellationToken);
var match = flags?.FirstOrDefault(flag => string.Equals(flag.Feature.Name, featureName, StringComparison.OrdinalIgnoreCase));
return match?.Enabled ?? defaultValue;
}
}
public interface IFeatureClient
{
Task<bool> IsEnabledAsync(string featureName, bool defaultValue = false, CancellationToken cancellationToken = default);
}
+5
View File
@@ -0,0 +1,5 @@
namespace MicCheck;
public record FlagResult(int Id, FeatureSummary Feature, bool Enabled, string? FeatureStateValue);
public record FeatureSummary(int Id, string Name, string Type);
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>MicCheck</RootNamespace>
</PropertyGroup>
</Project>
+8 -10
View File
@@ -6012,16 +6012,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -8430,11 +8429,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "3.14.2", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^1.0.7", "argparse": "^1.0.7",
"esprima": "^4.0.0" "esprima": "^4.0.0"
+1 -1
View File
@@ -1,6 +1,6 @@
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditLog public record AuditLog
{ {
public int Id { get; init; } public int Id { get; init; }
public required string ResourceType { get; init; } public required string ResourceType { get; init; }
@@ -1,33 +1,30 @@
using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditLogQueryService(MicCheckDbContext db) public class AuditLogQueryService(IMicCheckDbContext db)
{ {
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByOrganizationAsync( public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default)
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByProjectAsync( public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default)
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.ProjectId == projectId); var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByEnvironmentAsync( public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default)
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
private async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ApplyFilterAndPageAsync( private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{ {
if (filter.From.HasValue) if (filter.From.HasValue)
query = query.Where(l => l.CreatedAt >= filter.From.Value); query = query.Where(l => l.CreatedAt >= filter.From.Value);
@@ -63,6 +60,6 @@ public class AuditLogQueryService(MicCheckDbContext db)
user != null ? user.FirstName + " " + user.LastName : null)) user != null ? user.FirstName + " " + user.LastName : null))
.ToListAsync(ct); .ToListAsync(ct);
return (total, items); return new PagedResult<AuditLogResponse>(total, items);
} }
} }
@@ -12,48 +12,40 @@ namespace MicCheck.Api.Audit;
[ApiController] [ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
[Route("api/v1")]
public class AuditLogsController( public class AuditLogsController(
AuditLogQueryService auditLogQueryService, AuditLogQueryService auditLogQueryService,
OrganizationService organizationService, OrganizationService organizationService,
ProjectService projectService, ProjectService projectService,
EnvironmentService environmentService) : ControllerBase EnvironmentService environmentService) : ControllerBase
{ {
[HttpGet("api/v1/organisation/{id}/audit-logs")] [HttpGet("organisation/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
int id,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound(); if (org is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct); var result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("api/v1/project/{projectId}/audit-logs")] [HttpGet("project/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct)
int projectId,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(projectId, ct); var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound(); if (project is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct); var result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("api/v1/environment/{apiKey}/audit-logs")] [HttpGet("environment/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct)
string apiKey,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct); var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
} }
+30 -6
View File
@@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) : IAuditService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@@ -12,7 +12,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
PropertyNamingPolicy = JsonNamingPolicy.CamelCase PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}; };
public virtual async Task RecordAsync( public async Task RecordAsync(
string resourceType, string resourceType,
string resourceId, string resourceId,
string action, string action,
@@ -33,7 +33,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
}, JsonOptions); }, JsonOptions);
} }
var actorUserId = ResolveActorUserId(); var actorUserId = GetCurrentUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -68,7 +68,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
} }
// Backward-compatible overload used by existing callers // Backward-compatible overload used by existing callers
public virtual async Task LogAsync( public async Task LogAsync(
string resourceType, string resourceType,
string resourceId, string resourceId,
string action, string action,
@@ -78,7 +78,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
string? changes = null, string? changes = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var actorUserId = ResolveActorUserId(); var actorUserId = GetCurrentUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -112,9 +112,33 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
}); });
} }
private int? ResolveActorUserId() private int? GetCurrentUserId()
{ {
var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return int.TryParse(claim, out var id) ? id : null; return int.TryParse(claim, out var id) ? id : null;
} }
} }
public interface IAuditService
{
Task RecordAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
object? before = null,
object? after = null,
CancellationToken ct = default);
Task LogAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
namespace MicCheck.Api.Audit;
public static class DependencyRegistration
{
public static IServiceCollection AddAuditServices(this IServiceCollection services)
{
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<AuditLogQueryService>();
return services;
}
}
@@ -0,0 +1,65 @@
using System.Text;
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Common.Security.Authentication;
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace MicCheck.Api.Common;
public static class DependencyRegistration
{
public static IServiceCollection AddCommonServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.SchemeName, _ => { })
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!))
};
});
services.AddAuthorization(options =>
{
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName)
.RequireClaim("EnvironmentId"));
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireClaim("OrganizationRole", "Admin"));
});
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<AuthService>();
services.AddScoped<ApiKeyService>();
services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
services.AddModelValidatorsFromAssemblyContaining<Program>();
services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context => ValidationProblemResponseFactory.Create(context.ModelState);
});
return services;
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace MicCheck.Api.Common;
public static class Guard
{
public static void Null<T>(T t, string parameterName) where T : class
{
if (t is null)
throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null");
}
public static void Empty(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Empty<T>(IEnumerable<T> collection, string parameterName)
{
if (collection == null || !collection.Any())
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Negative(int value, string parameterName)
{
if (value < 0)
throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero");
}
public static void NegativeOrZero(int value, string parameterName)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero");
}
public static void Default<T>(T value, string parameterName)
{
if (EqualityComparer<T>.Default.Equals(value, default))
throw new ArgumentException($"{parameterName} can not be a default value", parameterName);
}
}
@@ -0,0 +1,3 @@
namespace MicCheck.Api.Common;
public record PagedResult<T>(int Total, IReadOnlyList<T> Items);
@@ -1,8 +1,3 @@
namespace MicCheck.Api.Common; namespace MicCheck.Api.Common;
public record PaginatedResponse<T>( public record PaginatedResponse<T>(int Count, string? Next, string? Previous, IReadOnlyList<T> Results);
int Count,
string? Next,
string? Previous,
IReadOnlyList<T> Results
);
@@ -3,11 +3,11 @@ namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKey public class ApiKey
{ {
public int Id { get; init; } public int Id { get; init; }
public required string Key { get; set; } public required string Key { get; init; }
public required string Prefix { get; set; } public required string Prefix { get; init; }
public required string Name { get; set; } public required string Name { get; init; }
public int OrganizationId { get; init; } public int OrganizationId { get; init; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; set; } public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
} }
@@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via ApiKeyService unit tests.")]
public static class ApiKeyEndpoints public static class ApiKeyEndpoints
{ {
public static void MapApiKeyEndpoints(this WebApplication app) public static void MapApiKeyEndpoints(this WebApplication app)
@@ -13,10 +15,9 @@ public static class ApiKeyEndpoints
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) => group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
var (key, rawKey) = await apiKeyService.CreateAsync( var result = await apiKeyService.CreateAsync(organizationId, request.Name, request.ExpiresAt, ct);
organizationId, request.Name, request.ExpiresAt, ct);
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt)); return Results.Ok(new CreateApiKeyResponse(result.Key.Id, result.Key.Name, result.RawKey, result.Key.Prefix, result.Key.ExpiresAt));
}).WithName("CreateApiKey"); }).WithName("CreateApiKey");
@@ -1,9 +1,3 @@
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
public record ApiKeyResponse( public record ApiKeyResponse(int Id, string Name, string Prefix, bool IsActive, DateTimeOffset? ExpiresAt, DateTimeOffset CreatedAt);
int Id,
string Name,
string Prefix,
bool IsActive,
DateTimeOffset? ExpiresAt,
DateTimeOffset CreatedAt);
@@ -3,9 +3,9 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKeyService(MicCheckDbContext db) public class ApiKeyService(IMicCheckDbContext db)
{ {
public async Task<(ApiKey Key, string RawKey)> CreateAsync( public async Task<ApiKeyCreationResult> CreateAsync(
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
{ {
var rawKey = ApiKeyHasher.GenerateKey(); var rawKey = ApiKeyHasher.GenerateKey();
@@ -26,7 +26,7 @@ public class ApiKeyService(MicCheckDbContext db)
db.ApiKeys.Add(apiKey); db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (apiKey, rawKey); return new ApiKeyCreationResult(apiKey, rawKey);
} }
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default) public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
@@ -48,3 +48,5 @@ public class ApiKeyService(MicCheckDbContext db)
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
public record ApiKeyCreationResult(ApiKey Key, string RawKey);
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
namespace MicCheck.Api.Common.Security.Authentication; namespace MicCheck.Api.Common.Security.Authentication;
public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db) public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
public const string SchemeName = "ApiKey"; public const string SchemeName = "ApiKey";
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
namespace MicCheck.Api.Common.Security.Authentication; namespace MicCheck.Api.Common.Security.Authentication;
public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db) public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
public const string SchemeName = "EnvironmentKey"; public const string SchemeName = "EnvironmentKey";
@@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via AuthService unit tests.")]
public static class AuthEndpoints public static class AuthEndpoints
{ {
public static void MapAuthEndpoints(this WebApplication app) public static void MapAuthEndpoints(this WebApplication app)
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class AuthService( public class AuthService(
MicCheckDbContext db, IMicCheckDbContext db,
ITokenService tokenService, ITokenService tokenService,
IPasswordHasher<User> passwordHasher) IPasswordHasher<User> passwordHasher)
{ {
@@ -81,7 +81,9 @@ public class AuthService(
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct); var organizationUsers = await db.OrganizationUsers.Where(ou => ou.UserId == user.Id).ToListAsync(ct);
foreach (var organizationUser in organizationUsers)
user.Organizations.Add(organizationUser);
var accessToken = tokenService.GenerateToken(user); var accessToken = tokenService.GenerateToken(user);
var refreshTokenValue = GenerateSecureToken(); var refreshTokenValue = GenerateSecureToken();
@@ -2,9 +2,4 @@ using Microsoft.AspNetCore.Authorization;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class ProjectPermissionRequirement : IAuthorizationRequirement public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement;
{
public ProjectPermission Permission { get; }
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
}
@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class ProjectPermissionRequirementHandler( public class ProjectPermissionRequirementHandler(
MicCheckDbContext db, IMicCheckDbContext db,
IHttpContextAccessor httpContextAccessor) IHttpContextAccessor httpContextAccessor)
: AuthorizationHandler<ProjectPermissionRequirement> : AuthorizationHandler<ProjectPermissionRequirement>
{ {
@@ -0,0 +1,13 @@
namespace MicCheck.Api.Common.Validation;
public interface IModelValidator
{
ValidationResult Validate(object model);
}
public interface IModelValidator<in T> : IModelValidator
{
ValidationResult Validate(T model);
ValidationResult IModelValidator.Validate(object model) => Validate((T)model);
}
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace MicCheck.Api.Common.Validation;
public class ModelValidationActionFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
foreach (var argument in context.ActionArguments.Values)
{
if (argument is null) continue;
var validatorType = typeof(IModelValidator<>).MakeGenericType(argument.GetType());
if (context.HttpContext.RequestServices.GetService(validatorType) is not IModelValidator validator) continue;
var result = validator.Validate(argument);
foreach (var error in result.Errors)
context.ModelState.AddModelError(error.PropertyName, error.Message);
}
if (!context.ModelState.IsValid)
context.Result = ValidationProblemResponseFactory.Create(context.ModelState);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}
@@ -0,0 +1,20 @@
using Microsoft.Extensions.DependencyInjection;
namespace MicCheck.Api.Common.Validation;
public static class ModelValidatorServiceCollectionExtensions
{
public static IServiceCollection AddModelValidatorsFromAssemblyContaining<TMarker>(this IServiceCollection services)
{
var registrations = typeof(TMarker).Assembly.GetTypes()
.Where(type => !type.IsAbstract && !type.IsInterface)
.SelectMany(type => type.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IModelValidator<>))
.Select(i => (Interface: i, Implementation: type)));
foreach (var (@interface, implementation) in registrations)
services.AddScoped(@interface, implementation);
return services;
}
}
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace MicCheck.Api.Common.Validation;
public static class ValidationProblemResponseFactory
{
public static IActionResult Create(ModelStateDictionary modelState)
{
var errors = modelState
.Where(e => e.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
return new UnprocessableEntityObjectResult(new { errors });
}
}
@@ -0,0 +1,14 @@
namespace MicCheck.Api.Common.Validation;
public record ValidationError(string PropertyName, string Message);
public class ValidationResult
{
private readonly List<ValidationError> _errors = [];
public IReadOnlyList<ValidationError> Errors => _errors;
public bool IsValid => _errors.Count == 0;
public bool IsInvalid => _errors.Count > 0;
public void AddError(string propertyName, string message) => _errors.Add(new ValidationError(propertyName, message));
}
@@ -1,4 +1,4 @@
using MicCheck.Api.Features; using MicCheck.Api.Features.Usage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
+17 -25
View File
@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data; namespace MicCheck.Api.Data;
public class DatabaseSeeder public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{ {
/// <summary> /// <summary>
/// Deterministic credentials for the development/QA seed admin user. Only ever created when /// Deterministic credentials for the development/QA seed admin user. Only ever created when
@@ -15,25 +15,17 @@ public class DatabaseSeeder
/// Used by the API integration suite and the Playwright admin e2e suite to authenticate /// Used by the API integration suite and the Playwright admin e2e suite to authenticate
/// without depending on per-run registration. /// without depending on per-run registration.
/// </summary> /// </summary>
public const string SeedAdminEmail = "admin@miccheck.local"; private const string SeedAdminEmail = "admin@miccheck.local";
public const string SeedAdminPassword = "MicCheckQa!2026";
private const string SeedAdminPassword = "MicCheckQa!2026";
/// <summary>Deterministic environment key for the seeded Development environment, so /// <summary>Deterministic environment key for the seeded Development environment, so
/// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary> /// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary>
public const string SeedDevelopmentEnvironmentKey = "env-qa-development"; private const string SeedDevelopmentEnvironmentKey = "env-qa-development";
private readonly MicCheckDbContext _db;
private readonly IPasswordHasher<User> _passwordHasher;
public DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{
_db = db;
_passwordHasher = passwordHasher;
}
public async Task SeedAsync(CancellationToken ct = default) public async Task SeedAsync(CancellationToken ct = default)
{ {
if (await _db.Organizations.AnyAsync(ct)) if (await db.Organizations.AnyAsync(ct))
return; return;
var organization = new Organization var organization = new Organization
@@ -41,8 +33,8 @@ public class DatabaseSeeder
Name = "Default", Name = "Default",
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Organizations.Add(organization); db.Organizations.Add(organization);
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var project = new Project var project = new Project
{ {
@@ -50,13 +42,13 @@ public class DatabaseSeeder
OrganizationId = organization.Id, OrganizationId = organization.Id,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Projects.Add(project); db.Projects.Add(project);
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var environmentNames = new[] { "Development", "Staging", "Production" }; var environmentNames = new[] { "Development", "Staging", "Production" };
foreach (var name in environmentNames) foreach (var name in environmentNames)
{ {
_db.Environments.Add(new AppEnvironment db.Environments.Add(new AppEnvironment
{ {
Name = name, Name = name,
ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"env-{Guid.NewGuid():N}", ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"env-{Guid.NewGuid():N}",
@@ -65,7 +57,7 @@ public class DatabaseSeeder
}); });
} }
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var adminUser = new User var adminUser = new User
{ {
@@ -76,17 +68,17 @@ public class DatabaseSeeder
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
PasswordHash = string.Empty PasswordHash = string.Empty
}; };
adminUser.PasswordHash = _passwordHasher.HashPassword(adminUser, SeedAdminPassword); adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, SeedAdminPassword);
_db.Users.Add(adminUser); db.Users.Add(adminUser);
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
_db.OrganizationUsers.Add(new OrganizationUser db.OrganizationUsers.Add(new OrganizationUser
{ {
OrganizationId = organization.Id, OrganizationId = organization.Id,
UserId = adminUser.Id, UserId = adminUser.Id,
Role = OrganizationRole.Admin, Role = OrganizationRole.Admin,
IsPrimary = true IsPrimary = true
}); });
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Data;
public static class DependencyRegistration
{
public static IServiceCollection AddDataServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<DatabaseSeeder>();
var connectionString = configuration.GetConnectionString("miccheck")
?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
: configuration.GetConnectionString("DefaultConnection")!);
services.AddDbContext<MicCheckDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
return services;
}
}
+30 -1
View File
@@ -2,6 +2,7 @@ using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities; using MicCheck.Api.Identities;
using MicCheck.Api.Organizations; using MicCheck.Api.Organizations;
using MicCheck.Api.Projects; using MicCheck.Api.Projects;
@@ -13,7 +14,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data; namespace MicCheck.Api.Data;
public class MicCheckDbContext : DbContext public class MicCheckDbContext : DbContext, IMicCheckDbContext
{ {
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { } public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
@@ -42,3 +43,31 @@ public class MicCheckDbContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly); => modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
} }
public interface IMicCheckDbContext
{
DbSet<Organization> Organizations { get; }
DbSet<OrganizationUser> OrganizationUsers { get; }
DbSet<Project> Projects { get; }
DbSet<AppEnvironment> Environments { get; }
DbSet<Feature> Features { get; }
DbSet<FeatureState> FeatureStates { get; }
DbSet<FeatureSegment> FeatureSegments { get; }
DbSet<Tag> Tags { get; }
DbSet<Segment> Segments { get; }
DbSet<SegmentRule> SegmentRules { get; }
DbSet<SegmentCondition> SegmentConditions { get; }
DbSet<Identity> Identities { get; }
DbSet<IdentityTrait> IdentityTraits { get; }
DbSet<AuditLog> AuditLogs { get; }
DbSet<Webhook> Webhooks { get; }
DbSet<WebhookDeliveryLog> WebhookDeliveryLogs { get; }
DbSet<ApiKey> ApiKeys { get; }
DbSet<User> Users { get; }
DbSet<RefreshToken> RefreshTokens { get; }
DbSet<UserProjectPermission> UserProjectPermissions { get; }
DbSet<FeatureUsageDaily> FeatureUsageDaily { get; }
int SaveChanges();
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -1,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public record CloneEnvironmentRequest(string Name); public record CloneEnvironmentRequest(string Name);
public class CloneEnvironmentRequestValidator : AbstractValidator<CloneEnvironmentRequest> public class CloneEnvironmentRequestValidator : IModelValidator<CloneEnvironmentRequest>
{ {
public CloneEnvironmentRequestValidator() public ValidationResult Validate(CloneEnvironmentRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
} }
} }
@@ -1,14 +1,23 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public record CreateEnvironmentRequest(string Name, int ProjectId); public record CreateEnvironmentRequest(string Name, int ProjectId);
public class CreateEnvironmentRequestValidator : AbstractValidator<CreateEnvironmentRequest> public class CreateEnvironmentRequestValidator : IModelValidator<CreateEnvironmentRequest>
{ {
public CreateEnvironmentRequestValidator() public ValidationResult Validate(CreateEnvironmentRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
RuleFor(x => x.ProjectId).GreaterThan(0);
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
if (model.ProjectId <= 0)
result.AddError(nameof(model.ProjectId), "'Project Id' must be greater than 0.");
return result;
} }
} }
@@ -0,0 +1,12 @@
namespace MicCheck.Api.Environments;
public static class DependencyRegistration
{
public static IServiceCollection AddEnvironmentsServices(this IServiceCollection services)
{
services.AddScoped<EnvironmentService>();
services.AddScoped<EnvironmentDocumentService>();
return services;
}
}
@@ -5,7 +5,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public class EnvironmentDocumentService(MicCheckDbContext db) public class EnvironmentDocumentService(IMicCheckDbContext db)
{ {
public async Task<EnvironmentDocumentResponse?> GetAsync(int environmentId, CancellationToken ct = default) public async Task<EnvironmentDocumentResponse?> GetAsync(int environmentId, CancellationToken ct = default)
{ {
@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public class EnvironmentService(MicCheckDbContext db, AuditService auditService) public class EnvironmentService(IMicCheckDbContext db, IAuditService auditService)
{ {
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default) public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{ {
@@ -1,5 +1,8 @@
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common; using MicCheck.Api.Common;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Segments;
using MicCheck.Api.Webhooks; using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -141,14 +144,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
return Ok(logs.ToList()); return Ok(result.Items.ToList());
} }
[HttpGet("api/v1/environment/{apiKey}/identities")] [HttpGet("api/v1/environment/{apiKey}/identities")]
public async Task<ActionResult<PaginatedResponse<Identities.AdminIdentityResponse>>> ListIdentities( public async Task<ActionResult<PaginatedResponse<AdminIdentityResponse>>> ListIdentities(
string apiKey, string apiKey,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
CancellationToken ct = default) CancellationToken ct = default)
@@ -157,16 +160,16 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
var results = items.Select(Identities.AdminIdentityResponse.From).ToList(); var results = result.Items.Select(AdminIdentityResponse.From).ToList();
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results)); return Ok(new PaginatedResponse<AdminIdentityResponse>(result.Total, null, null, results));
} }
[HttpPost("api/v1/environment/{apiKey}/identities")] [HttpPost("api/v1/environment/{apiKey}/identities")]
public async Task<ActionResult<Identities.AdminIdentityResponse>> CreateIdentity( public async Task<ActionResult<AdminIdentityResponse>> CreateIdentity(
string apiKey, string apiKey,
[FromBody] Identities.CreateIdentityRequest request, [FromBody] CreateIdentityRequest request,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -177,13 +180,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Conflict(new { error = "An identity with this identifier already exists in the environment." }); return Conflict(new { error = "An identity with this identifier already exists in the environment." });
return CreatedAtAction(nameof(GetIdentity), new { apiKey, id = identity.Id }, return CreatedAtAction(nameof(GetIdentity), new { apiKey, id = identity.Id },
Identities.AdminIdentityResponse.From(identity)); AdminIdentityResponse.From(identity));
} }
[HttpGet("api/v1/environment/{apiKey}/identity/{id}")] [HttpGet("api/v1/environment/{apiKey}/identity/{id}")]
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity( public async Task<ActionResult<AdminIdentityResponse>> GetIdentity(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -191,13 +194,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct); var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
if (identity is null) return NotFound(); if (identity is null) return NotFound();
return Ok(Identities.AdminIdentityResponse.From(identity)); return Ok(AdminIdentityResponse.From(identity));
} }
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}")] [HttpDelete("api/v1/environment/{apiKey}/identity/{id}")]
public async Task<IActionResult> DeleteIdentity( public async Task<IActionResult> DeleteIdentity(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -211,10 +214,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpPut("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")] [HttpPut("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")]
public async Task<ActionResult<Identities.TraitResponse>> UpsertIdentityTrait( public async Task<ActionResult<TraitResponse>> UpsertIdentityTrait(
string apiKey, int id, string key, string apiKey, int id, string key,
[FromBody] Identities.UpsertTraitRequest request, [FromBody] UpsertTraitRequest request,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -228,7 +231,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")] [HttpDelete("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")]
public async Task<IActionResult> DeleteIdentityTrait( public async Task<IActionResult> DeleteIdentityTrait(
string apiKey, int id, string key, string apiKey, int id, string key,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -240,9 +243,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpGet("api/v1/environment/{apiKey}/identity/{id}/featurestates")] [HttpGet("api/v1/environment/{apiKey}/identity/{id}/featurestates")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates( public async Task<ActionResult<IReadOnlyList<FeatureStateResponse>>> GetIdentityFeatureStates(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -252,14 +255,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (identity is null) return NotFound(); if (identity is null) return NotFound();
var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct); var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct);
return Ok(states.Select(Features.FeatureStateResponse.From).ToList()); return Ok(states.Select(FeatureStateResponse.From).ToList());
} }
[HttpPut("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")] [HttpPut("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")]
public async Task<ActionResult<Features.FeatureStateResponse>> SetIdentityFeatureState( public async Task<ActionResult<FeatureStateResponse>> SetIdentityFeatureState(
string apiKey, int id, int featureId, string apiKey, int id, int featureId,
Features.UpdateFeatureStateRequest request, UpdateFeatureStateRequest request,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -269,13 +272,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (identity is null) return NotFound(); if (identity is null) return NotFound();
var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct); var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct);
return Ok(Features.FeatureStateResponse.From(state)); return Ok(FeatureStateResponse.From(state));
} }
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")] [HttpDelete("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")]
public async Task<IActionResult> DeleteIdentityFeatureState( public async Task<IActionResult> DeleteIdentityFeatureState(
string apiKey, int id, int featureId, string apiKey, int id, int featureId,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -289,22 +292,22 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpGet("api/v1/environment/{apiKey}/featurestates")] [HttpGet("api/v1/environment/{apiKey}/featurestates")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> ListFeatureStates( public async Task<ActionResult<IReadOnlyList<FeatureStateResponse>>> ListFeatureStates(
string apiKey, string apiKey,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] FeatureStateService featureStateService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct); var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct);
return Ok(states.Select(Features.FeatureStateResponse.From).ToList()); return Ok(states.Select(FeatureStateResponse.From).ToList());
} }
[HttpGet("api/v1/environment/{apiKey}/featurestate/{id}")] [HttpGet("api/v1/environment/{apiKey}/featurestate/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> GetFeatureState( public async Task<ActionResult<FeatureStateResponse>> GetFeatureState(
string apiKey, int id, string apiKey, int id,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] FeatureStateService featureStateService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -312,14 +315,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct); var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
if (state is null) return NotFound(); if (state is null) return NotFound();
return Ok(Features.FeatureStateResponse.From(state)); return Ok(FeatureStateResponse.From(state));
} }
[HttpPut("api/v1/environment/{apiKey}/featurestate/{id}")] [HttpPut("api/v1/environment/{apiKey}/featurestate/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> UpdateFeatureState( public async Task<ActionResult<FeatureStateResponse>> UpdateFeatureState(
string apiKey, int id, string apiKey, int id,
Features.UpdateFeatureStateRequest request, UpdateFeatureStateRequest request,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] FeatureStateService featureStateService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -329,14 +332,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (state is null) return NotFound(); if (state is null) return NotFound();
var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct); var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct);
return Ok(Features.FeatureStateResponse.From(updated)); return Ok(FeatureStateResponse.From(updated));
} }
[HttpPatch("api/v1/environment/{apiKey}/featurestate/{id}")] [HttpPatch("api/v1/environment/{apiKey}/featurestate/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> PatchFeatureState( public async Task<ActionResult<FeatureStateResponse>> PatchFeatureState(
string apiKey, int id, string apiKey, int id,
Features.PatchFeatureStateRequest request, PatchFeatureStateRequest request,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] FeatureStateService featureStateService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -346,15 +349,15 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (state is null) return NotFound(); if (state is null) return NotFound();
var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct); var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
return Ok(Features.FeatureStateResponse.From(updated)); return Ok(FeatureStateResponse.From(updated));
} }
// ─── Feature Segments ──────────────────────────────────────────────────── // ─── Feature Segments ────────────────────────────────────────────────────
[HttpGet("api/v1/environment/{apiKey}/feature/{featureId}/segments")] [HttpGet("api/v1/environment/{apiKey}/feature/{featureId}/segments")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureSegmentResponse>>> ListFeatureSegments( public async Task<ActionResult<IReadOnlyList<FeatureSegmentResponse>>> ListFeatureSegments(
string apiKey, int featureId, string apiKey, int featureId,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -365,10 +368,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpPost("api/v1/environment/{apiKey}/feature/{featureId}/segments")] [HttpPost("api/v1/environment/{apiKey}/feature/{featureId}/segments")]
public async Task<ActionResult<Features.FeatureSegmentResponse>> CreateFeatureSegment( public async Task<ActionResult<FeatureSegmentResponse>> CreateFeatureSegment(
string apiKey, int featureId, string apiKey, int featureId,
Features.CreateFeatureSegmentRequest request, CreateFeatureSegmentRequest request,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -388,10 +391,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpPut("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")] [HttpPut("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")]
public async Task<ActionResult<Features.FeatureSegmentResponse>> UpdateFeatureSegment( public async Task<ActionResult<FeatureSegmentResponse>> UpdateFeatureSegment(
string apiKey, int featureId, int id, string apiKey, int featureId, int id,
Features.UpdateFeatureSegmentRequest request, UpdateFeatureSegmentRequest request,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -405,7 +408,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
[HttpDelete("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")] [HttpDelete("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")]
public async Task<IActionResult> DeleteFeatureSegment( public async Task<IActionResult> DeleteFeatureSegment(
string apiKey, int featureId, int id, string apiKey, int featureId, int id,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -418,11 +421,11 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
// ─── Identity Segments ─────────────────────────────────────────────────── // ─── Identity Segments ───────────────────────────────────────────────────
[HttpGet("api/v1/environment/{apiKey}/identity/{id}/segments")] [HttpGet("api/v1/environment/{apiKey}/identity/{id}/segments")]
public async Task<ActionResult<IReadOnlyList<Segments.SegmentSummaryResponse>>> GetIdentitySegments( public async Task<ActionResult<IReadOnlyList<SegmentSummaryResponse>>> GetIdentitySegments(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
[FromServices] Segments.SegmentService segmentService, [FromServices] SegmentService segmentService,
[FromServices] Segments.SegmentEvaluator segmentEvaluator, [FromServices] SegmentEvaluator segmentEvaluator,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -434,7 +437,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var segments = await segmentService.ListByProjectAsync(environment.ProjectId, ct); var segments = await segmentService.ListByProjectAsync(environment.ProjectId, ct);
var matching = segments var matching = segments
.Where(s => segmentEvaluator.Evaluate(s, identity.Traits.ToList(), identity.Identifier)) .Where(s => segmentEvaluator.Evaluate(s, identity.Traits.ToList(), identity.Identifier))
.Select(s => new Segments.SegmentSummaryResponse(s.Id, s.Name)) .Select(s => new SegmentSummaryResponse(s.Id, s.Name))
.ToList(); .ToList();
return Ok(matching); return Ok(matching);
@@ -1,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public record UpdateEnvironmentRequest(string Name); public record UpdateEnvironmentRequest(string Name);
public class UpdateEnvironmentRequestValidator : AbstractValidator<UpdateEnvironmentRequest> public class UpdateEnvironmentRequestValidator : IModelValidator<UpdateEnvironmentRequest>
{ {
public UpdateEnvironmentRequestValidator() public ValidationResult Validate(UpdateEnvironmentRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
} }
} }
@@ -1,26 +1,28 @@
using FluentValidation; using System.Text.RegularExpressions;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record CreateFeatureRequest( public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description);
string Name,
FeatureType Type,
string? InitialValue,
string? Description
);
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest> public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest>
{ {
public CreateFeatureRequestValidator() private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$")
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
RuleFor(x => x.InitialValue) public ValidationResult Validate(CreateFeatureRequest model)
.MaximumLength(20_000) {
.When(x => x.InitialValue is not null); var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 150)
result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer.");
else if (!NamePattern.IsMatch(model.Name))
result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens.");
if (model.InitialValue is not null && model.InitialValue.Length > 20_000)
result.AddError(nameof(model.InitialValue), "'Initial Value' must be 20000 characters or fewer.");
return result;
} }
} }
@@ -1,16 +1,30 @@
using FluentValidation; using System.Text.RegularExpressions;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record CreateTagRequest(string Label, string Color); public record CreateTagRequest(string Label, string Color);
public class CreateTagRequestValidator : AbstractValidator<CreateTagRequest> public class CreateTagRequestValidator : IModelValidator<CreateTagRequest>
{ {
public CreateTagRequestValidator() private static readonly Regex ColorPattern = new("^#[0-9A-Fa-f]{3,6}$");
public ValidationResult Validate(CreateTagRequest model)
{ {
RuleFor(x => x.Label).NotEmpty().MaximumLength(100); var result = new ValidationResult();
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
.Matches("^#[0-9A-Fa-f]{3,6}$") if (string.IsNullOrEmpty(model.Label))
.WithMessage("Color must be a valid hex color (e.g. #FF0000)."); result.AddError(nameof(model.Label), "'Label' must not be empty.");
else if (model.Label.Length > 100)
result.AddError(nameof(model.Label), "'Label' must be 100 characters or fewer.");
if (string.IsNullOrEmpty(model.Color))
result.AddError(nameof(model.Color), "'Color' must not be empty.");
else if (model.Color.Length > 20)
result.AddError(nameof(model.Color), "'Color' must be 20 characters or fewer.");
else if (!ColorPattern.IsMatch(model.Color))
result.AddError(nameof(model.Color), "Color must be a valid hex color (e.g. #FF0000).");
return result;
} }
} }
@@ -0,0 +1,20 @@
using MicCheck.Api.Features.Usage;
namespace MicCheck.Api.Features;
public static class DependencyRegistration
{
public static IServiceCollection AddFeaturesServices(this IServiceCollection services)
{
services.AddScoped<FeatureEvaluationService>();
services.AddSingleton<FlagCache>();
services.AddScoped<FeatureService>();
services.AddScoped<FeatureStateService>();
services.AddScoped<FeatureSegmentService>();
services.AddScoped<TagService>();
services.AddFeatureUsageServices();
return services;
}
}
@@ -1,4 +1,5 @@
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities; using MicCheck.Api.Identities;
using MicCheck.Api.Segments; using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -6,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureEvaluationService( public class FeatureEvaluationService(
MicCheckDbContext db, IMicCheckDbContext db,
SegmentEvaluator segmentEvaluator, SegmentEvaluator segmentEvaluator,
FlagCache flagCache, FlagCache flagCache,
FeatureUsageMetrics usageMetrics) FeatureUsageMetrics usageMetrics)
@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureSegmentService(MicCheckDbContext db) public class FeatureSegmentService(IMicCheckDbContext db)
{ {
public async Task<IReadOnlyList<FeatureSegmentResponse>> ListByFeatureAsync( public async Task<IReadOnlyList<FeatureSegmentResponse>> ListByFeatureAsync(
int featureId, int environmentId, CancellationToken ct = default) int featureId, int environmentId, CancellationToken ct = default)
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureService(MicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue) public class FeatureService(IMicCheckDbContext db, IAuditService auditService, WebhookQueue webhookQueue)
{ {
private const int MaxFeaturesPerProject = 400; private const int MaxFeaturesPerProject = 400;
@@ -23,13 +23,7 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct); return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct);
} }
public async Task<Feature> CreateAsync( public async Task<Feature> CreateAsync(int projectId, string name, FeatureType type, string? initialValue, string? description, CancellationToken ct = default)
int projectId,
string name,
FeatureType type,
string? initialValue,
string? description,
CancellationToken ct = default)
{ {
var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct); var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
if (count >= MaxFeaturesPerProject) if (count >= MaxFeaturesPerProject)
@@ -78,8 +72,7 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
return feature; return feature;
} }
public async Task<Feature> UpdateAsync( public async Task<Feature> UpdateAsync(int id, string name, string? description, CancellationToken ct = default)
int id, string name, string? description, CancellationToken ct = default)
{ {
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct) var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found."); ?? throw new KeyNotFoundException($"Feature {id} not found.");
@@ -162,11 +155,7 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
EventType = WebhookEventTypes.FlagDeleted, EventType = WebhookEventTypes.FlagDeleted,
EnvironmentId = env.Id, EnvironmentId = env.Id,
OrganizationId = project.OrganizationId, OrganizationId = project.OrganizationId,
Data = new FlagDeletedData( Data = new FlagDeletedData(null, DateTimeOffset.UtcNow, new FeatureSummary(feature.Id, feature.Name)) });
null,
DateTimeOffset.UtcNow,
new FeatureSummary(feature.Id, feature.Name))
});
} }
} }
} }
@@ -1,6 +1,6 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureStateResult public record FeatureStateResult
{ {
public required Feature Feature { get; init; } public required Feature Feature { get; init; }
public bool Enabled { get; init; } public bool Enabled { get; init; }
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureStateService(MicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService) public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, IAuditService auditService)
{ {
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{ {
@@ -1,12 +0,0 @@
namespace MicCheck.Api.Features;
public class FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { get; init; }
public int FeatureId { get; init; }
public required string FeatureName { get; set; }
public DateOnly UsageDate { get; init; }
public long Count { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
+2 -4
View File
@@ -12,11 +12,9 @@ public class FlagCache(IMemoryCache cache)
return flags; return flags;
} }
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration);
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
public void Invalidate(int environmentId) public void Invalidate(int environmentId) => cache.Remove(CacheKey(environmentId));
=> cache.Remove(CacheKey(environmentId));
private static string CacheKey(int environmentId) => $"flags:{environmentId}"; private static string CacheKey(int environmentId) => $"flags:{environmentId}";
} }
+3 -3
View File
@@ -1,9 +1,9 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class Tag public record Tag
{ {
public int Id { get; init; } public int Id { get; init; }
public required string Label { get; set; } public required string Label { get; init; }
public required string Color { get; set; } public required string Color { get; init; }
public int ProjectId { get; init; } public int ProjectId { get; init; }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class TagService(MicCheckDbContext db) public class TagService(IMicCheckDbContext db)
{ {
public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{ {
@@ -8,23 +8,24 @@ namespace MicCheck.Api.Features;
[ApiController] [ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
[Route("api/v1/project/{projectId}")]
public class TagsController(TagService tagService) : ControllerBase public class TagsController(TagService tagService) : ControllerBase
{ {
[HttpGet("api/v1/project/{projectId}/tags")] [HttpGet("tags")]
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct) public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
{ {
var tags = await tagService.ListByProjectAsync(projectId, ct); var tags = await tagService.ListByProjectAsync(projectId, ct);
return Ok(tags.Select(TagResponse.From).ToList()); return Ok(tags.Select(TagResponse.From).ToList());
} }
[HttpPost("api/v1/project/{projectId}/tags")] [HttpPost("tags")]
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct) public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
{ {
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct); var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag)); return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
} }
[HttpDelete("api/v1/project/{projectId}/tag/{id}")] [HttpDelete("tag/{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct) public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{ {
var tag = await tagService.FindByIdAsync(id, ct); var tag = await tagService.FindByIdAsync(id, ct);
@@ -1,20 +1,25 @@
using FluentValidation; using System.Text.RegularExpressions;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record UpdateFeatureRequest( public record UpdateFeatureRequest(string Name, string? Description);
string Name,
string? Description
);
public class UpdateFeatureRequestValidator : AbstractValidator<UpdateFeatureRequest> public class UpdateFeatureRequestValidator : IModelValidator<UpdateFeatureRequest>
{ {
public UpdateFeatureRequestValidator() private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
public ValidationResult Validate(UpdateFeatureRequest model)
{ {
RuleFor(x => x.Name) var result = new ValidationResult();
.NotEmpty()
.MaximumLength(150) if (string.IsNullOrEmpty(model.Name))
.Matches("^[a-zA-Z0-9_-]+$") result.AddError(nameof(model.Name), "'Name' must not be empty.");
.WithMessage("Name may only contain letters, digits, underscores, and hyphens."); else if (model.Name.Length > 150)
result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer.");
else if (!NamePattern.IsMatch(model.Name))
result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens.");
return result;
} }
} }
@@ -0,0 +1,13 @@
namespace MicCheck.Api.Features.Usage;
public static class DependencyRegistration
{
public static IServiceCollection AddFeatureUsageServices(this IServiceCollection services)
{
services.AddSingleton<FeatureUsageMetrics>();
services.AddScoped<FeatureUsageQueryService>();
services.AddHostedService<FeatureUsageFlushBackgroundService>();
return services;
}
}
@@ -1,3 +1,3 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features.Usage;
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate); public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features.Usage;
[ApiController] [ApiController]
[Route("api/v1/environment/{environmentId}/usage")] [Route("api/v1/environment/{environmentId}/usage")]
@@ -13,10 +13,7 @@ namespace MicCheck.Api.Features;
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage( public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(int environmentId, [FromQuery] int days = 14, CancellationToken ct = default)
int environmentId,
[FromQuery] int days = 14,
CancellationToken ct = default)
{ {
days = Math.Clamp(days, 1, 90); days = Math.Clamp(days, 1, 90);
var cacheKey = $"usage-dashboard:{environmentId}:{days}"; var cacheKey = $"usage-dashboard:{environmentId}:{days}";
@@ -0,0 +1,12 @@
namespace MicCheck.Api.Features.Usage;
public record FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { get; init; }
public int FeatureId { get; init; }
public required string FeatureName { get; init; }
public DateOnly UsageDate { get; init; }
public long Count { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
@@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features.Usage;
[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that issues raw SQL through a DI-scoped concrete MicCheckDbContext; exercising it cleanly requires a live DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). DrainAccumulated's bucketing logic is covered by FeatureUsageMetricsTests.")]
public class FeatureUsageFlushBackgroundService( public class FeatureUsageFlushBackgroundService(
FeatureUsageMetrics metrics, FeatureUsageMetrics metrics,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
@@ -1,7 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features.Usage;
public class FeatureUsageMetrics : IDisposable public class FeatureUsageMetrics : IDisposable
{ {
@@ -1,9 +1,9 @@
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features.Usage;
public class FeatureUsageQueryService(MicCheckDbContext db) public class FeatureUsageQueryService(IMicCheckDbContext db)
{ {
public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default) public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default)
{ {
@@ -1,9 +1,7 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features.Usage;
public record TopFeatureUsage(int FeatureId, string FeatureName, long Count); public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features); public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
public record DashboardUsageResponse( public record DashboardUsageResponse(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage);
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
IReadOnlyList<DailyUsage> DailyUsage);

Some files were not shown because too many files have changed in this diff Show More