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;
}
}