Platform: add script variables API and scope/policy updates
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>
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user