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:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#761 (2026-11-12 gate run): the headless deploy POST must accept a request with NO body, as
|
||||
/// docker-dev/deploy.sh and the README document. It answered 404 on the rig: an inferred optional JSON
|
||||
/// body put Accepts(application/json) metadata on the endpoint, and for a request with no Content-Type
|
||||
/// the AcceptsMatcherPolicy routed to grpc-dotnet's unimplemented-service catch-all instead. The host is
|
||||
/// composed like a fused admin+driver node (deploy API + mesh gRPC services) and driven over real HTTP,
|
||||
/// so routing and the matcher policies all take part — the deploy API alone does not reproduce it.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DeployApiBodylessPostTests
|
||||
{
|
||||
private const string Key = "test-deploy-key";
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)] // no body, no Content-Length header at all
|
||||
[InlineData(true, false)] // Content-Length: 0
|
||||
[InlineData(true, true)] // a JSON body (positive control)
|
||||
public async Task Post_without_a_body_is_accepted(bool contentLengthZero, bool jsonBody)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var ops = new RecordingAdminOps();
|
||||
await using var app = await StartAsync(ops, ct);
|
||||
using var http = new HttpClient { BaseAddress = new Uri(Address(app)) };
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "/api/deployments");
|
||||
req.Headers.Add(DeployApiEndpoints.ApiKeyHeader, Key);
|
||||
if (jsonBody) req.Content = JsonContent.Create(new { createdBy = "ci-bot" });
|
||||
else if (contentLengthZero) req.Content = new ByteArrayContent([]);
|
||||
|
||||
using var resp = await http.SendAsync(req, ct);
|
||||
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Accepted, await resp.Content.ReadAsStringAsync(ct));
|
||||
ops.CreatedBy.ShouldBe(jsonBody ? "ci-bot" : "rest-api");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("text/plain", "hello", HttpStatusCode.UnsupportedMediaType)]
|
||||
[InlineData("application/json", "{not json", HttpStatusCode.BadRequest)]
|
||||
public async Task Post_with_an_unusable_body_is_refused_without_deploying(
|
||||
string contentType, string content, HttpStatusCode expected)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var ops = new RecordingAdminOps();
|
||||
await using var app = await StartAsync(ops, ct);
|
||||
using var http = new HttpClient { BaseAddress = new Uri(Address(app)) };
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "/api/deployments")
|
||||
{
|
||||
Content = new StringContent(content, System.Text.Encoding.UTF8, contentType),
|
||||
};
|
||||
req.Headers.Add(DeployApiEndpoints.ApiKeyHeader, Key);
|
||||
|
||||
using var resp = await http.SendAsync(req, ct);
|
||||
|
||||
resp.StatusCode.ShouldBe(expected);
|
||||
ops.CreatedBy.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task<WebApplication> StartAsync(IAdminOperationsClient ops, CancellationToken ct)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.AddSingleton(ops);
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("deploy-api-bodyless"));
|
||||
var app = builder.Build();
|
||||
app.UseAuthorization();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { [DeployApiEndpoints.ConfigKey] = Key })
|
||||
.Build();
|
||||
app.MapOtOpcUaDeployApi(config);
|
||||
// Composed exactly as Program.cs does on a fused admin+driver node: the mesh gRPC services are
|
||||
// mapped AFTER the deploy API, and every MapGrpcService adds grpc-dotnet catch-all routes.
|
||||
app.MapOtOpcUaMeshGrpcServices(hasAdmin: true, hasDriver: true, syncListenPort: 9001,
|
||||
configServeGrpcPort: 4055, telemetryListenPort: 4056);
|
||||
await app.StartAsync(ct);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string Address(WebApplication app) =>
|
||||
app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
|
||||
private sealed class RecordingAdminOps : IAdminOperationsClient
|
||||
{
|
||||
public string? CreatedBy { get; private set; }
|
||||
|
||||
public Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct)
|
||||
{
|
||||
CreatedBy = createdBy;
|
||||
return Task.FromResult(new StartDeploymentResult(
|
||||
StartDeploymentOutcome.Accepted, new DeploymentId(Guid.NewGuid()), new RevisionHash("rev"), null,
|
||||
CorrelationId.NewId()));
|
||||
}
|
||||
|
||||
public Task<AcknowledgeAlarmResult> AcknowledgeAlarmAsync(string alarmId, string user, string? comment, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<ShelveAlarmResult> ShelveAlarmAsync(string alarmId, string user, ShelveKind kind, DateTime? unshelveAtUtc,
|
||||
string? comment, CancellationToken ct) => throw new NotSupportedException();
|
||||
|
||||
public Task<T> AskAsync<T>(object message, CancellationToken ct) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user