Skip to content

Commit 316438a

Browse files
ANcpLuaclaude
andcommitted
fix: endpoints invisible to OpenAPI/Swagger (#25)
Three-part fix for endpoints being invisible in OpenAPI documents: 1. Wrapper returns typed `Task<Results<...>>` instead of plain `Task`, breaking the `RequestDelegate` signature match so `MapGet` picks the `Delegate` overload and `RequestDelegateFactory` processes it. 2. `(Delegate)` cast on all endpoint registrations as a safety net, explicitly forcing the `Delegate` overload regardless of signature. 3. Always emit explicit `ProducesResponseTypeMetadata` for every response type, giving the OpenAPI document generator full response schema info. Additionally: - Add OpenAPI parameter extraction (route, query, header) via operation transformer for `AddErrorOrOpenApi()` consumers - Update README and CLAUDE.md examples from int to Guid - Bump version to 3.5.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 569c0bf commit 316438a

63 files changed

Lines changed: 727 additions & 310 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,18 @@ Convert `ErrorOr<T>` handlers into fully-wired ASP.NET endpoints:
3131

3232
```
3333
User writes: Generator produces:
34-
[Get("/todos/{id}")] app.MapGet("/todos/{id}", Invoke_Ep1)
35-
ErrorOr<Todo> GetById(int id) -> .WithName("TodoApi_GetById")
34+
[Get("/todos/{id:guid}")] app.MapGet("/todos/{id:guid}", (Delegate)Invoke_Ep1)
35+
ErrorOr<Todo> GetById(Guid id) -> .WithName("TodoApi_GetById")
36+
.WithMetadata(new ProducesResponseTypeMetadata(...))
3637
.RequireAuthorization("Admin")
3738
;
3839
39-
static async Task Invoke_Ep1(HttpContext ctx)
40+
static async Task<Results<Ok<Todo>, ...>> Invoke_Ep1(HttpContext ctx)
4041
{
41-
var __result = await Invoke_Ep1_Core(ctx);
42-
await __result.ExecuteAsync(ctx);
42+
return await Invoke_Ep1_Core(ctx);
4343
}
4444
45-
static Task<IResult> Invoke_Ep1_Core(...)
45+
static Task<Results<Ok<Todo>, ...>> Invoke_Ep1_Core(...)
4646
{
4747
var result = TodoApi.GetById(id);
4848
if (result.IsError) return ToProblem(result.Errors);
@@ -71,24 +71,24 @@ return result.Match(
7171

7272
### AOT Wrapper Pattern
7373

74-
Two-method pattern ensures Native AOT compatibility:
74+
Two-method pattern ensures Native AOT compatibility and OpenAPI visibility:
7575

7676
```csharp
77-
// Wrapper - matches RequestDelegate (HttpContext -> Task)
78-
private static async Task Invoke_Ep1(HttpContext ctx)
77+
// Wrapper - returns typed Results<...> for OpenAPI metadata
78+
// MapGet uses (Delegate)Invoke_Ep1 to force the Delegate overload
79+
private static async Task<Results<Ok<Todo>, NotFound<PD>>> Invoke_Ep1(HttpContext ctx)
7980
{
80-
var __result = await Invoke_Ep1_Core(ctx);
81-
await __result.ExecuteAsync(ctx);
81+
return await Invoke_Ep1_Core(ctx);
8282
}
8383

84-
// Core - returns typed Results<...> for OpenAPI
84+
// Core - returns typed Results<...> with handler logic
8585
private static Task<Results<Ok<Todo>, NotFound<ProblemDetails>>> Invoke_Ep1_Core(HttpContext ctx)
8686
{
8787
// ... handler logic using minimal interface
8888
}
8989
```
9090

91-
**Why**: `(Delegate)` cast forces reflection; `Task<Results<...>>` cannot have `[JsonSerializable]`.
91+
**Why**: Without `(Delegate)` cast, `Func<HttpContext, Task<T>>` matches `RequestDelegate` — endpoints become invisible to OpenAPI. The cast forces `RequestDelegateFactory` to process the delegate, enabling typed return inspection.
9292

9393
### Middleware Emission
9494

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,16 +143,16 @@ using ErrorOr;
143143

144144
public static class TodoApi
145145
{
146-
[Get("/todos/{id}")]
147-
public static ErrorOr<Todo> GetById(int id, ITodoService svc)
146+
[Get("/todos/{id:guid}")]
147+
public static ErrorOr<Todo> GetById(Guid id, ITodoService svc)
148148
=> svc.GetById(id).OrNotFound($"Todo {id} not found");
149149

150150
[Post("/todos")]
151151
public static ErrorOr<Todo> Create(CreateTodoRequest req, ITodoService svc)
152152
=> svc.Create(req); // 201 Created
153153
154-
[Delete("/todos/{id}")]
155-
public static ErrorOr<Deleted> Delete(int id, ITodoService svc)
154+
[Delete("/todos/{id:guid}")]
155+
public static ErrorOr<Deleted> Delete(Guid id, ITodoService svc)
156156
=> svc.Delete(id) ? Result.Deleted : Error.NotFound();
157157
}
158158
```
@@ -244,11 +244,11 @@ public interface ITodoService
244244
{
245245
[ReturnsError(ErrorType.NotFound, "Todo.NotFound")]
246246
[ReturnsError(ErrorType.Validation, "Todo.Invalid")]
247-
ErrorOr<Todo> GetById(int id);
247+
ErrorOr<Todo> GetById(Guid id);
248248
}
249249

250-
[Get("/todos/{id}")]
251-
public static ErrorOr<Todo> GetById(int id, ITodoService svc) =>
250+
[Get("/todos/{id:guid}")]
251+
public static ErrorOr<Todo> GetById(Guid id, ITodoService svc) =>
252252
svc.GetById(id);
253253
// Generates: Results<Ok<Todo>, NotFound<ProblemDetails>, ValidationProblem>
254254
```
@@ -267,9 +267,9 @@ public static ErrorOr<Todo> Create(
267267
ITodoService svc) // -> Service (interface)
268268
=> svc.Create(req);
269269

270-
[Get("/todos/{id}")]
270+
[Get("/todos/{id:guid}")]
271271
public static ErrorOr<Todo> GetById(
272-
int id, // -> Route (matches {id})
272+
Guid id, // -> Route (matches {id})
273273
ITodoService svc) // -> Service
274274
=> svc.GetById(id).OrNotFound();
275275
```

samples/ErrorOrX.Sample/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
var builder = WebApplication.CreateSlimBuilder(args);
22

33
builder.Services
4-
.AddOpenApi()
4+
.AddErrorOrOpenApi()
55
.AddSingleton(TimeProvider.System)
66
.AddScoped<ITodoService, TodoService>();
77

@@ -18,4 +18,5 @@
1818
// convention builder return (like ASP.NET Core's MapRazorComponents)
1919
app.MapErrorOrEndpoints();
2020

21+
2122
app.Run();

src/ErrorOrX.Generators/CLAUDE.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ Roslyn source generator and analyzers for ErrorOrX. Target: `netstandard2.0`.
77
Converts `ErrorOr<T>` methods with route attributes into ASP.NET Core Minimal API endpoints:
88

99
```csharp
10-
[Get("/todos/{id}")]
11-
public static ErrorOr<Todo> GetById(int id) => ...
10+
[Get("/todos/{id:guid}")]
11+
public static ErrorOr<Todo> GetById(Guid id) => ...
1212
```
1313

1414
Outputs:
@@ -37,17 +37,17 @@ return result.Match(value => TypedResults.Ok(value), errors => ToProblem(errors)
3737
### AOT Wrapper Pattern
3838

3939
```csharp
40-
// Wrapper - returns Task (no Delegate cast needed)
41-
private static async Task Invoke_Ep1(HttpContext ctx)
40+
// Wrapper - returns typed Results<...> for OpenAPI visibility
41+
// MapGet passes (Delegate)Invoke_Ep1 to force Delegate overload
42+
private static async Task<Results<Ok<T>, ...>> Invoke_Ep1(HttpContext ctx)
4243
{
43-
var __result = await Invoke_Ep1_Core(ctx);
44-
await __result.ExecuteAsync(ctx);
44+
return await Invoke_Ep1_Core(ctx);
4545
}
4646

4747
// Core - returns typed Results<...> for OpenAPI
4848
private static Task<IResult> Invoke_Ep1_Core(HttpContext ctx)
4949
{
50-
int id = (int)ctx.Request.RouteValues["id"]!;
50+
Guid id = Guid.Parse((string)ctx.Request.RouteValues["id"]!);
5151
var result = TodoApi.GetById(id);
5252
if (result.IsError) return Task.FromResult(ToProblem(result.Errors));
5353
return Task.FromResult(TypedResults.Ok(result.Value));

src/ErrorOrX.Generators/Core/ErrorOrEndpointGenerator.Emitter.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,13 @@ private static void EmitMapCall(StringBuilder code, in EndpointDescriptor ep, in
145145
$" // {ep.HttpMethod} {ep.Pattern} -> {ep.HandlerContainingTypeFqn}.{ep.HandlerMethodName}");
146146
var mapMethod = Emit.MapMethod(ep.HttpMethod);
147147

148-
// Use typed Map* methods without Delegate cast for AOT compatibility
149-
// Store builder for CompositeEndpointConventionBuilder
148+
// Cast to Delegate to force the Delegate overload of MapGet/MapPost/etc.
149+
// Without this cast, the compiler selects the RequestDelegate overload
150+
// (since Func<HttpContext, Task<T>> is also Func<HttpContext, Task>),
151+
// which bypasses RequestDelegateFactory and makes endpoints invisible to OpenAPI.
150152
code.AppendLine(mapMethod == "MapMethods"
151-
? $" var __ep{index} = app.MapMethods(@\"{ep.Pattern}\", new[] {{ \"{ep.HttpMethod}\" }}, Invoke_Ep{index})"
152-
: $" var __ep{index} = app.{mapMethod}(@\"{ep.Pattern}\", Invoke_Ep{index})");
153+
? $" var __ep{index} = app.MapMethods(@\"{ep.Pattern}\", new[] {{ \"{ep.HttpMethod}\" }}, (Delegate)Invoke_Ep{index})"
154+
: $" var __ep{index} = app.{mapMethod}(@\"{ep.Pattern}\", (Delegate)Invoke_Ep{index})");
153155

154156
var (_, operationId) =
155157
EndpointNameHelper.GetEndpointIdentity(ep.HandlerContainingTypeFqn, ep.HandlerMethodName);
@@ -326,10 +328,10 @@ private static void EmitErrorHandling(
326328

327329
private static void EmitWrapperMethod(StringBuilder code, in InvokerContext ctx)
328330
{
329-
code.AppendLine($" private static async Task {ctx.WrapperName}(HttpContext ctx)");
331+
var returnType = ctx.UnionResult.ReturnTypeFqn;
332+
code.AppendLine($" private static async Task<{returnType}> {ctx.WrapperName}(HttpContext ctx)");
330333
code.AppendLine(" {");
331-
code.AppendLine($" var __result = await {ctx.CoreName}(ctx);");
332-
code.AppendLine(" await __result.ExecuteAsync(ctx);");
334+
code.AppendLine($" return await {ctx.CoreName}(ctx);");
333335
code.AppendLine(" }");
334336
code.AppendLine();
335337
}

src/ErrorOrX.Generators/Emitters/EndpointMetadataEmitter.cs

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -92,25 +92,23 @@ private static void EmitProducesMetadata(StringBuilder code, in EndpointDescript
9292
ep.Middleware,
9393
ep.HasParameterValidation);
9494

95-
// Only emit explicit produces metadata when we can't use union types
96-
// (Union types provide this metadata automatically)
97-
if (!unionResult.CanUseUnion)
95+
// Always emit explicit Produces metadata for OpenAPI visibility.
96+
// The wrapper uses RequestDelegate signature (returns Task), so ASP.NET Core's
97+
// RequestDelegateFactory never inspects the union return type for metadata.
98+
// Success response
99+
EmitProducesMetadataLine(code, indent, successInfo.StatusCode,
100+
successInfo.HasBody ? ep.SuccessTypeFqn : null,
101+
WellKnownTypes.Constants.ContentTypeJson);
102+
103+
// Error responses
104+
foreach (var statusCode in unionResult.ExplicitProduceCodes.AsImmutableArray().Distinct()
105+
.OrderBy(static x => x))
98106
{
99-
// Success response
100-
EmitProducesMetadataLine(code, indent, successInfo.StatusCode,
101-
successInfo.HasBody ? ep.SuccessTypeFqn : null,
102-
WellKnownTypes.Constants.ContentTypeJson);
103-
104-
// Error responses
105-
foreach (var statusCode in unionResult.ExplicitProduceCodes.AsImmutableArray().Distinct()
106-
.OrderBy(static x => x))
107-
{
108-
EmitProducesMetadataLine(code, indent, statusCode,
109-
statusCode == 400
110-
? WellKnownTypes.Fqn.HttpValidationProblemDetails
111-
: WellKnownTypes.Fqn.ProblemDetails,
112-
WellKnownTypes.Constants.ContentTypeProblemJson);
113-
}
107+
EmitProducesMetadataLine(code, indent, statusCode,
108+
statusCode == 400
109+
? WellKnownTypes.Fqn.HttpValidationProblemDetails
110+
: WellKnownTypes.Fqn.ProblemDetails,
111+
WellKnownTypes.Constants.ContentTypeProblemJson);
114112
}
115113
}
116114

src/ErrorOrX.Generators/Emitters/GroupEmitter.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,13 @@ public static void EmitGroupedMapCall(
9393
// Use relative pattern (group prefix already handled by MapGroup)
9494
var relativePattern = GetRelativePattern(in ep);
9595

96-
// Emit the map call against the group variable
97-
// Store builder for CompositeEndpointConventionBuilder
96+
// Cast to Delegate to force the Delegate overload of MapGet/MapPost/etc.
97+
// Without this cast, the compiler selects the RequestDelegate overload,
98+
// which bypasses RequestDelegateFactory and makes endpoints invisible to OpenAPI.
9899
var mapMethod = GetMapMethod(ep.HttpMethod);
99100
code.Append(mapMethod == "MapMethods"
100-
? $" var __ep{globalIndex} = {groupVarName}.MapMethods(@\"{relativePattern}\", new[] {{ \"{ep.HttpMethod}\" }}, Invoke_Ep{globalIndex})"
101-
: $" var __ep{globalIndex} = {groupVarName}.{mapMethod}(@\"{relativePattern}\", Invoke_Ep{globalIndex})");
101+
? $" var __ep{globalIndex} = {groupVarName}.MapMethods(@\"{relativePattern}\", new[] {{ \"{ep.HttpMethod}\" }}, (Delegate)Invoke_Ep{globalIndex})"
102+
: $" var __ep{globalIndex} = {groupVarName}.{mapMethod}(@\"{relativePattern}\", (Delegate)Invoke_Ep{globalIndex})");
102103

103104
// Emit operation name
104105
code.AppendLine();

src/ErrorOrX.Generators/Models/EndpointModels.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,16 @@ internal readonly record struct JsonContextInfo(
439439
EquatableArray<string> SerializableTypes,
440440
bool HasCamelCasePolicy);
441441

442+
/// <summary>
443+
/// Represents a parameter for OpenAPI documentation.
444+
/// </summary>
445+
internal readonly record struct OpenApiParameterInfo(
446+
string Name,
447+
string Location,
448+
bool Required,
449+
string SchemaType,
450+
string? SchemaFormat);
451+
442452
/// <summary>
443453
/// Immutable endpoint info for OpenAPI generation.
444454
/// </summary>
@@ -449,7 +459,8 @@ internal readonly record struct OpenApiEndpointInfo(
449459
string? Description,
450460
string HttpMethod,
451461
string Pattern,
452-
EquatableArray<(string ParamName, string Description)> ParameterDocs);
462+
EquatableArray<(string ParamName, string Description)> ParameterDocs,
463+
EquatableArray<OpenApiParameterInfo> Parameters);
453464

454465
/// <summary>
455466
/// Immutable type metadata for schema generation.

0 commit comments

Comments
 (0)