From 13a76f01d16ca4a453408178ea796e5513805aaa Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 9 Jan 2026 19:21:56 +0100 Subject: [PATCH 01/29] throw openapi specification parsing errors --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 14 ++++++++++++-- .../Extensions/EnumerableExtensions.cs | 6 +++++- .../ApiGeneratorTests.cs | 8 ++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index b5ea589..40b1ca5 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -68,10 +68,20 @@ private static void GenerateCode(SourceProductionContext context, ( jsonValidationExceptionGenerator.GenerateJsonValidationExceptionClass().AddTo(context); var endpointGenerator = new OperationGenerator(compilation, jsonValidationExceptionGenerator); - var openApi = OpenApiDocument.Load(openApiDocumentFile.AsStream(), "json").Document ?? + var openApiResult = OpenApiDocument.Load(openApiDocumentFile.AsStream(), "json"); + var openApiVersion = openApiResult.Diagnostic?.SpecificationVersion ?? + throw new InvalidOperationException("Unknown openapi version"); + if (openApiResult.Diagnostic.Errors.Any()) + { + throw new InvalidOperationException( + openApiResult.Diagnostic.Errors.AggregateToString( + "Errors while parsing OpenAPI specification: ", + error => $"{(error.Pointer == null ? "" : $"{error.Pointer}: ")}{error.Message}")); + } + var openApi = openApiResult.Document ?? throw new InvalidOperationException( $"Could not load OpenAPI document {openApiDocumentFile.Path}"); - + var openApiUri = new JsonReference(openApi.BaseUri.ToString()); var documentResolver = new PrepopulatedDocumentResolver(); var openApiDocument = JsonDocument.Parse(generatorContext.OpenApiDocument.AsStream()); diff --git a/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs index 403c536..eb0a13d 100644 --- a/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs +++ b/src/OpenAPI.WebApiGenerator/Extensions/EnumerableExtensions.cs @@ -8,8 +8,12 @@ namespace OpenAPI.WebApiGenerator.Extensions; internal static class EnumerableExtensions { internal static string AggregateToString(this IEnumerable items, Func convert) => + items.AggregateToString(new StringBuilder().AppendLine(), convert); + internal static string AggregateToString(this IEnumerable items, string firstLine, Func convert) => + items.AggregateToString(new StringBuilder(firstLine), convert); + private static string AggregateToString(this IEnumerable items, StringBuilder stringBuilder, Func convert) => items - .Aggregate(new StringBuilder().AppendLine(), (builder, item) => + .Aggregate(stringBuilder, (builder, item) => builder.AppendLine(convert(item))) .ToString() .TrimEnd(); diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs index b1010ff..d920ca1 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs @@ -62,6 +62,10 @@ public void GivenAImplementedOperation_WhenGeneratingAPI_NoOperationHandlerStubs """ { "swagger": "2.0", + "info": { + "title": "foo", + "version": "1.0" + }, "paths": { "/foo": { "put": { @@ -156,6 +160,10 @@ public void NoResponseContent_Generating_DefaultResponseConstructor() """ { "swagger": "2.0", + "info": { + "title": "foo", + "version": "1.0" + }, "paths": { "/foo": { "delete": { From 788cb0e61c37c25c1dd9cff6104677bf76a5160e Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 9 Jan 2026 22:54:21 +0100 Subject: [PATCH 02/29] feat(openapi): add v3 visitors --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 2 +- .../Json/JsonPointer.cs | 4 +- .../Visitor/IOpenApiPathItemVisitor.cs | 3 +- .../OpenApi/Visitor/OpenApiVisitor.cs | 12 ++- .../V2/OpenApiV2Visitor.OperationVisitor.cs | 68 ++++++++++++++ .../V2/OpenApiV2Visitor.PathItemVisitor.cs | 58 +----------- .../V3/OpenApiV3Visitor.OperationVisitor.cs | 88 +++++++++++++++++++ .../V3/OpenApiV3Visitor.ParametersVisitor.cs | 60 +++++++++++++ .../V3/OpenApiV3Visitor.PathItemVisitor.cs | 62 +++++++++++++ .../V3/OpenApiV3Visitor.ResponseVisitor.cs | 76 ++++++++++++++++ .../OpenApi/Visitor/V3/OpenApiV3Visitor.cs | 67 +------------- 11 files changed, 369 insertions(+), 131 deletions(-) create mode 100644 src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.OperationVisitor.cs create mode 100644 src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs create mode 100644 src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ParametersVisitor.cs create mode 100644 src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.PathItemVisitor.cs create mode 100644 src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 40b1ca5..cd6d617 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -96,7 +96,7 @@ private static void GenerateCode(SourceProductionContext context, ( generationContext); var openApiReference = new OpenApiReference(openApi, openApiDocument, openApiUri); - var openApiVisitor = OpenApiVisitor.V2(openApiReference); + var openApiVisitor = OpenApiVisitor.V(openApiVersion, openApiReference); var httpRequestExtensionsGenerator = new HttpRequestExtensionsGenerator(rootNamespace); httpRequestExtensionsGenerator.GenerateHttpRequestExtensionsClass().AddTo(context); diff --git a/src/OpenAPI.WebApiGenerator/Json/JsonPointer.cs b/src/OpenAPI.WebApiGenerator/Json/JsonPointer.cs index e45459d..5720f46 100644 --- a/src/OpenAPI.WebApiGenerator/Json/JsonPointer.cs +++ b/src/OpenAPI.WebApiGenerator/Json/JsonPointer.cs @@ -20,9 +20,9 @@ internal static JsonPointer ParseFrom(string pointer) internal string[] Segments => segments ?? []; - internal JsonPointer Append(string segment) + internal JsonPointer Append(params string[] segmentList) { - return new JsonPointer(Segments.Append(segment).ToArray()); + return new JsonPointer(Segments.Concat(segmentList).ToArray()); } public override string ToString() => diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiPathItemVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiPathItemVisitor.cs index 4c3bbcb..081450b 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiPathItemVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiPathItemVisitor.cs @@ -6,6 +6,7 @@ namespace OpenAPI.WebApiGenerator.OpenApi.Visitor; internal interface IOpenApiPathItemVisitor : IVisitor { - public JsonReference GetSchemaReference(IOpenApiParameter parameter); + JsonReference GetSchemaReference(IOpenApiParameter parameter); IOpenApiOperationVisitor Visit(HttpMethod parameter); + } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs index 286d412..0e945c8 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs @@ -11,10 +11,14 @@ namespace OpenAPI.WebApiGenerator.OpenApi.Visitor; internal abstract class OpenApiVisitor { - public static IOpenApiVisitor V3(OpenApiReference openApiReference) => - OpenApiV3Visitor.Visit(openApiReference); - public static IOpenApiVisitor V2(OpenApiReference openApiReference) => - OpenApiV2Visitor.Visit(openApiReference); + public static IOpenApiVisitor V(OpenApiSpecVersion version, OpenApiReference openApiReference) => + version switch + { + OpenApiSpecVersion.OpenApi2_0 => OpenApiV2Visitor.Visit(openApiReference), + OpenApiSpecVersion.OpenApi3_0 or OpenApiSpecVersion.OpenApi3_1 => + OpenApiV3Visitor.Visit(openApiReference), + _ => throw new InvalidOperationException($"OpenAPI version {version} not supported") + }; } internal abstract class OpenApiVisitor( diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.OperationVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.OperationVisitor.cs new file mode 100644 index 0000000..8eb8a40 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.OperationVisitor.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using Corvus.Json; +using Microsoft.OpenApi; + +namespace OpenAPI.WebApiGenerator.OpenApi.Visitor.V2; + +internal sealed partial class OpenApiV2Visitor +{ + private sealed partial class PathItemVisitor + { + private sealed class OperationVisitor : + OpenApiVisitor, IOpenApiOperationVisitor + { + private Dictionary _parameterSchemaReferences = new(); + private JsonReference? _bodySchemaReference; + private readonly Dictionary _responseVisitors = new(); + + private OperationVisitor(OpenApiReference openApiReference) : base(openApiReference) + { + VisitParameters(); + VisitResponses(); + } + + private void VisitParameters() + { + if (OpenApiDocument.Parameters == null) + { + return; + } + var parametersPointer = Visit("parameters"); + var parametersVisitor = ParametersVisitor.Visit( + new OpenApiReference>( + OpenApiDocument.Parameters, + Document, + new JsonReference(Reference.Uri, parametersPointer.ToString().AsSpan()))); + _parameterSchemaReferences = parametersVisitor.Schemas; + _bodySchemaReference = parametersVisitor.BodySchema; + } + + private void VisitResponses() + { + foreach (var response in OpenApiDocument.Responses ?? []) + { + var responsePointer = Visit("responses", response.Key); + var responseReference = new JsonReference(Reference.Uri, responsePointer.ToString().AsSpan()); + var responseVisitor = + ResponseVisitor.Visit( + new OpenApiReference(response.Value, Document, responseReference)); + _responseVisitors.Add(response.Value, responseVisitor); + } + } + + internal static OperationVisitor Visit( + OpenApiReference openApiReference) => + new(openApiReference); + + public JsonReference GetSchemaReference(IOpenApiParameter parameter) => + _parameterSchemaReferences[parameter]; + + public JsonReference GetSchemaReference(OpenApiMediaType requestBodyContent) => + _bodySchemaReference ?? throw new InvalidOperationException("Operation doesn't define a body"); + + public IOpenApiResponseVisitor Visit(IOpenApiResponse response) => + _responseVisitors[response]; + } + } +} \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.PathItemVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.PathItemVisitor.cs index 2e59f0e..d809510 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.PathItemVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.PathItemVisitor.cs @@ -8,7 +8,7 @@ namespace OpenAPI.WebApiGenerator.OpenApi.Visitor.V2; internal sealed partial class OpenApiV2Visitor { - private sealed class PathItemVisitor : + private sealed partial class PathItemVisitor : OpenApiVisitor, IOpenApiPathItemVisitor { private Dictionary _parameterSchemaReferences = new(); @@ -58,61 +58,5 @@ public JsonReference GetSchemaReference(IOpenApiParameter parameter) => public IOpenApiOperationVisitor Visit(HttpMethod httpMethod) => _operations[httpMethod]; - - private sealed class OperationVisitor : - OpenApiVisitor, IOpenApiOperationVisitor - { - private Dictionary _parameterSchemaReferences = new(); - private JsonReference? _bodySchemaReference; - private readonly Dictionary _responseVisitors = new(); - - private OperationVisitor(OpenApiReference openApiReference) : base(openApiReference) - { - VisitParameters(); - VisitResponses(); - } - - private void VisitParameters() - { - if (OpenApiDocument.Parameters == null) - { - return; - } - var parametersPointer = Visit("parameters"); - var parametersVisitor = ParametersVisitor.Visit( - new OpenApiReference>( - OpenApiDocument.Parameters, - Document, - new JsonReference(Reference.Uri, parametersPointer.ToString().AsSpan()))); - _parameterSchemaReferences = parametersVisitor.Schemas; - _bodySchemaReference = parametersVisitor.BodySchema; - } - - private void VisitResponses() - { - foreach (var response in OpenApiDocument.Responses ?? []) - { - var responsePointer = Visit("responses", response.Key); - var responseReference = new JsonReference(Reference.Uri, responsePointer.ToString().AsSpan()); - var responseVisitor = - ResponseVisitor.Visit( - new OpenApiReference(response.Value, Document, responseReference)); - _responseVisitors.Add(response.Value, responseVisitor); - } - } - - internal static OperationVisitor Visit( - OpenApiReference openApiReference) => - new(openApiReference); - - public JsonReference GetSchemaReference(IOpenApiParameter parameter) => - _parameterSchemaReferences[parameter]; - - public JsonReference GetSchemaReference(OpenApiMediaType requestBodyContent) => - _bodySchemaReference ?? throw new InvalidOperationException("Operation doesn't define a body"); - - public IOpenApiResponseVisitor Visit(IOpenApiResponse response) => - _responseVisitors[response]; - } } } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs new file mode 100644 index 0000000..a186e11 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using Corvus.Json; +using Microsoft.OpenApi; + +namespace OpenAPI.WebApiGenerator.OpenApi.Visitor.V3; + +internal sealed partial class OpenApiV3Visitor +{ + private sealed partial class PathItemVisitor + { + private sealed class OperationVisitor : + OpenApiVisitor, IOpenApiOperationVisitor + { + private Dictionary _parameterSchemaReferences = new(); + private readonly Dictionary _responseVisitors = new(); + private readonly Dictionary _requestContentSchemaReferences = new(); + + private OperationVisitor(OpenApiReference openApiReference) : base(openApiReference) + { + VisitParameters(); + VisitResponses(); + VisitRequestBody(); + } + + private void VisitParameters() + { + if (OpenApiDocument.Parameters == null) + { + return; + } + var parametersPointer = Visit("parameters"); + var parametersVisitor = ParametersVisitor.Visit( + new OpenApiReference>( + OpenApiDocument.Parameters, + Document, + new JsonReference(Reference.Uri, parametersPointer.ToString().AsSpan()))); + _parameterSchemaReferences = parametersVisitor.Schemas; + } + + private void VisitResponses() + { + foreach (var response in OpenApiDocument.Responses ?? []) + { + var responsePointer = Visit("responses", response.Key); + var responseReference = new JsonReference(Reference.Uri, responsePointer.ToString().AsSpan()); + var responseVisitor = + ResponseVisitor.Visit( + new OpenApiReference(response.Value, Document, responseReference)); + _responseVisitors.Add(response.Value, responseVisitor); + } + } + + private void VisitRequestBody() + { + if (OpenApiDocument.RequestBody?.Content == null) + { + return; + } + + var requestContentPointer = Visit("requestBody", "content"); + foreach (var content in OpenApiDocument.RequestBody.Content) + { + _requestContentSchemaReferences.Add(content.Value, + new JsonReference(Reference.Uri, + requestContentPointer + .Append(content.Key) + .Append("schema") + .ToString() + .AsSpan())); + } + } + + internal static OperationVisitor Visit( + OpenApiReference openApiReference) => + new(openApiReference); + + public JsonReference GetSchemaReference(IOpenApiParameter parameter) => + _parameterSchemaReferences[parameter]; + + public JsonReference GetSchemaReference(OpenApiMediaType requestBodyContent) => + _requestContentSchemaReferences[requestBodyContent]; + + public IOpenApiResponseVisitor Visit(IOpenApiResponse response) => + _responseVisitors[response]; + } + } +} \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ParametersVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ParametersVisitor.cs new file mode 100644 index 0000000..06cbdc8 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ParametersVisitor.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Corvus.Json; +using Microsoft.OpenApi; + +namespace OpenAPI.WebApiGenerator.OpenApi.Visitor.V3; + +internal sealed partial class OpenApiV3Visitor +{ + private sealed class ParametersVisitor : + OpenApiVisitor> + { + private ParametersVisitor(OpenApiReference> openApiReference) : base(openApiReference) + { + VisitParameters(); + } + + internal Dictionary Schemas { get; } = new(); + + internal static ParametersVisitor Visit(OpenApiReference> openApiReference) => + new(openApiReference); + + private void VisitParameters() + { + var parameterIndex = 0; + while (TryVisit([parameterIndex.ToString()], out var parameterPointer)) + { + var parameterNameElement = JsonPointerUtilities.ResolvePointer( + Document, + parameterPointer.Append("name").ToString().AsSpan()); + var parameterName = parameterNameElement.GetString() ?? + throw new InvalidOperationException("parameter doesn't have a name"); + var parameterLocationElement = JsonPointerUtilities.ResolvePointer( + Document, + parameterPointer.Append("in").ToString().AsSpan()); + var parameterLocation = parameterLocationElement.GetString() ?? + throw new InvalidOperationException("parameter doesn't have a location"); + + var parameter = OpenApiDocument.Single(apiParameter => + apiParameter.GetName() == parameterName && + apiParameter.GetLocation() == parameterLocation); + + if (!TryVisit([parameterIndex.ToString(), "schema"], out var schemaPointer)) + { + schemaPointer = Visit( + "content", + parameter.Content?.Single().Key ?? + throw new InvalidOperationException("Parameter doesn't contain a schema"), + "schema"); + } + + Schemas.Add(parameter, + new JsonReference(Reference.Uri, schemaPointer.ToString().AsSpan())); + + parameterIndex++; + } + } + } +} \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.PathItemVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.PathItemVisitor.cs new file mode 100644 index 0000000..906fc4f --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.PathItemVisitor.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using Corvus.Json; +using Microsoft.OpenApi; + +namespace OpenAPI.WebApiGenerator.OpenApi.Visitor.V3; + +internal sealed partial class OpenApiV3Visitor +{ + private sealed partial class PathItemVisitor : + OpenApiVisitor, IOpenApiPathItemVisitor + { + private Dictionary _parameterSchemaReferences = new(); + private readonly Dictionary _operations = new(); + + private PathItemVisitor(OpenApiReference openApiReference) : base(openApiReference) + { + VisitParameters(); + VisitOperations(); + } + + private void VisitParameters() + { + if (OpenApiDocument.Parameters == null) + { + return; + } + + var parametersPointer = Visit("parameters"); + var parametersVisitor = ParametersVisitor.Visit( + new OpenApiReference>( + OpenApiDocument.Parameters, + Document, + new JsonReference(Reference.Uri, parametersPointer.ToString().AsSpan()))); + _parameterSchemaReferences = parametersVisitor.Schemas; + } + + private void VisitOperations() + { + foreach (var openApiOperation in OpenApiDocument.Operations ?? []) + { + var method = openApiOperation.Key; + var operation = openApiOperation.Value; + var operationPointer = Visit(method.Method.ToLowerInvariant()); + var operationReference = new JsonReference(Reference.Uri, operationPointer.ToString().AsSpan()); + _operations.Add(method, + OperationVisitor.Visit( + new OpenApiReference(operation, Document, operationReference))); + } + } + + public JsonReference GetSchemaReference(IOpenApiParameter parameter) => + _parameterSchemaReferences[parameter]; + + internal static PathItemVisitor Visit(OpenApiReference openApiReference) => + new(openApiReference); + + public IOpenApiOperationVisitor Visit(HttpMethod httpMethod) => + _operations[httpMethod]; + } +} \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs new file mode 100644 index 0000000..494ae24 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Corvus.Json; +using Microsoft.OpenApi; + +namespace OpenAPI.WebApiGenerator.OpenApi.Visitor.V3; + +internal sealed partial class OpenApiV3Visitor +{ + private sealed class ResponseVisitor : + OpenApiVisitor, IOpenApiResponseVisitor + { + private ResponseVisitor(OpenApiReference openApiReference) : base(openApiReference) + { + VisitContent(); + VisitHeaders(); + } + + private readonly Dictionary _headerReferences = new(); + private readonly Dictionary _contentReferences = new(); + + internal static ResponseVisitor Visit(OpenApiReference openApiReference) => + new(openApiReference); + + private void VisitContent() + { + if (OpenApiDocument.Content == null) + { + return; + } + + foreach (var content in OpenApiDocument.Content) + { + _contentReferences.Add(content.Value, new JsonReference(Reference.Uri, + Pointer + .Append( + "content", + content.Key, + "schema" + ) + .ToString() + .AsSpan())); + } + } + + private void VisitHeaders() + { + if (OpenApiDocument.Headers == null) + { + return; + } + + foreach (var openApiHeader in OpenApiDocument.Headers) + { + var reference = new JsonReference(Reference.Uri, + Pointer + .Append( + "headers", + openApiHeader.Key, + "schema") + .ToString() + .AsSpan()); + _headerReferences.Add(openApiHeader.Value, reference); + } + } + + public JsonReference GetSchemaReference(OpenApiMediaType mediaType) => + _contentReferences[mediaType]; + + public bool HasContent() => _contentReferences.Any(); + + public JsonReference GetSchemaReference(IOpenApiHeader header) => + _headerReferences[header]; + } +} \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.cs index dbb5eca..0b7b1e3 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.cs @@ -1,14 +1,11 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Net.Http; using Corvus.Json; using Microsoft.OpenApi; -using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.OpenApi.Visitor.V3; -internal sealed class OpenApiV3Visitor : +internal sealed partial class OpenApiV3Visitor : OpenApiVisitor, IOpenApiVisitor { private OpenApiV3Visitor(OpenApiReference openApiReference) : base(openApiReference) @@ -32,66 +29,4 @@ private void VisitPathItems() public IOpenApiPathItemVisitor Visit(IOpenApiPathItem pathItem) => PathItemVisitor.Visit(new OpenApiReference(pathItem, Document, _pathItems[pathItem])); - - private sealed class PathItemVisitor : - OpenApiVisitor, IOpenApiPathItemVisitor - { - private readonly Dictionary _parameterVisitors = new(); - - private PathItemVisitor(OpenApiReference openApiReference) : base(openApiReference) - { - VisitParameters(); - } - - public JsonReference GetSchemaReference(IOpenApiParameter parameter) => - _parameterVisitors[parameter].Reference; - - private void VisitParameters() - { - foreach (var (parameter, i) in (OpenApiDocument.Parameters ?? []).WithIndex()) - { - var parameterPointer = Visit("parameters", i.ToString()); - var parameterReference = new JsonReference(Reference.Uri, parameterPointer.ToString().AsSpan()); - _parameterVisitors.Add(parameter, ParameterVisitor.Visit(new OpenApiReference( - parameter, - Document, - parameterReference))); - } - } - - internal static PathItemVisitor Visit(OpenApiReference openApiReference) => - new(openApiReference); - - public IOpenApiOperationVisitor Visit(HttpMethod parameter) - { - throw new NotImplementedException(); - } - } - - private sealed class ParameterVisitor : - OpenApiVisitor - { - internal JsonReference SchemaReference { get; } - - private ParameterVisitor(OpenApiReference openApiReference) : base(openApiReference) - { - SchemaReference = VisitSchema(); - } - - internal static ParameterVisitor Visit(OpenApiReference reference) => new(reference); - - private JsonReference VisitSchema() - { - if (!TryVisit(["schema"], out var schemaPointer)) - { - schemaPointer = Visit( - "content", - OpenApiDocument.Content?.Single().Key ?? - throw new InvalidOperationException("Parameter doesn't contain a schema"), - "schema"); - } - - return new JsonReference(Reference.Uri, schemaPointer.ToString().AsSpan()); - } - } } \ No newline at end of file From 846fa347d575a878fafdd196fed4908ff3e7acfe Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 10 Jan 2026 00:48:47 +0100 Subject: [PATCH 03/29] generate schemas from v3 specs --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 33 +++++-------------- .../CodeGeneration/SchemaGenerator.cs | 32 +++++++++++++++++- .../OpenAPI.WebApiGenerator.csproj | 2 +- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index cd6d617..4396b7d 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -1,13 +1,11 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json; using Corvus.Json; -using Corvus.Json.SourceGeneratorTools; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using Microsoft.OpenApi; @@ -31,35 +29,21 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var openapiDocumentProvider = provider.Select((array, _) => array.First()); - // Get global options - var globalOptions = - context.AnalyzerConfigOptionsProvider.Select((optionsProvider, token) => - new SourceGeneratorHelpers.GlobalOptions( - fallbackVocabulary: Corvus.Json.CodeGeneration.Draft4.VocabularyAnalyser.DefaultVocabulary, - optionalAsNullable: true, - useOptionalNameHeuristics: true, - alwaysAssertFormat: true, - ImmutableArray.Empty)); - - var openApiProvider = globalOptions - .Combine(openapiDocumentProvider) + var openApiProvider = openapiDocumentProvider .Combine(context.CompilationProvider) .Select((tuple, _) => ( - Options: tuple.Left.Left, - OpenApiDocument: tuple.Left.Right, + OpenApiDocument: tuple.Left, Compilation: tuple.Right )); context.RegisterSourceOutput(openApiProvider, - WithExceptionReporting<(SourceGeneratorHelpers.GlobalOptions, AdditionalText, Compilation)>(GenerateCode)); + WithExceptionReporting<(AdditionalText, Compilation)>(GenerateCode)); } private static void GenerateCode(SourceProductionContext context, ( - SourceGeneratorHelpers.GlobalOptions Options, AdditionalText OpenApiDocument, Compilation Compilation) generatorContext) { - var globalOptions = generatorContext.Options; var compilation = generatorContext.Compilation; var rootNamespace = compilation.Assembly.Name; @@ -82,6 +66,7 @@ private static void GenerateCode(SourceProductionContext context, ( throw new InvalidOperationException( $"Could not load OpenAPI document {openApiDocumentFile.Path}"); + var openApiUri = new JsonReference(openApi.BaseUri.ToString()); var documentResolver = new PrepopulatedDocumentResolver(); var openApiDocument = JsonDocument.Parse(generatorContext.OpenApiDocument.AsStream()); @@ -89,11 +74,11 @@ private static void GenerateCode(SourceProductionContext context, ( { throw new InvalidOperationException("Could not add OpenApi document"); } - var generationContext = new SourceGeneratorHelpers.GenerationContext(documentResolver, globalOptions); - var schemaGenerator = new SchemaGenerator( - rootNamespace, - context, - generationContext); + var schemaGenerator = SchemaGenerator.For( + openApiVersion, + documentResolver, + rootNamespace, + context); var openApiReference = new OpenApiReference(openApi, openApiDocument, openApiUri); var openApiVisitor = OpenApiVisitor.V(openApiVersion, openApiReference); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs index 04e3e08..16c47e4 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using Corvus.Json; @@ -7,12 +8,14 @@ using Corvus.Json.CodeGeneration.CSharp; using Corvus.Json.SourceGeneratorTools; using Microsoft.CodeAnalysis; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.OpenApi; using JsonPointer = OpenAPI.WebApiGenerator.Json.JsonPointer; namespace OpenAPI.WebApiGenerator.CodeGeneration; -internal sealed class SchemaGenerator(string rootNamespace, +internal sealed class SchemaGenerator( + string rootNamespace, SourceProductionContext context, SourceGeneratorHelpers.GenerationContext generationContext) { @@ -20,6 +23,33 @@ internal sealed class SchemaGenerator(string rootNamespace, private static readonly VocabularyRegistry VocabularyRegistry = SourceGeneratorHelpers.CreateVocabularyRegistry(MetaSchemaResolver); private readonly Dictionary _typeCache = new(); private readonly HashSet _fileCache = []; + + internal static SchemaGenerator For( + OpenApiSpecVersion openApiSpecVersion, + IDocumentResolver documentResolver, + string rootNamespace, + SourceProductionContext context) + { + var vocabulary = openApiSpecVersion switch + { + OpenApiSpecVersion.OpenApi2_0 => + Corvus.Json.CodeGeneration.Draft4.VocabularyAnalyser.DefaultVocabulary, + OpenApiSpecVersion.OpenApi3_0 => + Corvus.Json.CodeGeneration.OpenApi30.VocabularyAnalyser.DefaultVocabulary, + OpenApiSpecVersion.OpenApi3_1 => + Corvus.Json.CodeGeneration.Draft202012.VocabularyAnalyser.DefaultVocabulary, + _ => throw new InvalidOperationException($"OpenAPI specification {openApiSpecVersion} is not supported") + }; + var globalOptions = + new SourceGeneratorHelpers.GlobalOptions( + fallbackVocabulary: vocabulary, + optionalAsNullable: true, + useOptionalNameHeuristics: true, + alwaysAssertFormat: true, + ImmutableArray.Empty); + var generationContext = new SourceGeneratorHelpers.GenerationContext(documentResolver, globalOptions); + return new SchemaGenerator(rootNamespace, context, generationContext); + } internal TypeDeclaration Generate(JsonReference reference) { diff --git a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj index cdd17d0..edda743 100644 --- a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj +++ b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj @@ -52,7 +52,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From e64e558c97088ce82b621f05c5a4b54c12733dfa Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 10 Jan 2026 10:52:03 +0100 Subject: [PATCH 04/29] test(v3): generate with a v3.0.3 spec --- .../ApiGeneratorTests.cs | 15 +- .../OpenAPI.WebApiGenerator.Tests.csproj | 3 + .../OpenApiSpecs/openapi-v3.json | 908 ++++++++++++++++++ 3 files changed, 920 insertions(+), 6 deletions(-) create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs index d920ca1..5b0e76e 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs @@ -16,8 +16,10 @@ public class ApiGeneratorTests { private CancellationToken Cancellation => TestContext.Current.CancellationToken; - [Fact] - public void GivenAnOpenAPISpec_WhenGeneratingAPI_ExpectedClassesShouldHaveBeenGenerated() + [Theory] + [InlineData("OpenApiSpecs/file.json")] + [InlineData("OpenApiSpecs/openapi-v3.json")] + public void GivenAnOpenAPISpec_WhenGeneratingAPI_ExpectedClassesShouldHaveBeenGenerated(string specFile) { var generator = new ApiGenerator(); @@ -25,24 +27,24 @@ public void GivenAnOpenAPISpec_WhenGeneratingAPI_ExpectedClassesShouldHaveBeenGe driver = driver.AddAdditionalTexts( [ - new TestAdditionalFile("OpenApiSpecs/file.json") + new TestAdditionalFile(specFile) ] ); var compilation = CSharpCompilation.Create(nameof(ApiGeneratorTests)); - driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out var diagnostics, TestContext.Current.CancellationToken); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out var diagnostics, Cancellation); // Operation handler stubs should be generated with a warning diagnostics.Should().AllSatisfy(diagnostic => { diagnostic.Severity.Should().Be(DiagnosticSeverity.Warning); diagnostic.Id.Should().Be("AF1001", diagnostic.GetMessage()); - }); + }); var generatedFiles = newCompilation.SyntaxTrees .Select(t => Path.GetFileName(t.FilePath)) .ToArray(); - + generatedFiles.Should().HaveCountGreaterThan(0); generatedFiles.Should().ContainMatch("*.Request.g.cs"); generatedFiles.Should().ContainMatch("*.Response.g.cs"); @@ -221,4 +223,5 @@ private Compilation SetupGenerator(string openApiSpec, out ImmutableArray Always + + Always + diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json new file mode 100644 index 0000000..caece69 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.json @@ -0,0 +1,908 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Comprehensive Test API", + "description": "An exhaustive OpenAPI 3.0 specification for testing code generation", + "version": "1.0.0", + "contact": { + "name": "Test", + "email": "test@example.com" + }, + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "tags": [ + {"name": "pets", "description": "Pet operations"}, + {"name": "store", "description": "Store operations"}, + {"name": "users", "description": "User operations"} + ], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "summary": "List all pets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Maximum number of items to return", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of items to skip", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0, + "default": 0 + } + }, + { + "name": "status", + "in": "query", + "description": "Filter by status", + "required": false, + "schema": { + "type": "string", + "enum": ["available", "pending", "sold"] + } + }, + { + "name": "tags", + "in": "query", + "description": "Filter by tags", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "X-Request-Id", + "in": "header", + "description": "Request correlation ID", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "A list of pets", + "headers": { + "X-Total-Count": { + "description": "Total number of pets", + "schema": { + "type": "integer" + } + }, + "X-Page-Size": { + "description": "Number of items per page", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "default": { + "description": "Unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createPet", + "summary": "Create a pet", + "tags": ["pets"], + "requestBody": { + "description": "Pet to create", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + } + }, + "responses": { + "201": { + "description": "Pet created", + "headers": { + "Location": { + "description": "URL of created pet", + "schema": { + "type": "string", + "format": "uri" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pets/{petId}": { + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "Pet ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "get": { + "operationId": "getPet", + "summary": "Get a pet by ID", + "tags": ["pets"], + "responses": { + "200": { + "description": "Pet found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "404": { + "description": "Pet not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "operationId": "updatePet", + "summary": "Update a pet", + "tags": ["pets"], + "requestBody": { + "description": "Pet data to update", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + } + }, + "responses": { + "200": { + "description": "Pet updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "404": { + "description": "Pet not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "deletePet", + "summary": "Delete a pet", + "tags": ["pets"], + "parameters": [ + { + "name": "X-Api-Key", + "in": "header", + "description": "API key for authorization", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Pet deleted" + }, + "404": { + "description": "Pet not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pets/{petId}/image": { + "post": { + "operationId": "uploadPetImage", + "summary": "Upload pet image", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "description": "Image to upload", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + }, + "description": { + "type": "string" + } + }, + "required": ["file"] + } + } + } + }, + "responses": { + "200": { + "description": "Image uploaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageUploadResponse" + } + } + } + } + } + } + }, + "/store/orders": { + "post": { + "operationId": "placeOrder", + "summary": "Place an order", + "tags": ["store"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "responses": { + "201": { + "description": "Order placed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid order", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/store/orders/{orderId}": { + "get": { + "operationId": "getOrder", + "summary": "Get order by ID", + "tags": ["store"], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Order found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "404": { + "description": "Order not found" + } + } + }, + "delete": { + "operationId": "cancelOrder", + "summary": "Cancel an order", + "tags": ["store"], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Order cancelled" + }, + "404": { + "description": "Order not found" + } + } + } + }, + "/store/inventory": { + "get": { + "operationId": "getInventory", + "summary": "Get store inventory", + "tags": ["store"], + "responses": { + "200": { + "description": "Inventory counts by status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + } + }, + "/users": { + "post": { + "operationId": "createUser", + "summary": "Create user", + "tags": ["users"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "201": { + "description": "User created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/users/{username}": { + "get": { + "operationId": "getUser", + "summary": "Get user by username", + "tags": ["users"], + "parameters": [ + { + "name": "username", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_]+$" + } + } + ], + "responses": { + "200": { + "description": "User found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "404": { + "description": "User not found" + } + } + }, + "put": { + "operationId": "updateUser", + "summary": "Update user", + "tags": ["users"], + "parameters": [ + { + "name": "username", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "User updated" + }, + "404": { + "description": "User not found" + } + } + }, + "delete": { + "operationId": "deleteUser", + "summary": "Delete user", + "tags": ["users"], + "parameters": [ + { + "name": "username", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "User deleted" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/users/login": { + "post": { + "operationId": "loginUser", + "summary": "User login", + "tags": ["users"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string", + "format": "password" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "headers": { + "X-Rate-Limit": { + "description": "Calls per hour allowed", + "schema": { + "type": "integer", + "format": "int32" + } + }, + "X-Expires-After": { + "description": "Token expiration time", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "401": { + "description": "Invalid credentials" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "enum": ["available", "pending", "sold"], + "default": "available" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, + "NewPet": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "enum": ["available", "pending", "sold"], + "default": "available" + } + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "Order": { + "type": "object", + "required": ["petId", "quantity"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "petId": { + "type": "integer", + "format": "int64" + }, + "quantity": { + "type": "integer", + "format": "int32", + "minimum": 1, + "maximum": 10 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": ["placed", "approved", "delivered"], + "default": "placed" + }, + "complete": { + "type": "boolean", + "default": false + } + } + }, + "User": { + "type": "object", + "required": ["username", "email"], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "username": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_]+$" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "format": "password", + "writeOnly": true + }, + "phone": { + "type": "string" + }, + "userStatus": { + "type": "integer", + "format": "int32", + "description": "User status" + } + } + }, + "LoginResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresIn": { + "type": "integer", + "format": "int32" + } + } + }, + "ImageUploadResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-Api-Key" + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] +} \ No newline at end of file From 7b89caf2fd52c67dc272420f44809bb31a5df1da Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 10 Jan 2026 11:50:40 +0100 Subject: [PATCH 05/29] test: v3.1 --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 6 +- .../Visitor/IOpenApiResponseVisitor.cs | 2 +- .../V2/OpenApiV2Visitor.ResponseVisitor.cs | 3 +- .../V3/OpenApiV3Visitor.ResponseVisitor.cs | 32 +- .../ApiGeneratorTests.cs | 7 +- .../OpenAPI.WebApiGenerator.Tests.csproj | 5 +- .../{file.json => openapi-v2.json} | 0 .../OpenApiSpecs/openapi-v3.1.json | 865 ++++++++++++++++++ 8 files changed, 888 insertions(+), 32 deletions(-) rename tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/{file.json => openapi-v2.json} (100%) create mode 100644 tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 4396b7d..e4e0aff 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -164,10 +164,10 @@ private static void GenerateCode(SourceProductionContext context, ( var openApiResponseVisitor = openApiOperationVisitor.Visit(response); var responseContent = - // OpenAPI.NET is incorrectly adding content when there is none defined. + // OpenAPI.NET is incorrectly adding content where there is none defined. // No content definition means NO content. - (openApiResponseVisitor.HasContent() ? response.Content : null) ?? - new Dictionary(); + response.Content?.Where(content => + openApiResponseVisitor.HasContent(content.Value)) ?? []; var responseBodyGenerators = responseContent.Select(valuePair => { var content = valuePair.Value; diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiResponseVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiResponseVisitor.cs index 2b8ba0e..b37f090 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiResponseVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiResponseVisitor.cs @@ -6,6 +6,6 @@ namespace OpenAPI.WebApiGenerator.OpenApi.Visitor; internal interface IOpenApiResponseVisitor { public JsonReference GetSchemaReference(OpenApiMediaType mediaType); - public bool HasContent(); + public bool HasContent(OpenApiMediaType mediaType); public JsonReference GetSchemaReference(IOpenApiHeader header); } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ResponseVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ResponseVisitor.cs index f599624..ab6c8fc 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ResponseVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ResponseVisitor.cs @@ -47,7 +47,8 @@ private void VisitHeaders() public JsonReference GetSchemaReference(OpenApiMediaType mediaType) => _contentSchemaReference ?? throw new InvalidOperationException("Response has no content defined"); - public bool HasContent() => _contentSchemaReference != null; + public bool HasContent(OpenApiMediaType mediaType) => + _contentSchemaReference != null; public JsonReference GetSchemaReference(IOpenApiHeader header) => _headerReferences[header]; diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs index 494ae24..49bd034 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using Corvus.Json; using Microsoft.OpenApi; @@ -32,15 +31,11 @@ private void VisitContent() foreach (var content in OpenApiDocument.Content) { - _contentReferences.Add(content.Value, new JsonReference(Reference.Uri, - Pointer - .Append( - "content", - content.Key, - "schema" - ) - .ToString() - .AsSpan())); + if (TryVisit(["content", content.Key, "schema"], out var schemaPointer)) + { + _contentReferences.Add(content.Value, new JsonReference(Reference.Uri, + schemaPointer.ToString().AsSpan())); + } } } @@ -53,22 +48,19 @@ private void VisitHeaders() foreach (var openApiHeader in OpenApiDocument.Headers) { - var reference = new JsonReference(Reference.Uri, - Pointer - .Append( - "headers", - openApiHeader.Key, - "schema") - .ToString() - .AsSpan()); - _headerReferences.Add(openApiHeader.Value, reference); + if (TryVisit(["headers", openApiHeader.Key, "schema"], out var schemaPointer)) + { + _headerReferences.Add(openApiHeader.Value, new JsonReference(Reference.Uri, + schemaPointer.ToString().AsSpan())); + } } } public JsonReference GetSchemaReference(OpenApiMediaType mediaType) => _contentReferences[mediaType]; - public bool HasContent() => _contentReferences.Any(); + public bool HasContent(OpenApiMediaType mediaType) => + _contentReferences.ContainsKey(mediaType); public JsonReference GetSchemaReference(IOpenApiHeader header) => _headerReferences[header]; diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs index 5b0e76e..7f3301f 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs @@ -17,8 +17,9 @@ public class ApiGeneratorTests private CancellationToken Cancellation => TestContext.Current.CancellationToken; [Theory] - [InlineData("OpenApiSpecs/file.json")] - [InlineData("OpenApiSpecs/openapi-v3.json")] + [InlineData("openapi-v2.json")] + [InlineData("openapi-v3.json")] + [InlineData("openapi-v3.1.json")] public void GivenAnOpenAPISpec_WhenGeneratingAPI_ExpectedClassesShouldHaveBeenGenerated(string specFile) { var generator = new ApiGenerator(); @@ -27,7 +28,7 @@ public void GivenAnOpenAPISpec_WhenGeneratingAPI_ExpectedClassesShouldHaveBeenGe driver = driver.AddAdditionalTexts( [ - new TestAdditionalFile(specFile) + new TestAdditionalFile($"OpenApiSpecs/{specFile}") ] ); diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj b/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj index dbbaa8c..9a4ef64 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj @@ -22,10 +22,7 @@ - - Always - - + Always diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/file.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json similarity index 100% rename from tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/file.json rename to tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v2.json diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json new file mode 100644 index 0000000..2b00e74 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.1.json @@ -0,0 +1,865 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Comprehensive Test API", + "description": "An exhaustive OpenAPI 3.1 specification for testing code generation", + "version": "1.0.0", + "contact": { + "name": "Test", + "email": "test@example.com" + }, + "license": { + "name": "MIT", + "identifier": "MIT" + } + }, + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "tags": [ + {"name": "pets", "description": "Pet operations"}, + {"name": "store", "description": "Store operations"}, + {"name": "users", "description": "User operations"} + ], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "summary": "List all pets", + "tags": ["pets"], + "parameters": [ + {"$ref": "#/components/parameters/LimitParam"}, + {"$ref": "#/components/parameters/OffsetParam"}, + { + "name": "status", + "in": "query", + "description": "Filter by status", + "required": false, + "schema": { + "$ref": "#/components/schemas/PetStatus" + } + }, + { + "name": "tags", + "in": "query", + "description": "Filter by tags", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + {"$ref": "#/components/parameters/RequestIdHeader"} + ], + "responses": { + "200": { + "description": "A list of pets", + "headers": { + "X-Total-Count": {"$ref": "#/components/headers/TotalCount"}, + "X-Page-Size": { + "description": "Number of items per page", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "default": { + "description": "Unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createPet", + "summary": "Create a pet", + "tags": ["pets"], + "requestBody": {"$ref": "#/components/requestBodies/NewPetBody"}, + "responses": { + "201": { + "description": "Pet created", + "headers": { + "Location": { + "description": "URL of created pet", + "schema": { + "type": "string", + "format": "uri" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": {"$ref": "#/components/responses/BadRequest"} + } + } + }, + "/pets/{petId}": { + "parameters": [ + {"$ref": "#/components/parameters/PetIdPath"} + ], + "get": { + "operationId": "getPet", + "summary": "Get a pet by ID", + "tags": ["pets"], + "responses": { + "200": {"$ref": "#/components/responses/PetResponse"}, + "404": {"$ref": "#/components/responses/NotFound"} + } + }, + "put": { + "operationId": "updatePet", + "summary": "Update a pet", + "tags": ["pets"], + "requestBody": { + "description": "Pet data to update", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + } + }, + "responses": { + "200": { + "description": "Pet updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "404": {"$ref": "#/components/responses/NotFound"} + } + }, + "delete": { + "operationId": "deletePet", + "summary": "Delete a pet", + "tags": ["pets"], + "parameters": [ + { + "name": "X-Api-Key", + "in": "header", + "description": "API key for authorization", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Pet deleted" + }, + "404": {"$ref": "#/components/responses/NotFound"} + } + } + }, + "/pets/{petId}/image": { + "post": { + "operationId": "uploadPetImage", + "summary": "Upload pet image", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "description": "Image to upload", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "image/*", + "contentEncoding": "binary" + }, + "description": { + "type": "string" + } + }, + "required": ["file"] + } + } + } + }, + "responses": { + "200": { + "description": "Image uploaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageUploadResponse" + } + } + } + } + } + } + }, + "/store/orders": { + "post": { + "operationId": "placeOrder", + "summary": "Place an order", + "tags": ["store"], + "requestBody": {"$ref": "#/components/requestBodies/OrderBody"}, + "responses": { + "201": {"$ref": "#/components/responses/OrderResponse"}, + "400": { + "description": "Invalid order", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/store/orders/{orderId}": { + "get": { + "operationId": "getOrder", + "summary": "Get order by ID", + "tags": ["store"], + "parameters": [ + {"$ref": "#/components/parameters/OrderIdPath"} + ], + "responses": { + "200": {"$ref": "#/components/responses/OrderResponse"}, + "404": { + "description": "Order not found" + } + } + }, + "delete": { + "operationId": "cancelOrder", + "summary": "Cancel an order", + "tags": ["store"], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Order cancelled" + }, + "404": { + "description": "Order not found" + } + } + } + }, + "/store/inventory": { + "get": { + "operationId": "getInventory", + "summary": "Get store inventory", + "tags": ["store"], + "responses": { + "200": { + "description": "Inventory counts by status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + } + } + } + } + }, + "/users": { + "post": { + "operationId": "createUser", + "summary": "Create user", + "tags": ["users"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "201": {"$ref": "#/components/responses/UserResponse"} + } + } + }, + "/users/{username}": { + "parameters": [ + {"$ref": "#/components/parameters/UsernamePath"} + ], + "get": { + "operationId": "getUser", + "summary": "Get user by username", + "tags": ["users"], + "responses": { + "200": {"$ref": "#/components/responses/UserResponse"}, + "404": {"$ref": "#/components/responses/NotFound"} + } + }, + "put": { + "operationId": "updateUser", + "summary": "Update user", + "tags": ["users"], + "requestBody": {"$ref": "#/components/requestBodies/UserBody"}, + "responses": { + "200": { + "description": "User updated" + }, + "404": {"$ref": "#/components/responses/NotFound"} + } + }, + "delete": { + "operationId": "deleteUser", + "summary": "Delete user", + "tags": ["users"], + "parameters": [ + { + "name": "username", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "User deleted" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/users/login": { + "post": { + "operationId": "loginUser", + "summary": "User login", + "tags": ["users"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string", + "format": "password" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "headers": { + "X-Rate-Limit": {"$ref": "#/components/headers/RateLimit"}, + "X-Expires-After": { + "description": "Token expiration time", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "401": { + "description": "Invalid credentials" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "$ref": "#/components/schemas/PetStatus" + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, + "NewPet": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "enum": ["available", "pending", "sold"], + "default": "available" + } + } + }, + "PetStatus": { + "type": "string", + "enum": ["available", "pending", "sold"], + "default": "available" + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "Order": { + "type": "object", + "required": ["petId", "quantity"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "petId": { + "type": "integer" + }, + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": ["placed", "approved", "delivered"], + "default": "placed" + }, + "complete": { + "type": "boolean", + "default": false + } + } + }, + "User": { + "type": "object", + "required": ["username", "email"], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "username": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_]+$" + }, + "firstName": { + "type": ["string", "null"] + }, + "lastName": { + "type": ["string", "null"] + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "format": "password", + "writeOnly": true + }, + "phone": { + "type": ["string", "null"] + }, + "userStatus": { + "type": "integer", + "description": "User status" + } + } + }, + "LoginResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresIn": { + "type": "integer" + } + } + }, + "ImageUploadResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": { + "PetIdPath": { + "name": "petId", + "in": "path", + "description": "Pet ID", + "required": true, + "schema": { + "type": "integer" + } + }, + "OrderIdPath": { + "name": "orderId", + "in": "path", + "description": "Order ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + "UsernamePath": { + "name": "username", + "in": "path", + "description": "Username", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_]+$" + } + }, + "LimitParam": { + "name": "limit", + "in": "query", + "description": "Maximum number of items to return", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + "OffsetParam": { + "name": "offset", + "in": "query", + "description": "Number of items to skip", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + "RequestIdHeader": { + "name": "X-Request-Id", + "in": "header", + "description": "Request correlation ID", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "headers": { + "TotalCount": { + "description": "Total number of items", + "schema": { + "type": "integer" + } + }, + "RateLimit": { + "description": "Calls per hour allowed", + "schema": { + "type": "integer" + } + } + }, + "requestBodies": { + "NewPetBody": { + "description": "Pet to create", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + } + }, + "OrderBody": { + "description": "Order to place", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "UserBody": { + "description": "User data", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "responses": { + "PetResponse": { + "description": "Pet found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "OrderResponse": { + "description": "Order found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "UserResponse": { + "description": "User found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "BadRequest": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "NotFound": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-Api-Key" + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] +} \ No newline at end of file From 31d1266116da88af4f0c9bdce5af7f4aacd011eb Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 10 Jan 2026 11:53:51 +0100 Subject: [PATCH 06/29] refactor: consolidate generation exception handling into one analyzer rule --- .../AnalyzerReleases.Unshipped.md | 3 +- .../CodeGeneration/SchemaGenerator.cs | 55 +++---------------- 2 files changed, 8 insertions(+), 50 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/AnalyzerReleases.Unshipped.md b/src/OpenAPI.WebApiGenerator/AnalyzerReleases.Unshipped.md index 6860b5e..4e89ed1 100644 --- a/src/OpenAPI.WebApiGenerator/AnalyzerReleases.Unshipped.md +++ b/src/OpenAPI.WebApiGenerator/AnalyzerReleases.Unshipped.md @@ -3,5 +3,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- AF0001 | Compiler | Error | ApiGenerator -AF1001 | Api | Warning | EndpointGenerator -CRV1001 | JsonSchemaCodeGenerator | Error | ApiGenerator \ No newline at end of file +AF1001 | Api | Warning | EndpointGenerator \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs index 16c47e4..ce2556e 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs @@ -102,24 +102,9 @@ private List GenerateCode(SourceGeneratorHelpers.TypesToGenerat return []; } - string schemaFile = spec.Location; + var schemaFile = spec.Location; JsonReference reference = new(schemaFile); - TypeDeclaration rootType; - try - { - rootType = typeBuilder.AddTypeDeclarations(reference, typesToGenerate.FallbackVocabulary, spec.RebaseToRootPath, context.CancellationToken); - } - catch (Exception ex) - { - context.ReportDiagnostic( - Diagnostic.Create( - Crv1001ErrorGeneratingCSharpCode, - Location.None, - reference, - ex.Message)); - - return []; - } + var rootType = typeBuilder.AddTypeDeclarations(reference, typesToGenerate.FallbackVocabulary, spec.RebaseToRootPath, context.CancellationToken); typeDeclarationsToGenerate.Add(rootType); @@ -164,27 +149,11 @@ private List GenerateCode(SourceGeneratorHelpers.TypesToGenerat var languageProvider = CSharpLanguageProvider.DefaultWithOptions(options); - IReadOnlyCollection generatedCode; - - try - { - generatedCode = - typeBuilder.GenerateCodeUsing( - languageProvider, - context.CancellationToken, - typeDeclarationsToGenerate); - } - catch (Exception ex) - { - context.ReportDiagnostic( - Diagnostic.Create( - Crv1001ErrorGeneratingCSharpCode, - Location.None, - ex.Message)); - - return []; - } - + var generatedCode = typeBuilder.GenerateCodeUsing( + languageProvider, + context.CancellationToken, + typeDeclarationsToGenerate); + foreach (var codeFile in generatedCode) { context.CancellationToken.ThrowIfCancellationRequested(); @@ -209,14 +178,4 @@ private List GenerateCode(SourceGeneratorHelpers.TypesToGenerat .Select(declaration => declaration.ReducedTypeDeclaration().ReducedType) .ToList(); } - - private static readonly DiagnosticDescriptor Crv1001ErrorGeneratingCSharpCode = - new( - id: "CRV1001", - title: "JSON Schema Type Generator Error", - messageFormat: "Error generating C# code: {0}: {1}", - category: "JsonSchemaCodeGenerator", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - } \ No newline at end of file From c61a98d5bb18561b693496c7988f5d48846dda61 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sun, 11 Jan 2026 13:31:44 +0100 Subject: [PATCH 07/29] remove redundant is required directive --- .../HttpRequestExtensionsGenerator.cs | 17 ++++++----------- .../CodeGeneration/ParameterGenerator.cs | 8 ++++---- .../RequestBodyContentGenerator.cs | 8 ++++---- .../CodeGeneration/RequestBodyGenerator.cs | 2 +- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index 510ee23..a4728df 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs @@ -8,8 +8,7 @@ internal sealed class HttpRequestExtensionsGenerator( internal string CreateBindParameterInvocation( string requestVariableName, string bindingTypeName, - string parameterSpecificationAsJson, - bool isRequired) + string parameterSpecificationAsJson) { return $"""" @@ -17,20 +16,18 @@ internal string CreateBindParameterInvocation( {requestVariableName}, """ {parameterSpecificationAsJson} - """, - {isRequired.ToString().ToLowerInvariant()}) + """) """"; } internal string CreateBindBodyInvocation( string requestVariableName, - string bindingTypeName, - bool isRequired) + string bindingTypeName) { return $""" await {@namespace}.{HttpRequestExtensionsClassName}.BindBodyAsync<{bindingTypeName}>( - {requestVariableName}, {isRequired.ToString().ToLowerInvariant()}, cancellationToken) + {requestVariableName}, cancellationToken) .ConfigureAwait(false) """; } @@ -62,8 +59,7 @@ internal static class {{{HttpRequestExtensionsClassName}}} /// /// internal static T Bind(this HttpRequest request, - string parameterSpecificationAsJson, - bool isRequired) + string parameterSpecificationAsJson) where T : struct, IJsonValue { var parameter = Parameter.FromOpenApi20ParameterSpecification(parameterSpecificationAsJson); @@ -76,8 +72,7 @@ _ when TryGetValue(request, parameter, out var stringValue) => }; } - internal static async Task BindBodyAsync(this HttpRequest request, - bool isRequired, + internal static async Task BindBodyAsync(this HttpRequest request, CancellationToken cancellationToken) where T : struct, IJsonValue { diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs index fbd757e..68f2410 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs @@ -41,9 +41,9 @@ internal string GenerateRequestBindingDirective(string requestVariableName) textWriter.Flush(); return $"{PropertyName} = {httpRequestExtensionsGenerator.CreateBindParameterInvocation( - requestVariableName, - FullyQualifiedTypeDeclarationIdentifier, - textWriter.GetStringBuilder().ToString(), - IsParameterRequired).Indent(4).TrimStart()}{(IsParameterRequired ? "" : ".AsOptional()")},"; + requestVariableName, + FullyQualifiedTypeDeclarationIdentifier, + textWriter.GetStringBuilder().ToString()) + .Indent(4).TrimStart()}{(IsParameterRequired ? "" : ".AsOptional()")},"; } } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index 759e10a..387bec1 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -19,13 +19,13 @@ internal sealed class RequestBodyContentGenerator( internal string ContentType => contentType; - internal string GenerateRequestBindingDirective(bool isRequired) => + internal string GenerateRequestBindingDirective() => $""" {PropertyName} = ({httpRequestExtensionsGenerator.CreateBindBodyInvocation( - "request", - FullyQualifiedTypeDeclarationIdentifier, - isRequired).Indent(8).Trim()}) + "request", + FullyQualifiedTypeDeclarationIdentifier) + .Indent(8).Trim()}) .AsOptional() """; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs index 76bc47b..342a612 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs @@ -87,7 +87,7 @@ internal sealed class RequestContent case "{{content.ContentType.ToLower()}}": return new RequestContent { -{{content.GenerateRequestBindingDirective(_body.Required).Indent(20)}} +{{content.GenerateRequestBindingDirective().Indent(20)}} }; """)}}{{(_body.Required ? "" : """ From bdbcbb7b3b54d2992c4d9075117b80989d36ac38 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sun, 11 Jan 2026 13:34:22 +0100 Subject: [PATCH 08/29] clean up --- .../RequestBodyContentGenerator.cs | 13 +++++-------- .../CodeGeneration/RequestBodyGenerator.cs | 17 ++++++----------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index 387bec1..a28ea7e 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -1,5 +1,4 @@ -using System.Linq; -using Corvus.Json.CodeGeneration; +using Corvus.Json.CodeGeneration; using Corvus.Json.CodeGeneration.CSharp; using OpenAPI.WebApiGenerator.Extensions; @@ -29,10 +28,8 @@ internal string GenerateRequestBindingDirective() => .AsOptional() """; - public string GenerateRequestProperty() - { - return $$""" - internal {{FullyQualifiedTypeName}} {{PropertyName}} { get; private set; } - """; - } + public string GenerateRequestProperty() => + $$""" + internal {{FullyQualifiedTypeName}} {{PropertyName}} { get; private set; } + """; } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs index 342a612..f87f362 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; @@ -33,15 +31,12 @@ public RequestBodyGenerator( internal string GenerateRequestBindingDirective(string propertyName, string requestVariableName, out bool isAsync) { isAsync = _body is not null; - if (_body is null) - { - return string.Empty; - } - - return $""" - {propertyName} = await RequestContent.BindAsync({requestVariableName}, cancellationToken) - .ConfigureAwait(false) - """; + return _body is null + ? string.Empty + : $""" + {propertyName} = await RequestContent.BindAsync({requestVariableName}, cancellationToken) + .ConfigureAwait(false) + """; } internal string GenerateValidateDirective(string propertyName, string validationContextVariableName, string validationLevelVariableName) From e220edb2f04ee652db328b748cde238e7d694c78 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 13 Jan 2026 22:53:04 +0100 Subject: [PATCH 09/29] feat(parameter): support parameter value parsing for openapi 3.0 and 3.1 --- README.md | 4 +- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 16 ++- .../HttpRequestExtensionsGenerator.cs | 116 +++++++++++------- tests/Example.Api/Example.Api.csproj | 2 +- 4 files changed, 85 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index f9d14b9..e6e9776 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,11 @@ https://www.nuget.org/packages/WebApiGenerator.OpenAPI ``` - + ``` * Corvus.Json.ExtendedTypes >= 4.0.0 -* ParameterStyleParsers.OpenAPI >= 1.1.0 +* ParameterStyleParsers.OpenAPI >= 1.4.0 4. Compile the project. diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index e4e0aff..9fa9192 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -83,7 +83,9 @@ private static void GenerateCode(SourceProductionContext context, ( var openApiReference = new OpenApiReference(openApi, openApiDocument, openApiUri); var openApiVisitor = OpenApiVisitor.V(openApiVersion, openApiReference); - var httpRequestExtensionsGenerator = new HttpRequestExtensionsGenerator(rootNamespace); + var httpRequestExtensionsGenerator = new HttpRequestExtensionsGenerator( + openApiVersion, + rootNamespace); httpRequestExtensionsGenerator.GenerateHttpRequestExtensionsClass().AddTo(context); var httpResponseExtensionsGenerator = new HttpResponseExtensionsGenerator(rootNamespace); @@ -106,8 +108,10 @@ private static void GenerateCode(SourceProductionContext context, ( { var schemaReference = openApiPathVisitor.GetSchemaReference(parameter); var typeDeclaration = schemaGenerator.Generate(schemaReference); - pathParameterGenerators[$"{parameter.GetName()}_{parameter.GetLocation()}"] = new ParameterGenerator(typeDeclaration, parameter, - httpRequestExtensionsGenerator); + pathParameterGenerators[$"{parameter.GetName()}_{parameter.GetLocation()}"] = + new ParameterGenerator(typeDeclaration, + parameter, + httpRequestExtensionsGenerator); } foreach (var openApiOperation in path.Value.GetOperations()) @@ -124,8 +128,10 @@ private static void GenerateCode(SourceProductionContext context, ( { var schemaReference = openApiOperationVisitor.GetSchemaReference(parameter); var typeDeclaration = schemaGenerator.Generate(schemaReference); - operationParameterGenerators[$"{parameter.GetName()}_{parameter.GetLocation()}"] = new ParameterGenerator(typeDeclaration, parameter, - httpRequestExtensionsGenerator); + operationParameterGenerators[$"{parameter.GetName()}_{parameter.GetLocation()}"] = + new ParameterGenerator(typeDeclaration, + parameter, + httpRequestExtensionsGenerator); } var body = operation.RequestBody; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index a4728df..4ca0516 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs @@ -1,9 +1,21 @@ -namespace OpenAPI.WebApiGenerator.CodeGeneration; +using System; +using Microsoft.OpenApi; + +namespace OpenAPI.WebApiGenerator.CodeGeneration; internal sealed class HttpRequestExtensionsGenerator( + OpenApiSpecVersion openApiVersion, string @namespace) { private const string HttpRequestExtensionsClassName = "HttpRequestExtensions"; + + private readonly string _openApiVersion = openApiVersion switch + { + OpenApiSpecVersion.OpenApi2_0 => "2.0", + OpenApiSpecVersion.OpenApi3_0 => "3.0", + OpenApiSpecVersion.OpenApi3_1 => "3.1", + _ => throw new ArgumentOutOfRangeException(nameof(openApiVersion), openApiVersion, "Unknown OpenAPI version") + }; internal string CreateBindParameterInvocation( string requestVariableName, @@ -14,6 +26,7 @@ internal string CreateBindParameterInvocation( $"""" {@namespace}.{HttpRequestExtensionsClassName}.Bind<{bindingTypeName}>( {requestVariableName}, + "{_openApiVersion}", """ {parameterSpecificationAsJson} """) @@ -37,37 +50,39 @@ internal SourceCode GenerateHttpRequestExtensionsClass() => $$$"""" #nullable enable using System.Collections.Concurrent; + using System.Diagnostics.CodeAnalysis; using System.Text.Json; using Corvus.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; - using OpenAPI.ParameterStyleParsers.OpenApi20; - using OpenAPI.ParameterStyleParsers.OpenApi20.ParameterParsers; + using OpenAPI.ParameterStyleParsers; namespace {{{@namespace}}}; internal static class {{{HttpRequestExtensionsClassName}}} { - private static readonly ConcurrentDictionary ParserCache = new(); + private static readonly ConcurrentDictionary ParserCache = new(); + private static IParameterValueParser GetParser(IParameter parameter) => ParserCache.GetOrAdd(parameter, _ => parameter.CreateParameterValueParser()); /// /// Binds an http parameter to a json type /// /// - /// - /// - /// + /// OpenAPI Version of the specification + /// OpenAPI parameter specification formatted as json + /// The type to bind + /// The bound instance /// internal static T Bind(this HttpRequest request, + string openApiVersion, string parameterSpecificationAsJson) where T : struct, IJsonValue { - var parameter = Parameter.FromOpenApi20ParameterSpecification(parameterSpecificationAsJson); + var parameter = ParameterFactory.OpenApi(openApiVersion, parameterSpecificationAsJson); return parameter switch { _ when parameter.InBody => T.Parse(request.BodyReader.AsStream()), - _ when TryGetValue(request, parameter, out var stringValue) => - Parse(parameter, stringValue), + _ when TryParse(request, parameter, out var value) => value.Value, _ => T.Undefined }; } @@ -82,79 +97,90 @@ internal static async Task BindBodyAsync(this HttpRequest request, return T.FromJson(document.RootElement.Clone()); } - - private static T Parse(Parameter parameter, string? stringValue) - where T : struct, IJsonValue - { - var parser = ParserCache.GetOrAdd(parameter, ParameterValueParser.Create); - if (!parser.TryParse(stringValue, out var instance, out var error)) - { - throw new BadHttpRequestException(error); - } - - return instance == null ? T.Null : T.Parse(instance.ToJsonString()); - } - - private static bool TryGetValue(this HttpRequest request, Parameter parameter, out string? stringValue) => + private static bool TryParse(this HttpRequest request, IParameter parameter, [NotNullWhen(true)] out T? value) + where T : struct, IJsonValue => parameter switch { - _ when parameter.InHeader => TryGetHeaderValue(request.Headers, parameter, out stringValue), - _ when parameter.InFormData => TryGetFormDataValue(request.Form, parameter, out stringValue), - _ when parameter.InPath => TryGetPathValue(request.RouteValues, parameter, out stringValue), - _ when parameter.InQuery => TryGetQueryValue(request.Query, parameter, out stringValue), + _ when parameter.InHeader => TryParseHeader(request.Headers, parameter, out value), + _ when parameter.InFormData => TryParseForm(request.Form, parameter, out value), + _ when parameter.InPath => TryParsePath(request.RouteValues, parameter, out value), + _ when parameter.InQuery => TryParseQuery(request.Query, parameter, out value), _ => throw new InvalidOperationException($"Parameter {parameter.Name} has an unknown location") }; - private static bool TryGetQueryValue(IQueryCollection query, Parameter parameter, out string? stringValue) + private static bool TryParseQuery(IQueryCollection query, IParameter parameter, [NotNullWhen(true)] out T? value) + where T : struct, IJsonValue { - stringValue = null; + value = null; return query.TryGetValue(parameter.Name, out var values) && - TryGetValue(values, parameter, out stringValue); + TryParse(values, parameter, out value); } - private static bool TryGetPathValue(RouteValueDictionary requestPath, Parameter parameter, out string? stringValue) + private static bool TryParsePath(RouteValueDictionary requestPath, IParameter parameter, [NotNullWhen(true)] out T? value) + where T : struct, IJsonValue { - if (!requestPath.TryGetValue(parameter.Name, out var value)) + if (!requestPath.TryGetValue(parameter.Name, out var objValue)) { - stringValue = null; + value = default; return false; } - stringValue = value switch + var stringValue = objValue switch { null => null, string strValue => strValue, _ => throw new InvalidOperationException( - $"Route value of '{value}' with type '{value.GetType()}' is not supported") + $"Route value of '{objValue}' with type '{objValue.GetType()}' is not supported") }; + + var parser = GetParser(parameter); + value = Parse(parser, stringValue); return true; } - private static bool TryGetFormDataValue(IFormCollection requestForm, Parameter parameter, out string? stringValue) + private static bool TryParseForm(IFormCollection requestForm, IParameter parameter, [NotNullWhen(true)] out T? value) + where T : struct, IJsonValue { - stringValue = null; - return requestForm.TryGetValue(parameter.Name, out var values) && TryGetValue(values, parameter, out stringValue); + value = default; + return requestForm.TryGetValue(parameter.Name, out var values) && TryParse(values, parameter, out value); } - private static bool TryGetHeaderValue(IHeaderDictionary headers, Parameter parameter, out string? stringValue) + private static bool TryParseHeader(IHeaderDictionary headers, IParameter parameter, [NotNullWhen(true)] out T? value) + where T : struct, IJsonValue { - stringValue = null; + value = default; return headers.TryGetValue(parameter.Name, out var values) && - TryGetValue(values, parameter, out stringValue); + TryParse(values, parameter, out value); } - private static bool TryGetValue(StringValues values, Parameter parameter, out string? stringValue) + private static bool TryParse(StringValues values, IParameter parameter, [NotNullWhen(true)] out T? value) + where T : struct, IJsonValue { if (values.Count == 0) { - stringValue = null; + value = default; return false; } - stringValue = parameter.ValueIncludesKey + + var parser = GetParser(parameter); + var stringValue = parser.ValueIncludesParameterName ? string.Join('&', values.Select(value => $"{parameter.Name}=${value}")) : values.Single(); + + value = Parse(parser, stringValue); return true; } + + private static T Parse(IParameterValueParser parser, string? value) + where T : struct, IJsonValue + { + if (!parser.TryParse(value, out var instance, out var error)) + { + throw new BadHttpRequestException(error); + } + + return instance == null ? T.Null : T.Parse(instance.ToJsonString()); + } } #nullable restore """"); diff --git a/tests/Example.Api/Example.Api.csproj b/tests/Example.Api/Example.Api.csproj index f07125d..2c4be04 100644 --- a/tests/Example.Api/Example.Api.csproj +++ b/tests/Example.Api/Example.Api.csproj @@ -8,7 +8,7 @@ - + From aac323f28692bd4f9af3e07248b0d296fb3e2c58 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 13 Jan 2026 23:23:16 +0100 Subject: [PATCH 10/29] move openapi 2.0 example api --- OpenAPI.WebApiGenerator.sln | 4 ++-- README.md | 4 ++-- .../DeleteFooTests.cs | 0 .../Example.Api.IntegrationTests.csproj | 2 +- .../FooApplicationFactory.cs | 0 .../FooTestSpecification.cs | 0 .../Http/HttpContentExtensions.cs | 0 .../Json/JsonNodeExtensions.cs | 0 .../UpdateFooTests.cs | 0 .../Example.OpenApi20.csproj} | 1 + .../Paths/FooFooId/Delete/Operation.Handler.cs | 2 +- .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- tests/{Example.Api => Example.OpenApi20}/Program.cs | 2 +- .../Properties/launchSettings.json | 0 tests/{Example.Api => Example.OpenApi20}/api.json | 0 .../appsettings.Development.json | 0 tests/{Example.Api => Example.OpenApi20}/appsettings.json | 0 17 files changed, 9 insertions(+), 8 deletions(-) rename tests/{Example.Api.IntegrationTests => Example.OpenApi20.IntegrationTests}/DeleteFooTests.cs (100%) rename tests/{Example.Api.IntegrationTests => Example.OpenApi20.IntegrationTests}/Example.Api.IntegrationTests.csproj (93%) rename tests/{Example.Api.IntegrationTests => Example.OpenApi20.IntegrationTests}/FooApplicationFactory.cs (100%) rename tests/{Example.Api.IntegrationTests => Example.OpenApi20.IntegrationTests}/FooTestSpecification.cs (100%) rename tests/{Example.Api.IntegrationTests => Example.OpenApi20.IntegrationTests}/Http/HttpContentExtensions.cs (100%) rename tests/{Example.Api.IntegrationTests => Example.OpenApi20.IntegrationTests}/Json/JsonNodeExtensions.cs (100%) rename tests/{Example.Api.IntegrationTests => Example.OpenApi20.IntegrationTests}/UpdateFooTests.cs (100%) rename tests/{Example.Api/Example.Api.csproj => Example.OpenApi20/Example.OpenApi20.csproj} (92%) rename tests/{Example.Api => Example.OpenApi20}/Paths/FooFooId/Delete/Operation.Handler.cs (82%) rename tests/{Example.Api => Example.OpenApi20}/Paths/FooFooId/Put/Operation.Handler.cs (96%) rename tests/{Example.Api => Example.OpenApi20}/Program.cs (89%) rename tests/{Example.Api => Example.OpenApi20}/Properties/launchSettings.json (100%) rename tests/{Example.Api => Example.OpenApi20}/api.json (100%) rename tests/{Example.Api => Example.OpenApi20}/appsettings.Development.json (100%) rename tests/{Example.Api => Example.OpenApi20}/appsettings.json (100%) diff --git a/OpenAPI.WebApiGenerator.sln b/OpenAPI.WebApiGenerator.sln index b66a631..b2da1af 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -2,9 +2,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenAPI.WebApiGenerator", "src\OpenAPI.WebApiGenerator\OpenAPI.WebApiGenerator.csproj", "{E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.Api", "tests\Example.Api\Example.Api.csproj", "{790AE9B7-F3EA-459C-BAB2-D75E903D9B39}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi20", "tests\Example.OpenApi20\Example.OpenApi20.csproj", "{790AE9B7-F3EA-459C-BAB2-D75E903D9B39}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.Api.IntegrationTests", "tests\Example.Api.IntegrationTests\Example.Api.IntegrationTests.csproj", "{2A585540-1B80-4848-9A93-E0286758E2E0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi20.IntegrationTests", "tests\Example.OpenApi20.IntegrationTests\Example.OpenApi20.IntegrationTests.csproj", "{2A585540-1B80-4848-9A93-E0286758E2E0}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenAPI.WebApiGenerator.Tests", "tests\OpenAPI.WebApiGenerator.Tests\OpenAPI.WebApiGenerator.Tests.csproj", "{8044D11A-B0D2-400A-B2A1-8C50E396073A}" EndProject diff --git a/README.md b/README.md index e6e9776..3ad8b50 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ app.MapOperations(); app.Run(); ``` -See [Example.Api](tests/Example.Api) as an example. +See [Example.OpenApi20](tests/Example.OpenApi20) as an example. -**Note**: The Example.Api references the generator through a project reference. Use a package reference instead as described above. +**Note**: The Example.OpenApi20 references the generator through a project reference. Use a package reference instead as described above. ## Implementing an [API Operation](https://swagger.io/specification/#operation-object) The generator generates stubbed partial classes for any operation handlers (`Foo.Bar.Operation.Handler.cs`) if there are none existing in the project and logs it with a compiler warning (AF1001). The classes should be copied into source control and the operation methods implemented. The operation methods have a familiar request/response design: diff --git a/tests/Example.Api.IntegrationTests/DeleteFooTests.cs b/tests/Example.OpenApi20.IntegrationTests/DeleteFooTests.cs similarity index 100% rename from tests/Example.Api.IntegrationTests/DeleteFooTests.cs rename to tests/Example.OpenApi20.IntegrationTests/DeleteFooTests.cs diff --git a/tests/Example.Api.IntegrationTests/Example.Api.IntegrationTests.csproj b/tests/Example.OpenApi20.IntegrationTests/Example.Api.IntegrationTests.csproj similarity index 93% rename from tests/Example.Api.IntegrationTests/Example.Api.IntegrationTests.csproj rename to tests/Example.OpenApi20.IntegrationTests/Example.Api.IntegrationTests.csproj index 6f45236..7171e05 100644 --- a/tests/Example.Api.IntegrationTests/Example.Api.IntegrationTests.csproj +++ b/tests/Example.OpenApi20.IntegrationTests/Example.Api.IntegrationTests.csproj @@ -26,7 +26,7 @@ - + diff --git a/tests/Example.Api.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs similarity index 100% rename from tests/Example.Api.IntegrationTests/FooApplicationFactory.cs rename to tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs diff --git a/tests/Example.Api.IntegrationTests/FooTestSpecification.cs b/tests/Example.OpenApi20.IntegrationTests/FooTestSpecification.cs similarity index 100% rename from tests/Example.Api.IntegrationTests/FooTestSpecification.cs rename to tests/Example.OpenApi20.IntegrationTests/FooTestSpecification.cs diff --git a/tests/Example.Api.IntegrationTests/Http/HttpContentExtensions.cs b/tests/Example.OpenApi20.IntegrationTests/Http/HttpContentExtensions.cs similarity index 100% rename from tests/Example.Api.IntegrationTests/Http/HttpContentExtensions.cs rename to tests/Example.OpenApi20.IntegrationTests/Http/HttpContentExtensions.cs diff --git a/tests/Example.Api.IntegrationTests/Json/JsonNodeExtensions.cs b/tests/Example.OpenApi20.IntegrationTests/Json/JsonNodeExtensions.cs similarity index 100% rename from tests/Example.Api.IntegrationTests/Json/JsonNodeExtensions.cs rename to tests/Example.OpenApi20.IntegrationTests/Json/JsonNodeExtensions.cs diff --git a/tests/Example.Api.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs similarity index 100% rename from tests/Example.Api.IntegrationTests/UpdateFooTests.cs rename to tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs diff --git a/tests/Example.Api/Example.Api.csproj b/tests/Example.OpenApi20/Example.OpenApi20.csproj similarity index 92% rename from tests/Example.Api/Example.Api.csproj rename to tests/Example.OpenApi20/Example.OpenApi20.csproj index 2c4be04..1e1d32e 100644 --- a/tests/Example.Api/Example.Api.csproj +++ b/tests/Example.OpenApi20/Example.OpenApi20.csproj @@ -4,6 +4,7 @@ net9.0 enable enable + Example.OpenApi20 diff --git a/tests/Example.Api/Paths/FooFooId/Delete/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Delete/Operation.Handler.cs similarity index 82% rename from tests/Example.Api/Paths/FooFooId/Delete/Operation.Handler.cs rename to tests/Example.OpenApi20/Paths/FooFooId/Delete/Operation.Handler.cs index 02db5d2..b83f444 100644 --- a/tests/Example.Api/Paths/FooFooId/Delete/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Delete/Operation.Handler.cs @@ -1,4 +1,4 @@ -namespace Example.Api.Paths.FooFooId.Delete; +namespace Example.OpenApi20.Paths.FooFooId.Delete; internal partial class Operation { diff --git a/tests/Example.Api/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs similarity index 96% rename from tests/Example.Api/Paths/FooFooId/Put/Operation.Handler.cs rename to tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs index 85b74e0..a794991 100644 --- a/tests/Example.Api/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using Corvus.Json; -namespace Example.Api.Paths.FooFooId.Put; +namespace Example.OpenApi20.Paths.FooFooId.Put; internal partial class Operation { diff --git a/tests/Example.Api/Program.cs b/tests/Example.OpenApi20/Program.cs similarity index 89% rename from tests/Example.Api/Program.cs rename to tests/Example.OpenApi20/Program.cs index b8784b3..a45305d 100644 --- a/tests/Example.Api/Program.cs +++ b/tests/Example.OpenApi20/Program.cs @@ -1,4 +1,4 @@ -using Example.Api; +using Example.OpenApi20; var builder = WebApplication.CreateBuilder(args); builder.AddOperations(builder.Configuration.Get()); diff --git a/tests/Example.Api/Properties/launchSettings.json b/tests/Example.OpenApi20/Properties/launchSettings.json similarity index 100% rename from tests/Example.Api/Properties/launchSettings.json rename to tests/Example.OpenApi20/Properties/launchSettings.json diff --git a/tests/Example.Api/api.json b/tests/Example.OpenApi20/api.json similarity index 100% rename from tests/Example.Api/api.json rename to tests/Example.OpenApi20/api.json diff --git a/tests/Example.Api/appsettings.Development.json b/tests/Example.OpenApi20/appsettings.Development.json similarity index 100% rename from tests/Example.Api/appsettings.Development.json rename to tests/Example.OpenApi20/appsettings.Development.json diff --git a/tests/Example.Api/appsettings.json b/tests/Example.OpenApi20/appsettings.json similarity index 100% rename from tests/Example.Api/appsettings.json rename to tests/Example.OpenApi20/appsettings.json From f124d0ef5d6dfaa7e31046dda07b8416c380f6e6 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 13 Jan 2026 23:25:02 +0100 Subject: [PATCH 11/29] adjust namespaces --- tests/Example.OpenApi20.IntegrationTests/DeleteFooTests.cs | 2 +- ...sts.csproj => Example.OpenApi20.IntegrationTests.csproj} | 0 .../FooApplicationFactory.cs | 2 +- .../FooTestSpecification.cs | 2 +- .../Http/HttpContentExtensions.cs | 2 +- .../Json/JsonNodeExtensions.cs | 2 +- tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs | 6 +++--- 7 files changed, 8 insertions(+), 8 deletions(-) rename tests/Example.OpenApi20.IntegrationTests/{Example.Api.IntegrationTests.csproj => Example.OpenApi20.IntegrationTests.csproj} (100%) diff --git a/tests/Example.OpenApi20.IntegrationTests/DeleteFooTests.cs b/tests/Example.OpenApi20.IntegrationTests/DeleteFooTests.cs index 211da06..86d63e3 100644 --- a/tests/Example.OpenApi20.IntegrationTests/DeleteFooTests.cs +++ b/tests/Example.OpenApi20.IntegrationTests/DeleteFooTests.cs @@ -1,7 +1,7 @@ using System.Net; using AwesomeAssertions; -namespace Example.Api.IntegrationTests; +namespace Example.OpenApi20.IntegrationTests; public class DeleteFooTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture { diff --git a/tests/Example.OpenApi20.IntegrationTests/Example.Api.IntegrationTests.csproj b/tests/Example.OpenApi20.IntegrationTests/Example.OpenApi20.IntegrationTests.csproj similarity index 100% rename from tests/Example.OpenApi20.IntegrationTests/Example.Api.IntegrationTests.csproj rename to tests/Example.OpenApi20.IntegrationTests/Example.OpenApi20.IntegrationTests.csproj diff --git a/tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs index 61fade6..29594a4 100644 --- a/tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs +++ b/tests/Example.OpenApi20.IntegrationTests/FooApplicationFactory.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Testing; -namespace Example.Api.IntegrationTests; +namespace Example.OpenApi20.IntegrationTests; [UsedImplicitly] public class FooApplicationFactory : WebApplicationFactory; \ No newline at end of file diff --git a/tests/Example.OpenApi20.IntegrationTests/FooTestSpecification.cs b/tests/Example.OpenApi20.IntegrationTests/FooTestSpecification.cs index ab8693d..15eca21 100644 --- a/tests/Example.OpenApi20.IntegrationTests/FooTestSpecification.cs +++ b/tests/Example.OpenApi20.IntegrationTests/FooTestSpecification.cs @@ -1,6 +1,6 @@ using System.Text; -namespace Example.Api.IntegrationTests; +namespace Example.OpenApi20.IntegrationTests; public abstract class FooTestSpecification { diff --git a/tests/Example.OpenApi20.IntegrationTests/Http/HttpContentExtensions.cs b/tests/Example.OpenApi20.IntegrationTests/Http/HttpContentExtensions.cs index 226419e..106567e 100644 --- a/tests/Example.OpenApi20.IntegrationTests/Http/HttpContentExtensions.cs +++ b/tests/Example.OpenApi20.IntegrationTests/Http/HttpContentExtensions.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; -namespace Example.Api.IntegrationTests.Http; +namespace Example.OpenApi20.IntegrationTests.Http; internal static class HttpContentExtensions { diff --git a/tests/Example.OpenApi20.IntegrationTests/Json/JsonNodeExtensions.cs b/tests/Example.OpenApi20.IntegrationTests/Json/JsonNodeExtensions.cs index b36f928..d13a357 100644 --- a/tests/Example.OpenApi20.IntegrationTests/Json/JsonNodeExtensions.cs +++ b/tests/Example.OpenApi20.IntegrationTests/Json/JsonNodeExtensions.cs @@ -2,7 +2,7 @@ using AwesomeAssertions; using Json.Pointer; -namespace Example.Api.IntegrationTests.Json; +namespace Example.OpenApi20.IntegrationTests.Json; internal static class JsonNodeExtensions { diff --git a/tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs index 775405f..ca1d4d7 100644 --- a/tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs +++ b/tests/Example.OpenApi20.IntegrationTests/UpdateFooTests.cs @@ -1,10 +1,10 @@ using System.Net; using System.Net.Http.Headers; using AwesomeAssertions; -using Example.Api.IntegrationTests.Http; -using Example.Api.IntegrationTests.Json; +using Example.OpenApi20.IntegrationTests.Http; +using Example.OpenApi20.IntegrationTests.Json; -namespace Example.Api.IntegrationTests; +namespace Example.OpenApi20.IntegrationTests; public class UpdateFooTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture { From 72e59ed11ee04a5c63ae58034c93ab34b8c3985a Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 13 Jan 2026 23:38:20 +0100 Subject: [PATCH 12/29] test(openapi30): add example api and integration tests --- OpenAPI.WebApiGenerator.sln | 12 ++ .../Example.OpenApi20.csproj | 2 +- .../{api.json => openapi.json} | 0 .../DeleteFooTests.cs | 24 +++ .../Example.OpenApi30.IntegrationTests.csproj | 32 ++++ .../FooApplicationFactory.cs | 7 + .../FooTestSpecification.cs | 13 ++ .../Http/HttpContentExtensions.cs | 14 ++ .../Json/JsonNodeExtensions.cs | 23 +++ .../UpdateFooTests.cs | 67 ++++++++ .../Example.OpenApi30.csproj | 22 +++ .../FooFooId/Delete/Operation.Handler.cs | 10 ++ .../Paths/FooFooId/Put/Operation.Handler.cs | 39 +++++ tests/Example.OpenApi30/Program.cs | 9 ++ .../appsettings.Development.json | 8 + tests/Example.OpenApi30/appsettings.json | 10 ++ tests/Example.OpenApi30/openapi.json | 146 ++++++++++++++++++ 17 files changed, 437 insertions(+), 1 deletion(-) rename tests/Example.OpenApi20/{api.json => openapi.json} (100%) create mode 100644 tests/Example.OpenApi30.IntegrationTests/DeleteFooTests.cs create mode 100644 tests/Example.OpenApi30.IntegrationTests/Example.OpenApi30.IntegrationTests.csproj create mode 100644 tests/Example.OpenApi30.IntegrationTests/FooApplicationFactory.cs create mode 100644 tests/Example.OpenApi30.IntegrationTests/FooTestSpecification.cs create mode 100644 tests/Example.OpenApi30.IntegrationTests/Http/HttpContentExtensions.cs create mode 100644 tests/Example.OpenApi30.IntegrationTests/Json/JsonNodeExtensions.cs create mode 100644 tests/Example.OpenApi30.IntegrationTests/UpdateFooTests.cs create mode 100644 tests/Example.OpenApi30/Example.OpenApi30.csproj create mode 100644 tests/Example.OpenApi30/Paths/FooFooId/Delete/Operation.Handler.cs create mode 100644 tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs create mode 100644 tests/Example.OpenApi30/Program.cs create mode 100644 tests/Example.OpenApi30/appsettings.Development.json create mode 100644 tests/Example.OpenApi30/appsettings.json create mode 100644 tests/Example.OpenApi30/openapi.json diff --git a/OpenAPI.WebApiGenerator.sln b/OpenAPI.WebApiGenerator.sln index b2da1af..cf8f675 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -8,6 +8,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi20.Integrati EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenAPI.WebApiGenerator.Tests", "tests\OpenAPI.WebApiGenerator.Tests\OpenAPI.WebApiGenerator.Tests.csproj", "{8044D11A-B0D2-400A-B2A1-8C50E396073A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi30", "tests\Example.OpenApi30\Example.OpenApi30.csproj", "{B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi30.IntegrationTests", "tests\Example.OpenApi30.IntegrationTests\Example.OpenApi30.IntegrationTests.csproj", "{C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{F4FDC271-0CCB-4171-87B8-937A6E1CBF9A}" ProjectSection(SolutionItems) = preProject README.md = README.md @@ -37,5 +41,13 @@ Global {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Debug|Any CPU.Build.0 = Debug|Any CPU {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Release|Any CPU.ActiveCfg = Release|Any CPU {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Release|Any CPU.Build.0 = Release|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/tests/Example.OpenApi20/Example.OpenApi20.csproj b/tests/Example.OpenApi20/Example.OpenApi20.csproj index 1e1d32e..0335268 100644 --- a/tests/Example.OpenApi20/Example.OpenApi20.csproj +++ b/tests/Example.OpenApi20/Example.OpenApi20.csproj @@ -16,7 +16,7 @@ - + diff --git a/tests/Example.OpenApi20/api.json b/tests/Example.OpenApi20/openapi.json similarity index 100% rename from tests/Example.OpenApi20/api.json rename to tests/Example.OpenApi20/openapi.json diff --git a/tests/Example.OpenApi30.IntegrationTests/DeleteFooTests.cs b/tests/Example.OpenApi30.IntegrationTests/DeleteFooTests.cs new file mode 100644 index 0000000..76f16d3 --- /dev/null +++ b/tests/Example.OpenApi30.IntegrationTests/DeleteFooTests.cs @@ -0,0 +1,24 @@ +using System.Net; +using AwesomeAssertions; + +namespace Example.OpenApi30.IntegrationTests; + +public class DeleteFooTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture +{ + [Fact] + public async Task When_Deleting_Foo_It_Should_Return_Ok() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("DELETE") + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await result.Content.ReadAsByteArrayAsync(CancellationToken); + responseContent.Should().BeEmpty(); + result.Content.Headers.ContentType.Should().BeNull(); + + result.Headers.Should().BeEmpty(); + } +} diff --git a/tests/Example.OpenApi30.IntegrationTests/Example.OpenApi30.IntegrationTests.csproj b/tests/Example.OpenApi30.IntegrationTests/Example.OpenApi30.IntegrationTests.csproj new file mode 100644 index 0000000..ba609f5 --- /dev/null +++ b/tests/Example.OpenApi30.IntegrationTests/Example.OpenApi30.IntegrationTests.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/Example.OpenApi30.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi30.IntegrationTests/FooApplicationFactory.cs new file mode 100644 index 0000000..7421815 --- /dev/null +++ b/tests/Example.OpenApi30.IntegrationTests/FooApplicationFactory.cs @@ -0,0 +1,7 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Example.OpenApi30.IntegrationTests; + +[UsedImplicitly] +public class FooApplicationFactory : WebApplicationFactory; diff --git a/tests/Example.OpenApi30.IntegrationTests/FooTestSpecification.cs b/tests/Example.OpenApi30.IntegrationTests/FooTestSpecification.cs new file mode 100644 index 0000000..d248b34 --- /dev/null +++ b/tests/Example.OpenApi30.IntegrationTests/FooTestSpecification.cs @@ -0,0 +1,13 @@ +using System.Text; + +namespace Example.OpenApi30.IntegrationTests; + +public abstract class FooTestSpecification +{ + protected CancellationToken CancellationToken { get; } = TestContext.Current.CancellationToken; + + protected HttpContent CreateJsonContent(string json) => new StringContent( + json, + encoding: Encoding.UTF8, + mediaType: "application/json"); +} diff --git a/tests/Example.OpenApi30.IntegrationTests/Http/HttpContentExtensions.cs b/tests/Example.OpenApi30.IntegrationTests/Http/HttpContentExtensions.cs new file mode 100644 index 0000000..0cab390 --- /dev/null +++ b/tests/Example.OpenApi30.IntegrationTests/Http/HttpContentExtensions.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Nodes; + +namespace Example.OpenApi30.IntegrationTests.Http; + +internal static class HttpContentExtensions +{ + internal static async Task ReadAsJsonNodeAsync(this HttpContent content, + CancellationToken cancellationToken) => + await JsonNode.ParseAsync( + await content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false), + cancellationToken: cancellationToken) + .ConfigureAwait(false); +} diff --git a/tests/Example.OpenApi30.IntegrationTests/Json/JsonNodeExtensions.cs b/tests/Example.OpenApi30.IntegrationTests/Json/JsonNodeExtensions.cs new file mode 100644 index 0000000..05b2100 --- /dev/null +++ b/tests/Example.OpenApi30.IntegrationTests/Json/JsonNodeExtensions.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Nodes; +using AwesomeAssertions; +using Json.Pointer; + +namespace Example.OpenApi30.IntegrationTests.Json; + +internal static class JsonNodeExtensions +{ + internal static JsonNode Evaluate(this JsonNode? node, string path) + { + JsonPointer.Parse(path).TryEvaluate(node, out var value).Should() + .BeTrue($"because the json node should contain the property {path}"); + value.Should().NotBeNull($"because the property {path} should not be null"); + return value!; + } + + internal static T GetValue(this JsonNode? node, string path) + { + var value = node.Evaluate(path); + return value.GetValue(); + } + +} diff --git a/tests/Example.OpenApi30.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi30.IntegrationTests/UpdateFooTests.cs new file mode 100644 index 0000000..29fed4b --- /dev/null +++ b/tests/Example.OpenApi30.IntegrationTests/UpdateFooTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Net.Http.Headers; +using AwesomeAssertions; +using Example.OpenApi30.IntegrationTests.Http; +using Example.OpenApi30.IntegrationTests.Json; + +namespace Example.OpenApi30.IntegrationTests; + +public class UpdateFooTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture +{ + [Fact] + public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await result.Content.ReadAsJsonNodeAsync(CancellationToken); + responseContent.Should().NotBeNull(); + responseContent.GetValue("#/Name").Should().Be("test"); + result.Headers.Should().HaveCount(1); + result.Headers.Should().ContainKey("Status") + .WhoseValue.Should().HaveCount(1) + .And.Contain("2"); + result.Content.Headers.ContentType.Should().Be(MediaTypeHeaderValue.Parse("application/json")); + } + + [Fact] + public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/test"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var responseContent = await result.Content.ReadAsJsonNodeAsync(CancellationToken); + responseContent.Should().NotBeNull(); + responseContent.AsArray().Should().HaveCount(1); + responseContent.GetValue("#/0/error").Should().NotBeNullOrEmpty(); + responseContent.GetValue("#/0/name").Should().Be("https://localhost/api.json#/components/parameters/FooId/schema/type"); + } +} diff --git a/tests/Example.OpenApi30/Example.OpenApi30.csproj b/tests/Example.OpenApi30/Example.OpenApi30.csproj new file mode 100644 index 0000000..e654656 --- /dev/null +++ b/tests/Example.OpenApi30/Example.OpenApi30.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + Example.OpenApi30 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Example.OpenApi30/Paths/FooFooId/Delete/Operation.Handler.cs b/tests/Example.OpenApi30/Paths/FooFooId/Delete/Operation.Handler.cs new file mode 100644 index 0000000..3028b19 --- /dev/null +++ b/tests/Example.OpenApi30/Paths/FooFooId/Delete/Operation.Handler.cs @@ -0,0 +1,10 @@ +namespace Example.OpenApi30.Paths.FooFooId.Delete; + +internal partial class Operation +{ + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + { + var response = new Response.OK200(); + return Task.FromResult(response); + } +} diff --git a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs new file mode 100644 index 0000000..b64c999 --- /dev/null +++ b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs @@ -0,0 +1,39 @@ +using System.Collections.Immutable; +using Corvus.Json; + +namespace Example.OpenApi30.Paths.FooFooId.Put; + +internal partial class Operation +{ + public Operation() + { + HandleValidationError = HandleValidationErrors; + } + + private static Response HandleValidationErrors(ImmutableList validationResults) + { + var response = validationResults.Select(result => + Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( + name: result.Location?.SchemaLocation.ToString() ?? string.Empty, + error: result.Message ?? string.Empty)); + return new Response.BadRequest400( + Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + } + + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + { + _ = request.Query.Fee; + _ = request.Path.FooId; + _ = request.Header.Bar; + + var response = new Response.OK200(Components.Schemas.FooProperties.Create( + name: request.Body.ApplicationJson?.Name)) + { + Headers = new Response.OK200.ResponseHeaders + { + Status = 2 + } + }; + return Task.FromResult(response); + } +} diff --git a/tests/Example.OpenApi30/Program.cs b/tests/Example.OpenApi30/Program.cs new file mode 100644 index 0000000..bd9e298 --- /dev/null +++ b/tests/Example.OpenApi30/Program.cs @@ -0,0 +1,9 @@ +using Example.OpenApi30; + +var builder = WebApplication.CreateBuilder(args); +builder.AddOperations(builder.Configuration.Get()); +var app = builder.Build(); +app.MapOperations(); +app.Run(); + +public abstract partial class Program; \ No newline at end of file diff --git a/tests/Example.OpenApi30/appsettings.Development.json b/tests/Example.OpenApi30/appsettings.Development.json new file mode 100644 index 0000000..1b2d3ba --- /dev/null +++ b/tests/Example.OpenApi30/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi30/appsettings.json b/tests/Example.OpenApi30/appsettings.json new file mode 100644 index 0000000..1fdceaf --- /dev/null +++ b/tests/Example.OpenApi30/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "OpenApiSpecificationUri": "https://localhost/api.json" +} \ No newline at end of file diff --git a/tests/Example.OpenApi30/openapi.json b/tests/Example.OpenApi30/openapi.json new file mode 100644 index 0000000..b0d2923 --- /dev/null +++ b/tests/Example.OpenApi30/openapi.json @@ -0,0 +1,146 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Example API", + "version": "2025-11-05" + }, + "paths": { + "/foo/{FooId}": { + "put": { + "operationId": "Update_Foo", + "parameters": [ + { + "$ref": "#/components/parameters/Bar" + }, + { + "name": "Fee", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "required": false, + "style": "form", + "explode": true + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/FooProperties" + }, + "responses": { + "200": { + "description": "Successfully updated", + "headers": { + "Status": { + "description": "The Status of foo", + "schema": { + "type": "integer" + } + }, + "Tag": { + "description": "An optional tag", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "delete": { + "operationId": "Delete_Foo", + "responses": { + "200": { + "description": "Successfully deleted" + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/FooId" + } + ] + } + }, + "components": { + "schemas": { + "FooProperties": { + "description": "Foo properties.", + "type": "object", + "properties": { + "Name": { + "description": "Name of foo", + "type": "string" + } + } + } + }, + "parameters": { + "Bar": { + "name": "Bar", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + "FooId": { + "name": "FooId", + "in": "path", + "schema": { + "type": "integer" + }, + "required": true + } + }, + "requestBodies": { + "FooProperties": { + "description": "Foo properties.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Returned when the request has validation errors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["name", "error"] + } + } + } + } + } + } + } +} \ No newline at end of file From 88f294600a07f6bdab818e3d6096b200cbccf67d Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 14 Jan 2026 22:02:07 +0100 Subject: [PATCH 13/29] generate parameter specification for correct openapi spec version --- .../HttpRequestExtensionsGenerator.cs | 23 +++++++++++++-- .../CodeGeneration/ParameterGenerator.cs | 29 +++++-------------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index 4ca0516..685df09 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using Microsoft.OpenApi; namespace OpenAPI.WebApiGenerator.CodeGeneration; @@ -14,21 +15,37 @@ internal sealed class HttpRequestExtensionsGenerator( OpenApiSpecVersion.OpenApi2_0 => "2.0", OpenApiSpecVersion.OpenApi3_0 => "3.0", OpenApiSpecVersion.OpenApi3_1 => "3.1", - _ => throw new ArgumentOutOfRangeException(nameof(openApiVersion), openApiVersion, "Unknown OpenAPI version") + _ => throw new NotSupportedException($"OpenAPI version {Enum.GetName(typeof(OpenApiSpecVersion), openApiVersion)} not supported") }; internal string CreateBindParameterInvocation( string requestVariableName, string bindingTypeName, - string parameterSpecificationAsJson) + IOpenApiParameter parameter) { + using var textWriter = new StringWriter(); + var jsonWriter = new OpenApiJsonWriter(textWriter, new OpenApiJsonWriterSettings() + { + InlineLocalReferences = true + }); + Action serialize = openApiVersion switch + { + OpenApiSpecVersion.OpenApi3_1 => parameter.SerializeAsV31, + OpenApiSpecVersion.OpenApi3_0 => parameter.SerializeAsV3, + OpenApiSpecVersion.OpenApi2_0 => parameter.SerializeAsV2, + _ => throw new NotSupportedException( + $"OpenAPI version {Enum.GetName(typeof(OpenApiSpecVersion), openApiVersion)} not supported") + }; + serialize(jsonWriter); + textWriter.Flush(); + return $"""" {@namespace}.{HttpRequestExtensionsClassName}.Bind<{bindingTypeName}>( {requestVariableName}, "{_openApiVersion}", """ - {parameterSpecificationAsJson} + {textWriter.GetStringBuilder()} """) """"; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs index 68f2410..d64237e 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs @@ -1,5 +1,4 @@ -using System.IO; -using Corvus.Json.CodeGeneration; +using Corvus.Json.CodeGeneration; using Corvus.Json.CodeGeneration.CSharp; using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; @@ -21,29 +20,17 @@ internal sealed class ParameterGenerator( internal bool IsParameterRequired { get; } = parameter.Required; internal string Location { get; } = parameter.GetLocation(); - internal string GenerateRequestProperty() - { - return $$""" - internal {{(IsParameterRequired ? "required " : "")}}{{FullyQualifiedTypeName}} {{PropertyName}} { get; init; } - """; - } + internal string GenerateRequestProperty() => + $$""" + internal {{(IsParameterRequired ? "required " : "")}}{{FullyQualifiedTypeName}} {{PropertyName}} { get; init; } + """; internal string AsRequired(string variableName) => $"{variableName}{(IsParameterRequired ? "" : $" ?? {FullyQualifiedTypeDeclarationIdentifier}.Undefined")}"; - internal string GenerateRequestBindingDirective(string requestVariableName) - { - using var textWriter = new StringWriter(); - var jsonWriter = new OpenApiJsonWriter(textWriter, new OpenApiJsonWriterSettings() - { - InlineLocalReferences = true - }); - parameter.SerializeAsV2(jsonWriter); - textWriter.Flush(); - - return $"{PropertyName} = {httpRequestExtensionsGenerator.CreateBindParameterInvocation( + internal string GenerateRequestBindingDirective(string requestVariableName) => + $"{PropertyName} = {httpRequestExtensionsGenerator.CreateBindParameterInvocation( requestVariableName, FullyQualifiedTypeDeclarationIdentifier, - textWriter.GetStringBuilder().ToString()) + parameter) .Indent(4).TrimStart()}{(IsParameterRequired ? "" : ".AsOptional()")},"; - } } \ No newline at end of file From acf9527f118e28f6ac823dfb9d259cbe40f8ef92 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 15 Jan 2026 00:27:45 +0100 Subject: [PATCH 14/29] fix(parameter): remove invalid $ character in value --- .../CodeGeneration/HttpRequestExtensionsGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index 685df09..7d25231 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs @@ -181,7 +181,7 @@ private static bool TryParse(StringValues values, IParameter parameter, [NotN var parser = GetParser(parameter); var stringValue = parser.ValueIncludesParameterName - ? string.Join('&', values.Select(value => $"{parameter.Name}=${value}")) + ? string.Join('&', values.Select(value => $"{parameter.Name}={value}")) : values.Single(); value = Parse(parser, stringValue); From ea938cd9e74668b14057505d9129cd59d0a9270f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 15 Jan 2026 22:47:28 +0100 Subject: [PATCH 15/29] fix(validation): push schema location explicitly to set proper location on primitive json types --- .../CodeGeneration/ParameterGenerator.cs | 1 + .../RequestBodyContentGenerator.cs | 3 ++- .../CodeGeneration/RequestBodyGenerator.cs | 2 +- .../CodeGeneration/RequestGenerator.cs | 18 ++++-------------- .../ValidationExtensionsGenerator.cs | 19 ++++++++++++++++++- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs index d64237e..6bc3992 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ParameterGenerator.cs @@ -19,6 +19,7 @@ internal sealed class ParameterGenerator( internal string PropertyName { get; } = parameter.GetName().ToPascalCase(); internal bool IsParameterRequired { get; } = parameter.Required; internal string Location { get; } = parameter.GetLocation(); + internal string SchemaLocation { get; } = typeDeclaration.RelativeSchemaLocation; internal string GenerateRequestProperty() => $$""" diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index a28ea7e..9794196 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -17,7 +17,8 @@ internal sealed class RequestBodyContentGenerator( internal string PropertyName { get; } = contentType.ToPascalCase(); internal string ContentType => contentType; - + + internal string SchemaLocation => typeDeclaration.RelativeSchemaLocation; internal string GenerateRequestBindingDirective() => $""" {PropertyName} = diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs index f87f362..40588a4 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs @@ -100,7 +100,7 @@ internal ValidationContext Validate(ValidationContext validationContext, Validat {{{_contentGenerators.AggregateToString(content => $""" case true when {content.PropertyName} is not null: - return {content.PropertyName}!.Value.Validate(validationContext, validationLevel); + return {content.PropertyName}!.Value.Validate("{content.SchemaLocation}", true, validationContext, validationLevel); """)}} default: {{(_body.Required ? diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs index 1e68965..db72992 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestGenerator.cs @@ -61,24 +61,14 @@ internal partial class Request internal ValidationContext Validate(ValidationLevel validationLevel) { - var validationContext = ValidationContext.ValidContext;{{ + var validationContext = ValidationContext.ValidContext.UsingStack().UsingResults();{{ bodyGenerator.GenerateValidateDirective("Body", "validationContext", "validationLevel").Indent(8) }}{{_parameterGeneratorsGroupedByLocation.AggregateToString(group => group.AggregateToString(generator => - $"validationContext = Validate({group.Key}.{generator.AsRequired(generator.PropertyName)}, {generator.IsParameterRequired.ToString().ToLowerInvariant()});").Trim()).Indent(8)}} + $""" + validationContext = ({group.Key}.{generator.AsRequired(generator.PropertyName)}).Validate("{generator.SchemaLocation}", {generator.IsParameterRequired.ToString().ToLowerInvariant()}, validationContext, validationLevel); + """).Trim()).Indent(8)}} return validationContext; - - ValidationContext Validate(T value, - bool isRequired) - where T : struct, IJsonValue - { - if (!isRequired && value.IsUndefined()) - { - return validationContext; - } - - return value.Validate(validationContext, validationLevel); - } }{{_parameterGeneratorsGroupedByLocation.AggregateToString(group => $$""" internal sealed class {{group.Key}}Parameters diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ValidationExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ValidationExtensionsGenerator.cs index 3a060cc..3a6871a 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ValidationExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ValidationExtensionsGenerator.cs @@ -2,7 +2,7 @@ internal sealed class ValidationExtensionsGenerator(string @namespace) { - private const string ClassName = "ValidationResultsExtensions"; + private const string ClassName = "ValidationExtensions"; internal SourceCode GenerateClass() => new($"{ClassName}.g.cs", $$""" #nullable enable @@ -35,6 +35,23 @@ private static (JsonReference ValidationLocation, JsonReference SchemaLocation, var schemaLocation = new JsonReference(uri.AsSpan(), location.Value.SchemaLocation.Fragment); return (location.Value.ValidationLocation, schemaLocation, location.Value.DocumentLocation); } + + internal static ValidationContext Validate(this T value, + string schemaLocation, + bool isRequired, + ValidationContext validationContext, + ValidationLevel validationLevel) + where T : struct, IJsonValue + { + if (!isRequired && value.IsUndefined()) + { + return validationContext; + } + + var context = validationContext.PushSchemaLocation(schemaLocation); + context = value.Validate(context, validationLevel); + return context.PopLocation(); + } } #nullable restore """); From 7f431e77148a712c7baf06d8d2facb289f918538 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 15 Jan 2026 23:17:20 +0100 Subject: [PATCH 16/29] fix(response): write response header specifications using the correct format --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 2 +- .../HttpResponseExtensionsGenerator.cs | 49 ++++++++++++------- .../CodeGeneration/ResponseHeaderGenerator.cs | 38 +++++--------- .../Extensions/OpenApiSchemaExtensions.cs | 21 -------- 4 files changed, 45 insertions(+), 65 deletions(-) delete mode 100644 src/OpenAPI.WebApiGenerator/Extensions/OpenApiSchemaExtensions.cs diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 9fa9192..f237204 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -88,7 +88,7 @@ private static void GenerateCode(SourceProductionContext context, ( rootNamespace); httpRequestExtensionsGenerator.GenerateHttpRequestExtensionsClass().AddTo(context); - var httpResponseExtensionsGenerator = new HttpResponseExtensionsGenerator(rootNamespace); + var httpResponseExtensionsGenerator = new HttpResponseExtensionsGenerator(openApiVersion, rootNamespace); httpResponseExtensionsGenerator.GenerateHttpResponseExtensionsClass().AddTo(context); var apiConfigurationGenerator = new ApiConfigurationGenerator(rootNamespace); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs index ba484fc..7862018 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs @@ -1,30 +1,45 @@ -using OpenAPI.WebApiGenerator.Extensions; +using System; +using System.IO; +using Microsoft.OpenApi; +using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; internal sealed class HttpResponseExtensionsGenerator( + OpenApiSpecVersion openApiVersion, string @namespace) { private const string HttpResponseExtensionsClassName = "HttpResponseExtensions"; public string Namespace => @namespace; - internal string CreateWriteHeaderInvocation( - string responseVariableName, - string headerSpecificationAsJson, - string headerName, - string headerValueVariableName, - bool isRequired) + internal string GetResponseHeaderSpecificationAsJson( + IOpenApiHeader header, + string name) { - return - $"""" - {responseVariableName}.WriteResponseHeader( - """ - {headerSpecificationAsJson.Indent(4)} - """, - "{headerName}", - {headerValueVariableName}, - {isRequired.ToString().ToLowerInvariant()}) - """"; + using var textWriter = new StringWriter(); + var jsonWriter = new OpenApiJsonWriter(textWriter, new OpenApiJsonWriterSettings + { + InlineLocalReferences = true + }); + Action serialize = openApiVersion switch + { + OpenApiSpecVersion.OpenApi3_1 => header.SerializeAsV31, + OpenApiSpecVersion.OpenApi3_0 => header.SerializeAsV3, + OpenApiSpecVersion.OpenApi2_0 => header.SerializeAsV2, + _ => throw new NotSupportedException( + $"OpenAPI version {Enum.GetName(typeof(OpenApiSpecVersion), openApiVersion)} not supported") + }; + serialize(jsonWriter); + textWriter.Flush(); + + // Response header specification is a subset of the parameter specification, so we add the missing properties to be able to use the parameter value parser + return + $$""" + { + "name": "{{name}}", + "in": "header", + {{textWriter.GetStringBuilder().ToString().TrimStart('{').TrimStart()}} + """; } internal string CreateWriteBodyInvocation( diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs index e03728b..d80fcab 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs @@ -1,6 +1,4 @@ -using System.IO; -using System.Linq; -using Corvus.Json.CodeGeneration; +using Corvus.Json.CodeGeneration; using Corvus.Json.CodeGeneration.CSharp; using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; @@ -29,28 +27,16 @@ internal string GenerateProperty() => internal string GenerateWriteDirective(string responseVariableName) { - using var textWriter = new StringWriter(); - var jsonWriter = new OpenApiJsonWriter(textWriter, new OpenApiJsonWriterSettings - { - InlineLocalReferences = true - }); - header.SerializeAsV2(jsonWriter); - textWriter.Flush(); - - // Response header specification is a subset of the parameter specification, so we add the missing properties to be able to use the parameter value parser - var headerSpecificationAsJson = - $$""" - { - "name": "{{name}}", - "in": "header", - {{textWriter.GetStringBuilder().ToString().TrimStart('{').TrimStart()}} - """; - - return $"{httpResponseExtensionsGenerator.CreateWriteHeaderInvocation( - responseVariableName, - headerSpecificationAsJson, - name, - $"Headers.{_propertyName}", - header.Required)};"; + var headerSpecificationAsJson = httpResponseExtensionsGenerator.GetResponseHeaderSpecificationAsJson(header, name); + return + $"""" + {responseVariableName}.WriteResponseHeader( + """ + {headerSpecificationAsJson.Indent(4).TrimStart()} + """, + "{name}", + Headers.{_propertyName}, + {header.Required.ToString().ToLowerInvariant()}); + """"; } } diff --git a/src/OpenAPI.WebApiGenerator/Extensions/OpenApiSchemaExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/OpenApiSchemaExtensions.cs deleted file mode 100644 index 7200644..0000000 --- a/src/OpenAPI.WebApiGenerator/Extensions/OpenApiSchemaExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.IO; -using Microsoft.OpenApi; - -namespace OpenAPI.WebApiGenerator.Extensions; - -internal static class OpenApiSchemaExtensions -{ - internal static string SerializeToJson(this IOpenApiSchema? schema) - { - if (schema is null) - return "{}"; - - using var schemaWriter = new StringWriter(); - var openApiSchemaWriter = new OpenApiJsonWriter(schemaWriter, new OpenApiWriterSettings - { - InlineLocalReferences = true - }); - schema.SerializeAsV2(openApiSchemaWriter); - return schemaWriter.ToString(); - } -} \ No newline at end of file From 01ac4be034e3e9f7520a7a6453d67c318ee5f3c1 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 15 Jan 2026 23:58:49 +0100 Subject: [PATCH 17/29] fix(response): use the proper serializer --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 7 +- .../HttpRequestExtensionsGenerator.cs | 48 +++--------- .../HttpResponseExtensionsGenerator.cs | 75 +++++++------------ .../ResponseContentGenerator.cs | 10 +-- .../CodeGeneration/ResponseGenerator.cs | 11 ++- .../CodeGeneration/ResponseHeaderGenerator.cs | 1 + .../OpenApi/OpenApiVersionExtensions.cs | 39 ++++++++++ 7 files changed, 94 insertions(+), 97 deletions(-) create mode 100644 src/OpenAPI.WebApiGenerator/OpenApi/OpenApiVersionExtensions.cs diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index f237204..3479e07 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -195,12 +195,13 @@ private static void GenerateCode(SourceProductionContext context, ( return new ResponseContentGenerator( responseStatusCodePattern, responseBodyGenerators, - responseHeaderGenerators, - httpResponseExtensionsGenerator); + responseHeaderGenerators); }).ToList(); var responseGenerator = new ResponseGenerator( - responseBodyGenerators, httpResponseExtensionsGenerator); + responseBodyGenerators, + httpResponseExtensionsGenerator, + openApiVersion); var responseSourceCode = responseGenerator.GenerateResponseClass( operationNamespace, diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index 7d25231..bfcf076 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs @@ -1,6 +1,7 @@ using System; using System.IO; using Microsoft.OpenApi; +using OpenAPI.WebApiGenerator.OpenApi; namespace OpenAPI.WebApiGenerator.CodeGeneration; @@ -10,46 +11,21 @@ internal sealed class HttpRequestExtensionsGenerator( { private const string HttpRequestExtensionsClassName = "HttpRequestExtensions"; - private readonly string _openApiVersion = openApiVersion switch - { - OpenApiSpecVersion.OpenApi2_0 => "2.0", - OpenApiSpecVersion.OpenApi3_0 => "3.0", - OpenApiSpecVersion.OpenApi3_1 => "3.1", - _ => throw new NotSupportedException($"OpenAPI version {Enum.GetName(typeof(OpenApiSpecVersion), openApiVersion)} not supported") - }; + private readonly string _openApiVersion = openApiVersion.GetParameterVersion(); internal string CreateBindParameterInvocation( string requestVariableName, string bindingTypeName, - IOpenApiParameter parameter) - { - using var textWriter = new StringWriter(); - var jsonWriter = new OpenApiJsonWriter(textWriter, new OpenApiJsonWriterSettings() - { - InlineLocalReferences = true - }); - Action serialize = openApiVersion switch - { - OpenApiSpecVersion.OpenApi3_1 => parameter.SerializeAsV31, - OpenApiSpecVersion.OpenApi3_0 => parameter.SerializeAsV3, - OpenApiSpecVersion.OpenApi2_0 => parameter.SerializeAsV2, - _ => throw new NotSupportedException( - $"OpenAPI version {Enum.GetName(typeof(OpenApiSpecVersion), openApiVersion)} not supported") - }; - serialize(jsonWriter); - textWriter.Flush(); - - return - $"""" - {@namespace}.{HttpRequestExtensionsClassName}.Bind<{bindingTypeName}>( - {requestVariableName}, - "{_openApiVersion}", - """ - {textWriter.GetStringBuilder()} - """) - """"; - } - + IOpenApiParameter parameter) => + $"""" + {@namespace}.{HttpRequestExtensionsClassName}.Bind<{bindingTypeName}>( + {requestVariableName}, + "{_openApiVersion}", + """ + {parameter.Serialize(openApiVersion)} + """) + """"; + internal string CreateBindBodyInvocation( string requestVariableName, string bindingTypeName) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs index 7862018..deab313 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs @@ -1,7 +1,5 @@ -using System; -using System.IO; -using Microsoft.OpenApi; -using OpenAPI.WebApiGenerator.Extensions; +using Microsoft.OpenApi; +using OpenAPI.WebApiGenerator.OpenApi; namespace OpenAPI.WebApiGenerator.CodeGeneration; @@ -11,47 +9,25 @@ internal sealed class HttpResponseExtensionsGenerator( { private const string HttpResponseExtensionsClassName = "HttpResponseExtensions"; public string Namespace => @namespace; - + internal string GetResponseHeaderSpecificationAsJson( IOpenApiHeader header, - string name) - { - using var textWriter = new StringWriter(); - var jsonWriter = new OpenApiJsonWriter(textWriter, new OpenApiJsonWriterSettings - { - InlineLocalReferences = true - }); - Action serialize = openApiVersion switch - { - OpenApiSpecVersion.OpenApi3_1 => header.SerializeAsV31, - OpenApiSpecVersion.OpenApi3_0 => header.SerializeAsV3, - OpenApiSpecVersion.OpenApi2_0 => header.SerializeAsV2, - _ => throw new NotSupportedException( - $"OpenAPI version {Enum.GetName(typeof(OpenApiSpecVersion), openApiVersion)} not supported") - }; - serialize(jsonWriter); - textWriter.Flush(); - + string name) => // Response header specification is a subset of the parameter specification, so we add the missing properties to be able to use the parameter value parser - return - $$""" - { - "name": "{{name}}", - "in": "header", - {{textWriter.GetStringBuilder().ToString().TrimStart('{').TrimStart()}} - """; - } - - internal string CreateWriteBodyInvocation( + $$""" + { + "name": "{{name}}", + "in": "header", + {{header.Serialize(openApiVersion).ToString().TrimStart('{').TrimStart()}} + """; + + internal static string CreateWriteBodyInvocation( string responseVariableName, - string contentVariableName) - { - return - $""" - {responseVariableName}.WriteResponseBody({contentVariableName}) - """; - } - + string contentVariableName) => + $""" + {responseVariableName}.WriteResponseBody({contentVariableName}) + """; + internal SourceCode GenerateHttpResponseExtensionsClass() => new($"{HttpResponseExtensionsClassName}.g.cs", $$$"""" @@ -62,17 +38,18 @@ internal SourceCode GenerateHttpResponseExtensionsClass() => using Corvus.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; - using OpenAPI.ParameterStyleParsers.OpenApi20; - using OpenAPI.ParameterStyleParsers.OpenApi20.ParameterParsers; + using OpenAPI.ParameterStyleParsers; using JsonObject = System.Text.Json.Nodes.JsonObject; namespace {{{@namespace}}}; internal static class {{{HttpResponseExtensionsClassName}}} { - private static readonly ConcurrentDictionary ParserCache = new(); - - internal static void WriteResponseHeader(this HttpResponse response, + private static readonly ConcurrentDictionary ParserCache = new(); + private static IParameterValueParser GetParser(IParameter parameter) => ParserCache.GetOrAdd(parameter, _ => parameter.CreateParameterValueParser()); + + internal static void WriteResponseHeader(this HttpResponse response, + string openApiVersion, string headerSpecificationAsJson, string name, TValue value, @@ -86,7 +63,7 @@ internal static void WriteResponseHeader(this HttpResponse response, Validate(value); - var parameter = Parameter.FromOpenApi20ParameterSpecification(headerSpecificationAsJson); + var parameter = ParameterFactory.OpenApi(openApiVersion, headerSpecificationAsJson); var serializedValue = Serialize(parameter, name, value); response.Headers[name] = serializedValue; } @@ -100,10 +77,10 @@ internal static void WriteResponseBody(this HttpResponse response, TValu value.WriteTo(jsonWriter); } - private static string? Serialize(Parameter parameter, string name, TValue jsonValue) + private static string? Serialize(IParameter parameter, string name, TValue jsonValue) where TValue : struct, IJsonValue { - var parser = ParserCache.GetOrAdd(parameter, ParameterValueParser.Create); + var parser = GetParser(parameter); var value = jsonValue.Serialize(); return parser.Serialize(JsonNode.Parse(value)); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index f9864df..2fdf5fc 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -10,15 +10,12 @@ internal sealed class ResponseContentGenerator { private readonly List _contentGenerators = []; private readonly List _headerGenerators = []; - private readonly HttpResponseExtensionsGenerator _httpResponseExtensionsGenerator; private readonly string _responseClassName; private readonly string _responseStatusCodePattern; private ResponseContentGenerator( - string responseStatusCodePattern, - HttpResponseExtensionsGenerator httpResponseExtensionsGenerator) + string responseStatusCodePattern) { - _httpResponseExtensionsGenerator = httpResponseExtensionsGenerator; var classNamePrefix = Enum.TryParse(responseStatusCodePattern, out var statusCode) ? statusCode.ToString() : responseStatusCodePattern.First() switch @@ -39,8 +36,7 @@ var chr when char.IsDigit(chr) => "X", public ResponseContentGenerator( string responseStatusCodePattern, List contentGenerators, - List headerGenerators, - HttpResponseExtensionsGenerator httpResponseExtensionsGenerator) : this(responseStatusCodePattern, httpResponseExtensionsGenerator) + List headerGenerators) : this(responseStatusCodePattern) { _contentGenerators = contentGenerators; _headerGenerators = headerGenerators; @@ -99,7 +95,7 @@ internal override void WriteTo(HttpResponse {{responseVariableName}}) {{{_contentGenerators.AggregateToString(generator => $""" case true when {generator.ContentPropertyName} is not null: - {_httpResponseExtensionsGenerator.CreateWriteBodyInvocation( + {HttpResponseExtensionsGenerator.CreateWriteBodyInvocation( responseVariableName, $"{generator.ContentPropertyName}.Value")}; break; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index c1bf868..dc2e0c5 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -1,10 +1,15 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; +using OpenAPI.WebApiGenerator.OpenApi; namespace OpenAPI.WebApiGenerator.CodeGeneration; -internal sealed class ResponseGenerator(List responseBodyGenerators, HttpResponseExtensionsGenerator httpResponseExtensionsGenerator) +internal sealed class ResponseGenerator( + List responseBodyGenerators, + HttpResponseExtensionsGenerator httpResponseExtensionsGenerator, + OpenApiSpecVersion openApiSpecVersion) { public SourceCode GenerateResponseClass(string @namespace, string path) { @@ -18,7 +23,9 @@ public SourceCode GenerateResponseClass(string @namespace, string path) namespace {{@namespace}}; internal abstract partial class Response -{{{Enumerable.Range(1, 5).AggregateToString(i => +{ + private const string OpenApiVersion = "{{openApiSpecVersion.GetParameterVersion()}}"; +{{Enumerable.Range(1, 5).AggregateToString(i => $$""" protected int Validate{{i}}xxStatusCode(int code) => (code >= {{i}}00 && code <= {{i}}99) ? code : throw new InvalidOperationException($"Expected {{i}}xx status code, got {code}"); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs index d80fcab..849f3e7 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs @@ -31,6 +31,7 @@ internal string GenerateWriteDirective(string responseVariableName) return $"""" {responseVariableName}.WriteResponseHeader( + OpenApiVersion, """ {headerSpecificationAsJson.Indent(4).TrimStart()} """, diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiVersionExtensions.cs b/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiVersionExtensions.cs new file mode 100644 index 0000000..3e7aba3 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiVersionExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Text; +using Microsoft.OpenApi; + +namespace OpenAPI.WebApiGenerator.OpenApi; + +internal static class OpenApiVersionExtensions +{ + internal static string GetParameterVersion(this OpenApiSpecVersion version) => version switch + { + OpenApiSpecVersion.OpenApi2_0 => "2.0", + OpenApiSpecVersion.OpenApi3_0 => "3.0", + OpenApiSpecVersion.OpenApi3_1 => "3.1", + _ => throw new NotSupportedException($"OpenAPI version {Enum.GetName(typeof(OpenApiSpecVersion), version)} not supported") + }; + + internal static Action GetSerializer(this IOpenApiSerializable parameter, OpenApiSpecVersion version) => version switch + { + OpenApiSpecVersion.OpenApi3_1 => parameter.SerializeAsV31, + OpenApiSpecVersion.OpenApi3_0 => parameter.SerializeAsV3, + OpenApiSpecVersion.OpenApi2_0 => parameter.SerializeAsV2, + _ => throw new NotSupportedException( + $"OpenAPI version {Enum.GetName(typeof(OpenApiSpecVersion), version)} not supported") + }; + + internal static StringBuilder Serialize(this IOpenApiSerializable serializable, OpenApiSpecVersion version) + { + using var textWriter = new StringWriter(); + var jsonWriter = new OpenApiJsonWriter(textWriter, new OpenApiJsonWriterSettings + { + InlineLocalReferences = true + }); + var serialize = serializable.GetSerializer(version); + serialize(jsonWriter); + textWriter.Flush(); + return textWriter.GetStringBuilder(); + } +} \ No newline at end of file From d257b09b2348a2c7b3f6607d879195b1e13b1978 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 16 Jan 2026 00:02:50 +0100 Subject: [PATCH 18/29] refactor: simplify writing response headers --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 7 +++---- .../HttpResponseExtensionsGenerator.cs | 18 ++---------------- .../CodeGeneration/ResponseGenerator.cs | 9 ++------- .../CodeGeneration/ResponseHeaderGenerator.cs | 15 ++++++++++++--- 4 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 3479e07..8610d53 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -88,7 +88,7 @@ private static void GenerateCode(SourceProductionContext context, ( rootNamespace); httpRequestExtensionsGenerator.GenerateHttpRequestExtensionsClass().AddTo(context); - var httpResponseExtensionsGenerator = new HttpResponseExtensionsGenerator(openApiVersion, rootNamespace); + var httpResponseExtensionsGenerator = new HttpResponseExtensionsGenerator(rootNamespace); httpResponseExtensionsGenerator.GenerateHttpResponseExtensionsClass().AddTo(context); var apiConfigurationGenerator = new ApiConfigurationGenerator(rootNamespace); @@ -189,7 +189,7 @@ private static void GenerateCode(SourceProductionContext context, ( var responseHeaderSchema = openApiResponseVisitor.GetSchemaReference(header); var typeDeclaration = schemaGenerator.Generate(responseHeaderSchema); return new ResponseHeaderGenerator(name, header, typeDeclaration, - httpResponseExtensionsGenerator); + openApiVersion); }).ToList() ?? []; return new ResponseContentGenerator( @@ -200,8 +200,7 @@ private static void GenerateCode(SourceProductionContext context, ( var responseGenerator = new ResponseGenerator( responseBodyGenerators, - httpResponseExtensionsGenerator, - openApiVersion); + httpResponseExtensionsGenerator); var responseSourceCode = responseGenerator.GenerateResponseClass( operationNamespace, diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs index deab313..4a8ed4c 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs @@ -1,26 +1,12 @@ -using Microsoft.OpenApi; -using OpenAPI.WebApiGenerator.OpenApi; - -namespace OpenAPI.WebApiGenerator.CodeGeneration; +namespace OpenAPI.WebApiGenerator.CodeGeneration; internal sealed class HttpResponseExtensionsGenerator( - OpenApiSpecVersion openApiVersion, string @namespace) { private const string HttpResponseExtensionsClassName = "HttpResponseExtensions"; public string Namespace => @namespace; - internal string GetResponseHeaderSpecificationAsJson( - IOpenApiHeader header, - string name) => - // Response header specification is a subset of the parameter specification, so we add the missing properties to be able to use the parameter value parser - $$""" - { - "name": "{{name}}", - "in": "header", - {{header.Serialize(openApiVersion).ToString().TrimStart('{').TrimStart()}} - """; - + internal static string CreateWriteBodyInvocation( string responseVariableName, string contentVariableName) => diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index dc2e0c5..c735402 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -1,15 +1,12 @@ using System.Collections.Generic; using System.Linq; -using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; -using OpenAPI.WebApiGenerator.OpenApi; namespace OpenAPI.WebApiGenerator.CodeGeneration; internal sealed class ResponseGenerator( List responseBodyGenerators, - HttpResponseExtensionsGenerator httpResponseExtensionsGenerator, - OpenApiSpecVersion openApiSpecVersion) + HttpResponseExtensionsGenerator httpResponseExtensionsGenerator) { public SourceCode GenerateResponseClass(string @namespace, string path) { @@ -23,9 +20,7 @@ public SourceCode GenerateResponseClass(string @namespace, string path) namespace {{@namespace}}; internal abstract partial class Response -{ - private const string OpenApiVersion = "{{openApiSpecVersion.GetParameterVersion()}}"; -{{Enumerable.Range(1, 5).AggregateToString(i => +{{{Enumerable.Range(1, 5).AggregateToString(i => $$""" protected int Validate{{i}}xxStatusCode(int code) => (code >= {{i}}00 && code <= {{i}}99) ? code : throw new InvalidOperationException($"Expected {{i}}xx status code, got {code}"); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs index 849f3e7..89e4b4b 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs @@ -2,6 +2,7 @@ using Corvus.Json.CodeGeneration.CSharp; using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; +using OpenAPI.WebApiGenerator.OpenApi; namespace OpenAPI.WebApiGenerator.CodeGeneration; @@ -9,7 +10,7 @@ internal sealed class ResponseHeaderGenerator( string name, IOpenApiHeader header, TypeDeclaration typeDeclaration, - HttpResponseExtensionsGenerator httpResponseExtensionsGenerator) + OpenApiSpecVersion openApiSpecVersion) { private readonly string _propertyName = name.ToPascalCase(); private readonly string _requiredDirective = header.Required ? "required" : string.Empty; @@ -27,11 +28,19 @@ internal string GenerateProperty() => internal string GenerateWriteDirective(string responseVariableName) { - var headerSpecificationAsJson = httpResponseExtensionsGenerator.GetResponseHeaderSpecificationAsJson(header, name); + // Response header specification is a subset of the parameter specification, so we add the missing properties to be able to use the parameter value parser + var headerSpecificationAsJson = + $$""" + { + "name": "{{name}}", + "in": "header", + {{header.Serialize(openApiSpecVersion).ToString().TrimStart('{').TrimStart()}} + """; + return $"""" {responseVariableName}.WriteResponseHeader( - OpenApiVersion, + "{openApiSpecVersion.GetParameterVersion()}", """ {headerSpecificationAsJson.Indent(4).TrimStart()} """, From efee8b3192cc3d2468b2977fb043042e10455357 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 16 Jan 2026 21:47:22 +0100 Subject: [PATCH 19/29] test(openapi31): add integration test for openapi 3.1 --- OpenAPI.WebApiGenerator.sln | 85 ++++++++++ .../DeleteFooTests.cs | 24 +++ .../Example.OpenApi31.IntegrationTests.csproj | 32 ++++ .../FooApplicationFactory.cs | 7 + .../FooTestSpecification.cs | 13 ++ .../Http/HttpContentExtensions.cs | 14 ++ .../Json/JsonNodeExtensions.cs | 23 +++ .../UpdateFooTests.cs | 67 ++++++++ .../Example.OpenApi31.csproj | 22 +++ .../FooFooId/Delete/Operation.Handler.cs | 10 ++ .../Paths/FooFooId/Put/Operation.Handler.cs | 39 +++++ tests/Example.OpenApi31/Program.cs | 9 ++ .../appsettings.Development.json | 8 + tests/Example.OpenApi31/appsettings.json | 10 ++ tests/Example.OpenApi31/openapi.json | 146 ++++++++++++++++++ 15 files changed, 509 insertions(+) create mode 100644 tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs create mode 100644 tests/Example.OpenApi31.IntegrationTests/Example.OpenApi31.IntegrationTests.csproj create mode 100644 tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs create mode 100644 tests/Example.OpenApi31.IntegrationTests/FooTestSpecification.cs create mode 100644 tests/Example.OpenApi31.IntegrationTests/Http/HttpContentExtensions.cs create mode 100644 tests/Example.OpenApi31.IntegrationTests/Json/JsonNodeExtensions.cs create mode 100644 tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs create mode 100644 tests/Example.OpenApi31/Example.OpenApi31.csproj create mode 100644 tests/Example.OpenApi31/Paths/FooFooId/Delete/Operation.Handler.cs create mode 100644 tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs create mode 100644 tests/Example.OpenApi31/Program.cs create mode 100644 tests/Example.OpenApi31/appsettings.Development.json create mode 100644 tests/Example.OpenApi31/appsettings.json create mode 100644 tests/Example.OpenApi31/openapi.json diff --git a/OpenAPI.WebApiGenerator.sln b/OpenAPI.WebApiGenerator.sln index cf8f675..4e20d55 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -19,35 +19,120 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{F4FDC271-0 LICENSE = LICENSE EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi31", "tests\Example.OpenApi31\Example.OpenApi31.csproj", "{FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi31.IntegrationTests", "tests\Example.OpenApi31.IntegrationTests\Example.OpenApi31.IntegrationTests.csproj", "{FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Debug|x64.ActiveCfg = Debug|Any CPU + {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Debug|x64.Build.0 = Debug|Any CPU + {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Debug|x86.ActiveCfg = Debug|Any CPU + {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Debug|x86.Build.0 = Debug|Any CPU {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Release|Any CPU.ActiveCfg = Release|Any CPU {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Release|Any CPU.Build.0 = Release|Any CPU + {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Release|x64.ActiveCfg = Release|Any CPU + {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Release|x64.Build.0 = Release|Any CPU + {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Release|x86.ActiveCfg = Release|Any CPU + {E2935A8A-ED91-4A1D-BEF4-08D916A7ED07}.Release|x86.Build.0 = Release|Any CPU {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Debug|x64.ActiveCfg = Debug|Any CPU + {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Debug|x64.Build.0 = Debug|Any CPU + {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Debug|x86.ActiveCfg = Debug|Any CPU + {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Debug|x86.Build.0 = Debug|Any CPU {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Release|Any CPU.ActiveCfg = Release|Any CPU {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Release|Any CPU.Build.0 = Release|Any CPU + {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Release|x64.ActiveCfg = Release|Any CPU + {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Release|x64.Build.0 = Release|Any CPU + {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Release|x86.ActiveCfg = Release|Any CPU + {790AE9B7-F3EA-459C-BAB2-D75E903D9B39}.Release|x86.Build.0 = Release|Any CPU {2A585540-1B80-4848-9A93-E0286758E2E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2A585540-1B80-4848-9A93-E0286758E2E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A585540-1B80-4848-9A93-E0286758E2E0}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A585540-1B80-4848-9A93-E0286758E2E0}.Debug|x64.Build.0 = Debug|Any CPU + {2A585540-1B80-4848-9A93-E0286758E2E0}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A585540-1B80-4848-9A93-E0286758E2E0}.Debug|x86.Build.0 = Debug|Any CPU {2A585540-1B80-4848-9A93-E0286758E2E0}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A585540-1B80-4848-9A93-E0286758E2E0}.Release|Any CPU.Build.0 = Release|Any CPU + {2A585540-1B80-4848-9A93-E0286758E2E0}.Release|x64.ActiveCfg = Release|Any CPU + {2A585540-1B80-4848-9A93-E0286758E2E0}.Release|x64.Build.0 = Release|Any CPU + {2A585540-1B80-4848-9A93-E0286758E2E0}.Release|x86.ActiveCfg = Release|Any CPU + {2A585540-1B80-4848-9A93-E0286758E2E0}.Release|x86.Build.0 = Release|Any CPU {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Debug|x64.ActiveCfg = Debug|Any CPU + {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Debug|x64.Build.0 = Debug|Any CPU + {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Debug|x86.ActiveCfg = Debug|Any CPU + {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Debug|x86.Build.0 = Debug|Any CPU {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Release|Any CPU.ActiveCfg = Release|Any CPU {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Release|Any CPU.Build.0 = Release|Any CPU + {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Release|x64.ActiveCfg = Release|Any CPU + {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Release|x64.Build.0 = Release|Any CPU + {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Release|x86.ActiveCfg = Release|Any CPU + {8044D11A-B0D2-400A-B2A1-8C50E396073A}.Release|x86.Build.0 = Release|Any CPU {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Debug|x64.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Debug|x86.Build.0 = Debug|Any CPU {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Release|x64.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Release|x64.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Release|x86.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-47B8-9C0D-1E2F3A4B5C6D}.Release|x86.Build.0 = Release|Any CPU {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Debug|x64.ActiveCfg = Debug|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Debug|x64.Build.0 = Debug|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Debug|x86.Build.0 = Debug|Any CPU {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Release|Any CPU.Build.0 = Release|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Release|x64.ActiveCfg = Release|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Release|x64.Build.0 = Release|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Release|x86.ActiveCfg = Release|Any CPU + {C2D3E4F5-A6B7-48C9-0D1E-2F3A4B5C6D7E}.Release|x86.Build.0 = Release|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Debug|x64.ActiveCfg = Debug|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Debug|x64.Build.0 = Debug|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Debug|x86.ActiveCfg = Debug|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Debug|x86.Build.0 = Debug|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Release|Any CPU.Build.0 = Release|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Release|x64.ActiveCfg = Release|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Release|x64.Build.0 = Release|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Release|x86.ActiveCfg = Release|Any CPU + {FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}.Release|x86.Build.0 = Release|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Debug|x64.ActiveCfg = Debug|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Debug|x64.Build.0 = Debug|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Debug|x86.ActiveCfg = Debug|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Debug|x86.Build.0 = Debug|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Release|Any CPU.Build.0 = Release|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Release|x64.ActiveCfg = Release|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Release|x64.Build.0 = Release|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Release|x86.ActiveCfg = Release|Any CPU + {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution EndGlobalSection EndGlobal diff --git a/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs b/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs new file mode 100644 index 0000000..50a1773 --- /dev/null +++ b/tests/Example.OpenApi31.IntegrationTests/DeleteFooTests.cs @@ -0,0 +1,24 @@ +using System.Net; +using AwesomeAssertions; + +namespace Example.OpenApi31.IntegrationTests; + +public class DeleteFooTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture +{ + [Fact] + public async Task When_Deleting_Foo_It_Should_Return_Ok() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("DELETE") + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await result.Content.ReadAsByteArrayAsync(CancellationToken); + responseContent.Should().BeEmpty(); + result.Content.Headers.ContentType.Should().BeNull(); + + result.Headers.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi31.IntegrationTests/Example.OpenApi31.IntegrationTests.csproj b/tests/Example.OpenApi31.IntegrationTests/Example.OpenApi31.IntegrationTests.csproj new file mode 100644 index 0000000..78f593d --- /dev/null +++ b/tests/Example.OpenApi31.IntegrationTests/Example.OpenApi31.IntegrationTests.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs new file mode 100644 index 0000000..2ba4121 --- /dev/null +++ b/tests/Example.OpenApi31.IntegrationTests/FooApplicationFactory.cs @@ -0,0 +1,7 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Example.OpenApi31.IntegrationTests; + +[UsedImplicitly] +public class FooApplicationFactory : WebApplicationFactory; \ No newline at end of file diff --git a/tests/Example.OpenApi31.IntegrationTests/FooTestSpecification.cs b/tests/Example.OpenApi31.IntegrationTests/FooTestSpecification.cs new file mode 100644 index 0000000..a2ac8ca --- /dev/null +++ b/tests/Example.OpenApi31.IntegrationTests/FooTestSpecification.cs @@ -0,0 +1,13 @@ +using System.Text; + +namespace Example.OpenApi31.IntegrationTests; + +public abstract class FooTestSpecification +{ + protected CancellationToken CancellationToken { get; } = TestContext.Current.CancellationToken; + + protected HttpContent CreateJsonContent(string json) => new StringContent( + json, + encoding: Encoding.UTF8, + mediaType: "application/json"); +} \ No newline at end of file diff --git a/tests/Example.OpenApi31.IntegrationTests/Http/HttpContentExtensions.cs b/tests/Example.OpenApi31.IntegrationTests/Http/HttpContentExtensions.cs new file mode 100644 index 0000000..bcc0da8 --- /dev/null +++ b/tests/Example.OpenApi31.IntegrationTests/Http/HttpContentExtensions.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Nodes; + +namespace Example.OpenApi31.IntegrationTests.Http; + +internal static class HttpContentExtensions +{ + internal static async Task ReadAsJsonNodeAsync(this HttpContent content, + CancellationToken cancellationToken) => + await JsonNode.ParseAsync( + await content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false), + cancellationToken: cancellationToken) + .ConfigureAwait(false); +} \ No newline at end of file diff --git a/tests/Example.OpenApi31.IntegrationTests/Json/JsonNodeExtensions.cs b/tests/Example.OpenApi31.IntegrationTests/Json/JsonNodeExtensions.cs new file mode 100644 index 0000000..5689ab6 --- /dev/null +++ b/tests/Example.OpenApi31.IntegrationTests/Json/JsonNodeExtensions.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Nodes; +using AwesomeAssertions; +using Json.Pointer; + +namespace Example.OpenApi31.IntegrationTests.Json; + +internal static class JsonNodeExtensions +{ + internal static JsonNode Evaluate(this JsonNode? node, string path) + { + JsonPointer.Parse(path).TryEvaluate(node, out var value).Should() + .BeTrue($"because the json node should contain the property {path}"); + value.Should().NotBeNull($"because the property {path} should not be null"); + return value!; + } + + internal static T GetValue(this JsonNode? node, string path) + { + var value = node.Evaluate(path); + return value.GetValue(); + } + +} \ No newline at end of file diff --git a/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs new file mode 100644 index 0000000..248a95e --- /dev/null +++ b/tests/Example.OpenApi31.IntegrationTests/UpdateFooTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Net.Http.Headers; +using AwesomeAssertions; +using Example.OpenApi31.IntegrationTests.Http; +using Example.OpenApi31.IntegrationTests.Json; + +namespace Example.OpenApi31.IntegrationTests; + +public class UpdateFooTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture +{ + [Fact] + public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await result.Content.ReadAsJsonNodeAsync(CancellationToken); + responseContent.Should().NotBeNull(); + responseContent.GetValue("#/Name").Should().Be("test"); + result.Headers.Should().HaveCount(1); + result.Headers.Should().ContainKey("Status") + .WhoseValue.Should().HaveCount(1) + .And.Contain("2"); + result.Content.Headers.ContentType.Should().Be(MediaTypeHeaderValue.Parse("application/json")); + } + + [Fact] + public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/test"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var responseContent = await result.Content.ReadAsJsonNodeAsync(CancellationToken); + responseContent.Should().NotBeNull(); + responseContent.AsArray().Should().HaveCount(1); + responseContent.GetValue("#/0/error").Should().NotBeNullOrEmpty(); + responseContent.GetValue("#/0/name").Should().Be("https://localhost/api.json#/components/parameters/FooId/schema/type"); + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi31/Example.OpenApi31.csproj b/tests/Example.OpenApi31/Example.OpenApi31.csproj new file mode 100644 index 0000000..9c5c66c --- /dev/null +++ b/tests/Example.OpenApi31/Example.OpenApi31.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + Example.OpenApi31 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Example.OpenApi31/Paths/FooFooId/Delete/Operation.Handler.cs b/tests/Example.OpenApi31/Paths/FooFooId/Delete/Operation.Handler.cs new file mode 100644 index 0000000..ec6b618 --- /dev/null +++ b/tests/Example.OpenApi31/Paths/FooFooId/Delete/Operation.Handler.cs @@ -0,0 +1,10 @@ +namespace Example.OpenApi31.Paths.FooFooId.Delete; + +internal partial class Operation +{ + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + { + var response = new Response.OK200(); + return Task.FromResult(response); + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs new file mode 100644 index 0000000..bf16c8e --- /dev/null +++ b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs @@ -0,0 +1,39 @@ +using System.Collections.Immutable; +using Corvus.Json; + +namespace Example.OpenApi31.Paths.FooFooId.Put; + +internal partial class Operation +{ + public Operation() + { + HandleValidationError = HandleValidationErrors; + } + + private static Response HandleValidationErrors(ImmutableList validationResults) + { + var response = validationResults.Select(result => + Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( + name: result.Location?.SchemaLocation.ToString() ?? string.Empty, + error: result.Message ?? string.Empty)); + return new Response.BadRequest400( + Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + } + + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + { + _ = request.Query.Fee; + _ = request.Path.FooId; + _ = request.Header.Bar; + + var response = new Response.OK200(Components.Schemas.FooProperties.Create( + name: request.Body.ApplicationJson?.Name)) + { + Headers = new Response.OK200.ResponseHeaders + { + Status = 2 + } + }; + return Task.FromResult(response); + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi31/Program.cs b/tests/Example.OpenApi31/Program.cs new file mode 100644 index 0000000..334940a --- /dev/null +++ b/tests/Example.OpenApi31/Program.cs @@ -0,0 +1,9 @@ +using Example.OpenApi31; + +var builder = WebApplication.CreateBuilder(args); +builder.AddOperations(builder.Configuration.Get()); +var app = builder.Build(); +app.MapOperations(); +app.Run(); + +public abstract partial class Program; \ No newline at end of file diff --git a/tests/Example.OpenApi31/appsettings.Development.json b/tests/Example.OpenApi31/appsettings.Development.json new file mode 100644 index 0000000..1b2d3ba --- /dev/null +++ b/tests/Example.OpenApi31/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi31/appsettings.json b/tests/Example.OpenApi31/appsettings.json new file mode 100644 index 0000000..1fdceaf --- /dev/null +++ b/tests/Example.OpenApi31/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "OpenApiSpecificationUri": "https://localhost/api.json" +} \ No newline at end of file diff --git a/tests/Example.OpenApi31/openapi.json b/tests/Example.OpenApi31/openapi.json new file mode 100644 index 0000000..09dd94d --- /dev/null +++ b/tests/Example.OpenApi31/openapi.json @@ -0,0 +1,146 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Example API", + "version": "2025-11-05" + }, + "paths": { + "/foo/{FooId}": { + "put": { + "operationId": "Update_Foo", + "parameters": [ + { + "$ref": "#/components/parameters/Bar" + }, + { + "name": "Fee", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "required": false, + "style": "form", + "explode": true + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/FooProperties" + }, + "responses": { + "200": { + "description": "Successfully updated", + "headers": { + "Status": { + "description": "The Status of foo", + "schema": { + "type": "integer" + } + }, + "Tag": { + "description": "An optional tag", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "delete": { + "operationId": "Delete_Foo", + "responses": { + "200": { + "description": "Successfully deleted" + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/FooId" + } + ] + } + }, + "components": { + "schemas": { + "FooProperties": { + "description": "Foo properties.", + "type": "object", + "properties": { + "Name": { + "description": "Name of foo", + "type": "string" + } + } + } + }, + "parameters": { + "Bar": { + "name": "Bar", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + "FooId": { + "name": "FooId", + "in": "path", + "schema": { + "type": "integer" + }, + "required": true + } + }, + "requestBodies": { + "FooProperties": { + "description": "Foo properties.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Returned when the request has validation errors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["name", "error"] + } + } + } + } + } + } + } +} \ No newline at end of file From ea20fa2ba14489fa5ba2c62b4159ed2cf036b22c Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 16 Jan 2026 21:54:51 +0100 Subject: [PATCH 20/29] fix validation errors in openapi specs --- tests/Example.OpenApi30/openapi.json | 3 ++- tests/Example.OpenApi31/openapi.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Example.OpenApi30/openapi.json b/tests/Example.OpenApi30/openapi.json index b0d2923..48611c6 100644 --- a/tests/Example.OpenApi30/openapi.json +++ b/tests/Example.OpenApi30/openapi.json @@ -2,7 +2,8 @@ "openapi": "3.0.0", "info": { "title": "Example API", - "version": "2025-11-05" + "version": "2025-11-05", + "description": "Foo API" }, "paths": { "/foo/{FooId}": { diff --git a/tests/Example.OpenApi31/openapi.json b/tests/Example.OpenApi31/openapi.json index 09dd94d..f8d7cba 100644 --- a/tests/Example.OpenApi31/openapi.json +++ b/tests/Example.OpenApi31/openapi.json @@ -2,7 +2,8 @@ "openapi": "3.1.0", "info": { "title": "Example API", - "version": "2025-11-05" + "version": "2025-11-05", + "description": "Foo API" }, "paths": { "/foo/{FooId}": { From 4390395d3e6a0cb510672aceaafdebb707ac5a0f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 16 Jan 2026 22:16:12 +0100 Subject: [PATCH 21/29] ci: lint openapi specs --- .github/workflows/lint-openapi.yml | 21 +++++++++++++++++++++ OpenAPI.WebApiGenerator.sln | 1 + 2 files changed, 22 insertions(+) create mode 100644 .github/workflows/lint-openapi.yml diff --git a/.github/workflows/lint-openapi.yml b/.github/workflows/lint-openapi.yml new file mode 100644 index 0000000..a3335f0 --- /dev/null +++ b/.github/workflows/lint-openapi.yml @@ -0,0 +1,21 @@ +name: Lint OpenAPI Specs + +on: + push: + branches: + - "**" + tags-ignore: + - '**' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install vacuum + run: curl -fsSL https://quobix.com/scripts/install_vacuum.sh | sh + + - name: Lint OpenAPI specs + run: | + vacuum lint */openapi.json diff --git a/OpenAPI.WebApiGenerator.sln b/OpenAPI.WebApiGenerator.sln index 4e20d55..97dda50 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -17,6 +17,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{F4FDC271-0 README.md = README.md .github\workflows\cd.yml = .github\workflows\cd.yml LICENSE = LICENSE + .github\workflows\lint-openapi.yml = .github\workflows\lint-openapi.yml EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi31", "tests\Example.OpenApi31\Example.OpenApi31.csproj", "{FF8E3B7A-20A5-4702-871C-D8ABC6D82F09}" From eea607d29f4daab02ead7afa23feb086d06fd027 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 16 Jan 2026 22:32:15 +0100 Subject: [PATCH 22/29] ci: glob all openapi specs --- .github/workflows/lint-openapi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-openapi.yml b/.github/workflows/lint-openapi.yml index a3335f0..3324bcd 100644 --- a/.github/workflows/lint-openapi.yml +++ b/.github/workflows/lint-openapi.yml @@ -18,4 +18,4 @@ jobs: - name: Lint OpenAPI specs run: | - vacuum lint */openapi.json + vacuum lint --globbed-files="*/**/openapi.json" From b2ff9d617d187a22d270dd5c7fc39f77a2b1ef6b Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 17 Jan 2026 01:33:05 +0100 Subject: [PATCH 23/29] fix response header code formatting --- .../CodeGeneration/ResponseHeaderGenerator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs index 89e4b4b..33f52ae 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs @@ -13,7 +13,7 @@ internal sealed class ResponseHeaderGenerator( OpenApiSpecVersion openApiSpecVersion) { private readonly string _propertyName = name.ToPascalCase(); - private readonly string _requiredDirective = header.Required ? "required" : string.Empty; + private readonly string _requiredDirective = header.Required ? "required " : string.Empty; private string DefaultValueAssignment => header.Required ? "" : $" = {FullyQualifiedTypeName}.Undefined;"; private string FullyQualifiedTypeName => $"{_fullyQualifiedTypeDeclarationIdentifier}"; @@ -23,7 +23,7 @@ internal sealed class ResponseHeaderGenerator( internal string GenerateProperty() => $$""" - internal {{_requiredDirective}} {{FullyQualifiedTypeName}} {{_propertyName}} { get; init; }{{DefaultValueAssignment}} + internal {{_requiredDirective}}{{FullyQualifiedTypeName}} {{_propertyName}} { get; init; }{{DefaultValueAssignment}} """; internal string GenerateWriteDirective(string responseVariableName) From c93975a707c303fc47fbf7e1876fc21c29a71495 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 17 Jan 2026 01:52:15 +0100 Subject: [PATCH 24/29] avoid instantiating the same parameter specification everytime a parameter needs binding --- .../CodeGeneration/HttpRequestExtensionsGenerator.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index bfcf076..40789a3 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs @@ -56,6 +56,9 @@ internal static class {{{HttpRequestExtensionsClassName}}} { private static readonly ConcurrentDictionary ParserCache = new(); private static IParameterValueParser GetParser(IParameter parameter) => ParserCache.GetOrAdd(parameter, _ => parameter.CreateParameterValueParser()); + + private static readonly ConcurrentDictionary ParameterCache = new(); + private static IParameter GetParameter(string openApiVersion, string parameterSpecificationAsJson) => ParameterCache.GetOrAdd(parameterSpecificationAsJson, _ => ParameterFactory.OpenApi(openApiVersion, parameterSpecificationAsJson)); /// /// Binds an http parameter to a json type @@ -71,7 +74,7 @@ internal static T Bind(this HttpRequest request, string parameterSpecificationAsJson) where T : struct, IJsonValue { - var parameter = ParameterFactory.OpenApi(openApiVersion, parameterSpecificationAsJson); + var parameter = GetParameter(openApiVersion, parameterSpecificationAsJson); return parameter switch { _ when parameter.InBody => T.Parse(request.BodyReader.AsStream()), From be8eccfea5e2064a6184c6a3bcabe0f44dfa06ed Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 17 Jan 2026 11:32:59 +0100 Subject: [PATCH 25/29] refactor(response): separate write and validate in order to validate the whole response and let the user run validation --- .../HttpResponseExtensionsGenerator.cs | 42 ++++------------ .../CodeGeneration/OperationGenerator.cs | 10 +++- .../ResponseBodyContentGenerator.cs | 1 + .../ResponseContentGenerator.cs | 48 ++++++++++++------- .../CodeGeneration/ResponseGenerator.cs | 1 + .../CodeGeneration/ResponseHeaderGenerator.cs | 8 +++- .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- 7 files changed, 58 insertions(+), 54 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs index 4a8ed4c..a583d09 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs @@ -6,7 +6,6 @@ internal sealed class HttpResponseExtensionsGenerator( private const string HttpResponseExtensionsClassName = "HttpResponseExtensions"; public string Namespace => @namespace; - internal static string CreateWriteBodyInvocation( string responseVariableName, string contentVariableName) => @@ -31,58 +30,33 @@ namespace {{{@namespace}}}; internal static class {{{HttpResponseExtensionsClassName}}} { - private static readonly ConcurrentDictionary ParserCache = new(); - private static IParameterValueParser GetParser(IParameter parameter) => ParserCache.GetOrAdd(parameter, _ => parameter.CreateParameterValueParser()); + private static readonly ConcurrentDictionary ParserCache = new(); internal static void WriteResponseHeader(this HttpResponse response, string openApiVersion, string headerSpecificationAsJson, string name, - TValue value, - bool isRequired) + TValue value) where TValue : struct, IJsonValue { - if (!isRequired && value.IsUndefined()) + if (value.IsUndefined()) { return; } - - Validate(value); - - var parameter = ParameterFactory.OpenApi(openApiVersion, headerSpecificationAsJson); - var serializedValue = Serialize(parameter, name, value); + + var parser = ParserCache.GetOrAdd(headerSpecificationAsJson, + _ => ParameterValueParserFactory.OpenApi(openApiVersion, headerSpecificationAsJson)); + var jsonValue = value.Serialize(); + var serializedValue = parser.Serialize(JsonNode.Parse(jsonValue)); response.Headers[name] = serializedValue; } internal static void WriteResponseBody(this HttpResponse response, TValue value) where TValue : struct, IJsonValue { - Validate(value); - using var jsonWriter = new Utf8JsonWriter(response.BodyWriter); value.WriteTo(jsonWriter); } - - private static string? Serialize(IParameter parameter, string name, TValue jsonValue) - where TValue : struct, IJsonValue - { - var parser = GetParser(parameter); - var value = jsonValue.Serialize(); - - return parser.Serialize(JsonNode.Parse(value)); - } - - private static void Validate(T value) - where T : struct, IJsonValue - { - var validationContext = ValidationContext.ValidContext; - var validationLevel = ValidationLevel.Detailed; - validationContext = value.Validate(validationContext, validationLevel); - if (!validationContext.IsValid) - { - throw new JsonValidationException($"Object of type {typeof(T)} is not valid", validationContext.Results); - } - } } #nullable restore """"); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 084203e..be258cc 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -41,7 +41,8 @@ internal static async Task HandleAsync( var request = await Request.BindAsync(context, cancellationToken) .ConfigureAwait(false); - var validationContext = request.Validate(ValidationLevel.Detailed); + var validationLevel = ValidationLevel.Detailed; + var validationContext = request.Validate(validationLevel); if (!validationContext.IsValid) { operation.HandleValidationError(validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri)) @@ -51,6 +52,13 @@ internal static async Task HandleAsync( var response = await operation.HandleAsync(request, cancellationToken) .ConfigureAwait(false); + validationContext = response.Validate(validationLevel); + if (!validationContext.IsValid) + { + var validationResult = validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri); + {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Response is not valid", "validationResult")}}; + } + response.WriteTo(context.Response); } } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index cf7b11e..315a468 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -8,6 +8,7 @@ internal sealed class ResponseBodyContentGenerator(string contentType, TypeDecla { private readonly string _contentVariableName = contentType.ToCamelCase(); public string ContentPropertyName { get; } = contentType.ToPascalCase(); + internal string SchemaLocation => typeDeclaration.RelativeSchemaLocation; public string GenerateConstructor(string className, string contentTypeFieldName) => $$""" public {{className}}({{typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index 2fdf5fc..050ea30 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -56,26 +56,26 @@ public string GenerateResponseContentClass() var needsStatusCodeValidation = !hasExplicitStatusCode && !hasDefaultStatusCode; return -$$""" -internal sealed class {{_responseClassName}} : Response +$$$""" +internal sealed class {{{_responseClassName}}} : Response { - private string? {{contentTypeFieldName}} = null;{{ + private string? {{{contentTypeFieldName}}} = null;{{{ _contentGenerators.AggregateToString(generator => generator.GenerateConstructor(_responseClassName, contentTypeFieldName)).Indent(4) - }}{{ + }}}{{{ _contentGenerators.AggregateToString(generator => generator.GenerateContentProperty()).Indent(4) - }} + }}} - private int _statusCode{{(hasExplicitStatusCode ? $" = {_responseStatusCodePattern}" : string.Empty)}}; + private int _statusCode{{{(hasExplicitStatusCode ? $" = {_responseStatusCodePattern}" : string.Empty)}}}; internal int StatusCode { - get => _statusCode;{{(hasExplicitStatusCode ? "" : + get => _statusCode;{{{(hasExplicitStatusCode ? "" : $""" init => _statusCode = {(needsStatusCodeValidation ? $"Validate{_responseStatusCodePattern.First()}xxStatusCode(value)" : "value")}; -""")}} +""")}}} } -{{(anyHeaders ? +{{{(anyHeaders ? $$""" internal {{headerRequiredDirective}}ResponseHeaders Headers { get; init; }{{defaultHeadersValueAssignment}} @@ -86,9 +86,9 @@ internal sealed class ResponseHeaders generator.GenerateProperty()).Indent(8)}} } -""" : "")}} - internal override void WriteTo(HttpResponse {{responseVariableName}}) - {{{(_contentGenerators.Any() ? +""" : "")}}} + internal override void WriteTo(HttpResponse {{{responseVariableName}}}) + {{{{(_contentGenerators.Any() ? $$""" switch (true) @@ -104,11 +104,27 @@ internal override void WriteTo(HttpResponse {{responseVariableName}}) throw new InvalidOperationException("No content was defined"); } -""" : "")}} - {{responseVariableName}}.ContentType = {{contentTypeFieldName}}; - {{responseVariableName}}.StatusCode = StatusCode;{{ +""" : "")}}} + {{{responseVariableName}}}.ContentType = {{{contentTypeFieldName}}}; + {{{responseVariableName}}}.StatusCode = StatusCode;{{{ _headerGenerators.AggregateToString(generator => - generator.GenerateWriteDirective(responseVariableName)).Indent(8)}} + generator.GenerateWriteDirective(responseVariableName)).Indent(8)}}} + } + + internal override ValidationContext Validate(ValidationLevel validationLevel) + { + var validationContext = ValidationContext.ValidContext.UsingStack().UsingResults(); + validationContext = true switch + {{{{_contentGenerators.AggregateToString(generator => +$""" + true when {generator.ContentPropertyName} is not null => + {generator.ContentPropertyName}.Value.Validate("{generator.SchemaLocation}", true, validationContext, validationLevel), +""")}}} + _ => validationContext + }; + {{{_headerGenerators.AggregateToString(generator => + generator.GenerateValidateDirective()).Indent(8)}}} + return validationContext; } } """; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs index c735402..eb4beae 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs @@ -27,6 +27,7 @@ internal abstract partial class Response """)}} internal abstract void WriteTo(HttpResponse httpResponse); + internal abstract ValidationContext Validate(ValidationLevel validationLevel); {{ responseBodyGenerators.AggregateToString(generator => generator.GenerateResponseContentClass()).Indent(4) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs index 33f52ae..909fe62 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs @@ -45,8 +45,12 @@ internal string GenerateWriteDirective(string responseVariableName) {headerSpecificationAsJson.Indent(4).TrimStart()} """, "{name}", - Headers.{_propertyName}, - {header.Required.ToString().ToLowerInvariant()}); + Headers.{_propertyName}); """"; } + + internal string GenerateValidateDirective() => + $""" + validationContext = Headers.{_propertyName}.Validate("{typeDeclaration.RelativeSchemaLocation}", {header.Required.ToString().ToLowerInvariant()}, validationContext, validationLevel); + """; } diff --git a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs index bf16c8e..8be87a8 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() HandleValidationError = HandleValidationErrors; } - private static Response HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) { var response = validationResults.Select(result => Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( From 0173e451163f2025ae88a237804bbbd3bf872905 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 17 Jan 2026 12:05:34 +0100 Subject: [PATCH 26/29] let user override the response validation process --- .../CodeGeneration/OperationGenerator.cs | 40 ++++++++++++++----- .../Paths/FooFooId/Put/Operation.Handler.cs | 10 +++-- .../Paths/FooFooId/Put/Operation.Handler.cs | 5 ++- .../Paths/FooFooId/Put/Operation.Handler.cs | 2 +- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index be258cc..8521088 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -3,6 +3,7 @@ using System.Net.Http; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; +using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; @@ -27,11 +28,25 @@ internal partial class Operation internal const string PathTemplate = "{{pathTemplate}}"; internal const string Method = "{{method.Method}}"; - {{HandleMethodSignature}}; + /// + /// Should responses be validated? + /// If the response has already been validated, this can be disabled to avoid redundant validation. + /// + internal bool ValidateResponse { get; init; } = true; - private Func, Response> HandleValidationError { get; } = validationResult => +{{HandleMethodSignature.Indent(4)}}; + + /// + /// Set a custom delegate to handle request validation errors. + /// + /// + private Func, Response> HandleRequestValidationError { get; } = validationResult => {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}}; + /// + /// Handle a operation. + /// + /// internal static async Task HandleAsync( HttpContext context, [FromServices] Operation operation, @@ -45,20 +60,22 @@ internal static async Task HandleAsync( var validationContext = request.Validate(validationLevel); if (!validationContext.IsValid) { - operation.HandleValidationError(validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri)) + operation.HandleRequestValidationError(validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri)) .WriteTo(context.Response); return; } var response = await operation.HandleAsync(request, cancellationToken) .ConfigureAwait(false); - validationContext = response.Validate(validationLevel); - if (!validationContext.IsValid) + if (operation.ValidateResponse) { - var validationResult = validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri); - {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Response is not valid", "validationResult")}}; + validationContext = response.Validate(validationLevel); + if (!validationContext.IsValid) + { + var validationResult = validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri); + {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Response is not valid", "validationResult")}}; + } } - response.WriteTo(context.Response); } } @@ -79,7 +96,12 @@ internal static async Task HandleAsync( } private const string HandleMethodSignature = - "internal partial Task HandleAsync(Request request, CancellationToken cancellationToken)"; + """ + /// + /// Handles a request for this operation. + /// + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + """; private static bool HasImplementedHandleMethod(INamedTypeSymbol typeSymbol) { diff --git a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs index a794991..e05f165 100644 --- a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs @@ -7,10 +7,11 @@ internal partial class Operation { public Operation() { - HandleValidationError = HandleValidationErrors; + HandleRequestValidationError = HandleValidationErrors; + ValidateResponse = false; } - private static Response HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) { var response = validationResults.Select(result => Responses.BadRequest.RequiredErrorAndName.Create( @@ -34,6 +35,9 @@ internal partial Task HandleAsync(Request request, CancellationToken c Status = 2 } }; - return Task.FromResult(response); + var validationContext = response.Validate(ValidationLevel.Detailed); + return !validationContext.IsValid + ? throw new JsonValidationException("Response is not valid", validationContext.Results) + : Task.FromResult(response); } } \ 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 b64c999..a535008 100644 --- a/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi30/Paths/FooFooId/Put/Operation.Handler.cs @@ -7,10 +7,11 @@ internal partial class Operation { public Operation() { - HandleValidationError = HandleValidationErrors; + HandleRequestValidationError = HandleValidationErrors; + ValidateResponse = true; } - private static Response HandleValidationErrors(ImmutableList validationResults) + private static Response.BadRequest400 HandleValidationErrors(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 8be87a8..7fd2835 100644 --- a/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi31/Paths/FooFooId/Put/Operation.Handler.cs @@ -7,7 +7,7 @@ internal partial class Operation { public Operation() { - HandleValidationError = HandleValidationErrors; + HandleRequestValidationError = HandleValidationErrors; } private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) From c018fd16237c51402e739dccd4ed2766560fe7c0 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 17 Jan 2026 12:09:37 +0100 Subject: [PATCH 27/29] let user override validation level per operation --- .../CodeGeneration/OperationGenerator.cs | 10 +++++++--- .../Paths/FooFooId/Put/Operation.Handler.cs | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 8521088..45d8561 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -28,6 +28,11 @@ internal partial class Operation internal const string PathTemplate = "{{pathTemplate}}"; internal const string Method = "{{method.Method}}"; + /// + /// Set validation level for requests and responses + /// + internal ValidationLevel ValidationLevel { get; init; } = ValidationLevel.Detailed; + /// /// Should responses be validated? /// If the response has already been validated, this can be disabled to avoid redundant validation. @@ -56,8 +61,7 @@ internal static async Task HandleAsync( var request = await Request.BindAsync(context, cancellationToken) .ConfigureAwait(false); - var validationLevel = ValidationLevel.Detailed; - var validationContext = request.Validate(validationLevel); + var validationContext = request.Validate(operation.ValidationLevel); if (!validationContext.IsValid) { operation.HandleRequestValidationError(validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri)) @@ -69,7 +73,7 @@ internal static async Task HandleAsync( .ConfigureAwait(false); if (operation.ValidateResponse) { - validationContext = response.Validate(validationLevel); + validationContext = response.Validate(operation.ValidationLevel); if (!validationContext.IsValid) { var validationResult = validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri); diff --git a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs index e05f165..e21481c 100644 --- a/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs +++ b/tests/Example.OpenApi20/Paths/FooFooId/Put/Operation.Handler.cs @@ -9,6 +9,7 @@ public Operation() { HandleRequestValidationError = HandleValidationErrors; ValidateResponse = false; + ValidationLevel = ValidationLevel.Detailed; } private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) @@ -35,7 +36,8 @@ internal partial Task HandleAsync(Request request, CancellationToken c Status = 2 } }; - var validationContext = response.Validate(ValidationLevel.Detailed); + + var validationContext = response.Validate(ValidationLevel); return !validationContext.IsValid ? throw new JsonValidationException("Response is not valid", validationContext.Results) : Task.FromResult(response); From abf819f6b61de87513fd5453d8c66dc52995fa35 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 17 Jan 2026 13:51:35 +0100 Subject: [PATCH 28/29] refactor: move parameter spec version to generated constant --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 3 ++- .../HttpRequestExtensionsGenerator.cs | 21 +++++++++---------- .../HttpResponseExtensionsGenerator.cs | 12 +++++++---- .../CodeGeneration/ResponseHeaderGenerator.cs | 1 - .../OpenApi/Visitor/OpenApiVisitor.cs | 4 ++-- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 8610d53..a7058df 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -88,7 +88,8 @@ private static void GenerateCode(SourceProductionContext context, ( rootNamespace); httpRequestExtensionsGenerator.GenerateHttpRequestExtensionsClass().AddTo(context); - var httpResponseExtensionsGenerator = new HttpResponseExtensionsGenerator(rootNamespace); + var httpResponseExtensionsGenerator = new HttpResponseExtensionsGenerator(rootNamespace, + openApiVersion); httpResponseExtensionsGenerator.GenerateHttpResponseExtensionsClass().AddTo(context); var apiConfigurationGenerator = new ApiConfigurationGenerator(rootNamespace); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs index 40789a3..9b14792 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs @@ -1,6 +1,4 @@ -using System; -using System.IO; -using Microsoft.OpenApi; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.OpenApi; namespace OpenAPI.WebApiGenerator.CodeGeneration; @@ -11,8 +9,6 @@ internal sealed class HttpRequestExtensionsGenerator( { private const string HttpRequestExtensionsClassName = "HttpRequestExtensions"; - private readonly string _openApiVersion = openApiVersion.GetParameterVersion(); - internal string CreateBindParameterInvocation( string requestVariableName, string bindingTypeName, @@ -20,7 +16,6 @@ internal string CreateBindParameterInvocation( $"""" {@namespace}.{HttpRequestExtensionsClassName}.Bind<{bindingTypeName}>( {requestVariableName}, - "{_openApiVersion}", """ {parameter.Serialize(openApiVersion)} """) @@ -54,27 +49,31 @@ namespace {{{@namespace}}}; internal static class {{{HttpRequestExtensionsClassName}}} { + private const string ParameterValueParserVersion = "{{{openApiVersion.GetParameterVersion()}}}"; + private static readonly ConcurrentDictionary ParserCache = new(); - private static IParameterValueParser GetParser(IParameter parameter) => ParserCache.GetOrAdd(parameter, _ => parameter.CreateParameterValueParser()); + private static IParameterValueParser GetParser(IParameter parameter) => + ParserCache.GetOrAdd(parameter, _ => + parameter.CreateParameterValueParser()); private static readonly ConcurrentDictionary ParameterCache = new(); - private static IParameter GetParameter(string openApiVersion, string parameterSpecificationAsJson) => ParameterCache.GetOrAdd(parameterSpecificationAsJson, _ => ParameterFactory.OpenApi(openApiVersion, parameterSpecificationAsJson)); + private static IParameter GetParameter(string parameterSpecificationAsJson) => + ParameterCache.GetOrAdd(parameterSpecificationAsJson, _ => + ParameterFactory.OpenApi(ParameterValueParserVersion, parameterSpecificationAsJson)); /// /// Binds an http parameter to a json type /// /// - /// OpenAPI Version of the specification /// OpenAPI parameter specification formatted as json /// The type to bind /// The bound instance /// internal static T Bind(this HttpRequest request, - string openApiVersion, string parameterSpecificationAsJson) where T : struct, IJsonValue { - var parameter = GetParameter(openApiVersion, parameterSpecificationAsJson); + var parameter = GetParameter(parameterSpecificationAsJson); return parameter switch { _ when parameter.InBody => T.Parse(request.BodyReader.AsStream()), diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs index a583d09..3405be0 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/HttpResponseExtensionsGenerator.cs @@ -1,7 +1,11 @@ -namespace OpenAPI.WebApiGenerator.CodeGeneration; +using Microsoft.OpenApi; +using OpenAPI.WebApiGenerator.OpenApi; + +namespace OpenAPI.WebApiGenerator.CodeGeneration; internal sealed class HttpResponseExtensionsGenerator( - string @namespace) + string @namespace, + OpenApiSpecVersion openApiSpecVersion) { private const string HttpResponseExtensionsClassName = "HttpResponseExtensions"; public string Namespace => @namespace; @@ -31,9 +35,9 @@ namespace {{{@namespace}}}; internal static class {{{HttpResponseExtensionsClassName}}} { private static readonly ConcurrentDictionary ParserCache = new(); + private const string ParameterValueParserVersion = "{{{openApiSpecVersion.GetParameterVersion()}}}"; internal static void WriteResponseHeader(this HttpResponse response, - string openApiVersion, string headerSpecificationAsJson, string name, TValue value) @@ -45,7 +49,7 @@ internal static void WriteResponseHeader(this HttpResponse response, } var parser = ParserCache.GetOrAdd(headerSpecificationAsJson, - _ => ParameterValueParserFactory.OpenApi(openApiVersion, headerSpecificationAsJson)); + _ => ParameterValueParserFactory.OpenApi(ParameterValueParserVersion, headerSpecificationAsJson)); var jsonValue = value.Serialize(); var serializedValue = parser.Serialize(JsonNode.Parse(jsonValue)); response.Headers[name] = serializedValue; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs index 909fe62..59365da 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseHeaderGenerator.cs @@ -40,7 +40,6 @@ internal string GenerateWriteDirective(string responseVariableName) return $"""" {responseVariableName}.WriteResponseHeader( - "{openApiSpecVersion.GetParameterVersion()}", """ {headerSpecificationAsJson.Indent(4).TrimStart()} """, diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs index 0e945c8..d9e2c6e 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs @@ -15,8 +15,8 @@ public static IOpenApiVisitor V(OpenApiSpecVersion version, OpenApiReference OpenApiV2Visitor.Visit(openApiReference), - OpenApiSpecVersion.OpenApi3_0 or OpenApiSpecVersion.OpenApi3_1 => - OpenApiV3Visitor.Visit(openApiReference), + OpenApiSpecVersion.OpenApi3_0 => OpenApiV3Visitor.Visit(openApiReference), + OpenApiSpecVersion.OpenApi3_1 => OpenApiV3Visitor.Visit(openApiReference), _ => throw new InvalidOperationException($"OpenAPI version {version} not supported") }; } From 8ccbcb457801ab2ed64984bca72b6ebc0b25c719 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 17 Jan 2026 14:41:34 +0100 Subject: [PATCH 29/29] doc(openapi): include supported openapi version in README --- README.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ad8b50..74fc4ee 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,17 @@ Generates scaffolding for Web APIs from OpenAPI specifications. The generated functionality will route, serialize/deserialize and validate payloads according to the specification. +Supported OpenAPI version: +- [2.0](https://spec.openapis.org/oas/v2.0.html) +- [3.0.0](https://spec.openapis.org/oas/v3.0.0.html) +- [3.0.1](https://spec.openapis.org/oas/v3.0.1.html) +- [3.0.2](https://spec.openapis.org/oas/v3.0.2.html) +- [3.0.3](https://spec.openapis.org/oas/v3.0.3.html) +- [3.0.4](https://spec.openapis.org/oas/v3.0.4.html) +- [3.1.0](https://spec.openapis.org/oas/v3.1.0.html) +- [3.1.1](https://spec.openapis.org/oas/v3.1.1.html) +- [3.1.2](https://spec.openapis.org/oas/v3.1.2.html) + API frameworks supported: - [Minimal API](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis) @@ -52,9 +63,14 @@ app.MapOperations(); app.Run(); ``` -See [Example.OpenApi20](tests/Example.OpenApi20) as an example. +Examples: +- [OpenAPI 2.0](tests/Example.OpenApi20) +- [OpenAPI 3.0](tests/Example.OpenApi30) +- [OpenAPI 3.1](tests/Example.OpenApi31) + +All specifications mostly generate similar abstractions. What might differ is the location of generated resources, which follows the respective structure of the OpenAPI specification, and the JSON types, which are based on the respective schema version. -**Note**: The Example.OpenApi20 references the generator through a project reference. Use a package reference instead as described above. +**Note**: The Examples reference the generator through a project reference. Use a package reference instead as described above. ## Implementing an [API Operation](https://swagger.io/specification/#operation-object) The generator generates stubbed partial classes for any operation handlers (`Foo.Bar.Operation.Handler.cs`) if there are none existing in the project and logs it with a compiler warning (AF1001). The classes should be copied into source control and the operation methods implemented. The operation methods have a familiar request/response design: