diff --git a/README.md b/README.md index eb01b05..7940f22 100644 --- a/README.md +++ b/README.md @@ -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) @@ -134,6 +134,39 @@ These handlers will not be generated in subsequent compilations as the generator ``` +## 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(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(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. diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index e5ac4e1..8fb519a 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -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); /// public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) @@ -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; } @@ -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); } """; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index 4feb448..9bd62de 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs @@ -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}}}; @@ -184,7 +185,7 @@ private static T Parse(IParameterValueParser parser, string? value) } return instance == null ? T.Null : T.Parse(instance.ToJsonString()); - } + } } #nullable restore """"); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs index da36c6c..b0d12a7 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs @@ -71,9 +71,7 @@ internal static void WriteResponseHeader(this HttpResponse response, /// /// The response object to write the body to /// The value of the body - /// The type of the body - internal static void WriteResponseBody(this HttpResponse response, TValue value) - where TValue : struct, IJsonValue + internal static void WriteResponseBody(this HttpResponse response, IJsonValue value) { using var jsonWriter = new Utf8JsonWriter(response.BodyWriter); value.WriteTo(jsonWriter); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index b6ebc2d..4d6929f 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -65,7 +65,7 @@ internal partial class Operation /// Set a custom delegate to handle request validation errors. /// /// - private Func, Response> HandleRequestValidationError { get; } = validationResult => + private Func, Response> HandleRequestValidationError { get; } = (_, validationResult) => {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}}; {{authGenerator.GenerateAuthFilters(operation.Operation, parameters, out var requiresAuth).Indent(4)}} @@ -75,12 +75,12 @@ internal partial class Operation /// /// Set a custom delegate to handle unauthorized responses. /// - private Func HandleUnauthorized { get; } = () => new Response.Unauthorized(); + private Func HandleUnauthorized { get; } = _ => new Response.Unauthorized(); /// /// Set a custom delegate to handle forbidden responses. /// - private Func HandleForbidden { get; } = () => new Response.Forbidden(); + private Func HandleForbidden { get; } = _ => new Response.Forbidden(); """ : "")}} /// @@ -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; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index 10b91a6..c6257f7 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -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() => diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGeneratorExtensions.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGeneratorExtensions.cs new file mode 100644 index 0000000..5f1cd31 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGeneratorExtensions.cs @@ -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 SortByContentType( + this IEnumerable generators) => + generators + .GroupBy(generator => generator.ContentType.Quality ?? 1) + .OrderByDescending(grouping => grouping.Key) + .SelectMany(grouping => grouping + .OrderByDescending(generator => + generator.ContentType.GetPrecedence())); +} \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs index 7eeb427..d903a0a 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs @@ -25,8 +25,7 @@ public RequestBodyGenerator( { _body = body; _contentGenerators = contentGenerators - .OrderByDescending(generator => - generator.ContentType.GetPrecedence()) + .SortByContentType() .ToList(); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index 620b077..b9e3507 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -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; @@ -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) @@ -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) => $$""" /// -/// Construct content for {{_contentType}} +/// Response for content {{_contentType}} /// -/// Content{{(_isContentTypeRange ? $""" +internal sealed class {{ClassName}} : {{responseClassName}} +{ + /// + /// Construct response for content {{_contentType}} + /// + /// Content{{(_isContentTypeRange ? $""" -/// Content type must match range {_contentType.MediaType} + /// Content type must match range {_contentType.MediaType} + """ : "")}} + 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 -$$""" -/// -/// Content for {{_contentType}} -/// -internal {{_typeDeclaration.FullyQualifiedDotnetTypeName()}}? {{ContentPropertyName}} { get; } -"""; - } } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index 5f95eac..c46c00e 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -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; } + + /// + public static ContentMediaType<{{_responseClassName}}>[] ContentMediaTypes { get; } = + [{{_contentGenerators.AggregateToString(generator => +$$""" + {{generator.ClassName}}.ContentMediaType, +""").TrimEnd(',')}} + ]; +""" : "")}}} private int _statusCode{{{(hasExplicitStatusCode ? $" = {_responseStatusCodePattern}" : string.Empty)}}}; /// @@ -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;{{{ @@ -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; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index 200980a..088a9c3 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -14,7 +14,8 @@ public SourceCode GenerateResponseClass(string @namespace, string path) $$""" #nullable enable using Corvus.Json; -using System.Net.Http.Headers; +using Microsoft.Net.Http.Headers; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using {{httpResponseExtensionsGenerator.Namespace}}; @@ -44,18 +45,10 @@ internal abstract partial class Response /// Expected content type protected void EnsureExpectedContentType(MediaTypeHeaderValue contentType, MediaTypeHeaderValue expectedContentType) { - var valid = expectedContentType.MediaType switch + if (!contentType.IsSubsetOf(expectedContentType)) { - "*/*" => true, - not null when expectedContentType.MediaType.EndsWith("*") => - contentType.MediaType?.StartsWith(expectedContentType.MediaType.TrimEnd('*'), StringComparison.OrdinalIgnoreCase) ?? false, - not null => contentType.MediaType?.Equals(expectedContentType.MediaType, StringComparison.OrdinalIgnoreCase) ?? false, - _ => false - }; - - if (valid) - return; - throw new ArgumentOutOfRangeException($"Expected content type {contentType.MediaType} to match range {expectedContentType.MediaType}"); + throw new ArgumentOutOfRangeException($"Expected content type {contentType.MediaType} to be a subset of {expectedContentType.MediaType}"); + } } /// @@ -75,6 +68,76 @@ not null when expectedContentType.MediaType.EndsWith("*") => generator.GenerateResponseContentClass()).Indent(4) }} } + +/// +/// Represents a response with content +/// +/// Response +internal interface IContent where T : Response +{ + /// + /// Contents for the response + /// + internal static abstract ContentMediaType[] ContentMediaTypes { get; } +} + +/// +/// Typed content media type +/// +/// Response +internal readonly record struct ContentMediaType(MediaTypeHeaderValue Value) + where T : Response +{ + /// + /// Implicitly convert back to MediaTypeHeaderValue + /// + public static implicit operator MediaTypeHeaderValue(ContentMediaType mediaType) => mediaType.Value; +} + +internal partial class Request +{ + /// + /// Returns the best response content media type match if an acceptable media type is found. + /// + /// Matched content media type if method returns true + /// The response to match against + /// True if a matched media type was found + internal bool TryMatchAcceptMediaType( + [NotNullWhen(true)] out ContentMediaType? matchedContentMediaType) where T : Response, IContent + { + var mediaTypes = T.ContentMediaTypes; + var acceptHeaders = HttpContext.Request.GetTypedHeaders().Accept; + if (acceptHeaders is not { Count: > 0 }) + { + matchedContentMediaType = mediaTypes.Length > 0 ? mediaTypes[0] : null; + return matchedContentMediaType != null; + } + + var sortedAcceptMediaTypes = acceptHeaders + .OrderByDescending(headerValue => headerValue.Quality ?? 1.0) + .ThenByDescending(headerValue => headerValue.MatchesAllTypes ? 0 : headerValue.MatchesAllSubTypes ? 1 : 2) + .ThenByDescending(headerValue => headerValue.Parameters.Count); + + foreach (var acceptMediaType in sortedAcceptMediaTypes) + { + if ((acceptMediaType.Quality ?? 1.0) <= 0) + continue; + + foreach (var mediaType in mediaTypes) + { + if (!mediaType.Value.IsSubsetOf(acceptMediaType)) + { + continue; + } + matchedContentMediaType = mediaType; + return true; + } + } + + matchedContentMediaType = null; + return false; + } +} #nullable restore """); } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ValidationExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ValidationExtensionsGenerator.cs index 8005a93..acb99a5 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ValidationExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ValidationExtensionsGenerator.cs @@ -8,6 +8,7 @@ internal sealed class ValidationExtensionsGenerator(string @namespace) #nullable enable using Corvus.Json; using System.Collections.Immutable; +using System.Text.Json; namespace {{@namespace}}; @@ -54,14 +55,13 @@ private static (JsonReference ValidationLocation, JsonReference SchemaLocation, /// Current validation context /// The validation level /// The validation result - internal static ValidationContext Validate(this T value, + internal static ValidationContext Validate(this IJsonValue value, string schemaLocation, bool isRequired, ValidationContext validationContext, ValidationLevel validationLevel) - where T : struct, IJsonValue { - if (!isRequired && value.IsUndefined()) + if (!isRequired && value.ValueKind == JsonValueKind.Undefined) { return validationContext; } diff --git a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs index e21481c..5dfaf71 100644 --- a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs @@ -12,14 +12,21 @@ public Operation() ValidationLevel = ValidationLevel.Detailed; } - private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(Request request, ImmutableList validationResults) { - var response = validationResults.Select(result => - Responses.BadRequest.RequiredErrorAndName.Create( - name: result.Location?.SchemaLocation.ToString() ?? string.Empty, - error: result.Message ?? string.Empty)); - return new Response.BadRequest400( - Responses.BadRequest.Create(response.ToArray())); + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) + { + case false: + case true when matchedMediaType == Response.BadRequest400.ApplicationJson.ContentMediaType: + var response = validationResults.Select(result => + Responses.BadRequest.RequiredErrorAndName.Create( + name: result.Location?.SchemaLocation.ToString() ?? string.Empty, + error: result.Message ?? string.Empty)); + return new Response.BadRequest400.ApplicationJson( + Responses.BadRequest.Create(response.ToArray())); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + } } internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) @@ -28,18 +35,24 @@ internal partial Task HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - var response = new Response.OK200(Definitions.FooProperties.Create( - name: request.Body.ApplicationJson?.Name)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { - Headers = new Response.OK200.ResponseHeaders - { - Status = 2 - } - }; - - var validationContext = response.Validate(ValidationLevel); - return !validationContext.IsValid - ? throw new JsonValidationException("Response is not valid", validationContext.Results) - : Task.FromResult(response); + case false: + case true when matchedMediaType == Response.OK200.ApplicationJson.ContentMediaType: + var response = new Response.OK200.ApplicationJson(Definitions.FooProperties.Create( + name: request.Body.ApplicationJson?.Name)) + { + Headers = new Response.OK200.ResponseHeaders + { + Status = 2 + } + }; + var validationContext = response.Validate(ValidationLevel); + return !validationContext.IsValid + ? throw new JsonValidationException("Response is not valid", validationContext.Results) + : Task.FromResult(response); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + } } } \ No newline at end of file diff --git a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs index a535008..59c8e7f 100644 --- a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs @@ -11,14 +11,21 @@ public Operation() ValidateResponse = true; } - private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(Request request, ImmutableList validationResults) { - var response = validationResults.Select(result => - Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( - name: result.Location?.SchemaLocation.ToString() ?? string.Empty, - error: result.Message ?? string.Empty)); - return new Response.BadRequest400( - Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) + { + case false: + case true when matchedMediaType == Response.BadRequest400.ApplicationJson.ContentMediaType: + var response = validationResults.Select(result => + Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( + name: result.Location?.SchemaLocation.ToString() ?? string.Empty, + error: result.Message ?? string.Empty)); + return new Response.BadRequest400.ApplicationJson( + Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + } } internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) @@ -27,14 +34,17 @@ internal partial Task HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - var response = new Response.OK200(Components.Schemas.FooProperties.Create( - name: request.Body.ApplicationJson?.Name)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { - Headers = new Response.OK200.ResponseHeaders - { - Status = 2 - } - }; - return Task.FromResult(response); + case false: + case true when matchedMediaType == Response.OK200.ApplicationJson.ContentMediaType: + return Task.FromResult(new Response.OK200.ApplicationJson( + Components.Schemas.FooProperties.Create(name: request.Body.ApplicationJson?.Name)) + { + Headers = new Response.OK200.ResponseHeaders { Status = 2 } + }); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + } } } diff --git a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs index 7fd2835..174ac20 100644 --- a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs @@ -10,14 +10,21 @@ public Operation() HandleRequestValidationError = HandleValidationErrors; } - private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(Request request, ImmutableList validationResults) { - var response = validationResults.Select(result => - Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( - name: result.Location?.SchemaLocation.ToString() ?? string.Empty, - error: result.Message ?? string.Empty)); - return new Response.BadRequest400( - Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) + { + case false: + case true when matchedMediaType == Response.BadRequest400.ApplicationJson.ContentMediaType: + var response = validationResults.Select(result => + Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( + name: result.Location?.SchemaLocation.ToString() ?? string.Empty, + error: result.Message ?? string.Empty)); + return new Response.BadRequest400.ApplicationJson( + Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + } } internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) @@ -26,14 +33,17 @@ internal partial Task HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - var response = new Response.OK200(Components.Schemas.FooProperties.Create( - name: request.Body.ApplicationJson?.Name)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { - Headers = new Response.OK200.ResponseHeaders - { - Status = 2 - } - }; - return Task.FromResult(response); + case false: + case true when matchedMediaType == Response.OK200.ApplicationJson.ContentMediaType: + return Task.FromResult(new Response.OK200.ApplicationJson( + Components.Schemas.FooProperties.Create(name: request.Body.ApplicationJson?.Name)) + { + Headers = new Response.OK200.ResponseHeaders { Status = 2 } + }); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + } } -} \ No newline at end of file +} diff --git a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs index 42a0587..71f2e86 100644 --- a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs @@ -10,30 +10,37 @@ public Operation() HandleRequestValidationError = HandleValidationErrors; } - private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(Request request, ImmutableList validationResults) { - var response = validationResults.Select(result => - Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( - name: result.Location?.SchemaLocation.ToString() ?? string.Empty, - error: result.Message ?? string.Empty)); - return new Response.BadRequest400( - Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) + { + case false: + case true when matchedMediaType == Response.BadRequest400.ApplicationJson.ContentMediaType: + var response = validationResults.Select(result => + Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( + name: result.Location?.SchemaLocation.ToString() ?? string.Empty, + error: result.Message ?? string.Empty)); + return new Response.BadRequest400.ApplicationJson( + Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + } } internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) { _ = request.Query.Fee; _ = request.Path.FooId; - _ = request.Header.Bar; - - var response = new Response.OK200(Components.Schemas.FooProperties.Create( - name: request.Body.ApplicationJson?.Name), "application/json") + + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { - Headers = new Response.OK200.ResponseHeaders - { - Status = 2 - } - }; - return Task.FromResult(response); + case false: + case true when matchedMediaType == Response.OK200.AnyApplication.ContentMediaType: + return Task.FromResult(new Response.OK200.AnyApplication( + Components.Schemas.FooProperties.Create(name: request.Body.ApplicationJson?.Name), + "application/json") { Headers = new Response.OK200.ResponseHeaders { Status = 2 } }); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + } } -} +} \ No newline at end of file diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs index 8d99546..962cc67 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; -using System.Net.Http.Headers; -using System.Net.Mime; -using System.Text; using System.Threading; using AwesomeAssertions; using Microsoft.CodeAnalysis; @@ -144,12 +139,15 @@ public void ResponseContentMediaTypes_Generating_ConstructorPerMediaType(string .OfType() .Where(symbol => symbol.ContainingNamespace.ToDisplayString() == $"{compilation.AssemblyName}.Paths.Foo.Get") .Should().HaveCount(1).And.Subject.First(); - - var constructors = responseType.Constructors - .Where(c => !c.IsImplicitlyDeclared) + + var contentClasses = responseType.GetTypeMembers() + .Where(t => t.BaseType?.Name == "OK200") .ToArray(); - constructors.Should().HaveCount(5); + contentClasses.Should().HaveCount(5); + contentClasses.Select(symbol => symbol.Name) + .Should() + .OnlyHaveUniqueItems("content classes must have distinct names"); var sourceCode = responseType.DeclaringSyntaxReferences.First() .SyntaxTree.ToString(); diff --git a/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs new file mode 100644 index 0000000..90a07ce --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs @@ -0,0 +1,40 @@ +using System.Linq; +using AwesomeAssertions; +using OpenAPI.WebApiGenerator.CodeGeneration; +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests.CodeGeneration; + +public class RequestBodyContentGeneratorExtensionTests +{ + [Fact] + public void ListOfRequestBodyContentGenerators_SortByContentType_SortsAccordingToPrecedence() + { + var generators = new[] + { + CreateGenerator("*/*"), + CreateGenerator("application/json; q=0.5"), + CreateGenerator("text/*"), + CreateGenerator("application/json"), + CreateGenerator("text/*; q=0.9"), + CreateGenerator("application/json; charset=utf-8"), + CreateGenerator("text/plain"), + }; + + var sorted = generators.SortByContentType() + .Select(generator => generator.ContentType.ToString()) + .ToArray(); + + sorted.Should().ContainInOrder( + "application/json; charset=utf-8", + "application/json", + "text/plain", + "text/*", + "*/*", + "text/*; q=0.9", + "application/json; q=0.5"); + } + + private static RequestBodyContentGenerator CreateGenerator(string contentType) => + new(contentType, null!, null!); +} \ No newline at end of file diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj b/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj index 5dc289f..7643ccf 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj @@ -8,6 +8,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive