Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc021500c | ||
|
|
3888168571 | ||
|
|
fe22ba01b7 |
@@ -61,7 +61,6 @@ 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
|
||||||
@@ -82,7 +81,6 @@ 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.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Guidance for Claude Code (claude.ai/code) in this repo.
|
Guidance for Claude Code (claude.ai/code) when working in this repo.
|
||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments.
|
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
|
||||||
|
|
||||||
## Planned Structure
|
## Planned Structure
|
||||||
|
|
||||||
@@ -18,30 +18,27 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, pro
|
|||||||
- 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 logic as much as possible (both nunit and jest)
|
- New features need unit tests covering as much logic as possible (both nunit and jest)
|
||||||
- Modified file: check missing test coverage, all tests pass
|
- Any modified file: evaluate for missing test coverage and that all tests pass
|
||||||
|
|
||||||
# Coding
|
# Coding
|
||||||
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper
|
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
|
||||||
- Match formatting/style from `.editorconfig`
|
- Match formatting/style from `.editorconfig`
|
||||||
- Wrap lines at 220 chars, single line if fewer
|
- Wrap lines at 220 characters, leave single line if fewer
|
||||||
- Interfaces implemented by single class → bottom of class file. Interface w/ multiple implementations → separate file.
|
- 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.
|
||||||
- No tuples for return types. Prefer records or classes for multiple values
|
- Do not use tuples for return types. Prefer records or classes for multiple values
|
||||||
- No `sealed`
|
- Do not use `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 w/ Moq
|
- Mock external deps with Moq
|
||||||
- Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider.
|
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider.
|
||||||
- New features need unit tests covering logic as much as possible
|
- New features need unit tests covering as much logic as possible
|
||||||
- Modified file: check missing test coverage
|
- Any modified file: evaluate for 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 pass before commit
|
- All tests should 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/`
|
||||||
@@ -50,4 +47,4 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, pro
|
|||||||
- 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` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change.
|
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
|
||||||
|
|||||||
Regular → Executable
+23
-32
@@ -1,53 +1,44 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Guidance for Claude Code (claude.ai/code) when working in this repo.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
|
MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments.
|
||||||
|
|
||||||
## Planned Structure
|
## Planned Structure
|
||||||
|
|
||||||
- `src/admin/` — VueJS admin components
|
- `src/admin/` — administrative components in VueJS
|
||||||
- `src/api/` - .NET API + REST endpoints
|
- `src/api/` - api and restful endpoints in .NET
|
||||||
- `tests/` — test suite
|
- `tests/` — test suite
|
||||||
- `docs/` — documentation
|
- `docs/` — documentation
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
- Use latest LTS .NET + latest supported nuget packages for that version
|
- Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version
|
||||||
- Set `langVersion` to latest in all csproj files; enable nullable
|
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled
|
||||||
- Organize code by feature/area, not type (e.g. `features` namespace)
|
- 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)
|
||||||
- New features need unit tests covering as much logic as possible (both nunit and jest)
|
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible.
|
||||||
- Any modified file: evaluate for missing test coverage and that all tests pass
|
- Any file modified should be evaluated for potential test cases and missing coverage areas.
|
||||||
|
|
||||||
# Coding
|
# Coding
|
||||||
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
|
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper
|
||||||
- Match formatting/style from `.editorconfig`
|
- Coding should match formating and style rules in the .editorconfig file
|
||||||
- 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
|
||||||
- Require a minimum of 70% code coverage with a target of 90%. Unit tests should focus on end-user scenarios first.
|
- 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)
|
||||||
- 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.
|
- Mock any external dependencies using Moq.
|
||||||
- Code that can not be cleanly unit tested should be marked with [ExcludeFromCodeCoverage] or have it's namespace excluded from code coverage.
|
- Do not name any mocked objects with the word Mock in them
|
||||||
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
|
- Do not include any Arrange / Act / Assert comments in the code
|
||||||
- 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
|
||||||
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/`
|
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/`
|
||||||
- Split large plans into discrete chunks — each buildable + committable independently
|
- Divide up large plans into discrete chucks of functionality so each can be built and committed independently.
|
||||||
- Plan generated from `docs/plans/<name>.md` → save as `docs/plans/<name>_plan.md`
|
- 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 implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.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
|
||||||
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
+1
-2
@@ -3,6 +3,7 @@
|
|||||||
<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/">
|
||||||
@@ -12,12 +13,10 @@
|
|||||||
</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
@@ -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">68.9%</text><text class="" x="132.5" y="14">68.9%</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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -9,5 +9,4 @@ 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
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: miccheck
|
POSTGRES_DB: miccheck
|
||||||
POSTGRES_USER: miccheck
|
POSTGRES_USER: miccheck
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
POSTGRES_PASSWORD: password
|
||||||
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=${POSTGRES_PASSWORD}"
|
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=password"
|
||||||
Jwt__SecretKey: ${JWT_SECRET_KEY}
|
Jwt__SecretKey: ${JWT_SECRET_KEY}
|
||||||
Jwt__Issuer: MicCheck
|
Jwt__Issuer: MicCheck
|
||||||
Jwt__Audience: MicCheck
|
Jwt__Audience: MicCheck
|
||||||
|
|||||||
+22
-14
@@ -3,19 +3,27 @@ set -e
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
echo "Building solution..."
|
build_api() {
|
||||||
dotnet build "$SCRIPT_DIR/MicCheck.slnx"
|
echo "Building API..."
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
|
||||||
echo "Installing admin dependencies..."
|
build_admin() {
|
||||||
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
|
echo "Building admin..."
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
|
||||||
echo "Building admin..."
|
case "${1:-all}" in
|
||||||
npm --prefix "$SCRIPT_DIR/src/admin" run build
|
api) build_api ;;
|
||||||
|
admin) build_admin ;;
|
||||||
echo "Running .NET unit tests..."
|
all) build_api && build_admin ;;
|
||||||
dotnet test "$SCRIPT_DIR/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj"
|
*)
|
||||||
|
echo "Usage: $0 [api|admin|all]"
|
||||||
echo "Running Jest tests..."
|
exit 1
|
||||||
npm --prefix "$SCRIPT_DIR/src/admin" test
|
;;
|
||||||
|
esac
|
||||||
echo "Build and tests complete."
|
|
||||||
|
|||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
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:
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -29,11 +29,10 @@ Code in the API is organized by feature area (e.g. `Features`, `Segments`, `Iden
|
|||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
`MicCheck.AppHost` (.NET Aspire) orchestrates the API, admin app, and supporting services for local development.
|
`docker-compose.yml` provides supporting services. `MicCheck.AppHost` (.NET Aspire) orchestrates the API and admin app for local development.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./dev-build.sh
|
./dev-build.sh
|
||||||
aspire run --project src/MicCheck.AppHost
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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
|
||||||
|
|||||||
@@ -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=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}}"
|
export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=password}"
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
namespace MicCheck;
|
|
||||||
|
|
||||||
public record FlagResult(int Id, FeatureSummary Feature, bool Enabled, string? FeatureStateValue);
|
|
||||||
|
|
||||||
public record FeatureSummary(int Id, string Name, string Type);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
Generated
+10
-8
@@ -6012,15 +6012,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"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.4",
|
"hasown": "^2.0.2",
|
||||||
"mime-types": "^2.1.35"
|
"mime-types": "^2.1.12"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -8429,10 +8430,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "3.15.0",
|
"version": "3.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||||
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||||
"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,6 +1,6 @@
|
|||||||
namespace MicCheck.Api.Audit;
|
namespace MicCheck.Api.Audit;
|
||||||
|
|
||||||
public record AuditLog
|
public class AuditLog
|
||||||
{
|
{
|
||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
public required string ResourceType { get; init; }
|
public required string ResourceType { get; init; }
|
||||||
|
|||||||
@@ -6,25 +6,29 @@ namespace MicCheck.Api.Audit;
|
|||||||
|
|
||||||
public class AuditLogQueryService(IMicCheckDbContext db)
|
public class AuditLogQueryService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default)
|
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(
|
||||||
|
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(int projectId, AuditLogFilter filter, CancellationToken ct = default)
|
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(
|
||||||
|
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(int environmentId, AuditLogFilter filter, CancellationToken ct = default)
|
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(
|
||||||
|
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(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
|
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(
|
||||||
|
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);
|
||||||
@@ -62,4 +66,4 @@ public class AuditLogQueryService(IMicCheckDbContext db)
|
|||||||
|
|
||||||
return new PagedResult<AuditLogResponse>(total, items);
|
return new PagedResult<AuditLogResponse>(total, items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,17 @@ 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("organisation/{id}/audit-logs")]
|
[HttpGet("api/v1/organisation/{id}/audit-logs")]
|
||||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
|
||||||
|
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();
|
||||||
@@ -29,8 +31,11 @@ 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("project/{projectId}/audit-logs")]
|
[HttpGet("api/v1/project/{projectId}/audit-logs")]
|
||||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
|
||||||
|
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();
|
||||||
@@ -39,8 +44,11 @@ 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("environment/{apiKey}/audit-logs")]
|
[HttpGet("api/v1/environment/{apiKey}/audit-logs")]
|
||||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
|
||||||
|
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();
|
||||||
|
|||||||
@@ -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) : IAuditService
|
public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
|
||||||
{
|
{
|
||||||
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 async Task RecordAsync(
|
public virtual 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 = GetCurrentUserId();
|
var actorUserId = ResolveActorUserId();
|
||||||
|
|
||||||
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 async Task LogAsync(
|
public virtual 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 = GetCurrentUserId();
|
var actorUserId = ResolveActorUserId();
|
||||||
|
|
||||||
var log = new AuditLog
|
var log = new AuditLog
|
||||||
{
|
{
|
||||||
@@ -112,33 +112,9 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private int? GetCurrentUserId()
|
private int? ResolveActorUserId()
|
||||||
{
|
{
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace MicCheck.Api.Audit;
|
|
||||||
|
|
||||||
public static class DependencyRegistration
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddAuditServices(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddScoped<IAuditService, AuditService>();
|
|
||||||
services.AddScoped<AuditLogQueryService>();
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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,9 +1,7 @@
|
|||||||
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,3 +1,9 @@
|
|||||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||||
|
|
||||||
public record ApiKeyResponse(int Id, string Name, string Prefix, bool IsActive, DateTimeOffset? ExpiresAt, DateTimeOffset CreatedAt);
|
public record ApiKeyResponse(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string Prefix,
|
||||||
|
bool IsActive,
|
||||||
|
DateTimeOffset? ExpiresAt,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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,4 +2,9 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Common.Security.Authorization;
|
namespace MicCheck.Api.Common.Security.Authorization;
|
||||||
|
|
||||||
public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement;
|
public class ProjectPermissionRequirement : IAuthorizationRequirement
|
||||||
|
{
|
||||||
|
public ProjectPermission Permission { get; }
|
||||||
|
|
||||||
|
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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.Usage;
|
using MicCheck.Api.Features;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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,7 +2,6 @@ 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,20 +1,13 @@
|
|||||||
using MicCheck.Api.Common.Validation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Environments;
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
public record CloneEnvironmentRequest(string Name);
|
public record CloneEnvironmentRequest(string Name);
|
||||||
|
|
||||||
public class CloneEnvironmentRequestValidator : IModelValidator<CloneEnvironmentRequest>
|
public class CloneEnvironmentRequestValidator : AbstractValidator<CloneEnvironmentRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(CloneEnvironmentRequest model)
|
public CloneEnvironmentRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
|
||||||
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,23 +1,14 @@
|
|||||||
using MicCheck.Api.Common.Validation;
|
using FluentValidation;
|
||||||
|
|
||||||
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 : IModelValidator<CreateEnvironmentRequest>
|
public class CreateEnvironmentRequestValidator : AbstractValidator<CreateEnvironmentRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(CreateEnvironmentRequest model)
|
public CreateEnvironmentRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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, IAuditService auditService)
|
public class EnvironmentService(IMicCheckDbContext db, AuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
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;
|
||||||
@@ -149,9 +146,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/v1/environment/{apiKey}/identities")]
|
[HttpGet("api/v1/environment/{apiKey}/identities")]
|
||||||
public async Task<ActionResult<PaginatedResponse<AdminIdentityResponse>>> ListIdentities(
|
public async Task<ActionResult<PaginatedResponse<Identities.AdminIdentityResponse>>> ListIdentities(
|
||||||
string apiKey,
|
string apiKey,
|
||||||
[FromServices] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -161,15 +158,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(AdminIdentityResponse.From).ToList();
|
var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList();
|
||||||
return Ok(new PaginatedResponse<AdminIdentityResponse>(result.Total, null, null, results));
|
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(result.Total, null, null, results));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/v1/environment/{apiKey}/identities")]
|
[HttpPost("api/v1/environment/{apiKey}/identities")]
|
||||||
public async Task<ActionResult<AdminIdentityResponse>> CreateIdentity(
|
public async Task<ActionResult<Identities.AdminIdentityResponse>> CreateIdentity(
|
||||||
string apiKey,
|
string apiKey,
|
||||||
[FromBody] CreateIdentityRequest request,
|
[FromBody] Identities.CreateIdentityRequest request,
|
||||||
[FromServices] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -180,13 +177,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 },
|
||||||
AdminIdentityResponse.From(identity));
|
Identities.AdminIdentityResponse.From(identity));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/v1/environment/{apiKey}/identity/{id}")]
|
[HttpGet("api/v1/environment/{apiKey}/identity/{id}")]
|
||||||
public async Task<ActionResult<AdminIdentityResponse>> GetIdentity(
|
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity(
|
||||||
string apiKey, int id,
|
string apiKey, int id,
|
||||||
[FromServices] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -194,13 +191,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(AdminIdentityResponse.From(identity));
|
return Ok(Identities.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] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -214,10 +211,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<TraitResponse>> UpsertIdentityTrait(
|
public async Task<ActionResult<Identities.TraitResponse>> UpsertIdentityTrait(
|
||||||
string apiKey, int id, string key,
|
string apiKey, int id, string key,
|
||||||
[FromBody] UpsertTraitRequest request,
|
[FromBody] Identities.UpsertTraitRequest request,
|
||||||
[FromServices] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -231,7 +228,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] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -243,9 +240,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<FeatureStateResponse>>> GetIdentityFeatureStates(
|
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates(
|
||||||
string apiKey, int id,
|
string apiKey, int id,
|
||||||
[FromServices] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -255,14 +252,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(FeatureStateResponse.From).ToList());
|
return Ok(states.Select(Features.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<FeatureStateResponse>> SetIdentityFeatureState(
|
public async Task<ActionResult<Features.FeatureStateResponse>> SetIdentityFeatureState(
|
||||||
string apiKey, int id, int featureId,
|
string apiKey, int id, int featureId,
|
||||||
UpdateFeatureStateRequest request,
|
Features.UpdateFeatureStateRequest request,
|
||||||
[FromServices] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -272,13 +269,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(FeatureStateResponse.From(state));
|
return Ok(Features.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] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -292,22 +289,22 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/v1/environment/{apiKey}/featurestates")]
|
[HttpGet("api/v1/environment/{apiKey}/featurestates")]
|
||||||
public async Task<ActionResult<IReadOnlyList<FeatureStateResponse>>> ListFeatureStates(
|
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> ListFeatureStates(
|
||||||
string apiKey,
|
string apiKey,
|
||||||
[FromServices] FeatureStateService featureStateService,
|
[FromServices] Features.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(FeatureStateResponse.From).ToList());
|
return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/v1/environment/{apiKey}/featurestate/{id}")]
|
[HttpGet("api/v1/environment/{apiKey}/featurestate/{id}")]
|
||||||
public async Task<ActionResult<FeatureStateResponse>> GetFeatureState(
|
public async Task<ActionResult<Features.FeatureStateResponse>> GetFeatureState(
|
||||||
string apiKey, int id,
|
string apiKey, int id,
|
||||||
[FromServices] FeatureStateService featureStateService,
|
[FromServices] Features.FeatureStateService featureStateService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -315,14 +312,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(FeatureStateResponse.From(state));
|
return Ok(Features.FeatureStateResponse.From(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("api/v1/environment/{apiKey}/featurestate/{id}")]
|
[HttpPut("api/v1/environment/{apiKey}/featurestate/{id}")]
|
||||||
public async Task<ActionResult<FeatureStateResponse>> UpdateFeatureState(
|
public async Task<ActionResult<Features.FeatureStateResponse>> UpdateFeatureState(
|
||||||
string apiKey, int id,
|
string apiKey, int id,
|
||||||
UpdateFeatureStateRequest request,
|
Features.UpdateFeatureStateRequest request,
|
||||||
[FromServices] FeatureStateService featureStateService,
|
[FromServices] Features.FeatureStateService featureStateService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -332,14 +329,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(FeatureStateResponse.From(updated));
|
return Ok(Features.FeatureStateResponse.From(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("api/v1/environment/{apiKey}/featurestate/{id}")]
|
[HttpPatch("api/v1/environment/{apiKey}/featurestate/{id}")]
|
||||||
public async Task<ActionResult<FeatureStateResponse>> PatchFeatureState(
|
public async Task<ActionResult<Features.FeatureStateResponse>> PatchFeatureState(
|
||||||
string apiKey, int id,
|
string apiKey, int id,
|
||||||
PatchFeatureStateRequest request,
|
Features.PatchFeatureStateRequest request,
|
||||||
[FromServices] FeatureStateService featureStateService,
|
[FromServices] Features.FeatureStateService featureStateService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -349,15 +346,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(FeatureStateResponse.From(updated));
|
return Ok(Features.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<FeatureSegmentResponse>>> ListFeatureSegments(
|
public async Task<ActionResult<IReadOnlyList<Features.FeatureSegmentResponse>>> ListFeatureSegments(
|
||||||
string apiKey, int featureId,
|
string apiKey, int featureId,
|
||||||
[FromServices] FeatureSegmentService featureSegmentService,
|
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -368,10 +365,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<FeatureSegmentResponse>> CreateFeatureSegment(
|
public async Task<ActionResult<Features.FeatureSegmentResponse>> CreateFeatureSegment(
|
||||||
string apiKey, int featureId,
|
string apiKey, int featureId,
|
||||||
CreateFeatureSegmentRequest request,
|
Features.CreateFeatureSegmentRequest request,
|
||||||
[FromServices] FeatureSegmentService featureSegmentService,
|
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -391,10 +388,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<FeatureSegmentResponse>> UpdateFeatureSegment(
|
public async Task<ActionResult<Features.FeatureSegmentResponse>> UpdateFeatureSegment(
|
||||||
string apiKey, int featureId, int id,
|
string apiKey, int featureId, int id,
|
||||||
UpdateFeatureSegmentRequest request,
|
Features.UpdateFeatureSegmentRequest request,
|
||||||
[FromServices] FeatureSegmentService featureSegmentService,
|
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -408,7 +405,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] FeatureSegmentService featureSegmentService,
|
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -421,11 +418,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<SegmentSummaryResponse>>> GetIdentitySegments(
|
public async Task<ActionResult<IReadOnlyList<Segments.SegmentSummaryResponse>>> GetIdentitySegments(
|
||||||
string apiKey, int id,
|
string apiKey, int id,
|
||||||
[FromServices] AdminIdentityService adminIdentityService,
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
[FromServices] SegmentService segmentService,
|
[FromServices] Segments.SegmentService segmentService,
|
||||||
[FromServices] SegmentEvaluator segmentEvaluator,
|
[FromServices] Segments.SegmentEvaluator segmentEvaluator,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
@@ -437,7 +434,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 SegmentSummaryResponse(s.Id, s.Name))
|
.Select(s => new Segments.SegmentSummaryResponse(s.Id, s.Name))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Ok(matching);
|
return Ok(matching);
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
using MicCheck.Api.Common.Validation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Environments;
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
public record UpdateEnvironmentRequest(string Name);
|
public record UpdateEnvironmentRequest(string Name);
|
||||||
|
|
||||||
public class UpdateEnvironmentRequestValidator : IModelValidator<UpdateEnvironmentRequest>
|
public class UpdateEnvironmentRequestValidator : AbstractValidator<UpdateEnvironmentRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(UpdateEnvironmentRequest model)
|
public UpdateEnvironmentRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
|
||||||
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,28 +1,26 @@
|
|||||||
using System.Text.RegularExpressions;
|
using FluentValidation;
|
||||||
using MicCheck.Api.Common.Validation;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description);
|
public record CreateFeatureRequest(
|
||||||
|
string Name,
|
||||||
|
FeatureType Type,
|
||||||
|
string? InitialValue,
|
||||||
|
string? Description
|
||||||
|
);
|
||||||
|
|
||||||
public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest>
|
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
|
||||||
{
|
{
|
||||||
private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
|
public CreateFeatureRequestValidator()
|
||||||
|
|
||||||
public ValidationResult Validate(CreateFeatureRequest model)
|
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name)
|
||||||
|
.NotEmpty()
|
||||||
|
.MaximumLength(150)
|
||||||
|
.Matches("^[a-zA-Z0-9_-]+$")
|
||||||
|
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(model.Name))
|
RuleFor(x => x.InitialValue)
|
||||||
result.AddError(nameof(model.Name), "'Name' must not be empty.");
|
.MaximumLength(20_000)
|
||||||
else if (model.Name.Length > 150)
|
.When(x => x.InitialValue is not null);
|
||||||
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,30 +1,16 @@
|
|||||||
using System.Text.RegularExpressions;
|
using FluentValidation;
|
||||||
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 : IModelValidator<CreateTagRequest>
|
public class CreateTagRequestValidator : AbstractValidator<CreateTagRequest>
|
||||||
{
|
{
|
||||||
private static readonly Regex ColorPattern = new("^#[0-9A-Fa-f]{3,6}$");
|
public CreateTagRequestValidator()
|
||||||
|
|
||||||
public ValidationResult Validate(CreateTagRequest model)
|
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Label).NotEmpty().MaximumLength(100);
|
||||||
|
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
|
||||||
if (string.IsNullOrEmpty(model.Label))
|
.Matches("^#[0-9A-Fa-f]{3,6}$")
|
||||||
result.AddError(nameof(model.Label), "'Label' must not be empty.");
|
.WithMessage("Color must be a valid hex color (e.g. #FF0000).");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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,5 +1,4 @@
|
|||||||
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, IAuditService auditService, WebhookQueue webhookQueue)
|
public class FeatureService(IMicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue)
|
||||||
{
|
{
|
||||||
private const int MaxFeaturesPerProject = 400;
|
private const int MaxFeaturesPerProject = 400;
|
||||||
|
|
||||||
@@ -23,7 +23,13 @@ public class FeatureService(IMicCheckDbContext db, IAuditService auditService, W
|
|||||||
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(int projectId, string name, FeatureType type, string? initialValue, string? description, CancellationToken ct = default)
|
public async Task<Feature> CreateAsync(
|
||||||
|
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)
|
||||||
@@ -72,7 +78,8 @@ public class FeatureService(IMicCheckDbContext db, IAuditService auditService, W
|
|||||||
return feature;
|
return feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Feature> UpdateAsync(int id, string name, string? description, CancellationToken ct = default)
|
public async Task<Feature> UpdateAsync(
|
||||||
|
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.");
|
||||||
@@ -155,7 +162,11 @@ public class FeatureService(IMicCheckDbContext db, IAuditService auditService, W
|
|||||||
EventType = WebhookEventTypes.FlagDeleted,
|
EventType = WebhookEventTypes.FlagDeleted,
|
||||||
EnvironmentId = env.Id,
|
EnvironmentId = env.Id,
|
||||||
OrganizationId = project.OrganizationId,
|
OrganizationId = project.OrganizationId,
|
||||||
Data = new FlagDeletedData(null, DateTimeOffset.UtcNow, new FeatureSummary(feature.Id, feature.Name)) });
|
Data = new FlagDeletedData(
|
||||||
|
null,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
new FeatureSummary(feature.Id, feature.Name))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public record FeatureStateResult
|
public class 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, IAuditService auditService)
|
public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
namespace MicCheck.Api.Features.Usage;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);
|
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);
|
||||||
+5
-2
@@ -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.Usage;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/environment/{environmentId}/usage")]
|
[Route("api/v1/environment/{environmentId}/usage")]
|
||||||
@@ -13,7 +13,10 @@ namespace MicCheck.Api.Features.Usage;
|
|||||||
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
|
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(int environmentId, [FromQuery] int days = 14, CancellationToken ct = default)
|
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(
|
||||||
|
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;
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
+1
-3
@@ -1,10 +1,8 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace MicCheck.Api.Features.Usage;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
[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
-1
@@ -1,7 +1,7 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
|
||||||
namespace MicCheck.Api.Features.Usage;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class FeatureUsageMetrics : IDisposable
|
public class FeatureUsageMetrics : IDisposable
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace MicCheck.Api.Features.Usage;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class FeatureUsageQueryService(IMicCheckDbContext db)
|
public class FeatureUsageQueryService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
+4
-2
@@ -1,7 +1,9 @@
|
|||||||
namespace MicCheck.Api.Features.Usage;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
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(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage);
|
public record DashboardUsageResponse(
|
||||||
|
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
|
||||||
|
IReadOnlyList<DailyUsage> DailyUsage);
|
||||||
@@ -12,9 +12,11 @@ public class FlagCache(IMemoryCache cache)
|
|||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags)
|
||||||
|
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
||||||
|
|
||||||
public void Invalidate(int environmentId) => cache.Remove(CacheKey(environmentId));
|
public void Invalidate(int environmentId)
|
||||||
|
=> cache.Remove(CacheKey(environmentId));
|
||||||
|
|
||||||
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
|
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public record Tag
|
public class Tag
|
||||||
{
|
{
|
||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
public required string Label { get; init; }
|
public required string Label { get; set; }
|
||||||
public required string Color { get; init; }
|
public required string Color { get; set; }
|
||||||
public int ProjectId { get; init; }
|
public int ProjectId { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,24 +8,23 @@ 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("tags")]
|
[HttpGet("api/v1/project/{projectId}/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("tags")]
|
[HttpPost("api/v1/project/{projectId}/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("tag/{id}")]
|
[HttpDelete("api/v1/project/{projectId}/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,25 +1,20 @@
|
|||||||
using System.Text.RegularExpressions;
|
using FluentValidation;
|
||||||
using MicCheck.Api.Common.Validation;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public record UpdateFeatureRequest(string Name, string? Description);
|
public record UpdateFeatureRequest(
|
||||||
|
string Name,
|
||||||
|
string? Description
|
||||||
|
);
|
||||||
|
|
||||||
public class UpdateFeatureRequestValidator : IModelValidator<UpdateFeatureRequest>
|
public class UpdateFeatureRequestValidator : AbstractValidator<UpdateFeatureRequest>
|
||||||
{
|
{
|
||||||
private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
|
public UpdateFeatureRequestValidator()
|
||||||
|
|
||||||
public ValidationResult Validate(UpdateFeatureRequest model)
|
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name)
|
||||||
|
.NotEmpty()
|
||||||
if (string.IsNullOrEmpty(model.Name))
|
.MaximumLength(150)
|
||||||
result.AddError(nameof(model.Name), "'Name' must not be empty.");
|
.Matches("^[a-zA-Z0-9_-]+$")
|
||||||
else if (model.Name.Length > 150)
|
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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,12 +0,0 @@
|
|||||||
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,12 +0,0 @@
|
|||||||
namespace MicCheck.Api.Identities;
|
|
||||||
|
|
||||||
public static class DependencyRegistration
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddIdentitiesServices(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddScoped<IdentityResolutionService>();
|
|
||||||
services.AddScoped<AdminIdentityService>();
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
</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" />
|
||||||
|
|||||||
+1
-1
@@ -305,7 +305,7 @@ namespace MicCheck.Api.Migrations
|
|||||||
b.ToTable("FeatureStates");
|
b.ToTable("FeatureStates");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
|
modelBuilder.Entity("MicCheck.Api.Features.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.Usage.FeatureUsageDaily", b =>
|
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
using MicCheck.Api.Common.Validation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Organizations;
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
public record CreateOrganizationRequest(string Name);
|
public record CreateOrganizationRequest(string Name);
|
||||||
|
|
||||||
public class CreateOrganizationRequestValidator : IModelValidator<CreateOrganizationRequest>
|
public class CreateOrganizationRequestValidator : AbstractValidator<CreateOrganizationRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(CreateOrganizationRequest model)
|
public CreateOrganizationRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
|
||||||
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,11 +0,0 @@
|
|||||||
namespace MicCheck.Api.Organizations;
|
|
||||||
|
|
||||||
public static class DependencyRegistration
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddOrganizationsServices(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddScoped<OrganizationService>();
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,15 @@
|
|||||||
using MicCheck.Api.Common.Validation;
|
using FluentValidation;
|
||||||
|
|
||||||
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 : IModelValidator<InviteUserRequest>
|
public class InviteUserRequestValidator : AbstractValidator<InviteUserRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(InviteUserRequest model)
|
public InviteUserRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.UserId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse<OrganizationRole>(r, true, out _))
|
||||||
if (model.UserId <= 0)
|
.WithMessage("Role must be 'User' or 'Admin'.");
|
||||||
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,11 +1,10 @@
|
|||||||
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, IAuditService auditService)
|
public class OrganizationService(IMicCheckDbContext db, AuditService 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)
|
||||||
{
|
{
|
||||||
@@ -47,7 +46,6 @@ public class OrganizationService(IMicCheckDbContext db, IAuditService auditServi
|
|||||||
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,20 +1,13 @@
|
|||||||
using MicCheck.Api.Common.Validation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Organizations;
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
public record UpdateOrganizationRequest(string Name);
|
public record UpdateOrganizationRequest(string Name);
|
||||||
|
|
||||||
public class UpdateOrganizationRequestValidator : IModelValidator<UpdateOrganizationRequest>
|
public class UpdateOrganizationRequestValidator : AbstractValidator<UpdateOrganizationRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(UpdateOrganizationRequest model)
|
public UpdateOrganizationRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-15
@@ -1,9 +1,11 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using MicCheck.Api.Audit;
|
using FluentValidation;
|
||||||
using MicCheck.Api.Common;
|
using FluentValidation.AspNetCore;
|
||||||
using MicCheck.Api.Common.Security.ApiKeys;
|
using MicCheck.Api.Common.Security.ApiKeys;
|
||||||
|
using MicCheck.Api.Audit;
|
||||||
|
using MicCheck.Api.Common.Security.Authentication;
|
||||||
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;
|
||||||
@@ -13,7 +15,14 @@ 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;
|
||||||
|
|
||||||
@@ -33,12 +42,44 @@ try
|
|||||||
.WriteTo.Console());
|
.WriteTo.Console());
|
||||||
|
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
builder.Services.AddControllers(options => options.Filters.Add<ModelValidationActionFilter>())
|
builder.Services.AddControllers()
|
||||||
.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.AddCommonServices(builder.Configuration);
|
builder.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 = 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();
|
||||||
|
|
||||||
@@ -51,20 +92,67 @@ 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.AddAuditServices();
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
builder.Services.AddIdentitiesServices();
|
builder.Services.AddScoped<AuthService>();
|
||||||
builder.Services.AddEnvironmentsServices();
|
builder.Services.AddScoped<ApiKeyService>();
|
||||||
builder.Services.AddSegmentsServices();
|
builder.Services.AddScoped<DatabaseSeeder>();
|
||||||
builder.Services.AddOrganizationsServices();
|
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
|
||||||
builder.Services.AddProjectsServices();
|
builder.Services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
|
||||||
builder.Services.AddFeaturesServices();
|
builder.Services.AddScoped<FeatureEvaluationService>();
|
||||||
builder.Services.AddUsersServices();
|
builder.Services.AddScoped<IdentityResolutionService>();
|
||||||
builder.Services.AddWebhooksServices();
|
builder.Services.AddScoped<EnvironmentDocumentService>();
|
||||||
|
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"));
|
||||||
|
|
||||||
builder.Services.AddDataServices(builder.Configuration);
|
var connectionString = builder.Configuration.GetConnectionString("miccheck")
|
||||||
|
?? (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,23 +1,14 @@
|
|||||||
using MicCheck.Api.Common.Validation;
|
using FluentValidation;
|
||||||
|
|
||||||
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 : IModelValidator<CreateProjectRequest>
|
public class CreateProjectRequestValidator : AbstractValidator<CreateProjectRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(CreateProjectRequest model)
|
public CreateProjectRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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, IAuditService auditService)
|
public class ProjectService(IMicCheckDbContext db, AuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
using MicCheck.Api.Common.Security.Authorization;
|
using FluentValidation;
|
||||||
using MicCheck.Api.Common.Validation;
|
using MicCheck.Api.Common.Security.Authorization;
|
||||||
|
|
||||||
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 : IModelValidator<SetUserPermissionsRequest>
|
public class SetUserPermissionsRequestValidator : AbstractValidator<SetUserPermissionsRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(SetUserPermissionsRequest model)
|
public SetUserPermissionsRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.UserId).GreaterThan(0);
|
||||||
|
RuleForEach(x => x.Permissions)
|
||||||
if (model.UserId <= 0)
|
.Must(p => Enum.TryParse<ProjectPermission>(p, true, out _))
|
||||||
result.AddError(nameof(model.UserId), "'User Id' must be greater than 0.");
|
.WithMessage("Invalid permission value.");
|
||||||
|
}
|
||||||
foreach (var permission in model.Permissions)
|
}
|
||||||
{
|
|
||||||
if (!Enum.TryParse<ProjectPermission>(permission, true, out _))
|
|
||||||
result.AddError(nameof(model.Permissions), "Invalid permission value.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
using MicCheck.Api.Common.Validation;
|
using FluentValidation;
|
||||||
|
|
||||||
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 : IModelValidator<UpdateProjectRequest>
|
public class UpdateProjectRequestValidator : AbstractValidator<UpdateProjectRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(UpdateProjectRequest model)
|
public UpdateProjectRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
|
||||||
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 MicCheck.Api.Common.Validation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Segments;
|
namespace MicCheck.Api.Segments;
|
||||||
|
|
||||||
@@ -16,38 +16,24 @@ public record CreateSegmentRequest(
|
|||||||
string Name,
|
string Name,
|
||||||
IReadOnlyList<CreateSegmentRuleRequest> Rules);
|
IReadOnlyList<CreateSegmentRuleRequest> Rules);
|
||||||
|
|
||||||
public class CreateSegmentRequestValidator : IModelValidator<CreateSegmentRequest>
|
public class CreateSegmentRequestValidator : AbstractValidator<CreateSegmentRequest>
|
||||||
{
|
{
|
||||||
public ValidationResult Validate(CreateSegmentRequest model)
|
public CreateSegmentRequestValidator()
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.Rules).NotNull();
|
||||||
if (string.IsNullOrEmpty(model.Name))
|
RuleForEach(x => x.Rules).ChildRules(rule =>
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
result.AddError(nameof(model.Rules), "'Rules' must not be empty.");
|
rule.RuleFor(r => r.Type)
|
||||||
return result;
|
.Must(t => Enum.TryParse<SegmentRuleType>(t, true, out _))
|
||||||
}
|
.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)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(condition.Property))
|
cond.RuleFor(c => c.Property).NotEmpty();
|
||||||
result.AddError(nameof(model.Rules), "'Property' must not be empty.");
|
cond.RuleFor(c => c.Operator)
|
||||||
|
.Must(o => Enum.TryParse<SegmentConditionOperator>(o, true, out _))
|
||||||
if (!Enum.TryParse<SegmentConditionOperator>(condition.Operator, true, out _))
|
.WithMessage("Invalid operator.");
|
||||||
result.AddError(nameof(model.Rules), "Invalid operator.");
|
});
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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
Reference in New Issue
Block a user