Skip to content

Commit a3ff955

Browse files
authored
Merge pull request #29 from Fresa/define-response-precedence
Add response content negotiation
2 parents f52280b + 661dc3e commit a3ff955

19 files changed

Lines changed: 349 additions & 162 deletions

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ app.MapOperations();
7070
app.Run();
7171
```
7272

73-
Examples:
73+
## Examples:
7474
- [OpenAPI 2.0](tests/Example.OpenApi20)
7575
- [OpenAPI 3.0](tests/Example.OpenApi30)
7676
- [OpenAPI 3.1](tests/Example.OpenApi31)
@@ -134,6 +134,39 @@ These handlers will not be generated in subsequent compilations as the generator
134134
</Target>
135135
```
136136

137+
## Content Negotiation
138+
Content is negotiated for both request and responses.
139+
140+
See the [examples](#examples) for more details.
141+
### Request Body Content
142+
Request body content is automatically mapped via the [Content-Type](https://datatracker.ietf.org/doc/html/rfc9110#field.content-type) header. The `Request.Body` property has content properties generated for all specified content which can be tested for nullability to figure out which one was sent.
143+
144+
If `Body` is optional, all content properties might be null.
145+
146+
If body is not defined for the request, there will be no `Body` property generated.
147+
148+
### Response Content
149+
Response content can be negotiated using the `TryMatchAcceptMediaType` method exposed by the `Request` class. Call it with the wanted response and it will return the best content matching the [Accept](https://datatracker.ietf.org/doc/html/rfc9110#name-accept) header.
150+
151+
This method can only be used with response that define content, and it is scoped to responses defined by the current operation.
152+
153+
Example:
154+
```dotnet
155+
switch (request.TryMatchAcceptMediaType<Response.OK200>(out var matchedMediaType))
156+
{
157+
// No match, the server decides what to do
158+
case false:
159+
// Matched any application content (application/*)
160+
case true when matchedMediaType == Response.OK200.AnyApplication.ContentMediaType:
161+
return Task.FromResult<Response>(new Response.OK200.AnyApplication(
162+
Components.Schemas.FooProperties.Create(name: request.Body.ApplicationJson?.Name),
163+
"application/json") { Headers = new Response.OK200.ResponseHeaders { Status = 2 } });
164+
// Matched content that has not been implemented yet by the operation handler (can be used to detect newly specified content that has not yet been implemented)
165+
default:
166+
throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented");
167+
}
168+
```
169+
137170
## Authentication and Authorization
138171
OpenAPI defines [security scheme objects](https://spec.openapis.org/oas/latest#security-scheme-object) for authentication and authorization mechanisms. The generator implement endpoint filters that corresponds to the security declaration of each operation. Do _not_ call `UseAuthentication` or similar when configuring the application.
139172

src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,8 @@ internal abstract class BaseSecurityRequirementsFilter(WebApiConfiguration confi
228228
protected abstract SecurityRequirements Requirements { get; }
229229
protected WebApiConfiguration Configuration { get; } = configuration;
230230
231-
protected abstract void HandleForbidden(HttpResponse response);
232-
protected abstract void HandleUnauthorized(HttpResponse response);
231+
protected abstract void HandleForbidden(HttpContext context);
232+
protected abstract void HandleUnauthorized(HttpContext context);
233233
234234
/// <inheritdoc/>
235235
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
@@ -284,11 +284,11 @@ internal abstract class BaseSecurityRequirementsFilter(WebApiConfiguration confi
284284
285285
if (passedAuthentication)
286286
{
287-
HandleForbidden(httpContext.Response);
287+
HandleForbidden(httpContext);
288288
return null;
289289
}
290290
291-
HandleUnauthorized(httpContext.Response);
291+
HandleUnauthorized(httpContext);
292292
return null;
293293
}
294294
@@ -396,8 +396,9 @@ internal sealed class {{securityRequirementsFilterClassName}}(Operation operatio
396396
""")))}}
397397
};
398398
399-
protected override void HandleUnauthorized(HttpResponse response) => operation.Validate(operation.HandleUnauthorized(), Configuration).WriteTo(response);
400-
protected override void HandleForbidden(HttpResponse response) => operation.Validate(operation.HandleForbidden(), Configuration).WriteTo(response);
399+
private static Request ResolveRequest(HttpContext context) => (Request) context.Items[RequestItemKey]!;
400+
protected override void HandleUnauthorized(HttpContext context) => operation.Validate(operation.HandleUnauthorized(ResolveRequest(context)), Configuration).WriteTo(context.Response);
401+
protected override void HandleForbidden(HttpContext context) => operation.Validate(operation.HandleForbidden(ResolveRequest(context)), Configuration).WriteTo(context.Response);
401402
}
402403
""";
403404
}

src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ internal SourceCode GenerateHttpRequestExtensionsClass() =>
4343
using Corvus.Json;
4444
using Microsoft.AspNetCore.Http;
4545
using Microsoft.Extensions.Primitives;
46+
using Microsoft.Net.Http.Headers;
4647
using OpenAPI.ParameterStyleParsers;
4748
4849
namespace {{{@namespace}}};
@@ -184,7 +185,7 @@ private static T Parse<T>(IParameterValueParser parser, string? value)
184185
}
185186
186187
return instance == null ? T.Null : T.Parse(instance.ToJsonString());
187-
}
188+
}
188189
}
189190
#nullable restore
190191
"""");

src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,7 @@ internal static void WriteResponseHeader<TValue>(this HttpResponse response,
7171
/// </summary>
7272
/// <param name="response">The response object to write the body to</param>
7373
/// <param name="value">The value of the body</param>
74-
/// <typeparam name="TValue">The type of the body</typeparam>
75-
internal static void WriteResponseBody<TValue>(this HttpResponse response, TValue value)
76-
where TValue : struct, IJsonValue<TValue>
74+
internal static void WriteResponseBody(this HttpResponse response, IJsonValue value)
7775
{
7876
using var jsonWriter = new Utf8JsonWriter(response.BodyWriter);
7977
value.WriteTo(jsonWriter);

src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ internal partial class Operation
6565
/// Set a custom delegate to handle request validation errors.
6666
/// <exception cref="JsonValidationException"></exception>
6767
/// </summary>
68-
private Func<ImmutableList<ValidationResult>, Response> HandleRequestValidationError { get; } = validationResult =>
68+
private Func<Request, ImmutableList<ValidationResult>, Response> HandleRequestValidationError { get; } = (_, validationResult) =>
6969
{{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}};
7070
7171
{{authGenerator.GenerateAuthFilters(operation.Operation, parameters, out var requiresAuth).Indent(4)}}
@@ -75,12 +75,12 @@ internal partial class Operation
7575
/// <summary>
7676
/// Set a custom delegate to handle unauthorized responses.
7777
/// </summary>
78-
private Func<Response> HandleUnauthorized { get; } = () => new Response.Unauthorized();
78+
private Func<Request, Response> HandleUnauthorized { get; } = _ => new Response.Unauthorized();
7979
8080
/// <summary>
8181
/// Set a custom delegate to handle forbidden responses.
8282
/// </summary>
83-
private Func<Response> HandleForbidden { get; } = () => new Response.Forbidden();
83+
private Func<Request, Response> HandleForbidden { get; } = _ => new Response.Forbidden();
8484
8585
""" : "")}}
8686
/// <summary>
@@ -124,7 +124,7 @@ internal static async Task HandleAsync(
124124
var validationContext = request.Validate(operation.ValidationLevel);
125125
if (!validationContext.IsValid)
126126
{
127-
operation.HandleRequestValidationError(validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri))
127+
operation.HandleRequestValidationError(request, validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri))
128128
.WriteTo(context.Response);
129129
return;
130130
}

src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ internal sealed class RequestBodyContentGenerator(
1717

1818
internal string PropertyName { get; } = contentType.ToPascalCase();
1919

20-
internal MediaTypeHeaderValue ContentType { get; } = MediaTypeHeaderValue.Parse(contentType);
20+
internal MediaTypeWithQualityHeaderValue ContentType { get; } = MediaTypeWithQualityHeaderValue.Parse(contentType);
2121

2222
internal string SchemaLocation => typeDeclaration.RelativeSchemaLocation;
2323
internal string GenerateRequestBindingDirective() =>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using OpenAPI.WebApiGenerator.Extensions;
4+
5+
namespace OpenAPI.WebApiGenerator.CodeGeneration;
6+
7+
internal static class RequestBodyContentGeneratorExtensions
8+
{
9+
internal static IEnumerable<RequestBodyContentGenerator> SortByContentType(
10+
this IEnumerable<RequestBodyContentGenerator> generators) =>
11+
generators
12+
.GroupBy(generator => generator.ContentType.Quality ?? 1)
13+
.OrderByDescending(grouping => grouping.Key)
14+
.SelectMany(grouping => grouping
15+
.OrderByDescending(generator =>
16+
generator.ContentType.GetPrecedence()));
17+
}

src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ public RequestBodyGenerator(
2525
{
2626
_body = body;
2727
_contentGenerators = contentGenerators
28-
.OrderByDescending(generator =>
29-
generator.ContentType.GetPrecedence())
28+
.SortByContentType()
3029
.ToList();
3130
}
3231

src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace OpenAPI.WebApiGenerator.CodeGeneration;
99
internal sealed class ResponseBodyContentGenerator
1010
{
1111
private readonly string _contentVariableName;
12-
public string ContentPropertyName { get; }
12+
internal string ClassName { get; }
1313
private readonly MediaTypeHeaderValue _contentType;
1414
private readonly TypeDeclaration _typeDeclaration;
1515
private readonly bool _isContentTypeRange;
@@ -18,7 +18,7 @@ public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDecl
1818
{
1919
_contentType = MediaTypeHeaderValue.Parse(contentType);
2020
_typeDeclaration = typeDeclaration;
21-
ContentPropertyName = contentType.ToPascalCase();
21+
ClassName = contentType.ToPascalCase();
2222

2323
_isContentTypeRange = false;
2424
switch (_contentType.MediaType)
@@ -38,38 +38,37 @@ public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDecl
3838
break;
3939
}
4040

41-
ContentPropertyName = _contentVariableName.ToPascalCase();
41+
ClassName = _contentVariableName.ToPascalCase();
4242
}
4343

44-
internal string SchemaLocation => _typeDeclaration.RelativeSchemaLocation;
45-
public string GenerateConstructor(string className, string contentTypeFieldName) =>
44+
private string SchemaLocation => _typeDeclaration.RelativeSchemaLocation;
45+
public string GenerateResponseClass(string responseClassName, string contentTypeFieldName) =>
4646
$$"""
4747
/// <summary>
48-
/// Construct content for {{_contentType}}
48+
/// Response for content {{_contentType}}
4949
/// </summary>
50-
/// <param name="{{_contentVariableName}}">Content</param>{{(_isContentTypeRange ? $"""
50+
internal sealed class {{ClassName}} : {{responseClassName}}
51+
{
52+
/// <summary>
53+
/// Construct response for content {{_contentType}}
54+
/// </summary>
55+
/// <param name="{{_contentVariableName}}">Content</param>{{(_isContentTypeRange ? $"""
5156
52-
/// <param name="contentType">Content type must match range {_contentType.MediaType}</param>
57+
/// <param name="contentType">Content type must match range {_contentType.MediaType}</param>
58+
""" : "")}}
59+
public {{ClassName}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}})
60+
{{{(_isContentTypeRange ?
61+
"""
62+
63+
EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), ContentMediaType);
5364
""" : "")}}
54-
public {{className}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}})
55-
{{{(_isContentTypeRange ?
56-
$$"""
65+
Content = {{_contentVariableName}};
66+
{{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}};
67+
}
5768
58-
EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), MediaTypeHeaderValue.Parse("{{_contentType}}"));
59-
""" : "")}}
60-
{{ContentPropertyName}} = {{_contentVariableName}};
61-
{{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}};
69+
internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}"));
70+
protected override IJsonValue Content { get; }
71+
protected override string ContentSchemaLocation { get; } = "{{SchemaLocation}}";
6272
}
6373
""";
64-
65-
public string GenerateContentProperty()
66-
{
67-
return
68-
$$"""
69-
/// <summary>
70-
/// Content for {{_contentType}}
71-
/// </summary>
72-
internal {{_typeDeclaration.FullyQualifiedDotnetTypeName()}}? {{ContentPropertyName}} { get; }
73-
""";
74-
}
7574
}

src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,26 @@ public string GenerateResponseContentClass()
6262
return
6363
$$$"""
6464
{{{_response.Description.AsComment("summary", "para")}}}
65-
internal sealed class {{{_responseClassName}}} : Response
65+
internal {{{(_contentGenerators.Any() ? "abstract" : "sealed")}}} class {{{_responseClassName}}} : Response{{{(_contentGenerators.Any() ? $", IContent<{_responseClassName}>" : "")}}}
6666
{
6767
private string? {{{contentTypeFieldName}}} = null;{{{
6868
_contentGenerators.AggregateToString(generator =>
69-
generator.GenerateConstructor(_responseClassName, contentTypeFieldName)).Indent(4)
70-
}}}{{{
71-
_contentGenerators.AggregateToString(generator =>
72-
generator.GenerateContentProperty()).Indent(4)
73-
}}}
69+
generator.GenerateResponseClass(_responseClassName, contentTypeFieldName)).Indent(4)
70+
}}}{{{(_contentGenerators.Any() ?
71+
$$"""
72+
73+
74+
protected abstract IJsonValue Content { get; }
75+
protected abstract string ContentSchemaLocation { get; }
76+
77+
/// <inheritdoc/>
78+
public static ContentMediaType<{{_responseClassName}}>[] ContentMediaTypes { get; } =
79+
[{{_contentGenerators.AggregateToString(generator =>
80+
$$"""
81+
{{generator.ClassName}}.ContentMediaType,
82+
""").TrimEnd(',')}}
83+
];
84+
""" : "")}}}
7485
7586
private int _statusCode{{{(hasExplicitStatusCode ? $" = {_responseStatusCodePattern}" : string.Empty)}}};
7687
/// <summary>
@@ -106,19 +117,9 @@ internal override void WriteTo(HttpResponse {{{responseVariableName}}})
106117
{{{{(_contentGenerators.Any() ?
107118
$$"""
108119

109-
switch (true)
110-
{{{_contentGenerators.AggregateToString(generator =>
111-
$"""
112-
case true when {generator.ContentPropertyName} is not null:
113-
{HttpResponseExtensionsGenerator.CreateWriteBodyInvocation(
114-
responseVariableName,
115-
$"{generator.ContentPropertyName}.Value")};
116-
break;
117-
""")}}
118-
default:
119-
throw new InvalidOperationException("No content was defined");
120-
}
121-
120+
{{HttpResponseExtensionsGenerator.CreateWriteBodyInvocation(
121+
responseVariableName,
122+
"Content")}};
122123
""" : "")}}}
123124
{{{responseVariableName}}}.ContentType = {{{contentTypeFieldName}}};
124125
{{{responseVariableName}}}.StatusCode = StatusCode;{{{
@@ -130,14 +131,10 @@ internal override void WriteTo(HttpResponse {{{responseVariableName}}})
130131
internal override ValidationContext Validate(ValidationLevel validationLevel)
131132
{
132133
var validationContext = ValidationContext.ValidContext.UsingStack().UsingResults();
133-
validationContext = true switch
134-
{{{{_contentGenerators.AggregateToString(generator =>
134+
{{{(_contentGenerators.Any() ?
135135
$"""
136-
true when {generator.ContentPropertyName} is not null =>
137-
{generator.ContentPropertyName}.Value.Validate("{generator.SchemaLocation}", true, validationContext, validationLevel),
138-
""")}}}
139-
_ => validationContext
140-
};
136+
validationContext = Content.Validate(ContentSchemaLocation, true, validationContext, validationLevel);
137+
""" : "")}}}
141138
{{{_headerGenerators.AggregateToString(generator =>
142139
generator.GenerateValidateDirective()).Indent(8)}}}
143140
return validationContext;

0 commit comments

Comments
 (0)