using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Platform.WebService.Constants; using StellaOps.Platform.WebService.Contracts; using StellaOps.Platform.WebService.Services; namespace StellaOps.Platform.WebService.Endpoints; /// /// Script registry API endpoints (CRUD, versioning, validation). /// public static class ScriptEndpoints { private const int DefaultLimit = 50; private const int MaxLimit = 200; public static IEndpointRouteBuilder MapScriptEndpoints(this IEndpointRouteBuilder app) { var scripts = app.MapGroup("/api/v2/scripts") .WithTags("Scripts") .RequireAuthorization(PlatformPolicies.ScriptRead) .RequireTenant(); // GET /api/v2/scripts - List/search scripts scripts.MapGet(string.Empty, async Task( HttpContext context, PlatformRequestContextResolver resolver, IScriptService service, TimeProvider timeProvider, [FromQuery] ScriptLanguageDto? language, [FromQuery] ScriptVisibilityDto? visibility, [FromQuery] string? search, [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 service.SearchAsync( requestContext!.TenantId, language, visibility, search, normalizedLimit, normalizedOffset, cancellationToken).ConfigureAwait(false); var totalCount = await service.CountAsync( requestContext!.TenantId, language, visibility, search, cancellationToken).ConfigureAwait(false); return Results.Ok(new PlatformListResponse( requestContext.TenantId, requestContext.ActorId, timeProvider.GetUtcNow(), Cached: false, CacheTtlSeconds: 0, items, totalCount, normalizedLimit, normalizedOffset, Query: search)); }) .WithName("ListScripts") .WithSummary("List and search scripts") .WithDescription("Lists scripts with optional filtering by language, visibility, and search text.") .RequireAuthorization(PlatformPolicies.ScriptRead); // POST /api/v2/scripts - Create new script scripts.MapPost(string.Empty, async Task( HttpContext context, PlatformRequestContextResolver resolver, IScriptService service, [FromBody] CreateScriptApiRequest request, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } try { var created = await service.CreateAsync( requestContext!.TenantId, requestContext.ActorId, request, cancellationToken).ConfigureAwait(false); var location = $"/api/v2/scripts/{created.Id}"; return Results.Created(location, created); } catch (InvalidOperationException ex) { return MapServiceError(ex); } }) .WithName("CreateScript") .WithSummary("Create new script") .WithDescription("Creates a new script in the registry with initial content and metadata.") .RequireAuthorization(PlatformPolicies.ScriptWrite); // GET /api/v2/scripts/{id} - Get script with content scripts.MapGet("/{id}", async Task( HttpContext context, PlatformRequestContextResolver resolver, IScriptService service, TimeProvider timeProvider, string id, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } var item = await service.GetByIdAsync( requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false); if (item is null) { return Results.NotFound(new { error = "script_not_found", id }); } return Results.Ok(new PlatformItemResponse( requestContext.TenantId, requestContext.ActorId, timeProvider.GetUtcNow(), Cached: false, CacheTtlSeconds: 0, item)); }) .WithName("GetScript") .WithSummary("Get script by id") .WithDescription("Retrieves a script including its full content.") .RequireAuthorization(PlatformPolicies.ScriptRead); // PUT /api/v2/scripts/{id} - Update script scripts.MapPut("/{id}", async Task( HttpContext context, PlatformRequestContextResolver resolver, IScriptService service, TimeProvider timeProvider, string id, [FromBody] UpdateScriptApiRequest request, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } try { var updated = await service.UpdateAsync( requestContext!.TenantId, requestContext.ActorId, id, request, cancellationToken).ConfigureAwait(false); return Results.Ok(new PlatformItemResponse( requestContext.TenantId, requestContext.ActorId, timeProvider.GetUtcNow(), Cached: false, CacheTtlSeconds: 0, updated)); } catch (InvalidOperationException ex) { return MapServiceError(ex, id); } }) .WithName("UpdateScript") .WithSummary("Update script") .WithDescription("Updates an existing script. Content changes create a new version automatically.") .RequireAuthorization(PlatformPolicies.ScriptWrite); // DELETE /api/v2/scripts/{id} - Delete script scripts.MapDelete("/{id}", async Task( HttpContext context, PlatformRequestContextResolver resolver, IScriptService service, string id, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } var deleted = await service.DeleteAsync( requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false); if (!deleted) { return Results.NotFound(new { error = "script_not_found", id }); } return Results.NoContent(); }) .WithName("DeleteScript") .WithSummary("Delete script") .WithDescription("Deletes a script and all its version history.") .RequireAuthorization(PlatformPolicies.ScriptWrite); // GET /api/v2/scripts/{id}/versions - Version history scripts.MapGet("/{id}/versions", async Task( HttpContext context, PlatformRequestContextResolver resolver, IScriptService service, TimeProvider timeProvider, string id, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } try { var versions = await service.GetVersionsAsync( requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false); return Results.Ok(new PlatformListResponse( requestContext.TenantId, requestContext.ActorId, timeProvider.GetUtcNow(), Cached: false, CacheTtlSeconds: 0, versions, versions.Count)); } catch (InvalidOperationException ex) { return MapServiceError(ex, id); } }) .WithName("ListScriptVersions") .WithSummary("List script version history") .WithDescription("Returns the full version history for a script, newest first.") .RequireAuthorization(PlatformPolicies.ScriptRead); // POST /api/v2/scripts/validate - Validate without saving scripts.MapPost("/validate", async Task( HttpContext context, PlatformRequestContextResolver resolver, IScriptService service, [FromBody] ValidateScriptApiRequest request, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } if (string.IsNullOrWhiteSpace(request.Content)) { return Results.BadRequest(new { error = "script_content_required" }); } var result = await service.ValidateAsync( request, cancellationToken).ConfigureAwait(false); return Results.Ok(result); }) .WithName("ValidateScript") .WithSummary("Validate script without saving") .WithDescription("Validates script syntax and returns diagnostics. Used by the editor compile button.") .RequireAuthorization(PlatformPolicies.ScriptRead); // GET /api/v2/scripts/{id}/versions/{version}/content - Get version content for diff scripts.MapGet("/{id}/versions/{version:int}/content", async Task( HttpContext context, PlatformRequestContextResolver resolver, IScriptService service, TimeProvider timeProvider, string id, int version, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } var item = await service.GetVersionContentAsync( requestContext!.TenantId, id, version, cancellationToken).ConfigureAwait(false); if (item is null) { return Results.NotFound(new { error = "script_not_found", id }); } return Results.Ok(new PlatformItemResponse( requestContext.TenantId, requestContext.ActorId, timeProvider.GetUtcNow(), Cached: false, CacheTtlSeconds: 0, item)); }) .WithName("GetScriptVersionContent") .WithSummary("Get script version content") .WithDescription("Retrieves the content of a specific script version for diff comparison.") .RequireAuthorization(PlatformPolicies.ScriptRead); // POST /api/v2/scripts/{id}/check-compatibility - Check deployment compatibility scripts.MapPost("/{id}/check-compatibility", async Task( HttpContext context, PlatformRequestContextResolver resolver, IScriptService service, string id, [FromBody] CheckCompatibilityRequest request, CancellationToken cancellationToken) => { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } try { var result = await service.CheckCompatibilityAsync( requestContext!.TenantId, id, request, cancellationToken).ConfigureAwait(false); return Results.Ok(result); } catch (InvalidOperationException ex) { return MapServiceError(ex, id); } }) .WithName("CheckScriptCompatibility") .WithSummary("Check script deployment compatibility") .WithDescription("Checks if a script is compatible with a given deployment target type.") .RequireAuthorization(PlatformPolicies.ScriptRead); 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 MapServiceError(InvalidOperationException exception, string? id = null) { return exception.Message switch { "script_not_found" => Results.NotFound(new { error = "script_not_found", id }), "script_name_required" => Results.BadRequest(new { error = "script_name_required" }), "script_content_required" => Results.BadRequest(new { error = "script_content_required" }), "request_required" => Results.BadRequest(new { error = "request_required" }), "script_validation_failed" => Results.BadRequest(new { error = "script_validation_failed", detail = exception.Message }), _ => 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; } }