Compare commits

..
10 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
164 changed files with 7063 additions and 517 deletions
+2
View File
@@ -61,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
@@ -81,6 +82,7 @@ jobs:
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.
+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>
+1 -1
View File
@@ -123,7 +123,7 @@
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text> <text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
<text x="53" y="14" fill="#fff">Coverage</text> <text x="53" y="14" fill="#fff">Coverage</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">29.8%</text><text class="" x="132.5" y="14">29.8%</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>

Before

Width:  |  Height:  |  Size: 6.1 KiB

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
+14 -22
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`.
+2 -1
View File
@@ -29,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
+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
+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
+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; }
@@ -6,29 +6,25 @@ namespace MicCheck.Api.Audit;
public class AuditLogQueryService(IMicCheckDbContext db) public class AuditLogQueryService(IMicCheckDbContext db)
{ {
public async Task<PagedResult<AuditLogResponse>> 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<PagedResult<AuditLogResponse>> 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<PagedResult<AuditLogResponse>> 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<PagedResult<AuditLogResponse>> 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);
@@ -12,17 +12,15 @@ 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();
@@ -31,11 +29,8 @@ public class AuditLogsController(
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.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();
@@ -44,11 +39,8 @@ public class AuditLogsController(
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.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();
+30 -6
View File
@@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditService(IMicCheckDbContext 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(IMicCheckDbContext db, IHttpContextAccessor httpContex
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(IMicCheckDbContext db, IHttpContextAccessor httpContex
}, JsonOptions); }, JsonOptions);
} }
var actorUserId = ResolveActorUserId(); var actorUserId = GetCurrentUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -68,7 +68,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
} }
// 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(IMicCheckDbContext db, IHttpContextAccessor httpContex
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(IMicCheckDbContext db, IHttpContextAccessor httpContex
}); });
} }
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);
}
}
@@ -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)
@@ -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);
@@ -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)
@@ -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;
}
@@ -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;
@@ -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;
}
}
@@ -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;
@@ -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;
}
}
@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
public class EnvironmentService(IMicCheckDbContext 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;
@@ -146,9 +149,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[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)
@@ -158,15 +161,15 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList(); var results = result.Items.Select(AdminIdentityResponse.From).ToList();
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(result.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 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureService(IMicCheckDbContext 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(IMicCheckDbContext db, AuditService auditService, We
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(IMicCheckDbContext db, AuditService auditService, We
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(IMicCheckDbContext db, AuditService auditService, We
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(IMicCheckDbContext 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; }
} }
@@ -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,7 +1,7 @@
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(IMicCheckDbContext db) public class FeatureUsageQueryService(IMicCheckDbContext db)
{ {
@@ -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);
@@ -0,0 +1,12 @@
namespace MicCheck.Api.Identities;
public static class DependencyRegistration
{
public static IServiceCollection AddIdentitiesServices(this IServiceCollection services)
{
services.AddScoped<IdentityResolutionService>();
services.AddScoped<AdminIdentityService>();
return services;
}
}
-1
View File
@@ -9,7 +9,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Microsoft.OpenApi" Version="2.9.0" /> <PackageReference Include="Microsoft.OpenApi" Version="2.9.0" />
@@ -305,7 +305,7 @@ namespace MicCheck.Api.Migrations
b.ToTable("FeatureStates"); b.ToTable("FeatureStates");
}); });
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b => modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -302,7 +302,7 @@ namespace MicCheck.Api.Migrations
b.ToTable("FeatureStates"); b.ToTable("FeatureStates");
}); });
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b => modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -1,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public record CreateOrganizationRequest(string Name); public record CreateOrganizationRequest(string Name);
public class CreateOrganizationRequestValidator : AbstractValidator<CreateOrganizationRequest> public class CreateOrganizationRequestValidator : IModelValidator<CreateOrganizationRequest>
{ {
public CreateOrganizationRequestValidator() public ValidationResult Validate(CreateOrganizationRequest 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;
} }
} }
@@ -0,0 +1,11 @@
namespace MicCheck.Api.Organizations;
public static class DependencyRegistration
{
public static IServiceCollection AddOrganizationsServices(this IServiceCollection services)
{
services.AddScoped<OrganizationService>();
return services;
}
}
@@ -1,15 +1,23 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public record InviteUserRequest(int UserId, string Role); public record InviteUserRequest(int UserId, string Role);
public class InviteUserRequestValidator : AbstractValidator<InviteUserRequest> public class InviteUserRequestValidator : IModelValidator<InviteUserRequest>
{ {
public InviteUserRequestValidator() public ValidationResult Validate(InviteUserRequest model)
{ {
RuleFor(x => x.UserId).GreaterThan(0); var result = new ValidationResult();
RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse<OrganizationRole>(r, true, out _))
.WithMessage("Role must be 'User' or 'Admin'."); if (model.UserId <= 0)
result.AddError(nameof(model.UserId), "'User Id' must be greater than 0.");
if (string.IsNullOrEmpty(model.Role))
result.AddError(nameof(model.Role), "'Role' must not be empty.");
else if (!Enum.TryParse<OrganizationRole>(model.Role, true, out _))
result.AddError(nameof(model.Role), "Role must be 'User' or 'Admin'.");
return result;
} }
} }
@@ -1,10 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public class OrganizationService(IMicCheckDbContext db, AuditService auditService) public class OrganizationService(IMicCheckDbContext db, IAuditService auditService)
{ {
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default) public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
{ {
@@ -46,6 +47,7 @@ public class OrganizationService(IMicCheckDbContext db, AuditService auditServic
return org; return org;
} }
[ExcludeFromCodeCoverage(Justification = "ExecuteUpdateAsync requires a real EF Core relational query provider; our Mock<DbSet<T>> LINQ-to-Objects provider can't execute it, and CLAUDE.md disallows the EF InMemory provider as a substitute. The not-a-member early-return branch is covered by OrganizationServiceTests.")]
public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default) public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default)
{ {
var member = await db.OrganizationUsers var member = await db.OrganizationUsers
@@ -1,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
public record UpdateOrganizationRequest(string Name); public record UpdateOrganizationRequest(string Name);
public class UpdateOrganizationRequestValidator : AbstractValidator<UpdateOrganizationRequest> public class UpdateOrganizationRequestValidator : IModelValidator<UpdateOrganizationRequest>
{ {
public UpdateOrganizationRequestValidator() public ValidationResult Validate(UpdateOrganizationRequest 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;
} }
} }
+15 -103
View File
@@ -1,11 +1,9 @@
using System.Text;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using FluentValidation;
using FluentValidation.AspNetCore;
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authentication; using MicCheck.Api.Common;
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Environments; using MicCheck.Api.Environments;
using MicCheck.Api.Features; using MicCheck.Api.Features;
@@ -15,14 +13,7 @@ using MicCheck.Api.Projects;
using MicCheck.Api.Segments; using MicCheck.Api.Segments;
using MicCheck.Api.Users; using MicCheck.Api.Users;
using MicCheck.Api.Webhooks; using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog; using Serilog;
@@ -42,44 +33,12 @@ try
.WriteTo.Console()); .WriteTo.Console());
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddControllers() builder.Services.AddControllers(options => options.Filters.Add<ModelValidationActionFilter>())
.AddJsonOptions(options => .AddJsonOptions(options =>
options.JsonSerializerOptions.Converters.Add( options.JsonSerializerOptions.Converters.Add(
new System.Text.Json.Serialization.JsonStringEnumConverter())); new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddAuthentication() builder.Services.AddCommonServices(builder.Configuration);
.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 = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!))
};
});
builder.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"));
});
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
@@ -92,67 +51,20 @@ try
limiter.QueueLimit = 0; limiter.QueueLimit = 0;
})); }));
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.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 });
};
});
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddMetrics(); builder.Services.AddMetrics();
builder.Services.AddScoped<ITokenService, TokenService>(); builder.Services.AddAuditServices();
builder.Services.AddScoped<AuthService>(); builder.Services.AddIdentitiesServices();
builder.Services.AddScoped<ApiKeyService>(); builder.Services.AddEnvironmentsServices();
builder.Services.AddScoped<DatabaseSeeder>(); builder.Services.AddSegmentsServices();
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>(); builder.Services.AddOrganizationsServices();
builder.Services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>(); builder.Services.AddProjectsServices();
builder.Services.AddScoped<FeatureEvaluationService>(); builder.Services.AddFeaturesServices();
builder.Services.AddScoped<IdentityResolutionService>(); builder.Services.AddUsersServices();
builder.Services.AddScoped<EnvironmentDocumentService>(); builder.Services.AddWebhooksServices();
builder.Services.AddSingleton<SegmentEvaluator>();
builder.Services.AddSingleton<FlagCache>();
builder.Services.AddScoped<AuditService>();
builder.Services.AddScoped<AuditLogQueryService>();
builder.Services.AddScoped<OrganizationService>();
builder.Services.AddScoped<ProjectService>();
builder.Services.AddScoped<EnvironmentService>();
builder.Services.AddScoped<FeatureService>();
builder.Services.AddScoped<FeatureStateService>();
builder.Services.AddScoped<FeatureSegmentService>();
builder.Services.AddScoped<SegmentService>();
builder.Services.AddScoped<TagService>();
builder.Services.AddScoped<WebhookService>();
builder.Services.AddScoped<WebhookDispatcher>();
builder.Services.AddScoped<AdminIdentityService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddSingleton<WebhookQueue>();
builder.Services.AddHostedService<WebhookBackgroundService>();
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
builder.Services.AddSingleton<FeatureUsageMetrics>();
builder.Services.AddScoped<FeatureUsageQueryService>();
builder.Services.AddHostedService<FeatureUsageFlushBackgroundService>();
builder.Services.AddHttpClient("Webhooks", client =>
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
var connectionString = builder.Configuration.GetConnectionString("miccheck") builder.Services.AddDataServices(builder.Configuration);
?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
: builder.Configuration.GetConnectionString("DefaultConnection")!);
builder.Services.AddDbContext<MicCheckDbContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
var app = builder.Build(); var app = builder.Build();
@@ -1,14 +1,23 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
public record CreateProjectRequest(string Name, int OrganizationId); public record CreateProjectRequest(string Name, int OrganizationId);
public class CreateProjectRequestValidator : AbstractValidator<CreateProjectRequest> public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest>
{ {
public CreateProjectRequestValidator() public ValidationResult Validate(CreateProjectRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
RuleFor(x => x.OrganizationId).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.OrganizationId <= 0)
result.AddError(nameof(model.OrganizationId), "'Organization Id' must be greater than 0.");
return result;
} }
} }
@@ -0,0 +1,11 @@
namespace MicCheck.Api.Projects;
public static class DependencyRegistration
{
public static IServiceCollection AddProjectsServices(this IServiceCollection services)
{
services.AddScoped<ProjectService>();
return services;
}
}
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
public class ProjectService(IMicCheckDbContext db, AuditService auditService) public class ProjectService(IMicCheckDbContext db, IAuditService auditService)
{ {
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default) public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
{ {
@@ -1,17 +1,25 @@
using FluentValidation;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions); public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
public class SetUserPermissionsRequestValidator : AbstractValidator<SetUserPermissionsRequest> public class SetUserPermissionsRequestValidator : IModelValidator<SetUserPermissionsRequest>
{ {
public SetUserPermissionsRequestValidator() public ValidationResult Validate(SetUserPermissionsRequest model)
{ {
RuleFor(x => x.UserId).GreaterThan(0); var result = new ValidationResult();
RuleForEach(x => x.Permissions)
.Must(p => Enum.TryParse<ProjectPermission>(p, true, out _)) if (model.UserId <= 0)
.WithMessage("Invalid permission value."); result.AddError(nameof(model.UserId), "'User Id' must be greater than 0.");
foreach (var permission in model.Permissions)
{
if (!Enum.TryParse<ProjectPermission>(permission, true, out _))
result.AddError(nameof(model.Permissions), "Invalid permission value.");
}
return result;
} }
} }
@@ -1,13 +1,20 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
public record UpdateProjectRequest(string Name, bool HideDisabledFlags); public record UpdateProjectRequest(string Name, bool HideDisabledFlags);
public class UpdateProjectRequestValidator : AbstractValidator<UpdateProjectRequest> public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest>
{ {
public UpdateProjectRequestValidator() public ValidationResult Validate(UpdateProjectRequest 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,4 +1,4 @@
using FluentValidation; using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Segments; namespace MicCheck.Api.Segments;
@@ -16,24 +16,38 @@ public record CreateSegmentRequest(
string Name, string Name,
IReadOnlyList<CreateSegmentRuleRequest> Rules); IReadOnlyList<CreateSegmentRuleRequest> Rules);
public class CreateSegmentRequestValidator : AbstractValidator<CreateSegmentRequest> public class CreateSegmentRequestValidator : IModelValidator<CreateSegmentRequest>
{ {
public CreateSegmentRequestValidator() public ValidationResult Validate(CreateSegmentRequest model)
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(200); var result = new ValidationResult();
RuleFor(x => x.Rules).NotNull();
RuleForEach(x => x.Rules).ChildRules(rule => 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.Rules is null)
{ {
rule.RuleFor(r => r.Type) result.AddError(nameof(model.Rules), "'Rules' must not be empty.");
.Must(t => Enum.TryParse<SegmentRuleType>(t, true, out _)) return result;
.WithMessage("Rule type must be 'All', 'Any', or 'None'."); }
rule.RuleForEach(r => r.Conditions).ChildRules(cond =>
foreach (var rule in model.Rules)
{
if (!Enum.TryParse<SegmentRuleType>(rule.Type, true, out _))
result.AddError(nameof(model.Rules), "Rule type must be 'All', 'Any', or 'None'.");
foreach (var condition in rule.Conditions)
{ {
cond.RuleFor(c => c.Property).NotEmpty(); if (string.IsNullOrEmpty(condition.Property))
cond.RuleFor(c => c.Operator) result.AddError(nameof(model.Rules), "'Property' must not be empty.");
.Must(o => Enum.TryParse<SegmentConditionOperator>(o, true, out _))
.WithMessage("Invalid operator."); if (!Enum.TryParse<SegmentConditionOperator>(condition.Operator, true, out _))
}); result.AddError(nameof(model.Rules), "Invalid operator.");
}); }
}
return result;
} }
} }
@@ -0,0 +1,12 @@
namespace MicCheck.Api.Segments;
public static class DependencyRegistration
{
public static IServiceCollection AddSegmentsServices(this IServiceCollection services)
{
services.AddSingleton<SegmentEvaluator>();
services.AddScoped<SegmentService>();
return services;
}
}

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