Remove FluentValidation; validator/DI cleanup (#7)
## Summary - Remove FluentValidation dependency; replace with plain IModelValidator<T>/ValidationResult pattern (ported from BuyEngine's Guard/validation approach), wired via a ModelValidationActionFilter - Move FeatureUsage code into MicCheck.Api.Features.Usage namespace - Convert logic-free data classes to records per CLAUDE.md (WebhookEvent, FeatureStateResult, ProjectPermissionRequirement, AuditLog, Tag, FeatureUsageDaily) - Extract IAuditService interface, drop virtual-method mock seam on AuditService - Split Program.cs DI registrations into per-namespace DependencyRegistration classes ## Test plan - [x] dotnet build (API + tests) clean - [x] dotnet test MicCheck.Api.Tests.Unit — 646/646 passing - [x] pre-push hook (full solution build/test + admin build) passed Co-authored-by: miccheck-ci <ci@miccheck.local> Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
@@ -1,26 +1,28 @@
|
||||
using FluentValidation;
|
||||
using System.Text.RegularExpressions;
|
||||
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 : AbstractValidator<CreateFeatureRequest>
|
||||
public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest>
|
||||
{
|
||||
public CreateFeatureRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(150)
|
||||
.Matches("^[a-zA-Z0-9_-]+$")
|
||||
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CreateTagRequest>
|
||||
public class CreateTagRequestValidator : IModelValidator<CreateTagRequest>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/api/MicCheck.Api/Features/DependencyRegistration.cs
Normal file
20
src/api/MicCheck.Api/Features/DependencyRegistration.cs
Normal file
@@ -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<FeatureEvaluationService>();
|
||||
services.AddSingleton<FlagCache>();
|
||||
services.AddScoped<FeatureService>();
|
||||
services.AddScoped<FeatureStateService>();
|
||||
services.AddScoped<FeatureSegmentService>();
|
||||
services.AddScoped<TagService>();
|
||||
|
||||
services.AddFeatureUsageServices();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features.Usage;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Segments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Feature> CreateAsync(
|
||||
int projectId,
|
||||
string name,
|
||||
FeatureType type,
|
||||
string? initialValue,
|
||||
string? description,
|
||||
CancellationToken ct = default)
|
||||
public async Task<Feature> CreateAsync(int projectId, string name, FeatureType type, string? initialValue, string? description, CancellationToken ct = default)
|
||||
{
|
||||
var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
|
||||
if (count >= MaxFeaturesPerProject)
|
||||
@@ -78,8 +72,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We
|
||||
return feature;
|
||||
}
|
||||
|
||||
public async Task<Feature> UpdateAsync(
|
||||
int id, string name, string? description, CancellationToken ct = default)
|
||||
public async Task<Feature> UpdateAsync(int id, string name, string? description, CancellationToken ct = default)
|
||||
{
|
||||
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
|
||||
?? 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)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class FeatureUsageDaily
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int EnvironmentId { get; init; }
|
||||
public int FeatureId { get; init; }
|
||||
public required string FeatureName { get; set; }
|
||||
public DateOnly UsageDate { get; init; }
|
||||
public long Count { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -12,11 +12,9 @@ public class FlagCache(IMemoryCache cache)
|
||||
return flags;
|
||||
}
|
||||
|
||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags)
|
||||
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
||||
|
||||
public void Invalidate(int environmentId)
|
||||
=> cache.Remove(CacheKey(environmentId));
|
||||
public void Invalidate(int environmentId) => cache.Remove(CacheKey(environmentId));
|
||||
|
||||
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<ActionResult<IReadOnlyList<TagResponse>>> 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<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
|
||||
{
|
||||
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
|
||||
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
|
||||
}
|
||||
|
||||
[HttpDelete("api/v1/project/{projectId}/tag/{id}")]
|
||||
[HttpDelete("tag/{id}")]
|
||||
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
|
||||
{
|
||||
var tag = await tagService.FindByIdAsync(id, ct);
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
using FluentValidation;
|
||||
using System.Text.RegularExpressions;
|
||||
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 : AbstractValidator<UpdateFeatureRequest>
|
||||
public class UpdateFeatureRequestValidator : IModelValidator<UpdateFeatureRequest>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace MicCheck.Api.Features.Usage;
|
||||
|
||||
public static class DependencyRegistration
|
||||
{
|
||||
public static IServiceCollection AddFeatureUsageServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<FeatureUsageMetrics>();
|
||||
services.AddScoped<FeatureUsageQueryService>();
|
||||
services.AddHostedService<FeatureUsageFlushBackgroundService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
namespace MicCheck.Api.Features.Usage;
|
||||
|
||||
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);
|
||||
@@ -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")]
|
||||
12
src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs
Normal file
12
src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace MicCheck.Api.Features.Usage;
|
||||
|
||||
public record FeatureUsageDaily
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int EnvironmentId { get; init; }
|
||||
public int FeatureId { get; init; }
|
||||
public required string FeatureName { get; init; }
|
||||
public DateOnly UsageDate { get; init; }
|
||||
public long Count { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -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(
|
||||
@@ -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
|
||||
{
|
||||
@@ -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)
|
||||
{
|
||||
@@ -1,9 +1,7 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
namespace MicCheck.Api.Features.Usage;
|
||||
|
||||
public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
|
||||
|
||||
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
|
||||
|
||||
public record DashboardUsageResponse(
|
||||
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
|
||||
IReadOnlyList<DailyUsage> DailyUsage);
|
||||
public record DashboardUsageResponse(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage);
|
||||
Reference in New Issue
Block a user