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 ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; namespace ZB.MOM.WW.ScadaBridge.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 { /// Registers the POST /api/{methodName} inbound API endpoint with the active-node gate and body-size filter applied. /// The route builder to add the endpoint to. 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) { // Telemetry follow-on: count every inbound request, including auth // failures. The raw {methodName} route value is arbitrary caller input // and would be high-cardinality, so failures are tagged with a small // bounded set of sentinels keyed off the validator's status code rather // than the unvalidated name (401 → "", 403 → ""). ScadaBridgeTelemetry.RecordInboundApiRequest( validationResult.StatusCode == StatusCodes.Status401Unauthorized ? "" : ""); // 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!; // Telemetry follow-on: count this inbound request against the resolved, // registered method name. method.Name comes from the repository's method // catalogue (an exact-name lookup), so the `method` tag is bounded to the // set of configured API methods — never the raw caller-supplied route value. ScadaBridgeTelemetry.RecordInboundApiRequest(method.Name); // 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 { // InboundAPI-020: the content-type sniff must be case-insensitive — a // request with `application/JSON` or `Application/Json` is still JSON // and must enter the body-parsing path. The previous case-sensitive // `Contains("json")` silently skipped JSON deserialization for any // capitalised value, leaving `body = null` and surfacing required // parameters as 400 "missing" even though the caller sent a valid body. if (httpContext.Request.ContentLength > 0 || httpContext.Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == 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(); } }