Add CRUD endpoints for script variables with validation and diff-compatible models (ScriptEndpoints, ScriptApiModels, IScriptService, InMemoryScriptService). Update PlatformScopes and PlatformPolicies for script.read/write permissions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
411 lines
15 KiB
C#
411 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Script registry API endpoints (CRUD, versioning, validation).
|
|
/// </summary>
|
|
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<IResult>(
|
|
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<ScriptSummary>(
|
|
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<IResult>(
|
|
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<IResult>(
|
|
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<ScriptDetail>(
|
|
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<IResult>(
|
|
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<ScriptDetail>(
|
|
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<IResult>(
|
|
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<IResult>(
|
|
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<ScriptVersionDto>(
|
|
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<IResult>(
|
|
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<IResult>(
|
|
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<ScriptVersionDetailDto>(
|
|
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<IResult>(
|
|
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;
|
|
}
|
|
}
|