fix(adminui): bodyless POST /api/deployments answered 404 on fused admin+driver nodes (#761)

Found on the docker-dev rig during the #761 Wave-4 gate run (2026-11-12): the documented
bodyless headless deploy (docker-dev/deploy.sh, README "Headless deploy") returned 404 on
both centrals, while the same POST with a JSON body was accepted.

Cause: the optional body was an inferred `DeployRequest?` parameter, which stamps
Accepts(application/json) metadata on the endpoint. For a request with no Content-Type the
routing AcceptsMatcherPolicy prefers endpoints that declare no consumes metadata, and on a
fused admin+driver host MapOtOpcUaMeshGrpcServices has mapped grpc-dotnet's
`{unimplementedService}/{unimplementedMethod}` catch-all, so the request was routed away
from the deploy handler and 404'd. The deploy API on its own does not reproduce it.

The handler now reads the optional body itself: no body (absent or zero-length) is the
headless form, a non-JSON body answers 415 and malformed JSON 400, as the framework's
binding did. New DeployApiBodylessPostTests compose the deploy API with the mesh gRPC
services and drive real HTTP; with the fix reverted the two bodyless cases fail with 404.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 22:03:27 -05:00
parent 2705f16670
commit fa8c3d1a1a
2 changed files with 162 additions and 1 deletions
@@ -43,9 +43,14 @@ public static class DeployApiEndpoints
{
var configuredKey = config[ConfigKey];
// The optional body is read in the handler, NOT bound as an inferred `DeployRequest?` parameter
// (lmxopcua#761 gate run, 2026-11-12). An inferred JSON body stamps Accepts(application/json)
// metadata on the endpoint, and for a request with NO Content-Type the routing AcceptsMatcherPolicy
// prefers endpoints without consumes metadata — on a fused admin+driver host that is grpc-dotnet's
// `{unimplementedService}/{unimplementedMethod}` catch-all (mapped by MapOtOpcUaMeshGrpcServices),
// so the documented bodyless POST (docker-dev/deploy.sh) answered 404 and never reached this handler.
app.MapPost("/api/deployments", async (
HttpContext http,
DeployRequest? body,
IAdminOperationsClient adminOps,
CancellationToken ct) =>
{
@@ -57,6 +62,10 @@ public static class DeployApiEndpoints
if (!IsAuthorized(http.Request.Headers[ApiKeyHeader].ToString(), configuredKey))
return Results.Unauthorized();
var (body, bodyError) = await ReadOptionalBodyAsync(http.Request, ct);
if (bodyError is not null)
return bodyError;
var createdBy = string.IsNullOrWhiteSpace(body?.CreatedBy) ? "rest-api" : body!.CreatedBy!.Trim();
var result = await adminOps.StartDeploymentAsync(createdBy, ct);
return ToResult(result);
@@ -151,6 +160,34 @@ public static class DeployApiEndpoints
statusCode: StatusCodes.Status500InternalServerError),
};
/// <summary>Reads the POST's optional JSON body. No body (absent or zero-length) is the documented
/// headless form and yields <c>(null, null)</c>; a non-JSON body answers 415 and malformed JSON 400, as
/// the framework's own body binding did before the body stopped being an inferred parameter.</summary>
/// <param name="request">The incoming request.</param>
/// <param name="ct">Request cancellation.</param>
/// <returns>The parsed body (or null), or the error result to return instead.</returns>
internal static async Task<(DeployRequest? Body, IResult? Error)> ReadOptionalBodyAsync(
HttpRequest request, CancellationToken ct)
{
var hasBody = request.ContentLength is > 0
|| (request.ContentLength is null && request.Headers.TransferEncoding.Count > 0);
if (!hasBody)
return (null, null);
if (!request.HasJsonContentType())
return (null, Results.StatusCode(StatusCodes.Status415UnsupportedMediaType));
try
{
return (await request.ReadFromJsonAsync<DeployRequest>(ct), null);
}
catch (System.Text.Json.JsonException)
{
return (null, Results.Problem("The request body is not valid JSON.",
statusCode: StatusCodes.Status400BadRequest));
}
}
/// <summary>Optional request body. <c>CreatedBy</c> is recorded as the deployment's initiator
/// (audit); defaults to <c>"rest-api"</c> when absent.</summary>
/// <param name="CreatedBy">Who/what initiated the deploy (for the audit trail).</param>