using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ScadaLink.InboundAPI.Middleware; namespace ScadaLink.InboundAPI; /// /// WP-1: POST /api/{methodName} endpoint registration. /// WP-2: Method routing and parameter validation. /// WP-3: Script execution on central. /// WP-5: Error handling — 401, 403, 400, 500. /// public static class EndpointExtensions { public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints) { endpoints.MapPost("/api/{methodName}", HandleInboundApiRequest) // InboundAPI-006 / InboundAPI-008: active-node gating + request body // size cap are enforced by the endpoint filter before the handler runs. .AddEndpointFilter(); return endpoints; } private static async Task HandleInboundApiRequest( HttpContext httpContext, string methodName) { var logger = httpContext.RequestServices.GetRequiredService>(); var validator = httpContext.RequestServices.GetRequiredService(); var executor = httpContext.RequestServices.GetRequiredService(); var routeHelper = httpContext.RequestServices.GetRequiredService(); var options = httpContext.RequestServices.GetRequiredService>().Value; // WP-1: Extract and validate API key var apiKeyValue = httpContext.Request.Headers["X-API-Key"].FirstOrDefault(); var validationResult = await validator.ValidateAsync(apiKeyValue, methodName, httpContext.RequestAborted); if (!validationResult.IsValid) { // WP-5: Failures-only logging logger.LogWarning( "Inbound API auth failure for method {Method}: {Error} (status {StatusCode})", methodName, validationResult.ErrorMessage, validationResult.StatusCode); return Results.Json( new { error = validationResult.ErrorMessage }, statusCode: validationResult.StatusCode); } var method = validationResult.Method!; // Audit Log (#23 M4 Bundle D): publish the resolved API key name so // AuditWriteMiddleware can populate AuditEvent.Actor in its finally // block. Done AFTER validation succeeded — auth failures leave the // slot empty and the middleware records the row with Actor=null. httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] = validationResult.ApiKey!.Name; // WP-2: Deserialize and validate parameters JsonElement? body = null; try { if (httpContext.Request.ContentLength > 0 || httpContext.Request.ContentType?.Contains("json") == true) { using var doc = await JsonDocument.ParseAsync( httpContext.Request.Body, cancellationToken: httpContext.RequestAborted); body = doc.RootElement.Clone(); } } catch (JsonException) { return Results.Json( new { error = "Invalid JSON in request body" }, statusCode: 400); } var paramResult = ParameterValidator.Validate(body, method.ParameterDefinitions); if (!paramResult.IsValid) { return Results.Json( new { error = paramResult.ErrorMessage }, statusCode: 400); } // WP-3: Execute the method's script var timeout = method.TimeoutSeconds > 0 ? TimeSpan.FromSeconds(method.TimeoutSeconds) : options.DefaultMethodTimeout; // Audit Log #23 (ParentExecutionId): the inbound request's per-request // ExecutionId was minted early by AuditWriteMiddleware and stashed on // HttpContext.Items. Thread it into the executor so a routed // Route.To(...).Call(...) carries it as RouteToCallRequest.ParentExecutionId // — the spawned site script execution points back at this inbound request. var parentExecutionId = httpContext.Items.TryGetValue( AuditWriteMiddleware.InboundExecutionIdItemKey, out var stashedExecutionId) && stashedExecutionId is Guid inboundExecutionId ? inboundExecutionId : (Guid?)null; var scriptResult = await executor.ExecuteAsync( method, paramResult.Parameters, routeHelper, timeout, httpContext.RequestAborted, parentExecutionId); if (!scriptResult.Success) { // InboundAPI-004: a client-aborted request is not a script failure. // Do not pollute the failure log (reserved for genuine script errors) // and do not attempt to write a 500 body to an already-gone connection. if (httpContext.RequestAborted.IsCancellationRequested) { return Results.Empty; } // WP-5: 500 for script failures, safe error message logger.LogWarning( "Inbound API script failure for method {Method}: {Error}", methodName, scriptResult.ErrorMessage); return Results.Json( new { error = scriptResult.ErrorMessage ?? "Internal server error" }, statusCode: 500); } // Return the script result as JSON if (scriptResult.ResultJson != null) { return Results.Text(scriptResult.ResultJson, "application/json", statusCode: 200); } return Results.Ok(); } }