version-0.1 (#1)
Squash and merge of version-0.1 Co-authored-by: James Wampler <james@wamp.dev> Co-committed-by: James Wampler <james@wamp.dev>
This commit was merged in pull request #1.
This commit is contained in:
0
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/Feature.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/Feature.cs
Normal file → Executable file
10
src/api/MicCheck.Api/Features/FeatureEvaluationService.cs
Normal file → Executable file
10
src/api/MicCheck.Api/Features/FeatureEvaluationService.cs
Normal file → Executable file
@@ -8,7 +8,8 @@ namespace MicCheck.Api.Features;
|
||||
public class FeatureEvaluationService(
|
||||
MicCheckDbContext db,
|
||||
SegmentEvaluator segmentEvaluator,
|
||||
FlagCache flagCache)
|
||||
FlagCache flagCache,
|
||||
FeatureUsageMetrics usageMetrics)
|
||||
{
|
||||
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
|
||||
int environmentId, CancellationToken ct = default)
|
||||
@@ -32,6 +33,10 @@ public class FeatureEvaluationService(
|
||||
).ToListAsync(ct);
|
||||
|
||||
flagCache.Set(environmentId, results);
|
||||
|
||||
foreach (var r in results)
|
||||
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -134,6 +139,9 @@ public class FeatureEvaluationService(
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var r in results)
|
||||
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
6
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal file → Executable file
6
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal file → Executable file
@@ -8,7 +8,8 @@ public record FeatureResponse(
|
||||
string? Description,
|
||||
bool DefaultEnabled,
|
||||
int ProjectId,
|
||||
DateTimeOffset CreatedAt
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<TagResponse> Tags
|
||||
)
|
||||
{
|
||||
public static FeatureResponse From(Feature feature) => new(
|
||||
@@ -19,5 +20,6 @@ public record FeatureResponse(
|
||||
feature.Description,
|
||||
feature.DefaultEnabled,
|
||||
feature.ProjectId,
|
||||
feature.CreatedAt);
|
||||
feature.CreatedAt,
|
||||
feature.Tags.Select(TagResponse.From).ToList());
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Features/FeatureSegment.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegment.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentService.cs
Normal file → Executable file
39
src/api/MicCheck.Api/Features/FeatureService.cs
Normal file → Executable file
39
src/api/MicCheck.Api/Features/FeatureService.cs
Normal file → Executable file
@@ -13,13 +13,14 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
|
||||
public async Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Features
|
||||
.Include(f => f.Tags)
|
||||
.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);
|
||||
return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<Feature> CreateAsync(
|
||||
@@ -80,7 +81,7 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
|
||||
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)
|
||||
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"Feature {id} not found.");
|
||||
|
||||
var before = new { feature.Name, feature.Description };
|
||||
@@ -101,6 +102,40 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
|
||||
return feature;
|
||||
}
|
||||
|
||||
public async Task<Feature> AssignTagAsync(int featureId, int tagId, CancellationToken ct = default)
|
||||
{
|
||||
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == featureId, ct)
|
||||
?? throw new KeyNotFoundException($"Feature {featureId} not found.");
|
||||
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
|
||||
?? throw new KeyNotFoundException($"Tag {tagId} not found.");
|
||||
|
||||
if (tag.ProjectId != feature.ProjectId)
|
||||
throw new DomainException("Tag does not belong to the feature's project.");
|
||||
|
||||
if (feature.Tags.All(t => t.Id != tagId))
|
||||
{
|
||||
feature.Tags.Add(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return feature;
|
||||
}
|
||||
|
||||
public async Task<Feature> RemoveTagAsync(int featureId, int tagId, CancellationToken ct = default)
|
||||
{
|
||||
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == featureId, ct)
|
||||
?? throw new KeyNotFoundException($"Feature {featureId} not found.");
|
||||
|
||||
var tag = feature.Tags.FirstOrDefault(t => t.Id == tagId);
|
||||
if (tag is not null)
|
||||
{
|
||||
feature.Tags.Remove(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return feature;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
|
||||
0
src/api/MicCheck.Api/Features/FeatureState.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureState.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureStateResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureStateResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureStateResult.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureStateResult.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureStateService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureStateService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSummaryResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSummaryResponse.cs
Normal file → Executable file
3
src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs
Normal file
3
src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);
|
||||
32
src/api/MicCheck.Api/Features/FeatureUsageController.cs
Normal file
32
src/api/MicCheck.Api/Features/FeatureUsageController.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/environment/{environmentId}/usage")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(
|
||||
int environmentId,
|
||||
[FromQuery] int days = 14,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
days = Math.Clamp(days, 1, 90);
|
||||
var cacheKey = $"usage-dashboard:{environmentId}:{days}";
|
||||
|
||||
if (cache.TryGetValue(cacheKey, out DashboardUsageResponse? cached))
|
||||
return Ok(cached);
|
||||
|
||||
var result = await queryService.GetDashboardUsageAsync(environmentId, days, ct);
|
||||
|
||||
cache.Set(cacheKey, result, TimeSpan.FromSeconds(180));
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
12
src/api/MicCheck.Api/Features/FeatureUsageDaily.cs
Normal file
12
src/api/MicCheck.Api/Features/FeatureUsageDaily.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class FeatureUsageFlushBackgroundService(
|
||||
FeatureUsageMetrics metrics,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<FeatureUsageFlushBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(FlushInterval);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await FlushAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.StopAsync(cancellationToken);
|
||||
await FlushAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task FlushAsync(CancellationToken ct)
|
||||
{
|
||||
var deltas = metrics.DrainAccumulated();
|
||||
if (deltas.Count == 0)
|
||||
return;
|
||||
|
||||
var updatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
|
||||
foreach (var (key, count) in deltas)
|
||||
{
|
||||
await db.Database.ExecuteSqlAsync(
|
||||
$"""
|
||||
INSERT INTO "FeatureUsageDaily" ("EnvironmentId", "FeatureId", "FeatureName", "UsageDate", "Count", "UpdatedAt")
|
||||
VALUES ({key.EnvironmentId}, {key.FeatureId}, {key.FeatureName}, {key.UsageDate}, {count}, {updatedAt})
|
||||
ON CONFLICT ("EnvironmentId", "FeatureId", "UsageDate")
|
||||
DO UPDATE SET "Count" = "FeatureUsageDaily"."Count" + EXCLUDED."Count",
|
||||
"FeatureName" = EXCLUDED."FeatureName",
|
||||
"UpdatedAt" = EXCLUDED."UpdatedAt"
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to flush feature usage data ({BucketCount} buckets)", deltas.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs
Normal file
35
src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class FeatureUsageMetrics : IDisposable
|
||||
{
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _evaluationCounter;
|
||||
private ConcurrentDictionary<FeatureUsageBucketKey, long> _accumulator = new();
|
||||
|
||||
public FeatureUsageMetrics(IMeterFactory meterFactory)
|
||||
{
|
||||
_meter = meterFactory.Create("MicCheck.FeatureUsage");
|
||||
_evaluationCounter = _meter.CreateCounter<long>("miccheck.feature.evaluations", description: "Number of feature flag evaluations");
|
||||
}
|
||||
|
||||
public void RecordEvaluation(int environmentId, int featureId, string featureName)
|
||||
{
|
||||
_evaluationCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("environment.id", environmentId),
|
||||
new KeyValuePair<string, object?>("feature.id", featureId));
|
||||
|
||||
var key = new FeatureUsageBucketKey(environmentId, featureId, featureName, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
_accumulator.AddOrUpdate(key, 1L, (_, existing) => existing + 1);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<FeatureUsageBucketKey, long> DrainAccumulated()
|
||||
{
|
||||
var drained = Interlocked.Exchange(ref _accumulator, new ConcurrentDictionary<FeatureUsageBucketKey, long>());
|
||||
return drained;
|
||||
}
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
42
src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs
Normal file
42
src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class FeatureUsageQueryService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var windowStart = today.AddDays(-days);
|
||||
var yesterday = today.AddDays(-1);
|
||||
|
||||
var rows = await db.FeatureUsageDaily
|
||||
.Where(u => u.EnvironmentId == environmentId && u.UsageDate >= windowStart)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var topFeaturesLastDay = rows
|
||||
.Where(u => u.UsageDate >= yesterday)
|
||||
.GroupBy(u => new { u.FeatureId, u.FeatureName })
|
||||
.Select(g => new TopFeatureUsage(g.Key.FeatureId, g.Key.FeatureName, g.Sum(u => u.Count)))
|
||||
.OrderByDescending(t => t.Count)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
var dailyUsage = rows
|
||||
.GroupBy(u => u.UsageDate)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g =>
|
||||
{
|
||||
var features = g
|
||||
.GroupBy(u => new { u.FeatureId, u.FeatureName })
|
||||
.Select(fg => new TopFeatureUsage(fg.Key.FeatureId, fg.Key.FeatureName, fg.Sum(u => u.Count)))
|
||||
.OrderByDescending(f => f.Count)
|
||||
.ToList();
|
||||
return new DailyUsage(g.Key, features.Sum(f => f.Count), features);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new DashboardUsageResponse(topFeaturesLastDay, dailyUsage);
|
||||
}
|
||||
}
|
||||
9
src/api/MicCheck.Api/Features/FeatureUsageResponses.cs
Normal file
9
src/api/MicCheck.Api/Features/FeatureUsageResponses.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
|
||||
|
||||
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
|
||||
|
||||
public record DashboardUsageResponse(
|
||||
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
|
||||
IReadOnlyList<DailyUsage> DailyUsage);
|
||||
205
src/api/MicCheck.Api/Features/FeaturesController.cs
Normal file → Executable file
205
src/api/MicCheck.Api/Features/FeaturesController.cs
Normal file → Executable file
@@ -1,88 +1,117 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class FeaturesController(FeatureService featureService) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/v1/project/{projectId}/features")]
|
||||
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("api/v1/project/{projectId}/features")]
|
||||
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("api/v1/project/{projectId}/feature/{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("api/v1/project/{projectId}/feature/{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("api/v1/project/{projectId}/feature/{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("api/v1/project/{projectId}/feature/{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();
|
||||
}
|
||||
|
||||
[HttpPut("api/v1/project/{projectId}/feature/{id}/tag/{tagId}")]
|
||||
public async Task<ActionResult<FeatureResponse>> AssignTag(int projectId, int id, int tagId, CancellationToken ct)
|
||||
{
|
||||
var feature = await featureService.FindByIdAsync(id, ct);
|
||||
if (feature is null || feature.ProjectId != projectId) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await featureService.AssignTagAsync(id, tagId, ct);
|
||||
return Ok(FeatureResponse.From(updated));
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (DomainException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("api/v1/project/{projectId}/feature/{id}/tag/{tagId}")]
|
||||
public async Task<ActionResult<FeatureResponse>> RemoveTag(int projectId, int id, int tagId, CancellationToken ct)
|
||||
{
|
||||
var feature = await featureService.FindByIdAsync(id, ct);
|
||||
if (feature is null || feature.ProjectId != projectId) return NotFound();
|
||||
|
||||
var updated = await featureService.RemoveTagAsync(id, tagId, ct);
|
||||
return Ok(FeatureResponse.From(updated));
|
||||
}
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Features/FlagCache.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FlagCache.cs
Normal file → Executable file
12
src/api/MicCheck.Api/Features/FlagResponse.cs
Normal file → Executable file
12
src/api/MicCheck.Api/Features/FlagResponse.cs
Normal file → Executable file
@@ -1,14 +1,6 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record FlagResponse(
|
||||
int Id,
|
||||
FeatureSummaryResponse Feature,
|
||||
bool Enabled,
|
||||
string? FeatureStateValue)
|
||||
public record FlagResponse(int Id, FeatureSummaryResponse Feature, bool Enabled, string? FeatureStateValue)
|
||||
{
|
||||
public static FlagResponse From(FeatureStateResult result) => new(
|
||||
result.Feature.Id,
|
||||
FeatureSummaryResponse.From(result.Feature),
|
||||
result.Enabled,
|
||||
result.Value);
|
||||
public static FlagResponse From(FeatureStateResult result) => new(result.Feature.Id, FeatureSummaryResponse.From(result.Feature), result.Enabled, result.Value);
|
||||
}
|
||||
|
||||
38
src/api/MicCheck.Api/Features/FlagsController.cs
Normal file → Executable file
38
src/api/MicCheck.Api/Features/FlagsController.cs
Normal file → Executable file
@@ -1,19 +1,19 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/flags")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class FlagsController(FeatureEvaluationService featureEvaluationService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<FlagResponse>>> GetAll(CancellationToken ct)
|
||||
{
|
||||
var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value);
|
||||
var results = await featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct);
|
||||
return Ok(results.Select(FlagResponse.From).ToList());
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/flags")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class FlagsController(FeatureEvaluationService featureEvaluationService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<FlagResponse>>> GetAll(CancellationToken ct)
|
||||
{
|
||||
var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value);
|
||||
var results = await featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct);
|
||||
return Ok(results.Select(FlagResponse.From).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/Tag.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/Tag.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/TagResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/TagResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/TagService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/TagService.cs
Normal file → Executable file
73
src/api/MicCheck.Api/Features/TagsController.cs
Normal file → Executable file
73
src/api/MicCheck.Api/Features/TagsController.cs
Normal file → Executable file
@@ -1,37 +1,36 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class TagsController(TagService tagService) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/v1/project/{projectId}/tags")]
|
||||
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
|
||||
{
|
||||
var tags = await tagService.ListByProjectAsync(projectId, ct);
|
||||
return Ok(tags.Select(TagResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("api/v1/project/{projectId}/tags")]
|
||||
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
|
||||
{
|
||||
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
|
||||
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
|
||||
}
|
||||
|
||||
[HttpDelete("api/v1/project/{projectId}/tag/{id}")]
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/UpdateFeatureStateRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/UpdateFeatureStateRequest.cs
Normal file → Executable file
Reference in New Issue
Block a user