Files
git.stella-ops.org/src/Platform/StellaOps.Platform.WebService/Endpoints/ReleaseControlEndpoints.cs
2026-02-19 22:10:54 +02:00

331 lines
12 KiB
C#

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;
/// <summary>
/// Release Control bundle lifecycle endpoints consumed by UI v2 shell.
/// </summary>
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<IResult>(
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<ReleaseControlBundleSummary>(
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<IResult>(
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<ReleaseControlBundleDetail>(
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<IResult>(
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<IResult>(
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<ReleaseControlBundleVersionSummary>(
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<IResult>(
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<ReleaseControlBundleVersionDetail>(
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<IResult>(
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<IResult>(
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;
}
}