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:
@@ -123,7 +123,7 @@
|
|||||||
|
|
||||||
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
|
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
|
||||||
<text x="53" y="14" fill="#fff">Coverage</text>
|
<text x="53" y="14" fill="#fff">Coverage</text>
|
||||||
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">69.2%</text><text class="" x="132.5" y="14">69.2%</text>
|
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">68.9%</text><text class="" x="132.5" y="14">68.9%</text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -1,6 +1,6 @@
|
|||||||
namespace MicCheck.Api.Audit;
|
namespace MicCheck.Api.Audit;
|
||||||
|
|
||||||
public class AuditLog
|
public record AuditLog
|
||||||
{
|
{
|
||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
public required string ResourceType { get; init; }
|
public required string ResourceType { get; init; }
|
||||||
|
|||||||
@@ -6,29 +6,25 @@ namespace MicCheck.Api.Audit;
|
|||||||
|
|
||||||
public class AuditLogQueryService(IMicCheckDbContext db)
|
public class AuditLogQueryService(IMicCheckDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(
|
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default)
|
||||||
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
|
|
||||||
{
|
{
|
||||||
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
|
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
|
||||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(
|
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default)
|
||||||
int projectId, AuditLogFilter filter, CancellationToken ct = default)
|
|
||||||
{
|
{
|
||||||
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
|
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
|
||||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(
|
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default)
|
||||||
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
|
|
||||||
{
|
{
|
||||||
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
|
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
|
||||||
return await ApplyFilterAndPageAsync(query, filter, ct);
|
return await ApplyFilterAndPageAsync(query, filter, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(
|
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
|
||||||
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
if (filter.From.HasValue)
|
if (filter.From.HasValue)
|
||||||
query = query.Where(l => l.CreatedAt >= filter.From.Value);
|
query = query.Where(l => l.CreatedAt >= filter.From.Value);
|
||||||
@@ -66,4 +62,4 @@ public class AuditLogQueryService(IMicCheckDbContext db)
|
|||||||
|
|
||||||
return new PagedResult<AuditLogResponse>(total, items);
|
return new PagedResult<AuditLogResponse>(total, items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Audit;
|
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()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -12,7 +12,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
|
|||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
};
|
};
|
||||||
|
|
||||||
public virtual async Task RecordAsync(
|
public async Task RecordAsync(
|
||||||
string resourceType,
|
string resourceType,
|
||||||
string resourceId,
|
string resourceId,
|
||||||
string action,
|
string action,
|
||||||
@@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
|
|||||||
}, JsonOptions);
|
}, JsonOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
var actorUserId = ResolveActorUserId();
|
var actorUserId = GetCurrentUserId();
|
||||||
|
|
||||||
var log = new AuditLog
|
var log = new AuditLog
|
||||||
{
|
{
|
||||||
@@ -68,7 +68,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Backward-compatible overload used by existing callers
|
// Backward-compatible overload used by existing callers
|
||||||
public virtual async Task LogAsync(
|
public async Task LogAsync(
|
||||||
string resourceType,
|
string resourceType,
|
||||||
string resourceId,
|
string resourceId,
|
||||||
string action,
|
string action,
|
||||||
@@ -78,7 +78,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
|
|||||||
string? changes = null,
|
string? changes = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var actorUserId = ResolveActorUserId();
|
var actorUserId = GetCurrentUserId();
|
||||||
|
|
||||||
var log = new AuditLog
|
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;
|
var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
return int.TryParse(claim, out var id) ? id : null;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
12
src/api/MicCheck.Api/Audit/DependencyRegistration.cs
Normal file
12
src/api/MicCheck.Api/Audit/DependencyRegistration.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MicCheck.Api.Audit;
|
||||||
|
|
||||||
|
public static class DependencyRegistration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAuditServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
|
services.AddScoped<AuditLogQueryService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/api/MicCheck.Api/Common/DependencyRegistration.cs
Normal file
65
src/api/MicCheck.Api/Common/DependencyRegistration.cs
Normal file
@@ -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<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
|
||||||
|
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||||
|
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<ITokenService, TokenService>();
|
||||||
|
services.AddScoped<AuthService>();
|
||||||
|
services.AddScoped<ApiKeyService>();
|
||||||
|
services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
|
||||||
|
|
||||||
|
services.AddModelValidatorsFromAssemblyContaining<Program>();
|
||||||
|
services.Configure<ApiBehaviorOptions>(options =>
|
||||||
|
{
|
||||||
|
options.InvalidModelStateResponseFactory = context => ValidationProblemResponseFactory.Create(context.ModelState);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/api/MicCheck.Api/Common/Guard.cs
Normal file
40
src/api/MicCheck.Api/Common/Guard.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
namespace MicCheck.Api.Common;
|
||||||
|
|
||||||
|
public static class Guard
|
||||||
|
{
|
||||||
|
public static void Null<T>(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<T>(IEnumerable<T> 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>(T value, string parameterName)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(value, default))
|
||||||
|
throw new ArgumentException($"{parameterName} can not be a default value", parameterName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,4 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Common.Security.Authorization;
|
namespace MicCheck.Api.Common.Security.Authorization;
|
||||||
|
|
||||||
public class ProjectPermissionRequirement : IAuthorizationRequirement
|
public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement;
|
||||||
{
|
|
||||||
public ProjectPermission Permission { get; }
|
|
||||||
|
|
||||||
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
|
|
||||||
}
|
|
||||||
|
|||||||
13
src/api/MicCheck.Api/Common/Validation/IModelValidator.cs
Normal file
13
src/api/MicCheck.Api/Common/Validation/IModelValidator.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
|
public interface IModelValidator
|
||||||
|
{
|
||||||
|
ValidationResult Validate(object model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IModelValidator<in T> : IModelValidator
|
||||||
|
{
|
||||||
|
ValidationResult Validate(T model);
|
||||||
|
|
||||||
|
ValidationResult IModelValidator.Validate(object model) => Validate((T)model);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
|
public static class ModelValidatorServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddModelValidatorsFromAssemblyContaining<TMarker>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/api/MicCheck.Api/Common/Validation/ValidationResult.cs
Normal file
14
src/api/MicCheck.Api/Common/Validation/ValidationResult.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
|
public record ValidationError(string PropertyName, string Message);
|
||||||
|
|
||||||
|
public class ValidationResult
|
||||||
|
{
|
||||||
|
private readonly List<ValidationError> _errors = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<ValidationError> 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));
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features.Usage;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
|||||||
21
src/api/MicCheck.Api/Data/DependencyRegistration.cs
Normal file
21
src/api/MicCheck.Api/Data/DependencyRegistration.cs
Normal file
@@ -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<DatabaseSeeder>();
|
||||||
|
|
||||||
|
var connectionString = configuration.GetConnectionString("miccheck")
|
||||||
|
?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
|
||||||
|
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
|
||||||
|
: configuration.GetConnectionString("DefaultConnection")!);
|
||||||
|
|
||||||
|
services.AddDbContext<MicCheckDbContext>(options => options.UseNpgsql(connectionString));
|
||||||
|
services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using MicCheck.Api.Common.Security.ApiKeys;
|
|||||||
using MicCheck.Api.Audit;
|
using MicCheck.Api.Audit;
|
||||||
using MicCheck.Api.Common.Security.Authorization;
|
using MicCheck.Api.Common.Security.Authorization;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
|
using MicCheck.Api.Features.Usage;
|
||||||
using MicCheck.Api.Identities;
|
using MicCheck.Api.Identities;
|
||||||
using MicCheck.Api.Organizations;
|
using MicCheck.Api.Organizations;
|
||||||
using MicCheck.Api.Projects;
|
using MicCheck.Api.Projects;
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Environments;
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
public record CloneEnvironmentRequest(string Name);
|
public record CloneEnvironmentRequest(string Name);
|
||||||
|
|
||||||
public class CloneEnvironmentRequestValidator : AbstractValidator<CloneEnvironmentRequest>
|
public class CloneEnvironmentRequestValidator : IModelValidator<CloneEnvironmentRequest>
|
||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Environments;
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
public record CreateEnvironmentRequest(string Name, int ProjectId);
|
public record CreateEnvironmentRequest(string Name, int ProjectId);
|
||||||
|
|
||||||
public class CreateEnvironmentRequestValidator : AbstractValidator<CreateEnvironmentRequest>
|
public class CreateEnvironmentRequestValidator : IModelValidator<CreateEnvironmentRequest>
|
||||||
{
|
{
|
||||||
public CreateEnvironmentRequestValidator()
|
public ValidationResult Validate(CreateEnvironmentRequest model)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
var result = new ValidationResult();
|
||||||
RuleFor(x => x.ProjectId).GreaterThan(0);
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/api/MicCheck.Api/Environments/DependencyRegistration.cs
Normal file
12
src/api/MicCheck.Api/Environments/DependencyRegistration.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
|
public static class DependencyRegistration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddEnvironmentsServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<EnvironmentService>();
|
||||||
|
services.AddScoped<EnvironmentDocumentService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Environments;
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
public class EnvironmentService(IMicCheckDbContext db, AuditService auditService)
|
public class EnvironmentService(IMicCheckDbContext db, IAuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Environments;
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
public record UpdateEnvironmentRequest(string Name);
|
public record UpdateEnvironmentRequest(string Name);
|
||||||
|
|
||||||
public class UpdateEnvironmentRequestValidator : AbstractValidator<UpdateEnvironmentRequest>
|
public class UpdateEnvironmentRequestValidator : IModelValidator<UpdateEnvironmentRequest>
|
||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
using FluentValidation;
|
using System.Text.RegularExpressions;
|
||||||
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public record CreateFeatureRequest(
|
public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description);
|
||||||
string Name,
|
|
||||||
FeatureType Type,
|
|
||||||
string? InitialValue,
|
|
||||||
string? Description
|
|
||||||
);
|
|
||||||
|
|
||||||
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
|
public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest>
|
||||||
{
|
{
|
||||||
public CreateFeatureRequestValidator()
|
private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
|
||||||
{
|
|
||||||
RuleFor(x => x.Name)
|
|
||||||
.NotEmpty()
|
|
||||||
.MaximumLength(150)
|
|
||||||
.Matches("^[a-zA-Z0-9_-]+$")
|
|
||||||
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
|
|
||||||
|
|
||||||
RuleFor(x => x.InitialValue)
|
public ValidationResult Validate(CreateFeatureRequest model)
|
||||||
.MaximumLength(20_000)
|
{
|
||||||
.When(x => x.InitialValue is not null);
|
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;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public record CreateTagRequest(string Label, string Color);
|
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);
|
var result = new ValidationResult();
|
||||||
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
|
|
||||||
.Matches("^#[0-9A-Fa-f]{3,6}$")
|
if (string.IsNullOrEmpty(model.Label))
|
||||||
.WithMessage("Color must be a valid hex color (e.g. #FF0000).");
|
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.Data;
|
||||||
|
using MicCheck.Api.Features.Usage;
|
||||||
using MicCheck.Api.Identities;
|
using MicCheck.Api.Identities;
|
||||||
using MicCheck.Api.Segments;
|
using MicCheck.Api.Segments;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
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;
|
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);
|
return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Feature> CreateAsync(
|
public async Task<Feature> CreateAsync(int projectId, string name, FeatureType type, string? initialValue, string? description, CancellationToken ct = default)
|
||||||
int projectId,
|
|
||||||
string name,
|
|
||||||
FeatureType type,
|
|
||||||
string? initialValue,
|
|
||||||
string? description,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
{
|
||||||
var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
|
var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
|
||||||
if (count >= MaxFeaturesPerProject)
|
if (count >= MaxFeaturesPerProject)
|
||||||
@@ -78,8 +72,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We
|
|||||||
return feature;
|
return feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Feature> UpdateAsync(
|
public async Task<Feature> UpdateAsync(int id, string name, string? description, CancellationToken ct = default)
|
||||||
int id, string name, string? description, CancellationToken ct = default)
|
|
||||||
{
|
{
|
||||||
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
|
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
|
||||||
?? throw new KeyNotFoundException($"Feature {id} not found.");
|
?? throw new KeyNotFoundException($"Feature {id} not found.");
|
||||||
@@ -162,11 +155,7 @@ public class FeatureService(IMicCheckDbContext db, AuditService auditService, We
|
|||||||
EventType = WebhookEventTypes.FlagDeleted,
|
EventType = WebhookEventTypes.FlagDeleted,
|
||||||
EnvironmentId = env.Id,
|
EnvironmentId = env.Id,
|
||||||
OrganizationId = project.OrganizationId,
|
OrganizationId = project.OrganizationId,
|
||||||
Data = new FlagDeletedData(
|
Data = new FlagDeletedData(null, DateTimeOffset.UtcNow, new FeatureSummary(feature.Id, feature.Name)) });
|
||||||
null,
|
|
||||||
DateTimeOffset.UtcNow,
|
|
||||||
new FeatureSummary(feature.Id, feature.Name))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class FeatureStateResult
|
public record FeatureStateResult
|
||||||
{
|
{
|
||||||
public required Feature Feature { get; init; }
|
public required Feature Feature { get; init; }
|
||||||
public bool Enabled { get; init; }
|
public bool Enabled { get; init; }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
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)
|
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;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags)
|
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
||||||
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
|
||||||
|
|
||||||
public void Invalidate(int environmentId)
|
public void Invalidate(int environmentId) => cache.Remove(CacheKey(environmentId));
|
||||||
=> cache.Remove(CacheKey(environmentId));
|
|
||||||
|
|
||||||
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
|
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public class Tag
|
public record Tag
|
||||||
{
|
{
|
||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
public required string Label { get; set; }
|
public required string Label { get; init; }
|
||||||
public required string Color { get; set; }
|
public required string Color { get; init; }
|
||||||
public int ProjectId { get; init; }
|
public int ProjectId { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,24 @@ namespace MicCheck.Api.Features;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||||
[EnableRateLimiting("AdminApi")]
|
[EnableRateLimiting("AdminApi")]
|
||||||
|
[Route("api/v1/project/{projectId}")]
|
||||||
public class TagsController(TagService tagService) : ControllerBase
|
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)
|
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var tags = await tagService.ListByProjectAsync(projectId, ct);
|
var tags = await tagService.ListByProjectAsync(projectId, ct);
|
||||||
return Ok(tags.Select(TagResponse.From).ToList());
|
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)
|
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
|
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
|
||||||
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
|
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)
|
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var tag = await tagService.FindByIdAsync(id, 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;
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
public record UpdateFeatureRequest(
|
public record UpdateFeatureRequest(string Name, string? Description);
|
||||||
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)
|
var result = new ValidationResult();
|
||||||
.NotEmpty()
|
|
||||||
.MaximumLength(150)
|
if (string.IsNullOrEmpty(model.Name))
|
||||||
.Matches("^[a-zA-Z0-9_-]+$")
|
result.AddError(nameof(model.Name), "'Name' must not be empty.");
|
||||||
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
|
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);
|
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.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features.Usage;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/environment/{environmentId}/usage")]
|
[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 MicCheck.Api.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
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.")]
|
[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(
|
public class FeatureUsageFlushBackgroundService(
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features.Usage;
|
||||||
|
|
||||||
public class FeatureUsageMetrics : IDisposable
|
public class FeatureUsageMetrics : IDisposable
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace MicCheck.Api.Features;
|
namespace MicCheck.Api.Features.Usage;
|
||||||
|
|
||||||
public class FeatureUsageQueryService(IMicCheckDbContext db)
|
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 TopFeatureUsage(int FeatureId, string FeatureName, long Count);
|
||||||
|
|
||||||
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
|
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
|
||||||
|
|
||||||
public record DashboardUsageResponse(
|
public record DashboardUsageResponse(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage);
|
||||||
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
|
|
||||||
IReadOnlyList<DailyUsage> DailyUsage);
|
|
||||||
12
src/api/MicCheck.Api/Identities/DependencyRegistration.cs
Normal file
12
src/api/MicCheck.Api/Identities/DependencyRegistration.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MicCheck.Api.Identities;
|
||||||
|
|
||||||
|
public static class DependencyRegistration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddIdentitiesServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IdentityResolutionService>();
|
||||||
|
services.AddScoped<AdminIdentityService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.OpenApi" Version="2.9.0" />
|
<PackageReference Include="Microsoft.OpenApi" Version="2.9.0" />
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ namespace MicCheck.Api.Migrations
|
|||||||
b.ToTable("FeatureStates");
|
b.ToTable("FeatureStates");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
|
modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ namespace MicCheck.Api.Migrations
|
|||||||
b.ToTable("FeatureStates");
|
b.ToTable("FeatureStates");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
|
modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Organizations;
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
public record CreateOrganizationRequest(string Name);
|
public record CreateOrganizationRequest(string Name);
|
||||||
|
|
||||||
public class CreateOrganizationRequestValidator : AbstractValidator<CreateOrganizationRequest>
|
public class CreateOrganizationRequestValidator : IModelValidator<CreateOrganizationRequest>
|
||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/api/MicCheck.Api/Organizations/DependencyRegistration.cs
Normal file
11
src/api/MicCheck.Api/Organizations/DependencyRegistration.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
|
public static class DependencyRegistration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddOrganizationsServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<OrganizationService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Organizations;
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
public record InviteUserRequest(int UserId, string Role);
|
public record InviteUserRequest(int UserId, string Role);
|
||||||
|
|
||||||
public class InviteUserRequestValidator : AbstractValidator<InviteUserRequest>
|
public class InviteUserRequestValidator : IModelValidator<InviteUserRequest>
|
||||||
{
|
{
|
||||||
public InviteUserRequestValidator()
|
public ValidationResult Validate(InviteUserRequest model)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.UserId).GreaterThan(0);
|
var result = new ValidationResult();
|
||||||
RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse<OrganizationRole>(r, true, out _))
|
|
||||||
.WithMessage("Role must be 'User' or 'Admin'.");
|
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<OrganizationRole>(model.Role, true, out _))
|
||||||
|
result.AddError(nameof(model.Role), "Role must be 'User' or 'Admin'.");
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Organizations;
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
public class OrganizationService(IMicCheckDbContext db, AuditService auditService)
|
public class OrganizationService(IMicCheckDbContext db, IAuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Organizations;
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
public record UpdateOrganizationRequest(string Name);
|
public record UpdateOrganizationRequest(string Name);
|
||||||
|
|
||||||
public class UpdateOrganizationRequestValidator : AbstractValidator<UpdateOrganizationRequest>
|
public class UpdateOrganizationRequestValidator : IModelValidator<UpdateOrganizationRequest>
|
||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using FluentValidation;
|
|
||||||
using FluentValidation.AspNetCore;
|
|
||||||
using MicCheck.Api.Common.Security.ApiKeys;
|
|
||||||
using MicCheck.Api.Audit;
|
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.Security.Authorization;
|
||||||
|
using MicCheck.Api.Common.Validation;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Environments;
|
using MicCheck.Api.Environments;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
@@ -15,14 +13,7 @@ using MicCheck.Api.Projects;
|
|||||||
using MicCheck.Api.Segments;
|
using MicCheck.Api.Segments;
|
||||||
using MicCheck.Api.Users;
|
using MicCheck.Api.Users;
|
||||||
using MicCheck.Api.Webhooks;
|
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.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
@@ -42,44 +33,12 @@ try
|
|||||||
.WriteTo.Console());
|
.WriteTo.Console());
|
||||||
|
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers(options => options.Filters.Add<ModelValidationActionFilter>())
|
||||||
.AddJsonOptions(options =>
|
.AddJsonOptions(options =>
|
||||||
options.JsonSerializerOptions.Converters.Add(
|
options.JsonSerializerOptions.Converters.Add(
|
||||||
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
||||||
|
|
||||||
builder.Services.AddAuthentication()
|
builder.Services.AddCommonServices(builder.Configuration);
|
||||||
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
|
|
||||||
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
|
|
||||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
|
||||||
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.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
@@ -92,67 +51,20 @@ try
|
|||||||
limiter.QueueLimit = 0;
|
limiter.QueueLimit = 0;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
builder.Services.AddFluentValidationAutoValidation();
|
|
||||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
|
||||||
|
|
||||||
builder.Services.Configure<ApiBehaviorOptions>(options =>
|
|
||||||
{
|
|
||||||
options.InvalidModelStateResponseFactory = context =>
|
|
||||||
{
|
|
||||||
var errors = context.ModelState
|
|
||||||
.Where(e => e.Value?.Errors.Count > 0)
|
|
||||||
.ToDictionary(
|
|
||||||
kvp => kvp.Key,
|
|
||||||
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
|
|
||||||
return new UnprocessableEntityObjectResult(new { errors });
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddMetrics();
|
builder.Services.AddMetrics();
|
||||||
|
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
builder.Services.AddAuditServices();
|
||||||
builder.Services.AddScoped<AuthService>();
|
builder.Services.AddIdentitiesServices();
|
||||||
builder.Services.AddScoped<ApiKeyService>();
|
builder.Services.AddEnvironmentsServices();
|
||||||
builder.Services.AddScoped<DatabaseSeeder>();
|
builder.Services.AddSegmentsServices();
|
||||||
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
|
builder.Services.AddOrganizationsServices();
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
|
builder.Services.AddProjectsServices();
|
||||||
builder.Services.AddScoped<FeatureEvaluationService>();
|
builder.Services.AddFeaturesServices();
|
||||||
builder.Services.AddScoped<IdentityResolutionService>();
|
builder.Services.AddUsersServices();
|
||||||
builder.Services.AddScoped<EnvironmentDocumentService>();
|
builder.Services.AddWebhooksServices();
|
||||||
builder.Services.AddSingleton<SegmentEvaluator>();
|
|
||||||
builder.Services.AddSingleton<FlagCache>();
|
|
||||||
builder.Services.AddScoped<AuditService>();
|
|
||||||
builder.Services.AddScoped<AuditLogQueryService>();
|
|
||||||
builder.Services.AddScoped<OrganizationService>();
|
|
||||||
builder.Services.AddScoped<ProjectService>();
|
|
||||||
builder.Services.AddScoped<EnvironmentService>();
|
|
||||||
builder.Services.AddScoped<FeatureService>();
|
|
||||||
builder.Services.AddScoped<FeatureStateService>();
|
|
||||||
builder.Services.AddScoped<FeatureSegmentService>();
|
|
||||||
builder.Services.AddScoped<SegmentService>();
|
|
||||||
builder.Services.AddScoped<TagService>();
|
|
||||||
builder.Services.AddScoped<WebhookService>();
|
|
||||||
builder.Services.AddScoped<WebhookDispatcher>();
|
|
||||||
builder.Services.AddScoped<AdminIdentityService>();
|
|
||||||
builder.Services.AddScoped<UserService>();
|
|
||||||
builder.Services.AddSingleton<WebhookQueue>();
|
|
||||||
builder.Services.AddHostedService<WebhookBackgroundService>();
|
|
||||||
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
|
|
||||||
builder.Services.AddSingleton<FeatureUsageMetrics>();
|
|
||||||
builder.Services.AddScoped<FeatureUsageQueryService>();
|
|
||||||
builder.Services.AddHostedService<FeatureUsageFlushBackgroundService>();
|
|
||||||
builder.Services.AddHttpClient("Webhooks", client =>
|
|
||||||
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
|
|
||||||
|
|
||||||
var connectionString = builder.Configuration.GetConnectionString("miccheck")
|
builder.Services.AddDataServices(builder.Configuration);
|
||||||
?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
|
|
||||||
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
|
|
||||||
: builder.Configuration.GetConnectionString("DefaultConnection")!);
|
|
||||||
|
|
||||||
builder.Services.AddDbContext<MicCheckDbContext>(options =>
|
|
||||||
options.UseNpgsql(connectionString));
|
|
||||||
builder.Services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Projects;
|
namespace MicCheck.Api.Projects;
|
||||||
|
|
||||||
public record CreateProjectRequest(string Name, int OrganizationId);
|
public record CreateProjectRequest(string Name, int OrganizationId);
|
||||||
|
|
||||||
public class CreateProjectRequestValidator : AbstractValidator<CreateProjectRequest>
|
public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest>
|
||||||
{
|
{
|
||||||
public CreateProjectRequestValidator()
|
public ValidationResult Validate(CreateProjectRequest model)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
var result = new ValidationResult();
|
||||||
RuleFor(x => x.OrganizationId).GreaterThan(0);
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/api/MicCheck.Api/Projects/DependencyRegistration.cs
Normal file
11
src/api/MicCheck.Api/Projects/DependencyRegistration.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace MicCheck.Api.Projects;
|
||||||
|
|
||||||
|
public static class DependencyRegistration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddProjectsServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<ProjectService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace MicCheck.Api.Projects;
|
namespace MicCheck.Api.Projects;
|
||||||
|
|
||||||
public class ProjectService(IMicCheckDbContext db, AuditService auditService)
|
public class ProjectService(IMicCheckDbContext db, IAuditService auditService)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Security.Authorization;
|
||||||
using MicCheck.Api.Common.Security.Authorization;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Projects;
|
namespace MicCheck.Api.Projects;
|
||||||
|
|
||||||
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
|
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
|
||||||
|
|
||||||
public class SetUserPermissionsRequestValidator : AbstractValidator<SetUserPermissionsRequest>
|
public class SetUserPermissionsRequestValidator : IModelValidator<SetUserPermissionsRequest>
|
||||||
{
|
{
|
||||||
public SetUserPermissionsRequestValidator()
|
public ValidationResult Validate(SetUserPermissionsRequest model)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.UserId).GreaterThan(0);
|
var result = new ValidationResult();
|
||||||
RuleForEach(x => x.Permissions)
|
|
||||||
.Must(p => Enum.TryParse<ProjectPermission>(p, true, out _))
|
if (model.UserId <= 0)
|
||||||
.WithMessage("Invalid permission value.");
|
result.AddError(nameof(model.UserId), "'User Id' must be greater than 0.");
|
||||||
}
|
|
||||||
}
|
foreach (var permission in model.Permissions)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<ProjectPermission>(permission, true, out _))
|
||||||
|
result.AddError(nameof(model.Permissions), "Invalid permission value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Projects;
|
namespace MicCheck.Api.Projects;
|
||||||
|
|
||||||
public record UpdateProjectRequest(string Name, bool HideDisabledFlags);
|
public record UpdateProjectRequest(string Name, bool HideDisabledFlags);
|
||||||
|
|
||||||
public class UpdateProjectRequestValidator : AbstractValidator<UpdateProjectRequest>
|
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest>
|
||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Segments;
|
namespace MicCheck.Api.Segments;
|
||||||
|
|
||||||
@@ -16,24 +16,38 @@ public record CreateSegmentRequest(
|
|||||||
string Name,
|
string Name,
|
||||||
IReadOnlyList<CreateSegmentRuleRequest> Rules);
|
IReadOnlyList<CreateSegmentRuleRequest> Rules);
|
||||||
|
|
||||||
public class CreateSegmentRequestValidator : AbstractValidator<CreateSegmentRequest>
|
public class CreateSegmentRequestValidator : IModelValidator<CreateSegmentRequest>
|
||||||
{
|
{
|
||||||
public CreateSegmentRequestValidator()
|
public ValidationResult Validate(CreateSegmentRequest model)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
var result = new ValidationResult();
|
||||||
RuleFor(x => x.Rules).NotNull();
|
|
||||||
RuleForEach(x => x.Rules).ChildRules(rule =>
|
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)
|
result.AddError(nameof(model.Rules), "'Rules' must not be empty.");
|
||||||
.Must(t => Enum.TryParse<SegmentRuleType>(t, true, out _))
|
return result;
|
||||||
.WithMessage("Rule type must be 'All', 'Any', or 'None'.");
|
}
|
||||||
rule.RuleForEach(r => r.Conditions).ChildRules(cond =>
|
|
||||||
|
foreach (var rule in model.Rules)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<SegmentRuleType>(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();
|
if (string.IsNullOrEmpty(condition.Property))
|
||||||
cond.RuleFor(c => c.Operator)
|
result.AddError(nameof(model.Rules), "'Property' must not be empty.");
|
||||||
.Must(o => Enum.TryParse<SegmentConditionOperator>(o, true, out _))
|
|
||||||
.WithMessage("Invalid operator.");
|
if (!Enum.TryParse<SegmentConditionOperator>(condition.Operator, true, out _))
|
||||||
});
|
result.AddError(nameof(model.Rules), "Invalid operator.");
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/api/MicCheck.Api/Segments/DependencyRegistration.cs
Normal file
12
src/api/MicCheck.Api/Segments/DependencyRegistration.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MicCheck.Api.Segments;
|
||||||
|
|
||||||
|
public static class DependencyRegistration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddSegmentsServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<SegmentEvaluator>();
|
||||||
|
services.AddScoped<SegmentService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ public record SegmentConditionDefinition(string Property, SegmentConditionOperat
|
|||||||
|
|
||||||
public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
|
public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
|
||||||
|
|
||||||
public class SegmentService(IMicCheckDbContext db, AuditService auditService)
|
public class SegmentService(IMicCheckDbContext db, IAuditService auditService)
|
||||||
{
|
{
|
||||||
private const int MaxSegmentsPerProject = 100;
|
private const int MaxSegmentsPerProject = 100;
|
||||||
private const int MaxConditionsPerSegment = 100;
|
private const int MaxConditionsPerSegment = 100;
|
||||||
|
|||||||
14
src/api/MicCheck.Api/Users/DependencyRegistration.cs
Normal file
14
src/api/MicCheck.Api/Users/DependencyRegistration.cs
Normal file
@@ -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<UserService>();
|
||||||
|
services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
using FluentValidation;
|
using MicCheck.Api.Common.Validation;
|
||||||
|
|
||||||
namespace MicCheck.Api.Webhooks;
|
namespace MicCheck.Api.Webhooks;
|
||||||
|
|
||||||
public record CreateWebhookRequest(string Url, string? Secret, bool Enabled);
|
public record CreateWebhookRequest(string Url, string? Secret, bool Enabled);
|
||||||
|
|
||||||
public class CreateWebhookRequestValidator : AbstractValidator<CreateWebhookRequest>
|
public class CreateWebhookRequestValidator : IModelValidator<CreateWebhookRequest>
|
||||||
{
|
{
|
||||||
public CreateWebhookRequestValidator()
|
public ValidationResult Validate(CreateWebhookRequest model)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Url).NotEmpty().MaximumLength(500).Must(u => Uri.TryCreate(u, UriKind.Absolute, out _))
|
var result = new ValidationResult();
|
||||||
.WithMessage("Url must be a valid absolute URL.");
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs
Normal file
17
src/api/MicCheck.Api/Webhooks/DependencyRegistration.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace MicCheck.Api.Webhooks;
|
||||||
|
|
||||||
|
public static class DependencyRegistration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddWebhooksServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<WebhookService>();
|
||||||
|
services.AddScoped<WebhookDispatcher>();
|
||||||
|
services.AddSingleton<WebhookQueue>();
|
||||||
|
services.AddHostedService<WebhookBackgroundService>();
|
||||||
|
services.AddHostedService<WebhookRetryBackgroundService>();
|
||||||
|
services.AddHttpClient("Webhooks", client =>
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace MicCheck.Api.Webhooks;
|
namespace MicCheck.Api.Webhooks;
|
||||||
|
|
||||||
public class WebhookEvent
|
public record WebhookEvent
|
||||||
{
|
{
|
||||||
public required string EventType { get; init; }
|
public required string EventType { get; init; }
|
||||||
public int? EnvironmentId { get; init; }
|
public int? EnvironmentId { get; init; }
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public class AdminApiIntegrationTests
|
|||||||
private List<FeatureState> _featureStates = null!;
|
private List<FeatureState> _featureStates = null!;
|
||||||
private List<AppEnvironment> _environments = null!;
|
private List<AppEnvironment> _environments = null!;
|
||||||
private List<Segment> _segments = null!;
|
private List<Segment> _segments = null!;
|
||||||
private Mock<AuditService> _auditService = null!;
|
private Mock<IAuditService> _auditService = null!;
|
||||||
private int _organizationId;
|
private int _organizationId;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
@@ -50,7 +50,7 @@ public class AdminApiIntegrationTests
|
|||||||
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []);
|
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []);
|
||||||
|
|
||||||
var webhookQueue = new WebhookQueue();
|
var webhookQueue = new WebhookQueue();
|
||||||
_auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
_auditService = new Mock<IAuditService>();
|
||||||
_auditService.Setup(a => a.LogAsync(
|
_auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ public class AuditLogsControllerTests
|
|||||||
_db.SetupDbSet(c => c.AuditLogs, _auditLogs);
|
_db.SetupDbSet(c => c.AuditLogs, _auditLogs);
|
||||||
_db.SetupDbSet(c => c.Users, []);
|
_db.SetupDbSet(c => c.Users, []);
|
||||||
|
|
||||||
var auditServiceMock = new Mock<AuditService>(_db.Object, null!, new MicCheck.Api.Webhooks.WebhookQueue());
|
var auditServiceMock = new Mock<IAuditService>();
|
||||||
var auditService = auditServiceMock.Object;
|
var auditService = auditServiceMock.Object;
|
||||||
|
|
||||||
_controller = new AuditLogsController(
|
_controller = new AuditLogsController(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class EnvironmentServiceTests
|
|||||||
_db.SetupDbSet(c => c.Identities, _identities);
|
_db.SetupDbSet(c => c.Identities, _identities);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.LogAsync(
|
auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ public class EnvironmentsControllerTests
|
|||||||
_db.SetupDbSet(c => c.Users, []);
|
_db.SetupDbSet(c => c.Users, []);
|
||||||
|
|
||||||
var webhookQueue = new WebhookQueue();
|
var webhookQueue = new WebhookQueue();
|
||||||
var auditServiceMock = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditServiceMock = new Mock<IAuditService>();
|
||||||
auditServiceMock.Setup(a => a.LogAsync(
|
auditServiceMock.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
|
using MicCheck.Api.Features.Usage;
|
||||||
using MicCheck.Api.Identities;
|
using MicCheck.Api.Identities;
|
||||||
using MicCheck.Api.Segments;
|
using MicCheck.Api.Segments;
|
||||||
using MicCheck.Api.Tests.Unit.TestSupport;
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class FeatureServiceTests
|
|||||||
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
|
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.RecordAsync(
|
auditService.Setup(a => a.RecordAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public class FeatureStateServiceTests
|
|||||||
_db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates);
|
_db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates);
|
||||||
|
|
||||||
var webhookQueue = new WebhookQueue();
|
var webhookQueue = new WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.RecordAsync(
|
auditService.Setup(a => a.RecordAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
@@ -154,7 +154,7 @@ public class FeatureStateServiceTests
|
|||||||
var state = AddState();
|
var state = AddState();
|
||||||
var queue = new WebhookQueue();
|
var queue = new WebhookQueue();
|
||||||
|
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, queue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.RecordAsync(
|
auditService.Setup(a => a.RecordAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class FeaturesControllerTests
|
|||||||
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
|
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.RecordAsync(
|
auditService.Setup(a => a.RecordAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Security.Claims;
|
|||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Environments;
|
using MicCheck.Api.Environments;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
|
using MicCheck.Api.Features.Usage;
|
||||||
using MicCheck.Api.Identities;
|
using MicCheck.Api.Identities;
|
||||||
using MicCheck.Api.Projects;
|
using MicCheck.Api.Projects;
|
||||||
using MicCheck.Api.Segments;
|
using MicCheck.Api.Segments;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features.Usage;
|
||||||
using MicCheck.Api.Tests.Unit.TestSupport;
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
namespace MicCheck.Api.Tests.Unit.Features;
|
namespace MicCheck.Api.Tests.Unit.Features.Usage;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class FeatureUsageControllerTests
|
public class FeatureUsageControllerTests
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features.Usage;
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
namespace MicCheck.Api.Tests.Unit.Features;
|
namespace MicCheck.Api.Tests.Unit.Features.Usage;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class FeatureUsageMetricsTests
|
public class FeatureUsageMetricsTests
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features.Usage;
|
||||||
using MicCheck.Api.Tests.Unit.TestSupport;
|
using MicCheck.Api.Tests.Unit.TestSupport;
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
namespace MicCheck.Api.Tests.Unit.Features;
|
namespace MicCheck.Api.Tests.Unit.Features.Usage;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class FeatureUsageQueryServiceTests
|
public class FeatureUsageQueryServiceTests
|
||||||
@@ -2,6 +2,7 @@ using System.Security.Claims;
|
|||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Environments;
|
using MicCheck.Api.Environments;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
|
using MicCheck.Api.Features.Usage;
|
||||||
using MicCheck.Api.Identities;
|
using MicCheck.Api.Identities;
|
||||||
using MicCheck.Api.Projects;
|
using MicCheck.Api.Projects;
|
||||||
using MicCheck.Api.Segments;
|
using MicCheck.Api.Segments;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class OrganizationServiceTests
|
|||||||
_db.SetupDbSet(c => c.Users, _users);
|
_db.SetupDbSet(c => c.Users, _users);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.LogAsync(
|
auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public class OrganizationsControllerTests
|
|||||||
_db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks);
|
_db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks);
|
||||||
|
|
||||||
var webhookQueue = new WebhookQueue();
|
var webhookQueue = new WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.LogAsync(
|
auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public class ProjectServiceTests
|
|||||||
_db.SetupDbSet(c => c.UserProjectPermissions, _permissions);
|
_db.SetupDbSet(c => c.UserProjectPermissions, _permissions);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.LogAsync(
|
auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public class ProjectsControllerTests
|
|||||||
_db.SetupDbSet(c => c.UserProjectPermissions, _permissions);
|
_db.SetupDbSet(c => c.UserProjectPermissions, _permissions);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.LogAsync(
|
auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class SegmentServiceTests
|
|||||||
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions);
|
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.LogAsync(
|
auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class SegmentsControllerTests
|
|||||||
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions);
|
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions);
|
||||||
|
|
||||||
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
|
||||||
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
|
var auditService = new Mock<IAuditService>();
|
||||||
auditService.Setup(a => a.LogAsync(
|
auditService.Setup(a => a.LogAsync(
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
|
||||||
|
|||||||
Reference in New Issue
Block a user