Adding Admin API

This commit is contained in:
2026-04-08 14:25:10 -07:00
parent 0ba076b650
commit b9a04df861
67 changed files with 3316 additions and 74 deletions

154
.editorconfig Normal file
View File

@@ -0,0 +1,154 @@
[*.{cs,vb}]
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_sort_system_directives_first = true
# Enforce private fields to start with an underscore
dotnet_naming_rule.private_fields_with_underscore.severity = suggestion
dotnet_naming_rule.private_fields_with_underscore.symbols = private_fields
dotnet_naming_rule.private_fields_with_underscore.style = underscore_prefix
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_fields.required_modifiers = instance
dotnet_naming_style.underscore_prefix.required_prefix = _
dotnet_naming_style.underscore_prefix.capitalization = camel_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_style_namespace_match_folder = true:suggestion
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf
max_line_length = 220
dotnet_style_readonly_field = true:suggestion
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
dotnet_style_allow_multiple_blank_lines_experimental = true:silent
dotnet_style_namespace_declarations = file_scoped:suggestion
dotnet_code_quality_unused_parameters = all:warning
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_qualification_for_field = false:warning
dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning
[*.cs]
csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = block_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
csharp_style_prefer_primary_constructors = false:warning
csharp_prefer_system_threading_lock = true:suggestion
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_throw_expression = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_indent_labels = one_less_than_current
csharp_space_around_binary_operators = before_and_after
csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
csharp_prefer_static_local_function = true:suggestion
csharp_prefer_static_anonymous_function = true:suggestion
csharp_style_prefer_readonly_struct = true:suggestion
csharp_style_prefer_readonly_struct_member = true:suggestion
csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
csharp_style_conditional_delegate_call = true:suggestion
csharp_style_prefer_switch_expression = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_extended_property_pattern = true:suggestion
csharp_style_var_for_built_in_types = false:silent
csharp_style_var_when_type_is_apparent = false:silent
csharp_style_var_elsewhere = false:silent
csharp_style_prefer_implicitly_typed_lambda_expression = true:suggestion
csharp_style_target_typed_new_expression = true:suggestion

View File

@@ -1,10 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/api/MicCheck.Api/MicCheck.Api.csproj" />
<Project Path="src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" />
<Project Path="tests/infrastructure/MicCheck.Infrastructure.Tests/MicCheck.Infrastructure.Tests.csproj" />
</Folder>
</Solution>

237
docs/06_admin_api_output.md Normal file
View File

@@ -0,0 +1,237 @@
# Step 6 Output: Admin API
## Summary
Implemented the Admin API — authenticated management endpoints for organizations, projects, environments, features, segments, identities, tags, webhooks, and audit logs. Authenticated via `Authorization: Api-Key <TOKEN>` or JWT bearer token.
---
## What Was Built
### Shared Utilities
**`MicCheck.Api/Common/DomainException.cs`**
- Custom exception for business rule violations (feature limit, segment limit, etc.)
- Caught in controllers and returned as `400 BadRequest`
**`MicCheck.Api/Common/PaginatedResponse.cs`**
- Generic `PaginatedResponse<T>(Count, Next, Previous, Results)` record used by all list endpoints
---
### Audit
**`MicCheck.Api/Audit/AuditService.cs`**
- `virtual LogAsync(resourceType, resourceId, action, organizationId, projectId?, environmentId?, changes?, ct)` — persists audit log entry, extracts actor user ID from `UserId` claim via `IHttpContextAccessor`
**`MicCheck.Api/Audit/AuditLogQueryService.cs`**
- `ListByOrganizationAsync`, `ListByProjectAsync`, `ListByEnvironmentAsync` — ordered by descending `CreatedAt`
**`MicCheck.Api/Audit/AuditLogResponse.cs`**
- Static `From(AuditLog)` factory
**`MicCheck.Api/Audit/AuditLogsController.cs`**
- `GET /api/v1/organisations/{id}/audit-logs`
- `GET /api/v1/projects/{projectId}/audit-logs`
- (Environment audit logs handled in `EnvironmentsController`)
---
### Organizations
**`MicCheck.Api/Organizations/OrganizationService.cs`**
- `ListForUserAsync(userId)` — returns orgs where user is a member
- `CreateAsync(name, creatorUserId)` — auto-adds creator as Admin member
- `UpdateAsync`, `DeleteAsync`, `FindByIdAsync`
- `ListMembersAsync`, `InviteUserAsync` (upsert), `RemoveMemberAsync`
**`MicCheck.Api/Organizations/OrganizationsController.cs`**
Routes:
- `GET /api/v1/organisations/` — paginated, filtered to calling user's orgs
- `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
**`MicCheck.Api/Projects/ProjectService.cs`**
- `ListByOrganizationAsync`, `FindByIdAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`
- `ListUserPermissionsAsync`, `SetUserPermissionsAsync` (upsert), `RemoveUserPermissionsAsync`
**`MicCheck.Api/Projects/ProjectsController.cs`**
Routes:
- `GET /api/v1/projects/?organizationId=`
- `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
**`MicCheck.Api/Environments/EnvironmentService.cs`**
- `CreateAsync` — generates a random API key via `ApiKeyHasher.GenerateKey()`, auto-creates `FeatureState` for all existing features
- `CloneAsync` — duplicates environment-level `FeatureState` records only (not identity or segment overrides)
- `UpdateAsync`, `DeleteAsync`, `FindByApiKeyAsync`, `ListByProjectAsync`
**`MicCheck.Api/Environments/EnvironmentsController.cs`**
Routes:
- `GET /api/v1/environments/?projectId=`
- `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}/`
- `GET /api/v1/environments/{apiKey}/audit-logs/`
- `GET /api/v1/environments/{apiKey}/identities/` (paginated)
- `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}/`
- `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}/`
---
### Features
**`MicCheck.Api/Features/FeatureService.cs`**
- `CreateAsync` — enforces 400-feature limit (`DomainException`), auto-creates `FeatureState` for all project environments
- `UpdateAsync`, `DeleteAsync`, `FindByIdAsync`, `ListByProjectAsync`
**`MicCheck.Api/Features/FeatureStateService.cs`**
- `ListByEnvironmentAsync` — environment-level states only (no identity/segment overrides)
- `FindByIdAsync`, `UpdateAsync` (full replace), `PatchAsync` (partial)
**`MicCheck.Api/Features/FeaturesController.cs`**
Routes under `api/v1/projects/{projectId}/features/`:
- `GET`, `POST`, `GET {id}`, `PUT {id}`, `PATCH {id}`, `DELETE {id}`
---
### Segments
**`MicCheck.Api/Segments/SegmentService.cs`**
- `CreateAsync` — enforces 100-segment limit, 100-condition limit (across all rules recursively)
- `UpdateAsync` — replaces all rules and conditions (full replace semantics)
- `DeleteAsync`, `FindByIdAsync`, `ListByProjectAsync`
**`MicCheck.Api/Segments/SegmentsController.cs`**
Routes under `api/v1/projects/{projectId}/segments/`:
- `GET`, `POST`, `GET {id}`, `PUT {id}`, `DELETE {id}`
---
### Admin Identities
**`MicCheck.Api/Identities/AdminIdentityService.cs`**
- `ListAsync` (paginated), `FindByIdAsync`, `DeleteAsync`
- `GetFeatureStatesAsync`, `SetFeatureStateAsync` (upsert), `DeleteFeatureStateAsync`
---
### Tags
**`MicCheck.Api/Features/TagService.cs`** — list, create, delete
**`MicCheck.Api/Features/TagsController.cs`**
Routes under `api/v1/projects/{projectId}/tags/`:
- `GET`, `POST`, `DELETE {id}`
---
### Webhooks
**`MicCheck.Api/Webhooks/WebhookService.cs`** — list by environment, create, update, delete
---
## Request Validation
All incoming DTOs have FluentValidation validators in the same folder. Validation errors return **422 Unprocessable Entity** with field-level detail (configured via `ApiBehaviorOptions`).
Key validation rules:
- Feature names: `^[a-zA-Z0-9_-]+$`, max 150 chars
- Feature `InitialValue`: max 20,000 chars
- Webhook URLs: must be absolute URI
- Tag colors: hex format (`#RGB` or `#RRGGBB`)
- Segment rule `Type`: `All`, `Any`, or `None`
- Segment condition `Operator`: any of the 17 `SegmentConditionOperator` enum values
---
## Program.cs Changes
- Added `JsonStringEnumConverter` to MVC JSON options (allows `"Standard"` instead of `0` in API requests)
- Added `Configure<ApiBehaviorOptions>` to return 422 with field-level errors for validation failures
- Registered new services: `AuditService`, `AuditLogQueryService`, `OrganizationService`, `ProjectService`, `EnvironmentService`, `FeatureService`, `FeatureStateService`, `SegmentService`, `TagService`, `WebhookService`, `AdminIdentityService`
---
## Deviations from Plan
- Identity admin endpoints are nested under `/api/v1/environments/{apiKey}/` (as specified in the plan) and handled in `EnvironmentsController` via `[FromServices]` to avoid constructor over-injection
- Feature state endpoints (`/api/v1/environments/{apiKey}/featurestates/`) are also in `EnvironmentsController` for the same reason
- `AuditService.LogAsync` is marked `virtual` to support Moq in service unit tests
---
## Tests Added
### `FeatureServiceTests` (6 tests, in-memory DB)
- Creating a feature auto-creates `FeatureState` for each environment
- `InitialValue` is propagated to `FeatureState.Value`
- 400-feature limit throws `DomainException`
- Update changes name and description
- Delete removes the feature
- List returns all project features
### `EnvironmentServiceTests` (5 tests, in-memory DB)
- Creating an environment auto-creates `FeatureState` for each existing feature
- Each created environment gets a unique API key
- Clone copies environment-level feature states
- Clone does NOT copy identity overrides
- Clone generates a different API key from source
### `SegmentServiceTests` (6 tests, in-memory DB)
- Create persists segment, rules, and conditions
- 100-segment limit throws `DomainException`
- 100-condition limit throws `DomainException`
- Update replaces old rules with new rules
- Delete removes segment
- List returns all project segments
### `AdminApiIntegrationTests` (7 tests, WebApplicationFactory)
- Creating a project returns 201
- Creating a feature returns 201
- Creating a feature auto-creates `FeatureState` for environments
- Creating an environment auto-creates `FeatureState` for existing features
- Missing API key returns 401
- Invalid feature name (with spaces) returns 422
- Creating a segment returns 201
---
## Test Results
```
Passed! - Failed: 0, Passed: 166, Skipped: 0, Total: 166
```

