diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index 2c1e830..10b91a6 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() => @@ -26,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 d5c2a2c..7eeb427 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(); @@ -71,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)}} @@ -88,21 +92,21 @@ 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()}}": - return new RequestContent + case not null when {{content.ContentType.GetMatchConditionExpression("requestContentMediaType")}}: + return new RequestContent(requestContentType) { {{content.GenerateRequestBindingDirective().Indent(20)}} }; """)}}{{(_body.Required ? "" : """ - case "": + case null: return null; """)}} default: - throw new BadHttpRequestException($"Request body does not support content type {requestContentType}"); + return new RequestContent(requestContentType, true); } } @@ -112,22 +116,19 @@ internal sealed class RequestContent /// 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); -""")}} - default: - {{(_body.Required ? - """ - throw new InvalidOperationException("Request body not set"); - """ : - "return validationContext;")}} - } - } + true when {content.PropertyName} is not null => + {content.PropertyName}!.Value.Validate("{content.SchemaLocation}", true, validationContext, validationLevel), +""")}} + 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 + }; } """; } 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..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}}; @@ -35,6 +36,28 @@ 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) ?? false, + not null => contentType.MediaType?.Equals(expectedContentType.MediaType, StringComparison.OrdinalIgnoreCase) ?? false, + _ => false + }; + + if (valid) + return; + throw new ArgumentOutOfRangeException($"Expected content type {contentType.MediaType} to match range {expectedContentType.MediaType}"); + } + /// /// Write the response to a http response object /// diff --git a/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs new file mode 100644 index 0000000..a4a301c --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs @@ -0,0 +1,39 @@ +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) + { + expressions.Add(value.MediaType switch + { + "*/*" => "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 => + $"{mediaTypeVariableName}.{nameof(value.Parameters)}.{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.Parameters.Count + value.MediaType switch + { + "*/*" => 0, + 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/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" } 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; } } 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