From bdc85a1f3a752b10d0f850664ac8e24751328d69 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sun, 5 Jul 2026 21:57:35 -0700 Subject: [PATCH 1/6] Remove FluentValidation dependency, use plain .NET validation Replace AbstractValidator-based request validators with a small IModelValidator/ValidationResult pattern (ported from BuyEngine's Common namespace), wired into the MVC pipeline via a ModelValidationActionFilter so controllers and response shape are unaffected. Also copy over the Guard precondition helper. --- src/api/MicCheck.Api/Common/Guard.cs | 40 ++++++++++++++++ .../Common/Validation/IModelValidator.cs | 13 ++++++ .../Validation/ModelValidationActionFilter.cs | 28 +++++++++++ ...delValidatorServiceCollectionExtensions.cs | 20 ++++++++ .../ValidationProblemResponseFactory.cs | 18 ++++++++ .../Common/Validation/ValidationResult.cs | 14 ++++++ .../Environments/CloneEnvironmentRequest.cs | 15 ++++-- .../Environments/CreateEnvironmentRequest.cs | 19 ++++++-- .../Environments/UpdateEnvironmentRequest.cs | 15 ++++-- .../Features/CreateFeatureRequest.cs | 31 ++++++++----- .../MicCheck.Api/Features/CreateTagRequest.cs | 28 ++++++++--- .../MicCheck.Api/Features/FeatureService.cs | 17 ++----- .../Features/UpdateFeatureRequest.cs | 24 ++++++---- src/api/MicCheck.Api/MicCheck.Api.csproj | 1 - .../CreateOrganizationRequest.cs | 15 ++++-- .../Organizations/InviteUserRequest.cs | 20 +++++--- .../UpdateOrganizationRequest.cs | 15 ++++-- src/api/MicCheck.Api/Program.cs | 18 ++------ .../Projects/CreateProjectRequest.cs | 19 ++++++-- .../Projects/SetUserPermissionsRequest.cs | 42 ++++++++++------- .../Projects/UpdateProjectRequest.cs | 15 ++++-- .../Segments/CreateSegmentRequest.cs | 46 ++++++++++++------- .../Webhooks/CreateWebhookRequest.cs | 18 ++++++-- 23 files changed, 361 insertions(+), 130 deletions(-) create mode 100644 src/api/MicCheck.Api/Common/Guard.cs create mode 100644 src/api/MicCheck.Api/Common/Validation/IModelValidator.cs create mode 100644 src/api/MicCheck.Api/Common/Validation/ModelValidationActionFilter.cs create mode 100644 src/api/MicCheck.Api/Common/Validation/ModelValidatorServiceCollectionExtensions.cs create mode 100644 src/api/MicCheck.Api/Common/Validation/ValidationProblemResponseFactory.cs create mode 100644 src/api/MicCheck.Api/Common/Validation/ValidationResult.cs diff --git a/src/api/MicCheck.Api/Common/Guard.cs b/src/api/MicCheck.Api/Common/Guard.cs new file mode 100644 index 0000000..cdc387c --- /dev/null +++ b/src/api/MicCheck.Api/Common/Guard.cs @@ -0,0 +1,40 @@ +namespace MicCheck.Api.Common; + +public static class Guard +{ + public static void Null(T t, string parameterName) where T : class + { + if (t is null) + throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null"); + } + + public static void Empty(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException($"{parameterName} can not be empty", parameterName); + } + + public static void Empty(IEnumerable collection, string parameterName) + { + if (collection == null || !collection.Any()) + throw new ArgumentException($"{parameterName} can not be empty", parameterName); + } + + public static void Negative(int value, string parameterName) + { + if (value < 0) + throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero"); + } + + public static void NegativeOrZero(int value, string parameterName) + { + if (value <= 0) + throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero"); + } + + public static void Default(T value, string parameterName) + { + if (EqualityComparer.Default.Equals(value, default)) + throw new ArgumentException($"{parameterName} can not be a default value", parameterName); + } +} diff --git a/src/api/MicCheck.Api/Common/Validation/IModelValidator.cs b/src/api/MicCheck.Api/Common/Validation/IModelValidator.cs new file mode 100644 index 0000000..182f135 --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/IModelValidator.cs @@ -0,0 +1,13 @@ +namespace MicCheck.Api.Common.Validation; + +public interface IModelValidator +{ + ValidationResult Validate(object model); +} + +public interface IModelValidator : IModelValidator +{ + ValidationResult Validate(T model); + + ValidationResult IModelValidator.Validate(object model) => Validate((T)model); +} diff --git a/src/api/MicCheck.Api/Common/Validation/ModelValidationActionFilter.cs b/src/api/MicCheck.Api/Common/Validation/ModelValidationActionFilter.cs new file mode 100644 index 0000000..c5ad851 --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/ModelValidationActionFilter.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace MicCheck.Api.Common.Validation; + +public class ModelValidationActionFilter : IActionFilter +{ + public void OnActionExecuting(ActionExecutingContext context) + { + foreach (var argument in context.ActionArguments.Values) + { + if (argument is null) continue; + + var validatorType = typeof(IModelValidator<>).MakeGenericType(argument.GetType()); + if (context.HttpContext.RequestServices.GetService(validatorType) is not IModelValidator validator) continue; + + var result = validator.Validate(argument); + foreach (var error in result.Errors) + context.ModelState.AddModelError(error.PropertyName, error.Message); + } + + if (!context.ModelState.IsValid) + context.Result = ValidationProblemResponseFactory.Create(context.ModelState); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } +} diff --git a/src/api/MicCheck.Api/Common/Validation/ModelValidatorServiceCollectionExtensions.cs b/src/api/MicCheck.Api/Common/Validation/ModelValidatorServiceCollectionExtensions.cs new file mode 100644 index 0000000..3a1957b --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/ModelValidatorServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MicCheck.Api.Common.Validation; + +public static class ModelValidatorServiceCollectionExtensions +{ + public static IServiceCollection AddModelValidatorsFromAssemblyContaining(this IServiceCollection services) + { + var registrations = typeof(TMarker).Assembly.GetTypes() + .Where(type => !type.IsAbstract && !type.IsInterface) + .SelectMany(type => type.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IModelValidator<>)) + .Select(i => (Interface: i, Implementation: type))); + + foreach (var (@interface, implementation) in registrations) + services.AddScoped(@interface, implementation); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Common/Validation/ValidationProblemResponseFactory.cs b/src/api/MicCheck.Api/Common/Validation/ValidationProblemResponseFactory.cs new file mode 100644 index 0000000..168fbb2 --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/ValidationProblemResponseFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace MicCheck.Api.Common.Validation; + +public static class ValidationProblemResponseFactory +{ + public static IActionResult Create(ModelStateDictionary modelState) + { + var errors = modelState + .Where(e => e.Value?.Errors.Count > 0) + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()); + + return new UnprocessableEntityObjectResult(new { errors }); + } +} diff --git a/src/api/MicCheck.Api/Common/Validation/ValidationResult.cs b/src/api/MicCheck.Api/Common/Validation/ValidationResult.cs new file mode 100644 index 0000000..3f4add5 --- /dev/null +++ b/src/api/MicCheck.Api/Common/Validation/ValidationResult.cs @@ -0,0 +1,14 @@ +namespace MicCheck.Api.Common.Validation; + +public record ValidationError(string PropertyName, string Message); + +public class ValidationResult +{ + private readonly List _errors = []; + + public IReadOnlyList Errors => _errors; + public bool IsValid => _errors.Count == 0; + public bool IsInvalid => _errors.Count > 0; + + public void AddError(string propertyName, string message) => _errors.Add(new ValidationError(propertyName, message)); +} diff --git a/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs b/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs index ba8b0aa..7d5a63f 100755 --- a/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs +++ b/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Environments; public record CloneEnvironmentRequest(string Name); -public class CloneEnvironmentRequestValidator : AbstractValidator +public class CloneEnvironmentRequestValidator : IModelValidator { - public CloneEnvironmentRequestValidator() + public ValidationResult Validate(CloneEnvironmentRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs b/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs index d8ac7c9..9466e6f 100755 --- a/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs +++ b/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs @@ -1,14 +1,23 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Environments; public record CreateEnvironmentRequest(string Name, int ProjectId); -public class CreateEnvironmentRequestValidator : AbstractValidator +public class CreateEnvironmentRequestValidator : IModelValidator { - public CreateEnvironmentRequestValidator() + public ValidationResult Validate(CreateEnvironmentRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); - RuleFor(x => x.ProjectId).GreaterThan(0); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + if (model.ProjectId <= 0) + result.AddError(nameof(model.ProjectId), "'Project Id' must be greater than 0."); + + return result; } } diff --git a/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs b/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs index e659afb..7b8ed76 100755 --- a/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs +++ b/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Environments; public record UpdateEnvironmentRequest(string Name); -public class UpdateEnvironmentRequestValidator : AbstractValidator +public class UpdateEnvironmentRequestValidator : IModelValidator { - public UpdateEnvironmentRequestValidator() + public ValidationResult Validate(UpdateEnvironmentRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs b/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs index 3c7ec70..1c3a23b 100755 --- a/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs +++ b/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs @@ -1,4 +1,5 @@ -using FluentValidation; +using System.Text.RegularExpressions; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Features; @@ -9,18 +10,24 @@ public record CreateFeatureRequest( string? Description ); -public class CreateFeatureRequestValidator : AbstractValidator +public class CreateFeatureRequestValidator : IModelValidator { - public CreateFeatureRequestValidator() - { - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(150) - .Matches("^[a-zA-Z0-9_-]+$") - .WithMessage("Name may only contain letters, digits, underscores, and hyphens."); + private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$"); - RuleFor(x => x.InitialValue) - .MaximumLength(20_000) - .When(x => x.InitialValue is not null); + public ValidationResult Validate(CreateFeatureRequest model) + { + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 150) + result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer."); + else if (!NamePattern.IsMatch(model.Name)) + result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens."); + + if (model.InitialValue is not null && model.InitialValue.Length > 20_000) + result.AddError(nameof(model.InitialValue), "'Initial Value' must be 20000 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Features/CreateTagRequest.cs b/src/api/MicCheck.Api/Features/CreateTagRequest.cs index 705cb09..ecec153 100755 --- a/src/api/MicCheck.Api/Features/CreateTagRequest.cs +++ b/src/api/MicCheck.Api/Features/CreateTagRequest.cs @@ -1,16 +1,30 @@ -using FluentValidation; +using System.Text.RegularExpressions; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Features; public record CreateTagRequest(string Label, string Color); -public class CreateTagRequestValidator : AbstractValidator +public class CreateTagRequestValidator : IModelValidator { - public CreateTagRequestValidator() + private static readonly Regex ColorPattern = new("^#[0-9A-Fa-f]{3,6}$"); + + public ValidationResult Validate(CreateTagRequest model) { - RuleFor(x => x.Label).NotEmpty().MaximumLength(100); - 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)."); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Label)) + result.AddError(nameof(model.Label), "'Label' must not be empty."); + else if (model.Label.Length > 100) + result.AddError(nameof(model.Label), "'Label' must be 100 characters or fewer."); + + if (string.IsNullOrEmpty(model.Color)) + result.AddError(nameof(model.Color), "'Color' must not be empty."); + else if (model.Color.Length > 20) + result.AddError(nameof(model.Color), "'Color' must be 20 characters or fewer."); + else if (!ColorPattern.IsMatch(model.Color)) + result.AddError(nameof(model.Color), "Color must be a valid hex color (e.g. #FF0000)."); + + return result; } } diff --git a/src/api/MicCheck.Api/Features/FeatureService.cs b/src/api/MicCheck.Api/Features/FeatureService.cs index 77793b9..7809d0e 100755 --- a/src/api/MicCheck.Api/Features/FeatureService.cs +++ b/src/api/MicCheck.Api/Features/FeatureService.cs @@ -23,13 +23,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct); } - public async Task CreateAsync( - int projectId, - string name, - FeatureType type, - string? initialValue, - string? description, - CancellationToken ct = default) + public async Task 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) @@ -78,8 +72,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We return feature; } - public async Task UpdateAsync( - int id, string name, string? description, CancellationToken ct = default) + public async Task UpdateAsync(int id, string name, string? description, CancellationToken ct = default) { var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct) ?? throw new KeyNotFoundException($"Feature {id} not found."); @@ -162,11 +155,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We EventType = WebhookEventTypes.FlagDeleted, EnvironmentId = env.Id, OrganizationId = project.OrganizationId, - Data = new FlagDeletedData( - null, - DateTimeOffset.UtcNow, - new FeatureSummary(feature.Id, feature.Name)) - }); + Data = new FlagDeletedData(null, DateTimeOffset.UtcNow, new FeatureSummary(feature.Id, feature.Name)) }); } } } diff --git a/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs b/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs index 451733d..8e8de0e 100755 --- a/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs +++ b/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs @@ -1,4 +1,5 @@ -using FluentValidation; +using System.Text.RegularExpressions; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Features; @@ -7,14 +8,21 @@ public record UpdateFeatureRequest( string? Description ); -public class UpdateFeatureRequestValidator : AbstractValidator +public class UpdateFeatureRequestValidator : IModelValidator { - public UpdateFeatureRequestValidator() + private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$"); + + public ValidationResult Validate(UpdateFeatureRequest model) { - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(150) - .Matches("^[a-zA-Z0-9_-]+$") - .WithMessage("Name may only contain letters, digits, underscores, and hyphens."); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 150) + result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer."); + else if (!NamePattern.IsMatch(model.Name)) + result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens."); + + return result; } } diff --git a/src/api/MicCheck.Api/MicCheck.Api.csproj b/src/api/MicCheck.Api/MicCheck.Api.csproj index da34818..98bae35 100755 --- a/src/api/MicCheck.Api/MicCheck.Api.csproj +++ b/src/api/MicCheck.Api/MicCheck.Api.csproj @@ -9,7 +9,6 @@ - diff --git a/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs b/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs index 32f755b..488b1e7 100755 --- a/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs +++ b/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Organizations; public record CreateOrganizationRequest(string Name); -public class CreateOrganizationRequestValidator : AbstractValidator +public class CreateOrganizationRequestValidator : IModelValidator { - public CreateOrganizationRequestValidator() + public ValidationResult Validate(CreateOrganizationRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs b/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs index a0ca37e..ed5f2ac 100755 --- a/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs +++ b/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs @@ -1,15 +1,23 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Organizations; public record InviteUserRequest(int UserId, string Role); -public class InviteUserRequestValidator : AbstractValidator +public class InviteUserRequestValidator : IModelValidator { - public InviteUserRequestValidator() + public ValidationResult Validate(InviteUserRequest model) { - RuleFor(x => x.UserId).GreaterThan(0); - RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse(r, true, out _)) - .WithMessage("Role must be 'User' or 'Admin'."); + var result = new ValidationResult(); + + if (model.UserId <= 0) + result.AddError(nameof(model.UserId), "'User Id' must be greater than 0."); + + if (string.IsNullOrEmpty(model.Role)) + result.AddError(nameof(model.Role), "'Role' must not be empty."); + else if (!Enum.TryParse(model.Role, true, out _)) + result.AddError(nameof(model.Role), "Role must be 'User' or 'Admin'."); + + return result; } } diff --git a/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs b/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs index 16a951f..30c0795 100755 --- a/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs +++ b/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Organizations; public record UpdateOrganizationRequest(string Name); -public class UpdateOrganizationRequestValidator : AbstractValidator +public class UpdateOrganizationRequestValidator : IModelValidator { - public UpdateOrganizationRequestValidator() + public ValidationResult Validate(UpdateOrganizationRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Program.cs b/src/api/MicCheck.Api/Program.cs index 8c0bc49..714ceb6 100755 --- a/src/api/MicCheck.Api/Program.cs +++ b/src/api/MicCheck.Api/Program.cs @@ -1,11 +1,10 @@ using System.Text; using System.Threading.RateLimiting; -using FluentValidation; -using FluentValidation.AspNetCore; using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Audit; using MicCheck.Api.Common.Security.Authentication; using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Common.Validation; using MicCheck.Api.Data; using MicCheck.Api.Environments; using MicCheck.Api.Features; @@ -42,7 +41,7 @@ try .WriteTo.Console()); builder.Services.AddOpenApi(); - builder.Services.AddControllers() + builder.Services.AddControllers(options => options.Filters.Add()) .AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add( new System.Text.Json.Serialization.JsonStringEnumConverter())); @@ -92,20 +91,11 @@ try limiter.QueueLimit = 0; })); - builder.Services.AddFluentValidationAutoValidation(); - builder.Services.AddValidatorsFromAssemblyContaining(); + builder.Services.AddModelValidatorsFromAssemblyContaining(); builder.Services.Configure(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 }); - }; + options.InvalidModelStateResponseFactory = context => ValidationProblemResponseFactory.Create(context.ModelState); }); builder.Services.AddMemoryCache(); diff --git a/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs b/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs index 155c34f..23b114d 100755 --- a/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs +++ b/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs @@ -1,14 +1,23 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Projects; public record CreateProjectRequest(string Name, int OrganizationId); -public class CreateProjectRequestValidator : AbstractValidator +public class CreateProjectRequestValidator : IModelValidator { - public CreateProjectRequestValidator() + public ValidationResult Validate(CreateProjectRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); - RuleFor(x => x.OrganizationId).GreaterThan(0); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + if (model.OrganizationId <= 0) + result.AddError(nameof(model.OrganizationId), "'Organization Id' must be greater than 0."); + + return result; } } diff --git a/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs b/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs index 047d948..b800408 100755 --- a/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs +++ b/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs @@ -1,17 +1,25 @@ -using FluentValidation; -using MicCheck.Api.Common.Security.Authorization; - -namespace MicCheck.Api.Projects; - -public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List Permissions); - -public class SetUserPermissionsRequestValidator : AbstractValidator -{ - public SetUserPermissionsRequestValidator() - { - RuleFor(x => x.UserId).GreaterThan(0); - RuleForEach(x => x.Permissions) - .Must(p => Enum.TryParse(p, true, out _)) - .WithMessage("Invalid permission value."); - } -} +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Common.Validation; + +namespace MicCheck.Api.Projects; + +public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List Permissions); + +public class SetUserPermissionsRequestValidator : IModelValidator +{ + public ValidationResult Validate(SetUserPermissionsRequest model) + { + var result = new ValidationResult(); + + if (model.UserId <= 0) + result.AddError(nameof(model.UserId), "'User Id' must be greater than 0."); + + foreach (var permission in model.Permissions) + { + if (!Enum.TryParse(permission, true, out _)) + result.AddError(nameof(model.Permissions), "Invalid permission value."); + } + + return result; + } +} diff --git a/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs b/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs index 9ce7213..76ad2e6 100755 --- a/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs +++ b/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs @@ -1,13 +1,20 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Projects; public record UpdateProjectRequest(string Name, bool HideDisabledFlags); -public class UpdateProjectRequestValidator : AbstractValidator +public class UpdateProjectRequestValidator : IModelValidator { - public UpdateProjectRequestValidator() + public ValidationResult Validate(UpdateProjectRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + return result; } } diff --git a/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs b/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs index 6661251..3d6429b 100755 --- a/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs +++ b/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Segments; @@ -16,24 +16,38 @@ public record CreateSegmentRequest( string Name, IReadOnlyList Rules); -public class CreateSegmentRequestValidator : AbstractValidator +public class CreateSegmentRequestValidator : IModelValidator { - public CreateSegmentRequestValidator() + public ValidationResult Validate(CreateSegmentRequest model) { - RuleFor(x => x.Name).NotEmpty().MaximumLength(200); - RuleFor(x => x.Rules).NotNull(); - RuleForEach(x => x.Rules).ChildRules(rule => + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Name)) + result.AddError(nameof(model.Name), "'Name' must not be empty."); + else if (model.Name.Length > 200) + result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer."); + + if (model.Rules is null) { - rule.RuleFor(r => r.Type) - .Must(t => Enum.TryParse(t, true, out _)) - .WithMessage("Rule type must be 'All', 'Any', or 'None'."); - rule.RuleForEach(r => r.Conditions).ChildRules(cond => + result.AddError(nameof(model.Rules), "'Rules' must not be empty."); + return result; + } + + foreach (var rule in model.Rules) + { + if (!Enum.TryParse(rule.Type, true, out _)) + result.AddError(nameof(model.Rules), "Rule type must be 'All', 'Any', or 'None'."); + + foreach (var condition in rule.Conditions) { - cond.RuleFor(c => c.Property).NotEmpty(); - cond.RuleFor(c => c.Operator) - .Must(o => Enum.TryParse(o, true, out _)) - .WithMessage("Invalid operator."); - }); - }); + if (string.IsNullOrEmpty(condition.Property)) + result.AddError(nameof(model.Rules), "'Property' must not be empty."); + + if (!Enum.TryParse(condition.Operator, true, out _)) + result.AddError(nameof(model.Rules), "Invalid operator."); + } + } + + return result; } } diff --git a/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs b/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs index 85b36e5..4e024b5 100755 --- a/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs +++ b/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs @@ -1,14 +1,22 @@ -using FluentValidation; +using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Webhooks; public record CreateWebhookRequest(string Url, string? Secret, bool Enabled); -public class CreateWebhookRequestValidator : AbstractValidator +public class CreateWebhookRequestValidator : IModelValidator { - public CreateWebhookRequestValidator() + public ValidationResult Validate(CreateWebhookRequest model) { - RuleFor(x => x.Url).NotEmpty().MaximumLength(500).Must(u => Uri.TryCreate(u, UriKind.Absolute, out _)) - .WithMessage("Url must be a valid absolute URL."); + var result = new ValidationResult(); + + if (string.IsNullOrEmpty(model.Url)) + result.AddError(nameof(model.Url), "'Url' must not be empty."); + else if (model.Url.Length > 500) + result.AddError(nameof(model.Url), "'Url' must be 500 characters or fewer."); + else if (!Uri.TryCreate(model.Url, UriKind.Absolute, out _)) + result.AddError(nameof(model.Url), "Url must be a valid absolute URL."); + + return result; } } -- 2.49.1 From 27497e31a93ee9f9655f336dc33a2129b52cb5e1 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sun, 5 Jul 2026 22:00:13 -0700 Subject: [PATCH 2/6] Move FeatureUsage code into MicCheck.Api.Features.Usage namespace Groups usage-tracking (metrics, background flush, query service, controller, entity) under its own namespace/folder, updates all consumers and the EF migration snapshot's CLR type strings to match. --- .../Data/Configurations/FeatureUsageDailyConfiguration.cs | 2 +- src/api/MicCheck.Api/Data/MicCheckDbContext.cs | 1 + src/api/MicCheck.Api/Features/FeatureEvaluationService.cs | 1 + .../Features/{ => Usage}/FeatureUsageBucketKey.cs | 2 +- .../Features/{ => Usage}/FeatureUsageController.cs | 2 +- .../MicCheck.Api/Features/{ => Usage}/FeatureUsageDaily.cs | 2 +- .../{ => Usage}/FeatureUsageFlushBackgroundService.cs | 2 +- .../MicCheck.Api/Features/{ => Usage}/FeatureUsageMetrics.cs | 2 +- .../Features/{ => Usage}/FeatureUsageQueryService.cs | 2 +- .../Features/{ => Usage}/FeatureUsageResponses.cs | 2 +- .../20260617194113_AddFeatureUsageDaily.Designer.cs | 2 +- .../MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs | 2 +- src/api/MicCheck.Api/Program.cs | 1 + .../Features/FeatureEvaluationServiceTests.cs | 1 + .../Features/FlagsApiIntegrationTests.cs | 1 + .../Features/{ => Usage}/FeatureUsageControllerTests.cs | 4 ++-- .../Features/{ => Usage}/FeatureUsageMetricsTests.cs | 4 ++-- .../Features/{ => Usage}/FeatureUsageQueryServiceTests.cs | 4 ++-- .../Identities/IdentitiesControllerTests.cs | 1 + 19 files changed, 22 insertions(+), 16 deletions(-) rename src/api/MicCheck.Api/Features/{ => Usage}/FeatureUsageBucketKey.cs (75%) rename src/api/MicCheck.Api/Features/{ => Usage}/FeatureUsageController.cs (96%) rename src/api/MicCheck.Api/Features/{ => Usage}/FeatureUsageDaily.cs (89%) rename src/api/MicCheck.Api/Features/{ => Usage}/FeatureUsageFlushBackgroundService.cs (98%) rename src/api/MicCheck.Api/Features/{ => Usage}/FeatureUsageMetrics.cs (97%) rename src/api/MicCheck.Api/Features/{ => Usage}/FeatureUsageQueryService.cs (97%) rename src/api/MicCheck.Api/Features/{ => Usage}/FeatureUsageResponses.cs (89%) rename tests/api/MicCheck.Api.Tests.Unit/Features/{ => Usage}/FeatureUsageControllerTests.cs (97%) rename tests/api/MicCheck.Api.Tests.Unit/Features/{ => Usage}/FeatureUsageMetricsTests.cs (96%) rename tests/api/MicCheck.Api.Tests.Unit/Features/{ => Usage}/FeatureUsageQueryServiceTests.cs (98%) diff --git a/src/api/MicCheck.Api/Data/Configurations/FeatureUsageDailyConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/FeatureUsageDailyConfiguration.cs index 087ca85..e9aafbe 100644 --- a/src/api/MicCheck.Api/Data/Configurations/FeatureUsageDailyConfiguration.cs +++ b/src/api/MicCheck.Api/Data/Configurations/FeatureUsageDailyConfiguration.cs @@ -1,4 +1,4 @@ -using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/src/api/MicCheck.Api/Data/MicCheckDbContext.cs b/src/api/MicCheck.Api/Data/MicCheckDbContext.cs index 9e10b97..c4f0b9b 100755 --- a/src/api/MicCheck.Api/Data/MicCheckDbContext.cs +++ b/src/api/MicCheck.Api/Data/MicCheckDbContext.cs @@ -2,6 +2,7 @@ using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Audit; using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Organizations; using MicCheck.Api.Projects; diff --git a/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs b/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs index dd8666e..1e5e595 100755 --- a/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs +++ b/src/api/MicCheck.Api/Features/FeatureEvaluationService.cs @@ -1,4 +1,5 @@ using MicCheck.Api.Data; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Segments; using Microsoft.EntityFrameworkCore; diff --git a/src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageBucketKey.cs similarity index 75% rename from src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageBucketKey.cs index 370437d..5fbf11c 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageBucketKey.cs @@ -1,3 +1,3 @@ -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate); diff --git a/src/api/MicCheck.Api/Features/FeatureUsageController.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageController.cs similarity index 96% rename from src/api/MicCheck.Api/Features/FeatureUsageController.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageController.cs index 09bbc95..7430eeb 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageController.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageController.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Caching.Memory; -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; [ApiController] [Route("api/v1/environment/{environmentId}/usage")] diff --git a/src/api/MicCheck.Api/Features/FeatureUsageDaily.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs similarity index 89% rename from src/api/MicCheck.Api/Features/FeatureUsageDaily.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs index 30ed719..5507b81 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageDaily.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs @@ -1,4 +1,4 @@ -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; public class FeatureUsageDaily { diff --git a/src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageFlushBackgroundService.cs similarity index 98% rename from src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageFlushBackgroundService.cs index f69a807..6b14e85 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageFlushBackgroundService.cs @@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; [ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that issues raw SQL through a DI-scoped concrete MicCheckDbContext; exercising it cleanly requires a live DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). DrainAccumulated's bucketing logic is covered by FeatureUsageMetricsTests.")] public class FeatureUsageFlushBackgroundService( diff --git a/src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageMetrics.cs similarity index 97% rename from src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageMetrics.cs index fd44b4b..2ab2a51 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageMetrics.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.Metrics; -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; public class FeatureUsageMetrics : IDisposable { diff --git a/src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageQueryService.cs similarity index 97% rename from src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageQueryService.cs index f2608ed..e8f3429 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageQueryService.cs @@ -1,7 +1,7 @@ using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; public class FeatureUsageQueryService(IMicCheckDbContext db) { diff --git a/src/api/MicCheck.Api/Features/FeatureUsageResponses.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs similarity index 89% rename from src/api/MicCheck.Api/Features/FeatureUsageResponses.cs rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs index 201d8b5..61833fc 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageResponses.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs @@ -1,4 +1,4 @@ -namespace MicCheck.Api.Features; +namespace MicCheck.Api.Features.Usage; public record TopFeatureUsage(int FeatureId, string FeatureName, long Count); diff --git a/src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs b/src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs index 1632da8..1994b03 100644 --- a/src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs +++ b/src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs @@ -305,7 +305,7 @@ namespace MicCheck.Api.Migrations b.ToTable("FeatureStates"); }); - modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b => + modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b => { b.Property("Id") .ValueGeneratedOnAdd() diff --git a/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs index f1e18f0..6e18fbe 100755 --- a/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs +++ b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs @@ -302,7 +302,7 @@ namespace MicCheck.Api.Migrations b.ToTable("FeatureStates"); }); - modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b => + modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b => { b.Property("Id") .ValueGeneratedOnAdd() diff --git a/src/api/MicCheck.Api/Program.cs b/src/api/MicCheck.Api/Program.cs index 714ceb6..cec1604 100755 --- a/src/api/MicCheck.Api/Program.cs +++ b/src/api/MicCheck.Api/Program.cs @@ -8,6 +8,7 @@ using MicCheck.Api.Common.Validation; using MicCheck.Api.Data; using MicCheck.Api.Environments; using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Organizations; using MicCheck.Api.Projects; diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs index 86073e5..f757f83 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics.Metrics; using MicCheck.Api.Data; using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Segments; using MicCheck.Api.Tests.Unit.TestSupport; diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs index 689f31d..b1a4261 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FlagsApiIntegrationTests.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using MicCheck.Api.Data; using MicCheck.Api.Environments; using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Projects; using MicCheck.Api.Segments; diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageControllerTests.cs similarity index 97% rename from tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageControllerTests.cs rename to tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageControllerTests.cs index 051f809..df57bcf 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageControllerTests.cs @@ -1,12 +1,12 @@ using MicCheck.Api.Data; -using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Tests.Unit.TestSupport; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Moq; using NUnit.Framework; -namespace MicCheck.Api.Tests.Unit.Features; +namespace MicCheck.Api.Tests.Unit.Features.Usage; [TestFixture] public class FeatureUsageControllerTests diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageMetricsTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageMetricsTests.cs similarity index 96% rename from tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageMetricsTests.cs rename to tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageMetricsTests.cs index 8d95b06..3dcc650 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageMetricsTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageMetricsTests.cs @@ -1,9 +1,9 @@ using System.Diagnostics.Metrics; -using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using Moq; using NUnit.Framework; -namespace MicCheck.Api.Tests.Unit.Features; +namespace MicCheck.Api.Tests.Unit.Features.Usage; [TestFixture] public class FeatureUsageMetricsTests diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageQueryServiceTests.cs similarity index 98% rename from tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs rename to tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageQueryServiceTests.cs index 966bbf0..ce75b41 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageQueryServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/Usage/FeatureUsageQueryServiceTests.cs @@ -1,10 +1,10 @@ using MicCheck.Api.Data; -using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Tests.Unit.TestSupport; using Moq; using NUnit.Framework; -namespace MicCheck.Api.Tests.Unit.Features; +namespace MicCheck.Api.Tests.Unit.Features.Usage; [TestFixture] public class FeatureUsageQueryServiceTests diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs index 7b66443..25c7833 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using MicCheck.Api.Data; using MicCheck.Api.Environments; using MicCheck.Api.Features; +using MicCheck.Api.Features.Usage; using MicCheck.Api.Identities; using MicCheck.Api.Projects; using MicCheck.Api.Segments; -- 2.49.1 From 75595970fbfbd24d00f71fe2943c5559f32c8c6a Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sun, 5 Jul 2026 22:20:41 -0700 Subject: [PATCH 3/6] Convert logic-free classes to records per CLAUDE.md WebhookEvent, FeatureStateResult, ProjectPermissionRequirement, AuditLog, Tag, and FeatureUsageDaily are plain data holders with no behavior, so switch them to records. EF-mapped entities with identity semantics (User, Organization, Project, Feature, Segment, etc.) stay classes, since record value-equality and deep ToString fight EF's change tracker. Also restore `virtual` on AuditService.RecordAsync/LogAsync, which had been dropped and broke Mock-based test setups. --- src/api/MicCheck.Api/Audit/AuditLog.cs | 2 +- src/api/MicCheck.Api/Audit/AuditLogQueryService.cs | 14 +++++--------- src/api/MicCheck.Api/Audit/AuditService.cs | 6 +++--- .../Authorization/ProjectPermissionRequirement.cs | 7 +------ .../MicCheck.Api/Features/CreateFeatureRequest.cs | 7 +------ .../MicCheck.Api/Features/FeatureStateResult.cs | 2 +- src/api/MicCheck.Api/Features/Tag.cs | 6 +++--- src/api/MicCheck.Api/Features/TagsController.cs | 7 ++++--- .../MicCheck.Api/Features/UpdateFeatureRequest.cs | 5 +---- .../Features/Usage/FeatureUsageDaily.cs | 8 ++++---- .../Features/Usage/FeatureUsageResponses.cs | 4 +--- src/api/MicCheck.Api/Webhooks/WebhookEvent.cs | 2 +- 12 files changed, 26 insertions(+), 44 deletions(-) diff --git a/src/api/MicCheck.Api/Audit/AuditLog.cs b/src/api/MicCheck.Api/Audit/AuditLog.cs index 683aee1..39bc405 100755 --- a/src/api/MicCheck.Api/Audit/AuditLog.cs +++ b/src/api/MicCheck.Api/Audit/AuditLog.cs @@ -1,6 +1,6 @@ namespace MicCheck.Api.Audit; -public class AuditLog +public record AuditLog { public int Id { get; init; } public required string ResourceType { get; init; } diff --git a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs index cf4bb52..7175790 100755 --- a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs @@ -6,29 +6,25 @@ namespace MicCheck.Api.Audit; public class AuditLogQueryService(IMicCheckDbContext db) { - public async Task> ListByOrganizationAsync( - int organizationId, AuditLogFilter filter, CancellationToken ct = default) + public async Task> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default) { var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); return await ApplyFilterAndPageAsync(query, filter, ct); } - public async Task> ListByProjectAsync( - int projectId, AuditLogFilter filter, CancellationToken ct = default) + public async Task> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default) { var query = db.AuditLogs.Where(l => l.ProjectId == projectId); return await ApplyFilterAndPageAsync(query, filter, ct); } - public async Task> ListByEnvironmentAsync( - int environmentId, AuditLogFilter filter, CancellationToken ct = default) + public async Task> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default) { var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); return await ApplyFilterAndPageAsync(query, filter, ct); } - private async Task> ApplyFilterAndPageAsync( - IQueryable query, AuditLogFilter filter, CancellationToken ct) + private async Task> ApplyFilterAndPageAsync(IQueryable query, AuditLogFilter filter, CancellationToken ct) { if (filter.From.HasValue) query = query.Where(l => l.CreatedAt >= filter.From.Value); @@ -66,4 +62,4 @@ public class AuditLogQueryService(IMicCheckDbContext db) return new PagedResult(total, items); } -} +} \ No newline at end of file diff --git a/src/api/MicCheck.Api/Audit/AuditService.cs b/src/api/MicCheck.Api/Audit/AuditService.cs index a8fd5b4..e54e285 100755 --- a/src/api/MicCheck.Api/Audit/AuditService.cs +++ b/src/api/MicCheck.Api/Audit/AuditService.cs @@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex }, JsonOptions); } - var actorUserId = ResolveActorUserId(); + var actorUserId = GetCurrentUserId(); var log = new AuditLog { @@ -78,7 +78,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex string? changes = null, CancellationToken ct = default) { - var actorUserId = ResolveActorUserId(); + var actorUserId = GetCurrentUserId(); var log = new AuditLog { @@ -112,7 +112,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex }); } - private int? ResolveActorUserId() + private int? GetCurrentUserId() { var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; return int.TryParse(claim, out var id) ? id : null; diff --git a/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs b/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs index 503447a..f98f995 100755 --- a/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs +++ b/src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs @@ -2,9 +2,4 @@ using Microsoft.AspNetCore.Authorization; namespace MicCheck.Api.Common.Security.Authorization; -public class ProjectPermissionRequirement : IAuthorizationRequirement -{ - public ProjectPermission Permission { get; } - - public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission; -} +public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement; diff --git a/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs b/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs index 1c3a23b..376b484 100755 --- a/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs +++ b/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs @@ -3,12 +3,7 @@ using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Features; -public record CreateFeatureRequest( - string Name, - FeatureType Type, - string? InitialValue, - string? Description -); +public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description); public class CreateFeatureRequestValidator : IModelValidator { diff --git a/src/api/MicCheck.Api/Features/FeatureStateResult.cs b/src/api/MicCheck.Api/Features/FeatureStateResult.cs index 636ecbd..d371606 100755 --- a/src/api/MicCheck.Api/Features/FeatureStateResult.cs +++ b/src/api/MicCheck.Api/Features/FeatureStateResult.cs @@ -1,6 +1,6 @@ namespace MicCheck.Api.Features; -public class FeatureStateResult +public record FeatureStateResult { public required Feature Feature { get; init; } public bool Enabled { get; init; } diff --git a/src/api/MicCheck.Api/Features/Tag.cs b/src/api/MicCheck.Api/Features/Tag.cs index e848dbc..f004d7e 100755 --- a/src/api/MicCheck.Api/Features/Tag.cs +++ b/src/api/MicCheck.Api/Features/Tag.cs @@ -1,9 +1,9 @@ namespace MicCheck.Api.Features; -public class Tag +public record Tag { public int Id { get; init; } - public required string Label { get; set; } - public required string Color { get; set; } + public required string Label { get; init; } + public required string Color { get; init; } public int ProjectId { get; init; } } diff --git a/src/api/MicCheck.Api/Features/TagsController.cs b/src/api/MicCheck.Api/Features/TagsController.cs index 709bcb4..a5e390b 100755 --- a/src/api/MicCheck.Api/Features/TagsController.cs +++ b/src/api/MicCheck.Api/Features/TagsController.cs @@ -8,23 +8,24 @@ namespace MicCheck.Api.Features; [ApiController] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [EnableRateLimiting("AdminApi")] +[Route("api/v1/project/{projectId}")] public class TagsController(TagService tagService) : ControllerBase { - [HttpGet("api/v1/project/{projectId}/tags")] + [HttpGet("tags")] public async Task>> List(int projectId, CancellationToken ct) { var tags = await tagService.ListByProjectAsync(projectId, ct); return Ok(tags.Select(TagResponse.From).ToList()); } - [HttpPost("api/v1/project/{projectId}/tags")] + [HttpPost("tags")] public async Task> 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("api/v1/project/{projectId}/tag/{id}")] + [HttpDelete("tag/{id}")] public async Task Delete(int projectId, int id, CancellationToken ct) { var tag = await tagService.FindByIdAsync(id, ct); diff --git a/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs b/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs index 8e8de0e..4dde7e0 100755 --- a/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs +++ b/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs @@ -3,10 +3,7 @@ using MicCheck.Api.Common.Validation; namespace MicCheck.Api.Features; -public record UpdateFeatureRequest( - string Name, - string? Description -); +public record UpdateFeatureRequest(string Name, string? Description); public class UpdateFeatureRequestValidator : IModelValidator { diff --git a/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs index 5507b81..114975a 100644 --- a/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs @@ -1,12 +1,12 @@ namespace MicCheck.Api.Features.Usage; -public class FeatureUsageDaily +public record FeatureUsageDaily { public int Id { get; init; } public int EnvironmentId { get; init; } public int FeatureId { get; init; } - public required string FeatureName { get; set; } + public required string FeatureName { get; init; } public DateOnly UsageDate { get; init; } - public long Count { get; set; } - public DateTimeOffset UpdatedAt { get; set; } + public long Count { get; init; } + public DateTimeOffset UpdatedAt { get; init; } } diff --git a/src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs index 61833fc..b067b80 100644 --- a/src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs +++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs @@ -4,6 +4,4 @@ public record TopFeatureUsage(int FeatureId, string FeatureName, long Count); public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList Features); -public record DashboardUsageResponse( - IReadOnlyList TopFeaturesLastDay, - IReadOnlyList DailyUsage); +public record DashboardUsageResponse(IReadOnlyList TopFeaturesLastDay, IReadOnlyList DailyUsage); diff --git a/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs b/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs index 0f603d0..76a5fb5 100755 --- a/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs @@ -1,6 +1,6 @@ namespace MicCheck.Api.Webhooks; -public class WebhookEvent +public record WebhookEvent { public required string EventType { get; init; } public int? EnvironmentId { get; init; } -- 2.49.1 From f920a6d0a54d6d971a1b08700363b0c598e04645 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sun, 5 Jul 2026 22:30:10 -0700 Subject: [PATCH 4/6] Extract IAuditService, drop virtual mock seam from AuditService Consumers now depend on IAuditService instead of the concrete class, so tests mock the interface (Mock()) rather than subclassing AuditService via virtual methods. --- src/api/MicCheck.Api/Audit/AuditService.cs | 30 +++++++++++++++++-- .../Environments/EnvironmentService.cs | 2 +- .../MicCheck.Api/Features/FeatureService.cs | 2 +- .../Features/FeatureStateService.cs | 2 +- .../Organizations/OrganizationService.cs | 2 +- src/api/MicCheck.Api/Program.cs | 2 +- .../MicCheck.Api/Projects/ProjectService.cs | 2 +- .../MicCheck.Api/Segments/SegmentService.cs | 2 +- .../Admin/AdminApiIntegrationTests.cs | 4 +-- .../Audit/AuditLogsControllerTests.cs | 2 +- .../Environments/EnvironmentServiceTests.cs | 2 +- .../EnvironmentsControllerTests.cs | 2 +- .../Features/FeatureServiceTests.cs | 2 +- .../Features/FeatureStateServiceTests.cs | 4 +-- .../Features/FeaturesControllerTests.cs | 2 +- .../Organizations/OrganizationServiceTests.cs | 2 +- .../OrganizationsControllerTests.cs | 2 +- .../Projects/ProjectServiceTests.cs | 2 +- .../Projects/ProjectsControllerTests.cs | 2 +- .../Segments/SegmentServiceTests.cs | 2 +- .../Segments/SegmentsControllerTests.cs | 2 +- 21 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/api/MicCheck.Api/Audit/AuditService.cs b/src/api/MicCheck.Api/Audit/AuditService.cs index e54e285..721321e 100755 --- a/src/api/MicCheck.Api/Audit/AuditService.cs +++ b/src/api/MicCheck.Api/Audit/AuditService.cs @@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks; namespace MicCheck.Api.Audit; -public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) +public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) : IAuditService { private static readonly JsonSerializerOptions JsonOptions = new() { @@ -12,7 +12,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - public virtual async Task RecordAsync( + public async Task RecordAsync( string resourceType, string resourceId, string action, @@ -68,7 +68,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex } // Backward-compatible overload used by existing callers - public virtual async Task LogAsync( + public async Task LogAsync( string resourceType, string resourceId, string action, @@ -118,3 +118,27 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex return int.TryParse(claim, out var id) ? id : null; } } + +public interface IAuditService +{ + Task RecordAsync( + string resourceType, + string resourceId, + string action, + int organizationId, + int? projectId = null, + int? environmentId = null, + object? before = null, + object? after = null, + CancellationToken ct = default); + + Task LogAsync( + string resourceType, + string resourceId, + string action, + int organizationId, + int? projectId = null, + int? environmentId = null, + string? changes = null, + CancellationToken ct = default); +} diff --git a/src/api/MicCheck.Api/Environments/EnvironmentService.cs b/src/api/MicCheck.Api/Environments/EnvironmentService.cs index 991e2eb..dcba660 100755 --- a/src/api/MicCheck.Api/Environments/EnvironmentService.cs +++ b/src/api/MicCheck.Api/Environments/EnvironmentService.cs @@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment; namespace MicCheck.Api.Environments; -public class EnvironmentService(IMicCheckDbContext db, AuditService auditService) +public class EnvironmentService(IMicCheckDbContext db, IAuditService auditService) { public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Features/FeatureService.cs b/src/api/MicCheck.Api/Features/FeatureService.cs index 7809d0e..bbfd99f 100755 --- a/src/api/MicCheck.Api/Features/FeatureService.cs +++ b/src/api/MicCheck.Api/Features/FeatureService.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureService(IMicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue) +public class FeatureService(IMicCheckDbContext db, IAuditService auditService, WebhookQueue webhookQueue) { private const int MaxFeaturesPerProject = 400; diff --git a/src/api/MicCheck.Api/Features/FeatureStateService.cs b/src/api/MicCheck.Api/Features/FeatureStateService.cs index 4325be4..a509023 100755 --- a/src/api/MicCheck.Api/Features/FeatureStateService.cs +++ b/src/api/MicCheck.Api/Features/FeatureStateService.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService) +public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, IAuditService auditService) { public async Task> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Organizations/OrganizationService.cs b/src/api/MicCheck.Api/Organizations/OrganizationService.cs index f0c04db..962d2fd 100755 --- a/src/api/MicCheck.Api/Organizations/OrganizationService.cs +++ b/src/api/MicCheck.Api/Organizations/OrganizationService.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Organizations; -public class OrganizationService(IMicCheckDbContext db, AuditService auditService) +public class OrganizationService(IMicCheckDbContext db, IAuditService auditService) { public async Task> ListForUserAsync(int userId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Program.cs b/src/api/MicCheck.Api/Program.cs index cec1604..bb5f1b8 100755 --- a/src/api/MicCheck.Api/Program.cs +++ b/src/api/MicCheck.Api/Program.cs @@ -113,7 +113,7 @@ try builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/api/MicCheck.Api/Projects/ProjectService.cs b/src/api/MicCheck.Api/Projects/ProjectService.cs index de54cb7..9e2e4b4 100755 --- a/src/api/MicCheck.Api/Projects/ProjectService.cs +++ b/src/api/MicCheck.Api/Projects/ProjectService.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Projects; -public class ProjectService(IMicCheckDbContext db, AuditService auditService) +public class ProjectService(IMicCheckDbContext db, IAuditService auditService) { public async Task> ListByOrganizationAsync(int organizationId, CancellationToken ct = default) { diff --git a/src/api/MicCheck.Api/Segments/SegmentService.cs b/src/api/MicCheck.Api/Segments/SegmentService.cs index e7fb359..545a59b 100755 --- a/src/api/MicCheck.Api/Segments/SegmentService.cs +++ b/src/api/MicCheck.Api/Segments/SegmentService.cs @@ -9,7 +9,7 @@ public record SegmentConditionDefinition(string Property, SegmentConditionOperat public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList Conditions, IReadOnlyList? ChildRules = null); -public class SegmentService(IMicCheckDbContext db, AuditService auditService) +public class SegmentService(IMicCheckDbContext db, IAuditService auditService) { private const int MaxSegmentsPerProject = 100; private const int MaxConditionsPerSegment = 100; diff --git a/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs index 37b3b1e..af19876 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs @@ -23,7 +23,7 @@ public class AdminApiIntegrationTests private List _featureStates = null!; private List _environments = null!; private List _segments = null!; - private Mock _auditService = null!; + private Mock _auditService = null!; private int _organizationId; [SetUp] @@ -50,7 +50,7 @@ public class AdminApiIntegrationTests _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []); var webhookQueue = new WebhookQueue(); - _auditService = new Mock(_db.Object, null!, webhookQueue); + _auditService = new Mock(); _auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs index 0ae3d3f..38848c6 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs @@ -46,7 +46,7 @@ public class AuditLogsControllerTests _db.SetupDbSet(c => c.AuditLogs, _auditLogs); _db.SetupDbSet(c => c.Users, []); - var auditServiceMock = new Mock(_db.Object, null!, new MicCheck.Api.Webhooks.WebhookQueue()); + var auditServiceMock = new Mock(); var auditService = auditServiceMock.Object; _controller = new AuditLogsController( diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs index d7591f1..1da20dd 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs @@ -45,7 +45,7 @@ public class EnvironmentServiceTests _db.SetupDbSet(c => c.Identities, _identities); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs index 6fb5401..59a407c 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs @@ -113,7 +113,7 @@ public class EnvironmentsControllerTests _db.SetupDbSet(c => c.Users, []); var webhookQueue = new WebhookQueue(); - var auditServiceMock = new Mock(_db.Object, null!, webhookQueue); + var auditServiceMock = new Mock(); auditServiceMock.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs index 45ec63c..0c0b530 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs @@ -45,7 +45,7 @@ public class FeatureServiceTests _db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs index 834991d..92a639d 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs @@ -43,7 +43,7 @@ public class FeatureStateServiceTests _db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates); var webhookQueue = new WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -154,7 +154,7 @@ public class FeatureStateServiceTests var state = AddState(); var queue = new WebhookQueue(); - var auditService = new Mock(_db.Object, null!, queue); + var auditService = new Mock(); auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs index 36456da..43c4f54 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs @@ -41,7 +41,7 @@ public class FeaturesControllerTests _db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs index 023fe5d..ca9980a 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs @@ -41,7 +41,7 @@ public class OrganizationServiceTests _db.SetupDbSet(c => c.Users, _users); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs index 6c7956a..585bf1b 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs @@ -49,7 +49,7 @@ public class OrganizationsControllerTests _db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks); var webhookQueue = new WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs index 1c04bfc..707a89c 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs @@ -32,7 +32,7 @@ public class ProjectServiceTests _db.SetupDbSet(c => c.UserProjectPermissions, _permissions); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs index 33c67e4..524f8c2 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs @@ -34,7 +34,7 @@ public class ProjectsControllerTests _db.SetupDbSet(c => c.UserProjectPermissions, _permissions); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs index 51bb9f1..232b6ab 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs @@ -40,7 +40,7 @@ public class SegmentServiceTests _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs index b85c22e..b143d13 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs @@ -41,7 +41,7 @@ public class SegmentsControllerTests _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions); var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); - var auditService = new Mock(_db.Object, null!, webhookQueue); + var auditService = new Mock(); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), -- 2.49.1 From 7b51100ab6e12f9299d5d70a233b3148f8fe99ad Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sun, 5 Jul 2026 22:39:52 -0700 Subject: [PATCH 5/6] Split Program.cs DI registrations into per-namespace DependencyRegistration classes Each root namespace (Audit, Common, Data, Environments, Features/Usage, Identities, Organizations, Projects, Segments, Users, Webhooks) gets its own static DependencyRegistration class exposing an AddServices extension method, so Program.cs just wires them together instead of listing every registration inline. --- .../Audit/DependencyRegistration.cs | 12 ++ .../Common/DependencyRegistration.cs | 65 +++++++++++ .../Data/DependencyRegistration.cs | 21 ++++ .../Environments/DependencyRegistration.cs | 12 ++ .../Features/DependencyRegistration.cs | 20 ++++ src/api/MicCheck.Api/Features/FlagCache.cs | 6 +- .../Features/Usage/DependencyRegistration.cs | 13 +++ .../Identities/DependencyRegistration.cs | 12 ++ .../Organizations/DependencyRegistration.cs | 11 ++ src/api/MicCheck.Api/Program.cs | 105 +++--------------- .../Projects/DependencyRegistration.cs | 11 ++ .../Segments/DependencyRegistration.cs | 12 ++ .../Users/DependencyRegistration.cs | 14 +++ .../Webhooks/DependencyRegistration.cs | 17 +++ 14 files changed, 235 insertions(+), 96 deletions(-) create mode 100644 src/api/MicCheck.Api/Audit/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Common/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Data/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Environments/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Features/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Features/Usage/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Identities/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Organizations/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Projects/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Segments/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Users/DependencyRegistration.cs create mode 100644 src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs diff --git a/src/api/MicCheck.Api/Audit/DependencyRegistration.cs b/src/api/MicCheck.Api/Audit/DependencyRegistration.cs new file mode 100644 index 0000000..b159052 --- /dev/null +++ b/src/api/MicCheck.Api/Audit/DependencyRegistration.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Audit; + +public static class DependencyRegistration +{ + public static IServiceCollection AddAuditServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Common/DependencyRegistration.cs b/src/api/MicCheck.Api/Common/DependencyRegistration.cs new file mode 100644 index 0000000..44cb592 --- /dev/null +++ b/src/api/MicCheck.Api/Common/DependencyRegistration.cs @@ -0,0 +1,65 @@ +using System.Text; +using MicCheck.Api.Common.Security.ApiKeys; +using MicCheck.Api.Common.Security.Authentication; +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Common.Validation; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +namespace MicCheck.Api.Common; + +public static class DependencyRegistration +{ + public static IServiceCollection AddCommonServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddAuthentication() + .AddScheme( + EnvironmentKeyAuthenticationHandler.SchemeName, _ => { }) + .AddScheme( + ApiKeyAuthenticationHandler.SchemeName, _ => { }) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidIssuer = configuration["Jwt:Issuer"], + ValidAudience = configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!)) + }; + }); + + services.AddAuthorization(options => + { + options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy => + policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName) + .RequireClaim("EnvironmentId")); + + options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy => + policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy => + policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) + .RequireClaim("OrganizationRole", "Admin")); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddModelValidatorsFromAssemblyContaining(); + services.Configure(options => + { + options.InvalidModelStateResponseFactory = context => ValidationProblemResponseFactory.Create(context.ModelState); + }); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Data/DependencyRegistration.cs b/src/api/MicCheck.Api/Data/DependencyRegistration.cs new file mode 100644 index 0000000..9721092 --- /dev/null +++ b/src/api/MicCheck.Api/Data/DependencyRegistration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; + +namespace MicCheck.Api.Data; + +public static class DependencyRegistration +{ + public static IServiceCollection AddDataServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + + var connectionString = configuration.GetConnectionString("miccheck") + ?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl + ? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl) + : configuration.GetConnectionString("DefaultConnection")!); + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Environments/DependencyRegistration.cs b/src/api/MicCheck.Api/Environments/DependencyRegistration.cs new file mode 100644 index 0000000..f5f5a8c --- /dev/null +++ b/src/api/MicCheck.Api/Environments/DependencyRegistration.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Environments; + +public static class DependencyRegistration +{ + public static IServiceCollection AddEnvironmentsServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Features/DependencyRegistration.cs b/src/api/MicCheck.Api/Features/DependencyRegistration.cs new file mode 100644 index 0000000..7e24f7c --- /dev/null +++ b/src/api/MicCheck.Api/Features/DependencyRegistration.cs @@ -0,0 +1,20 @@ +using MicCheck.Api.Features.Usage; + +namespace MicCheck.Api.Features; + +public static class DependencyRegistration +{ + public static IServiceCollection AddFeaturesServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddFeatureUsageServices(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Features/FlagCache.cs b/src/api/MicCheck.Api/Features/FlagCache.cs index 536c9a5..db3303c 100755 --- a/src/api/MicCheck.Api/Features/FlagCache.cs +++ b/src/api/MicCheck.Api/Features/FlagCache.cs @@ -12,11 +12,9 @@ public class FlagCache(IMemoryCache cache) return flags; } - public void Set(int environmentId, IReadOnlyList flags) - => cache.Set(CacheKey(environmentId), flags, CacheDuration); + public void Set(int environmentId, IReadOnlyList flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration); - public void Invalidate(int environmentId) - => cache.Remove(CacheKey(environmentId)); + public void Invalidate(int environmentId) => cache.Remove(CacheKey(environmentId)); private static string CacheKey(int environmentId) => $"flags:{environmentId}"; } diff --git a/src/api/MicCheck.Api/Features/Usage/DependencyRegistration.cs b/src/api/MicCheck.Api/Features/Usage/DependencyRegistration.cs new file mode 100644 index 0000000..68bb57d --- /dev/null +++ b/src/api/MicCheck.Api/Features/Usage/DependencyRegistration.cs @@ -0,0 +1,13 @@ +namespace MicCheck.Api.Features.Usage; + +public static class DependencyRegistration +{ + public static IServiceCollection AddFeatureUsageServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddHostedService(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Identities/DependencyRegistration.cs b/src/api/MicCheck.Api/Identities/DependencyRegistration.cs new file mode 100644 index 0000000..4f1efea --- /dev/null +++ b/src/api/MicCheck.Api/Identities/DependencyRegistration.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Identities; + +public static class DependencyRegistration +{ + public static IServiceCollection AddIdentitiesServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Organizations/DependencyRegistration.cs b/src/api/MicCheck.Api/Organizations/DependencyRegistration.cs new file mode 100644 index 0000000..e474831 --- /dev/null +++ b/src/api/MicCheck.Api/Organizations/DependencyRegistration.cs @@ -0,0 +1,11 @@ +namespace MicCheck.Api.Organizations; + +public static class DependencyRegistration +{ + public static IServiceCollection AddOrganizationsServices(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Program.cs b/src/api/MicCheck.Api/Program.cs index bb5f1b8..fa7569a 100755 --- a/src/api/MicCheck.Api/Program.cs +++ b/src/api/MicCheck.Api/Program.cs @@ -1,28 +1,19 @@ -using System.Text; using System.Threading.RateLimiting; -using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Audit; -using MicCheck.Api.Common.Security.Authentication; +using MicCheck.Api.Common; +using MicCheck.Api.Common.Security.ApiKeys; using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Validation; using MicCheck.Api.Data; using MicCheck.Api.Environments; using MicCheck.Api.Features; -using MicCheck.Api.Features.Usage; 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; using Scalar.AspNetCore; using Serilog; @@ -47,39 +38,7 @@ try options.JsonSerializerOptions.Converters.Add( new System.Text.Json.Serialization.JsonStringEnumConverter())); - builder.Services.AddAuthentication() - .AddScheme( - EnvironmentKeyAuthenticationHandler.SchemeName, _ => { }) - .AddScheme( - ApiKeyAuthenticationHandler.SchemeName, _ => { }) - .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateIssuerSigningKey = true, - ValidIssuer = builder.Configuration["Jwt:Issuer"], - ValidAudience = builder.Configuration["Jwt:Audience"], - IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!)) - }; - }); - - builder.Services.AddAuthorization(options => - { - options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy => - policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName) - .RequireClaim("EnvironmentId")); - - options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy => - policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) - .RequireAuthenticatedUser()); - - options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy => - policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme) - .RequireClaim("OrganizationRole", "Admin")); - }); + builder.Services.AddCommonServices(builder.Configuration); builder.Services.AddHttpContextAccessor(); @@ -92,58 +51,20 @@ try limiter.QueueLimit = 0; })); - builder.Services.AddModelValidatorsFromAssemblyContaining(); - - builder.Services.Configure(options => - { - options.InvalidModelStateResponseFactory = context => ValidationProblemResponseFactory.Create(context.ModelState); - }); - builder.Services.AddMemoryCache(); builder.Services.AddMetrics(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped, PasswordHasher>(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddHostedService(); - builder.Services.AddHttpClient("Webhooks", client => - client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0")); + builder.Services.AddAuditServices(); + builder.Services.AddIdentitiesServices(); + builder.Services.AddEnvironmentsServices(); + builder.Services.AddSegmentsServices(); + builder.Services.AddOrganizationsServices(); + builder.Services.AddProjectsServices(); + builder.Services.AddFeaturesServices(); + builder.Services.AddUsersServices(); + builder.Services.AddWebhooksServices(); - var connectionString = builder.Configuration.GetConnectionString("miccheck") - ?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl - ? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl) - : builder.Configuration.GetConnectionString("DefaultConnection")!); - - builder.Services.AddDbContext(options => - options.UseNpgsql(connectionString)); - builder.Services.AddScoped(sp => sp.GetRequiredService()); + builder.Services.AddDataServices(builder.Configuration); var app = builder.Build(); diff --git a/src/api/MicCheck.Api/Projects/DependencyRegistration.cs b/src/api/MicCheck.Api/Projects/DependencyRegistration.cs new file mode 100644 index 0000000..81929fd --- /dev/null +++ b/src/api/MicCheck.Api/Projects/DependencyRegistration.cs @@ -0,0 +1,11 @@ +namespace MicCheck.Api.Projects; + +public static class DependencyRegistration +{ + public static IServiceCollection AddProjectsServices(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Segments/DependencyRegistration.cs b/src/api/MicCheck.Api/Segments/DependencyRegistration.cs new file mode 100644 index 0000000..df7c80f --- /dev/null +++ b/src/api/MicCheck.Api/Segments/DependencyRegistration.cs @@ -0,0 +1,12 @@ +namespace MicCheck.Api.Segments; + +public static class DependencyRegistration +{ + public static IServiceCollection AddSegmentsServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Users/DependencyRegistration.cs b/src/api/MicCheck.Api/Users/DependencyRegistration.cs new file mode 100644 index 0000000..7229e37 --- /dev/null +++ b/src/api/MicCheck.Api/Users/DependencyRegistration.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Identity; + +namespace MicCheck.Api.Users; + +public static class DependencyRegistration +{ + public static IServiceCollection AddUsersServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped, PasswordHasher>(); + + return services; + } +} diff --git a/src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs b/src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs new file mode 100644 index 0000000..c240f4b --- /dev/null +++ b/src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs @@ -0,0 +1,17 @@ +namespace MicCheck.Api.Webhooks; + +public static class DependencyRegistration +{ + public static IServiceCollection AddWebhooksServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHttpClient("Webhooks", client => + client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0")); + + return services; + } +} -- 2.49.1 From d4feca8ad43e54734333a02dd4bf6f2c77d7a004 Mon Sep 17 00:00:00 2001 From: miccheck-ci Date: Mon, 6 Jul 2026 05:54:20 +0000 Subject: [PATCH 6/6] chore: refresh coverage badge [skip ci] --- badges/coverage.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/badges/coverage.svg b/badges/coverage.svg index 1f0065c..8679a05 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -123,7 +123,7 @@ Coverage Coverage - 69.2%69.2% + 68.9%68.9% -- 2.49.1