View File

@@ -0,0 +1,31 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Audit;
public class AuditLogQueryService(MicCheckDbContext db)
{
public async Task<IReadOnlyList<AuditLog>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
{
return await db.AuditLogs
.Where(l => l.OrganizationId == organizationId)
.OrderByDescending(l => l.CreatedAt)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<AuditLog>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{
return await db.AuditLogs
.Where(l => l.ProjectId == projectId)
.OrderByDescending(l => l.CreatedAt)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<AuditLog>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{
return await db.AuditLogs
.Where(l => l.EnvironmentId == environmentId)
.OrderByDescending(l => l.CreatedAt)
.ToListAsync(ct);
}
}

View File

@@ -0,0 +1,27 @@
namespace MicCheck.Api.Audit;
public record AuditLogResponse(
int Id,
string ResourceType,
string ResourceId,
string Action,
string? Changes,
int OrganizationId,
int? ProjectId,
int? EnvironmentId,
int? ActorUserId,
DateTimeOffset CreatedAt
)
{
public static AuditLogResponse From(AuditLog log) => new(
log.Id,
log.ResourceType,
log.ResourceId,
log.Action,
log.Changes,
log.OrganizationId,
log.ProjectId,
log.EnvironmentId,
log.ActorUserId,
log.CreatedAt);
}

View File

@@ -0,0 +1,37 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Projects;
using MicCheck.Api.Organizations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Audit;
[ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class AuditLogsController(
AuditLogQueryService auditLogQueryService,
OrganizationService organizationService,
ProjectService projectService) : ControllerBase
{
[HttpGet("api/v1/organisations/{id}/audit-logs")]
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> ListByOrganization(int id, CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
var logs = await auditLogQueryService.ListByOrganizationAsync(id, ct);
return Ok(logs.Select(AuditLogResponse.From).ToList());
}
[HttpGet("api/v1/projects/{projectId}/audit-logs")]
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> ListByProject(int projectId, CancellationToken ct)
{
var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound();
var logs = await auditLogQueryService.ListByProjectAsync(projectId, ct);
return Ok(logs.Select(AuditLogResponse.From).ToList());
}
}

View File

@@ -0,0 +1,38 @@
using MicCheck.Api.Data;
namespace MicCheck.Api.Audit;
public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor)
{
public virtual async Task LogAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
CancellationToken ct = default)
{
int? actorUserId = null;
var userIdClaim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value;
if (int.TryParse(userIdClaim, out var parsedId))
actorUserId = parsedId;
db.AuditLogs.Add(new AuditLog
{
ResourceType = resourceType,
ResourceId = resourceId,
Action = action,
OrganizationId = organizationId,
ProjectId = projectId,
EnvironmentId = environmentId,
ActorUserId = actorUserId,
Changes = changes,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Common;
public class DomainException(string message) : Exception(message);

View File

@@ -0,0 +1,8 @@
namespace MicCheck.Api.Common;
public record PaginatedResponse<T>(
int Count,
string? Next,
string? Previous,
IReadOnlyList<T> Results
);

View File

@@ -0,0 +1,13 @@
using FluentValidation;
namespace MicCheck.Api.Environments;
public record CloneEnvironmentRequest(string Name);
public class CloneEnvironmentRequestValidator : AbstractValidator<CloneEnvironmentRequest>
{
public CloneEnvironmentRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -0,0 +1,14 @@
using FluentValidation;
namespace MicCheck.Api.Environments;
public record CreateEnvironmentRequest(string Name, int ProjectId);
public class CreateEnvironmentRequestValidator : AbstractValidator<CreateEnvironmentRequest>
{
public CreateEnvironmentRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.ProjectId).GreaterThan(0);
}
}

View File

@@ -0,0 +1,15 @@
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments;
public record EnvironmentResponse(
int Id,
string Name,
string ApiKey,
int ProjectId,
DateTimeOffset CreatedAt
)
{
public static EnvironmentResponse From(AppEnvironment env) => new(
env.Id, env.Name, env.ApiKey, env.ProjectId, env.CreatedAt);
}

View File

@@ -0,0 +1,135 @@
using MicCheck.Api.ApiKeys;
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments;
public class EnvironmentService(MicCheckDbContext db, AuditService auditService)
{
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{
return await db.Environments
.Where(e => e.ProjectId == projectId)
.ToListAsync(ct);
}
public async Task<AppEnvironment?> FindByApiKeyAsync(string apiKey, CancellationToken ct = default)
{
return await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
}
public async Task<AppEnvironment> CreateAsync(int projectId, string name, CancellationToken ct = default)
{
var apiKey = ApiKeyHasher.GenerateKey();
var environment = new AppEnvironment
{
Name = name,
ApiKey = apiKey,
ProjectId = projectId,
CreatedAt = DateTimeOffset.UtcNow
};
db.Environments.Add(environment);
await db.SaveChangesAsync(ct);
var features = await db.Features
.Where(f => f.ProjectId == projectId)
.ToListAsync(ct);
foreach (var feature in features)
{
db.FeatureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = environment.Id,
Enabled = feature.DefaultEnabled,
Value = feature.InitialValue,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
}
if (features.Count > 0)
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is not null)
await auditService.LogAsync("Environment", environment.Id.ToString(), "created",
project.OrganizationId, projectId, environment.Id, ct: ct);
return environment;
}
public async Task<AppEnvironment> UpdateAsync(string apiKey, string name, CancellationToken ct = default)
{
var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct)
?? throw new KeyNotFoundException($"Environment with key '{apiKey}' not found.");
environment.Name = name;
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct);
if (project is not null)
await auditService.LogAsync("Environment", environment.Id.ToString(), "updated",
project.OrganizationId, environment.ProjectId, environment.Id, ct: ct);
return environment;
}
public async Task DeleteAsync(string apiKey, CancellationToken ct = default)
{
var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
if (environment is null) return;
db.Environments.Remove(environment);
await db.SaveChangesAsync(ct);
}
public async Task<AppEnvironment> CloneAsync(string sourceApiKey, string newName, CancellationToken ct = default)
{
var source = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == sourceApiKey, ct)
?? throw new KeyNotFoundException($"Source environment with key '{sourceApiKey}' not found.");
var newApiKey = ApiKeyHasher.GenerateKey();
var cloned = new AppEnvironment
{
Name = newName,
ApiKey = newApiKey,
ProjectId = source.ProjectId,
CreatedAt = DateTimeOffset.UtcNow
};
db.Environments.Add(cloned);
await db.SaveChangesAsync(ct);
var sourceStates = await db.FeatureStates
.Where(fs => fs.EnvironmentId == source.Id && fs.IdentityId == null && fs.FeatureSegmentId == null)
.ToListAsync(ct);
foreach (var state in sourceStates)
{
db.FeatureStates.Add(new FeatureState
{
FeatureId = state.FeatureId,
EnvironmentId = cloned.Id,
Enabled = state.Enabled,
Value = state.Value,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
}
if (sourceStates.Count > 0)
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == source.ProjectId, ct);
if (project is not null)
await auditService.LogAsync("Environment", cloned.Id.ToString(), "cloned",
project.OrganizationId, source.ProjectId, cloned.Id, ct: ct);
return cloned;
}
}

View File

@@ -0,0 +1,291 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Common;
using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Environments;
[ApiController]
[Route("api/v1/environments")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class EnvironmentsController(EnvironmentService environmentService, WebhookService webhookService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PaginatedResponse<EnvironmentResponse>>> List(
[FromQuery] int projectId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var all = await environmentService.ListByProjectAsync(projectId, ct);
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(EnvironmentResponse.From).ToList();
return Ok(new PaginatedResponse<EnvironmentResponse>(all.Count, null, null, paged));
}
[HttpPost]
public async Task<ActionResult<EnvironmentResponse>> Create(CreateEnvironmentRequest request, CancellationToken ct)
{
var environment = await environmentService.CreateAsync(request.ProjectId, request.Name, ct);
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = environment.ApiKey }, EnvironmentResponse.From(environment));
}
[HttpGet("{apiKey}")]
public async Task<ActionResult<EnvironmentResponse>> GetByApiKey(string apiKey, CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
return Ok(EnvironmentResponse.From(environment));
}
[HttpPut("{apiKey}")]
public async Task<ActionResult<EnvironmentResponse>> Update(string apiKey, UpdateEnvironmentRequest request, CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var updated = await environmentService.UpdateAsync(apiKey, request.Name, ct);
return Ok(EnvironmentResponse.From(updated));
}
[HttpDelete("{apiKey}")]
public async Task<IActionResult> Delete(string apiKey, CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
await environmentService.DeleteAsync(apiKey, ct);
return NoContent();
}
[HttpPost("{apiKey}/clone")]
public async Task<ActionResult<EnvironmentResponse>> Clone(string apiKey, CloneEnvironmentRequest request, CancellationToken ct)
{
var source = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (source is null) return NotFound();
var cloned = await environmentService.CloneAsync(apiKey, request.Name, ct);
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = cloned.ApiKey }, EnvironmentResponse.From(cloned));
}
[HttpGet("{apiKey}/webhooks")]
public async Task<ActionResult<IReadOnlyList<WebhookResponse>>> ListWebhooks(string apiKey, CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var webhooks = await webhookService.ListByEnvironmentAsync(environment.Id, ct);
return Ok(webhooks.Select(WebhookResponse.From).ToList());
}
[HttpPost("{apiKey}/webhooks")]
public async Task<ActionResult<WebhookResponse>> CreateWebhook(
string apiKey, CreateWebhookRequest request, CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var webhook = await webhookService.CreateAsync(environment.Id, request.Url, request.Secret, request.Enabled, ct);
return CreatedAtAction(nameof(GetByApiKey), new { apiKey }, WebhookResponse.From(webhook));
}
[HttpPut("{apiKey}/webhooks/{id}")]
public async Task<ActionResult<WebhookResponse>> UpdateWebhook(
string apiKey, int id, CreateWebhookRequest request, CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var webhook = await webhookService.FindByIdAsync(id, ct);
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
var updated = await webhookService.UpdateAsync(id, request.Url, request.Secret, request.Enabled, ct);
return Ok(WebhookResponse.From(updated));
}
[HttpDelete("{apiKey}/webhooks/{id}")]
public async Task<IActionResult> DeleteWebhook(string apiKey, int id, CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var webhook = await webhookService.FindByIdAsync(id, ct);
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
await webhookService.DeleteAsync(id, ct);
return NoContent();
}
[HttpGet("{apiKey}/audit-logs")]
public async Task<ActionResult<IReadOnlyList<Audit.AuditLogResponse>>> ListAuditLogs(
string apiKey,
[FromServices] Audit.AuditLogQueryService auditLogQueryService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var logs = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, ct);
return Ok(logs.Select(Audit.AuditLogResponse.From).ToList());
}
[HttpGet("{apiKey}/identities")]
public async Task<ActionResult<PaginatedResponse<Identities.AdminIdentityResponse>>> ListIdentities(
string apiKey,
[FromServices] Identities.AdminIdentityService adminIdentityService,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
var results = items.Select(Identities.AdminIdentityResponse.From).ToList();
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results));
}
[HttpGet("{apiKey}/identities/{id}")]
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity(
string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
if (identity is null) return NotFound();
return Ok(Identities.AdminIdentityResponse.From(identity));
}
[HttpDelete("{apiKey}/identities/{id}")]
public async Task<IActionResult> DeleteIdentity(
string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
if (identity is null) return NotFound();
await adminIdentityService.DeleteAsync(id, ct);
return NoContent();
}
[HttpGet("{apiKey}/identities/{id}/featurestates")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates(
string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
if (identity is null) return NotFound();
var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct);
return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
}
[HttpPut("{apiKey}/identities/{id}/featurestates/{featureId}")]
public async Task<ActionResult<Features.FeatureStateResponse>> SetIdentityFeatureState(
string apiKey, int id, int featureId,
Features.UpdateFeatureStateRequest request,
[FromServices] Identities.AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
if (identity is null) return NotFound();
var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct);
return Ok(Features.FeatureStateResponse.From(state));
}
[HttpDelete("{apiKey}/identities/{id}/featurestates/{featureId}")]
public async Task<IActionResult> DeleteIdentityFeatureState(
string apiKey, int id, int featureId,
[FromServices] Identities.AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
if (identity is null) return NotFound();
await adminIdentityService.DeleteFeatureStateAsync(id, environment.Id, featureId, ct);
return NoContent();
}
[HttpGet("{apiKey}/featurestates")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> ListFeatureStates(
string apiKey,
[FromServices] Features.FeatureStateService featureStateService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct);
return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
}
[HttpGet("{apiKey}/featurestates/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> GetFeatureState(
string apiKey, int id,
[FromServices] Features.FeatureStateService featureStateService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
if (state is null) return NotFound();
return Ok(Features.FeatureStateResponse.From(state));
}
[HttpPut("{apiKey}/featurestates/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> UpdateFeatureState(
string apiKey, int id,
Features.UpdateFeatureStateRequest request,
[FromServices] Features.FeatureStateService featureStateService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
if (state is null) return NotFound();
var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct);
return Ok(Features.FeatureStateResponse.From(updated));
}
[HttpPatch("{apiKey}/featurestates/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> PatchFeatureState(
string apiKey, int id,
Features.PatchFeatureStateRequest request,
[FromServices] Features.FeatureStateService featureStateService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
if (state is null) return NotFound();
var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
return Ok(Features.FeatureStateResponse.From(updated));
}
}

View File

@@ -0,0 +1,13 @@
using FluentValidation;
namespace MicCheck.Api.Environments;
public record UpdateEnvironmentRequest(string Name);
public class UpdateEnvironmentRequestValidator : AbstractValidator<UpdateEnvironmentRequest>
{
public UpdateEnvironmentRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -0,0 +1,26 @@
using FluentValidation;
namespace MicCheck.Api.Features;
public record CreateFeatureRequest(
string Name,
FeatureType Type,
string? InitialValue,
string? Description
);
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
{
public CreateFeatureRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$")
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
RuleFor(x => x.InitialValue)
.MaximumLength(20_000)
.When(x => x.InitialValue is not null);
}
}

View File

@@ -0,0 +1,16 @@
using FluentValidation;
namespace MicCheck.Api.Features;
public record CreateTagRequest(string Label, string Color);
public class CreateTagRequestValidator : AbstractValidator<CreateTagRequest>
{
public CreateTagRequestValidator()
{
RuleFor(x => x.Label).NotEmpty().MaximumLength(100);
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
.Matches("^#[0-9A-Fa-f]{3,6}$")
.WithMessage("Color must be a valid hex color (e.g. #FF0000).");
}
}

View File

@@ -0,0 +1,23 @@
namespace MicCheck.Api.Features;
public record FeatureResponse(
int Id,
string Name,
string Type,
string? InitialValue,
string? Description,
bool DefaultEnabled,
int ProjectId,
DateTimeOffset CreatedAt
)
{
public static FeatureResponse From(Feature feature) => new(
feature.Id,
feature.Name,
feature.Type.ToString().ToUpperInvariant(),
feature.InitialValue,
feature.Description,
feature.DefaultEnabled,
feature.ProjectId,
feature.CreatedAt);
}

View File

@@ -0,0 +1,102 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureService(MicCheckDbContext db, AuditService auditService)
{
private const int MaxFeaturesPerProject = 400;
public async Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{
return await db.Features
.Where(f => f.ProjectId == projectId)
.ToListAsync(ct);
}
public async Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
}
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);
if (count >= MaxFeaturesPerProject)
throw new DomainException($"Project has reached the maximum of {MaxFeaturesPerProject} features.");
var feature = new Feature
{
Name = name,
Type = type,
InitialValue = initialValue,
Description = description,
ProjectId = projectId,
CreatedAt = DateTimeOffset.UtcNow
};
db.Features.Add(feature);
await db.SaveChangesAsync(ct);
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,
Enabled = feature.DefaultEnabled,
Value = initialValue,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
}
if (environments.Count > 0)
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is not null)
await auditService.LogAsync("Feature", feature.Id.ToString(), "created",
project.OrganizationId, projectId, ct: ct);
return feature;
}
public async Task<Feature> UpdateAsync(
int id, string name, string? description, CancellationToken ct = default)
{
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found.");
feature.Name = name;
feature.Description = description;
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == feature.ProjectId, ct);
if (project is not null)
await auditService.LogAsync("Feature", feature.Id.ToString(), "updated",
project.OrganizationId, feature.ProjectId, ct: ct);
return feature;
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
if (feature is null) return;
db.Features.Remove(feature);
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,25 @@
namespace MicCheck.Api.Features;
public record FeatureStateResponse(
int Id,
int FeatureId,
int EnvironmentId,
int? IdentityId,
int? FeatureSegmentId,
bool Enabled,
string? Value,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
)
{
public static FeatureStateResponse From(FeatureState state) => new(
state.Id,
state.FeatureId,
state.EnvironmentId,
state.IdentityId,
state.FeatureSegmentId,
state.Enabled,
state.Value,
state.CreatedAt,
state.UpdatedAt);
}

