From af011baee490467529203577d05d8f8f9887eca4 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 19 Feb 2026 22:07:58 +0100 Subject: [PATCH 01/14] fix(request): include quality value to determine content negotiation precedence for request content --- .../RequestBodyContentGenerator.cs | 2 +- .../RequestBodyContentGeneratorExtensions.cs | 17 ++++++++ .../CodeGeneration/RequestBodyGenerator.cs | 3 +- ...questBodyContentGeneratorExtensionTests.cs | 40 +++++++++++++++++++ .../OpenAPI.WebApiGenerator.Tests.csproj | 1 + 5 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGeneratorExtensions.cs create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs 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/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 From c22ec7e77ebe2ad1b6f6359b7ed24fdd1572a810 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Feb 2026 17:41:26 +0100 Subject: [PATCH 02/14] refactor(response): simplify ensuring selected content type is a subset of defined content type --- .../CodeGeneration/ResponseGenerator.cs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index 200980a..636ca36 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -44,18 +44,11 @@ internal abstract partial class Response /// Expected content type protected void EnsureExpectedContentType(MediaTypeHeaderValue contentType, MediaTypeHeaderValue expectedContentType) { - var valid = expectedContentType.MediaType switch - { - "*/*" => 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 - }; + var valid = contentType.IsSubsetOf(expectedContentType); 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}"); } /// From 6ec2de8de577157da422fbaa291e92ec99ef7d0a Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Feb 2026 17:56:34 +0100 Subject: [PATCH 03/14] reference proper headers namespace --- src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index 636ca36..9934674 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -14,7 +14,7 @@ public SourceCode GenerateResponseClass(string @namespace, string path) $$""" #nullable enable using Corvus.Json; -using System.Net.Http.Headers; +using Microsoft.Net.Http.Headers; using System.Text.Json; using {{httpResponseExtensionsGenerator.Namespace}}; From c4048228643796fcbdb57a9e59f8ac6e41c248c2 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Feb 2026 18:29:09 +0100 Subject: [PATCH 04/14] test(response): assert all response constructors are unique --- tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs index 8d99546..9888b62 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs @@ -151,6 +151,11 @@ public void ResponseContentMediaTypes_Generating_ConstructorPerMediaType(string constructors.Should().HaveCount(5); + var constructorSignatures = constructors + .Select(methodSymbol => string.Join(", ", methodSymbol.Parameters.Select(parameterSymbol => + parameterSymbol.Type.ToDisplayString()))); + constructorSignatures.Should().OnlyHaveUniqueItems("constructors must have distinct signatures"); + var sourceCode = responseType.DeclaringSyntaxReferences.First() .SyntaxTree.ToString(); TestContext.Current.TestOutputHelper?.Write(sourceCode); From 8ccf391a103d8076ad794d200462d96f28b7639f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Feb 2026 22:06:23 +0100 Subject: [PATCH 05/14] fix(response): move content into separate response classes to guarantee unique content signatures --- .../HttpResponseExtensionsGenerator.cs | 4 +- .../ResponseBodyContentGenerator.cs | 44 +++++++++---------- .../ResponseContentGenerator.cs | 43 +++++++----------- .../ValidationExtensionsGenerator.cs | 6 +-- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 21 ++++++++- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 +- .../ApiGeneratorTests.cs | 21 +++------ 9 files changed, 73 insertions(+), 78 deletions(-) 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/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index 620b077..34b2c71 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -41,35 +41,33 @@ public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDecl ContentPropertyName = _contentVariableName.ToPascalCase(); } - internal string SchemaLocation => _typeDeclaration.RelativeSchemaLocation; - public string GenerateConstructor(string className, string contentTypeFieldName) => + private string SchemaLocation => _typeDeclaration.RelativeSchemaLocation; + public string GenerateResponseClass(string className, string contentTypeFieldName) => $$""" /// -/// Construct content for {{_contentType}} +/// Response for content {{_contentType}} /// -/// Content{{(_isContentTypeRange ? $""" +internal sealed class {{ContentPropertyName}} : {{className}} +{ + /// + /// Construct response for content {{_contentType}} + /// + /// Content{{(_isContentTypeRange ? $""" -/// Content type must match range {_contentType.MediaType} -""" : "")}} -public {{className}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}}) -{{{(_isContentTypeRange ? + /// Content type must match range {_contentType.MediaType} + """ : "")}} + public {{ContentPropertyName}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}}) + {{{(_isContentTypeRange ? $$""" - - EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), MediaTypeHeaderValue.Parse("{{_contentType}}")); + + EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), MediaTypeHeaderValue.Parse("{{_contentType}}")); """ : "")}} - {{ContentPropertyName}} = {{_contentVariableName}}; - {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; + Content = {{_contentVariableName}}; + {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; + } + + 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..b6903a7 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -62,16 +62,19 @@ public string GenerateResponseContentClass() return $$$""" {{{_response.Description.AsComment("summary", "para")}}} -internal sealed class {{{_responseClassName}}} : Response +internal {{{(_contentGenerators.Any() ? "abstract" : "sealed")}}} class {{{_responseClassName}}} : Response { 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; } +""" : "")}}} + private int _statusCode{{{(hasExplicitStatusCode ? $" = {_responseStatusCodePattern}" : string.Empty)}}}; /// /// Response status code @@ -106,19 +109,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 +123,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/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..ea82839 100644 --- a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs @@ -18,7 +18,7 @@ private static Response.BadRequest400 HandleValidationErrors(ImmutableList HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - var response = new Response.OK200(Definitions.FooProperties.Create( + var response = new Response.OK200.ApplicationJson(Definitions.FooProperties.Create( name: request.Body.ApplicationJson?.Name)) { Headers = new Response.OK200.ResponseHeaders diff --git a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs index a535008..ee1eb23 100644 --- a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs @@ -17,7 +17,7 @@ private static Response.BadRequest400 HandleValidationErrors(ImmutableList HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - var response = new Response.OK200(Components.Schemas.FooProperties.Create( + var response = new Response.OK200.ApplicationJson(Components.Schemas.FooProperties.Create( name: request.Body.ApplicationJson?.Name)) { Headers = new Response.OK200.ResponseHeaders diff --git a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs index 7fd2835..0361fc3 100644 --- a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs @@ -16,7 +16,7 @@ private static Response.BadRequest400 HandleValidationErrors(ImmutableList HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - var response = new Response.OK200(Components.Schemas.FooProperties.Create( + var response = new Response.OK200.ApplicationJson(Components.Schemas.FooProperties.Create( name: request.Body.ApplicationJson?.Name)) { Headers = new Response.OK200.ResponseHeaders @@ -36,4 +36,21 @@ internal partial Task HandleAsync(Request request, CancellationToken c }; return Task.FromResult(response); } +} + +internal abstract class Test +{ + protected abstract string A { get; } + + +} + +internal class Testar : Test +{ + public Testar(string a) + { + A = a; + } + + protected sealed override string A { get; } } \ 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..0f87673 100644 --- a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs @@ -16,7 +16,7 @@ private static Response.BadRequest400 HandleValidationErrors(ImmutableList HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - var response = new Response.OK200(Components.Schemas.FooProperties.Create( + var response = new Response.OK200.AnyApplication(Components.Schemas.FooProperties.Create( name: request.Body.ApplicationJson?.Name), "application/json") { Headers = new Response.OK200.ResponseHeaders diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs index 9888b62..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,17 +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) - .ToArray(); - constructors.Should().HaveCount(5); + var contentClasses = responseType.GetTypeMembers() + .Where(t => t.BaseType?.Name == "OK200") + .ToArray(); - var constructorSignatures = constructors - .Select(methodSymbol => string.Join(", ", methodSymbol.Parameters.Select(parameterSymbol => - parameterSymbol.Type.ToDisplayString()))); - constructorSignatures.Should().OnlyHaveUniqueItems("constructors must have distinct signatures"); + contentClasses.Should().HaveCount(5); + contentClasses.Select(symbol => symbol.Name) + .Should() + .OnlyHaveUniqueItems("content classes must have distinct names"); var sourceCode = responseType.DeclaringSyntaxReferences.First() .SyntaxTree.ToString(); From 0b7d81a12eed9ec5537c6686e9bb0f7375579ce9 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 26 Feb 2026 17:46:36 +0100 Subject: [PATCH 06/14] feat(response): add accept header content negotiation --- .../CodeGeneration/RequestGenerator.cs | 44 +++++++++++++++++++ .../ResponseBodyContentGenerator.cs | 13 +++--- .../ResponseContentGenerator.cs | 14 +++++- .../Paths/FooFooId/Put/Operation.Handler.cs | 18 ++++---- 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs index 8322fe4..cdfe6fa 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs @@ -31,6 +31,8 @@ internal SourceCode GenerateRequestClass(string @namespace, string path) $$""" #nullable enable using Corvus.Json; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Net.Http.Headers; namespace {{@namespace}}; @@ -71,6 +73,48 @@ internal partial class Request return {{(isAsync ? "request" : "Task.FromResult(request)")}}; } + /// + /// Returns the best match if an acceptable media type is found. + /// + /// Media types to match against + /// Matched media type if method returns true + /// True if a matched media type was found + internal bool TryMatchAcceptMediaType( + MediaTypeHeaderValue[] mediaTypes, + [NotNullWhen(true)] out MediaTypeHeaderValue? matchedMediaType) + { + var acceptHeaders = HttpContext.Request.GetTypedHeaders().Accept; + if (acceptHeaders is not { Count: > 0 }) + { + matchedMediaType = mediaTypes.Length > 0 ? mediaTypes[0] : null; + return matchedMediaType != 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.IsSubsetOf(acceptMediaType)) + { + continue; + } + matchedMediaType = mediaType; + return true; + } + } + + matchedMediaType = null; + return false; + } + /// /// Validate the request /// diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index 34b2c71..9531bc5 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,16 +38,16 @@ public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDecl break; } - ContentPropertyName = _contentVariableName.ToPascalCase(); + ClassName = _contentVariableName.ToPascalCase(); } private string SchemaLocation => _typeDeclaration.RelativeSchemaLocation; - public string GenerateResponseClass(string className, string contentTypeFieldName) => + public string GenerateResponseClass(string responseClassName, string contentTypeFieldName) => $$""" /// /// Response for content {{_contentType}} /// -internal sealed class {{ContentPropertyName}} : {{className}} +internal sealed class {{ClassName}} : {{responseClassName}} { /// /// Construct response for content {{_contentType}} @@ -56,7 +56,7 @@ internal sealed class {{ContentPropertyName}} : {{className}} /// Content type must match range {_contentType.MediaType} """ : "")}} - public {{ContentPropertyName}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}}) + public {{ClassName}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}}) {{{(_isContentTypeRange ? $$""" @@ -66,6 +66,7 @@ internal sealed class {{ContentPropertyName}} : {{className}} {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; } + internal static readonly MediaTypeHeaderValue ContentMediaType = MediaTypeHeaderValue.Parse("{{_contentType}}"); protected override IJsonValue Content { get; } protected override string ContentSchemaLocation { get; } = "{{SchemaLocation}}"; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index b6903a7..9419ac6 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -68,13 +68,23 @@ public string GenerateResponseContentClass() _contentGenerators.AggregateToString(generator => generator.GenerateResponseClass(_responseClassName, contentTypeFieldName)).Indent(4) }}}{{{(_contentGenerators.Any() ? -""" +$$""" protected abstract IJsonValue Content { get; } protected abstract string ContentSchemaLocation { get; } + + /// + /// Content media types + /// + internal static readonly MediaTypeHeaderValue[] ContentMediaTypes = + [{{_contentGenerators.AggregateToString(generator => +$$""" + {{generator.ClassName}}.ContentMediaType, +""").TrimEnd(',')}} + ]; """ : "")}}} - + private int _statusCode{{{(hasExplicitStatusCode ? $" = {_responseStatusCodePattern}" : string.Empty)}}}; /// /// Response status code diff --git a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs index 0f87673..fbc1a9a 100644 --- a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs @@ -24,16 +24,16 @@ internal partial Task HandleAsync(Request request, CancellationToken c { _ = request.Query.Fee; _ = request.Path.FooId; - _ = request.Header.Bar; - var response = new Response.OK200.AnyApplication(Components.Schemas.FooProperties.Create( - name: request.Body.ApplicationJson?.Name), "application/json") + switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var type)) { - Headers = new Response.OK200.ResponseHeaders - { - Status = 2 - } - }; - return Task.FromResult(response); + case false: + case true when ReferenceEquals(type, 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 {type} has not been implemented"); + } } } From aba398501b4408701072caa9755f68c972c579a7 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 26 Feb 2026 17:58:41 +0100 Subject: [PATCH 07/14] use existing content media type property --- .../CodeGeneration/ResponseBodyContentGenerator.cs | 4 ++-- .../CodeGeneration/ResponseGenerator.cs | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index 9531bc5..a007f04 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -58,9 +58,9 @@ internal sealed class {{ClassName}} : {{responseClassName}} """ : "")}} public {{ClassName}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}}) {{{(_isContentTypeRange ? -$$""" +""" - EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), MediaTypeHeaderValue.Parse("{{_contentType}}")); + EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), ContentMediaType); """ : "")}} Content = {{_contentVariableName}}; {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index 9934674..6e766b4 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -44,11 +44,10 @@ internal abstract partial class Response /// Expected content type protected void EnsureExpectedContentType(MediaTypeHeaderValue contentType, MediaTypeHeaderValue expectedContentType) { - var valid = contentType.IsSubsetOf(expectedContentType); - - if (valid) - return; - throw new ArgumentOutOfRangeException($"Expected content type {contentType.MediaType} to be a subset of {expectedContentType.MediaType}"); + if (!contentType.IsSubsetOf(expectedContentType)) + { + throw new ArgumentOutOfRangeException($"Expected content type {contentType.MediaType} to be a subset of {expectedContentType.MediaType}"); + } } /// From 380278788d7179154dd1b3629d39ce5d1efe5960 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 26 Feb 2026 19:27:44 +0100 Subject: [PATCH 08/14] refactor(response): include httpcontext in all handlers to be able to do content negotiation --- .../CodeGeneration/AuthGenerator.cs | 12 ++--- .../HttpRequestExtensionsGenerator.cs | 44 +++++++++++++++++++ .../CodeGeneration/OperationGenerator.cs | 8 ++-- .../CodeGeneration/RequestGenerator.cs | 35 +-------------- .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 19 +------- .../Paths/FooFooId/Put/Operation.Handler.cs | 31 ++++++++----- 8 files changed, 78 insertions(+), 75 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index e5ac4e1..575ac90 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,8 @@ 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); + protected override void HandleUnauthorized(HttpContext context) => operation.Validate(operation.HandleUnauthorized(context), Configuration).WriteTo(context.Response); + protected override void HandleForbidden(HttpContext context) => operation.Validate(operation.HandleForbidden(context), Configuration).WriteTo(context.Response); } """; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index 4feb448..ecb8489 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,6 +185,49 @@ private static T Parse(IParameterValueParser parser, string? value) } return instance == null ? T.Null : T.Parse(instance.ToJsonString()); + } + + /// + /// Returns the best match if an acceptable media type is found. + /// + /// Media types to match against + /// Matched media type if method returns true + /// True if a matched media type was found + internal static bool TryMatchAcceptMediaType( + this HttpRequest request, + MediaTypeHeaderValue[] mediaTypes, + [NotNullWhen(true)] out MediaTypeHeaderValue? matchedMediaType) + { + var acceptHeaders = request.GetTypedHeaders().Accept; + if (acceptHeaders is not { Count: > 0 }) + { + matchedMediaType = mediaTypes.Length > 0 ? mediaTypes[0] : null; + return matchedMediaType != 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.IsSubsetOf(acceptMediaType)) + { + continue; + } + matchedMediaType = mediaType; + return true; + } + } + + matchedMediaType = null; + return false; } } #nullable restore diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index b6ebc2d..0fa4f7c 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(context, validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri)) .WriteTo(context.Response); return; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs index cdfe6fa..e55ba9c 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs @@ -81,39 +81,8 @@ internal partial class Request /// True if a matched media type was found internal bool TryMatchAcceptMediaType( MediaTypeHeaderValue[] mediaTypes, - [NotNullWhen(true)] out MediaTypeHeaderValue? matchedMediaType) - { - var acceptHeaders = HttpContext.Request.GetTypedHeaders().Accept; - if (acceptHeaders is not { Count: > 0 }) - { - matchedMediaType = mediaTypes.Length > 0 ? mediaTypes[0] : null; - return matchedMediaType != 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.IsSubsetOf(acceptMediaType)) - { - continue; - } - matchedMediaType = mediaType; - return true; - } - } - - matchedMediaType = null; - return false; - } + [NotNullWhen(true)] out MediaTypeHeaderValue? matchedMediaType) => + HttpContext.Request.TryMatchAcceptMediaType(mediaTypes, out matchedMediaType); /// /// Validate the request diff --git a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs index ea82839..564f9db 100644 --- a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs @@ -12,7 +12,7 @@ public Operation() ValidationLevel = ValidationLevel.Detailed; } - private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(HttpContext _, ImmutableList validationResults) { var response = validationResults.Select(result => Responses.BadRequest.RequiredErrorAndName.Create( diff --git a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs index ee1eb23..1b16f4c 100644 --- a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs @@ -11,7 +11,7 @@ public Operation() ValidateResponse = true; } - private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(HttpContext _, ImmutableList validationResults) { var response = validationResults.Select(result => Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( diff --git a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs index 0361fc3..cbecce1 100644 --- a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs @@ -10,7 +10,7 @@ public Operation() HandleRequestValidationError = HandleValidationErrors; } - private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(HttpContext _, ImmutableList validationResults) { var response = validationResults.Select(result => Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( @@ -37,20 +37,3 @@ internal partial Task HandleAsync(Request request, CancellationToken c return Task.FromResult(response); } } - -internal abstract class Test -{ - protected abstract string A { get; } - - -} - -internal class Testar : Test -{ - public Testar(string a) - { - A = a; - } - - protected sealed override string A { get; } -} \ 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 fbc1a9a..6324ee2 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(HttpContext context, 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.ApplicationJson( - Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + switch (context.Request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var matchedMediaType)) + { + case false: + case true when ReferenceEquals(matchedMediaType, Response.OK200.AnyApplication.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; - - switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var type)) + + switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var matchedMediaType)) { case false: - case true when ReferenceEquals(type, Response.OK200.AnyApplication.ContentMediaType): + case true when ReferenceEquals(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 {type} has not been implemented"); + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); } } -} +} \ No newline at end of file From c6798400971270543596ecfe06edc30bb68de13f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 26 Feb 2026 21:53:58 +0100 Subject: [PATCH 09/14] test(response): test content negotiation for all openapi specs --- .../Paths/FooFooId/Put/Operation.Handler.cs | 51 ++++++++++++------- .../Paths/FooFooId/Put/Operation.Handler.cs | 40 +++++++++------ .../Paths/FooFooId/Put/Operation.Handler.cs | 40 +++++++++------ .../Paths/FooFooId/Put/Operation.Handler.cs | 4 +- 4 files changed, 84 insertions(+), 51 deletions(-) diff --git a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs index 564f9db..5d10033 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(HttpContext _, ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(HttpContext context, 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.ApplicationJson( - Responses.BadRequest.Create(response.ToArray())); + switch (context.Request.TryMatchAcceptMediaType(Response.BadRequest400.ContentMediaTypes, out var matchedMediaType)) + { + case false: + case true when ReferenceEquals(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.ApplicationJson(Definitions.FooProperties.Create( - name: request.Body.ApplicationJson?.Name)) + switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, 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 ReferenceEquals(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 1b16f4c..42efe06 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(HttpContext _, ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(HttpContext context, 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.ApplicationJson( - Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + switch (context.Request.TryMatchAcceptMediaType(Response.BadRequest400.ContentMediaTypes, out var matchedMediaType)) + { + case false: + case true when ReferenceEquals(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.ApplicationJson(Components.Schemas.FooProperties.Create( - name: request.Body.ApplicationJson?.Name)) + switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var matchedMediaType)) { - Headers = new Response.OK200.ResponseHeaders - { - Status = 2 - } - }; - return Task.FromResult(response); + case false: + case true when ReferenceEquals(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 cbecce1..e03f6a4 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(HttpContext _, ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(HttpContext context, 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.ApplicationJson( - Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + switch (context.Request.TryMatchAcceptMediaType(Response.BadRequest400.ContentMediaTypes, out var matchedMediaType)) + { + case false: + case true when ReferenceEquals(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.ApplicationJson(Components.Schemas.FooProperties.Create( - name: request.Body.ApplicationJson?.Name)) + switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var matchedMediaType)) { - Headers = new Response.OK200.ResponseHeaders - { - Status = 2 - } - }; - return Task.FromResult(response); + case false: + case true when ReferenceEquals(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.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs index 6324ee2..bee1e77 100644 --- a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs @@ -12,10 +12,10 @@ public Operation() private static Response.BadRequest400 HandleValidationErrors(HttpContext context, ImmutableList validationResults) { - switch (context.Request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var matchedMediaType)) + switch (context.Request.TryMatchAcceptMediaType(Response.BadRequest400.ContentMediaTypes, out var matchedMediaType)) { case false: - case true when ReferenceEquals(matchedMediaType, Response.OK200.AnyApplication.ContentMediaType): + case true when ReferenceEquals(matchedMediaType, Response.BadRequest400.ApplicationJson.ContentMediaType): var response = validationResults.Select(result => Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( name: result.Location?.SchemaLocation.ToString() ?? string.Empty, From b1c31264e43301b46bc32e2407997c18170836d7 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 26 Feb 2026 22:27:05 +0100 Subject: [PATCH 10/14] simplify content negotiation scaffolding with an interface --- .../CodeGeneration/RequestGenerator.cs | 7 +++---- .../CodeGeneration/ResponseContentGenerator.cs | 8 +++----- .../CodeGeneration/ResponseGenerator.cs | 8 ++++++++ .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- 7 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs index e55ba9c..e436b44 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs @@ -79,10 +79,9 @@ internal partial class Request /// Media types to match against /// Matched media type if method returns true /// True if a matched media type was found - internal bool TryMatchAcceptMediaType( - MediaTypeHeaderValue[] mediaTypes, - [NotNullWhen(true)] out MediaTypeHeaderValue? matchedMediaType) => - HttpContext.Request.TryMatchAcceptMediaType(mediaTypes, out matchedMediaType); + internal bool TryMatchAcceptMediaType( + [NotNullWhen(true)] out MediaTypeHeaderValue? matchedMediaType) where T : class, IResponse => + HttpContext.Request.TryMatchAcceptMediaType(T.ContentMediaTypes, out matchedMediaType); /// /// Validate the request diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index 9419ac6..73d3c69 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -62,7 +62,7 @@ public string GenerateResponseContentClass() return $$$""" {{{_response.Description.AsComment("summary", "para")}}} -internal {{{(_contentGenerators.Any() ? "abstract" : "sealed")}}} class {{{_responseClassName}}} : Response +internal {{{(_contentGenerators.Any() ? "abstract" : "sealed")}}} class {{{_responseClassName}}} : Response{{{(_contentGenerators.Any() ? ", IResponse" : "")}}} { private string? {{{contentTypeFieldName}}} = null;{{{ _contentGenerators.AggregateToString(generator => @@ -74,10 +74,8 @@ public string GenerateResponseContentClass() protected abstract IJsonValue Content { get; } protected abstract string ContentSchemaLocation { get; } - /// - /// Content media types - /// - internal static readonly MediaTypeHeaderValue[] ContentMediaTypes = + /// + public static MediaTypeHeaderValue[] ContentMediaTypes { get; } = [{{_contentGenerators.AggregateToString(generator => $$""" {{generator.ClassName}}.ContentMediaType, diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index 6e766b4..e6d73cd 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -67,6 +67,14 @@ protected void EnsureExpectedContentType(MediaTypeHeaderValue contentType, Media generator.GenerateResponseContentClass()).Indent(4) }} } + +internal interface IResponse +{ + /// + /// Content media types + /// + internal static abstract MediaTypeHeaderValue[] ContentMediaTypes { get; } +} #nullable restore """); } diff --git a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs index 5d10033..6eee785 100644 --- a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs @@ -35,7 +35,7 @@ internal partial Task HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var matchedMediaType)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: case true when ReferenceEquals(matchedMediaType, Response.OK200.ApplicationJson.ContentMediaType): diff --git a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs index 42efe06..f8dae50 100644 --- a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs @@ -34,7 +34,7 @@ internal partial Task HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var matchedMediaType)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: case true when ReferenceEquals(matchedMediaType, Response.OK200.ApplicationJson.ContentMediaType): diff --git a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs index e03f6a4..f795d18 100644 --- a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs @@ -33,7 +33,7 @@ internal partial Task HandleAsync(Request request, CancellationToken c _ = request.Path.FooId; _ = request.Header.Bar; - switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var matchedMediaType)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: case true when ReferenceEquals(matchedMediaType, Response.OK200.ApplicationJson.ContentMediaType): diff --git a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs index bee1e77..9d7c38f 100644 --- a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs @@ -32,7 +32,7 @@ internal partial Task HandleAsync(Request request, CancellationToken c _ = request.Query.Fee; _ = request.Path.FooId; - switch (request.TryMatchAcceptMediaType(Response.OK200.ContentMediaTypes, out var matchedMediaType)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: case true when ReferenceEquals(matchedMediaType, Response.OK200.AnyApplication.ContentMediaType): From ec1a83cf54db83884e6504e91d04b05b01b11f56 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 26 Feb 2026 22:40:24 +0100 Subject: [PATCH 11/14] send request to auth and validation error handlers instead of httpcontext --- .../CodeGeneration/AuthGenerator.cs | 5 +++-- .../CodeGeneration/OperationGenerator.cs | 8 ++++---- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 ++-- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 ++-- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 ++-- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 ++-- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 575ac90..8fb519a 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -396,8 +396,9 @@ internal sealed class {{securityRequirementsFilterClassName}}(Operation operatio """)))}} }; - protected override void HandleUnauthorized(HttpContext context) => operation.Validate(operation.HandleUnauthorized(context), Configuration).WriteTo(context.Response); - protected override void HandleForbidden(HttpContext context) => operation.Validate(operation.HandleForbidden(context), Configuration).WriteTo(context.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/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 0fa4f7c..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(context, validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri)) + operation.HandleRequestValidationError(request, validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri)) .WriteTo(context.Response); return; } diff --git a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs index 6eee785..0b3f72d 100644 --- a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs @@ -12,9 +12,9 @@ public Operation() ValidationLevel = ValidationLevel.Detailed; } - private static Response.BadRequest400 HandleValidationErrors(HttpContext context, ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(Request request, ImmutableList validationResults) { - switch (context.Request.TryMatchAcceptMediaType(Response.BadRequest400.ContentMediaTypes, out var matchedMediaType)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: case true when ReferenceEquals(matchedMediaType, Response.BadRequest400.ApplicationJson.ContentMediaType): diff --git a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs index f8dae50..1e12e78 100644 --- a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs @@ -11,9 +11,9 @@ public Operation() ValidateResponse = true; } - private static Response.BadRequest400 HandleValidationErrors(HttpContext context, ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(Request request, ImmutableList validationResults) { - switch (context.Request.TryMatchAcceptMediaType(Response.BadRequest400.ContentMediaTypes, out var matchedMediaType)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: case true when ReferenceEquals(matchedMediaType, Response.BadRequest400.ApplicationJson.ContentMediaType): diff --git a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs index f795d18..42f9ea1 100644 --- a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs @@ -10,9 +10,9 @@ public Operation() HandleRequestValidationError = HandleValidationErrors; } - private static Response.BadRequest400 HandleValidationErrors(HttpContext context, ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(Request request, ImmutableList validationResults) { - switch (context.Request.TryMatchAcceptMediaType(Response.BadRequest400.ContentMediaTypes, out var matchedMediaType)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: case true when ReferenceEquals(matchedMediaType, Response.BadRequest400.ApplicationJson.ContentMediaType): diff --git a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs index 9d7c38f..7bcfa76 100644 --- a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs @@ -10,9 +10,9 @@ public Operation() HandleRequestValidationError = HandleValidationErrors; } - private static Response.BadRequest400 HandleValidationErrors(HttpContext context, ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(Request request, ImmutableList validationResults) { - switch (context.Request.TryMatchAcceptMediaType(Response.BadRequest400.ContentMediaTypes, out var matchedMediaType)) + switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: case true when ReferenceEquals(matchedMediaType, Response.BadRequest400.ApplicationJson.ContentMediaType): From a7ddff62f7b988826f50461e1fecc3e08665cb81 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 27 Feb 2026 19:42:51 +0100 Subject: [PATCH 12/14] refactor(response): make response content comparison type safe --- .../HttpRequestExtensionsGenerator.cs | 43 ------------- .../CodeGeneration/RequestGenerator.cs | 12 ---- .../ResponseBodyContentGenerator.cs | 3 +- .../ResponseContentGenerator.cs | 4 +- .../CodeGeneration/ResponseGenerator.cs | 63 ++++++++++++++++++- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 +- 9 files changed, 72 insertions(+), 69 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index ecb8489..9bd62de 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs @@ -186,49 +186,6 @@ private static T Parse(IParameterValueParser parser, string? value) return instance == null ? T.Null : T.Parse(instance.ToJsonString()); } - - /// - /// Returns the best match if an acceptable media type is found. - /// - /// Media types to match against - /// Matched media type if method returns true - /// True if a matched media type was found - internal static bool TryMatchAcceptMediaType( - this HttpRequest request, - MediaTypeHeaderValue[] mediaTypes, - [NotNullWhen(true)] out MediaTypeHeaderValue? matchedMediaType) - { - var acceptHeaders = request.GetTypedHeaders().Accept; - if (acceptHeaders is not { Count: > 0 }) - { - matchedMediaType = mediaTypes.Length > 0 ? mediaTypes[0] : null; - return matchedMediaType != 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.IsSubsetOf(acceptMediaType)) - { - continue; - } - matchedMediaType = mediaType; - return true; - } - } - - matchedMediaType = null; - return false; - } } #nullable restore """"); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs index e436b44..8322fe4 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs @@ -31,8 +31,6 @@ internal SourceCode GenerateRequestClass(string @namespace, string path) $$""" #nullable enable using Corvus.Json; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Net.Http.Headers; namespace {{@namespace}}; @@ -73,16 +71,6 @@ internal partial class Request return {{(isAsync ? "request" : "Task.FromResult(request)")}}; } - /// - /// Returns the best match if an acceptable media type is found. - /// - /// Media types to match against - /// Matched media type if method returns true - /// True if a matched media type was found - internal bool TryMatchAcceptMediaType( - [NotNullWhen(true)] out MediaTypeHeaderValue? matchedMediaType) where T : class, IResponse => - HttpContext.Request.TryMatchAcceptMediaType(T.ContentMediaTypes, out matchedMediaType); - /// /// Validate the request /// diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index a007f04..763cc25 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -66,7 +66,8 @@ internal sealed class {{ClassName}} : {{responseClassName}} {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; } - internal static readonly MediaTypeHeaderValue ContentMediaType = MediaTypeHeaderValue.Parse("{{_contentType}}"); + /// + public static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}")); protected override IJsonValue Content { get; } protected override string ContentSchemaLocation { get; } = "{{SchemaLocation}}"; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index 73d3c69..d966c91 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -62,7 +62,7 @@ public string GenerateResponseContentClass() return $$$""" {{{_response.Description.AsComment("summary", "para")}}} -internal {{{(_contentGenerators.Any() ? "abstract" : "sealed")}}} class {{{_responseClassName}}} : Response{{{(_contentGenerators.Any() ? ", IResponse" : "")}}} +internal {{{(_contentGenerators.Any() ? "abstract" : "sealed")}}} class {{{_responseClassName}}} : Response{{{(_contentGenerators.Any() ? $", IResponse<{_responseClassName}>" : "")}}} { private string? {{{contentTypeFieldName}}} = null;{{{ _contentGenerators.AggregateToString(generator => @@ -75,7 +75,7 @@ public string GenerateResponseContentClass() protected abstract string ContentSchemaLocation { get; } /// - public static MediaTypeHeaderValue[] ContentMediaTypes { get; } = + public static ContentMediaType<{{_responseClassName}}>[] ContentMediaTypes { get; } = [{{_contentGenerators.AggregateToString(generator => $$""" {{generator.ClassName}}.ContentMediaType, diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index e6d73cd..d6b0b9c 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -15,6 +15,7 @@ public SourceCode GenerateResponseClass(string @namespace, string path) #nullable enable using Corvus.Json; using Microsoft.Net.Http.Headers; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using {{httpResponseExtensionsGenerator.Namespace}}; @@ -68,12 +69,68 @@ protected void EnsureExpectedContentType(MediaTypeHeaderValue contentType, Media }} } -internal interface IResponse +internal interface IResponse where T : class { /// - /// Content media types + /// Contents for this response /// - internal static abstract MediaTypeHeaderValue[] ContentMediaTypes { get; } + internal static abstract ContentMediaType[] ContentMediaTypes { get; } +} + +internal interface IContent where T : class, IResponse +{ + internal static abstract ContentMediaType ContentMediaType { get; } +} + +internal readonly record struct ContentMediaType(MediaTypeHeaderValue Value) + where T : class +{ + 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 : class, IResponse + { + 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/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs index 0b3f72d..5dfaf71 100644 --- a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs @@ -17,7 +17,7 @@ private static Response.BadRequest400 HandleValidationErrors(Request request, Im switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: - case true when ReferenceEquals(matchedMediaType, Response.BadRequest400.ApplicationJson.ContentMediaType): + case true when matchedMediaType == Response.BadRequest400.ApplicationJson.ContentMediaType: var response = validationResults.Select(result => Responses.BadRequest.RequiredErrorAndName.Create( name: result.Location?.SchemaLocation.ToString() ?? string.Empty, @@ -38,7 +38,7 @@ internal partial Task HandleAsync(Request request, CancellationToken c switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: - case true when ReferenceEquals(matchedMediaType, Response.OK200.ApplicationJson.ContentMediaType): + case true when matchedMediaType == Response.OK200.ApplicationJson.ContentMediaType: var response = new Response.OK200.ApplicationJson(Definitions.FooProperties.Create( name: request.Body.ApplicationJson?.Name)) { diff --git a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs index 1e12e78..59c8e7f 100644 --- a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs @@ -16,7 +16,7 @@ private static Response.BadRequest400 HandleValidationErrors(Request request, Im switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: - case true when ReferenceEquals(matchedMediaType, Response.BadRequest400.ApplicationJson.ContentMediaType): + 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, @@ -37,7 +37,7 @@ internal partial Task HandleAsync(Request request, CancellationToken c switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: - case true when ReferenceEquals(matchedMediaType, Response.OK200.ApplicationJson.ContentMediaType): + case true when matchedMediaType == Response.OK200.ApplicationJson.ContentMediaType: return Task.FromResult(new Response.OK200.ApplicationJson( Components.Schemas.FooProperties.Create(name: request.Body.ApplicationJson?.Name)) { diff --git a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs index 42f9ea1..174ac20 100644 --- a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs @@ -15,7 +15,7 @@ private static Response.BadRequest400 HandleValidationErrors(Request request, Im switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: - case true when ReferenceEquals(matchedMediaType, Response.BadRequest400.ApplicationJson.ContentMediaType): + 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, @@ -36,7 +36,7 @@ internal partial Task HandleAsync(Request request, CancellationToken c switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: - case true when ReferenceEquals(matchedMediaType, Response.OK200.ApplicationJson.ContentMediaType): + case true when matchedMediaType == Response.OK200.ApplicationJson.ContentMediaType: return Task.FromResult(new Response.OK200.ApplicationJson( Components.Schemas.FooProperties.Create(name: request.Body.ApplicationJson?.Name)) { diff --git a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs index 7bcfa76..71f2e86 100644 --- a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs @@ -15,7 +15,7 @@ private static Response.BadRequest400 HandleValidationErrors(Request request, Im switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: - case true when ReferenceEquals(matchedMediaType, Response.BadRequest400.ApplicationJson.ContentMediaType): + 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, @@ -35,7 +35,7 @@ internal partial Task HandleAsync(Request request, CancellationToken c switch (request.TryMatchAcceptMediaType(out var matchedMediaType)) { case false: - case true when ReferenceEquals(matchedMediaType, Response.OK200.AnyApplication.ContentMediaType): + 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 } }); From fd64fd720fd56ae328e3093c13e3393ae787f98f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 28 Feb 2026 00:01:12 +0100 Subject: [PATCH 13/14] rename and document content abstractions --- .../ResponseBodyContentGenerator.cs | 3 +-- .../ResponseContentGenerator.cs | 2 +- .../CodeGeneration/ResponseGenerator.cs | 24 ++++++++++++------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index 763cc25..b9e3507 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -66,8 +66,7 @@ internal sealed class {{ClassName}} : {{responseClassName}} {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; } - /// - public static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}")); + internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}")); protected override IJsonValue Content { get; } protected override string ContentSchemaLocation { get; } = "{{SchemaLocation}}"; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index d966c91..c46c00e 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -62,7 +62,7 @@ public string GenerateResponseContentClass() return $$$""" {{{_response.Description.AsComment("summary", "para")}}} -internal {{{(_contentGenerators.Any() ? "abstract" : "sealed")}}} class {{{_responseClassName}}} : Response{{{(_contentGenerators.Any() ? $", IResponse<{_responseClassName}>" : "")}}} +internal {{{(_contentGenerators.Any() ? "abstract" : "sealed")}}} class {{{_responseClassName}}} : Response{{{(_contentGenerators.Any() ? $", IContent<{_responseClassName}>" : "")}}} { private string? {{{contentTypeFieldName}}} = null;{{{ _contentGenerators.AggregateToString(generator => diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index d6b0b9c..088a9c3 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -69,22 +69,28 @@ protected void EnsureExpectedContentType(MediaTypeHeaderValue contentType, Media }} } -internal interface IResponse where T : class +/// +/// Represents a response with content +/// +/// Response +internal interface IContent where T : Response { /// - /// Contents for this response + /// Contents for the response /// internal static abstract ContentMediaType[] ContentMediaTypes { get; } } -internal interface IContent where T : class, IResponse -{ - internal static abstract ContentMediaType ContentMediaType { get; } -} - +/// +/// Typed content media type +/// +/// Response internal readonly record struct ContentMediaType(MediaTypeHeaderValue Value) - where T : class + where T : Response { + /// + /// Implicitly convert back to MediaTypeHeaderValue + /// public static implicit operator MediaTypeHeaderValue(ContentMediaType mediaType) => mediaType.Value; } @@ -97,7 +103,7 @@ internal partial class Request /// The response to match against /// True if a matched media type was found internal bool TryMatchAcceptMediaType( - [NotNullWhen(true)] out ContentMediaType? matchedContentMediaType) where T : class, IResponse + [NotNullWhen(true)] out ContentMediaType? matchedContentMediaType) where T : Response, IContent { var mediaTypes = T.ContentMediaTypes; var acceptHeaders = HttpContext.Request.GetTypedHeaders().Accept; From 661dc3e1f404f9bb6d70786f5b2d38eb1218ccb1 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 28 Feb 2026 14:11:13 +0100 Subject: [PATCH 14/14] doc(response): add content negotiation to README --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) 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.