namespace SomhairlesDream.Server open System open System.IO open System.Text.Json open System.Threading.Tasks open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Http open SomhairlesDream.Shared [] type RunStartRequest = { ProjectId: string RunId: string BaseId: string TargetId: string Render: bool } type ArtifactRunApiOptions = { Coordinator: ArtifactRunCoordinator Clock: unit -> DateTimeOffset JsonOptions: JsonSerializerOptions } module ArtifactRunApi = let private json options value = Results.Json(value, options.JsonOptions) let private jsonWithStatus options statusCode value = Results.Json(value, options.JsonOptions, statusCode = Nullable(statusCode)) let private error options statusCode message = jsonWithStatus options statusCode {| error = message |} let private readStartRequest options (request: HttpRequest) = task { try let! value = request.ReadFromJsonAsync(options.JsonOptions) if isNull (box value) then return Error "request body must be a JSON object" else return Ok value with | :? JsonException as ex -> return Error $"invalid JSON: {ex.Message}" } let private start options (context: HttpContext) : Task = task { let! request = readStartRequest options context.Request match request with | Error message -> return error options 400 message | Ok request -> let ids : RunIds = { ProjectId = request.ProjectId RunId = request.RunId BaseId = request.BaseId TargetId = request.TargetId } match options.Coordinator.Start(ids, request.Render) with | Ok(Accepted snapshot) -> return jsonWithStatus options 202 snapshot | Ok(Conflict snapshot) -> return jsonWithStatus options 409 snapshot | Error message when message = "run registry entry disappeared" -> return error options 500 message | Error message -> return error options 400 message } let private queryValue (context: HttpContext) name = let value = context.Request.Query[name].ToString() if String.IsNullOrWhiteSpace(value) then None else Some value let private selector context = match queryValue context "projectId", queryValue context "runId" with | Some projectId, Some runId -> match Ids.validateResourceId projectId, Ids.validateResourceId runId with | Error message, _ -> Error $"invalid projectId: {message}" | _, Error message -> Error $"invalid runId: {message}" | Ok (), Ok () -> Ok(projectId, runId) | None, _ -> Error "missing query parameter 'projectId'" | _, None -> Error "missing query parameter 'runId'" let private manifest options (context: HttpContext) = match selector context with | Error message -> error options 400 message | Ok(projectId, runId) -> match options.Coordinator.Manifest(projectId, runId) with | Ok value -> json options value | Error RunNotFound -> error options 404 "run not found" | Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot | Error(InvalidManifest message) -> error options 500 message | Error(InvalidArtifactPath message) -> error options 400 message | Error ArtifactNotFound -> error options 500 "manifest not found" | Error(ArtifactMismatch message) -> error options 500 message let private artifactContentType (path: string) = match Path.GetExtension(path).ToLowerInvariant() with | ".glb" -> "model/gltf-binary" | ".json" -> "application/json" | ".png" -> "image/png" | ".log" -> "text/plain" | _ -> "application/octet-stream" let private streamArtifact (context: HttpContext) (file: ArtifactFile) = context.Response.OnCompleted(Func(fun () -> file.Stream.Dispose() Task.CompletedTask)) Results.Stream(file.Stream, artifactContentType file.RelativePath) let private artifact options (context: HttpContext) = match selector context, queryValue context "path" with | Error message, _ -> error options 400 message | _, None -> error options 400 "missing query parameter 'path'" | Ok(projectId, runId), Some relativePath -> match options.Coordinator.Artifact(projectId, runId, relativePath) with | Ok file -> streamArtifact context file | Error RunNotFound -> error options 404 "run not found" | Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot | Error(InvalidArtifactPath message) -> error options 400 message | Error ArtifactNotFound -> error options 404 "artifact not found" | Error(InvalidManifest message) -> error options 500 message | Error(ArtifactMismatch message) -> error options 409 message let private stepArtifact options (context: HttpContext) = match selector context, queryValue context "path" with | Error message, _ -> error options 400 message | _, None -> error options 400 "missing query parameter 'path'" | Ok(projectId, runId), Some relativePath -> match options.Coordinator.StepArtifact(projectId, runId, relativePath) with | Ok file -> streamArtifact context file | Error RunNotFound -> error options 404 "run not found" | Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot | Error(InvalidArtifactPath message) -> error options 400 message | Error ArtifactNotFound -> error options 404 "artifact not found" | Error(InvalidManifest message) -> error options 500 message | Error(ArtifactMismatch message) -> error options 409 message let private writeError options (context: HttpContext) statusCode message = task { context.Response.StatusCode <- statusCode context.Response.ContentType <- "application/json" let payload = JsonSerializer.Serialize({| error = message |}, options.JsonOptions) do! context.Response.WriteAsync(payload) } let private events options (context: HttpContext) : Task = task { match selector context with | Error message -> do! writeError options context 400 message | Ok(projectId, runId) -> match options.Coordinator.Subscribe(projectId, runId) with | None -> do! writeError options context 404 "run not found" | Some subscription -> use _lifetime = subscription :> IDisposable context.Response.StatusCode <- 200 context.Response.ContentType <- "text/event-stream" context.Response.Headers.CacheControl <- "no-cache" context.Response.Headers["X-Accel-Buffering"] <- "no" try while not context.RequestAborted.IsCancellationRequested do let! snapshot = subscription.Reader.ReadAsync(context.RequestAborted).AsTask() let payload = JsonSerializer.Serialize(snapshot, options.JsonOptions) do! context.Response.WriteAsync($"data: {payload}\n\n", context.RequestAborted) do! context.Response.Body.FlushAsync(context.RequestAborted) with | :? OperationCanceledException -> () } let register (app: WebApplication) options = app.MapPost("/api/runs/start", Func>(fun context -> start options context)) |> ignore app.MapGet("/api/runs/events", Func(fun context -> events options context)) |> ignore app.MapGet("/api/artifacts/manifest", Func(fun context -> manifest options context)) |> ignore app.MapGet("/api/artifacts/file", Func(fun context -> artifact options context)) |> ignore app.MapGet("/api/artifacts/steps", Func(fun context -> stepArtifact options context)) |> ignore