View File

@@ -0,0 +1,46 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureStateService(MicCheckDbContext db)
{
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{
return await db.FeatureStates
.Where(fs => fs.EnvironmentId == environmentId && fs.IdentityId == null && fs.FeatureSegmentId == null)
.ToListAsync(ct);
}
public async Task<FeatureState?> FindByIdAsync(int id, int environmentId, CancellationToken ct = default)
{
return await db.FeatureStates
.FirstOrDefaultAsync(fs => fs.Id == id && fs.EnvironmentId == environmentId, ct);
}
public async Task<FeatureState> UpdateAsync(int id, bool enabled, string? value, CancellationToken ct = default)
{
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
state.Enabled = enabled;
state.Value = value;
state.UpdatedAt = DateTimeOffset.UtcNow;
state.Version++;
await db.SaveChangesAsync(ct);
return state;
}
public async Task<FeatureState> PatchAsync(int id, bool? enabled, string? value, CancellationToken ct = default)
{
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
if (enabled.HasValue) state.Enabled = enabled.Value;
if (value is not null) state.Value = value;
state.UpdatedAt = DateTimeOffset.UtcNow;
state.Version++;
await db.SaveChangesAsync(ct);
return state;
}
}

View File

@@ -0,0 +1,88 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/projects/{projectId}/features")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeaturesController(FeatureService featureService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PaginatedResponse<FeatureResponse>>> List(
int projectId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var all = await featureService.ListByProjectAsync(projectId, ct);
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(FeatureResponse.From).ToList();
return Ok(new PaginatedResponse<FeatureResponse>(all.Count, null, null, paged));
}
[HttpPost]
public async Task<ActionResult<FeatureResponse>> Create(
int projectId, CreateFeatureRequest request, CancellationToken ct)
{
try
{
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));
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[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 || feature.ProjectId != projectId) return NotFound();
return Ok(FeatureResponse.From(feature));
}
[HttpPut("{id}")]
public async Task<ActionResult<FeatureResponse>> Update(
int projectId, int id, UpdateFeatureRequest request, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
var updated = await featureService.UpdateAsync(id, request.Name, request.Description, ct);
return Ok(FeatureResponse.From(updated));
}
[HttpPatch("{id}")]
public async Task<ActionResult<FeatureResponse>> Patch(
int projectId, int id, PatchFeatureRequest request, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
if (request.Name is not null) feature.Name = request.Name;
if (request.Description is not null) feature.Description = request.Description;
if (request.DefaultEnabled.HasValue) feature.DefaultEnabled = request.DefaultEnabled.Value;
var updated = await featureService.UpdateAsync(id, feature.Name, feature.Description, ct);
return Ok(FeatureResponse.From(updated));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
await featureService.DeleteAsync(id, ct);
return NoContent();
}
}

