Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/mediator-framework/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Comment thread
AndreaCuneo marked this conversation as resolved.
| 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray<EndpointMod
sb.AppendLine(" {");
sb.AppendLine(" var body = await global::Ark.Tools.MediatorFramework.MinimalApi.ArkMessagePackEx.ReadRequestAsync<" + e.TypeFullName + ">(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, title: \"INVALID_REQUEST_BODY\", detail: \"Request body is missing or could not be deserialized.\");");
if (explicitBindings)
{
var assignments = string.Join(", ", e.Properties
Expand All @@ -553,7 +553,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray<EndpointMod
+ SuccessStatusCode(e) + ", " + NullResultStatusCode(e) + ");");
sb.AppendLine(" }).Accepts<" + e.TypeFullName + ">(\"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 (");
Expand Down Expand Up @@ -599,7 +599,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray<EndpointMod
sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)" + NullResult(e) + ";");
sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)" + SuccessResult(e) + ";");
sb.AppendLine(" }).Produces<" + e.Response + ">(" + SuccessStatusCode(e) + ").Produces(" + NullResultStatusCode(e)
+ ")" + OpenApiMetadata(e, version, maxVersion) + AuthorizationMetadata(e) + ";");
+ ")" + ProblemMetadata(e) + OpenApiMetadata(e, version, maxVersion) + AuthorizationMetadata(e) + ";");
}
}
}
Expand Down Expand Up @@ -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, title: \"INVALID_FILE_COUNT\", detail: \"Exactly one file is required.\");");
sb.AppendLine(" var file = form.Files[0];");
if (!endpoint.AllowedContentTypes.IsDefaultOrEmpty)
{
Expand All @@ -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<global::Microsoft.AspNetCore.Http.IFormFile>(\"multipart/form-data\")").Append(OpenApiMetadata(endpoint, version, maxVersion)).Append(MultipartMetadata(endpoint))
sb.Append(" }).Accepts<global::Microsoft.AspNetCore.Http.IFormFile>(\"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(";");
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(";");
}
Expand Down Expand Up @@ -924,6 +924,27 @@ private static string AuthorizationMetadata(EndpointModel endpoint)
: ".RequireAuthorization(" + Literal(endpoint.Policy!) + ")";
}

private static string ProblemMetadata(EndpointModel endpoint)
{
var metadata = new StringBuilder();
var declaredStatuses = new HashSet<int>(
[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<global::Microsoft.AspNetCore.Mvc.ProblemDetails>(")
.Append(statusCode)
.Append(", \"application/problem+json\")");
}
}

private static string OpenApiMetadata(EndpointModel endpoint, int version, int maxVersion)
=> ".WithGroupName(" + Literal("v" + version)
+ ").WithTags(" + Literal(endpoint.ApiGroup)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,31 @@ public sealed class PublicEndpoint : IQuery<string>
generated.Should().Contain("GetRegistration(handlerType)");
}

[TestMethod]
public void MinimalApiGeneratorAdvertisesStandardProblemResponsesWithoutDuplicates()
{
var generated = RunGenerator<ArkMinimalApiEndpointGenerator>(
"""
using Ark.MediatorFramework;
using Ark.Tools.Solid;
[HttpEndpoint("GET", "/secure", SuccessStatusCode = 400, NullResultStatusCode = 500)]
public sealed class SecureEndpoint : IQuery<string>
{
}
[HttpEndpoint("GET", "/public", AllowAnonymous = true)]
public sealed class PublicEndpoint : IQuery<string>
{
}
""");

generated.Should().Contain(".Produces<global::Microsoft.AspNetCore.Mvc.ProblemDetails>(403, \"application/problem+json\")");
generated.Should().Contain(".Produces<global::Microsoft.AspNetCore.Mvc.ProblemDetails>(500, \"application/problem+json\")");
(generated.Split(".Produces<global::Microsoft.AspNetCore.Mvc.ProblemDetails>(400, \"application/problem+json\")").Length - 1).Should().Be(1);
(generated.Split(".Produces<global::Microsoft.AspNetCore.Mvc.ProblemDetails>(500, \"application/problem+json\")").Length - 1).Should().Be(1);
generated.Should().NotContain(".Produces<global::Microsoft.AspNetCore.Mvc.ProblemDetails>(400, \"application/problem+json\").Produces<global::Microsoft.AspNetCore.Mvc.ProblemDetails>(400");
generated.Should().NotContain(".Produces<global::Microsoft.AspNetCore.Mvc.ProblemDetails>(500, \"application/problem+json\").Produces<global::Microsoft.AspNetCore.Mvc.ProblemDetails>(500");
}
Comment thread
AndreaCuneo marked this conversation as resolved.

[TestMethod]
public void MinimalApiGeneratorEmitsCommandStatusSemantics()
{
Expand Down
Loading