From 8ba6a3baabeba5f55e6802d7f1d8157b0a189727 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:02:01 +0000 Subject: [PATCH 1/3] feat(mediator): advertise standard problem responses Co-authored-by: AndreaCuneo <5227688+AndreaCuneo@users.noreply.github.com> --- docs/mediator-framework/design.md | 8 ++++ .../MinimalApiEndpointGenerator.cs | 38 +++++++++++++++---- .../GeneratorSnapshotTests.cs | 24 ++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/docs/mediator-framework/design.md b/docs/mediator-framework/design.md index f1481a154..129c7930d 100644 --- a/docs/mediator-framework/design.md +++ b/docs/mediator-framework/design.md @@ -304,6 +304,14 @@ graph validation. | --- | --- | --- | | Minimal API | `Ark.Tools.AspNetCore.ProblemDetails` registered with `AddArkProblemDetailsExceptionHandler` and `UseExceptionHandler` | `EntityNotFoundException`→404; `ValidationException`→400 + `extensions` field violations; `BusinessRuleViolationException`→400 with the violation payload in `extensions`; unhandled exceptions are logged server-side and return a generic 500 | | gRPC | server interceptor → `Google.Rpc.Status` rich error model | field violations packed as `BadRequest` details in trailing metadata; business rule violations packed as an `ArkBusinessRuleViolation` detail; unhandled exceptions are logged server-side and return a generic `Internal`; thrown as `RpcException` | + +HTTP standard problem responses map to the gRPC canonical status codes as follows: + +| HTTP | gRPC | +| --- | --- | +| 400 `application/problem+json` | `InvalidArgument` | +| 403 `application/problem+json` | `PermissionDenied` | +| 500 `application/problem+json` | `Internal` | | Rebus | scope disposal + native retry | exhausted → error/dead-letter queue with serialized exception headers | Handlers only throw semantic domain exceptions; they never format transport diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs index eeb5bf945..a011fe7ed 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs @@ -528,7 +528,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray(httpContext, cancellationToken).ConfigureAwait(false);"); sb.AppendLine(" if (body is null)"); - sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.BadRequest();"); + sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400);"); if (explicitBindings) { var assignments = string.Join(", ", e.Properties @@ -553,7 +553,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray(\"application/json\", \"application/x-msgpack\").Produces<" + e.Response + ">(" + SuccessStatusCode(e) + ", \"application/json\", \"application/x-msgpack\").Produces(" + NullResultStatusCode(e) - + ")" + OpenApiMetadata(e, version, maxVersion) + AuthorizationMetadata(e) + ";"); + + ")" + ProblemMetadata(e) + OpenApiMetadata(e, version, maxVersion) + AuthorizationMetadata(e) + ";"); continue; } sb.AppendLine(" group." + map + "(" + Literal(template) + ", static async ("); @@ -599,7 +599,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray(" + SuccessStatusCode(e) + ").Produces(" + NullResultStatusCode(e) - + ")" + OpenApiMetadata(e, version, maxVersion) + AuthorizationMetadata(e) + ";"); + + ")" + ProblemMetadata(e) + OpenApiMetadata(e, version, maxVersion) + AuthorizationMetadata(e) + ";"); } } } @@ -748,7 +748,7 @@ private static void EmitMultipartEndpoint( sb.AppendLine(" {"); sb.AppendLine(" var form = await httpContext.Request.ReadFormAsync(cancellationToken).ConfigureAwait(false);"); sb.AppendLine(" if (form.Files.Count != 1)"); - sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.BadRequest(\"Exactly one file is required.\");"); + sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400, detail: \"Exactly one file is required.\");"); sb.AppendLine(" var file = form.Files[0];"); if (!endpoint.AllowedContentTypes.IsDefaultOrEmpty) { @@ -770,7 +770,7 @@ private static void EmitMultipartEndpoint( sb.AppendLine(" if (result is null)"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)" + NullResult(endpoint) + ";"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)" + SuccessResult(endpoint) + ";"); - sb.Append(" }).Accepts(\"multipart/form-data\")").Append(OpenApiMetadata(endpoint, version, maxVersion)).Append(MultipartMetadata(endpoint)) + sb.Append(" }).Accepts(\"multipart/form-data\")").Append(ProblemMetadata(endpoint)).Append(OpenApiMetadata(endpoint, version, maxVersion)).Append(MultipartMetadata(endpoint)) .Append(".Produces<").Append(endpoint.Response).Append(">(").Append(SuccessStatusCode(endpoint)) .Append(").Produces(").Append(NullResultStatusCode(endpoint)).Append(')') .Append(AuthorizationMetadata(endpoint)).AppendLine(";"); @@ -814,7 +814,7 @@ private static void EmitDownloadEndpoint( sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.TypedResults.NotFound();"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.File(result.OpenRead(), result.ContentType, fileDownloadName: global::Ark.MediatorFramework.ArkAttachmentName.Sanitize(result.Name));"); sb.Append(" }).Produces(200, contentType: \"application/octet-stream\").Produces(404)") - .Append(OpenApiMetadata(endpoint, version, maxVersion)).Append(AuthorizationMetadata(endpoint)).AppendLine(";"); + .Append(ProblemMetadata(endpoint)).Append(OpenApiMetadata(endpoint, version, maxVersion)).Append(AuthorizationMetadata(endpoint)).AppendLine(";"); } private static void EmitCommandEndpoint( @@ -875,7 +875,7 @@ private static void EmitCommandEndpoint( sb.AppendLine(" await handler.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);"); sb.AppendLine(" return global::Microsoft.AspNetCore.Http.TypedResults.NoContent();"); } - sb.Append(" })").Append(OpenApiMetadata(endpoint, version, maxVersion)); + sb.Append(" })").Append(ProblemMetadata(endpoint)).Append(OpenApiMetadata(endpoint, version, maxVersion)); sb.Append(endpoint.OwnerQueue is null ? ".Produces(204)" : ".Produces(202)"); sb.Append(AuthorizationMetadata(endpoint)).AppendLine(";"); } @@ -924,6 +924,30 @@ private static string AuthorizationMetadata(EndpointModel endpoint) : ".RequireAuthorization(" + Literal(endpoint.Policy!) + ")"; } + private static string ProblemMetadata(EndpointModel endpoint) + { + var metadata = new StringBuilder(); + var declaredStatuses = new HashSet + { + SuccessStatusCode(endpoint), + NullResultStatusCode(endpoint), + }; + + AppendProblem(400); + if (!endpoint.AllowAnonymous) + AppendProblem(403); + AppendProblem(500); + return metadata.ToString(); + + void AppendProblem(int statusCode) + { + if (declaredStatuses.Add(statusCode)) + metadata.Append(".Produces(") + .Append(statusCode) + .Append(", \"application/problem+json\")"); + } + } + private static string OpenApiMetadata(EndpointModel endpoint, int version, int maxVersion) => ".WithGroupName(" + Literal("v" + version) + ").WithTags(" + Literal(endpoint.ApiGroup) diff --git a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs index 9a5a3d7a8..ae5efbc0f 100644 --- a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs +++ b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs @@ -208,6 +208,30 @@ public sealed class PublicEndpoint : IQuery generated.Should().Contain("GetRegistration(handlerType)"); } + [TestMethod] + public void MinimalApiGeneratorAdvertisesStandardProblemResponsesWithoutDuplicates() + { + var generated = RunGenerator( + """ + using Ark.MediatorFramework; + using Ark.Tools.Solid; + [HttpEndpoint("GET", "/secure", SuccessStatusCode = 400, NullResultStatusCode = 500)] + public sealed class SecureEndpoint : IQuery + { + } + [HttpEndpoint("GET", "/public", AllowAnonymous = true)] + public sealed class PublicEndpoint : IQuery + { + } + """); + + generated.Should().Contain(".Produces(403, \"application/problem+json\")"); + generated.Should().Contain(".Produces(500, \"application/problem+json\")"); + generated.Split(".Produces(400, \"application/problem+json\")").Length.Should().Be(2); + generated.Split(".Produces(500, \"application/problem+json\")").Length.Should().Be(2); + generated.Should().NotContain(".Produces(500, \"application/problem+json\").Produces(500"); + } + [TestMethod] public void MinimalApiGeneratorEmitsCommandStatusSemantics() { From d42424e1b6aa48bfa338c32e31647cb3cec6514a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:10:32 +0000 Subject: [PATCH 2/3] test(mediator): tighten problem metadata assertions Co-authored-by: AndreaCuneo <5227688+AndreaCuneo@users.noreply.github.com> --- .../MinimalApiEndpointGenerator.cs | 7 ++----- .../GeneratorSnapshotTests.cs | 5 +++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs index a011fe7ed..7570343fb 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs @@ -927,11 +927,8 @@ private static string AuthorizationMetadata(EndpointModel endpoint) private static string ProblemMetadata(EndpointModel endpoint) { var metadata = new StringBuilder(); - var declaredStatuses = new HashSet - { - SuccessStatusCode(endpoint), - NullResultStatusCode(endpoint), - }; + var declaredStatuses = new HashSet( + [SuccessStatusCode(endpoint), NullResultStatusCode(endpoint)]); AppendProblem(400); if (!endpoint.AllowAnonymous) diff --git a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs index ae5efbc0f..512d4f07b 100644 --- a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs +++ b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs @@ -227,8 +227,9 @@ public sealed class PublicEndpoint : IQuery generated.Should().Contain(".Produces(403, \"application/problem+json\")"); generated.Should().Contain(".Produces(500, \"application/problem+json\")"); - generated.Split(".Produces(400, \"application/problem+json\")").Length.Should().Be(2); - generated.Split(".Produces(500, \"application/problem+json\")").Length.Should().Be(2); + (generated.Split(".Produces(400, \"application/problem+json\")").Length - 1).Should().Be(1); + (generated.Split(".Produces(500, \"application/problem+json\")").Length - 1).Should().Be(1); + generated.Should().NotContain(".Produces(400, \"application/problem+json\").Produces(400"); generated.Should().NotContain(".Produces(500, \"application/problem+json\").Produces(500"); } From 3cb63d3e0c790f28c1dfa8c2ff1cb98d0231ba70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:20:32 +0000 Subject: [PATCH 3/3] fix(mediator): add error code titles and details to Problem results Co-authored-by: AndreaCuneo <5227688+AndreaCuneo@users.noreply.github.com> --- .../MinimalApiEndpointGenerator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs index 7570343fb..bede33a5c 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs @@ -528,7 +528,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray(httpContext, cancellationToken).ConfigureAwait(false);"); sb.AppendLine(" if (body is null)"); - sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400);"); + sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400, title: \"INVALID_REQUEST_BODY\", detail: \"Request body is missing or could not be deserialized.\");"); if (explicitBindings) { var assignments = string.Join(", ", e.Properties @@ -748,7 +748,7 @@ private static void EmitMultipartEndpoint( sb.AppendLine(" {"); sb.AppendLine(" var form = await httpContext.Request.ReadFormAsync(cancellationToken).ConfigureAwait(false);"); sb.AppendLine(" if (form.Files.Count != 1)"); - sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400, detail: \"Exactly one file is required.\");"); + sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400, title: \"INVALID_FILE_COUNT\", detail: \"Exactly one file is required.\");"); sb.AppendLine(" var file = form.Files[0];"); if (!endpoint.AllowedContentTypes.IsDefaultOrEmpty) {