View File

@@ -0,0 +1,7 @@
namespace MicCheck.Api.Features;
public record PatchFeatureRequest(
string? Name,
string? Description,
bool? DefaultEnabled
);

View File

@@ -0,0 +1,6 @@
namespace MicCheck.Api.Features;
public record TagResponse(int Id, string Label, string Color, int ProjectId)
{
public static TagResponse From(Tag tag) => new(tag.Id, tag.Label, tag.Color, tag.ProjectId);
}

View File

@@ -0,0 +1,33 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class TagService(MicCheckDbContext db)
{
public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{
return await db.Tags.Where(t => t.ProjectId == projectId).ToListAsync(ct);
}
public async Task<Tag> CreateAsync(int projectId, string label, string color, CancellationToken ct = default)
{
var tag = new Tag { Label = label, Color = color, ProjectId = projectId };
db.Tags.Add(tag);
await db.SaveChangesAsync(ct);
return tag;
}
public async Task<Tag?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Tags.FirstOrDefaultAsync(t => t.Id == id, ct);
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == id, ct);
if (tag is null) return;
db.Tags.Remove(tag);
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,37 @@
using MicCheck.Api.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/projects/{projectId}/tags")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class TagsController(TagService tagService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
{
var tags = await tagService.ListByProjectAsync(projectId, ct);
return Ok(tags.Select(TagResponse.From).ToList());
}
[HttpPost]
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
{
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var tag = await tagService.FindByIdAsync(id, ct);
if (tag is null || tag.ProjectId != projectId) return NotFound();
await tagService.DeleteAsync(id, ct);
return NoContent();
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
namespace MicCheck.Api.Features;
public record UpdateFeatureRequest(
string Name,
string? Description
);
public class UpdateFeatureRequestValidator : AbstractValidator<UpdateFeatureRequest>
{
public UpdateFeatureRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$")
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
}
}

View File

@@ -0,0 +1,5 @@
namespace MicCheck.Api.Features;
public record UpdateFeatureStateRequest(bool Enabled, string? Value);
public record PatchFeatureStateRequest(bool? Enabled, string? Value);

View File

@@ -0,0 +1,17 @@
namespace MicCheck.Api.Identities;
public record AdminIdentityResponse(
int Id,
string Identifier,
int EnvironmentId,
DateTimeOffset CreatedAt,
IReadOnlyList<TraitResponse> Traits
)
{
public static AdminIdentityResponse From(Identity identity) => new(
identity.Id,
identity.Identifier,
identity.EnvironmentId,
identity.CreatedAt,
identity.Traits.Select(t => new TraitResponse(t.Key, t.Value)).ToList());
}

View File

@@ -0,0 +1,93 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Identities;
public class AdminIdentityService(MicCheckDbContext db)
{
public async Task<(int Total, IReadOnlyList<Identity> Items)> ListAsync(
int environmentId, int page, int pageSize, CancellationToken ct = default)
{
var query = db.Identities
.Include(i => i.Traits)
.Where(i => i.EnvironmentId == environmentId);
var total = await query.CountAsync(ct);
var items = await query
.OrderBy(i => i.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return (total, items);
}
public async Task<Identity?> FindByIdAsync(int id, int environmentId, CancellationToken ct = default)
{
return await db.Identities
.Include(i => i.Traits)
.FirstOrDefaultAsync(i => i.Id == id && i.EnvironmentId == environmentId, ct);
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var identity = await db.Identities.FirstOrDefaultAsync(i => i.Id == id, ct);
if (identity is null) return;
db.Identities.Remove(identity);
await db.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<FeatureState>> GetFeatureStatesAsync(
int identityId, int environmentId, CancellationToken ct = default)
{
return await db.FeatureStates
.Where(fs => fs.IdentityId == identityId && fs.EnvironmentId == environmentId)
.ToListAsync(ct);
}
public async Task<FeatureState> SetFeatureStateAsync(
int identityId, int environmentId, int featureId, bool enabled, string? value, CancellationToken ct = default)
{
var state = await db.FeatureStates
.FirstOrDefaultAsync(fs => fs.IdentityId == identityId && fs.EnvironmentId == environmentId && fs.FeatureId == featureId, ct);
if (state is not null)
{
state.Enabled = enabled;
state.Value = value;
state.UpdatedAt = DateTimeOffset.UtcNow;
state.Version++;
}
else
{
state = new FeatureState
{
FeatureId = featureId,
EnvironmentId = environmentId,
IdentityId = identityId,
Enabled = enabled,
Value = value,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
db.FeatureStates.Add(state);
}
await db.SaveChangesAsync(ct);
return state;
}
public async Task DeleteFeatureStateAsync(
int identityId, int environmentId, int featureId, CancellationToken ct = default)
{
var state = await db.FeatureStates
.FirstOrDefaultAsync(fs => fs.IdentityId == identityId && fs.EnvironmentId == environmentId && fs.FeatureId == featureId, ct);
if (state is null) return;
db.FeatureStates.Remove(state);
await db.SaveChangesAsync(ct);
}
}

View File

@@ -23,8 +23,4 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
using FluentValidation;
namespace MicCheck.Api.Organizations;
public record CreateOrganizationRequest(string Name);
public class CreateOrganizationRequestValidator : AbstractValidator<CreateOrganizationRequest>
{
public CreateOrganizationRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -0,0 +1,15 @@
using FluentValidation;
namespace MicCheck.Api.Organizations;
public record InviteUserRequest(int UserId, string Role);
public class InviteUserRequestValidator : AbstractValidator<InviteUserRequest>
{
public InviteUserRequestValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse<OrganizationRole>(r, true, out _))
.WithMessage("Role must be 'User' or 'Admin'.");
}
}

View File

@@ -0,0 +1,20 @@
namespace MicCheck.Api.Organizations;
public record OrganizationResponse(
int Id,
string Name,
DateTimeOffset CreatedAt
)
{
public static OrganizationResponse From(Organization org) => new(
org.Id, org.Name, org.CreatedAt);
}
public record OrganizationMemberResponse(
int UserId,
string Role
)
{
public static OrganizationMemberResponse From(OrganizationUser member) => new(
member.UserId, member.Role.ToString());
}

View File

@@ -0,0 +1,102 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Organizations;
public class OrganizationService(MicCheckDbContext db, AuditService auditService)
{
public async Task<IReadOnlyList<Organization>> ListForUserAsync(int userId, CancellationToken ct = default)
{
return await db.Organizations
.Where(o => o.Members.Any(m => m.UserId == userId))
.ToListAsync(ct);
}
public async Task<Organization?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public async Task<Organization> CreateAsync(string name, int creatorUserId, CancellationToken ct = default)
{
var org = new Organization
{
Name = name,
CreatedAt = DateTimeOffset.UtcNow
};
org.Members.Add(new OrganizationUser
{
UserId = creatorUserId,
Role = OrganizationRole.Admin
});
db.Organizations.Add(org);
await db.SaveChangesAsync(ct);
await auditService.LogAsync("Organization", org.Id.ToString(), "created", org.Id, ct: ct);
return org;
}
public async Task<Organization> UpdateAsync(int id, string name, CancellationToken ct = default)
{
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct)
?? throw new KeyNotFoundException($"Organization {id} not found.");
org.Name = name;
await db.SaveChangesAsync(ct);
await auditService.LogAsync("Organization", org.Id.ToString(), "updated", org.Id, ct: ct);
return org;
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct);
if (org is null) return;
db.Organizations.Remove(org);
await db.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<OrganizationUser>> ListMembersAsync(int organizationId, CancellationToken ct = default)
{
return await db.OrganizationUsers
.Where(m => m.OrganizationId == organizationId)
.ToListAsync(ct);
}
public async Task InviteUserAsync(int organizationId, int userId, OrganizationRole role, CancellationToken ct = default)
{
var existing = await db.OrganizationUsers
.FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct);
if (existing is not null)
{
existing.Role = role;
}
else
{
db.OrganizationUsers.Add(new OrganizationUser
{
OrganizationId = organizationId,
UserId = userId,
Role = role
});
}
await db.SaveChangesAsync(ct);
}
public async Task RemoveMemberAsync(int organizationId, int userId, CancellationToken ct = default)
{
var member = await db.OrganizationUsers
.FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct);
if (member is null) return;
db.OrganizationUsers.Remove(member);
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,106 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Organizations;
[ApiController]
[Route("api/v1/organisations")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class OrganizationsController(OrganizationService organizationService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PaginatedResponse<OrganizationResponse>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var userId = GetCurrentUserId();
if (userId is null) return Unauthorized();
var all = await organizationService.ListForUserAsync(userId.Value, ct);
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(OrganizationResponse.From).ToList();
return Ok(new PaginatedResponse<OrganizationResponse>(all.Count, null, null, paged));
}
[HttpPost]
public async Task<ActionResult<OrganizationResponse>> Create(
CreateOrganizationRequest request, CancellationToken ct)
{
var userId = GetCurrentUserId();
if (userId is null) return Unauthorized();
var org = await organizationService.CreateAsync(request.Name, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = org.Id }, OrganizationResponse.From(org));
}
[HttpGet("{id}")]
public async Task<ActionResult<OrganizationResponse>> GetById(int id, CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
return Ok(OrganizationResponse.From(org));
}
[HttpPut("{id}")]
public async Task<ActionResult<OrganizationResponse>> Update(
int id, UpdateOrganizationRequest request, CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
var updated = await organizationService.UpdateAsync(id, request.Name, ct);
return Ok(OrganizationResponse.From(updated));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
await organizationService.DeleteAsync(id, ct);
return NoContent();
}
[HttpGet("{id}/users")]
public async Task<ActionResult<IReadOnlyList<OrganizationMemberResponse>>> ListUsers(
int id, CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
var members = await organizationService.ListMembersAsync(id, ct);
return Ok(members.Select(OrganizationMemberResponse.From).ToList());
}
[HttpPost("{id}/users/invite")]
public async Task<IActionResult> InviteUser(int id, InviteUserRequest request, CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
var role = Enum.Parse<OrganizationRole>(request.Role, ignoreCase: true);
await organizationService.InviteUserAsync(id, request.UserId, role, ct);
return Ok();
}
[HttpDelete("{id}/users/{userId}")]
public async Task<IActionResult> RemoveUser(int id, int userId, CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
await organizationService.RemoveMemberAsync(id, userId, ct);
return NoContent();
}
private int? GetCurrentUserId()
{
var claim = User.FindFirst("UserId")?.Value;
return int.TryParse(claim, out var id) ? id : null;
}
}

