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
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ app.MapOperations();
app.Run();
```

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

## Content Negotiation
Content is negotiated for both request and responses.

See the [examples](#examples) for more details.
### Request Body Content
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.

If `Body` is optional, all content properties might be null.

If body is not defined for the request, there will be no `Body` property generated.

### Response Content
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.

This method can only be used with response that define content, and it is scoped to responses defined by the current operation.

Example:
```dotnet
switch (request.TryMatchAcceptMediaType<Response.OK200>(out var matchedMediaType))
{
// No match, the server decides what to do
case false:
// Matched any application content (application/*)
case true when matchedMediaType == Response.OK200.AnyApplication.ContentMediaType:
return Task.FromResult<Response>(new Response.OK200.AnyApplication(
Components.Schemas.FooProperties.Create(name: request.Body.ApplicationJson?.Name),
"application/json") { Headers = new Response.OK200.ResponseHeaders { Status = 2 } });
// 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)
default:
throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented");
}
```

## Authentication and Authorization
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.

Expand Down
13 changes: 7 additions & 6 deletions src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ internal abstract class BaseSecurityRequirementsFilter(WebApiConfiguration confi
protected abstract SecurityRequirements Requirements { get; }
protected WebApiConfiguration Configuration { get; } = configuration;

protected abstract void HandleForbidden(HttpResponse response);
protected abstract void HandleUnauthorized(HttpResponse response);
protected abstract void HandleForbidden(HttpContext context);
protected abstract void HandleUnauthorized(HttpContext context);

/// <inheritdoc/>
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
Expand Down Expand Up @@ -284,11 +284,11 @@ internal abstract class BaseSecurityRequirementsFilter(WebApiConfiguration confi

if (passedAuthentication)
{
HandleForbidden(httpContext.Response);
HandleForbidden(httpContext);
return null;
}

HandleUnauthorized(httpContext.Response);
HandleUnauthorized(httpContext);
return null;
}

Expand Down Expand Up @@ -396,8 +396,9 @@ internal sealed class {{securityRequirementsFilterClassName}}(Operation operatio
""")))}}
};

protected override void HandleUnauthorized(HttpResponse response) => operation.Validate(operation.HandleUnauthorized(), Configuration).WriteTo(response);
protected override void HandleForbidden(HttpResponse response) => operation.Validate(operation.HandleForbidden(), Configuration).WriteTo(response);
private static Request ResolveRequest(HttpContext context) => (Request) context.Items[RequestItemKey]!;
protected override void HandleUnauthorized(HttpContext context) => operation.Validate(operation.HandleUnauthorized(ResolveRequest(context)), Configuration).WriteTo(context.Response);
protected override void HandleForbidden(HttpContext context) => operation.Validate(operation.HandleForbidden(ResolveRequest(context)), Configuration).WriteTo(context.Response);
}
""";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ internal SourceCode GenerateHttpRequestExtensionsClass() =>
using Corvus.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using OpenAPI.ParameterStyleParsers;

namespace {{{@namespace}}};
Expand Down Expand Up @@ -184,7 +185,7 @@ private static T Parse<T>(IParameterValueParser parser, string? value)
}

return instance == null ? T.Null : T.Parse(instance.ToJsonString());
}
}
}
#nullable restore
"""");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,7 @@ internal static void WriteResponseHeader<TValue>(this HttpResponse response,
/// </summary>
/// <param name="response">The response object to write the body to</param>
/// <param name="value">The value of the body</param>
/// <typeparam name="TValue">The type of the body</typeparam>
internal static void WriteResponseBody<TValue>(this HttpResponse response, TValue value)
where TValue : struct, IJsonValue<TValue>
internal static void WriteResponseBody(this HttpResponse response, IJsonValue value)
{
using var jsonWriter = new Utf8JsonWriter(response.BodyWriter);
value.WriteTo(jsonWriter);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ internal partial class Operation
/// Set a custom delegate to handle request validation errors.
/// <exception cref="JsonValidationException"></exception>
/// </summary>
private Func<ImmutableList<ValidationResult>, Response> HandleRequestValidationError { get; } = validationResult =>
private Func<Request, ImmutableList<ValidationResult>, Response> HandleRequestValidationError { get; } = (_, validationResult) =>
{{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}};

{{authGenerator.GenerateAuthFilters(operation.Operation, parameters, out var requiresAuth).Indent(4)}}
Expand All @@ -75,12 +75,12 @@ internal partial class Operation
/// <summary>
/// Set a custom delegate to handle unauthorized responses.
/// </summary>
private Func<Response> HandleUnauthorized { get; } = () => new Response.Unauthorized();
private Func<Request, Response> HandleUnauthorized { get; } = _ => new Response.Unauthorized();

/// <summary>
/// Set a custom delegate to handle forbidden responses.
/// </summary>
private Func<Response> HandleForbidden { get; } = () => new Response.Forbidden();
private Func<Request, Response> HandleForbidden { get; } = _ => new Response.Forbidden();

""" : "")}}
/// <summary>
Expand Down Expand Up @@ -124,7 +124,7 @@ internal static async Task HandleAsync(
var validationContext = request.Validate(operation.ValidationLevel);
if (!validationContext.IsValid)
{
operation.HandleRequestValidationError(validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri))
operation.HandleRequestValidationError(request, validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri))
.WriteTo(context.Response);
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ internal sealed class RequestBodyContentGenerator(

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

internal MediaTypeHeaderValue ContentType { get; } = MediaTypeHeaderValue.Parse(contentType);
internal MediaTypeWithQualityHeaderValue ContentType { get; } = MediaTypeWithQualityHeaderValue.Parse(contentType);

internal string SchemaLocation => typeDeclaration.RelativeSchemaLocation;
internal string GenerateRequestBindingDirective() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
using OpenAPI.WebApiGenerator.Extensions;

namespace OpenAPI.WebApiGenerator.CodeGeneration;

internal static class RequestBodyContentGeneratorExtensions
{
internal static IEnumerable<RequestBodyContentGenerator> SortByContentType(
this IEnumerable<RequestBodyContentGenerator> generators) =>
generators
.GroupBy(generator => generator.ContentType.Quality ?? 1)
.OrderByDescending(grouping => grouping.Key)
.SelectMany(grouping => grouping
.OrderByDescending(generator =>
generator.ContentType.GetPrecedence()));
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ public RequestBodyGenerator(
{
_body = body;
_contentGenerators = contentGenerators
.OrderByDescending(generator =>
generator.ContentType.GetPrecedence())
.SortByContentType()
.ToList();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace OpenAPI.WebApiGenerator.CodeGeneration;
internal sealed class ResponseBodyContentGenerator
{
private readonly string _contentVariableName;
public string ContentPropertyName { get; }
internal string ClassName { get; }
private readonly MediaTypeHeaderValue _contentType;
private readonly TypeDeclaration _typeDeclaration;
private readonly bool _isContentTypeRange;
Expand All @@ -18,7 +18,7 @@ public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDecl
{
_contentType = MediaTypeHeaderValue.Parse(contentType);
_typeDeclaration = typeDeclaration;
ContentPropertyName = contentType.ToPascalCase();
ClassName = contentType.ToPascalCase();

_isContentTypeRange = false;
switch (_contentType.MediaType)
Expand All @@ -38,38 +38,37 @@ public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDecl
break;
}

ContentPropertyName = _contentVariableName.ToPascalCase();
ClassName = _contentVariableName.ToPascalCase();
}

internal string SchemaLocation => _typeDeclaration.RelativeSchemaLocation;
public string GenerateConstructor(string className, string contentTypeFieldName) =>
private string SchemaLocation => _typeDeclaration.RelativeSchemaLocation;
public string GenerateResponseClass(string responseClassName, string contentTypeFieldName) =>
$$"""
/// <summary>
/// Construct content for {{_contentType}}
/// Response for content {{_contentType}}
/// </summary>
/// <param name="{{_contentVariableName}}">Content</param>{{(_isContentTypeRange ? $"""
internal sealed class {{ClassName}} : {{responseClassName}}
{
/// <summary>
/// Construct response for content {{_contentType}}
/// </summary>
/// <param name="{{_contentVariableName}}">Content</param>{{(_isContentTypeRange ? $"""

/// <param name="contentType">Content type must match range {_contentType.MediaType}</param>
/// <param name="contentType">Content type must match range {_contentType.MediaType}</param>
""" : "")}}
public {{ClassName}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}})
{{{(_isContentTypeRange ?
"""

EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), ContentMediaType);
""" : "")}}
public {{className}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}})
{{{(_isContentTypeRange ?
$$"""
Content = {{_contentVariableName}};
{{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}};
}

EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), MediaTypeHeaderValue.Parse("{{_contentType}}"));
""" : "")}}
{{ContentPropertyName}} = {{_contentVariableName}};
{{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}};
internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}"));
protected override IJsonValue Content { get; }
protected override string ContentSchemaLocation { get; } = "{{SchemaLocation}}";
}
""";

public string GenerateContentProperty()
{
return
$$"""
/// <summary>
/// Content for {{_contentType}}
/// </summary>
internal {{_typeDeclaration.FullyQualifiedDotnetTypeName()}}? {{ContentPropertyName}} { get; }
""";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,26 @@ public string GenerateResponseContentClass()
return
$$$"""
{{{_response.Description.AsComment("summary", "para")}}}
internal sealed class {{{_responseClassName}}} : Response
internal {{{(_contentGenerators.Any() ? "abstract" : "sealed")}}} class {{{_responseClassName}}} : Response{{{(_contentGenerators.Any() ? $", IContent<{_responseClassName}>" : "")}}}
{
private string? {{{contentTypeFieldName}}} = null;{{{
_contentGenerators.AggregateToString(generator =>
generator.GenerateConstructor(_responseClassName, contentTypeFieldName)).Indent(4)
}}}{{{
_contentGenerators.AggregateToString(generator =>
generator.GenerateContentProperty()).Indent(4)
}}}
generator.GenerateResponseClass(_responseClassName, contentTypeFieldName)).Indent(4)
}}}{{{(_contentGenerators.Any() ?
$$"""


protected abstract IJsonValue Content { get; }
protected abstract string ContentSchemaLocation { get; }

/// <inheritdoc/>
public static ContentMediaType<{{_responseClassName}}>[] ContentMediaTypes { get; } =
[{{_contentGenerators.AggregateToString(generator =>
$$"""
{{generator.ClassName}}.ContentMediaType,
""").TrimEnd(',')}}
];
""" : "")}}}

private int _statusCode{{{(hasExplicitStatusCode ? $" = {_responseStatusCodePattern}" : string.Empty)}}};
/// <summary>
Expand Down Expand Up @@ -106,19 +117,9 @@ internal override void WriteTo(HttpResponse {{{responseVariableName}}})
{{{{(_contentGenerators.Any() ?
$$"""

switch (true)
{{{_contentGenerators.AggregateToString(generator =>
$"""
case true when {generator.ContentPropertyName} is not null:
{HttpResponseExtensionsGenerator.CreateWriteBodyInvocation(
responseVariableName,
$"{generator.ContentPropertyName}.Value")};
break;
""")}}
default:
throw new InvalidOperationException("No content was defined");
}

{{HttpResponseExtensionsGenerator.CreateWriteBodyInvocation(
responseVariableName,
"Content")}};
""" : "")}}}
{{{responseVariableName}}}.ContentType = {{{contentTypeFieldName}}};
{{{responseVariableName}}}.StatusCode = StatusCode;{{{
Expand All @@ -130,14 +131,10 @@ internal override void WriteTo(HttpResponse {{{responseVariableName}}})
internal override ValidationContext Validate(ValidationLevel validationLevel)
{
var validationContext = ValidationContext.ValidContext.UsingStack().UsingResults();
validationContext = true switch
{{{{_contentGenerators.AggregateToString(generator =>
{{{(_contentGenerators.Any() ?
$"""
true when {generator.ContentPropertyName} is not null =>
{generator.ContentPropertyName}.Value.Validate("{generator.SchemaLocation}", true, validationContext, validationLevel),
""")}}}
_ => validationContext
};
validationContext = Content.Validate(ContentSchemaLocation, true, validationContext, validationLevel);
""" : "")}}}
{{{_headerGenerators.AggregateToString(generator =>
generator.GenerateValidateDirective()).Indent(8)}}}
return validationContext;
Expand Down
Loading
Loading