Adding Admin API

This commit is contained in:
2026-04-08 14:25:10 -07:00
parent 0ba076b650
commit b9a04df861
67 changed files with 3316 additions and 74 deletions

View File

@@ -0,0 +1,26 @@
using FluentValidation;
namespace MicCheck.Api.Features;
public record CreateFeatureRequest(
string Name,
FeatureType Type,
string? InitialValue,
string? Description
);
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
{
public CreateFeatureRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$")
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
RuleFor(x => x.InitialValue)
.MaximumLength(20_000)
.When(x => x.InitialValue is not null);
}
}

View File

@@ -0,0 +1,16 @@
using FluentValidation;
namespace MicCheck.Api.Features;
public record CreateTagRequest(string Label, string Color);
public class CreateTagRequestValidator : AbstractValidator<CreateTagRequest>
{
public CreateTagRequestValidator()
{
RuleFor(x => x.Label).NotEmpty().MaximumLength(100);
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
.Matches("^#[0-9A-Fa-f]{3,6}$")
.WithMessage("Color must be a valid hex color (e.g. #FF0000).");
}
}

View File

@@ -0,0 +1,23 @@
namespace MicCheck.Api.Features;
public record FeatureResponse(
int Id,
string Name,
string Type,
string? InitialValue,
string? Description,
bool DefaultEnabled,
int ProjectId,
DateTimeOffset CreatedAt
)
{
public static FeatureResponse From(Feature feature) => new(
feature.Id,
feature.Name,
feature.Type.ToString().ToUpperInvariant(),
feature.InitialValue,
feature.Description,
feature.DefaultEnabled,
feature.ProjectId,
feature.CreatedAt);
}

View File

@@ -0,0 +1,102 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureService(MicCheckDbContext db, AuditService auditService)
{
private const int MaxFeaturesPerProject = 400;
public async Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{
return await db.Features
.Where(f => f.ProjectId == projectId)
.ToListAsync(ct);
}
public async Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
}
public async Task<Feature> CreateAsync(
int projectId,
string name,
FeatureType type,
string? initialValue,
string? description,
CancellationToken ct = default)
{
var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
if (count >= MaxFeaturesPerProject)
throw new DomainException($"Project has reached the maximum of {MaxFeaturesPerProject} features.");
var feature = new Feature
{
Name = name,
Type = type,
InitialValue = initialValue,
Description = description,
ProjectId = projectId,
CreatedAt = DateTimeOffset.UtcNow
};
db.Features.Add(feature);
await db.SaveChangesAsync(ct);
var environments = await db.Environments
.Where(e => e.ProjectId == projectId)
.ToListAsync(ct);
foreach (var env in environments)
{
db.FeatureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = env.Id,
Enabled = feature.DefaultEnabled,
Value = initialValue,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
}
if (environments.Count > 0)
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is not null)
await auditService.LogAsync("Feature", feature.Id.ToString(), "created",
project.OrganizationId, projectId, ct: ct);
return feature;
}
public async Task<Feature> UpdateAsync(
int id, string name, string? description, CancellationToken ct = default)
{
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found.");
feature.Name = name;
feature.Description = description;
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == feature.ProjectId, ct);
if (project is not null)
await auditService.LogAsync("Feature", feature.Id.ToString(), "updated",
project.OrganizationId, feature.ProjectId, ct: ct);
return feature;
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
if (feature is null) return;
db.Features.Remove(feature);
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,25 @@
namespace MicCheck.Api.Features;
public record FeatureStateResponse(
int Id,
int FeatureId,
int EnvironmentId,
int? IdentityId,
int? FeatureSegmentId,
bool Enabled,
string? Value,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
)
{
public static FeatureStateResponse From(FeatureState state) => new(
state.Id,
state.FeatureId,
state.EnvironmentId,
state.IdentityId,
state.FeatureSegmentId,
state.Enabled,
state.Value,
state.CreatedAt,
state.UpdatedAt);
}

View File

