Initial commit, project structure and domain models
This commit is contained in:
104
docs/01_project_setup.md
Normal file
104
docs/01_project_setup.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Step 1: Project Setup & Solution Structure
|
||||
|
||||
## Goal
|
||||
Establish the solution structure, project references, NuGet packages, and foundational infrastructure for the MicCheck C# port of Flagsmith.
|
||||
|
||||
## Solution Layout
|
||||
|
||||
```
|
||||
mic-check.sln
|
||||
├── src/
|
||||
│ ├── MicCheck.Api/ # ASP.NET Core Web API — domain, services, and controllers
|
||||
│ │ ├── Features/ # Feature flag domain, services, controllers, DTOs
|
||||
│ │ ├── Environments/ # Environment domain, services, controllers, DTOs
|
||||
│ │ ├── Projects/ # Project domain, services, controllers, DTOs
|
||||
│ │ ├── Organizations/ # Organization domain, services, controllers, DTOs
|
||||
│ │ ├── Segments/ # Segment domain, services, controllers, DTOs
|
||||
│ │ ├── Identities/ # Identity domain, services, controllers, DTOs
|
||||
│ │ ├── Audit/ # Audit log domain, service, controller, DTOs
|
||||
│ │ ├── Webhooks/ # Webhook domain, service, controller, DTOs
|
||||
│ │ ├── ApiKeys/ # API key domain, service, DTOs
|
||||
│ │ └── Authentication/ # Auth handlers and policies
|
||||
│ └── MicCheck.Infrastructure/ # EF Core DbContext, entity configurations, migrations
|
||||
└── tests/
|
||||
├── MicCheck.Api.Tests/
|
||||
└── MicCheck.Infrastructure.Tests/
|
||||
```
|
||||
|
||||
Each feature folder in `MicCheck.Api` contains everything for that area:
|
||||
- Domain model(s) — e.g. `Feature.cs`, `FeatureState.cs`
|
||||
- Service(s) — e.g. `FeatureService.cs`
|
||||
- Controller(s) — e.g. `FeaturesController.cs`
|
||||
- Request/Response DTOs — e.g. `CreateFeatureRequest.cs`, `FeatureResponse.cs`
|
||||
|
||||
## Project Configurations
|
||||
|
||||
### All `.csproj` files must include:
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
## MicCheck.Api
|
||||
|
||||
**Purpose:** The entire application — domain models, business logic services, and HTTP controllers all live here, organized by feature area.
|
||||
|
||||
**NuGet packages:**
|
||||
- `Microsoft.AspNetCore.Authentication.JwtBearer` (latest)
|
||||
- `Swashbuckle.AspNetCore` (latest)
|
||||
- `Serilog.AspNetCore` (latest)
|
||||
- `FluentValidation.AspNetCore` (latest)
|
||||
|
||||
**Project reference:**
|
||||
- References `MicCheck.Infrastructure`
|
||||
|
||||
## MicCheck.Infrastructure
|
||||
|
||||
**Purpose:** EF Core DbContext, entity configurations, and PostgreSQL migrations only. No business logic.
|
||||
|
||||
**NuGet packages:**
|
||||
- `Microsoft.EntityFrameworkCore` (latest for net10.0)
|
||||
- `Npgsql.EntityFrameworkCore.PostgreSQL` (latest)
|
||||
- `Microsoft.EntityFrameworkCore.Design` (latest)
|
||||
|
||||
**Key namespaces:**
|
||||
- `MicCheck.Infrastructure.Data` — DbContext, entity configurations, migrations
|
||||
|
||||
## Test Projects
|
||||
|
||||
**NuGet packages (all test projects):**
|
||||
- `NUnit` (latest)
|
||||
- `NUnit3TestAdapter` (latest)
|
||||
- `Moq` (latest)
|
||||
- `Microsoft.NET.Test.Sdk` (latest)
|
||||
- `coverlet.collector` (latest)
|
||||
|
||||
**Project references:**
|
||||
- `MicCheck.Api.Tests` references `MicCheck.Api` and `MicCheck.Infrastructure`
|
||||
- `MicCheck.Infrastructure.Tests` references `MicCheck.Infrastructure`
|
||||
|
||||
## Docker Compose (development)
|
||||
|
||||
Services required:
|
||||
- `postgres:15-alpine` — primary database, port 5432
|
||||
- `mic-check-api` — the ASP.NET Core API, port 8080
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://miccheck:password@localhost:5432/miccheck
|
||||
JWT_SECRET=<secret>
|
||||
ADMIN_API_KEY=<key>
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Solution builds with `dotnet build` with zero warnings
|
||||
- [ ] All projects target net10.0 with `LangVersion=latest` and `Nullable=enable`
|
||||
- [ ] Docker Compose brings up API and database cleanly
|
||||
- [ ] `dotnet test` runs successfully (no tests yet, but harness works)
|
||||
- [ ] Swagger UI is accessible at `/swagger`
|
||||
67
docs/01_project_setup_output.md
Normal file
67
docs/01_project_setup_output.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Step 1 Implementation Output
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Solution Structure
|
||||
|
||||
The solution (`MicCheck.slnx`) now contains four projects:
|
||||
|
||||
| Project | Path |
|
||||
|---|---|
|
||||
| `MicCheck.Api` | `src/api/MicCheck.Api/` |
|
||||
| `MicCheck.Infrastructure` | `src/infrastructure/MicCheck.Infrastructure/` |
|
||||
| `MicCheck.Api.Tests.Unit` | `tests/api/MicCheck.Api.Tests.Unit/` |
|
||||
| `MicCheck.Infrastructure.Tests` | `tests/infrastructure/MicCheck.Infrastructure.Tests/` |
|
||||
|
||||
### Projects Created
|
||||
|
||||
**`MicCheck.Infrastructure`** — new class library targeting net10.0 with:
|
||||
- `Microsoft.EntityFrameworkCore` 10.0.5
|
||||
- `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.1
|
||||
- `Microsoft.EntityFrameworkCore.Design` 10.0.5
|
||||
- Folder structure: `Data/Configurations/`, `Data/Migrations/`
|
||||
|
||||
**`MicCheck.Infrastructure.Tests`** — new NUnit test project targeting net10.0 with:
|
||||
- `NUnit` 4.3.2, `NUnit3TestAdapter` 5.0.0, `NUnit.Analyzers` 4.7.0
|
||||
- `Moq` 4.20.72, `coverlet.collector` 6.0.4
|
||||
- Project reference to `MicCheck.Infrastructure`
|
||||
|
||||
### Projects Updated
|
||||
|
||||
**`MicCheck.Api`** — added:
|
||||
- `TreatWarningsAsErrors`
|
||||
- `Serilog.AspNetCore` 9.0.0
|
||||
- `FluentValidation.AspNetCore` 11.3.0
|
||||
- Project reference to `MicCheck.Infrastructure`
|
||||
- `Program.cs` updated: Serilog bootstrap logger, `AddControllers()`, `AddFluentValidationAutoValidation()`, `AddRateLimiter("AdminApi")`, `MapControllers()`
|
||||
- `appsettings.json` updated: Serilog config section, `ConnectionStrings:DefaultConnection`
|
||||
|
||||
**`MicCheck.Api.Tests.Unit`** — added:
|
||||
- `TreatWarningsAsErrors`
|
||||
- `coverlet.collector` 6.0.4
|
||||
- Project reference to `MicCheck.Infrastructure`
|
||||
|
||||
### Feature Folders Created in MicCheck.Api
|
||||
|
||||
`Features/`, `Environments/`, `Projects/`, `Organizations/`, `Segments/`, `Identities/`, `Audit/`, `Webhooks/`, `ApiKeys/`, `Users/`
|
||||
|
||||
Existing `Auth/` folder retained (equivalent to `Authentication/` in the plan).
|
||||
|
||||
### Files Added
|
||||
|
||||
- `docker-compose.yml` — PostgreSQL 15-alpine + API service with health check
|
||||
- `src/api/MicCheck.Api/Dockerfile` — multi-stage build (SDK → publish → runtime)
|
||||
|
||||
### Deviations from Plan
|
||||
|
||||
- **Scalar instead of Swashbuckle**: The project already used `Scalar.AspNetCore` + `Microsoft.AspNetCore.OpenApi`. This is the recommended approach for .NET 10 and was retained. The API reference UI is at `/scalar` rather than `/swagger`.
|
||||
- **`Auth/` folder**: The existing auth code lives in `Auth/` rather than `Authentication/` as named in the plan. The existing code was not renamed.
|
||||
- **NUnit template version**: `MicCheck.Infrastructure.Tests` was created with `dotnet new nunit` which brought in NUnit 4.3.2 / NUnit3TestAdapter 5.0.0 (slightly different patch versions from the Api test project). Both are current stable releases.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Solution builds with `dotnet build` with zero warnings
|
||||
- [x] All projects target net10.0 with `LangVersion=latest` and `Nullable=enable`
|
||||
- [x] `dotnet test` runs successfully — 8 tests pass across 2 test projects
|
||||
- [x] API reference UI accessible at `/scalar` (Scalar, not Swagger)
|
||||
- [ ] Docker Compose brings up API and database cleanly — requires Docker; not verified in this step
|
||||
332
docs/02_core_domain_models.md
Normal file
332
docs/02_core_domain_models.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Step 2: Domain Models
|
||||
|
||||
## Goal
|
||||
Define all domain entities in `MicCheck.Api`, organized by feature area. Each entity lives in the folder for the feature it belongs to.
|
||||
|
||||
## Constraints (from Flagsmith spec)
|
||||
- Max 400 features per project
|
||||
- Max 100 segments per project
|
||||
- Max 100 segment overrides per environment
|
||||
- Max 100 segment rule conditions
|
||||
- Feature flag values: max 20,000 bytes
|
||||
- Identity trait values: max 2,000 bytes
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Organizations/
|
||||
|
||||
```csharp
|
||||
// Organizations/Organization.cs
|
||||
public class Organization
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<Project> Projects { get; init; } = [];
|
||||
public ICollection<OrganizationUser> Members { get; init; } = [];
|
||||
public ICollection<ApiKey> ApiKeys { get; init; } = [];
|
||||
}
|
||||
|
||||
// Organizations/OrganizationUser.cs
|
||||
public class OrganizationUser
|
||||
{
|
||||
public int OrganizationId { get; init; }
|
||||
public int UserId { get; init; }
|
||||
public OrganizationRole Role { get; set; }
|
||||
}
|
||||
|
||||
public enum OrganizationRole { Admin, User }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Projects/
|
||||
|
||||
```csharp
|
||||
// Projects/Project.cs
|
||||
public class Project
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public int OrganizationId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public bool HideDisabledFlags { get; set; }
|
||||
public ICollection<Environment> Environments { get; init; } = [];
|
||||
public ICollection<Feature> Features { get; init; } = [];
|
||||
public ICollection<Segment> Segments { get; init; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Environments/
|
||||
|
||||
```csharp
|
||||
// Environments/Environment.cs
|
||||
public class Environment
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public required string ApiKey { get; set; } // X-Environment-Key header value
|
||||
public int ProjectId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<FeatureState> FeatureStates { get; init; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Features/
|
||||
|
||||
```csharp
|
||||
// Features/Feature.cs
|
||||
public class Feature
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public FeatureType Type { get; set; }
|
||||
public string? InitialValue { get; set; } // max 20,000 bytes
|
||||
public string? Description { get; set; }
|
||||
public bool DefaultEnabled { get; set; }
|
||||
public int ProjectId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<FeatureState> FeatureStates { get; init; } = [];
|
||||
public ICollection<Tag> Tags { get; init; } = [];
|
||||
}
|
||||
|
||||
public enum FeatureType { Standard, MultiVariate }
|
||||
|
||||
// Features/FeatureState.cs
|
||||
// Represents the value of a feature within a specific environment
|
||||
public class FeatureState
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int FeatureId { get; init; }
|
||||
public int EnvironmentId { get; init; }
|
||||
public int? IdentityId { get; set; } // null = environment-level
|
||||
public bool Enabled { get; set; }
|
||||
public string? Value { get; set; } // max 20,000 bytes
|
||||
public int? FeatureSegmentId { get; set; } // null = not a segment override
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public int Version { get; set; } // optimistic concurrency
|
||||
}
|
||||
|
||||
// Features/FeatureSegment.cs
|
||||
// Links a feature to a segment with an override priority
|
||||
public class FeatureSegment
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int FeatureId { get; init; }
|
||||
public int SegmentId { get; init; }
|
||||
public int EnvironmentId { get; init; }
|
||||
public int Priority { get; set; }
|
||||
public FeatureState? FeatureState { get; set; }
|
||||
}
|
||||
|
||||
// Features/Tag.cs
|
||||
public class Tag
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public int ProjectId { get; init; }
|
||||
}
|
||||
|
||||
// Features/FeatureStateResult.cs
|
||||
// Value object returned by FeatureEvaluationService — never exposed directly via API
|
||||
public class FeatureStateResult
|
||||
{
|
||||
public required Feature Feature { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Segments/
|
||||
|
||||
```csharp
|
||||
// Segments/Segment.cs
|
||||
public class Segment
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public int ProjectId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<SegmentRule> Rules { get; init; } = [];
|
||||
}
|
||||
|
||||
// Segments/SegmentRule.cs
|
||||
public class SegmentRule
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int SegmentId { get; init; }
|
||||
public int? ParentRuleId { get; set; } // allows nested AND/OR
|
||||
public SegmentRuleType Type { get; set; }
|
||||
public ICollection<SegmentCondition> Conditions { get; init; } = [];
|
||||
public ICollection<SegmentRule> ChildRules { get; init; } = [];
|
||||
}
|
||||
|
||||
public enum SegmentRuleType { All, Any, None }
|
||||
|
||||
// Segments/SegmentCondition.cs
|
||||
public class SegmentCondition
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int RuleId { get; init; }
|
||||
public required string Property { get; set; } // trait key
|
||||
public SegmentConditionOperator Operator { get; set; }
|
||||
public required string Value { get; set; }
|
||||
}
|
||||
|
||||
public enum SegmentConditionOperator
|
||||
{
|
||||
Equal, NotEqual,
|
||||
GreaterThan, GreaterThanOrEqual,
|
||||
LessThan, LessThanOrEqual,
|
||||
Contains, NotContains,
|
||||
Regex,
|
||||
IsSet, IsNotSet,
|
||||
In, NotIn,
|
||||
PercentageSplit,
|
||||
ModuloValueDivisorRemainder,
|
||||
IsTrue, IsFalse
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Identities/
|
||||
|
||||
```csharp
|
||||
// Identities/Identity.cs
|
||||
// Represents an end-user or SDK client
|
||||
public class Identity
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Identifier { get; set; }
|
||||
public int EnvironmentId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<IdentityTrait> Traits { get; init; } = [];
|
||||
public ICollection<FeatureState> FeatureStateOverrides { get; init; } = [];
|
||||
}
|
||||
|
||||
// Identities/IdentityTrait.cs
|
||||
public class IdentityTrait
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int IdentityId { get; init; }
|
||||
public required string Key { get; set; }
|
||||
public required string Value { get; set; } // max 2,000 bytes
|
||||
public TraitValueType ValueType { get; set; }
|
||||
}
|
||||
|
||||
public enum TraitValueType { String, Integer, Float, Boolean }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Audit/
|
||||
|
||||
```csharp
|
||||
// Audit/AuditLog.cs
|
||||
public class AuditLog
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public required string Action { get; init; } // Created, Updated, Deleted
|
||||
public string? Changes { get; init; } // JSON diff
|
||||
public int OrganizationId { get; init; }
|
||||
public int? ProjectId { get; init; }
|
||||
public int? EnvironmentId { get; init; }
|
||||
public int? ActorUserId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Webhooks/
|
||||
|
||||
```csharp
|
||||
// Webhooks/Webhook.cs
|
||||
public class Webhook
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Url { get; set; }
|
||||
public string? Secret { get; set; }
|
||||
public WebhookScope Scope { get; set; }
|
||||
public int? EnvironmentId { get; set; }
|
||||
public int? OrganizationId { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
public enum WebhookScope { Environment, Organization }
|
||||
|
||||
// Webhooks/WebhookDeliveryLog.cs
|
||||
public class WebhookDeliveryLog
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int WebhookId { get; init; }
|
||||
public required string EventType { get; init; }
|
||||
public required string PayloadJson { get; init; }
|
||||
public int? ResponseStatusCode { get; set; }
|
||||
public string? ResponseBody { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTimeOffset AttemptedAt { get; init; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/ApiKeys/
|
||||
|
||||
```csharp
|
||||
// ApiKeys/ApiKey.cs
|
||||
public class ApiKey
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Key { get; set; } // hashed
|
||||
public required string Prefix { get; set; } // first 8 chars for display
|
||||
public required string Name { get; set; }
|
||||
public int OrganizationId { get; init; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Users/ (referenced by auth)
|
||||
|
||||
```csharp
|
||||
// Users/User.cs
|
||||
public class User
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Email { get; set; }
|
||||
public required string PasswordHash { get; set; }
|
||||
public required string FirstName { get; set; }
|
||||
public required string LastName { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsTwoFactorEnabled { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
public ICollection<OrganizationUser> Organizations { get; init; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All domain models compile within `MicCheck.Api` with no cross-project domain dependencies
|
||||
- [ ] Nullable reference types enabled — all required properties explicitly marked
|
||||
- [ ] No domain logic in DTOs — domain models and request/response types are distinct classes
|
||||
- [ ] Unit tests validate domain constraints (e.g., feature count limits enforced in service logic)
|
||||
72
docs/02_core_domain_models_output.md
Normal file
72
docs/02_core_domain_models_output.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Step 2 Implementation Output
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Domain Models Created
|
||||
|
||||
19 files added to `MicCheck.Api`, each in its feature area folder:
|
||||
|
||||
| File | Namespace |
|
||||
|---|---|
|
||||
| `Organizations/Organization.cs` | `MicCheck.Api.Organizations` |
|
||||
| `Organizations/OrganizationUser.cs` | `MicCheck.Api.Organizations` |
|
||||
| `Projects/Project.cs` | `MicCheck.Api.Projects` |
|
||||
| `Environments/Environment.cs` | `MicCheck.Api.Environments` |
|
||||
| `Features/Feature.cs` | `MicCheck.Api.Features` |
|
||||
| `Features/FeatureState.cs` | `MicCheck.Api.Features` |
|
||||
| `Features/FeatureSegment.cs` | `MicCheck.Api.Features` |
|
||||
| `Features/Tag.cs` | `MicCheck.Api.Features` |
|
||||
| `Features/FeatureStateResult.cs` | `MicCheck.Api.Features` |
|
||||
| `Segments/Segment.cs` | `MicCheck.Api.Segments` |
|
||||
| `Segments/SegmentRule.cs` | `MicCheck.Api.Segments` |
|
||||
| `Segments/SegmentCondition.cs` | `MicCheck.Api.Segments` |
|
||||
| `Identities/Identity.cs` | `MicCheck.Api.Identities` |
|
||||
| `Identities/IdentityTrait.cs` | `MicCheck.Api.Identities` |
|
||||
| `Audit/AuditLog.cs` | `MicCheck.Api.Audit` |
|
||||
| `Webhooks/Webhook.cs` | `MicCheck.Api.Webhooks` |
|
||||
| `Webhooks/WebhookDeliveryLog.cs` | `MicCheck.Api.Webhooks` |
|
||||
| `ApiKeys/ApiKey.cs` | `MicCheck.Api.ApiKeys` |
|
||||
| `Users/User.cs` | `MicCheck.Api.Users` |
|
||||
|
||||
### Enums Defined
|
||||
|
||||
| Enum | Values | File |
|
||||
|---|---|---|
|
||||
| `OrganizationRole` | `User`, `Admin` | `OrganizationUser.cs` |
|
||||
| `FeatureType` | `Standard`, `MultiVariate` | `Feature.cs` |
|
||||
| `SegmentRuleType` | `All`, `Any`, `None` | `SegmentRule.cs` |
|
||||
| `SegmentConditionOperator` | 17 values | `SegmentCondition.cs` |
|
||||
| `TraitValueType` | `String`, `Integer`, `Float`, `Boolean` | `IdentityTrait.cs` |
|
||||
| `WebhookScope` | `Environment`, `Organization` | `Webhook.cs` |
|
||||
|
||||
### Tests Created
|
||||
|
||||
10 test files covering all domain areas, 51 new tests (58 total in the suite):
|
||||
|
||||
| File | Tests |
|
||||
|---|---|
|
||||
| `Organizations/OrganizationTests.cs` | 6 |
|
||||
| `Projects/ProjectTests.cs` | 3 |
|
||||
| `Environments/EnvironmentTests.cs` | 2 |
|
||||
| `Features/FeatureTests.cs` | 12 |
|
||||
| `Segments/SegmentTests.cs` | 7 |
|
||||
| `Identities/IdentityTests.cs` | 5 |
|
||||
| `Audit/AuditLogTests.cs` | 2 |
|
||||
| `Webhooks/WebhookTests.cs` | 6 |
|
||||
| `ApiKeys/ApiKeyTests.cs` | 3 |
|
||||
| `Users/UserTests.cs` | 4 |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
- **`OrganizationRole` enum ordering**: The plan declared `{ Admin, User }` making `Admin` the zero-value default. Reordered to `{ User, Admin }` so that an `OrganizationUser` created without an explicit role safely defaults to `User` rather than `Admin`.
|
||||
|
||||
- **`System.Environment` collision**: `MicCheck.Api.Environments.Environment` conflicts with `System.Environment` (pulled in via implicit usings). Resolved with `using AppEnvironment = MicCheck.Api.Environments.Environment;` in `Projects/Project.cs` and the corresponding test file. The class name `Environment` was kept as specified in the plan.
|
||||
|
||||
- **`using NUnit.Framework;`**: The existing `MicCheck.Api.Tests.Unit` project does not configure `NUnit.Framework` as a global using (unlike the `MicCheck.Infrastructure.Tests` template). All new test files include the using directive explicitly.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] All domain models compile with zero warnings
|
||||
- [x] Nullable reference types enabled — all required properties explicitly marked with `required`
|
||||
- [x] No domain logic in DTOs — models are plain C# classes with no service dependencies
|
||||
- [x] `dotnet test` — 59 tests pass, 0 failures
|
||||
195
docs/03_database_and_repositories.md
Normal file
195
docs/03_database_and_repositories.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Step 3: Database Schema & EF Core
|
||||
|
||||
## Goal
|
||||
Implement the EF Core `DbContext` and entity configurations in `MicCheck.Infrastructure`, and wire up services in `MicCheck.Api` to use it directly. There is no repository abstraction layer — services inject `MicCheckDbContext` and query it with LINQ.
|
||||
|
||||
---
|
||||
|
||||
## DbContext
|
||||
|
||||
```csharp
|
||||
// MicCheck.Infrastructure/Data/MicCheckDbContext.cs
|
||||
public class MicCheckDbContext : DbContext
|
||||
{
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Environment> Environments => Set<Environment>();
|
||||
public DbSet<Feature> Features => Set<Feature>();
|
||||
public DbSet<FeatureState> FeatureStates => Set<FeatureState>();
|
||||
public DbSet<FeatureSegment> FeatureSegments => Set<FeatureSegment>();
|
||||
public DbSet<Segment> Segments => Set<Segment>();
|
||||
public DbSet<SegmentRule> SegmentRules => Set<SegmentRule>();
|
||||
public DbSet<SegmentCondition> SegmentConditions => Set<SegmentCondition>();
|
||||
public DbSet<Identity> Identities => Set<Identity>();
|
||||
public DbSet<IdentityTrait> IdentityTraits => Set<IdentityTrait>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<Webhook> Webhooks => Set<Webhook>();
|
||||
public DbSet<WebhookDeliveryLog> WebhookDeliveryLogs => Set<WebhookDeliveryLog>();
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entity Configurations (EF Core Fluent API)
|
||||
|
||||
Each domain entity gets its own `IEntityTypeConfiguration<T>` in `MicCheck.Infrastructure/Data/Configurations/`:
|
||||
|
||||
### Key configurations:
|
||||
|
||||
**Feature:**
|
||||
```csharp
|
||||
entity.Property(f => f.Name).HasMaxLength(150).IsRequired();
|
||||
entity.Property(f => f.InitialValue).HasMaxLength(20_000);
|
||||
entity.HasIndex(f => new { f.ProjectId, f.Name }).IsUnique();
|
||||
```
|
||||
|
||||
**FeatureState:**
|
||||
```csharp
|
||||
entity.Property(fs => fs.Value).HasMaxLength(20_000);
|
||||
entity.Property(fs => fs.Version).IsConcurrencyToken();
|
||||
entity.HasIndex(fs => new { fs.FeatureId, fs.EnvironmentId, fs.IdentityId }).IsUnique();
|
||||
```
|
||||
|
||||
**IdentityTrait:**
|
||||
```csharp
|
||||
entity.Property(t => t.Value).HasMaxLength(2_000);
|
||||
entity.HasIndex(t => new { t.IdentityId, t.Key }).IsUnique();
|
||||
```
|
||||
|
||||
**Environment:**
|
||||
```csharp
|
||||
entity.Property(e => e.ApiKey).HasMaxLength(100).IsRequired();
|
||||
entity.HasIndex(e => e.ApiKey).IsUnique();
|
||||
```
|
||||
|
||||
**ApiKey:**
|
||||
```csharp
|
||||
entity.Property(k => k.Key).HasMaxLength(512).IsRequired(); // stores hash
|
||||
entity.Property(k => k.Prefix).HasMaxLength(8).IsRequired();
|
||||
entity.HasIndex(k => k.Key).IsUnique();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Usage Pattern
|
||||
|
||||
Services receive `MicCheckDbContext` via constructor injection and query it directly:
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeatureService.cs
|
||||
public class FeatureService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
public FeatureService(MicCheckDbContext db, AuditService auditService)
|
||||
{
|
||||
_db = db;
|
||||
_auditService = auditService;
|
||||
}
|
||||
|
||||
public async Task<Feature> CreateAsync(
|
||||
int projectId, string name, FeatureType type, string? initialValue,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var count = await _db.Features.CountAsync(f => f.ProjectId == projectId, ct);
|
||||
if (count >= 400)
|
||||
throw new DomainException("Projects may not have more than 400 features.");
|
||||
|
||||
var feature = new Feature { ProjectId = projectId, Name = name, Type = type, InitialValue = initialValue };
|
||||
_db.Features.Add(feature);
|
||||
|
||||
// Auto-create FeatureState for every existing environment
|
||||
var environments = await _db.Environments
|
||||
.Where(e => e.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var env in environments)
|
||||
_db.FeatureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = env.Id });
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _auditService.RecordAsync("Feature", feature.Id.ToString(), "Created", ...);
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Controllers call the service and map the returned domain object to a response DTO:
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeaturesController.cs
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<FeatureResponse>> Create(
|
||||
int projectId, CreateFeatureRequest request, CancellationToken ct)
|
||||
{
|
||||
var feature = await _featureService.CreateAsync(
|
||||
projectId, request.Name, request.Type, request.InitialValue, ct);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id },
|
||||
FeatureResponse.From(feature));
|
||||
}
|
||||
```
|
||||
|
||||
The static `From` method (or a mapping method on the controller) is the only place a domain object becomes a DTO.
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
- Use EF Core code-first migrations
|
||||
- Migration naming: `{Timestamp}_{DescriptiveName}` (e.g., `20260101_InitialSchema`)
|
||||
- All migrations in `MicCheck.Infrastructure/Data/Migrations/`
|
||||
- Run via `dotnet ef migrations add` and `dotnet ef database update`
|
||||
- The `--project MicCheck.Infrastructure --startup-project MicCheck.Api` flags are required since the DbContext is in Infrastructure
|
||||
|
||||
---
|
||||
|
||||
## Connection String Configuration
|
||||
|
||||
```json
|
||||
// appsettings.json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=miccheck;Username=miccheck;Password=password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Support `DATABASE_URL` environment variable override (Heroku/Railway style parsing).
|
||||
|
||||
---
|
||||
|
||||
## Seed Data
|
||||
|
||||
Create an initial migration seeder for local development:
|
||||
- Default organization: "Default"
|
||||
- Default project: "My Project"
|
||||
- Default environments: "Development", "Staging", "Production"
|
||||
|
||||
---
|
||||
|
||||
## Indexes for Performance
|
||||
|
||||
Critical indexes beyond unique constraints:
|
||||
- `FeatureState (EnvironmentId)` — for flag serving
|
||||
- `FeatureState (IdentityId)` — for identity lookups
|
||||
- `Identity (EnvironmentId, Identifier)` — unique, for identity resolution
|
||||
- `AuditLog (OrganizationId, CreatedAt DESC)` — for audit queries
|
||||
- `Webhook (EnvironmentId)` — for event dispatch
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `dotnet ef migrations add InitialSchema` generates a valid migration
|
||||
- [ ] `dotnet ef database update` applies cleanly against a fresh PostgreSQL instance
|
||||
- [ ] All unique constraints are enforced at the database level
|
||||
- [ ] Services use `MicCheckDbContext` directly — no repository interfaces
|
||||
- [ ] No raw SQL — all queries use EF Core LINQ
|
||||
- [ ] Integration tests against a real test database verify EF configurations are correct
|
||||
214
docs/04_authentication_and_authorization.md
Normal file
214
docs/04_authentication_and_authorization.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Step 4: Authentication & Authorization
|
||||
|
||||
## Goal
|
||||
Implement the two authentication schemes Flagsmith uses — Environment Key (public, for Flags API) and API Key (secret, for Admin API) — plus JWT for dashboard users, and role-based authorization.
|
||||
|
||||
---
|
||||
|
||||
## Authentication Schemes
|
||||
|
||||
### 1. Environment Key (Flags API)
|
||||
|
||||
- Delivered via `X-Environment-Key` header
|
||||
- Public — safe for client-side exposure
|
||||
- Identifies which environment to serve flags for
|
||||
- No user identity implied
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
// MicCheck.Api/Authentication/EnvironmentKeyAuthenticationHandler.cs
|
||||
public class EnvironmentKeyAuthenticationHandler
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
// Reads X-Environment-Key header
|
||||
// Looks up Environment from DB by ApiKey value
|
||||
// Sets ClaimsPrincipal with EnvironmentId claim
|
||||
// Scheme name: "EnvironmentKey"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Organisation API Key (Admin API)
|
||||
|
||||
- Delivered via `Authorization: Api-Key <TOKEN>` header
|
||||
- Secret — never expose client-side
|
||||
- Identifies the organization and grants admin access
|
||||
- Stored hashed in DB; prefix shown for identification
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
// MicCheck.Api/Authentication/ApiKeyAuthenticationHandler.cs
|
||||
public class ApiKeyAuthenticationHandler
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
// Parses "Api-Key <TOKEN>" from Authorization header
|
||||
// Hashes the provided token
|
||||
// Looks up ApiKey record by hash
|
||||
// Validates IsActive and ExpiresAt
|
||||
// Sets ClaimsPrincipal with OrganizationId and UserId claims
|
||||
// Scheme name: "ApiKey"
|
||||
}
|
||||
```
|
||||
|
||||
**API key hashing:**
|
||||
- Use `SHA256` for hashing stored keys
|
||||
- Store prefix (first 8 chars) for display/identification only
|
||||
- Generate new keys with `RandomNumberGenerator.GetBytes(32)` → Base64URL encoded
|
||||
|
||||
### 3. JWT (Dashboard Users)
|
||||
|
||||
- Standard bearer token authentication
|
||||
- Used for the admin web UI
|
||||
- Short-lived access token + refresh token pattern
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
builder.Services.AddAuthentication()
|
||||
.AddJwtBearer("Bearer", options => {
|
||||
options.TokenValidationParameters = new() {
|
||||
ValidIssuer = config["Jwt:Issuer"],
|
||||
ValidAudience = config["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(config["Jwt:Secret"]!))
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authorization Policies
|
||||
|
||||
Define named policies in `MicCheck.Api/Authorization/`:
|
||||
|
||||
```csharp
|
||||
// Policies
|
||||
public static class AuthorizationPolicies
|
||||
{
|
||||
public const string FlagsApiAccess = "FlagsApiAccess";
|
||||
public const string AdminApiAccess = "AdminApiAccess";
|
||||
public const string OrganizationAdmin = "OrganizationAdmin";
|
||||
}
|
||||
```
|
||||
|
||||
**Policy registrations:**
|
||||
```csharp
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
|
||||
policy.AddAuthenticationSchemes("EnvironmentKey")
|
||||
.RequireClaim("EnvironmentId"));
|
||||
|
||||
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
|
||||
policy.AddAuthenticationSchemes("ApiKey", "Bearer")
|
||||
.RequireAuthenticatedUser());
|
||||
|
||||
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
|
||||
policy.AddAuthenticationSchemes("ApiKey", "Bearer")
|
||||
.RequireClaim("OrganizationRole", "Admin"));
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role-Based Access Control (RBAC)
|
||||
|
||||
### Organization Roles
|
||||
- `Admin` — full access to all projects and settings
|
||||
- `User` — access to assigned projects only
|
||||
|
||||
### Project-Level Permissions
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Authorization/ProjectPermission.cs
|
||||
public enum ProjectPermission
|
||||
{
|
||||
ViewProject,
|
||||
CreateFeature, EditFeature, DeleteFeature,
|
||||
CreateEnvironment, EditEnvironment, DeleteEnvironment,
|
||||
CreateSegment, EditSegment, DeleteSegment,
|
||||
ManageWebhooks,
|
||||
ViewAuditLog
|
||||
}
|
||||
|
||||
// MicCheck.Api/Authorization/UserProjectPermission.cs
|
||||
public class UserProjectPermission
|
||||
{
|
||||
public int UserId { get; init; }
|
||||
public int ProjectId { get; init; }
|
||||
public ICollection<ProjectPermission> Permissions { get; init; } = [];
|
||||
public bool IsAdmin { get; set; } // grants all permissions
|
||||
}
|
||||
```
|
||||
|
||||
### Authorization requirement handler:
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Authorization/ProjectPermissionRequirementHandler.cs
|
||||
// Checks UserProjectPermission for the current user and requested project
|
||||
// Organization admins bypass all project-level checks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Model
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Users/User.cs
|
||||
public class User
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Email { get; set; }
|
||||
public required string PasswordHash { get; set; }
|
||||
public required string FirstName { get; set; }
|
||||
public required string LastName { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsTwoFactorEnabled { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
public ICollection<OrganizationUser> Organizations { get; init; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
**Password hashing:** Use ASP.NET Core's `IPasswordHasher<User>`.
|
||||
|
||||
---
|
||||
|
||||
## Auth Endpoints
|
||||
|
||||
```
|
||||
POST /api/v1/auth/login → Returns JWT access + refresh tokens
|
||||
POST /api/v1/auth/refresh → Exchanges refresh token for new access token
|
||||
POST /api/v1/auth/logout → Revokes refresh token
|
||||
POST /api/v1/auth/register → Creates new user + organization
|
||||
|
||||
POST /api/v1/organisations/{id}/api-keys/ → Create new API key
|
||||
GET /api/v1/organisations/{id}/api-keys/ → List API keys (prefix only)
|
||||
DELETE /api/v1/organisations/{id}/api-keys/{keyId}/ → Revoke key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Apply to Admin API:
|
||||
- 500 requests per minute per API key
|
||||
- Use ASP.NET Core's built-in rate limiting middleware (`Microsoft.AspNetCore.RateLimiting`)
|
||||
|
||||
```csharp
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
options.AddFixedWindowLimiter("AdminApi", limiter => {
|
||||
limiter.PermitLimit = 500;
|
||||
limiter.Window = TimeSpan.FromMinutes(1);
|
||||
limiter.QueueLimit = 0;
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `X-Environment-Key` header resolves to correct environment; invalid key returns 401
|
||||
- [ ] `Authorization: Api-Key <TOKEN>` validates against hashed DB record; invalid key returns 401
|
||||
- [ ] JWT tokens are issued and validated correctly
|
||||
- [ ] Organization admins have access to all project operations
|
||||
- [ ] Project-level permission checks block unauthorized users with 403
|
||||
- [ ] Rate limit returns 429 after 500 Admin API requests within a minute
|
||||
- [ ] Unit tests cover: key hashing, auth handler success/failure paths, permission checks
|
||||
264
docs/05_flags_api.md
Normal file
264
docs/05_flags_api.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Step 5: Flags API (Public SDK Endpoint)
|
||||
|
||||
## Goal
|
||||
Implement the public Flags API — the endpoints consumed by SDK clients to retrieve feature flags and identify users. This is the high-traffic, read-heavy API authenticated via `X-Environment-Key`.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
All routes prefixed with `/api/v1/` and authenticated with the `EnvironmentKey` scheme.
|
||||
|
||||
### GET /api/v1/flags/
|
||||
Returns all enabled feature flags for the resolved environment.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"feature": {
|
||||
"id": 1,
|
||||
"name": "dark_mode",
|
||||
"type": "STANDARD"
|
||||
},
|
||||
"enabled": true,
|
||||
"feature_state_value": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### GET /api/v1/identities/?identifier=user123
|
||||
Returns flags for a specific user identity, including segment overrides and identity-level overrides.
|
||||
|
||||
### POST /api/v1/identities/
|
||||
Identifies a user, optionally sets traits, and returns flags.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"identifier": "user123",
|
||||
"traits": [
|
||||
{ "trait_key": "plan", "trait_value": "premium" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"traits": [...],
|
||||
"flags": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/environment-document/
|
||||
Returns the full environment configuration for **local evaluation mode**.
|
||||
Used by server-side SDKs to pull all flags, segments, and targeting rules locally.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"api_key": "env-key-xxx",
|
||||
"feature_states": [...],
|
||||
"project": {
|
||||
"id": 1,
|
||||
"name": "My Project",
|
||||
"segments": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature Evaluation Service
|
||||
|
||||
Lives in `MicCheck.Api/Features/`. Injects `MicCheckDbContext` and returns domain objects — no DTOs.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeatureEvaluationService.cs
|
||||
public class FeatureEvaluationService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly SegmentEvaluator _segmentEvaluator;
|
||||
private readonly FlagCache _flagCache;
|
||||
|
||||
// Resolves the effective FeatureState for a given environment
|
||||
// Priority order (highest wins):
|
||||
// 1. Identity-level override
|
||||
// 2. Segment override (highest priority segment that matches)
|
||||
// 3. Environment default
|
||||
|
||||
public Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
|
||||
int environmentId, CancellationToken ct = default);
|
||||
|
||||
public Task<IReadOnlyList<FeatureStateResult>> EvaluateForIdentityAsync(
|
||||
int environmentId,
|
||||
string identifier,
|
||||
IReadOnlyList<TraitInput>? traits = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Identity Resolution Service
|
||||
|
||||
Lives in `MicCheck.Api/Identities/`. Injects `MicCheckDbContext` and returns domain objects.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Identities/IdentityResolutionService.cs
|
||||
public class IdentityResolutionService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
|
||||
// 1. Look up Identity by (EnvironmentId, Identifier) — create if not found
|
||||
// 2. Upsert provided traits by key
|
||||
// 3. Return the resolved Identity with current traits loaded
|
||||
public Task<Identity> ResolveAsync(
|
||||
int environmentId,
|
||||
string identifier,
|
||||
IReadOnlyList<TraitInput>? traits,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Segment Evaluator
|
||||
|
||||
Lives in `MicCheck.Api/Segments/`. Pure logic — no DB access (data is preloaded by callers).
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Segments/SegmentEvaluator.cs
|
||||
public class SegmentEvaluator
|
||||
{
|
||||
public bool Evaluate(Segment segment, IReadOnlyList<IdentityTrait> traits);
|
||||
|
||||
private bool EvaluateRule(SegmentRule rule, IReadOnlyList<IdentityTrait> traits);
|
||||
private bool EvaluateCondition(SegmentCondition condition, IReadOnlyList<IdentityTrait> traits);
|
||||
}
|
||||
```
|
||||
|
||||
**Operator implementations** (pure functions):
|
||||
- String: Equal, NotEqual, Contains, NotContains, Regex
|
||||
- Numeric: GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
|
||||
- Boolean: IsTrue, IsFalse
|
||||
- Membership: In, NotIn
|
||||
- Existence: IsSet, IsNotSet
|
||||
- Sampling: PercentageSplit (deterministic by identity+segment hash)
|
||||
- Modulo: ModuloValueDivisorRemainder
|
||||
|
||||
---
|
||||
|
||||
## Request/Response DTOs
|
||||
|
||||
Live alongside their controllers in the relevant feature folder. DTOs are only constructed in controller action methods — services never return or accept DTOs.
|
||||
|
||||
```csharp
|
||||
// Features/FlagResponse.cs
|
||||
public record FlagResponse(
|
||||
int Id,
|
||||
FeatureSummaryResponse Feature,
|
||||
bool Enabled,
|
||||
string? FeatureStateValue
|
||||
)
|
||||
{
|
||||
public static FlagResponse From(FeatureStateResult result) => new(
|
||||
result.Feature.Id, // mapping happens here, in the response type, called from the controller
|
||||
FeatureSummaryResponse.From(result.Feature),
|
||||
result.Enabled,
|
||||
result.Value);
|
||||
}
|
||||
|
||||
// Features/FeatureSummaryResponse.cs
|
||||
public record FeatureSummaryResponse(int Id, string Name, string Type)
|
||||
{
|
||||
public static FeatureSummaryResponse From(Feature feature) =>
|
||||
new(feature.Id, feature.Name, feature.Type.ToString().ToUpperInvariant());
|
||||
}
|
||||
|
||||
// Identities/IdentityRequest.cs
|
||||
public record IdentityRequest(
|
||||
string Identifier,
|
||||
IReadOnlyList<TraitInput>? Traits
|
||||
);
|
||||
|
||||
// Identities/TraitInput.cs
|
||||
public record TraitInput(
|
||||
string TraitKey,
|
||||
object? TraitValue // string | int | float | bool
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controllers
|
||||
|
||||
Controllers receive a request DTO, call a service with domain-level parameters, then map the domain result to a response DTO before returning.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FlagsController.cs
|
||||
[ApiController]
|
||||
[Route("api/v1/flags")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class FlagsController : ControllerBase
|
||||
{
|
||||
private readonly FeatureEvaluationService _featureEvaluationService;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<FlagResponse>>> GetAll(CancellationToken ct)
|
||||
{
|
||||
var environmentId = int.Parse(User.FindFirstValue("EnvironmentId")!);
|
||||
var results = await _featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct);
|
||||
return Ok(results.Select(FlagResponse.From).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
// MicCheck.Api/Identities/IdentitiesController.cs
|
||||
[ApiController]
|
||||
[Route("api/v1/identities")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class IdentitiesController : ControllerBase { ... }
|
||||
|
||||
// MicCheck.Api/Environments/EnvironmentDocumentController.cs
|
||||
[ApiController]
|
||||
[Route("api/v1/environment-document")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class EnvironmentDocumentController : ControllerBase { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Caching
|
||||
|
||||
Lives in `MicCheck.Api/Features/`. Cache stores domain results, not serialized responses.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FlagCache.cs
|
||||
public class FlagCache
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public IReadOnlyList<FeatureStateResult>? Get(int environmentId);
|
||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags);
|
||||
public void Invalidate(int environmentId);
|
||||
}
|
||||
```
|
||||
|
||||
Cache is invalidated inside `FeatureEvaluationService` whenever a `FeatureState` is modified. Designed to swap the backing store for Redis without changing the `FlagCache` interface.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `GET /api/v1/flags/` returns correct flags for a valid environment key
|
||||
- [ ] `GET /api/v1/flags/` returns 401 for missing or invalid environment key
|
||||
- [ ] `POST /api/v1/identities/` creates identity + upserts traits + returns correct flags
|
||||
- [ ] Segment override takes priority over environment default
|
||||
- [ ] Identity override takes priority over segment override
|
||||
- [ ] `GET /api/v1/environment-document/` returns full environment config
|
||||
- [ ] Controllers never pass DTOs into services — only primitive/domain values
|
||||
- [ ] `SegmentEvaluator` unit tests cover all operator types
|
||||
- [ ] `FeatureEvaluationService` unit tests cover all three priority tiers
|
||||
- [ ] `PercentageSplit` distributes identities deterministically and uniformly
|
||||
325
docs/06_admin_api.md
Normal file
325
docs/06_admin_api.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Step 6: Admin API (Management Endpoints)
|
||||
|
||||
## Goal
|
||||
Implement the Admin API — the authenticated management API for creating and managing projects, environments, features, segments, users, and API keys. Authenticated via `Authorization: Api-Key <TOKEN>` or JWT bearer token.
|
||||
|
||||
---
|
||||
|
||||
## API Versioning
|
||||
|
||||
```csharp
|
||||
builder.Services.AddApiVersioning(options =>
|
||||
{
|
||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
options.AssumeDefaultVersionWhenUnspecified = true;
|
||||
options.ReportApiVersions = true;
|
||||
});
|
||||
```
|
||||
|
||||
Routes:
|
||||
- Current: `/api/v1/`
|
||||
- Future: `/api/v2/`
|
||||
|
||||
All Admin API controllers:
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Groups
|
||||
|
||||
### Organizations
|
||||
|
||||
```
|
||||
GET /api/v1/organisations/
|
||||
POST /api/v1/organisations/
|
||||
GET /api/v1/organisations/{id}/
|
||||
PUT /api/v1/organisations/{id}/
|
||||
DELETE /api/v1/organisations/{id}/
|
||||
GET /api/v1/organisations/{id}/users/
|
||||
POST /api/v1/organisations/{id}/users/invite/
|
||||
DELETE /api/v1/organisations/{id}/users/{userId}/
|
||||
```
|
||||
|
||||
### Projects
|
||||
|
||||
```
|
||||
GET /api/v1/projects/
|
||||
POST /api/v1/projects/
|
||||
GET /api/v1/projects/{id}/
|
||||
PUT /api/v1/projects/{id}/
|
||||
DELETE /api/v1/projects/{id}/
|
||||
GET /api/v1/projects/{id}/user-permissions/
|
||||
POST /api/v1/projects/{id}/user-permissions/
|
||||
PUT /api/v1/projects/{id}/user-permissions/{userId}/
|
||||
DELETE /api/v1/projects/{id}/user-permissions/{userId}/
|
||||
```
|
||||
|
||||
### Environments
|
||||
|
||||
```
|
||||
GET /api/v1/environments/
|
||||
POST /api/v1/environments/
|
||||
GET /api/v1/environments/{apiKey}/
|
||||
PUT /api/v1/environments/{apiKey}/
|
||||
DELETE /api/v1/environments/{apiKey}/
|
||||
POST /api/v1/environments/{apiKey}/clone/
|
||||
GET /api/v1/environments/{apiKey}/webhooks/
|
||||
POST /api/v1/environments/{apiKey}/webhooks/
|
||||
PUT /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||
DELETE /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
```
|
||||
GET /api/v1/projects/{projectId}/features/
|
||||
POST /api/v1/projects/{projectId}/features/
|
||||
GET /api/v1/projects/{projectId}/features/{id}/
|
||||
PUT /api/v1/projects/{projectId}/features/{id}/
|
||||
PATCH /api/v1/projects/{projectId}/features/{id}/
|
||||
DELETE /api/v1/projects/{projectId}/features/{id}/
|
||||
```
|
||||
|
||||
### Feature States (Environment-level)
|
||||
|
||||
```
|
||||
GET /api/v1/environments/{apiKey}/featurestates/
|
||||
GET /api/v1/environments/{apiKey}/featurestates/{id}/
|
||||
PUT /api/v1/environments/{apiKey}/featurestates/{id}/
|
||||
PATCH /api/v1/environments/{apiKey}/featurestates/{id}/
|
||||
```
|
||||
|
||||
### Segments
|
||||
|
||||
```
|
||||
GET /api/v1/projects/{projectId}/segments/
|
||||
POST /api/v1/projects/{projectId}/segments/
|
||||
GET /api/v1/projects/{projectId}/segments/{id}/
|
||||
PUT /api/v1/projects/{projectId}/segments/{id}/
|
||||
DELETE /api/v1/projects/{projectId}/segments/{id}/
|
||||
```
|
||||
|
||||
### Identities (Admin view)
|
||||
|
||||
```
|
||||
GET /api/v1/environments/{apiKey}/identities/
|
||||
GET /api/v1/environments/{apiKey}/identities/{id}/
|
||||
DELETE /api/v1/environments/{apiKey}/identities/{id}/
|
||||
GET /api/v1/environments/{apiKey}/identities/{id}/featurestates/
|
||||
PUT /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/
|
||||
DELETE /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/
|
||||
```
|
||||
|
||||
### Audit Logs
|
||||
|
||||
```
|
||||
GET /api/v1/organisations/{id}/audit-logs/
|
||||
GET /api/v1/projects/{projectId}/audit-logs/
|
||||
GET /api/v1/environments/{apiKey}/audit-logs/
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
```
|
||||
GET /api/v1/projects/{projectId}/tags/
|
||||
POST /api/v1/projects/{projectId}/tags/
|
||||
DELETE /api/v1/projects/{projectId}/tags/{id}/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Layer
|
||||
|
||||
Each feature area has a service class in its folder within `MicCheck.Api`. Services accept and return domain objects — they have no knowledge of request/response DTOs. Services inject `MicCheckDbContext` directly.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeatureService.cs
|
||||
public class FeatureService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
// Enforces: max 400 features per project
|
||||
// On create: auto-creates FeatureState records for all existing environments
|
||||
public Task<Feature> CreateAsync(int projectId, string name, FeatureType type,
|
||||
string? initialValue, string? description, CancellationToken ct = default);
|
||||
|
||||
public Task<Feature> UpdateAsync(int id, string name, string? description,
|
||||
CancellationToken ct = default);
|
||||
|
||||
public Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
|
||||
public Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default);
|
||||
|
||||
public Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// MicCheck.Api/Environments/EnvironmentService.cs
|
||||
public class EnvironmentService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
// On create: auto-creates FeatureState records for all existing features
|
||||
public Task<Environment> CreateAsync(int projectId, string name, CancellationToken ct = default);
|
||||
|
||||
// Clone: duplicates all environment-level FeatureStates; not identity/segment overrides
|
||||
public Task<Environment> CloneAsync(string sourceApiKey, string newName, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// MicCheck.Api/Segments/SegmentService.cs
|
||||
public class SegmentService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
// Enforces: max 100 segments per project
|
||||
// Enforces: max 100 segment rule conditions
|
||||
public Task<Segment> CreateAsync(int projectId, string name,
|
||||
IReadOnlyList<SegmentRuleDefinition> rules, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controller Pattern
|
||||
|
||||
Controllers are responsible for:
|
||||
1. Receiving the request DTO
|
||||
2. Validating it (FluentValidation runs automatically via middleware)
|
||||
3. Calling the appropriate service with extracted domain-level values
|
||||
4. Mapping the returned domain object(s) to a response DTO
|
||||
5. Returning the HTTP response
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeaturesController.cs
|
||||
[ApiController]
|
||||
[Route("api/v1/projects/{projectId}/features")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class FeaturesController : ControllerBase
|
||||
{
|
||||
private readonly FeatureService _featureService;
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<FeatureResponse>> Create(
|
||||
int projectId, CreateFeatureRequest request, CancellationToken ct)
|
||||
{
|
||||
var feature = await _featureService.CreateAsync(
|
||||
projectId, request.Name, request.Type, request.InitialValue, request.Description, ct);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id },
|
||||
FeatureResponse.From(feature));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct)
|
||||
{
|
||||
var feature = await _featureService.FindByIdAsync(id, ct);
|
||||
if (feature is null) return NotFound();
|
||||
return Ok(FeatureResponse.From(feature));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Validation
|
||||
|
||||
Use FluentValidation for all incoming request DTOs. Validators live in the same folder as the DTO they validate.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/CreateFeatureRequest.cs
|
||||
public record CreateFeatureRequest(
|
||||
string Name,
|
||||
FeatureType Type,
|
||||
string? InitialValue,
|
||||
string? Description
|
||||
);
|
||||
|
||||
// MicCheck.Api/Features/CreateFeatureRequestValidator.cs
|
||||
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
|
||||
{
|
||||
public CreateFeatureRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(150)
|
||||
.Matches("^[a-zA-Z0-9_-]+$");
|
||||
|
||||
RuleFor(x => x.InitialValue)
|
||||
.MaximumLength(20_000)
|
||||
.When(x => x.InitialValue is not null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response DTOs
|
||||
|
||||
Live in the same folder as their domain entity. The static `From` factory is the only mapping point.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeatureResponse.cs
|
||||
public record FeatureResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Type,
|
||||
string? InitialValue,
|
||||
string? Description,
|
||||
bool DefaultEnabled,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
public static FeatureResponse From(Feature feature) => new(
|
||||
feature.Id, feature.Name,
|
||||
feature.Type.ToString().ToUpperInvariant(),
|
||||
feature.InitialValue, feature.Description,
|
||||
feature.DefaultEnabled, feature.CreatedAt);
|
||||
}
|
||||
```
|
||||
|
||||
Pagination wrapper for list endpoints:
|
||||
```csharp
|
||||
// MicCheck.Api/PaginatedResponse.cs
|
||||
public record PaginatedResponse<T>(
|
||||
int Count,
|
||||
string? Next,
|
||||
string? Previous,
|
||||
IReadOnlyList<T> Results
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Cloning
|
||||
|
||||
When cloning an environment:
|
||||
1. Create new `Environment` record with a fresh `ApiKey`
|
||||
2. Copy all environment-level `FeatureState` records from the source environment
|
||||
3. Do NOT copy identity-level overrides
|
||||
4. Do NOT copy segment overrides (those follow segment definitions)
|
||||
5. Emit an audit log entry
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Full CRUD for organizations, projects, environments, features, segments
|
||||
- [ ] Creating a feature auto-creates `FeatureState` for all project environments
|
||||
- [ ] Creating an environment auto-creates `FeatureState` for all project features
|
||||
- [ ] 400 feature limit enforced (returns 400 with descriptive error)
|
||||
- [ ] 100 segment limit enforced
|
||||
- [ ] 100 segment condition limit enforced
|
||||
- [ ] FluentValidation errors return 422 with field-level detail
|
||||
- [ ] Environment clone creates independent copy of feature states
|
||||
- [ ] All endpoints return 401 for missing credentials, 403 for insufficient permissions
|
||||
- [ ] Paginated list endpoints default to page size 20, max 100
|
||||
- [ ] Controllers never call `_db` directly — all data access goes through a service
|
||||
- [ ] Services never reference request/response DTO types
|
||||
- [ ] Unit tests for all service methods covering success paths and constraint violations
|
||||
249
docs/07_audit_and_webhooks.md
Normal file
249
docs/07_audit_and_webhooks.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Step 7: Audit Logging & Webhooks
|
||||
|
||||
## Goal
|
||||
Implement the audit trail system (recording all changes to flags, environments, segments, etc.) and the webhook system (notifying external systems of flag state changes).
|
||||
|
||||
---
|
||||
|
||||
## Audit Logging
|
||||
|
||||
### What Gets Audited
|
||||
|
||||
Every create, update, and delete operation on:
|
||||
- Features and FeatureStates
|
||||
- Environments
|
||||
- Segments
|
||||
- Identities (admin operations)
|
||||
- API Keys (create/revoke)
|
||||
- User permissions
|
||||
- Organization settings
|
||||
- Project settings
|
||||
|
||||
### AuditLog Domain Model
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Audit/AuditLog.cs (already defined in Step 2)
|
||||
// Key fields:
|
||||
// ResourceType: "Feature", "FeatureState", "Environment", etc.
|
||||
// ResourceId: string representation of the entity's PK
|
||||
// Action: "Created", "Updated", "Deleted"
|
||||
// Changes: JSON diff of before/after values (for Updates)
|
||||
// ActorUserId: null for API key operations without user context
|
||||
```
|
||||
|
||||
### Audit Service
|
||||
|
||||
Lives in `MicCheck.Api/Audit/`. Injects `MicCheckDbContext` directly.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Audit/AuditService.cs
|
||||
public class AuditService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
|
||||
public Task RecordAsync(
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
string action,
|
||||
int organizationId,
|
||||
int? projectId = null,
|
||||
int? environmentId = null,
|
||||
int? actorUserId = null,
|
||||
object? before = null,
|
||||
object? after = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
**Change diffing:**
|
||||
- Serialize `before` and `after` objects to JSON
|
||||
- Store both in `Changes` as `{ "before": {...}, "after": {...} }`
|
||||
- Use `System.Text.Json` for serialization
|
||||
|
||||
### Integration with Services
|
||||
|
||||
Audit logging is called directly from other service methods:
|
||||
|
||||
```csharp
|
||||
// In FeatureService.CreateAsync:
|
||||
await _auditService.RecordAsync(
|
||||
resourceType: "Feature",
|
||||
resourceId: feature.Id.ToString(),
|
||||
action: "Created",
|
||||
organizationId: project.OrganizationId,
|
||||
projectId: feature.ProjectId,
|
||||
after: feature);
|
||||
```
|
||||
|
||||
### Audit Log Queries
|
||||
|
||||
`AuditService` exposes a query method returning domain objects; the controller maps to response DTOs.
|
||||
|
||||
Support filtering by:
|
||||
- Date range (`from`, `to`)
|
||||
- Resource type
|
||||
- Action type
|
||||
- Project
|
||||
- Environment
|
||||
- Actor user
|
||||
|
||||
Always ordered by `CreatedAt DESC`. Paginated (default 20, max 100).
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Audit/AuditController.cs
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> List(
|
||||
int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
var (logs, total) = await _auditService.ListAsync(id, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(
|
||||
total, null, null,
|
||||
logs.Select(AuditLogResponse.From).ToList()));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
### Webhook Triggers
|
||||
|
||||
Webhooks fire on:
|
||||
- `FLAG_UPDATED` — any `FeatureState` change
|
||||
- `FLAG_DELETED` — feature deleted
|
||||
- `AUDIT_LOG_CREATED` — any audit log entry (organization webhooks)
|
||||
|
||||
### Webhook Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"changed_by": "user@example.com",
|
||||
"timestamp": "2026-04-06T12:00:00Z",
|
||||
"new_state": {
|
||||
"id": 42,
|
||||
"enabled": true,
|
||||
"feature_state_value": null,
|
||||
"feature": { "id": 1, "name": "dark_mode" },
|
||||
"environment": { "id": 5, "name": "Production" }
|
||||
},
|
||||
"previous_state": { ... }
|
||||
},
|
||||
"event_type": "FLAG_UPDATED"
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Event & Dispatcher
|
||||
|
||||
Live in `MicCheck.Api/Webhooks/`. Dispatcher injects `MicCheckDbContext` directly.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Webhooks/WebhookEvent.cs
|
||||
public class WebhookEvent
|
||||
{
|
||||
public required string EventType { get; init; }
|
||||
public required object Data { get; init; }
|
||||
public int? EnvironmentId { get; init; }
|
||||
public int OrganizationId { get; init; }
|
||||
}
|
||||
|
||||
// MicCheck.Api/Webhooks/WebhookDispatcher.cs
|
||||
public class WebhookDispatcher
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
// Finds all active webhooks for the environment (and org-level)
|
||||
// Serializes payload to JSON
|
||||
// Signs payload with HMAC-SHA256 using webhook secret
|
||||
// POSTs to webhook URL with timeout of 10 seconds
|
||||
// Stores delivery result in WebhookDeliveryLog
|
||||
public Task DispatchAsync(WebhookEvent webhookEvent, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Signing
|
||||
|
||||
```csharp
|
||||
// HMAC-SHA256 signature in header:
|
||||
// X-Flagsmith-Signature: sha256=<hex_digest>
|
||||
|
||||
private static string ComputeSignature(string secret, string payload)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(secret);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var hashBytes = HMACSHA256.HashData(keyBytes, payloadBytes);
|
||||
return "sha256=" + Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Queue (Async Dispatch)
|
||||
|
||||
Lives in `MicCheck.Api/Webhooks/`. Uses `System.Threading.Channels` so dispatch does not block the API response.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Webhooks/WebhookQueue.cs
|
||||
public class WebhookQueue
|
||||
{
|
||||
private readonly Channel<WebhookEvent> _channel = Channel.CreateUnbounded<WebhookEvent>();
|
||||
|
||||
public ValueTask EnqueueAsync(WebhookEvent webhookEvent) =>
|
||||
_channel.Writer.WriteAsync(webhookEvent);
|
||||
|
||||
public IAsyncEnumerable<WebhookEvent> ReadAllAsync(CancellationToken ct) =>
|
||||
_channel.Reader.ReadAllAsync(ct);
|
||||
}
|
||||
```
|
||||
|
||||
### Retry Background Service
|
||||
|
||||
Lives in `MicCheck.Infrastructure/Webhooks/`. Polls `WebhookDeliveryLog` and retries failures.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Infrastructure/Webhooks/WebhookRetryBackgroundService.cs
|
||||
public class WebhookRetryBackgroundService : BackgroundService
|
||||
{
|
||||
// Polls WebhookDeliveryLog for failed deliveries within retry window
|
||||
// Up to 3 attempts: immediate, +5 min, +30 min
|
||||
// Reattempts via WebhookDispatcher
|
||||
}
|
||||
```
|
||||
|
||||
### Retry Strategy
|
||||
|
||||
- Up to 3 delivery attempts (immediate, +5 min, +30 min)
|
||||
- Failed deliveries are logged but do not block the API response
|
||||
|
||||
---
|
||||
|
||||
## Webhook Admin Endpoints
|
||||
|
||||
Controllers map domain objects to/from DTOs in the action method, calling `WebhookService` for all data access.
|
||||
|
||||
```
|
||||
GET /api/v1/environments/{apiKey}/webhooks/
|
||||
POST /api/v1/environments/{apiKey}/webhooks/
|
||||
PUT /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||
DELETE /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||
GET /api/v1/environments/{apiKey}/webhooks/{id}/deliveries/
|
||||
|
||||
GET /api/v1/organisations/{id}/webhooks/
|
||||
POST /api/v1/organisations/{id}/webhooks/
|
||||
PUT /api/v1/organisations/{id}/webhooks/{id}/
|
||||
DELETE /api/v1/organisations/{id}/webhooks/{id}/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Every create/update/delete in the Admin API produces an `AuditLog` record
|
||||
- [ ] Audit logs correctly capture before/after state for updates
|
||||
- [ ] `GET /api/v1/organisations/{id}/audit-logs/` returns paginated, filtered results
|
||||
- [ ] Audit controllers map `AuditLog` domain objects to `AuditLogResponse` DTOs in the action
|
||||
- [ ] Webhooks fire within 1 second of a flag state change
|
||||
- [ ] HMAC-SHA256 signature is correct and verifiable by receivers
|
||||
- [ ] Failed webhook deliveries are logged with status code and response body
|
||||
- [ ] Retry logic attempts delivery 3 times with backoff
|
||||
- [ ] Webhook delivery does not block the API response (fire-and-forget via `WebhookQueue`)
|
||||
- [ ] Unit tests cover: signature computation, payload serialization, retry scheduling
|
||||
- [ ] Unit tests cover: audit diff generation for create/update/delete scenarios
|
||||
277
docs/08_testing_strategy.md
Normal file
277
docs/08_testing_strategy.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Step 8: Testing Strategy
|
||||
|
||||
## Goal
|
||||
Establish a comprehensive testing strategy that achieves high coverage of business logic, enforces BDD-style test naming, and validates the full request pipeline without touching external resources (database, file system, network).
|
||||
|
||||
---
|
||||
|
||||
## Test Project Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── MicCheck.Core.Tests/
|
||||
│ ├── Features/
|
||||
│ │ ├── FeatureEvaluationServiceTests.cs
|
||||
│ │ └── FeatureServiceTests.cs
|
||||
│ ├── Segments/
|
||||
│ │ └── SegmentEvaluatorTests.cs
|
||||
│ ├── Environments/
|
||||
│ │ └── EnvironmentServiceTests.cs
|
||||
│ ├── Identities/
|
||||
│ │ └── IdentityResolutionServiceTests.cs
|
||||
│ ├── Audit/
|
||||
│ │ └── AuditServiceTests.cs
|
||||
│ └── Webhooks/
|
||||
│ ├── WebhookDispatcherTests.cs
|
||||
│ └── WebhookSignatureTests.cs
|
||||
├── MicCheck.Api.Tests/
|
||||
│ ├── Flags/
|
||||
│ │ ├── FlagsControllerTests.cs
|
||||
│ │ └── IdentitiesControllerTests.cs
|
||||
│ ├── Admin/
|
||||
│ │ ├── FeaturesControllerTests.cs
|
||||
│ │ ├── EnvironmentsControllerTests.cs
|
||||
│ │ ├── SegmentsControllerTests.cs
|
||||
│ │ └── OrganizationsControllerTests.cs
|
||||
│ └── Authentication/
|
||||
│ ├── EnvironmentKeyAuthenticationHandlerTests.cs
|
||||
│ └── ApiKeyAuthenticationHandlerTests.cs
|
||||
└── MicCheck.Infrastructure.Tests/
|
||||
└── (integration tests — marked [Category("Integration")])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Convention (BDD Style)
|
||||
|
||||
```
|
||||
WhenA{Subject}_{Condition}_Then{ExpectedOutcome}
|
||||
|
||||
Examples:
|
||||
WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown
|
||||
WhenAnIdentityIsResolved_WithMatchingSegment_ThenSegmentFlagOverrideIsReturned
|
||||
WhenAWebhookIsDispatched_WithASecret_ThenPayloadIsSignedWithHmacSha256
|
||||
WhenAnEnvironmentKeyIsInvalid_ThenAuthenticationFails
|
||||
WhenAFlagIsUpdated_ThenAnAuditLogEntryIsCreated
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Unit Tests (Api — Services)
|
||||
|
||||
Services depend on `MicCheckDbContext`. Use an in-memory SQLite EF Core provider (or a real test-scoped PostgreSQL instance for integration tests) rather than mocking the DbContext. Mock only non-EF dependencies such as `AuditService` or `WebhookQueue`.
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class FeatureServiceTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
private Mock<AuditService> _auditService = null!;
|
||||
private FeatureService _sut = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
_auditService = new Mock<AuditService>();
|
||||
_sut = new FeatureService(_db, _auditService.Object);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
[Test]
|
||||
public void WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown()
|
||||
{
|
||||
var project = new Project { Name = "Test", OrganizationId = 1 };
|
||||
_db.Projects.Add(project);
|
||||
for (var i = 0; i < 400; i++)
|
||||
_db.Features.Add(new Feature { Name = $"flag_{i}", ProjectId = project.Id });
|
||||
_db.SaveChanges();
|
||||
|
||||
Assert.That(
|
||||
async () => await _sut.CreateAsync(project.Id, "new_flag", FeatureType.Standard, null, null),
|
||||
Throws.TypeOf<DomainException>());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Controller Tests
|
||||
|
||||
Use `WebApplicationFactory<Program>` with mocked services for HTTP-level tests:
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class FlagsControllerTests
|
||||
{
|
||||
private WebApplicationFactory<Program> _factory = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<IFeatureEvaluationService>(
|
||||
Mock.Of<IFeatureEvaluationService>());
|
||||
}));
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _factory.Dispose();
|
||||
|
||||
[Test]
|
||||
public async Task WhenGetFlagsIsCalledWithAValidEnvironmentKey_ThenFlagsAreReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Environment-Key", "valid-env-key");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/flags/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SegmentEvaluator Test Coverage
|
||||
|
||||
The `SegmentEvaluator` must have exhaustive unit tests for every operator:
|
||||
|
||||
The `SegmentEvaluator` takes no dependencies, so it is constructed directly with no mocking needed.
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class SegmentEvaluatorTests
|
||||
{
|
||||
private readonly SegmentEvaluator _sut = new();
|
||||
|
||||
[TestCase("premium", "premium", true)]
|
||||
[TestCase("premium", "free", false)]
|
||||
public void WhenEvaluatingEqualOperator_ThenResultMatchesExpectation(
|
||||
string traitValue, string conditionValue, bool expected) { ... }
|
||||
|
||||
[Test]
|
||||
public void WhenEvaluatingPercentageSplit_WithSameIdentifier_ThenResultIsDeterministic() { ... }
|
||||
|
||||
[Test]
|
||||
public void WhenEvaluatingPercentageSplit_WithZeroPercent_ThenNobodyMatches() { ... }
|
||||
|
||||
[Test]
|
||||
public void WhenEvaluatingPercentageSplit_WithHundredPercent_ThenEveryoneMatches() { ... }
|
||||
|
||||
[Test]
|
||||
public void WhenASegmentHasNestedRules_ThenAllRulesAreEvaluated() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature Evaluation Priority Tests
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class FeatureEvaluationServiceTests
|
||||
{
|
||||
[Test]
|
||||
public async Task WhenIdentityHasAnOverride_ThenIdentityOverrideTakesPriorityOverSegment() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentityMatchesSegment_ThenSegmentOverrideTakesPriorityOverEnvironmentDefault() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentityMatchesMultipleSegments_ThenHighestPrioritySegmentWins() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentityMatchesNoSegment_ThenEnvironmentDefaultIsReturned() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Tests
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class EnvironmentKeyAuthenticationHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyHeaderIsMissing_ThenAuthenticationFails() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsValid_ThenAuthenticationSucceedsWithEnvironmentIdClaim() { ... }
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
public class ApiKeyAuthenticationHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsRevoked_ThenAuthenticationFails() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsExpired_ThenAuthenticationFails() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsValid_ThenAuthenticationSucceedsWithOrganizationIdClaim() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Constraint Tests
|
||||
|
||||
```csharp
|
||||
// For each enforced constraint, there must be a test:
|
||||
WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown
|
||||
WhenASegmentIsCreated_AndProjectHasReachedMaxSegments_ThenAnExceptionIsThrown
|
||||
WhenASegmentConditionIsAdded_AndRuleHasReachedMaxConditions_ThenAnExceptionIsThrown
|
||||
WhenAFeatureStateValueExceedsMaxBytes_ThenValidationFails
|
||||
WhenATraitValueExceedsMaxBytes_ThenValidationFails
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Coverage Target
|
||||
|
||||
- `MicCheck.Api` services and domain logic: 90%+ line coverage
|
||||
- `MicCheck.Api` controllers and auth handlers: 80%+ line coverage
|
||||
- All new code must include corresponding tests per CLAUDE.md requirements
|
||||
|
||||
Run coverage via:
|
||||
```bash
|
||||
dotnet test --collect:"XPlat Code Coverage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI Test Execution
|
||||
|
||||
Tests are separated by NUnit category:
|
||||
```bash
|
||||
# Run all unit tests (fast, no external dependencies):
|
||||
dotnet test --filter "Category!=Integration"
|
||||
|
||||
# Run integration tests (requires database):
|
||||
dotnet test --filter "Category=Integration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All tests use BDD-style names (`WhenA{Subject}_{Condition}_Then{Outcome}`)
|
||||
- [ ] No mocked objects named with the word "Mock" (use descriptive names or `_sut`)
|
||||
- [ ] No Arrange/Act/Assert comments in test code
|
||||
- [ ] `SegmentEvaluator` has tests for every `SegmentConditionOperator` value
|
||||
- [ ] Feature evaluation priority (identity > segment > environment) is tested explicitly
|
||||
- [ ] All domain constraint limits have corresponding tests
|
||||
- [ ] `dotnet test` passes cleanly with zero failures
|
||||
- [ ] Core project achieves 90%+ line coverage
|
||||
71
docs/admin/00-New-Website-output.md
Normal file
71
docs/admin/00-New-Website-output.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# MicCheck Admin SPA — Implementation Output
|
||||
|
||||
All source files have been created under `src/admin/`. Node.js/npm is not installed on this machine, so the build and tests could not be run. See setup instructions below.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Install Node.js (via nvm is recommended)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
source ~/.bashrc
|
||||
nvm install --lts
|
||||
|
||||
# Then from the admin directory:
|
||||
cd /home/james/src/mic-check/src/admin
|
||||
npm install
|
||||
npm run build # produces dist/
|
||||
npm test # runs 6 Jest tests
|
||||
```
|
||||
|
||||
Or install via your package manager:
|
||||
```bash
|
||||
sudo apt install nodejs npm # Ubuntu/Debian
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Created
|
||||
|
||||
### Config files (`src/admin/`)
|
||||
- `package.json` — scripts: `build`, `serve`, `test`; all deps pinned
|
||||
- `webpack.config.js` — vue-loader, ts-loader, sass-loader, VuetifyPlugin, HtmlWebpackPlugin
|
||||
- `tsconfig.json`, `jest.config.js`, `babel.config.js`
|
||||
|
||||
### Application (`src/admin/src/`)
|
||||
- `main.ts` / `App.vue` — entry point
|
||||
- `plugins/vuetify.ts` — Vuetify 3 with MDI icons + light theme
|
||||
- `router/index.ts` — routes for `/`, `/features`, `/environments`, `/projects`, `/profile`, `/settings`
|
||||
- `assets/logo.svg` — vector microphone on stand with sound-wave arcs
|
||||
- `components/AppHeader.vue` — logo + nav links (center) + John Doe profile + gear (right)
|
||||
- `components/AppLayout.vue` — collapsible side drawer + `v-main` content area
|
||||
- `views/` — 6 stub pages (Dashboard, Features, Environments, Projects, Profile, Settings)
|
||||
|
||||
### Tests (`src/admin/tests/`)
|
||||
- `setup.ts` — Vuetify global registration + ResizeObserver stub for jsdom
|
||||
- `__mocks__/styleMock.js` — stubs CSS/SCSS imports in Jest
|
||||
- `__mocks__/fileMock.js` — stubs SVG/image imports in Jest
|
||||
- `unit/AppHeader.spec.ts` — 6 tests covering all acceptance criteria
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Concern | Technology |
|
||||
|---|---|
|
||||
| Framework | Vue 3 + TypeScript |
|
||||
| UI components | Vuetify 3 |
|
||||
| Bundler | webpack 5 |
|
||||
| Tests | Jest 29 + @vue/test-utils 2 |
|
||||
| Routing | vue-router 4 |
|
||||
| Icons | @mdi/font (Material Design Icons) |
|
||||
|
||||
---
|
||||
|
||||
## npm Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `npm install` | Install all dependencies |
|
||||
| `npm run build` | Production webpack bundle → `dist/` |
|
||||
| `npm run serve` | Dev server on :8080 with HMR |
|
||||
| `npm test` | Run all Jest unit tests |
|
||||
18
docs/admin/00-New-Website.md
Normal file
18
docs/admin/00-New-Website.md
Normal file
@@ -0,0 +1,18 @@
|
||||
The admin website for MicCheck will be a single page application written in type script and vuejs. We need to create a basic dashboard style admin website to manage feature flags, projects and environments
|
||||
|
||||
## Requirements
|
||||
|
||||
1. Find a simple admin theme for use with Vuetify and create a basic vuejs spa using typescript for the components.
|
||||
2. Utilize npm for build, webpack for bundling, and jest for frontend unit tests
|
||||
3. Generate a vector-based logo for MicCheck that uses a Microphone on a stand and add that logo to the header of the site
|
||||
4. For main navigation add the following: Features, Environments, Projects
|
||||
5. In the upper right hand of the header, include a link to a user profile with the name John Doe and a profile icon that points to /profile, and a gear icon that points to /settings
|
||||
6. Ensure the project with build and tests will pass using npm commands
|
||||
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A new website has been created with a default theme and key elements created for header, navigation, project and settings
|
||||
- A vector image of a microphone has been created and added to the header
|
||||
- A main navigation area is displayed with placeholder links for the expected sections
|
||||
- The site will build successfully and tests run without error
|
||||
17
docs/api/00 - new-aspnet-project.md
Normal file
17
docs/api/00 - new-aspnet-project.md
Normal file
@@ -0,0 +1,17 @@
|
||||
The api project for MicCheck will contain the admin endpoints needed to manage features, environments, projects, settings and users. It will also contain the endpoints for checking if a flag is enabled and returning current state of a flag.
|
||||
|
||||
## Requirements
|
||||
1. Create a new asp.net project in /src/api/ called MicCheck.Api using .net 10 and asp.net webapi.
|
||||
2. Ensure the project is configured to use OpenAPI and Swagger to document all endpoints
|
||||
3. Create a GetToken endpoint that returns a JWT user token when requested
|
||||
4. Create a corresponding unit test project in /tests/api called MicCheck.Api.Tests.Unit and use the latest version of NUnit and Moq as the testing frameworks
|
||||
5. Create a .slnx solution file at the root of the project called MicCheck.slnx and create src and tests solution folders within it
|
||||
6. Add the MicCheck API and Unit tests projects to the MicCheck.slnx
|
||||
7. Ensure the solution builds and all tests pass.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A new asp.net project exists at /src/api
|
||||
- A corresponding unit test project exists with any code covered by unit test cases
|
||||
- A new solution file called MicCheck.slnx was create and builds successfully
|
||||
- Unit tests run without error
|
||||
77
docs/api/00-output.md
Normal file
77
docs/api/00-output.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# MicCheck.Api ASP.NET Project — Implementation Output
|
||||
|
||||
All source files have been created. The `dotnet` CLI is not on the PATH in the current shell session, so the build and tests could not be run. See setup instructions below.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# If dotnet is not on your PATH, install the SDK:
|
||||
wget https://dot.net/v1/dotnet-install.sh && bash dotnet-install.sh --channel 10.0
|
||||
source ~/.bashrc
|
||||
|
||||
# Then from the repo root:
|
||||
cd /home/james/src/mic-check
|
||||
dotnet build MicCheck.slnx # 0 errors expected
|
||||
dotnet test MicCheck.slnx # 7 tests expected to pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Created
|
||||
|
||||
### Solution (`/`)
|
||||
- `MicCheck.slnx` — XML solution file with `/src/` and `/tests/` solution folders
|
||||
|
||||
### API Project (`src/api/MicCheck.Api/`)
|
||||
- `MicCheck.Api.csproj` — `net10.0`, nullable enabled, LangVersion latest
|
||||
- `Program.cs` — OpenAPI + Scalar UI + JWT bearer auth + `ITokenService` DI registration
|
||||
- `appsettings.json` — JWT config (`SecretKey`, `Issuer`, `Audience`, `ExpiryMinutes`)
|
||||
- `appsettings.Development.json` — debug-level logging for development
|
||||
- `Auth/TokenRequest.cs` — `record TokenRequest(string Username, string Password)`
|
||||
- `Auth/TokenResponse.cs` — `record TokenResponse(string Token, DateTime ExpiresAt)`
|
||||
- `Auth/ITokenService.cs` — service interface
|
||||
- `Auth/TokenService.cs` — JWT generation using `System.IdentityModel.Tokens.Jwt`
|
||||
- `Auth/AuthEndpoints.cs` — `POST /auth/token`, 400 on empty credentials, 200 + token on success
|
||||
|
||||
### Test Project (`tests/api/MicCheck.Api.Tests.Unit/`)
|
||||
- `MicCheck.Api.Tests.Unit.csproj` — NUnit 4.5.1 + Moq 4.20.72 + `Microsoft.AspNetCore.Mvc.Testing`
|
||||
- `Auth/TokenServiceTests.cs` — 4 BDD tests directly against `TokenService`
|
||||
- `Auth/AuthEndpointsTests.cs` — 3 BDD tests via `WebApplicationFactory<Program>` with mocked `ITokenService`
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Concern | Technology |
|
||||
|---|---|
|
||||
| Framework | .NET 10 (`net10.0`) |
|
||||
| API style | ASP.NET Minimal API |
|
||||
| OpenAPI document | `Microsoft.AspNetCore.OpenApi` 10.0.5 |
|
||||
| OpenAPI UI | `Scalar.AspNetCore` 2.5.14 (available at `/scalar/v1` in Development) |
|
||||
| JWT | `System.IdentityModel.Tokens.Jwt` 8.16.0 + `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.5 |
|
||||
| Test framework | NUnit 4.5.1 + NUnit3TestAdapter 6.2.0 + Microsoft.NET.Test.Sdk 18.3.0 |
|
||||
| Mocking | Moq 4.20.72 |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `POST` | `/auth/token` | Returns a JWT for the provided username/password |
|
||||
| `GET` | `/openapi/v1.json` | OpenAPI document (Development only) |
|
||||
| `GET` | `/scalar/v1` | Scalar API UI (Development only) |
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Class | Test | Coverage |
|
||||
|---|---|---|
|
||||
| `TokenServiceTests` | `WhenAUsernameIsProvided_ThenATokenIsReturned` | Token is non-empty |
|
||||
| `TokenServiceTests` | `WhenATokenIsGenerated_ThenItContainsTheUsernameAsSubjectClaim` | JWT `sub` claim |
|
||||
| `TokenServiceTests` | `WhenATokenIsGenerated_ThenItHasAFutureExpiry` | Expiry is in the future |
|
||||
| `TokenServiceTests` | `WhenATokenIsGenerated_ThenItHasTheConfiguredIssuer` | JWT `iss` claim |
|
||||
| `AuthEndpointsTests` | `WhenUsernameIsEmpty_ThenBadRequestIsReturned` | 400 on empty username |
|
||||
| `AuthEndpointsTests` | `WhenPasswordIsEmpty_ThenBadRequestIsReturned` | 400 on empty password |
|
||||
| `AuthEndpointsTests` | `WhenValidCredentialsAreProvided_ThenOkIsReturnedWithToken` | 200 + token body |
|
||||
Reference in New Issue
Block a user