From 498481c90d11cf226ff59ccd16c2018476b26565 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 12 Feb 2026 22:40:18 +0100 Subject: [PATCH 1/6] fix(request): handle media type range for request content-type negotiation --- .../RequestBodyContentGenerator.cs | 5 ++- .../CodeGeneration/RequestBodyGenerator.cs | 12 ++++-- .../Extensions/MediaTypeExtensions.cs | 42 +++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index 2c1e830..1360467 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -1,4 +1,5 @@ -using Corvus.Json.CodeGeneration; +using System.Net.Http.Headers; +using Corvus.Json.CodeGeneration; using Corvus.Json.CodeGeneration.CSharp; using OpenAPI.WebApiGenerator.Extensions; @@ -16,7 +17,7 @@ internal sealed class RequestBodyContentGenerator( internal string PropertyName { get; } = contentType.ToPascalCase(); - internal string ContentType => contentType; + internal MediaTypeHeaderValue ContentType { get; } = MediaTypeHeaderValue.Parse(contentType); internal string SchemaLocation => typeDeclaration.RelativeSchemaLocation; internal string GenerateRequestBindingDirective() => diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs index d5c2a2c..a8d4735 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; @@ -23,7 +24,10 @@ public RequestBodyGenerator( List contentGenerators) { _body = body; - _contentGenerators = contentGenerators; + _contentGenerators = contentGenerators + .OrderByDescending(generator => + generator.ContentType.GetPrecedence()) + .ToList(); } internal static readonly RequestBodyGenerator Empty = new(); @@ -88,17 +92,17 @@ internal sealed class RequestContent var requestContentType = request.ContentType; var requestContentMediaType = requestContentType == null ? null : System.Net.Http.Headers.MediaTypeHeaderValue.Parse(requestContentType); - switch (requestContentMediaType?.MediaType?.ToLower()) + switch (requestContentMediaType?.MediaType) {{{_contentGenerators.AggregateToString(content => $$""" - case "{{content.ContentType.ToLower()}}": + case not null when {{content.ContentType.GetMatchConditionExpression("requestContentMediaType")}}: return new RequestContent { {{content.GenerateRequestBindingDirective().Indent(20)}} }; """)}}{{(_body.Required ? "" : """ - case "": + case null: return null; """)}} default: diff --git a/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs new file mode 100644 index 0000000..d8d25e9 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http.Headers; + +namespace OpenAPI.WebApiGenerator.Extensions; + +internal static class MediaTypeExtensions +{ + internal static string GetMatchConditionExpression(this MediaTypeHeaderValue value, string mediaTypeVariableName) + { + var expressions = new List(); + if (value.MediaType is not null) + { + if (value.MediaType == "*/*") + { + expressions.Add("true"); + } + else + { + expressions.Add(value.MediaType.EndsWith("*") + ? $"""{mediaTypeVariableName}.{nameof(value.MediaType)}.{nameof(value.MediaType.StartsWith)}("{value.MediaType.TrimEnd('*')}", StringComparison.OrdinalIgnoreCase)""" + : $"""{mediaTypeVariableName}.{nameof(value.MediaType)}.{nameof(value.MediaType.Equals)}("{value.MediaType}", StringComparison.OrdinalIgnoreCase)"""); + } + } + + expressions.AddRange(value.Parameters.Select(parameter => + $"{mediaTypeVariableName}.{nameof(value.Parameters)}.Contains({(parameter.Value is null ? + $"""new NameValueHeaderValue("{parameter.Name}")""" : + $"""new NameValueHeaderValue("{parameter.Name}", "{parameter.Value}")""")})")); + + return string.Join(" && ", expressions); + } + + internal static int GetPrecedence(this MediaTypeHeaderValue value) => + value.MediaType switch + { + null => value.Parameters.Count, + "*/*" => 0, + not null when value.MediaType.EndsWith("*") => 1 + value.Parameters.Count, + _ => 2 + value.Parameters.Count + }; +} \ No newline at end of file From 671b678fd9ed40bfd1b4aba1fcb3dec4f08d1762 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 13 Feb 2026 18:58:09 +0100 Subject: [PATCH 2/6] test: assert match expressions and precedence of media types and ranges --- .../Extensions/MediaTypeExtensions.cs | 9 ++-- .../OpenAPI.WebApiGenerator.csproj | 3 ++ .../Extensions/MediaTypeExtensionsTests.cs | 43 +++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs diff --git a/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs index d8d25e9..cdfd535 100644 --- a/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs +++ b/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs @@ -24,7 +24,7 @@ internal static string GetMatchConditionExpression(this MediaTypeHeaderValue val } expressions.AddRange(value.Parameters.Select(parameter => - $"{mediaTypeVariableName}.{nameof(value.Parameters)}.Contains({(parameter.Value is null ? + $"{mediaTypeVariableName}.{nameof(value.Parameters)}.{nameof(value.Parameters.Contains)}({(parameter.Value is null ? $"""new NameValueHeaderValue("{parameter.Name}")""" : $"""new NameValueHeaderValue("{parameter.Name}", "{parameter.Value}")""")})")); @@ -32,11 +32,10 @@ internal static string GetMatchConditionExpression(this MediaTypeHeaderValue val } internal static int GetPrecedence(this MediaTypeHeaderValue value) => - value.MediaType switch + value.Parameters.Count + value.MediaType switch { - null => value.Parameters.Count, "*/*" => 0, - not null when value.MediaType.EndsWith("*") => 1 + value.Parameters.Count, - _ => 2 + value.Parameters.Count + not null when value.MediaType.EndsWith("*") => 100, + _ => 1000 }; } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj index e558c25..9f8f1af 100644 --- a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj +++ b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj @@ -82,4 +82,7 @@ + + + diff --git a/tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs new file mode 100644 index 0000000..2a1a976 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs @@ -0,0 +1,43 @@ +using System.Net.Http.Headers; +using AwesomeAssertions; +using OpenAPI.WebApiGenerator.Extensions; +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests.Extensions; + +public class MediaTypeExtensionsTests +{ + [Theory] + [InlineData("*/*", "true")] + [InlineData("*/*; charset=utf-8", """true && test.Parameters.Contains(new NameValueHeaderValue("charset", "utf-8"))""")] + [InlineData("text/*", """test.MediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase)""")] + [InlineData("text/*; charset=utf-8", """test.MediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) && test.Parameters.Contains(new NameValueHeaderValue("charset", "utf-8"))""")] + [InlineData("application/json", """test.MediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase)""")] + [InlineData("application/json; charset=utf-8", """test.MediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) && test.Parameters.Contains(new NameValueHeaderValue("charset", "utf-8"))""")] + [InlineData("application/json; charset=utf-8; boundary=something", """test.MediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) && test.Parameters.Contains(new NameValueHeaderValue("charset", "utf-8")) && test.Parameters.Contains(new NameValueHeaderValue("boundary", "something"))""")] + [InlineData("multipart/form-data; boundary=something", """test.MediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase) && test.Parameters.Contains(new NameValueHeaderValue("boundary", "something"))""")] + [InlineData("multipart/form-data; boundary", """test.MediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase) && test.Parameters.Contains(new NameValueHeaderValue("boundary"))""")] + public void MediaTypeHeaderValue_MatchConditionExpressions(string mediaTypeValue, string expectedExpression) + { + var mediaType = MediaTypeHeaderValue.Parse(mediaTypeValue); + var expression = mediaType.GetMatchConditionExpression("test"); + expression.Should().Be(expectedExpression); + } + + [Theory] + [InlineData("*/*", 0)] + [InlineData("text/*", 100)] + [InlineData("application/*; charset=utf-8", 101)] + [InlineData("application/*; charset=utf-8; boundary=something", 102)] + [InlineData("application/*; charset=utf-8; boundary=something; foo=bar", 103)] + [InlineData("application/json", 1000)] + [InlineData("application/json; charset=utf-8", 1001)] + [InlineData("application/json; charset=utf-8; boundary=something", 1002)] + [InlineData("multipart/form-data; boundary", 1001)] + public void MediaTypeHeaderValue_Precedence(string mediaTypeValue, int expectedPrecedence) + { + var mediaType = MediaTypeHeaderValue.Parse(mediaTypeValue); + var precedence = mediaType.GetPrecedence(); + precedence.Should().Be(expectedPrecedence); + } +} \ No newline at end of file From 7c32d2c88aadd0912fb178de3972c995395c052b Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 13 Feb 2026 23:02:19 +0100 Subject: [PATCH 3/6] fix(request): validate content type --- .../RequestBodyContentGenerator.cs | 1 - .../CodeGeneration/RequestBodyGenerator.cs | 18 +++++++++--------- .../Extensions/MediaTypeExtensions.cs | 16 +++++++--------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index 1360467..10b91a6 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -27,7 +27,6 @@ internal string GenerateRequestBindingDirective() => "request", FullyQualifiedTypeDeclarationIdentifier) .Indent(8).Trim()}) - .AsOptional() """; public string GenerateRequestProperty() => diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs index a8d4735..0f842e2 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs @@ -75,7 +75,7 @@ public string GenerateRequestProperty(string propertyName) /// /// Request content /// -internal sealed class RequestContent +internal sealed class RequestContent(string? requestContentType, bool invalidContentType = false) {{{ _contentGenerators.AggregateToString(content => content.GenerateRequestProperty()).Indent(4)}} @@ -96,7 +96,7 @@ internal sealed class RequestContent {{{_contentGenerators.AggregateToString(content => $$""" case not null when {{content.ContentType.GetMatchConditionExpression("requestContentMediaType")}}: - return new RequestContent + return new RequestContent(requestContentType) { {{content.GenerateRequestBindingDirective().Indent(20)}} }; @@ -106,7 +106,7 @@ internal sealed class RequestContent return null; """)}} default: - throw new BadHttpRequestException($"Request body does not support content type {requestContentType}"); + return new RequestContent(requestContentType, true); } } @@ -123,13 +123,13 @@ internal ValidationContext Validate(ValidationContext validationContext, Validat $""" case true when {content.PropertyName} is not null: return {content.PropertyName}!.Value.Validate("{content.SchemaLocation}", true, validationContext, validationLevel); -""")}} +""")}} + case true when requestContentType == null: + return {{(_body.Required ? """validationContext.WithResult(false, "Request content is required")""" : "validationContext")}}; + case true when invalidContentType: + return validationContext.WithResult(false, $"Request content type {requestContentType} is not supported"); default: - {{(_body.Required ? - """ - throw new InvalidOperationException("Request body not set"); - """ : - "return validationContext;")}} + return validationContext; } } } diff --git a/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs index cdfd535..a4a301c 100644 --- a/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs +++ b/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs @@ -11,16 +11,14 @@ internal static string GetMatchConditionExpression(this MediaTypeHeaderValue val var expressions = new List(); if (value.MediaType is not null) { - if (value.MediaType == "*/*") + expressions.Add(value.MediaType switch { - expressions.Add("true"); - } - else - { - expressions.Add(value.MediaType.EndsWith("*") - ? $"""{mediaTypeVariableName}.{nameof(value.MediaType)}.{nameof(value.MediaType.StartsWith)}("{value.MediaType.TrimEnd('*')}", StringComparison.OrdinalIgnoreCase)""" - : $"""{mediaTypeVariableName}.{nameof(value.MediaType)}.{nameof(value.MediaType.Equals)}("{value.MediaType}", StringComparison.OrdinalIgnoreCase)"""); - } + "*/*" => "true", + not null when value.MediaType.EndsWith("*") => + $"""{mediaTypeVariableName}.{nameof(value.MediaType)}.{nameof(value.MediaType.StartsWith)}("{value.MediaType.TrimEnd('*')}", StringComparison.OrdinalIgnoreCase)""", + _ => + $"""{mediaTypeVariableName}.{nameof(value.MediaType)}.{nameof(value.MediaType.Equals)}("{value.MediaType}", StringComparison.OrdinalIgnoreCase)""" + }); } expressions.AddRange(value.Parameters.Select(parameter => From 21df6375d9e2a5b8d7c525b90365110e44c6a684 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 13 Feb 2026 23:07:18 +0100 Subject: [PATCH 4/6] simplify switch --- .../CodeGeneration/RequestBodyGenerator.cs | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs index 0f842e2..7eeb427 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs @@ -116,22 +116,19 @@ internal sealed class RequestContent(string? requestContentType, bool invalidCon /// Current validation context /// Validation level /// The validation result - internal ValidationContext Validate(ValidationContext validationContext, ValidationLevel validationLevel) - { - switch (true) + internal ValidationContext Validate(ValidationContext validationContext, ValidationLevel validationLevel) => + true switch {{{_contentGenerators.AggregateToString(content => $""" - case true when {content.PropertyName} is not null: - return {content.PropertyName}!.Value.Validate("{content.SchemaLocation}", true, validationContext, validationLevel); + true when {content.PropertyName} is not null => + {content.PropertyName}!.Value.Validate("{content.SchemaLocation}", true, validationContext, validationLevel), """)}} - case true when requestContentType == null: - return {{(_body.Required ? """validationContext.WithResult(false, "Request content is required")""" : "validationContext")}}; - case true when invalidContentType: - return validationContext.WithResult(false, $"Request content type {requestContentType} is not supported"); - default: - return validationContext; - } - } + true when requestContentType is null => + {{(_body.Required ? """validationContext.WithResult(false, "Request content is missing")""" : "validationContext")}}, + true when invalidContentType => + validationContext.WithResult(false, $"Request content type {requestContentType} is not supported"), + _ => validationContext + }; } """; } From 6ff59f03daf0904ea2304ca9b10638578ceabc31 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 17 Feb 2026 22:26:15 +0100 Subject: [PATCH 5/6] fix(response): support media range for content type --- .../ResponseBodyContentGenerator.cs | 66 ++++++++-- .../CodeGeneration/ResponseGenerator.cs | 21 +++ ...heoryData.ResponseContentMediaTypeSpecs.cs | 124 ++++++++++++++++++ .../ApiGeneratorTests.cs | 36 +++++ 4 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ResponseContentMediaTypeSpecs.cs diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index a38bd74..620b077 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -1,25 +1,65 @@ -using Corvus.Json.CodeGeneration; +using System; +using System.Net.Http.Headers; +using Corvus.Json.CodeGeneration; using Corvus.Json.CodeGeneration.CSharp; using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; -internal sealed class ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDeclaration) +internal sealed class ResponseBodyContentGenerator { - private readonly string _contentVariableName = contentType.ToCamelCase(); - public string ContentPropertyName { get; } = contentType.ToPascalCase(); - internal string SchemaLocation => typeDeclaration.RelativeSchemaLocation; + private readonly string _contentVariableName; + public string ContentPropertyName { get; } + private readonly MediaTypeHeaderValue _contentType; + private readonly TypeDeclaration _typeDeclaration; + private readonly bool _isContentTypeRange; + + public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDeclaration) + { + _contentType = MediaTypeHeaderValue.Parse(contentType); + _typeDeclaration = typeDeclaration; + ContentPropertyName = contentType.ToPascalCase(); + + _isContentTypeRange = false; + switch (_contentType.MediaType) + { + case "*/*": + _contentVariableName = "any"; + _isContentTypeRange = true; + break; + case not null when _contentType.MediaType.EndsWith("*"): + _contentVariableName = $"any{_contentType.MediaType.TrimEnd('*').TrimEnd('/').ToPascalCase()}"; + _isContentTypeRange = true; + break; + case null: + throw new InvalidOperationException("Content type is null"); + default: + _contentVariableName = _contentType.MediaType.ToCamelCase(); + break; + } + + ContentPropertyName = _contentVariableName.ToPascalCase(); + } + + internal string SchemaLocation => _typeDeclaration.RelativeSchemaLocation; public string GenerateConstructor(string className, string contentTypeFieldName) => $$""" /// -/// Construct content for {{contentType}} +/// Construct content for {{_contentType}} /// -/// Content -public {{className}}({{typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}) -{ +/// Content{{(_isContentTypeRange ? $""" + +/// Content type must match range {_contentType.MediaType} +""" : "")}} +public {{className}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}}) +{{{(_isContentTypeRange ? +$$""" + + EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), MediaTypeHeaderValue.Parse("{{_contentType}}")); +""" : "")}} {{ContentPropertyName}} = {{_contentVariableName}}; - {{contentTypeFieldName}} = "{{contentType}}"; -} + {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; +} """; public string GenerateContentProperty() @@ -27,9 +67,9 @@ public string GenerateContentProperty() return $$""" /// -/// Content for {{contentType}} +/// Content for {{_contentType}} /// -internal {{typeDeclaration.FullyQualifiedDotnetTypeName()}}? {{ContentPropertyName}} { get; } +internal {{_typeDeclaration.FullyQualifiedDotnetTypeName()}}? {{ContentPropertyName}} { get; } """; } } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index 2919b11..3e9b0e1 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -35,6 +35,27 @@ internal abstract partial class Response => (code >= {{i}}00 && code <= {{i}}99) ? code : throw new InvalidOperationException($"Expected {{i}}xx status code, got {code}"); """)}} + /// + /// Ensures that the specified content type matches the specification + /// Thrown when the specified content type does not match the specification + /// + /// Content type + /// 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), + _ => contentType.MediaType.Equals(expectedContentType.MediaType, StringComparison.OrdinalIgnoreCase) + }; + + if (valid) + return; + throw new ArgumentOutOfRangeException($"Expected content type {contentType.MediaType} to match range {expectedContentType.MediaType}"); + } + /// /// Write the response to a http response object /// diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ResponseContentMediaTypeSpecs.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ResponseContentMediaTypeSpecs.cs new file mode 100644 index 0000000..059d14f --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.ResponseContentMediaTypeSpecs.cs @@ -0,0 +1,124 @@ +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests; + +public partial class ApiGeneratorTests +{ + public static TheoryData ResponseContentMediaTypeSpecs => new() + { + { + "OpenAPI 3.0", + """ + { + "openapi": "3.0.3", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { "type": "object", "properties": { "name": { "type": "string" } } } + }, + "application/xml": { + "schema": { "type": "object", "properties": { "name": { "type": "string" } } } + }, + "text/*": { + "schema": { "type": "string" } + }, + "text/plain; charset=utf-8": { + "schema": { "type": "string" } + }, + "*/*": { + "schema": { "type": "string" } + } + } + } + } + } + } + } + } + """ + }, + { + "OpenAPI 3.1", + """ + { + "openapi": "3.1.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { "type": "object", "properties": { "name": { "type": "string" } } } + }, + "application/xml": { + "schema": { "type": "object", "properties": { "name": { "type": "string" } } } + }, + "text/*": { + "schema": { "type": "string" } + }, + "text/plain; charset=utf-8": { + "schema": { "type": "string" } + }, + "*/*": { + "schema": { "type": "string" } + } + } + } + } + } + } + } + } + """ + }, + { + "OpenAPI 3.2", + """ + { + "openapi": "3.2.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "get": { + "operationId": "GetFoo", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { "type": "object", "properties": { "name": { "type": "string" } } } + }, + "application/xml": { + "schema": { "type": "object", "properties": { "name": { "type": "string" } } } + }, + "text/*": { + "schema": { "type": "string" } + }, + "text/plain; charset=utf-8": { + "schema": { "type": "string" } + }, + "*/*": { + "schema": { "type": "string" } + } + } + } + } + } + } + } + } + """ + } + }; +} \ No newline at end of file diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs index a431507..8d99546 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs @@ -1,6 +1,11 @@ +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; @@ -128,6 +133,29 @@ internal partial Task HandleAsync(Request request, CancellationToken c generatedFiles.Should().HaveCountGreaterThan(0); } + [Theory] + [MemberData(nameof(ResponseContentMediaTypeSpecs))] + public void ResponseContentMediaTypes_Generating_ConstructorPerMediaType(string _, string openApiSpec) + { + var compilation = SetupGenerator(openApiSpec, out var diagnostics); + HasOnlyMissingHandler(diagnostics); + + var responseType = compilation.GetSymbolsWithName("OK200", cancellationToken: Cancellation) + .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 sourceCode = responseType.DeclaringSyntaxReferences.First() + .SyntaxTree.ToString(); + TestContext.Current.TestOutputHelper?.Write(sourceCode); + } + [Theory] [MemberData(nameof(NoResponseContentSpecs))] public void NoResponseContent_Generating_DefaultResponseConstructor(string _, string openApiSpec) @@ -173,6 +201,14 @@ private Compilation SetupGenerator(string openApiSpec, out ImmutableArray + diagnostic.Severity == DiagnosticSeverity.Error || + diagnostic.Severity == DiagnosticSeverity.Warning); + } + return newCompilation; } } From 368f79e8b6086f32021cca0c2e7465f3fe924cfd Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 17 Feb 2026 22:42:38 +0100 Subject: [PATCH 6/6] fix null dereferences in ensure expected content type method --- .../CodeGeneration/ResponseGenerator.cs | 6 ++++-- .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- tests/Example.OpenApi32/openapi.json | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index 3e9b0e1..200980a 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -14,6 +14,7 @@ public SourceCode GenerateResponseClass(string @namespace, string path) $$""" #nullable enable using Corvus.Json; +using System.Net.Http.Headers; using System.Text.Json; using {{httpResponseExtensionsGenerator.Namespace}}; @@ -47,8 +48,9 @@ protected void EnsureExpectedContentType(MediaTypeHeaderValue contentType, Media { "*/*" => true, not null when expectedContentType.MediaType.EndsWith("*") => - contentType.MediaType?.StartsWith(expectedContentType.MediaType.TrimEnd('*'), StringComparison.OrdinalIgnoreCase), - _ => contentType.MediaType.Equals(expectedContentType.MediaType, StringComparison.OrdinalIgnoreCase) + contentType.MediaType?.StartsWith(expectedContentType.MediaType.TrimEnd('*'), StringComparison.OrdinalIgnoreCase) ?? false, + not null => contentType.MediaType?.Equals(expectedContentType.MediaType, StringComparison.OrdinalIgnoreCase) ?? false, + _ => false }; if (valid) diff --git a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs index 6333da1..42a0587 100644 --- a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs @@ -27,7 +27,7 @@ internal partial Task HandleAsync(Request request, CancellationToken c _ = request.Header.Bar; var response = new Response.OK200(Components.Schemas.FooProperties.Create( - name: request.Body.ApplicationJson?.Name)) + name: request.Body.ApplicationJson?.Name), "application/json") { Headers = new Response.OK200.ResponseHeaders { diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json index fbfccf5..bce1f03 100644 --- a/tests/Example.OpenApi32/openapi.json +++ b/tests/Example.OpenApi32/openapi.json @@ -48,7 +48,7 @@ } }, "content": { - "application/json": { + "application/*": { "schema": { "$ref": "#/components/schemas/FooProperties" }