@@ -0,0 +1,46 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureStateService(MicCheckDbContext db)
{
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{
return await db.FeatureStates
.Where(fs => fs.EnvironmentId == environmentId && fs.IdentityId == null && fs.FeatureSegmentId == null)
.ToListAsync(ct);
}
public async Task<FeatureState?> FindByIdAsync(int id, int environmentId, CancellationToken ct = default)
{
return await db.FeatureStates
.FirstOrDefaultAsync(fs => fs.Id == id && fs.EnvironmentId == environmentId, ct);
}
public async Task<FeatureState> UpdateAsync(int id, bool enabled, string? value, CancellationToken ct = default)
{
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
state.Enabled = enabled;
state.Value = value;
state.UpdatedAt = DateTimeOffset.UtcNow;
state.Version++;
await db.SaveChangesAsync(ct);
return state;
}
public async Task<FeatureState> PatchAsync(int id, bool? enabled, string? value, CancellationToken ct = default)
{
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
if (enabled.HasValue) state.Enabled = enabled.Value;
if (value is not null) state.Value = value;
state.UpdatedAt = DateTimeOffset.UtcNow;
state.Version++;
await db.SaveChangesAsync(ct);
return state;
}
}

View File

@@ -0,0 +1,88 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/projects/{projectId}/features")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeaturesController(FeatureService featureService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PaginatedResponse<FeatureResponse>>> List(
int projectId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var all = await featureService.ListByProjectAsync(projectId, ct);
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(FeatureResponse.From).ToList();
return Ok(new PaginatedResponse<FeatureResponse>(all.Count, null, null, paged));
}
[HttpPost]
public async Task<ActionResult<FeatureResponse>> Create(
int projectId, CreateFeatureRequest request, CancellationToken ct)
{
try
{
var feature = await featureService.CreateAsync(
projectId, request.Name, request.Type, request.InitialValue, request.Description, ct);
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id }, FeatureResponse.From(feature));
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("{id}")]
public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
return Ok(FeatureResponse.From(feature));
}
[HttpPut("{id}")]
public async Task<ActionResult<FeatureResponse>> Update(
int projectId, int id, UpdateFeatureRequest request, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
var updated = await featureService.UpdateAsync(id, request.Name, request.Description, ct);
return Ok(FeatureResponse.From(updated));
}
[HttpPatch("{id}")]
public async Task<ActionResult<FeatureResponse>> Patch(
int projectId, int id, PatchFeatureRequest request, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
if (request.Name is not null) feature.Name = request.Name;
if (request.Description is not null) feature.Description = request.Description;
if (request.DefaultEnabled.HasValue) feature.DefaultEnabled = request.DefaultEnabled.Value;
var updated = await featureService.UpdateAsync(id, feature.Name, feature.Description, ct);
return Ok(FeatureResponse.From(updated));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
await featureService.DeleteAsync(id, ct);
return NoContent();
}
}

View File

@@ -0,0 +1,7 @@
namespace MicCheck.Api.Features;
public record PatchFeatureRequest(
string? Name,
string? Description,
bool? DefaultEnabled
);

View File

@@ -0,0 +1,6 @@
namespace MicCheck.Api.Features;
public record TagResponse(int Id, string Label, string Color, int ProjectId)
{
public static TagResponse From(Tag tag) => new(tag.Id, tag.Label, tag.Color, tag.ProjectId);
}

View File

@@ -0,0 +1,33 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class TagService(MicCheckDbContext db)
{
public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{
return await db.Tags.Where(t => t.ProjectId == projectId).ToListAsync(ct);
}
public async Task<Tag> CreateAsync(int projectId, string label, string color, CancellationToken ct = default)
{
var tag = new Tag { Label = label, Color = color, ProjectId = projectId };
db.Tags.Add(tag);
await db.SaveChangesAsync(ct);
return tag;
}
public async Task<Tag?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Tags.FirstOrDefaultAsync(t => t.Id == id, ct);
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == id, ct);
if (tag is null) return;
db.Tags.Remove(tag);
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,37 @@
using MicCheck.Api.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/projects/{projectId}/tags")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class TagsController(TagService tagService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
{
var tags = await tagService.ListByProjectAsync(projectId, ct);
return Ok(tags.Select(TagResponse.From).ToList());
}
[HttpPost]
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
{
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var tag = await tagService.FindByIdAsync(id, ct);
if (tag is null || tag.ProjectId != projectId) return NotFound();
await tagService.DeleteAsync(id, ct);
return NoContent();
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
namespace MicCheck.Api.Features;
public record UpdateFeatureRequest(
string Name,
string? Description
);
public class UpdateFeatureRequestValidator : AbstractValidator<UpdateFeatureRequest>
{
public UpdateFeatureRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$")
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
}
}

View File

@@ -0,0 +1,5 @@
namespace MicCheck.Api.Features;
public record UpdateFeatureStateRequest(bool Enabled, string? Value);
public record PatchFeatureStateRequest(bool? Enabled, string? Value);