using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using StellaOps.Platform.WebService.Constants; using StellaOps.Platform.WebService.Contracts; using StellaOps.Platform.WebService.Services; namespace StellaOps.Platform.WebService.Endpoints; /// /// Release Control bundle lifecycle endpoints consumed by UI v2 shell. /// public static class ReleaseControlEndpoints { private const int DefaultLimit = 50; private const int MaxLimit = 200; public static IEndpointRouteBuilder MapReleaseControlEndpoints(this IEndpointRouteBuilder app) { var bundles = app.MapGroup("/api/v1/release-control/bundles") .WithTags("Release Control"); bundles.MapGet(string.Empty, async Task( HttpContext context, PlatformRequestContextResolver resolver, IReleaseControlBundleStore store, TimeProvider timeProvider, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } var normalizedLimit = NormalizeLimit(limit); var normalizedOffset = NormalizeOffset(offset); var items = await store.ListBundlesAsync( requestContext!.TenantId, normalizedLimit, normalizedOffset, cancellationToken).ConfigureAwait(false); return Results.Ok(new PlatformListResponse( requestContext.TenantId, requestContext.ActorId, timeProvider.GetUtcNow(), Cached: false, CacheTtlSeconds: 0, items, items.Count, normalizedLimit, normalizedOffset)); }) .WithName("ListReleaseControlBundles") .WithSummary("List release control bundles") .RequireAuthorization(PlatformPolicies.ReleaseControlRead); bundles.MapGet("/{bundleId:guid}", async Task( HttpContext context, PlatformRequestContextResolver resolver, IReleaseControlBundleStore store, TimeProvider timeProvider, Guid bundleId, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } var item = await store.GetBundleAsync( requestContext!.TenantId, bundleId, cancellationToken).ConfigureAwait(false); if (item is null) { return Results.NotFound(new { error = "bundle_not_found", bundleId }); } return Results.Ok(new PlatformItemResponse( requestContext.TenantId, requestContext.ActorId, timeProvider.GetUtcNow(), Cached: false, CacheTtlSeconds: 0, item)); }) .WithName("GetReleaseControlBundle") .WithSummary("Get release control bundle by id") .RequireAuthorization(PlatformPolicies.ReleaseControlRead); bundles.MapPost(string.Empty, async Task( HttpContext context, PlatformRequestContextResolver resolver, IReleaseControlBundleStore store, CreateReleaseControlBundleRequest request, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } try { var created = await store.CreateBundleAsync( requestContext!.TenantId, requestContext.ActorId, request, cancellationToken).ConfigureAwait(false); var location = $"/api/v1/release-control/bundles/{created.Id}"; return Results.Created(location, created); } catch (InvalidOperationException ex) { return MapStoreError(ex, bundleId: null, versionId: null); } }) .WithName("CreateReleaseControlBundle") .WithSummary("Create release control bundle") .RequireAuthorization(PlatformPolicies.ReleaseControlOperate); bundles.MapGet("/{bundleId:guid}/versions", async Task( HttpContext context, PlatformRequestContextResolver resolver, IReleaseControlBundleStore store, TimeProvider timeProvider, Guid bundleId, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } var normalizedLimit = NormalizeLimit(limit); var normalizedOffset = NormalizeOffset(offset); try { var items = await store.ListVersionsAsync( requestContext!.TenantId, bundleId, normalizedLimit, normalizedOffset, cancellationToken).ConfigureAwait(false); return Results.Ok(new PlatformListResponse( requestContext.TenantId, requestContext.ActorId, timeProvider.GetUtcNow(), Cached: false, CacheTtlSeconds: 0, items, items.Count, normalizedLimit, normalizedOffset)); } catch (InvalidOperationException ex) { return MapStoreError(ex, bundleId, versionId: null); } }) .WithName("ListReleaseControlBundleVersions") .WithSummary("List bundle versions") .RequireAuthorization(PlatformPolicies.ReleaseControlRead); bundles.MapGet("/{bundleId:guid}/versions/{versionId:guid}", async Task( HttpContext context, PlatformRequestContextResolver resolver, IReleaseControlBundleStore store, TimeProvider timeProvider, Guid bundleId, Guid versionId, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } var version = await store.GetVersionAsync( requestContext!.TenantId, bundleId, versionId, cancellationToken).ConfigureAwait(false); if (version is null) { return Results.NotFound(new { error = "bundle_version_not_found", bundleId, versionId }); } return Results.Ok(new PlatformItemResponse( requestContext.TenantId, requestContext.ActorId, timeProvider.GetUtcNow(), Cached: false, CacheTtlSeconds: 0, version)); }) .WithName("GetReleaseControlBundleVersion") .WithSummary("Get bundle version") .RequireAuthorization(PlatformPolicies.ReleaseControlRead); bundles.MapPost("/{bundleId:guid}/versions", async Task( HttpContext context, PlatformRequestContextResolver resolver, IReleaseControlBundleStore store, Guid bundleId, PublishReleaseControlBundleVersionRequest request, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } try { var created = await store.PublishVersionAsync( requestContext!.TenantId, requestContext.ActorId, bundleId, request, cancellationToken).ConfigureAwait(false); var location = $"/api/v1/release-control/bundles/{bundleId}/versions/{created.Id}"; return Results.Created(location, created); } catch (InvalidOperationException ex) { return MapStoreError(ex, bundleId, versionId: null); } }) .WithName("PublishReleaseControlBundleVersion") .WithSummary("Publish immutable bundle version") .RequireAuthorization(PlatformPolicies.ReleaseControlOperate); bundles.MapPost("/{bundleId:guid}/versions/{versionId:guid}/materialize", async Task( HttpContext context, PlatformRequestContextResolver resolver, IReleaseControlBundleStore store, Guid bundleId, Guid versionId, MaterializeReleaseControlBundleVersionRequest request, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } try { var run = await store.MaterializeVersionAsync( requestContext!.TenantId, requestContext.ActorId, bundleId, versionId, request, cancellationToken).ConfigureAwait(false); var location = $"/api/v1/release-control/bundles/{bundleId}/versions/{versionId}/materialize/{run.RunId}"; return Results.Accepted(location, run); } catch (InvalidOperationException ex) { return MapStoreError(ex, bundleId, versionId); } }) .WithName("MaterializeReleaseControlBundleVersion") .WithSummary("Materialize bundle version") .RequireAuthorization(PlatformPolicies.ReleaseControlOperate); return app; } private static int NormalizeLimit(int? value) { return value switch { null => DefaultLimit, < 1 => 1, > MaxLimit => MaxLimit, _ => value.Value }; } private static int NormalizeOffset(int? value) => value is null or < 0 ? 0 : value.Value; private static IResult MapStoreError(InvalidOperationException exception, Guid? bundleId, Guid? versionId) { return exception.Message switch { "bundle_not_found" => Results.NotFound(new { error = "bundle_not_found", bundleId }), "bundle_version_not_found" => Results.NotFound(new { error = "bundle_version_not_found", bundleId, versionId }), "bundle_slug_exists" => Results.Conflict(new { error = "bundle_slug_exists" }), "bundle_slug_required" => Results.BadRequest(new { error = "bundle_slug_required" }), "bundle_name_required" => Results.BadRequest(new { error = "bundle_name_required" }), "request_required" => Results.BadRequest(new { error = "request_required" }), "tenant_required" => Results.BadRequest(new { error = "tenant_required" }), "tenant_id_invalid" => Results.BadRequest(new { error = "tenant_id_invalid" }), _ => Results.BadRequest(new { error = exception.Message }) }; } private static bool TryResolveContext( HttpContext context, PlatformRequestContextResolver resolver, out PlatformRequestContext? requestContext, out IResult? failure) { if (resolver.TryResolve(context, out requestContext, out var error)) { failure = null; return true; } failure = Results.BadRequest(new { error = error ?? "tenant_missing" }); return false; } }