Adding Admin API
This commit is contained in:
154
.editorconfig
Normal file
154
.editorconfig
Normal 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
|
||||
@@ -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
237
docs/06_admin_api_output.md
Normal 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
|
||||
```
|
||||
31
src/api/MicCheck.Api/Audit/AuditLogQueryService.cs
Normal file
31
src/api/MicCheck.Api/Audit/AuditLogQueryService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
27
src/api/MicCheck.Api/Audit/AuditLogResponse.cs
Normal file
27
src/api/MicCheck.Api/Audit/AuditLogResponse.cs
Normal 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);
|
||||
}
|
||||
37
src/api/MicCheck.Api/Audit/AuditLogsController.cs
Normal file
37
src/api/MicCheck.Api/Audit/AuditLogsController.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
38
src/api/MicCheck.Api/Audit/AuditService.cs
Normal file
38
src/api/MicCheck.Api/Audit/AuditService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
3
src/api/MicCheck.Api/Common/DomainException.cs
Normal file
3
src/api/MicCheck.Api/Common/DomainException.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Common;
|
||||
|
||||
public class DomainException(string message) : Exception(message);
|
||||
8
src/api/MicCheck.Api/Common/PaginatedResponse.cs
Normal file
8
src/api/MicCheck.Api/Common/PaginatedResponse.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace MicCheck.Api.Common;
|
||||
|
||||
public record PaginatedResponse<T>(
|
||||
int Count,
|
||||
string? Next,
|
||||
string? Previous,
|
||||
IReadOnlyList<T> Results
|
||||
);
|
||||
13
src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs
Normal file
13
src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
15
src/api/MicCheck.Api/Environments/EnvironmentResponse.cs
Normal file
15
src/api/MicCheck.Api/Environments/EnvironmentResponse.cs
Normal 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);
|
||||
}
|
||||
135
src/api/MicCheck.Api/Environments/EnvironmentService.cs
Normal file
135
src/api/MicCheck.Api/Environments/EnvironmentService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
291
src/api/MicCheck.Api/Environments/EnvironmentsController.cs
Normal file
291
src/api/MicCheck.Api/Environments/EnvironmentsController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
26
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal file
26
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal file
16
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal 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).");
|
||||
}
|
||||
}
|
||||
23
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal file
23
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal 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);
|
||||
}
|
||||
102
src/api/MicCheck.Api/Features/FeatureService.cs
Normal file
102
src/api/MicCheck.Api/Features/FeatureService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/api/MicCheck.Api/Features/FeatureStateResponse.cs
Normal file
25
src/api/MicCheck.Api/Features/FeatureStateResponse.cs
Normal 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);
|
||||
}
|
||||
46
src/api/MicCheck.Api/Features/FeatureStateService.cs
Normal file
46
src/api/MicCheck.Api/Features/FeatureStateService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
88
src/api/MicCheck.Api/Features/FeaturesController.cs
Normal file
88
src/api/MicCheck.Api/Features/FeaturesController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
7
src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
Normal file
7
src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record PatchFeatureRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
bool? DefaultEnabled
|
||||
);
|
||||
6
src/api/MicCheck.Api/Features/TagResponse.cs
Normal file
6
src/api/MicCheck.Api/Features/TagResponse.cs
Normal 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);
|
||||
}
|
||||
33
src/api/MicCheck.Api/Features/TagService.cs
Normal file
33
src/api/MicCheck.Api/Features/TagService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
src/api/MicCheck.Api/Features/TagsController.cs
Normal file
37
src/api/MicCheck.Api/Features/TagsController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
20
src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
Normal file
20
src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record UpdateFeatureStateRequest(bool Enabled, string? Value);
|
||||
|
||||
public record PatchFeatureStateRequest(bool? Enabled, string? Value);
|
||||
17
src/api/MicCheck.Api/Identities/AdminIdentityResponse.cs
Normal file
17
src/api/MicCheck.Api/Identities/AdminIdentityResponse.cs
Normal 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());
|
||||
}
|
||||
93
src/api/MicCheck.Api/Identities/AdminIdentityService.cs
Normal file
93
src/api/MicCheck.Api/Identities/AdminIdentityService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
15
src/api/MicCheck.Api/Organizations/InviteUserRequest.cs
Normal file
15
src/api/MicCheck.Api/Organizations/InviteUserRequest.cs
Normal 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'.");
|
||||
}
|
||||
}
|
||||
20
src/api/MicCheck.Api/Organizations/OrganizationResponse.cs
Normal file
20
src/api/MicCheck.Api/Organizations/OrganizationResponse.cs
Normal 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());
|
||||
}
|
||||
102
src/api/MicCheck.Api/Organizations/OrganizationService.cs
Normal file
102
src/api/MicCheck.Api/Organizations/OrganizationService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
106
src/api/MicCheck.Api/Organizations/OrganizationsController.cs
Normal file
106
src/api/MicCheck.Api/Organizations/OrganizationsController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
|
||||
14
src/api/MicCheck.Api/Projects/CreateProjectRequest.cs
Normal file
14
src/api/MicCheck.Api/Projects/CreateProjectRequest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
27
src/api/MicCheck.Api/Projects/ProjectResponse.cs
Normal file
27
src/api/MicCheck.Api/Projects/ProjectResponse.cs
Normal 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());
|
||||
}
|
||||
105
src/api/MicCheck.Api/Projects/ProjectService.cs
Normal file
105
src/api/MicCheck.Api/Projects/ProjectService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
113
src/api/MicCheck.Api/Projects/ProjectsController.cs
Normal file
113
src/api/MicCheck.Api/Projects/ProjectsController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
17
src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs
Normal file
17
src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
13
src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs
Normal file
13
src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
39
src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs
Normal file
39
src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs
Normal 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.");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
51
src/api/MicCheck.Api/Segments/SegmentResponse.cs
Normal file
51
src/api/MicCheck.Api/Segments/SegmentResponse.cs
Normal 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);
|
||||
}
|
||||
166
src/api/MicCheck.Api/Segments/SegmentService.cs
Normal file
166
src/api/MicCheck.Api/Segments/SegmentService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
90
src/api/MicCheck.Api/Segments/SegmentsController.cs
Normal file
90
src/api/MicCheck.Api/Segments/SegmentsController.cs
Normal 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();
|
||||
}
|
||||
14
src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs
Normal file
14
src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
23
src/api/MicCheck.Api/Webhooks/WebhookResponse.cs
Normal file
23
src/api/MicCheck.Api/Webhooks/WebhookResponse.cs
Normal 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);
|
||||
}
|
||||
56
src/api/MicCheck.Api/Webhooks/WebhookService.cs
Normal file
56
src/api/MicCheck.Api/Webhooks/WebhookService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace MicCheck.Infrastructure.Tests;
|
||||
|
||||
public class Tests
|
||||
{
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test1()
|
||||
{
|
||||
Assert.Pass();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user