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%
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..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,
@@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}, JsonOptions);
}
- var actorUserId = ResolveActorUserId();
+ var actorUserId = GetCurrentUserId();
var log = new AuditLog
{
@@ -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,
@@ -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,9 +112,33 @@ 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;
}
}
+
+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/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/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/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/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/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/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/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/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/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/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/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..376b484 100755
--- a/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
+++ b/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
@@ -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
+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/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/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/FeatureService.cs b/src/api/MicCheck.Api/Features/FeatureService.cs
index 77793b9..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;
@@ -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/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/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/Features/FeatureUsageDaily.cs b/src/api/MicCheck.Api/Features/FeatureUsageDaily.cs
deleted file mode 100644
index 30ed719..0000000
--- a/src/api/MicCheck.Api/Features/FeatureUsageDaily.cs
+++ /dev/null
@@ -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; }
-}
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/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 451733d..4dde7e0 100755
--- a/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
+++ b/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
@@ -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
+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/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/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/Usage/FeatureUsageDaily.cs b/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs
new file mode 100644
index 0000000..114975a
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageDaily.cs
@@ -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; }
+}
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 51%
rename from src/api/MicCheck.Api/Features/FeatureUsageResponses.cs
rename to src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs
index 201d8b5..b067b80 100644
--- a/src/api/MicCheck.Api/Features/FeatureUsageResponses.cs
+++ b/src/api/MicCheck.Api/Features/Usage/FeatureUsageResponses.cs
@@ -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 Features);
-public record DashboardUsageResponse(
- IReadOnlyList TopFeaturesLastDay,
- IReadOnlyList DailyUsage);
+public record DashboardUsageResponse(IReadOnlyList TopFeaturesLastDay, IReadOnlyList DailyUsage);
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/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/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/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/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/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/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/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..fa7569a 100755
--- a/src/api/MicCheck.Api/Program.cs
+++ b/src/api/MicCheck.Api/Program.cs
@@ -1,11 +1,9 @@
-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;
+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;
@@ -15,14 +13,7 @@ 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;
@@ -42,44 +33,12 @@ 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()));
- 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,67 +51,20 @@ try
limiter.QueueLimit = 0;
}));
- builder.Services.AddFluentValidationAutoValidation();
- builder.Services.AddValidatorsFromAssemblyContaining();
-
- 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 });
- };
- });
-
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/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/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/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/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/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/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/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/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;
}
}
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;
+ }
+}
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; }
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/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/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/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;
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