View File

@@ -0,0 +1,13 @@
using FluentValidation;
namespace MicCheck.Api.Organizations;
public record UpdateOrganizationRequest(string Name);
public class UpdateOrganizationRequestValidator : AbstractValidator<UpdateOrganizationRequest>
{
public UpdateOrganizationRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -3,6 +3,7 @@ using System.Threading.RateLimiting;
using FluentValidation;
using FluentValidation.AspNetCore;
using MicCheck.Api.ApiKeys;
using MicCheck.Api.Audit;
using MicCheck.Api.Auth;
using MicCheck.Api.Authentication;
using MicCheck.Api.Authorization;
@@ -10,12 +11,16 @@ using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Users;
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.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@@ -36,7 +41,10 @@ try
.WriteTo.Console());
builder.Services.AddOpenApi();
builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(options =>
options.JsonSerializerOptions.Converters.Add(
new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
@@ -86,6 +94,19 @@ try
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.AddScoped<ITokenService, TokenService>();
@@ -99,6 +120,17 @@ try
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<SegmentService>();
builder.Services.AddScoped<TagService>();
builder.Services.AddScoped<WebhookService>();
builder.Services.AddScoped<AdminIdentityService>();
var connectionString = System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
@@ -138,5 +170,3 @@ finally
{
Log.CloseAndFlush();
}
public partial class Program { }

View File

@@ -0,0 +1,14 @@
using FluentValidation;
namespace MicCheck.Api.Projects;
public record CreateProjectRequest(string Name, int OrganizationId);
public class CreateProjectRequestValidator : AbstractValidator<CreateProjectRequest>
{
public CreateProjectRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.OrganizationId).GreaterThan(0);
}
}

View File

@@ -0,0 +1,27 @@
using MicCheck.Api.Authorization;
namespace MicCheck.Api.Projects;
public record ProjectResponse(
int Id,
string Name,
int OrganizationId,
bool HideDisabledFlags,
DateTimeOffset CreatedAt
)
{
public static ProjectResponse From(Project project) => new(
project.Id, project.Name, project.OrganizationId, project.HideDisabledFlags, project.CreatedAt);
}
public record UserPermissionResponse(
int UserId,
int ProjectId,
bool IsAdmin,
IReadOnlyList<string> Permissions
)
{
public static UserPermissionResponse From(UserProjectPermission p) => new(
p.UserId, p.ProjectId, p.IsAdmin,
p.Permissions.Select(x => x.ToString()).ToList());
}

View File

@@ -0,0 +1,105 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Authorization;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Projects;
public class ProjectService(MicCheckDbContext db, AuditService auditService)
{
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
{
return await db.Projects
.Where(p => p.OrganizationId == organizationId)
.ToListAsync(ct);
}
public async Task<Project?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
}
public async Task<Project> CreateAsync(int organizationId, string name, CancellationToken ct = default)
{
var project = new Project
{
Name = name,
OrganizationId = organizationId,
CreatedAt = DateTimeOffset.UtcNow
};
db.Projects.Add(project);
await db.SaveChangesAsync(ct);
await auditService.LogAsync("Project", project.Id.ToString(), "created", organizationId, project.Id, ct: ct);
return project;
}
public async Task<Project> UpdateAsync(int id, string name, bool hideDisabledFlags, CancellationToken ct = default)
{
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Project {id} not found.");
project.Name = name;
project.HideDisabledFlags = hideDisabledFlags;
await db.SaveChangesAsync(ct);
await auditService.LogAsync("Project", project.Id.ToString(), "updated", project.OrganizationId, project.Id, ct: ct);
return project;
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null) return;
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<UserProjectPermission>> ListUserPermissionsAsync(int projectId, CancellationToken ct = default)
{
return await db.UserProjectPermissions
.Where(p => p.ProjectId == projectId)
.ToListAsync(ct);
}
public async Task<UserProjectPermission> SetUserPermissionsAsync(
int projectId, int userId, bool isAdmin, List<ProjectPermission> permissions, CancellationToken ct = default)
{
var existing = await db.UserProjectPermissions
.FirstOrDefaultAsync(p => p.ProjectId == projectId && p.UserId == userId, ct);
if (existing is not null)
{
existing.IsAdmin = isAdmin;
existing.Permissions = permissions;
}
else
{
existing = new UserProjectPermission
{
ProjectId = projectId,
UserId = userId,
IsAdmin = isAdmin,
Permissions = permissions
};
db.UserProjectPermissions.Add(existing);
}
await db.SaveChangesAsync(ct);
return existing;
}
public async Task RemoveUserPermissionsAsync(int projectId, int userId, CancellationToken ct = default)
{
var perm = await db.UserProjectPermissions
.FirstOrDefaultAsync(p => p.ProjectId == projectId && p.UserId == userId, ct);
if (perm is null) return;
db.UserProjectPermissions.Remove(perm);
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,113 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Projects;
[ApiController]
[Route("api/v1/projects")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class ProjectsController(ProjectService projectService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PaginatedResponse<ProjectResponse>>> List(
[FromQuery] int organizationId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var all = await projectService.ListByOrganizationAsync(organizationId, ct);
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(ProjectResponse.From).ToList();
return Ok(new PaginatedResponse<ProjectResponse>(all.Count, null, null, paged));
}
[HttpPost]
public async Task<ActionResult<ProjectResponse>> Create(CreateProjectRequest request, CancellationToken ct)
{
var project = await projectService.CreateAsync(request.OrganizationId, request.Name, ct);
return CreatedAtAction(nameof(GetById), new { id = project.Id }, ProjectResponse.From(project));
}
[HttpGet("{id}")]
public async Task<ActionResult<ProjectResponse>> GetById(int id, CancellationToken ct)
{
var project = await projectService.FindByIdAsync(id, ct);
if (project is null) return NotFound();
return Ok(ProjectResponse.From(project));
}
[HttpPut("{id}")]
public async Task<ActionResult<ProjectResponse>> Update(int id, UpdateProjectRequest request, CancellationToken ct)
{
var project = await projectService.FindByIdAsync(id, ct);
if (project is null) return NotFound();
var updated = await projectService.UpdateAsync(id, request.Name, request.HideDisabledFlags, ct);
return Ok(ProjectResponse.From(updated));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var project = await projectService.FindByIdAsync(id, ct);
if (project is null) return NotFound();
await projectService.DeleteAsync(id, ct);
return NoContent();
}
[HttpGet("{id}/user-permissions")]
public async Task<ActionResult<IReadOnlyList<UserPermissionResponse>>> ListUserPermissions(
int id, CancellationToken ct)
{
var project = await projectService.FindByIdAsync(id, ct);
if (project is null) return NotFound();
var perms = await projectService.ListUserPermissionsAsync(id, ct);
return Ok(perms.Select(UserPermissionResponse.From).ToList());
}
[HttpPost("{id}/user-permissions")]
public async Task<ActionResult<UserPermissionResponse>> CreateUserPermissions(
int id, SetUserPermissionsRequest request, CancellationToken ct)
{
var project = await projectService.FindByIdAsync(id, ct);
if (project is null) return NotFound();
var permissions = request.Permissions
.Select(p => Enum.Parse<Authorization.ProjectPermission>(p, ignoreCase: true))
.ToList();
var perm = await projectService.SetUserPermissionsAsync(id, request.UserId, request.IsAdmin, permissions, ct);
return Ok(UserPermissionResponse.From(perm));
}
[HttpPut("{id}/user-permissions/{userId}")]
public async Task<ActionResult<UserPermissionResponse>> UpdateUserPermissions(
int id, int userId, SetUserPermissionsRequest request, CancellationToken ct)
{
var project = await projectService.FindByIdAsync(id, ct);
if (project is null) return NotFound();
var permissions = request.Permissions
.Select(p => Enum.Parse<Authorization.ProjectPermission>(p, ignoreCase: true))
.ToList();
var perm = await projectService.SetUserPermissionsAsync(id, userId, request.IsAdmin, permissions, ct);
return Ok(UserPermissionResponse.From(perm));
}
[HttpDelete("{id}/user-permissions/{userId}")]
public async Task<IActionResult> DeleteUserPermissions(int id, int userId, CancellationToken ct)
{
var project = await projectService.FindByIdAsync(id, ct);
if (project is null) return NotFound();
await projectService.RemoveUserPermissionsAsync(id, userId, ct);
return NoContent();
}
}

View File

@@ -0,0 +1,17 @@
using FluentValidation;
using MicCheck.Api.Authorization;
namespace MicCheck.Api.Projects;
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
public class SetUserPermissionsRequestValidator : AbstractValidator<SetUserPermissionsRequest>
{
public SetUserPermissionsRequestValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleForEach(x => x.Permissions)
.Must(p => Enum.TryParse<ProjectPermission>(p, true, out _))
.WithMessage("Invalid permission value.");
}
}

View File

@@ -0,0 +1,13 @@
using FluentValidation;
namespace MicCheck.Api.Projects;
public record UpdateProjectRequest(string Name, bool HideDisabledFlags);
public class UpdateProjectRequestValidator : AbstractValidator<UpdateProjectRequest>
{
public UpdateProjectRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -0,0 +1,39 @@
using FluentValidation;
namespace MicCheck.Api.Segments;
public record CreateSegmentConditionRequest(
string Property,
string Operator,
string Value);
public record CreateSegmentRuleRequest(
string Type,
IReadOnlyList<CreateSegmentConditionRequest> Conditions,
IReadOnlyList<CreateSegmentRuleRequest>? ChildRules = null);
public record CreateSegmentRequest(
string Name,
IReadOnlyList<CreateSegmentRuleRequest> Rules);
public class CreateSegmentRequestValidator : AbstractValidator<CreateSegmentRequest>
{
public CreateSegmentRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Rules).NotNull();
RuleForEach(x => x.Rules).ChildRules(rule =>
{
rule.RuleFor(r => r.Type)
.Must(t => Enum.TryParse<SegmentRuleType>(t, true, out _))
.WithMessage("Rule type must be 'All', 'Any', or 'None'.");
rule.RuleForEach(r => r.Conditions).ChildRules(cond =>
{
cond.RuleFor(c => c.Property).NotEmpty();
cond.RuleFor(c => c.Operator)
.Must(o => Enum.TryParse<SegmentConditionOperator>(o, true, out _))
.WithMessage("Invalid operator.");
});
});
}
}

