Adding Admin API
This commit is contained in:
26
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal file
26
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal file
16
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal 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).");
|
||||
}
|
||||
}
|
||||
23
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal file
23
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal 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);
|
||||
}
|
||||
102
src/api/MicCheck.Api/Features/FeatureService.cs
Normal file
102
src/api/MicCheck.Api/Features/FeatureService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/api/MicCheck.Api/Features/FeatureStateResponse.cs
Normal file
25
src/api/MicCheck.Api/Features/FeatureStateResponse.cs
Normal 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);
|
||||
}
|
||||
46
src/api/MicCheck.Api/Features/FeatureStateService.cs
Normal file
46
src/api/MicCheck.Api/Features/FeatureStateService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
88
src/api/MicCheck.Api/Features/FeaturesController.cs
Normal file
88
src/api/MicCheck.Api/Features/FeaturesController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
7
src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
Normal file
7
src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record PatchFeatureRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
bool? DefaultEnabled
|
||||
);
|
||||
6
src/api/MicCheck.Api/Features/TagResponse.cs
Normal file
6
src/api/MicCheck.Api/Features/TagResponse.cs
Normal 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);
|
||||
}
|
||||
33
src/api/MicCheck.Api/Features/TagService.cs
Normal file
33
src/api/MicCheck.Api/Features/TagService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
src/api/MicCheck.Api/Features/TagsController.cs
Normal file
37
src/api/MicCheck.Api/Features/TagsController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
20
src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
Normal file
20
src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record UpdateFeatureStateRequest(bool Enabled, string? Value);
|
||||
|
||||
public record PatchFeatureStateRequest(bool? Enabled, string? Value);
|
||||
Reference in New Issue
Block a user