View File

@@ -0,0 +1,51 @@
namespace MicCheck.Api.Segments;
public record SegmentResponse(
int Id,
string Name,
int ProjectId,
DateTimeOffset CreatedAt,
IReadOnlyList<SegmentRuleResponse> Rules
)
{
public static SegmentResponse From(Segment segment) => new(
segment.Id,
segment.Name,
segment.ProjectId,
segment.CreatedAt,
segment.Rules
.Where(r => r.ParentRuleId == null)
.Select(r => SegmentRuleResponse.From(r, segment.Rules.ToList()))
.ToList());
}
public record SegmentRuleResponse(
int Id,
string Type,
IReadOnlyList<SegmentConditionResponse> Conditions,
IReadOnlyList<SegmentRuleResponse> ChildRules
)
{
public static SegmentRuleResponse From(SegmentRule rule, List<SegmentRule> allRules) => new(
rule.Id,
rule.Type.ToString(),
rule.Conditions.Select(SegmentConditionResponse.From).ToList(),
allRules
.Where(r => r.ParentRuleId == rule.Id)
.Select(r => SegmentRuleResponse.From(r, allRules))
.ToList());
}
public record SegmentConditionResponse(
int Id,
string Property,
string Operator,
string Value
)
{
public static SegmentConditionResponse From(SegmentCondition condition) => new(
condition.Id,
condition.Property,
condition.Operator.ToString(),
condition.Value);
}

View File

@@ -0,0 +1,166 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Segments;
public record SegmentConditionDefinition(
string Property,
SegmentConditionOperator Operator,
string Value);
public record SegmentRuleDefinition(
SegmentRuleType Type,
IReadOnlyList<SegmentConditionDefinition> Conditions,
IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
public class SegmentService(MicCheckDbContext db, AuditService auditService)
{
private const int MaxSegmentsPerProject = 100;
private const int MaxConditionsPerSegment = 100;
public async Task<IReadOnlyList<Segment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{
return await db.Segments
.Include(s => s.Rules)
.ThenInclude(r => r.Conditions)
.Where(s => s.ProjectId == projectId)
.ToListAsync(ct);
}
public async Task<Segment?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Segments
.Include(s => s.Rules)
.ThenInclude(r => r.Conditions)
.FirstOrDefaultAsync(s => s.Id == id, ct);
}
public async Task<Segment> CreateAsync(
int projectId,
string name,
IReadOnlyList<SegmentRuleDefinition> rules,
CancellationToken ct = default)
{
var count = await db.Segments.CountAsync(s => s.ProjectId == projectId, ct);
if (count >= MaxSegmentsPerProject)
throw new DomainException($"Project has reached the maximum of {MaxSegmentsPerProject} segments.");
var totalConditions = rules.Sum(r => CountConditions(r));
if (totalConditions > MaxConditionsPerSegment)
throw new DomainException($"Segment cannot have more than {MaxConditionsPerSegment} conditions.");
var segment = new Segment
{
Name = name,
ProjectId = projectId,
CreatedAt = DateTimeOffset.UtcNow
};
db.Segments.Add(segment);
await db.SaveChangesAsync(ct);
foreach (var ruleDef in rules)
{
AddRule(segment.Id, ruleDef, parentRuleId: null);
}
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is not null)
await auditService.LogAsync("Segment", segment.Id.ToString(), "created",
project.OrganizationId, projectId, ct: ct);
return segment;
}
public async Task<Segment> UpdateAsync(
int id,
string name,
IReadOnlyList<SegmentRuleDefinition> rules,
CancellationToken ct = default)
{
var segment = await db.Segments
.Include(s => s.Rules)
.ThenInclude(r => r.Conditions)
.FirstOrDefaultAsync(s => s.Id == id, ct)
?? throw new KeyNotFoundException($"Segment {id} not found.");
var totalConditions = rules.Sum(r => CountConditions(r));
if (totalConditions > MaxConditionsPerSegment)
throw new DomainException($"Segment cannot have more than {MaxConditionsPerSegment} conditions.");
var existingRules = await db.SegmentRules
.Where(r => r.SegmentId == id)
.ToListAsync(ct);
var existingConditions = await db.SegmentConditions
.Where(c => existingRules.Select(r => r.Id).Contains(c.RuleId))
.ToListAsync(ct);
db.SegmentConditions.RemoveRange(existingConditions);
db.SegmentRules.RemoveRange(existingRules);
await db.SaveChangesAsync(ct);
segment.Name = name;
foreach (var ruleDef in rules)
{
AddRule(segment.Id, ruleDef, parentRuleId: null);
}
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == segment.ProjectId, ct);
if (project is not null)
await auditService.LogAsync("Segment", segment.Id.ToString(), "updated",
project.OrganizationId, segment.ProjectId, ct: ct);
return segment;
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var segment = await db.Segments.FirstOrDefaultAsync(s => s.Id == id, ct);
if (segment is null) return;
db.Segments.Remove(segment);
await db.SaveChangesAsync(ct);
}
private void AddRule(int segmentId, SegmentRuleDefinition def, int? parentRuleId)
{
var rule = new SegmentRule
{
SegmentId = segmentId,
ParentRuleId = parentRuleId,
Type = def.Type
};
foreach (var cond in def.Conditions)
{
rule.Conditions.Add(new SegmentCondition
{
Property = cond.Property,
Operator = cond.Operator,
Value = cond.Value
});
}
db.SegmentRules.Add(rule);
if (def.ChildRules is not null)
{
foreach (var child in def.ChildRules)
{
AddRule(segmentId, child, rule.Id);
}
}
}
private static int CountConditions(SegmentRuleDefinition rule)
{
var count = rule.Conditions.Count;
if (rule.ChildRules is not null)
count += rule.ChildRules.Sum(CountConditions);
return count;
}
}

View File

@@ -0,0 +1,90 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Segments;
[ApiController]
[Route("api/v1/projects/{projectId}/segments")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class SegmentsController(SegmentService segmentService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PaginatedResponse<SegmentResponse>>> List(
int projectId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var all = await segmentService.ListByProjectAsync(projectId, ct);
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(SegmentResponse.From).ToList();
return Ok(new PaginatedResponse<SegmentResponse>(all.Count, null, null, paged));
}
[HttpPost]
public async Task<ActionResult<SegmentResponse>> Create(
int projectId, CreateSegmentRequest request, CancellationToken ct)
{
try
{
var rules = MapRules(request.Rules);
var segment = await segmentService.CreateAsync(projectId, request.Name, rules, ct);
return CreatedAtAction(nameof(GetById), new { projectId, id = segment.Id }, SegmentResponse.From(segment));
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("{id}")]
public async Task<ActionResult<SegmentResponse>> GetById(int projectId, int id, CancellationToken ct)
{
var segment = await segmentService.FindByIdAsync(id, ct);
if (segment is null || segment.ProjectId != projectId) return NotFound();
return Ok(SegmentResponse.From(segment));
}
[HttpPut("{id}")]
public async Task<ActionResult<SegmentResponse>> Update(
int projectId, int id, CreateSegmentRequest request, CancellationToken ct)
{
var segment = await segmentService.FindByIdAsync(id, ct);
if (segment is null || segment.ProjectId != projectId) return NotFound();
try
{
var rules = MapRules(request.Rules);
var updated = await segmentService.UpdateAsync(id, request.Name, rules, ct);
return Ok(SegmentResponse.From(updated));
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var segment = await segmentService.FindByIdAsync(id, ct);
if (segment is null || segment.ProjectId != projectId) return NotFound();
await segmentService.DeleteAsync(id, ct);
return NoContent();
}
private static IReadOnlyList<SegmentRuleDefinition> MapRules(IReadOnlyList<CreateSegmentRuleRequest> rules) =>
rules.Select(r => new SegmentRuleDefinition(
Enum.Parse<SegmentRuleType>(r.Type, ignoreCase: true),
r.Conditions.Select(c => new SegmentConditionDefinition(
c.Property,
Enum.Parse<SegmentConditionOperator>(c.Operator, ignoreCase: true),
c.Value)).ToList(),
r.ChildRules is not null ? MapRules(r.ChildRules) : null
)).ToList();
}

View File

@@ -0,0 +1,14 @@
using FluentValidation;
namespace MicCheck.Api.Webhooks;
public record CreateWebhookRequest(string Url, string? Secret, bool Enabled);
public class CreateWebhookRequestValidator : AbstractValidator<CreateWebhookRequest>
{
public CreateWebhookRequestValidator()
{
RuleFor(x => x.Url).NotEmpty().MaximumLength(500).Must(u => Uri.TryCreate(u, UriKind.Absolute, out _))
.WithMessage("Url must be a valid absolute URL.");
}
}

View File

@@ -0,0 +1,23 @@
namespace MicCheck.Api.Webhooks;
public record WebhookResponse(
int Id,
string Url,
string? Secret,
string Scope,
bool Enabled,
int? EnvironmentId,
int? OrganizationId,
DateTimeOffset CreatedAt
)
{
public static WebhookResponse From(Webhook webhook) => new(
webhook.Id,
webhook.Url,
webhook.Secret,
webhook.Scope.ToString(),
webhook.Enabled,
webhook.EnvironmentId,
webhook.OrganizationId,
webhook.CreatedAt);
}

View File

@@ -0,0 +1,56 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Webhooks;
public class WebhookService(MicCheckDbContext db)
{
public async Task<IReadOnlyList<Webhook>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{
return await db.Webhooks
.Where(w => w.EnvironmentId == environmentId)
.ToListAsync(ct);
}
public async Task<Webhook?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Webhooks.FirstOrDefaultAsync(w => w.Id == id, ct);
}
public async Task<Webhook> CreateAsync(int environmentId, string url, string? secret, bool enabled, CancellationToken ct = default)
{
var webhook = new Webhook
{
Url = url,
Secret = secret,
Scope = WebhookScope.Environment,
EnvironmentId = environmentId,
Enabled = enabled,
CreatedAt = DateTimeOffset.UtcNow
};
db.Webhooks.Add(webhook);
await db.SaveChangesAsync(ct);
return webhook;
}
public async Task<Webhook> UpdateAsync(int id, string url, string? secret, bool enabled, CancellationToken ct = default)
{
var webhook = await db.Webhooks.FirstOrDefaultAsync(w => w.Id == id, ct)
?? throw new KeyNotFoundException($"Webhook {id} not found.");
webhook.Url = url;
webhook.Secret = secret;
webhook.Enabled = enabled;
await db.SaveChangesAsync(ct);
return webhook;
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var webhook = await db.Webhooks.FirstOrDefaultAsync(w => w.Id == id, ct);
if (webhook is null) return;
db.Webhooks.Remove(webhook);
await db.SaveChangesAsync(ct);
}
}

View File

@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,268 @@
using System.Net;
using System.Net.Http.Json;
using MicCheck.Api.ApiKeys;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Admin;
[TestFixture]
public class AdminApiIntegrationTests
{
private MicCheckWebApplicationFactory _factory = null!;
private string _rawApiKey = null!;
[SetUp]
public void SetUp()
{
_factory = new MicCheckWebApplicationFactory();
_rawApiKey = SeedOrganizationWithApiKey();
}
[TearDown]
public void TearDown() => _factory.Dispose();
private string SeedOrganizationWithApiKey()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var org = new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow };
db.Organizations.Add(org);
db.SaveChanges();
var rawKey = ApiKeyHasher.GenerateKey();
var hashedKey = ApiKeyHasher.Hash(rawKey);
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
db.ApiKeys.Add(new ApiKey
{
Key = hashedKey,
Prefix = prefix,
Name = "Test Key",
OrganizationId = org.Id,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
});
db.SaveChanges();
return rawKey;
}
private HttpClient CreateAuthenticatedClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {_rawApiKey}");
return client;
}
[Test]
public async Task WhenCreatingAProject_ThenProjectIsReturned()
{
var client = CreateAuthenticatedClient();
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var org = db.Organizations.First();
var response = await client.PostAsJsonAsync("/api/v1/projects", new
{
name = "My Project",
organizationId = org.Id
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
}
[Test]
public async Task WhenCreatingAFeature_ThenFeatureIsReturned()
{
var client = CreateAuthenticatedClient();
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var org = db.Organizations.First();
var project = new Project
{
Name = "My Project",
OrganizationId = org.Id,
CreatedAt = DateTimeOffset.UtcNow
};
db.Projects.Add(project);
db.SaveChanges();
var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
{
name = "dark_mode",
type = "Standard",
initialValue = (string?)null,
description = (string?)null
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
}
[Test]
public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments()
{
var client = CreateAuthenticatedClient();
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var org = db.Organizations.First();
var project = new Project
{
Name = "My Project",
OrganizationId = org.Id,
CreatedAt = DateTimeOffset.UtcNow
};
db.Projects.Add(project);
db.SaveChanges();
db.Environments.Add(new AppEnvironment
{
Name = "Production",
ApiKey = "env-key-prod",
ProjectId = project.Id,
CreatedAt = DateTimeOffset.UtcNow
});
db.SaveChanges();
await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
{
name = "flag_x",
type = "Standard",
initialValue = (string?)null,
description = (string?)null
});
using var verifyScope = _factory.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var feature = verifyDb.Features.First(f => f.Name == "flag_x");
var states = verifyDb.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToList();
Assert.That(states, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures()
{
var client = CreateAuthenticatedClient();
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var org = db.Organizations.First();
var project = new Project
{
Name = "My Project",
OrganizationId = org.Id,
CreatedAt = DateTimeOffset.UtcNow
};
db.Projects.Add(project);
db.Features.Add(new Feature
{
Name = "existing_flag",
ProjectId = project.Id,
CreatedAt = DateTimeOffset.UtcNow
});
db.SaveChanges();
var response = await client.PostAsJsonAsync("/api/v1/environments", new
{
name = "Staging",
projectId = project.Id
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
using var verifyScope = _factory.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var feature = verifyDb.Features.First(f => f.Name == "existing_flag");
var env = verifyDb.Environments.First(e => e.ProjectId == project.Id);
var states = verifyDb.FeatureStates.Where(fs => fs.EnvironmentId == env.Id && fs.FeatureId == feature.Id).ToList();
Assert.That(states, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenRequestIsMissingApiKey_ThenUnauthorizedIsReturned()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/projects?organizationId=1");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
[Test]
public async Task WhenCreatingAFeatureWithInvalidName_ThenUnprocessableEntityIsReturned()
{
var client = CreateAuthenticatedClient();
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var org = db.Organizations.First();
var project = new Project
{
Name = "My Project",
OrganizationId = org.Id,
CreatedAt = DateTimeOffset.UtcNow
};
db.Projects.Add(project);
db.SaveChanges();
var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
{
name = "invalid name with spaces!",
type = "Standard"
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity));
}
[Test]
public async Task WhenCreatingASegment_ThenSegmentIsReturned()
{
var client = CreateAuthenticatedClient();
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var org = db.Organizations.First();
var project = new Project
{
Name = "My Project",
OrganizationId = org.Id,
CreatedAt = DateTimeOffset.UtcNow
};
db.Projects.Add(project);
db.SaveChanges();
var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/segments", new
{
name = "Premium Users",
rules = new[]
{
new
{
type = "All",
conditions = new[]
{
new { property = "plan", @operator = "Equal", value = "premium" }
}
}
}
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
}
}

View File

@@ -0,0 +1,167 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Environments;
[TestFixture]
public class EnvironmentServiceTests
{
private MicCheckDbContext _db = null!;
private EnvironmentService _service = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
var auditService = new Mock<AuditService>(_db, null!);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_service = new EnvironmentService(_db, auditService.Object);
SeedBaseData();
}
[TearDown]
public void TearDown() => _db.Dispose();
private void SeedBaseData()
{
_db.Organizations.Add(new MicCheck.Api.Organizations.Organization
{
Id = OrganizationId,
Name = "Test Org",
CreatedAt = DateTimeOffset.UtcNow
});
_db.Projects.Add(new MicCheck.Api.Projects.Project
{
Id = ProjectId,
Name = "Test Project",
OrganizationId = OrganizationId,
CreatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
}
[Test]
public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsCreatedForEachExistingFeature()
{
_db.Features.Add(new Feature
{
Name = "feature_a",
ProjectId = ProjectId,
InitialValue = "hello",
CreatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
var environment = await _service.CreateAsync(ProjectId, "Staging");
var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == environment.Id).ToListAsync();
Assert.That(states, Has.Count.EqualTo(1));
Assert.That(states[0].Value, Is.EqualTo("hello"));
}
[Test]
public async Task WhenCreatingAnEnvironment_ThenAUniqueApiKeyIsGenerated()
{
var env1 = await _service.CreateAsync(ProjectId, "Env1");
var env2 = await _service.CreateAsync(ProjectId, "Env2");
Assert.That(env1.ApiKey, Is.Not.EqualTo(env2.ApiKey));
}
[Test]
public async Task WhenCloningAnEnvironment_ThenEnvironmentLevelFeatureStatesAreCopied()
{
var source = await _service.CreateAsync(ProjectId, "Production");
var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Features.Add(feature);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = source.Id,
Enabled = true,
Value = "prod-value",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
var clonedStates = await _db.FeatureStates
.Where(fs => fs.EnvironmentId == cloned.Id)
.ToListAsync();
Assert.That(clonedStates, Has.Count.EqualTo(1));
Assert.That(clonedStates[0].Value, Is.EqualTo("prod-value"));
Assert.That(clonedStates[0].Enabled, Is.True);
}
[Test]
public async Task WhenCloningAnEnvironment_ThenIdentityOverridesAreNotCopied()
{
var source = await _service.CreateAsync(ProjectId, "Production");
var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Features.Add(feature);
_db.SaveChanges();
var identity = new MicCheck.Api.Identities.Identity
{
Identifier = "user-1",
EnvironmentId = source.Id,
CreatedAt = DateTimeOffset.UtcNow
};
_db.Identities.Add(identity);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = source.Id,
IdentityId = identity.Id,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
var clonedStates = await _db.FeatureStates
.Where(fs => fs.EnvironmentId == cloned.Id)
.ToListAsync();
Assert.That(clonedStates, Is.Empty);
}
[Test]
public async Task WhenCloningAnEnvironment_ThenClonedEnvironmentHasDifferentApiKey()
{
var source = await _service.CreateAsync(ProjectId, "Production");
var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
Assert.That(cloned.ApiKey, Is.Not.EqualTo(source.ApiKey));
}
}

View File

@@ -0,0 +1,139 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Common;
using Microsoft.EntityFrameworkCore;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureServiceTests
{
private MicCheckDbContext _db = null!;
private FeatureService _service = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
var auditService = new Mock<AuditService>(_db, null!);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_service = new FeatureService(_db, auditService.Object);
SeedBaseData();
}
[TearDown]
public void TearDown() => _db.Dispose();
private void SeedBaseData()
{
_db.Organizations.Add(new MicCheck.Api.Organizations.Organization
{
Id = OrganizationId,
Name = "Test Org",
CreatedAt = DateTimeOffset.UtcNow
});
_db.Projects.Add(new MicCheck.Api.Projects.Project
{
Id = ProjectId,
Name = "Test Project",
OrganizationId = OrganizationId,
CreatedAt = DateTimeOffset.UtcNow
});
_db.Environments.Add(new AppEnvironment
{
Id = EnvironmentId,
Name = "Production",
ApiKey = "test-key",
ProjectId = ProjectId,
CreatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
}
[Test]
public async Task WhenCreatingAFeature_ThenFeatureStateIsCreatedForEachEnvironment()
{
var feature = await _service.CreateAsync(ProjectId, "dark_mode", FeatureType.Standard, null, null);
var states = await _db.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToListAsync();
Assert.That(states, Has.Count.EqualTo(1));
Assert.That(states[0].EnvironmentId, Is.EqualTo(EnvironmentId));
}
[Test]
public async Task WhenCreatingAFeatureWithInitialValue_ThenFeatureStateHasValue()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, "initial-val", null);
var state = await _db.FeatureStates.FirstAsync(fs => fs.FeatureId == feature.Id);
Assert.That(state.Value, Is.EqualTo("initial-val"));
}
[Test]
public async Task WhenProjectHas400Features_ThenCreatingAnotherThrowsDomainException()
{
for (var i = 0; i < 400; i++)
{
_db.Features.Add(new Feature
{
Name = $"feature_{i}",
ProjectId = ProjectId,
CreatedAt = DateTimeOffset.UtcNow
});
}
_db.SaveChanges();
Assert.ThrowsAsync<DomainException>(() =>
_service.CreateAsync(ProjectId, "overflow_feature", FeatureType.Standard, null, null));
}
[Test]
public async Task WhenUpdatingAFeature_ThenNameAndDescriptionAreChanged()
{
var feature = await _service.CreateAsync(ProjectId, "old_name", FeatureType.Standard, null, "old desc");
var updated = await _service.UpdateAsync(feature.Id, "new_name", "new desc");
Assert.That(updated.Name, Is.EqualTo("new_name"));
Assert.That(updated.Description, Is.EqualTo("new desc"));
}
[Test]
public async Task WhenDeletingAFeature_ThenItIsRemovedFromTheDatabase()
{
var feature = await _service.CreateAsync(ProjectId, "to_delete", FeatureType.Standard, null, null);
await _service.DeleteAsync(feature.Id);
var found = await _db.Features.FirstOrDefaultAsync(f => f.Id == feature.Id);
Assert.That(found, Is.Null);
}
[Test]
public async Task WhenListingFeatures_ThenAllProjectFeaturesAreReturned()
{
await _service.CreateAsync(ProjectId, "feature_a", FeatureType.Standard, null, null);
await _service.CreateAsync(ProjectId, "feature_b", FeatureType.Standard, null, null);
var features = await _service.ListByProjectAsync(ProjectId);
Assert.That(features, Has.Count.EqualTo(2));
}
}

View File

@@ -21,7 +21,6 @@
<ItemGroup>
<ProjectReference Include="../../../src/api/MicCheck.Api/MicCheck.Api.csproj" />
<ProjectReference Include="../../../src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,152 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Segments;
[TestFixture]
public class SegmentServiceTests
{
private MicCheckDbContext _db = null!;
private SegmentService _service = null!;
private const int OrganizationId = 1;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
var auditService = new Mock<AuditService>(_db, null!);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_service = new SegmentService(_db, auditService.Object);
SeedBaseData();
}
[TearDown]
public void TearDown() => _db.Dispose();
private void SeedBaseData()
{
_db.Organizations.Add(new MicCheck.Api.Organizations.Organization
{
Id = OrganizationId,
Name = "Test Org",
CreatedAt = DateTimeOffset.UtcNow
});
_db.Projects.Add(new MicCheck.Api.Projects.Project
{
Id = ProjectId,
Name = "Test Project",
OrganizationId = OrganizationId,
CreatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
}
private static SegmentRuleDefinition SingleConditionRule(
string property = "plan",
SegmentConditionOperator op = SegmentConditionOperator.Equal,
string value = "premium") =>
new(SegmentRuleType.All,
[new SegmentConditionDefinition(property, op, value)]);
[Test]
public async Task WhenCreatingASegment_ThenSegmentAndRulesArePersisted()
{
var rules = new[] { SingleConditionRule() };
var segment = await _service.CreateAsync(ProjectId, "Premium Users", rules);
var loaded = await _db.Segments
.Include(s => s.Rules)
.ThenInclude(r => r.Conditions)
.FirstAsync(s => s.Id == segment.Id);
Assert.That(loaded.Name, Is.EqualTo("Premium Users"));
Assert.That(loaded.Rules, Has.Count.EqualTo(1));
Assert.That(loaded.Rules.First().Conditions, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenProjectHas100Segments_ThenCreatingAnotherThrowsDomainException()
{
for (var i = 0; i < 100; i++)
{
_db.Segments.Add(new Segment
{
Name = $"segment_{i}",
ProjectId = ProjectId,
CreatedAt = DateTimeOffset.UtcNow
});
}
_db.SaveChanges();
Assert.ThrowsAsync<DomainException>(() =>
_service.CreateAsync(ProjectId, "overflow_segment", [SingleConditionRule()]));
}
[Test]
public async Task WhenSegmentHasMoreThan100Conditions_ThenCreatingThrowsDomainException()
{
var conditions = Enumerable.Range(0, 101)
.Select(i => new SegmentConditionDefinition($"prop_{i}", SegmentConditionOperator.Equal, "val"))
.ToList();
var rules = new[] { new SegmentRuleDefinition(SegmentRuleType.All, conditions) };
Assert.ThrowsAsync<DomainException>(() =>
_service.CreateAsync(ProjectId, "Too Many Conditions", rules));
}
[Test]
public async Task WhenUpdatingASegment_ThenOldRulesAreReplacedWithNewRules()
{
var segment = await _service.CreateAsync(ProjectId, "Segment", [SingleConditionRule("plan")]);
var newRules = new[] { SingleConditionRule("country") };
var updated = await _service.UpdateAsync(segment.Id, "Updated Segment", newRules);
var loaded = await _db.Segments
.Include(s => s.Rules)
.ThenInclude(r => r.Conditions)
.FirstAsync(s => s.Id == updated.Id);
Assert.That(loaded.Name, Is.EqualTo("Updated Segment"));
Assert.That(loaded.Rules, Has.Count.EqualTo(1));
Assert.That(loaded.Rules.First().Conditions.First().Property, Is.EqualTo("country"));
}
[Test]
public async Task WhenDeletingASegment_ThenItIsRemovedFromTheDatabase()
{
var segment = await _service.CreateAsync(ProjectId, "To Delete", [SingleConditionRule()]);
await _service.DeleteAsync(segment.Id);
var found = await _db.Segments.FirstOrDefaultAsync(s => s.Id == segment.Id);
Assert.That(found, Is.Null);
}
[Test]
public async Task WhenListingSegments_ThenAllProjectSegmentsAreReturned()
{
await _service.CreateAsync(ProjectId, "Segment A", [SingleConditionRule()]);
await _service.CreateAsync(ProjectId, "Segment B", [SingleConditionRule()]);
var segments = await _service.ListByProjectAsync(ProjectId);
Assert.That(segments, Has.Count.EqualTo(2));
}
}

View File

@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
</Project>

View File

@@ -1,15 +0,0 @@
namespace MicCheck.Infrastructure.Tests;
public class Tests
{
[SetUp]
public void Setup()
{
}
[Test]
public void Test1()
{
Assert.Pass();
}
}