From 0574bcae48f6814c649ad5da378521951679d1d3 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 12 Mar 2026 17:59:44 +0100 Subject: [PATCH 01/36] remove unused class --- .../CodeGeneration/TypeSpecification.cs | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 src/OpenAPI.WebApiGenerator/CodeGeneration/TypeSpecification.cs diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/TypeSpecification.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/TypeSpecification.cs deleted file mode 100644 index 65e5f2c..0000000 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/TypeSpecification.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace OpenAPI.WebApiGenerator.CodeGeneration; - -internal sealed class TypeSpecification(string name, string @namespace, AdditionalText schema) -{ - public string Name { get; } = name; - public string Namespace { get; } = @namespace; - public AdditionalText Schema { get; } = schema; -} \ No newline at end of file From 5e8920fc29bb9ecc389df7ffe16c90ab75c1e88f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 12 Mar 2026 18:05:41 +0100 Subject: [PATCH 02/36] add generator for sequential json enumerators --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 3 +- .../SequentialJsonEnumeratorGenerator.cs | 136 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 776ca45..34e95c6 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -89,9 +89,10 @@ private static void GenerateCode(SourceProductionContext context, var validationExtensionsGenerator = new ValidationExtensionsGenerator(rootNamespace); validationExtensionsGenerator.GenerateClass().AddTo(context); + var sequentialJsonEnumeratorsGenerator = new SequentialJsonEnumeratorGenerator(rootNamespace); + sequentialJsonEnumeratorsGenerator.GenerateClasses().AddTo(context); var operations = new List<(string Namespace, KeyValuePair Operation)>(); - var securityParameterGenerators = new ConcurrentDictionary>(); foreach (var path in openApi.Paths) { var pathExpression = path.Key; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs new file mode 100644 index 0000000..1bb40ab --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs @@ -0,0 +1,136 @@ +using System.Net.Http.Headers; +using Corvus.Json.CodeGeneration; +using Corvus.Json.CodeGeneration.CSharp; +using OpenAPI.WebApiGenerator.Extensions; + +namespace OpenAPI.WebApiGenerator.CodeGeneration; + +internal sealed class SequentialJsonEnumeratorGenerator(string @namespace) +{ + internal string GenerateConstructorInstance( + MediaTypeHeaderValue mediaType, + TypeDeclaration itemTypeDeclaration, + string streamParameterReference, + string schemaLocation, + string validationLevel) => +$""" +new {GetFullyQualifiedTypeName(mediaType)}<{itemTypeDeclaration.FullyQualifiedDotnetTypeName()}>({streamParameterReference}, "{schemaLocation}", {validationLevel}) +"""; + + internal string GetFullyQualifiedTypeName(MediaTypeHeaderValue mediaType) => + $"{@namespace}.{mediaType.MediaType.ToLower() switch + { + "application/jsonl" or "application/x-ndjson" or "application/x-jsonlines" => "ApplicationJsonlEnumerator", + "application/json-seq" or "application/geo+json-seq" => "ApplicationJsonSeqEnumerator", + _ => mediaType.MediaType.ToPascalCase() + }}"; + internal SourceCode GenerateClasses() => new("SequentialJsonEnumerators.g.cs", +$$""" +#nullable enable +using Microsoft.AspNetCore.Authorization; +using System; + +namespace {{@namespace}}; + +/// +/// Base class for sequential json enumerators +/// +internal abstract class SequentialJsonEnumerator( + Stream stream, + string schemaLocation, + ValidationLevel validationLevel) : IAsyncEnumerator + where T : struct, IJsonValue +{ + private PipeReader PipeReader { get; } = PipeReader.Create(stream); + protected abstract byte Delimiter { get; } + public ValueTask DisposeAsync() => PipeReader.CompleteAsync(); + + private int _itemPosition; + + /// + public async ValueTask MoveNextAsync() + { + do + { + var result = await PipeReader.ReadAsync() + .ConfigureAwait(false); + var buffer = result.Buffer; + var position = buffer.PositionOf(Delimiter); + + if (position != null) + { + var data = buffer.Slice(0, position.Value); + Current = ParseItem(data); + PipeReader.AdvanceTo(position.Value); + return true; + } + + if (result.IsCompleted) + { + PipeReader.AdvanceTo(buffer.End); + return false; + } + + PipeReader.AdvanceTo(buffer.Start, buffer.End); + _itemPosition++; + } while (true); + } + + /// + public T Current { get; private set; } + + /// + /// Parse the read item + /// + /// Data read up until the Delimiter + /// The parsed item + protected abstract T ParseItem(ReadOnlySequence data); + + /// + /// Validates the current item + /// + /// The validation result + internal ValidationContext ValidateCurrentItem() => + Current.Validate($"{schemaLocation}/{_itemPosition}", true, ValidationContext.ValidContext, validationLevel); +} + +/// +/// Sequential json enumerator for jsonl +/// +internal sealed class ApplicationJsonlEnumerator(Stream stream, string schemaLocation, ValidationLevel validationLevel) : + SequentialJsonEnumerator(stream, schemaLocation, validationLevel) + where T : struct, IJsonValue +{ + protected override byte Delimiter => 0x0A; + protected override T ParseItem(ReadOnlySequence data) => T.Parse(data); +} + +/// +/// Sequential json enumerator for json-seq +/// +internal sealed class ApplicationJsonSeqEnumerator(Stream stream, string schemaLocation, ValidationLevel validationLevel) : + SequentialJsonEnumerator(stream, schemaLocation, validationLevel) + where T : struct, IJsonValue +{ + private const byte RecordSeparator = 0x1E; + protected override byte Delimiter => 0x0A; + + protected override T ParseItem(ReadOnlySequence data) + { + var rsPosition = data.PositionOf(RecordSeparator); + + // RS should be first. + // If it is not, then the data is incomplete and invalid, + // let JSON validation handle it + if (rsPosition.HasValue && rsPosition.Value.GetInteger() == 0) + { + data = data.Slice(data.GetPosition(1)); + } + + return T.Parse(data); + } +} + +#nullable restore +"""); +} \ No newline at end of file From af4da6d2adfdcb4238092d3e4875c336ec6ff050 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 12 Mar 2026 18:07:52 +0100 Subject: [PATCH 03/36] feat(request): generate sequential json request content --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 5 +- .../RequestBodyContentGenerator.cs | 52 ++++++++++++------- .../OpenApi/TypeMetadata.cs | 3 +- .../V3/OpenApiV3Visitor.OperationVisitor.cs | 2 +- ...questBodyContentGeneratorExtensionTests.cs | 4 +- .../OpenAPI.WebApiGenerator.Tests.csproj | 1 + 6 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 34e95c6..a0beee8 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -138,9 +138,10 @@ private static void GenerateCode(SourceProductionContext context, var schemaReference = openApiOperationVisitor.GetSchemaReference(mediaType); var typeDeclaration = schemaGenerator.Generate(schemaReference); return new RequestBodyContentGenerator( - pair.Key, + pair, typeDeclaration, - httpRequestExtensionsGenerator); + httpRequestExtensionsGenerator, + sequentialJsonEnumeratorsGenerator); }).ToList(); requestBodyGenerator = new RequestBodyGenerator( body, diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index c6257f7..2339cb3 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -1,39 +1,55 @@ -using System.Net.Http.Headers; +using System.Collections.Generic; +using System.Net.Http.Headers; using Corvus.Json.CodeGeneration; using Corvus.Json.CodeGeneration.CSharp; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; internal sealed class RequestBodyContentGenerator( - string contentType, + KeyValuePair contentMediaType, TypeDeclaration typeDeclaration, - HttpRequestExtensionsGenerator httpRequestExtensionsGenerator) + HttpRequestExtensionsGenerator httpRequestExtensionsGenerator, + SequentialJsonEnumeratorGenerator sequentialJsonEnumeratorGenerator) { private string FullyQualifiedTypeName => $"{FullyQualifiedTypeDeclarationIdentifier}?"; private string FullyQualifiedTypeDeclarationIdentifier => typeDeclaration.FullyQualifiedDotnetTypeName(); + private readonly bool _isSequentialMediaType = contentMediaType.Value.ItemSchema != null; + + internal string PropertyName { get; } = contentMediaType.Key.ToPascalCase(); - internal string PropertyName { get; } = contentType.ToPascalCase(); - - internal MediaTypeWithQualityHeaderValue ContentType { get; } = MediaTypeWithQualityHeaderValue.Parse(contentType); + internal MediaTypeWithQualityHeaderValue ContentType { get; } = MediaTypeWithQualityHeaderValue.Parse(contentMediaType.Key); internal string SchemaLocation => typeDeclaration.RelativeSchemaLocation; internal string GenerateRequestBindingDirective() => $""" -{PropertyName} = - ({httpRequestExtensionsGenerator.CreateBindBodyInvocation( - "request", - FullyQualifiedTypeDeclarationIdentifier) - .Indent(8).Trim()}) +{PropertyName} = {(_isSequentialMediaType ? + $"{sequentialJsonEnumeratorGenerator.GenerateConstructorInstance( + ContentType, + typeDeclaration, + "request.Body", + SchemaLocation, + "validationLevel")}" : + $"({httpRequestExtensionsGenerator.CreateBindBodyInvocation( + "request", + FullyQualifiedTypeDeclarationIdentifier).Indent(8).Trim()})")} """; + - public string GenerateRequestProperty() => - $$""" - /// - /// Request content for {{contentType}} - /// - internal {{FullyQualifiedTypeName}} {{PropertyName}} { get; private set; } - """; + public string GenerateRequestProperty() + { + var fullyQualifiedTypeName = _isSequentialMediaType + ? sequentialJsonEnumeratorGenerator.GetFullyQualifiedTypeName(ContentType) + : FullyQualifiedTypeName; + return +$$""" +/// +/// Request content for {{contentMediaType.Key}} +/// +internal {{fullyQualifiedTypeName}} {{PropertyName}} { get; private set; } +"""; + } } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs b/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs index 7ac2347..5792ddd 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs @@ -7,6 +7,7 @@ namespace OpenAPI.WebApiGenerator.OpenApi; internal sealed class TypeMetadata(string @namespace, string path, string name) { + private static readonly string[] SchemaMetaLeafNodeNames = ["schema", "itemSchema"]; internal static TypeMetadata From(JsonPointer pointer) { var segments = @@ -20,7 +21,7 @@ internal static TypeMetadata From(JsonPointer pointer) .ToArray() .AsSpan(); // Remove any schema leaf node, as that is metadata and doesn't describe the name of the type - if (segments[^1].Equals("schema", StringComparison.CurrentCultureIgnoreCase)) + if (SchemaMetaLeafNodeNames.Contains(segments[^1])) { segments = segments[..^1]; } diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs index a0686ce..52fd946 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs @@ -65,7 +65,7 @@ private void VisitRequestBody() new JsonReference(Reference.Uri, requestContentPointer .Append(content.Key) - .Append("schema") + .Append(content.Value.ItemSchema is not null ? "itemSchema" : "schema") .ToString() .AsSpan())); } diff --git a/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs index 90a07ce..3189532 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs +++ b/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs @@ -1,5 +1,7 @@ +using System.Collections.Generic; using System.Linq; using AwesomeAssertions; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.CodeGeneration; using Xunit; @@ -36,5 +38,5 @@ public void ListOfRequestBodyContentGenerators_SortByContentType_SortsAccordingT } private static RequestBodyContentGenerator CreateGenerator(string contentType) => - new(contentType, null!, null!); + new(new KeyValuePair(contentType, new OpenApiMediaType()), null!, null!, null!); } \ No newline at end of file diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj b/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj index 7643ccf..b4784f3 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all From 3258669be6718724a6f893314d527c2def93811e Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 12 Mar 2026 18:24:00 +0100 Subject: [PATCH 04/36] test: move unit tests to separate project as they cause dependency collision with analyzer dependencies for the generator tests --- OpenAPI.WebApiGenerator.sln | 16 ++++++++++++ .../OpenAPI.WebApiGenerator.csproj | 2 +- .../OpenAPI.WebApiGenerator.Tests.csproj | 1 - ...questBodyContentGeneratorExtensionTests.cs | 2 +- .../Extensions/MediaTypeExtensionsTests.cs | 2 +- .../OpenAPI.WebApiGenerator.UnitTests.csproj | 26 +++++++++++++++++++ 6 files changed, 45 insertions(+), 4 deletions(-) rename tests/{OpenAPI.WebApiGenerator.Tests => OpenAPI.WebApiGenerator.UnitTests}/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs (95%) rename tests/{OpenAPI.WebApiGenerator.Tests => OpenAPI.WebApiGenerator.UnitTests}/Extensions/MediaTypeExtensionsTests.cs (98%) create mode 100644 tests/OpenAPI.WebApiGenerator.UnitTests/OpenAPI.WebApiGenerator.UnitTests.csproj diff --git a/OpenAPI.WebApiGenerator.sln b/OpenAPI.WebApiGenerator.sln index 3db1389..4bd6030 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -34,6 +34,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi", "tests\Example.OpenApi\Example.OpenApi.csproj", "{4E274740-E49C-4E56-9B69-C33D9409C119}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenAPI.WebApiGenerator.UnitTests", "tests\OpenAPI.WebApiGenerator.UnitTests\OpenAPI.WebApiGenerator.UnitTests.csproj", "{2CED6DCB-B934-438D-98F0-21C784B353EB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -188,6 +192,18 @@ Global {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x64.Build.0 = Release|Any CPU {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x86.ActiveCfg = Release|Any CPU {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x86.Build.0 = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|x64.Build.0 = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|x86.ActiveCfg = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|x86.Build.0 = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|Any CPU.Build.0 = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|x64.ActiveCfg = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|x64.Build.0 = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|x86.ActiveCfg = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj index 9f8f1af..ea48eed 100644 --- a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj +++ b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj @@ -83,6 +83,6 @@ - + diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj b/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj index b4784f3..7643ccf 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenAPI.WebApiGenerator.Tests.csproj @@ -10,7 +10,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs b/tests/OpenAPI.WebApiGenerator.UnitTests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs similarity index 95% rename from tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs rename to tests/OpenAPI.WebApiGenerator.UnitTests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs index 3189532..97ddce2 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs +++ b/tests/OpenAPI.WebApiGenerator.UnitTests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs @@ -5,7 +5,7 @@ using OpenAPI.WebApiGenerator.CodeGeneration; using Xunit; -namespace OpenAPI.WebApiGenerator.Tests.CodeGeneration; +namespace OpenAPI.WebApiGenerator.UnitTests.CodeGeneration; public class RequestBodyContentGeneratorExtensionTests { diff --git a/tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs b/tests/OpenAPI.WebApiGenerator.UnitTests/Extensions/MediaTypeExtensionsTests.cs similarity index 98% rename from tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs rename to tests/OpenAPI.WebApiGenerator.UnitTests/Extensions/MediaTypeExtensionsTests.cs index 2a1a976..f123f42 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs +++ b/tests/OpenAPI.WebApiGenerator.UnitTests/Extensions/MediaTypeExtensionsTests.cs @@ -3,7 +3,7 @@ using OpenAPI.WebApiGenerator.Extensions; using Xunit; -namespace OpenAPI.WebApiGenerator.Tests.Extensions; +namespace OpenAPI.WebApiGenerator.UnitTests.Extensions; public class MediaTypeExtensionsTests { diff --git a/tests/OpenAPI.WebApiGenerator.UnitTests/OpenAPI.WebApiGenerator.UnitTests.csproj b/tests/OpenAPI.WebApiGenerator.UnitTests/OpenAPI.WebApiGenerator.UnitTests.csproj new file mode 100644 index 0000000..4c72623 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.UnitTests/OpenAPI.WebApiGenerator.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + \ No newline at end of file From f5a8772b6330df2392590713e45b189cd9c94a1e Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 12 Mar 2026 18:26:51 +0100 Subject: [PATCH 05/36] include missing dependencies --- .../CodeGeneration/SequentialJsonEnumeratorGenerator.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs index 1bb40ab..8b0c692 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs @@ -27,8 +27,11 @@ internal string GetFullyQualifiedTypeName(MediaTypeHeaderValue mediaType) => internal SourceCode GenerateClasses() => new("SequentialJsonEnumerators.g.cs", $$""" #nullable enable +using Corvus.Json; using Microsoft.AspNetCore.Authorization; using System; +using System.Buffers; +using System.IO.Pipelines; namespace {{@namespace}}; From e49bc772559cc6d98120997973c7574ad268b60b Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 12 Mar 2026 18:32:48 +0100 Subject: [PATCH 06/36] ignore casing when looking for leaf schema nodes --- src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs b/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs index 5792ddd..1dd4335 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs @@ -21,7 +21,7 @@ internal static TypeMetadata From(JsonPointer pointer) .ToArray() .AsSpan(); // Remove any schema leaf node, as that is metadata and doesn't describe the name of the type - if (SchemaMetaLeafNodeNames.Contains(segments[^1])) + if (SchemaMetaLeafNodeNames.Contains(segments[^1], StringComparer.OrdinalIgnoreCase)) { segments = segments[..^1]; } From f573467242170159fe89cabf5b25d4397fe2df7f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 12 Mar 2026 23:18:15 +0100 Subject: [PATCH 07/36] add missing implicit dependency nodatime --- src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj index ea48eed..2ab5365 100644 --- a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj +++ b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj @@ -41,6 +41,7 @@ + @@ -61,6 +62,7 @@ + all runtime; build; native; contentfiles; analyzers @@ -76,6 +78,7 @@ + From dc04385c08161c806d28d0005f9fdfb948513b1c Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 12 Mar 2026 23:53:13 +0100 Subject: [PATCH 08/36] add validation method for sequential json enumerators --- .../RequestBodyContentGenerator.cs | 18 +++----- .../CodeGeneration/RequestBodyGenerator.cs | 2 +- .../SequentialJsonEnumeratorGenerator.cs | 46 +++++++++++++------ .../FooFooIdEvents/Post/Operation.Handler.cs | 10 ++++ tests/Example.OpenApi32/openapi.json | 25 ++++++++++ 5 files changed, 74 insertions(+), 27 deletions(-) create mode 100644 tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index 2339cb3..2025bb6 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -13,14 +13,12 @@ internal sealed class RequestBodyContentGenerator( HttpRequestExtensionsGenerator httpRequestExtensionsGenerator, SequentialJsonEnumeratorGenerator sequentialJsonEnumeratorGenerator) { - private string FullyQualifiedTypeName => - $"{FullyQualifiedTypeDeclarationIdentifier}?"; - private string FullyQualifiedTypeDeclarationIdentifier => typeDeclaration.FullyQualifiedDotnetTypeName(); private readonly bool _isSequentialMediaType = contentMediaType.Value.ItemSchema != null; internal string PropertyName { get; } = contentMediaType.Key.ToPascalCase(); - + internal bool IsPropertyStruct => !_isSequentialMediaType; + internal MediaTypeWithQualityHeaderValue ContentType { get; } = MediaTypeWithQualityHeaderValue.Parse(contentMediaType.Key); internal string SchemaLocation => typeDeclaration.RelativeSchemaLocation; @@ -28,11 +26,9 @@ internal string GenerateRequestBindingDirective() => $""" {PropertyName} = {(_isSequentialMediaType ? $"{sequentialJsonEnumeratorGenerator.GenerateConstructorInstance( - ContentType, + ContentType, typeDeclaration, - "request.Body", - SchemaLocation, - "validationLevel")}" : + "request.Body")}" : $"({httpRequestExtensionsGenerator.CreateBindBodyInvocation( "request", FullyQualifiedTypeDeclarationIdentifier).Indent(8).Trim()})")} @@ -42,14 +38,14 @@ internal string GenerateRequestBindingDirective() => public string GenerateRequestProperty() { var fullyQualifiedTypeName = _isSequentialMediaType - ? sequentialJsonEnumeratorGenerator.GetFullyQualifiedTypeName(ContentType) - : FullyQualifiedTypeName; + ? sequentialJsonEnumeratorGenerator.GetFullyQualifiedTypeName(ContentType, typeDeclaration) + : FullyQualifiedTypeDeclarationIdentifier; return $$""" /// /// Request content for {{contentMediaType.Key}} /// -internal {{fullyQualifiedTypeName}} {{PropertyName}} { get; private set; } +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 d903a0a..a3aa04f 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs @@ -120,7 +120,7 @@ internal ValidationContext Validate(ValidationContext validationContext, Validat {{{_contentGenerators.AggregateToString(content => $""" true when {content.PropertyName} is not null => - {content.PropertyName}!.Value.Validate("{content.SchemaLocation}", true, validationContext, validationLevel), + {content.PropertyName}!.{(content.IsPropertyStruct ? "Value." : "")}Validate("{content.SchemaLocation}", true, validationContext, validationLevel), """)}} true when requestContentType is null => {{(_body.Required ? """validationContext.WithResult(false, "Request content is missing")""" : "validationContext")}}, diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs index 8b0c692..ab13a21 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs @@ -10,20 +10,21 @@ internal sealed class SequentialJsonEnumeratorGenerator(string @namespace) internal string GenerateConstructorInstance( MediaTypeHeaderValue mediaType, TypeDeclaration itemTypeDeclaration, - string streamParameterReference, - string schemaLocation, - string validationLevel) => + string streamParameterReference) => $""" -new {GetFullyQualifiedTypeName(mediaType)}<{itemTypeDeclaration.FullyQualifiedDotnetTypeName()}>({streamParameterReference}, "{schemaLocation}", {validationLevel}) +new {GetFullyQualifiedTypeName(mediaType, itemTypeDeclaration)}({streamParameterReference}) """; - internal string GetFullyQualifiedTypeName(MediaTypeHeaderValue mediaType) => + internal string GetFullyQualifiedTypeName( + MediaTypeHeaderValue mediaType, + TypeDeclaration itemTypeDeclaration) => $"{@namespace}.{mediaType.MediaType.ToLower() switch { "application/jsonl" or "application/x-ndjson" or "application/x-jsonlines" => "ApplicationJsonlEnumerator", "application/json-seq" or "application/geo+json-seq" => "ApplicationJsonSeqEnumerator", _ => mediaType.MediaType.ToPascalCase() - }}"; + }}<{itemTypeDeclaration.FullyQualifiedDotnetTypeName()}>"; + internal SourceCode GenerateClasses() => new("SequentialJsonEnumerators.g.cs", $$""" #nullable enable @@ -39,9 +40,7 @@ namespace {{@namespace}}; /// Base class for sequential json enumerators /// internal abstract class SequentialJsonEnumerator( - Stream stream, - string schemaLocation, - ValidationLevel validationLevel) : IAsyncEnumerator + Stream stream) : IAsyncEnumerator where T : struct, IJsonValue { private PipeReader PipeReader { get; } = PipeReader.Create(stream); @@ -49,7 +48,9 @@ internal abstract class SequentialJsonEnumerator( public ValueTask DisposeAsync() => PipeReader.CompleteAsync(); private int _itemPosition; - + private ValidationLevel _validationLevel = default; + private string _schemaLocation = "#"; + /// public async ValueTask MoveNextAsync() { @@ -94,14 +95,29 @@ public async ValueTask MoveNextAsync() /// /// The validation result internal ValidationContext ValidateCurrentItem() => - Current.Validate($"{schemaLocation}/{_itemPosition}", true, ValidationContext.ValidContext, validationLevel); + Current.Validate($"{_schemaLocation}/{_itemPosition}", true, ValidationContext.ValidContext, _validationLevel); + + /// + /// Validates the sequence + /// + /// The location of the schema describing the sequence + /// Is the sequence required? + /// Current validation context + /// The validation level + /// The validation result + internal ValidationContext Validate(string schemaLocation, bool isRequired, ValidationContext validationContext, ValidationLevel validationLevel) + { + _schemaLocation = schemaLocation; + _validationLevel = validationLevel; + return validationContext; + } } /// /// Sequential json enumerator for jsonl /// -internal sealed class ApplicationJsonlEnumerator(Stream stream, string schemaLocation, ValidationLevel validationLevel) : - SequentialJsonEnumerator(stream, schemaLocation, validationLevel) +internal sealed class ApplicationJsonlEnumerator(Stream stream) : + SequentialJsonEnumerator(stream) where T : struct, IJsonValue { protected override byte Delimiter => 0x0A; @@ -111,8 +127,8 @@ internal sealed class ApplicationJsonlEnumerator(Stream stream, string schema /// /// Sequential json enumerator for json-seq /// -internal sealed class ApplicationJsonSeqEnumerator(Stream stream, string schemaLocation, ValidationLevel validationLevel) : - SequentialJsonEnumerator(stream, schemaLocation, validationLevel) +internal sealed class ApplicationJsonSeqEnumerator(Stream stream) : + SequentialJsonEnumerator(stream) where T : struct, IJsonValue { private const byte RecordSeparator = 0x1E; diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs new file mode 100644 index 0000000..1a5e26c --- /dev/null +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs @@ -0,0 +1,10 @@ +namespace Example.OpenApi32.Paths.FooFooIdEvents.Post +{ + internal partial class Operation + { + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json index bce1f03..4b37a8b 100644 --- a/tests/Example.OpenApi32/openapi.json +++ b/tests/Example.OpenApi32/openapi.json @@ -80,6 +80,31 @@ "$ref": "#/components/parameters/FooId" } ] + }, + "/foo/{FooId}/events": { + "post": { + "operationId": "Import_Foo_Events", + "requestBody": { + "required": true, + "content": { + "application/jsonl": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted" + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/FooId" + } + ] } }, "components": { From c78091e48e0c5844a192fe222a94fe663a842808 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 13 Mar 2026 00:20:50 +0100 Subject: [PATCH 09/36] fix(schema): request content schema render duplicates due to not resolving the json pointer by following $ref --- .../OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs index 52fd946..5f367ea 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs @@ -58,14 +58,11 @@ private void VisitRequestBody() 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(content.Value.ItemSchema is not null ? "itemSchema" : "schema") + Visit("requestBody", "content", content.Key, content.Value.ItemSchema is not null ? "itemSchema" : "schema") .ToString() .AsSpan())); } From a8e2d17c677045502780de0b7b095f8fbe731c91 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 13 Mar 2026 23:00:05 +0100 Subject: [PATCH 10/36] test(jsonl): add integration test for jsonl --- .../FooTestSpecification.cs | 4 +-- .../ImportFooEventsTests.cs | 28 +++++++++++++++++++ .../FooFooIdEvents/Post/Operation.Handler.cs | 23 ++++++++++++--- 3 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs diff --git a/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs b/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs index 845dacd..3e4c3a6 100644 --- a/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs +++ b/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs @@ -6,8 +6,8 @@ public abstract class FooTestSpecification { protected CancellationToken CancellationToken { get; } = TestContext.Current.CancellationToken; - protected HttpContent CreateJsonContent(string json) => new StringContent( + protected HttpContent CreateJsonContent(string json, string mediaType = "application/json") => new StringContent( json, encoding: Encoding.UTF8, - mediaType: "application/json"); + mediaType: mediaType); } diff --git a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs new file mode 100644 index 0000000..a00335f --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs @@ -0,0 +1,28 @@ +using System.Net; +using System.Net.Mime; +using AwesomeAssertions; +using OpenAPI.IntegrationTestHelpers.Auth; + +namespace Example.OpenApi32.IntegrationTests; + +public class ImportFooEventsTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture +{ + [Fact] + public async Task ImportingFooEvents_ShouldReturnAccepted() + { + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication("update"); + var result = await client.SendAsync(new HttpRequestMessage + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1/events"), + Method = new HttpMethod("POST"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """, "application/jsonl") + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Accepted); + } +} diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs index 1a5e26c..3f690a4 100644 --- a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs @@ -1,10 +1,25 @@ -namespace Example.OpenApi32.Paths.FooFooIdEvents.Post +namespace Example.OpenApi32.Paths.FooFooIdEvents.Post; + +internal partial class Operation { - internal partial class Operation + internal partial async Task HandleAsync(Request request, CancellationToken cancellationToken) { - internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + var content = request.Body.ApplicationJsonl; + if (content == null) { - throw new NotImplementedException(); + throw new InvalidOperationException("missing content, this cannot occur"); } + while (await content.MoveNextAsync() + .ConfigureAwait(false)) + { + var validationContext = content.ValidateCurrentItem(); + if (!validationContext.IsValid) + { + throw new InvalidOperationException("Invalid item"); + } + _ = content.Current; + } + + return new Response.Accepted202(); } } \ No newline at end of file From d0ddd59688087f0f5770b06af74b0c7885e10e0c Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 13 Mar 2026 23:43:02 +0100 Subject: [PATCH 11/36] add cancellation token support for the sequential media type enumerators --- .../RequestBodyContentGenerator.cs | 3 ++- .../SequentialJsonEnumeratorGenerator.cs | 17 +++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index 2025bb6..c857768 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -28,7 +28,8 @@ internal string GenerateRequestBindingDirective() => $"{sequentialJsonEnumeratorGenerator.GenerateConstructorInstance( ContentType, typeDeclaration, - "request.Body")}" : + "request.Body", + "cancellationToken")}" : $"({httpRequestExtensionsGenerator.CreateBindBodyInvocation( "request", FullyQualifiedTypeDeclarationIdentifier).Indent(8).Trim()})")} diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs index ab13a21..110cb38 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs @@ -10,9 +10,10 @@ internal sealed class SequentialJsonEnumeratorGenerator(string @namespace) internal string GenerateConstructorInstance( MediaTypeHeaderValue mediaType, TypeDeclaration itemTypeDeclaration, - string streamParameterReference) => + string streamParameterReference, + string cancellationTokenParameterReference) => $""" -new {GetFullyQualifiedTypeName(mediaType, itemTypeDeclaration)}({streamParameterReference}) +new {GetFullyQualifiedTypeName(mediaType, itemTypeDeclaration)}({streamParameterReference}, {cancellationTokenParameterReference}) """; internal string GetFullyQualifiedTypeName( @@ -40,7 +41,7 @@ namespace {{@namespace}}; /// Base class for sequential json enumerators /// internal abstract class SequentialJsonEnumerator( - Stream stream) : IAsyncEnumerator + Stream stream, CancellationToken cancellationToken) : IAsyncEnumerator where T : struct, IJsonValue { private PipeReader PipeReader { get; } = PipeReader.Create(stream); @@ -56,7 +57,7 @@ public async ValueTask MoveNextAsync() { do { - var result = await PipeReader.ReadAsync() + var result = await PipeReader.ReadAsync(cancellationToken) .ConfigureAwait(false); var buffer = result.Buffer; var position = buffer.PositionOf(Delimiter); @@ -116,8 +117,8 @@ internal ValidationContext Validate(string schemaLocation, bool isRequired, Vali /// /// Sequential json enumerator for jsonl /// -internal sealed class ApplicationJsonlEnumerator(Stream stream) : - SequentialJsonEnumerator(stream) +internal sealed class ApplicationJsonlEnumerator(Stream stream, CancellationToken cancellationToken) : + SequentialJsonEnumerator(stream, cancellationToken) where T : struct, IJsonValue { protected override byte Delimiter => 0x0A; @@ -127,8 +128,8 @@ internal sealed class ApplicationJsonlEnumerator(Stream stream) : /// /// Sequential json enumerator for json-seq /// -internal sealed class ApplicationJsonSeqEnumerator(Stream stream) : - SequentialJsonEnumerator(stream) +internal sealed class ApplicationJsonSeqEnumerator(Stream stream, CancellationToken cancellationToken) : + SequentialJsonEnumerator(stream, cancellationToken) where T : struct, IJsonValue { private const byte RecordSeparator = 0x1E; From b0c6157c5021de21b19fbdb21c578b920a080c06 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Fri, 13 Mar 2026 23:58:50 +0100 Subject: [PATCH 12/36] bump item position when an item has been read --- .../CodeGeneration/SequentialJsonEnumeratorGenerator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs index 110cb38..2b34f93 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs @@ -48,7 +48,7 @@ internal abstract class SequentialJsonEnumerator( protected abstract byte Delimiter { get; } public ValueTask DisposeAsync() => PipeReader.CompleteAsync(); - private int _itemPosition; + private int _itemPosition = -1; private ValidationLevel _validationLevel = default; private string _schemaLocation = "#"; @@ -65,6 +65,7 @@ public async ValueTask MoveNextAsync() if (position != null) { var data = buffer.Slice(0, position.Value); + _itemPosition++; Current = ParseItem(data); PipeReader.AdvanceTo(position.Value); return true; @@ -77,7 +78,6 @@ public async ValueTask MoveNextAsync() } PipeReader.AdvanceTo(buffer.Start, buffer.End); - _itemPosition++; } while (true); } From 2fb8778ca7eebe645bb82c7827c3234a6744d385 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 14 Mar 2026 00:34:55 +0100 Subject: [PATCH 13/36] advanceto the consumed data which need to include the delimiter --- .../CodeGeneration/SequentialJsonEnumeratorGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs index 2b34f93..552fc29 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs @@ -67,7 +67,7 @@ public async ValueTask MoveNextAsync() var data = buffer.Slice(0, position.Value); _itemPosition++; Current = ParseItem(data); - PipeReader.AdvanceTo(position.Value); + PipeReader.AdvanceTo(buffer.GetPosition(1, position.Value)); return true; } From 2f52dacf1efa18e8a210ca492922adeb63165c7e Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 14 Mar 2026 01:07:28 +0100 Subject: [PATCH 14/36] handle sequential media types that doesn't require the sequence to strictly end with a delimiter character --- .../SequentialJsonEnumeratorGenerator.cs | 29 +++++++++++++++---- .../ImportFooEventsTests.cs | 5 ++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs index 552fc29..3e09af4 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs @@ -45,7 +45,18 @@ internal abstract class SequentialJsonEnumerator( where T : struct, IJsonValue { private PipeReader PipeReader { get; } = PipeReader.Create(stream); + + /// + /// Delimiter between each item + /// protected abstract byte Delimiter { get; } + + /// + /// Does the sequence require ending with a delimiter? + /// + protected abstract bool RequiresDelimiterAfterLastItem { get; } + + /// public ValueTask DisposeAsync() => PipeReader.CompleteAsync(); private int _itemPosition = -1; @@ -71,13 +82,19 @@ public async ValueTask MoveNextAsync() return true; } - if (result.IsCompleted) + switch (result.IsCompleted) { - PipeReader.AdvanceTo(buffer.End); - return false; + case true when buffer.IsEmpty: + return false; + case true when !RequiresDelimiterAfterLastItem: + _itemPosition++; + Current = ParseItem(buffer); + PipeReader.AdvanceTo(buffer.End); + return true; + default: + PipeReader.AdvanceTo(buffer.Start, buffer.End); + break; } - - PipeReader.AdvanceTo(buffer.Start, buffer.End); } while (true); } @@ -122,6 +139,7 @@ internal sealed class ApplicationJsonlEnumerator(Stream stream, CancellationT where T : struct, IJsonValue { protected override byte Delimiter => 0x0A; + protected override bool RequiresDelimiterAfterLastItem => false; protected override T ParseItem(ReadOnlySequence data) => T.Parse(data); } @@ -134,6 +152,7 @@ internal sealed class ApplicationJsonSeqEnumerator(Stream stream, Cancellatio { private const byte RecordSeparator = 0x1E; protected override byte Delimiter => 0x0A; + protected override bool RequiresDelimiterAfterLastItem => true; protected override T ParseItem(ReadOnlySequence data) { diff --git a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs index a00335f..2d0504b 100644 --- a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs +++ b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs @@ -18,9 +18,8 @@ public async Task ImportingFooEvents_ShouldReturnAccepted() Method = new HttpMethod("POST"), Content = CreateJsonContent( """ - { - "Name": "test" - } + { "Name": "test" } + { "Name": "another test" } """, "application/jsonl") }, CancellationToken); result.StatusCode.Should().Be(HttpStatusCode.Accepted); From 83e0b962a839d8c087bf899e9d258da4be5556a4 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sat, 14 Mar 2026 01:17:07 +0100 Subject: [PATCH 15/36] test(request): assert all sequential json objects was read --- .../ImportFooEventsTests.cs | 5 ++++- .../Paths/FooFooIdEvents/Post/Operation.Handler.cs | 11 ++++++++++- tests/Example.OpenApi32/openapi.json | 11 ++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs index 2d0504b..8a07769 100644 --- a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs +++ b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Mime; using AwesomeAssertions; using OpenAPI.IntegrationTestHelpers.Auth; @@ -22,6 +21,10 @@ public async Task ImportingFooEvents_ShouldReturnAccepted() { "Name": "another test" } """, "application/jsonl") }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.Accepted); + result.Headers.Should().HaveCount(1); + result.Headers.GetValues("ImportedEvents") + .Should().HaveCount(1).And.AllBe("2"); } } diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs index 3f690a4..698acf0 100644 --- a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs @@ -9,6 +9,8 @@ internal partial async Task HandleAsync(Request request, CancellationT { throw new InvalidOperationException("missing content, this cannot occur"); } + + var importedEvents = 0; while (await content.MoveNextAsync() .ConfigureAwait(false)) { @@ -18,8 +20,15 @@ internal partial async Task HandleAsync(Request request, CancellationT throw new InvalidOperationException("Invalid item"); } _ = content.Current; + importedEvents++; } - return new Response.Accepted202(); + return new Response.Accepted202 + { + Headers = new Response.Accepted202.ResponseHeaders + { + ImportedEvents = importedEvents + } + }; } } \ No newline at end of file diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json index 4b37a8b..34bae64 100644 --- a/tests/Example.OpenApi32/openapi.json +++ b/tests/Example.OpenApi32/openapi.json @@ -96,7 +96,16 @@ }, "responses": { "202": { - "description": "Accepted" + "description": "Accepted", + "headers": { + "ImportedEvents": { + "description": "Number of events imported", + "schema": { + "type": "integer" + }, + "required": true + } + } } } }, From c963f975ad51d128c740dde17497a30d3f041763 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Sun, 15 Mar 2026 14:10:32 +0100 Subject: [PATCH 16/36] refactor sequential media type generator to produce an enumerable instead of enumerator as it is simpler to use correctly --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 2 +- .../RequestBodyContentGenerator.cs | 9 +- ...or.cs => SequentialMediaTypesGenerator.cs} | 125 ++++++++++-------- .../FooFooIdEvents/Post/Operation.Handler.cs | 6 +- 4 files changed, 75 insertions(+), 67 deletions(-) rename src/OpenAPI.WebApiGenerator/CodeGeneration/{SequentialJsonEnumeratorGenerator.cs => SequentialMediaTypesGenerator.cs} (55%) diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index a0beee8..9c55283 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -89,7 +89,7 @@ private static void GenerateCode(SourceProductionContext context, var validationExtensionsGenerator = new ValidationExtensionsGenerator(rootNamespace); validationExtensionsGenerator.GenerateClass().AddTo(context); - var sequentialJsonEnumeratorsGenerator = new SequentialJsonEnumeratorGenerator(rootNamespace); + var sequentialJsonEnumeratorsGenerator = new SequentialMediaTypesGenerator(rootNamespace); sequentialJsonEnumeratorsGenerator.GenerateClasses().AddTo(context); var operations = new List<(string Namespace, KeyValuePair Operation)>(); diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index c857768..0e1eeb7 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -11,7 +11,7 @@ internal sealed class RequestBodyContentGenerator( KeyValuePair contentMediaType, TypeDeclaration typeDeclaration, HttpRequestExtensionsGenerator httpRequestExtensionsGenerator, - SequentialJsonEnumeratorGenerator sequentialJsonEnumeratorGenerator) + SequentialMediaTypesGenerator sequentialMediaTypesGenerator) { private string FullyQualifiedTypeDeclarationIdentifier => typeDeclaration.FullyQualifiedDotnetTypeName(); private readonly bool _isSequentialMediaType = contentMediaType.Value.ItemSchema != null; @@ -25,11 +25,10 @@ internal sealed class RequestBodyContentGenerator( internal string GenerateRequestBindingDirective() => $""" {PropertyName} = {(_isSequentialMediaType ? - $"{sequentialJsonEnumeratorGenerator.GenerateConstructorInstance( + $"{sequentialMediaTypesGenerator.GenerateConstructorInstance( ContentType, typeDeclaration, - "request.Body", - "cancellationToken")}" : + "request.Body")}" : $"({httpRequestExtensionsGenerator.CreateBindBodyInvocation( "request", FullyQualifiedTypeDeclarationIdentifier).Indent(8).Trim()})")} @@ -39,7 +38,7 @@ internal string GenerateRequestBindingDirective() => public string GenerateRequestProperty() { var fullyQualifiedTypeName = _isSequentialMediaType - ? sequentialJsonEnumeratorGenerator.GetFullyQualifiedTypeName(ContentType, typeDeclaration) + ? sequentialMediaTypesGenerator.GetFullyQualifiedTypeName(ContentType, typeDeclaration) : FullyQualifiedTypeDeclarationIdentifier; return $$""" diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs similarity index 55% rename from src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs rename to src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs index 3e09af4..ed581d7 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialJsonEnumeratorGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs @@ -5,15 +5,14 @@ namespace OpenAPI.WebApiGenerator.CodeGeneration; -internal sealed class SequentialJsonEnumeratorGenerator(string @namespace) +internal sealed class SequentialMediaTypesGenerator(string @namespace) { internal string GenerateConstructorInstance( MediaTypeHeaderValue mediaType, TypeDeclaration itemTypeDeclaration, - string streamParameterReference, - string cancellationTokenParameterReference) => + string streamParameterReference) => $""" -new {GetFullyQualifiedTypeName(mediaType, itemTypeDeclaration)}({streamParameterReference}, {cancellationTokenParameterReference}) +new {GetFullyQualifiedTypeName(mediaType, itemTypeDeclaration)}({streamParameterReference}) """; internal string GetFullyQualifiedTypeName( @@ -21,12 +20,12 @@ internal string GetFullyQualifiedTypeName( TypeDeclaration itemTypeDeclaration) => $"{@namespace}.{mediaType.MediaType.ToLower() switch { - "application/jsonl" or "application/x-ndjson" or "application/x-jsonlines" => "ApplicationJsonlEnumerator", - "application/json-seq" or "application/geo+json-seq" => "ApplicationJsonSeqEnumerator", + "application/jsonl" or "application/x-ndjson" or "application/x-jsonlines" => "ApplicationJsonlEnumerable", + "application/json-seq" or "application/geo+json-seq" => "ApplicationJsonSeqEnumerable", _ => mediaType.MediaType.ToPascalCase() }}<{itemTypeDeclaration.FullyQualifiedDotnetTypeName()}>"; - internal SourceCode GenerateClasses() => new("SequentialJsonEnumerators.g.cs", + internal SourceCode GenerateClasses() => new("SequentialMediaTypes.g.cs", $$""" #nullable enable using Corvus.Json; @@ -38,14 +37,16 @@ internal string GetFullyQualifiedTypeName( namespace {{@namespace}}; /// -/// Base class for sequential json enumerators +/// Base class for sequential json enumerable /// -internal abstract class SequentialJsonEnumerator( - Stream stream, CancellationToken cancellationToken) : IAsyncEnumerator +internal abstract class SequentialJsonEnumerable(Stream stream) : IAsyncEnumerable where T : struct, IJsonValue { - private PipeReader PipeReader { get; } = PipeReader.Create(stream); - + private int _itemPosition = -1; + private ValidationLevel _validationLevel = default; + private string _schemaLocation = "#"; + private T? _current; + /// /// Delimiter between each item /// @@ -55,52 +56,60 @@ internal abstract class SequentialJsonEnumerator( /// Does the sequence require ending with a delimiter? /// protected abstract bool RequiresDelimiterAfterLastItem { get; } - - /// - public ValueTask DisposeAsync() => PipeReader.CompleteAsync(); - - private int _itemPosition = -1; - private ValidationLevel _validationLevel = default; - private string _schemaLocation = "#"; - + /// - public async ValueTask MoveNextAsync() + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - do + var pipeReader = PipeReader.Create(stream); + try { - var result = await PipeReader.ReadAsync(cancellationToken) - .ConfigureAwait(false); - var buffer = result.Buffer; - var position = buffer.PositionOf(Delimiter); - - if (position != null) + do { - var data = buffer.Slice(0, position.Value); - _itemPosition++; - Current = ParseItem(data); - PipeReader.AdvanceTo(buffer.GetPosition(1, position.Value)); - return true; - } + var result = await pipeReader.ReadAsync(cancellationToken) + .ConfigureAwait(false); + var buffer = result.Buffer; + var position = buffer.PositionOf(Delimiter); - switch (result.IsCompleted) - { - case true when buffer.IsEmpty: - return false; - case true when !RequiresDelimiterAfterLastItem: - _itemPosition++; - Current = ParseItem(buffer); - PipeReader.AdvanceTo(buffer.End); - return true; - default: - PipeReader.AdvanceTo(buffer.Start, buffer.End); - break; - } - } while (true); + switch (result.IsCompleted) + { + // Found an item + case false or true when position is not null: + var data = buffer.Slice(0, position.Value); + _itemPosition++; + _current = ParseItem(data); + pipeReader.AdvanceTo(buffer.GetPosition(1, position.Value)); + yield return _current.Value; + break; + // No more data + case true when buffer.IsEmpty: + yield break; + // No more data to read, data was found, but no delimiter. + // End delimiter is optional, so parse any found data. + case true when !RequiresDelimiterAfterLastItem: + _itemPosition++; + _current = ParseItem(buffer); + pipeReader.AdvanceTo(buffer.End); + yield return _current.Value; + yield break; + // No more data to read, data was found, but no delimiter. + // End delimiter is required, so discard any found data + case true: + pipeReader.AdvanceTo(buffer.End); + yield break; + // More data exist, and no item found yet + default: + pipeReader.AdvanceTo(buffer.Start, buffer.End); + break; + } + } while (true); + } + finally + { + await pipeReader.CompleteAsync() + .ConfigureAwait(false); + } } - /// - public T Current { get; private set; } - /// /// Parse the read item /// @@ -113,7 +122,7 @@ public async ValueTask MoveNextAsync() /// /// The validation result internal ValidationContext ValidateCurrentItem() => - Current.Validate($"{_schemaLocation}/{_itemPosition}", true, ValidationContext.ValidContext, _validationLevel); + _current?.Validate($"{_schemaLocation}/{_itemPosition}", true, ValidationContext.ValidContext, _validationLevel) ?? ValidationContext.ValidContext; /// /// Validates the sequence @@ -132,10 +141,10 @@ internal ValidationContext Validate(string schemaLocation, bool isRequired, Vali } /// -/// Sequential json enumerator for jsonl +/// Sequential json enumerable for jsonl /// -internal sealed class ApplicationJsonlEnumerator(Stream stream, CancellationToken cancellationToken) : - SequentialJsonEnumerator(stream, cancellationToken) +internal sealed class ApplicationJsonlEnumerable(Stream stream) : + SequentialJsonEnumerable(stream) where T : struct, IJsonValue { protected override byte Delimiter => 0x0A; @@ -144,10 +153,10 @@ internal sealed class ApplicationJsonlEnumerator(Stream stream, CancellationT } /// -/// Sequential json enumerator for json-seq +/// Sequential json enumerable for json-seq /// -internal sealed class ApplicationJsonSeqEnumerator(Stream stream, CancellationToken cancellationToken) : - SequentialJsonEnumerator(stream, cancellationToken) +internal sealed class ApplicationJsonSeqEnumerable(Stream stream) : + SequentialJsonEnumerable(stream) where T : struct, IJsonValue { private const byte RecordSeparator = 0x1E; diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs index 698acf0..447ae47 100644 --- a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs @@ -11,15 +11,15 @@ internal partial async Task HandleAsync(Request request, CancellationT } var importedEvents = 0; - while (await content.MoveNextAsync() - .ConfigureAwait(false)) + await foreach (var item in content + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) { var validationContext = content.ValidateCurrentItem(); if (!validationContext.IsValid) { throw new InvalidOperationException("Invalid item"); } - _ = content.Current; importedEvents++; } From 5af450e12ff5acf7cae5e38b09a9cff623c30d2d Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Mon, 16 Mar 2026 20:59:50 +0100 Subject: [PATCH 17/36] return validation context directly from the sequential media type --- .../SequentialMediaTypesGenerator.cs | 14 +++++--------- .../Paths/FooFooIdEvents/Post/Operation.Handler.cs | 3 +-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs index ed581d7..d72d4aa 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs @@ -39,7 +39,7 @@ namespace {{@namespace}}; /// /// Base class for sequential json enumerable /// -internal abstract class SequentialJsonEnumerable(Stream stream) : IAsyncEnumerable +internal abstract class SequentialJsonEnumerable(Stream stream) : IAsyncEnumerable<(T, ValidationContext)> where T : struct, IJsonValue { private int _itemPosition = -1; @@ -58,7 +58,7 @@ internal abstract class SequentialJsonEnumerable(Stream stream) : IAsyncEnume protected abstract bool RequiresDelimiterAfterLastItem { get; } /// - public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + public async IAsyncEnumerator<(T, ValidationContext)> GetAsyncEnumerator(CancellationToken cancellationToken = default) { var pipeReader = PipeReader.Create(stream); try @@ -78,7 +78,7 @@ public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellati _itemPosition++; _current = ParseItem(data); pipeReader.AdvanceTo(buffer.GetPosition(1, position.Value)); - yield return _current.Value; + yield return (_current.Value, ValidateCurrentItem()); break; // No more data case true when buffer.IsEmpty: @@ -89,7 +89,7 @@ public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellati _itemPosition++; _current = ParseItem(buffer); pipeReader.AdvanceTo(buffer.End); - yield return _current.Value; + yield return (_current.Value, ValidateCurrentItem()); yield break; // No more data to read, data was found, but no delimiter. // End delimiter is required, so discard any found data @@ -117,11 +117,7 @@ await pipeReader.CompleteAsync() /// The parsed item protected abstract T ParseItem(ReadOnlySequence data); - /// - /// Validates the current item - /// - /// The validation result - internal ValidationContext ValidateCurrentItem() => + private ValidationContext ValidateCurrentItem() => _current?.Validate($"{_schemaLocation}/{_itemPosition}", true, ValidationContext.ValidContext, _validationLevel) ?? ValidationContext.ValidContext; /// diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs index 447ae47..1b1b5bf 100644 --- a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs @@ -11,11 +11,10 @@ internal partial async Task HandleAsync(Request request, CancellationT } var importedEvents = 0; - await foreach (var item in content + await foreach (var (item, validationContext) in content .WithCancellation(cancellationToken) .ConfigureAwait(false)) { - var validationContext = content.ValidateCurrentItem(); if (!validationContext.IsValid) { throw new InvalidOperationException("Invalid item"); From 719dbc13ecabfd9825cee7bee635d4742f1fde76 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 18 Mar 2026 18:43:48 +0100 Subject: [PATCH 18/36] expose private method on operations that can be called to create a request validation error response --- .../CodeGeneration/OperationGenerator.cs | 12 ++++++++++++ .../Paths/FooFooIdEvents/Post/Operation.Handler.cs | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 4d6929f..7705151 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -68,6 +68,18 @@ internal partial class Operation private Func, Response> HandleRequestValidationError { get; } = (_, validationResult) => {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}}; + /// + /// Create a request validation error response. + /// + /// The invalid request + /// The validation context describing the validation errors + /// + private Response CreateRequestValidationErrorResponse(Request request, ValidationContext validationContext) + { + var configuration = request.HttpContext.RequestServices.GetRequiredService(); + return HandleRequestValidationError(request, validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri)); + } + {{authGenerator.GenerateAuthFilters(operation.Operation, parameters, out var requiresAuth).Indent(4)}} {{(requiresAuth ? """ diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs index 1b1b5bf..92780a0 100644 --- a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs @@ -17,7 +17,7 @@ internal partial async Task HandleAsync(Request request, CancellationT { if (!validationContext.IsValid) { - throw new InvalidOperationException("Invalid item"); + return CreateRequestValidationErrorResponse(request, validationContext); } importedEvents++; } From fffe40dd2afee136c3da6d0500f61c9abcaad15e Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 18 Mar 2026 19:39:11 +0100 Subject: [PATCH 19/36] refactor(response): let response implementations handle validation and writing content --- .../CodeGeneration/ResponseBodyContentGenerator.cs | 14 +++++++++++--- .../CodeGeneration/ResponseContentGenerator.cs | 12 ++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index b9e3507..17b7392 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -49,6 +49,8 @@ public string GenerateResponseClass(string responseClassName, string contentType /// internal sealed class {{ClassName}} : {{responseClassName}} { + private {{_typeDeclaration.FullyQualifiedDotnetTypeName()}} _content; + /// /// Construct response for content {{_contentType}} /// @@ -62,13 +64,19 @@ internal sealed class {{ClassName}} : {{responseClassName}} EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), ContentMediaType); """ : "")}} - Content = {{_contentVariableName}}; + _content = {{_contentVariableName}}; {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; } internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}")); - protected override IJsonValue Content { get; } - protected override string ContentSchemaLocation { get; } = "{{SchemaLocation}}"; + protected override void WriteContentTo(HttpResponse httpResponse) + { + httpResponse.WriteResponseBody(_content); + } + + private const string ContentSchemaLocation = "{{SchemaLocation}}"; + protected override ValidationContext ValidateContent(ValidationContext validationContext, ValidationLevel validationLevel) => + _content.Validate(ContentSchemaLocation, true, validationContext, validationLevel); } """; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index c46c00e..bb8598d 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -70,9 +70,8 @@ public string GenerateResponseContentClass() }}}{{{(_contentGenerators.Any() ? $$""" - - protected abstract IJsonValue Content { get; } - protected abstract string ContentSchemaLocation { get; } + protected abstract void WriteContentTo(HttpResponse httpResponse); + protected abstract ValidationContext ValidateContent(ValidationContext validationContext, ValidationLevel validationLevel); /// public static ContentMediaType<{{_responseClassName}}>[] ContentMediaTypes { get; } = @@ -116,10 +115,7 @@ internal sealed class ResponseHeaders internal override void WriteTo(HttpResponse {{{responseVariableName}}}) {{{{(_contentGenerators.Any() ? $$""" - - {{HttpResponseExtensionsGenerator.CreateWriteBodyInvocation( - responseVariableName, - "Content")}}; + WriteContentTo(httpResponse); """ : "")}}} {{{responseVariableName}}}.ContentType = {{{contentTypeFieldName}}}; {{{responseVariableName}}}.StatusCode = StatusCode;{{{ @@ -133,7 +129,7 @@ internal override ValidationContext Validate(ValidationLevel validationLevel) var validationContext = ValidationContext.ValidContext.UsingStack().UsingResults(); {{{(_contentGenerators.Any() ? $""" - validationContext = Content.Validate(ContentSchemaLocation, true, validationContext, validationLevel); + validationContext = ValidateContent(validationContext, validationLevel); """ : "")}}} {{{_headerGenerators.AggregateToString(generator => generator.GenerateValidateDirective()).Indent(8)}}} From 0c6378cf36afc0a3919fb8f1266256098df7aca6 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 18 Mar 2026 22:47:13 +0100 Subject: [PATCH 20/36] write response content last to make sure metadata and headers are included before it flushes --- .../CodeGeneration/ResponseContentGenerator.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index bb8598d..187bcff 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -113,21 +113,23 @@ internal sealed class ResponseHeaders """ : "")}}} /// internal override void WriteTo(HttpResponse {{{responseVariableName}}}) - {{{{(_contentGenerators.Any() ? -$$""" - WriteContentTo(httpResponse); -""" : "")}}} + { {{{responseVariableName}}}.ContentType = {{{contentTypeFieldName}}}; {{{responseVariableName}}}.StatusCode = StatusCode;{{{ _headerGenerators.AggregateToString(generator => - generator.GenerateWriteDirective(responseVariableName)).Indent(8)}}} + generator.GenerateWriteDirective(responseVariableName)).Indent(8) + }}}{{{(_contentGenerators.Any() ? +$$""" + + WriteContentTo(httpResponse); +""" : "")}}} } /// internal override ValidationContext Validate(ValidationLevel validationLevel) { var validationContext = ValidationContext.ValidContext.UsingStack().UsingResults(); - {{{(_contentGenerators.Any() ? +{{{(_contentGenerators.Any() ? $""" validationContext = ValidateContent(validationContext, validationLevel); """ : "")}}} From 77b640b965e3e1644969d73a70071cbd62f96e4f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 18 Mar 2026 23:41:07 +0100 Subject: [PATCH 21/36] refactor(response): simplify writing to response --- .../CodeGeneration/ResponseBodyContentGenerator.cs | 12 +++++++++--- .../CodeGeneration/ResponseContentGenerator.cs | 13 +------------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index 17b7392..d3694d8 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -69,14 +69,20 @@ internal sealed class {{ClassName}} : {{responseClassName}} } internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}")); - protected override void WriteContentTo(HttpResponse httpResponse) + /// + internal override void WriteTo(HttpResponse httpResponse) { + base.WriteTo(httpResponse); httpResponse.WriteResponseBody(_content); } private const string ContentSchemaLocation = "{{SchemaLocation}}"; - protected override ValidationContext ValidateContent(ValidationContext validationContext, ValidationLevel validationLevel) => - _content.Validate(ContentSchemaLocation, true, validationContext, validationLevel); + /// + internal override ValidationContext Validate(ValidationLevel validationLevel) + { + var validationContext = base.Validate(validationLevel); + return _content.Validate(ContentSchemaLocation, true, validationContext, validationLevel); + } } """; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index 187bcff..10c4c39 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -70,9 +70,6 @@ public string GenerateResponseContentClass() }}}{{{(_contentGenerators.Any() ? $$""" - protected abstract void WriteContentTo(HttpResponse httpResponse); - protected abstract ValidationContext ValidateContent(ValidationContext validationContext, ValidationLevel validationLevel); - /// public static ContentMediaType<{{_responseClassName}}>[] ContentMediaTypes { get; } = [{{_contentGenerators.AggregateToString(generator => @@ -118,21 +115,13 @@ internal override void WriteTo(HttpResponse {{{responseVariableName}}}) {{{responseVariableName}}}.StatusCode = StatusCode;{{{ _headerGenerators.AggregateToString(generator => generator.GenerateWriteDirective(responseVariableName)).Indent(8) - }}}{{{(_contentGenerators.Any() ? -$$""" - - WriteContentTo(httpResponse); -""" : "")}}} + }}} } /// internal override ValidationContext Validate(ValidationLevel validationLevel) { var validationContext = ValidationContext.ValidContext.UsingStack().UsingResults(); -{{{(_contentGenerators.Any() ? -$""" - validationContext = ValidateContent(validationContext, validationLevel); -""" : "")}}} {{{_headerGenerators.AggregateToString(generator => generator.GenerateValidateDirective()).Indent(8)}}} return validationContext; From 7fa3e5ed90dd5c2b6e4b825cbd2d0411580a0472 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 19 Mar 2026 18:01:09 +0100 Subject: [PATCH 22/36] expose method for creating validation context for response objects --- .../CodeGeneration/ResponseContentGenerator.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index 10c4c39..e200faf 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -118,10 +118,17 @@ internal override void WriteTo(HttpResponse {{{responseVariableName}}}) }}} } + /// + /// Create a validation context + /// + /// Validation context + protected ValidationContext CreateValidationContext() => + ValidationContext.ValidContext.UsingStack().UsingResults(); + /// internal override ValidationContext Validate(ValidationLevel validationLevel) { - var validationContext = ValidationContext.ValidContext.UsingStack().UsingResults(); + var validationContext = CreateValidationContext(); {{{_headerGenerators.AggregateToString(generator => generator.GenerateValidateDirective()).Indent(8)}}} return validationContext; From d57949f2ed13265f39ffe56ca0e23851df156c57 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Thu, 19 Mar 2026 18:07:34 +0100 Subject: [PATCH 23/36] create validation context should be private if the response class is not inheritable --- .../CodeGeneration/ResponseContentGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index e200faf..ce9ca2f 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -122,7 +122,7 @@ internal override void WriteTo(HttpResponse {{{responseVariableName}}}) /// Create a validation context /// /// Validation context - protected ValidationContext CreateValidationContext() => + {{{(_contentGenerators.Any() ? "protected" : "private")}}} ValidationContext CreateValidationContext() => ValidationContext.ValidContext.UsingStack().UsingResults(); /// From 234fe00bd5b4ccac7b9f54145240941f02749278 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Mon, 23 Mar 2026 22:47:25 +0100 Subject: [PATCH 24/36] fix captured local parameter --- src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 8fb519a..44500a3 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -262,7 +262,7 @@ internal abstract class BaseSecurityRequirementsFilter(WebApiConfiguration confi break; } - authorized &= ClaimContainsScopes(authenticateResult.Principal, configuration.SecuritySchemeOptions.GetScopeOptions(scheme), scopes); + authorized &= ClaimContainsScopes(authenticateResult.Principal, Configuration.SecuritySchemeOptions.GetScopeOptions(scheme), scopes); if (!authorized) break; } From b24dcb1b05c6e17a51a13920678e638ac34509f6 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Mon, 23 Mar 2026 23:44:15 +0100 Subject: [PATCH 25/36] feat(response): support sequential media types jsonl and json-seq --- src/OpenAPI.WebApiGenerator/ApiGenerator.cs | 12 +- .../ResponseBodyContentGenerator.cs | 108 ++++++++++++++++-- .../SequentialMediaTypesGenerator.cs | 77 +++++++++++++ .../V3/OpenApiV3Visitor.ResponseVisitor.cs | 7 +- .../ExportFooEventsTests.cs | 34 ++++++ .../FooFooIdEvents/Get/Operation.Handler.cs | 19 +++ tests/Example.OpenApi32/openapi.json | 15 +++ 7 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs create mode 100644 tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 9c55283..11bd874 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -166,14 +166,14 @@ private static void GenerateCode(SourceProductionContext context, var responseContent = // OpenAPI.NET is incorrectly adding content where there is none defined. // No content definition means NO content. - response.Content?.Where(content => - openApiResponseVisitor.HasContent(content.Value)) ?? []; - var responseBodyGenerators = responseContent.Select(valuePair => + response.Content?.Where(responseContent => + openApiResponseVisitor.HasContent(responseContent.Value)) ?? []; + var responseBodyGenerators = responseContent.Select(mediaContent => { - var content = valuePair.Value; - var contentSchemaReference = openApiResponseVisitor.GetSchemaReference(content); + var contentMediaType = mediaContent.Value; + var contentSchemaReference = openApiResponseVisitor.GetSchemaReference(contentMediaType); var typeDeclaration = schemaGenerator.Generate(contentSchemaReference); - return new ResponseBodyContentGenerator(valuePair.Key, typeDeclaration); + return new ResponseBodyContentGenerator(mediaContent, typeDeclaration); }).ToList(); var responseHeaderGenerators = response.Headers?.Select(valuePair => diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index d3694d8..a09f985 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Net.Http.Headers; using Corvus.Json.CodeGeneration; using Corvus.Json.CodeGeneration.CSharp; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; @@ -13,13 +15,14 @@ internal sealed class ResponseBodyContentGenerator private readonly MediaTypeHeaderValue _contentType; private readonly TypeDeclaration _typeDeclaration; private readonly bool _isContentTypeRange; - - public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDeclaration) + private readonly bool _isSequentialMediaType; + + public ResponseBodyContentGenerator(KeyValuePair contentMediaType, TypeDeclaration typeDeclaration) { - _contentType = MediaTypeHeaderValue.Parse(contentType); + _contentType = MediaTypeHeaderValue.Parse(contentMediaType.Key); _typeDeclaration = typeDeclaration; - ClassName = contentType.ToPascalCase(); - + ClassName = contentMediaType.Key.ToPascalCase(); + _isSequentialMediaType = contentMediaType.Value.ItemSchema != null; _isContentTypeRange = false; switch (_contentType.MediaType) { @@ -43,6 +46,94 @@ public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDecl private string SchemaLocation => _typeDeclaration.RelativeSchemaLocation; public string GenerateResponseClass(string responseClassName, string contentTypeFieldName) => + _isSequentialMediaType ? +$$""" +/// +/// Response for content {{_contentType}} +/// +internal sealed class {{ClassName}} : {{responseClassName}} +{ + private readonly {{ClassName}}Writer<{{_typeDeclaration.FullyQualifiedDotnetTypeName()}}> _content; + private {{_typeDeclaration.FullyQualifiedDotnetTypeName()}}? _currentItem; + private readonly Request _request; + private readonly Operation _operation; + private readonly WebApiConfiguration _configuration; + + /// + /// Construct response for content {{_contentType}} + /// + /// Request{{(_isContentTypeRange ? +$""" + + /// Content type must match range {_contentType.MediaType} +""" : "")}} + public {{ClassName}}(Request request{{(_isContentTypeRange ? ", string contentType" : "")}}) + {{{(_isContentTypeRange ? +""" + + EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), ContentMediaType); +""" : "")}} + _request = request; + _content = new(request.HttpContext.Response.BodyWriter); + _operation = request.HttpContext.RequestServices.GetRequiredService(); + _configuration = request.HttpContext.RequestServices.GetRequiredService(); + {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; + } + + /// + /// Write an item to the sequence + /// + /// Item to write + internal void WriteItem({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} item) + { + _currentItem = item; + _operation.Validate(this, _configuration); + + WriteHeaders(_request.HttpContext.Response); + _content.WriteItem(item); + _currentItem = null; + } + + internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}")); + /// + internal override void WriteTo(HttpResponse httpResponse) + { + WriteHeaders(httpResponse); + _content.Dispose(); + } + + private void WriteHeaders(HttpResponse httpResponse) + { + if (!httpResponse.HasStarted) + { + base.WriteTo(httpResponse); + } + } + + private const string ContentSchemaLocation = "{{SchemaLocation}}"; + /// + internal override ValidationContext Validate(ValidationLevel validationLevel) + { + var context = ValidateHeaders(validationLevel); + return ValidateCurrentItem(context, validationLevel); + } + + private ValidationContext ValidateHeaders(ValidationLevel validationLevel) => + _request.HttpContext.Response.HasStarted + ? CreateValidationContext() + : base.Validate(validationLevel); + + private ValidationContext ValidateCurrentItem( + ValidationContext validationContext, + ValidationLevel validationLevel) => + _currentItem is null + ? validationContext + : _content.Validate(_currentItem.Value, ContentSchemaLocation, validationContext, + validationLevel); +} +""" : + + $$""" /// /// Response for content {{_contentType}} @@ -54,10 +145,11 @@ internal sealed class {{ClassName}} : {{responseClassName}} /// /// Construct response for content {{_contentType}} /// - /// Content{{(_isContentTypeRange ? $""" + /// Content{{(_isContentTypeRange ? +$""" - /// Content type must match range {_contentType.MediaType} - """ : "")}} + /// Content type must match range {_contentType.MediaType} +""" : "")}} public {{ClassName}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}}) {{{(_isContentTypeRange ? """ diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs index d72d4aa..fb06f48 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs @@ -33,6 +33,7 @@ internal string GetFullyQualifiedTypeName( using System; using System.Buffers; using System.IO.Pipelines; +using System.Text.Json; namespace {{@namespace}}; @@ -175,6 +176,82 @@ protected override T ParseItem(ReadOnlySequence data) } } + +/// +/// Writer for sequential media types +/// +/// +/// Item type of the sequence +internal abstract class SequentialJsonWriter(PipeWriter writer) : IDisposable + where T : struct, IJsonValue +{ + private readonly Utf8JsonWriter _jsonWriter = new(writer, new JsonWriterOptions + { + // Items should already have been validated so it's + // redundant to validate here again + SkipValidation = true + }); + private int _writtenItems; + + protected abstract byte Delimiter { get; } + protected virtual byte? Prefix { get; } = null; + + /// + /// Validate the item + /// + /// The item to validate + /// Schema location of this sequence + /// Validation context + /// Validation level + /// The validation result + internal ValidationContext Validate(T item, string schemaLocation, ValidationContext validationContext, + ValidationLevel validationLevel) => + item.Validate($"{schemaLocation}/{_writtenItems}", true, validationContext, validationLevel); + + /// + /// Write an item to the sequence + /// + /// Item to write + internal void WriteItem(T item) + { + if (Prefix != null) + { + var prefix = Prefix.Value; + writer.Write(new ReadOnlySpan(ref prefix)); + } + item.WriteTo(_jsonWriter); + _jsonWriter.Flush(); + _jsonWriter.Reset(); + var delimiter = Delimiter; + writer.Write(new ReadOnlySpan(ref delimiter)); + _writtenItems++; + } + + /// + public void Dispose() + { + _jsonWriter.Dispose(); + } +} + +/// +/// Sequential json writer for jsonl +/// +internal sealed class ApplicationJsonlWriter(PipeWriter writer) : SequentialJsonWriter(writer) + where T : struct, IJsonValue +{ + protected override byte Delimiter => 0x0A; +} + +/// +/// Sequential json writer for json-seq +/// +internal sealed class ApplicationJsonSeqWriter(PipeWriter writer) : SequentialJsonWriter(writer) where T : struct, IJsonValue +{ + protected override byte Delimiter => 0x0A; + protected override byte? Prefix => 0x1E; +} + #nullable restore """); } \ 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 index 92f6d76..4d3c649 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs @@ -31,7 +31,12 @@ private void VisitContent() foreach (var content in OpenApiDocument.Content) { - if (TryVisit(["content", content.Key, "schema"], out var schemaPointer)) + if (TryVisit(["content", content.Key, "itemSchema"], out var itemSchemaPointer)) + { + _contentReferences.Add(content.Value, new JsonReference(Reference.Uri, + itemSchemaPointer.ToString().AsSpan())); + } + else if (TryVisit(["content", content.Key, "schema"], out var schemaPointer)) { _contentReferences.Add(content.Value, new JsonReference(Reference.Uri, schemaPointer.ToString().AsSpan())); diff --git a/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs new file mode 100644 index 0000000..58135e1 --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs @@ -0,0 +1,34 @@ +using System.Net; +using System.Text.Json.Nodes; +using AwesomeAssertions; +using Example.OpenApi32.IntegrationTests.Json; + +namespace Example.OpenApi32.IntegrationTests; + +public class ExportFooEventsTests(FooApplicationFactory app, ITestOutputHelper testOutput) : FooTestSpecification, IClassFixture +{ + [Fact] + public async Task ExportingFooEvents_ShouldReturnOkWithJsonl() + { + using var client = app.CreateClient(); + var request = new HttpRequestMessage + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1/events"), + Method = HttpMethod.Get + }; + request.Headers.Accept.ParseAdd("application/jsonl"); + + var result = await client.SendAsync(request, CancellationToken); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Content.Headers.ContentType?.MediaType.Should().Be("application/jsonl"); + + var content = await result.Content.ReadAsStringAsync(CancellationToken); + testOutput.WriteLine("Content:"); + testOutput.WriteLine(content); + var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); + lines.Should().HaveCount(2); + JsonNode.Parse(lines[0]).GetValue("#/Name").Should().Be("foo1"); + JsonNode.Parse(lines[1]).GetValue("#/Name").Should().Be("foo2"); + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs new file mode 100644 index 0000000..68606cc --- /dev/null +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs @@ -0,0 +1,19 @@ +namespace Example.OpenApi32.Paths.FooFooIdEvents.Get; + +internal partial class Operation +{ + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + { + switch (request.TryMatchAcceptMediaType(out ContentMediaType? matchedMediaType)) + { + case false: + case true when matchedMediaType == Response.OK200.ApplicationJsonl.ContentMediaType: + var response = new Response.OK200.ApplicationJsonl(request); + response.WriteItem(Components.Schemas.FooProperties.Create(name: "foo1")); + response.WriteItem(Components.Schemas.FooProperties.Create(name: "foo2")); + return Task.FromResult(response); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + } + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json index 34bae64..3a111b1 100644 --- a/tests/Example.OpenApi32/openapi.json +++ b/tests/Example.OpenApi32/openapi.json @@ -82,6 +82,21 @@ ] }, "/foo/{FooId}/events": { + "get": { + "operationId": "Export_Foo_Events", + "responses": { + "200": { + "description": "Foo events", + "content": { + "application/jsonl": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + } + } + }, "post": { "operationId": "Import_Foo_Events", "requestBody": { From 2656edb5bfdd7091561cfa844fbdad97dbf867df Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Mon, 23 Mar 2026 23:50:43 +0100 Subject: [PATCH 26/36] test(response): json-seq sequential media type --- .../ExportFooEventsTests.cs | 29 ++++++++++++++++++- .../FooFooIdEvents/Get/Operation.Handler.cs | 13 ++++++--- tests/Example.OpenApi32/openapi.json | 5 ++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs index 58135e1..4e59eef 100644 --- a/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs +++ b/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs @@ -31,4 +31,31 @@ public async Task ExportingFooEvents_ShouldReturnOkWithJsonl() JsonNode.Parse(lines[0]).GetValue("#/Name").Should().Be("foo1"); JsonNode.Parse(lines[1]).GetValue("#/Name").Should().Be("foo2"); } -} \ No newline at end of file + + [Fact] + public async Task ExportingFooEvents_ShouldReturnOkWithJsonSeq() + { + using var client = app.CreateClient(); + var request = new HttpRequestMessage + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1/events"), + Method = HttpMethod.Get + }; + request.Headers.Accept.ParseAdd("application/json-seq"); + + var result = await client.SendAsync(request, CancellationToken); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Content.Headers.ContentType?.MediaType.Should().Be("application/json-seq"); + + var content = await result.Content.ReadAsStringAsync(CancellationToken); + testOutput.WriteLine("Content:"); + testOutput.WriteLine(content); + var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim((char)0x1E)) + .ToArray(); + lines.Should().HaveCount(2); + JsonNode.Parse(lines[0]).GetValue("#/Name").Should().Be("foo1"); + JsonNode.Parse(lines[1]).GetValue("#/Name").Should().Be("foo2"); + } +} diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs index 68606cc..75a5b72 100644 --- a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs @@ -8,10 +8,15 @@ internal partial Task HandleAsync(Request request, CancellationToken c { case false: case true when matchedMediaType == Response.OK200.ApplicationJsonl.ContentMediaType: - var response = new Response.OK200.ApplicationJsonl(request); - response.WriteItem(Components.Schemas.FooProperties.Create(name: "foo1")); - response.WriteItem(Components.Schemas.FooProperties.Create(name: "foo2")); - return Task.FromResult(response); + var jsonl = new Response.OK200.ApplicationJsonl(request); + jsonl.WriteItem(Components.Schemas.FooProperties.Create(name: "foo1")); + jsonl.WriteItem(Components.Schemas.FooProperties.Create(name: "foo2")); + return Task.FromResult(jsonl); + case true when matchedMediaType == Response.OK200.ApplicationJsonSeq.ContentMediaType: + var jsonSeq = new Response.OK200.ApplicationJsonSeq(request); + jsonSeq.WriteItem(Components.Schemas.FooProperties.Create(name: "foo1")); + jsonSeq.WriteItem(Components.Schemas.FooProperties.Create(name: "foo2")); + return Task.FromResult(jsonSeq); default: throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); } diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json index 3a111b1..cfa6379 100644 --- a/tests/Example.OpenApi32/openapi.json +++ b/tests/Example.OpenApi32/openapi.json @@ -92,6 +92,11 @@ "itemSchema": { "$ref": "#/components/schemas/FooProperties" } + }, + "application/json-seq": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } } } } From 9b376738e5210c9b685ca1754ff464af7d864d25 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Mar 2026 19:26:49 +0100 Subject: [PATCH 27/36] use consistent naming convention for all request sequential media types --- .../CodeGeneration/SequentialMediaTypesGenerator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs index fb06f48..1b67eb0 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs @@ -20,10 +20,10 @@ internal string GetFullyQualifiedTypeName( TypeDeclaration itemTypeDeclaration) => $"{@namespace}.{mediaType.MediaType.ToLower() switch { - "application/jsonl" or "application/x-ndjson" or "application/x-jsonlines" => "ApplicationJsonlEnumerable", - "application/json-seq" or "application/geo+json-seq" => "ApplicationJsonSeqEnumerable", + "application/jsonl" or "application/x-ndjson" or "application/x-jsonlines" => "ApplicationJsonl", + "application/json-seq" or "application/geo+json-seq" => "ApplicationJsonSeq", _ => mediaType.MediaType.ToPascalCase() - }}<{itemTypeDeclaration.FullyQualifiedDotnetTypeName()}>"; + }}Enumerable<{itemTypeDeclaration.FullyQualifiedDotnetTypeName()}>"; internal SourceCode GenerateClasses() => new("SequentialMediaTypes.g.cs", $$""" From 25ebd9591e87801c256a5dac6a4e9b5dae4b318f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Mar 2026 19:36:02 +0100 Subject: [PATCH 28/36] doc: describe sequential media types in README --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 7940f22..632f6a3 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,26 @@ switch (request.TryMatchAcceptMediaType(out var matchedMediaType } ``` +## Sequential Media Types +OpenAPI 3.2 added support for [sequential media types](https://spec.openapis.org/oas/v3.2.0.html#sequential-media-types). The following sequential media types are supported for both request and response media content: +- application/jsonl +- application/x-ndjson +- application/x-jsonlines +- application/json-seq +- application/geo+json-seq + +Other sequential media types can be implemented by simply following the expected naming convention and placing the implementations in the expected namespace, see the compilation error of any missing media type class. + +### Request Content +Inherit from `SequentialJsonEnumerable` using the following naming convention: +- application/jsonl -> `ApplicationJsonlEnumerable` + +### Response Content +Inherit from `SequentialJsonWriter` using the following naming convention: +- application/jsonl -> `ApplicationJsonlWriter` + +See the [OpenAPI 3.2 examples](#examples) for further details how to consume and produce sequential media types. + ## Authentication and Authorization OpenAPI defines [security scheme objects](https://spec.openapis.org/oas/latest#security-scheme-object) for authentication and authorization mechanisms. The generator implement endpoint filters that corresponds to the security declaration of each operation. Do _not_ call `UseAuthentication` or similar when configuring the application. From de03572da7cadb568e6674351f10dc63e859996f Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Mar 2026 21:15:55 +0100 Subject: [PATCH 29/36] fix(response): content class name could be mixed pascal case where it doesn't make sense if the media type is not all lower case. NOTE: This is a breaking change if any response media types are defined as not all lower case. --- .../ResponseBodyContentGenerator.cs | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index a09f985..22deb5e 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -18,28 +18,19 @@ internal sealed class ResponseBodyContentGenerator private readonly bool _isSequentialMediaType; public ResponseBodyContentGenerator(KeyValuePair contentMediaType, TypeDeclaration typeDeclaration) - { + { _contentType = MediaTypeHeaderValue.Parse(contentMediaType.Key); _typeDeclaration = typeDeclaration; - ClassName = contentMediaType.Key.ToPascalCase(); _isSequentialMediaType = contentMediaType.Value.ItemSchema != null; - _isContentTypeRange = false; - switch (_contentType.MediaType) + _isContentTypeRange = _contentType.MediaType.EndsWith("*"); + _contentVariableName = _contentType.MediaType switch { - case "*/*": - _contentVariableName = "any"; - _isContentTypeRange = true; - break; - case not null when _contentType.MediaType.EndsWith("*"): - _contentVariableName = $"any{_contentType.MediaType.TrimEnd('*').TrimEnd('/').ToPascalCase()}"; - _isContentTypeRange = true; - break; - case null: - throw new InvalidOperationException("Content type is null"); - default: - _contentVariableName = _contentType.MediaType.ToCamelCase(); - break; - } + "*/*" => "any", + not null when _isContentTypeRange => + $"any{_contentType.MediaType.TrimEnd('*').TrimEnd('/').ToLower().ToPascalCase()}", + null => throw new InvalidOperationException("Content type is null"), + _ => _contentType.MediaType.ToLower().ToCamelCase() + }; ClassName = _contentVariableName.ToPascalCase(); } From be795d6d097c013813c8252c81b3590b4909a120 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Mar 2026 21:17:58 +0100 Subject: [PATCH 30/36] explicitly implement all supported sequential media type classes using proper name for readability --- README.md | 4 +- .../SequentialMediaTypesGenerator.cs | 51 +++++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 632f6a3..bbc095f 100644 --- a/README.md +++ b/README.md @@ -179,11 +179,11 @@ Other sequential media types can be implemented by simply following the expected ### Request Content Inherit from `SequentialJsonEnumerable` using the following naming convention: -- application/jsonl -> `ApplicationJsonlEnumerable` +- application/jsonl (lower case) -> `ApplicationJsonlEnumerable` ### Response Content Inherit from `SequentialJsonWriter` using the following naming convention: -- application/jsonl -> `ApplicationJsonlWriter` +- application/jsonl (lower case) -> `ApplicationJsonlWriter` See the [OpenAPI 3.2 examples](#examples) for further details how to consume and produce sequential media types. diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs index 1b67eb0..78e982b 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs @@ -18,12 +18,7 @@ internal string GenerateConstructorInstance( internal string GetFullyQualifiedTypeName( MediaTypeHeaderValue mediaType, TypeDeclaration itemTypeDeclaration) => - $"{@namespace}.{mediaType.MediaType.ToLower() switch - { - "application/jsonl" or "application/x-ndjson" or "application/x-jsonlines" => "ApplicationJsonl", - "application/json-seq" or "application/geo+json-seq" => "ApplicationJsonSeq", - _ => mediaType.MediaType.ToPascalCase() - }}Enumerable<{itemTypeDeclaration.FullyQualifiedDotnetTypeName()}>"; + $"{@namespace}.{mediaType.MediaType.ToLower().ToPascalCase()}Enumerable<{itemTypeDeclaration.FullyQualifiedDotnetTypeName()}>"; internal SourceCode GenerateClasses() => new("SequentialMediaTypes.g.cs", $$""" @@ -140,7 +135,7 @@ internal ValidationContext Validate(string schemaLocation, bool isRequired, Vali /// /// Sequential json enumerable for jsonl /// -internal sealed class ApplicationJsonlEnumerable(Stream stream) : +internal class ApplicationJsonlEnumerable(Stream stream) : SequentialJsonEnumerable(stream) where T : struct, IJsonValue { @@ -149,10 +144,22 @@ internal sealed class ApplicationJsonlEnumerable(Stream stream) : protected override T ParseItem(ReadOnlySequence data) => T.Parse(data); } +/// +/// Sequential json enumerable for x-ndjson +/// +internal class ApplicationXNdjsonEnumerable(Stream stream) : ApplicationJsonlEnumerable(stream) + where T : struct, IJsonValue; + +/// +/// Sequential json enumerable for x-jsonlines +/// +internal class ApplicationXJsonlinesEnumerable(Stream stream) : ApplicationJsonlEnumerable(stream) + where T : struct, IJsonValue; + /// /// Sequential json enumerable for json-seq /// -internal sealed class ApplicationJsonSeqEnumerable(Stream stream) : +internal class ApplicationJsonSeqEnumerable(Stream stream) : SequentialJsonEnumerable(stream) where T : struct, IJsonValue { @@ -176,6 +183,12 @@ protected override T ParseItem(ReadOnlySequence data) } } +/// +/// Sequential json enumerable for geo+json-seq +/// +internal class ApplicationGeoJsonSeqEnumerable(Stream stream) : ApplicationJsonSeqEnumerable(stream) + where T : struct, IJsonValue; + /// /// Writer for sequential media types @@ -237,21 +250,39 @@ public void Dispose() /// /// Sequential json writer for jsonl /// -internal sealed class ApplicationJsonlWriter(PipeWriter writer) : SequentialJsonWriter(writer) +internal class ApplicationJsonlWriter(PipeWriter writer) : SequentialJsonWriter(writer) where T : struct, IJsonValue { protected override byte Delimiter => 0x0A; } +/// +/// Sequential json writer for x-ndjson +/// +internal class ApplicationXNdjsonWriter(PipeWriter writer) : ApplicationJsonlWriter(writer) + where T : struct, IJsonValue; + +/// +/// Sequential json writer for x-jsonlines +/// +internal class ApplicationXJsonlinesWriter(PipeWriter writer) : ApplicationJsonlWriter(writer) + where T : struct, IJsonValue; + /// /// Sequential json writer for json-seq /// -internal sealed class ApplicationJsonSeqWriter(PipeWriter writer) : SequentialJsonWriter(writer) where T : struct, IJsonValue +internal class ApplicationJsonSeqWriter(PipeWriter writer) : SequentialJsonWriter(writer) where T : struct, IJsonValue { protected override byte Delimiter => 0x0A; protected override byte? Prefix => 0x1E; } +/// +/// Sequential json writer for geo+json-seq +/// +internal class ApplicationGeoJsonSeqWriter(PipeWriter writer) : ApplicationJsonSeqWriter(writer) + where T : struct, IJsonValue; + #nullable restore """); } \ No newline at end of file From 920a813a5b8e5c1f2d355a8e20c144b3dce3af57 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Mar 2026 23:01:09 +0100 Subject: [PATCH 31/36] doc: add missing comments on sequential media type writer base class --- .../CodeGeneration/SequentialMediaTypesGenerator.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs index 78e982b..b0ddf1d 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs @@ -206,7 +206,14 @@ internal abstract class SequentialJsonWriter(PipeWriter writer) : IDisposable }); private int _writtenItems; + /// + /// Delimiter between each item + /// protected abstract byte Delimiter { get; } + + /// + /// Optional prefix before each item + /// protected virtual byte? Prefix { get; } = null; /// From d8cc58d4af83a3fcc80ccfd512344e0d40892211 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Mar 2026 23:11:45 +0100 Subject: [PATCH 32/36] add + to default delimiters for converting a string to camel and pascal case --- src/OpenAPI.WebApiGenerator/Extensions/StringExtensions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/Extensions/StringExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/StringExtensions.cs index 20f8620..1f21cc6 100644 --- a/src/OpenAPI.WebApiGenerator/Extensions/StringExtensions.cs +++ b/src/OpenAPI.WebApiGenerator/Extensions/StringExtensions.cs @@ -1,13 +1,12 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text; namespace OpenAPI.WebApiGenerator.Extensions; internal static class StringExtensions { - private static readonly char[] DefaultDelimiters = ['/', '?', '=', '&', '{', '}', '-', '_']; + private static readonly char[] DefaultDelimiters = ['/', '?', '=', '&', '{', '}', '-', '_', '+']; [return: NotNullIfNotNull(nameof(str))] public static string? ToPascalCase(this string? str, params char[] delimiters) From 620ff8a277398cf8fa9bcc8eee8ff5ebb02685b3 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Mar 2026 23:55:34 +0100 Subject: [PATCH 33/36] fix: json-seq enumerable detect record separator incorrectly --- .../CodeGeneration/SequentialMediaTypesGenerator.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs index b0ddf1d..c9c15f5 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs @@ -169,14 +169,12 @@ internal class ApplicationJsonSeqEnumerable(Stream stream) : protected override T ParseItem(ReadOnlySequence data) { - var rsPosition = data.PositionOf(RecordSeparator); - // RS should be first. // If it is not, then the data is incomplete and invalid, // let JSON validation handle it - if (rsPosition.HasValue && rsPosition.Value.GetInteger() == 0) + if (!data.IsEmpty && data.FirstSpan[0] == RecordSeparator) { - data = data.Slice(data.GetPosition(1)); + data = data.Slice(1); } return T.Parse(data); From 7bf32cdcaeadc1043552de13fac8081f536a3d54 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Mar 2026 23:57:21 +0100 Subject: [PATCH 34/36] test(request): send different sequential media types --- .../ImportFooEventsTests.cs | 17 +++++++++++------ .../FooFooIdEvents/Post/Operation.Handler.cs | 16 ++++++++++------ tests/Example.OpenApi32/openapi.json | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs index 8a07769..bbb664b 100644 --- a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs +++ b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs @@ -6,8 +6,13 @@ namespace Example.OpenApi32.IntegrationTests; public class ImportFooEventsTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture { - [Fact] - public async Task ImportingFooEvents_ShouldReturnAccepted() + [Theory] + [InlineData("application/jsonl")] + [InlineData("application/x-jsonlines")] + [InlineData("application/x-ndjson")] + [InlineData("application/json-seq", "\x1E")] + [InlineData("application/geo+json-seq", "\x1E")] + public async Task ImportingFooEvents_ShouldReturnAccepted(string mediaType, string? prefix = "") { using var client = app.CreateClient() .WithOAuth2ImplicitFlowAuthentication("update"); @@ -16,10 +21,10 @@ public async Task ImportingFooEvents_ShouldReturnAccepted() RequestUri = new Uri(client.BaseAddress!, "/foo/1/events"), Method = new HttpMethod("POST"), Content = CreateJsonContent( - """ - { "Name": "test" } - { "Name": "another test" } - """, "application/jsonl") + $$""" + {{prefix}}{ "Name": "test" } + {{prefix}}{ "Name": "another test" } + """, mediaType) }, CancellationToken); result.StatusCode.Should().Be(HttpStatusCode.Accepted); diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs index 92780a0..8692acf 100644 --- a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs @@ -1,15 +1,19 @@ -namespace Example.OpenApi32.Paths.FooFooIdEvents.Post; +using Example.OpenApi32.Components.Schemas; + +namespace Example.OpenApi32.Paths.FooFooIdEvents.Post; internal partial class Operation { internal partial async Task HandleAsync(Request request, CancellationToken cancellationToken) { - var content = request.Body.ApplicationJsonl; - if (content == null) - { + var content = + request.Body.ApplicationJsonSeq ?? + request.Body.ApplicationGeoJsonSeq ?? + request.Body.ApplicationJsonl ?? + request.Body.ApplicationXNdjson ?? + request.Body.ApplicationXJsonlines as SequentialJsonEnumerable ?? throw new InvalidOperationException("missing content, this cannot occur"); - } - + var importedEvents = 0; await foreach (var (item, validationContext) in content .WithCancellation(cancellationToken) diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json index cfa6379..7c526fa 100644 --- a/tests/Example.OpenApi32/openapi.json +++ b/tests/Example.OpenApi32/openapi.json @@ -93,10 +93,25 @@ "$ref": "#/components/schemas/FooProperties" } }, + "application/x-jsonlines": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/x-ndjson": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, "application/json-seq": { "itemSchema": { "$ref": "#/components/schemas/FooProperties" } + }, + "application/geo+json-seq": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } } } } From 0425f7bbe35bd9f565a51e32a7aba7ae45f48b73 Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Tue, 24 Mar 2026 23:59:45 +0100 Subject: [PATCH 35/36] test(request): let last character always be a new line as supported by all sequential media types --- tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs index bbb664b..79de779 100644 --- a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs +++ b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs @@ -24,6 +24,7 @@ public async Task ImportingFooEvents_ShouldReturnAccepted(string mediaType, stri $$""" {{prefix}}{ "Name": "test" } {{prefix}}{ "Name": "another test" } + """, mediaType) }, CancellationToken); From 985fb9bf99b6805d89402bcc0ff6414daa8dac9a Mon Sep 17 00:00:00 2001 From: Fredrik Arvidsson Date: Wed, 25 Mar 2026 00:02:13 +0100 Subject: [PATCH 36/36] test(response): verify all supported sequential media types --- .../ExportFooEventsTests.cs | 40 +++++-------------- .../FooFooIdEvents/Get/Operation.Handler.cs | 27 +++++++++++-- tests/Example.OpenApi32/openapi.json | 20 ++++++++++ 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs index 4e59eef..d51216c 100644 --- a/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs +++ b/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs @@ -7,8 +7,13 @@ namespace Example.OpenApi32.IntegrationTests; public class ExportFooEventsTests(FooApplicationFactory app, ITestOutputHelper testOutput) : FooTestSpecification, IClassFixture { - [Fact] - public async Task ExportingFooEvents_ShouldReturnOkWithJsonl() + [Theory] + [InlineData("application/jsonl")] + [InlineData("application/x-jsonlines")] + [InlineData("application/x-ndjson")] + [InlineData("application/json-seq")] + [InlineData("application/geo+json-seq")] + public async Task ExportingFooEvents_ShouldReturnOkWithSequentialJson(string mediaType) { using var client = app.CreateClient(); var request = new HttpRequestMessage @@ -16,37 +21,12 @@ public async Task ExportingFooEvents_ShouldReturnOkWithJsonl() RequestUri = new Uri(client.BaseAddress!, "/foo/1/events"), Method = HttpMethod.Get }; - request.Headers.Accept.ParseAdd("application/jsonl"); + request.Headers.Accept.ParseAdd(mediaType); var result = await client.SendAsync(request, CancellationToken); result.StatusCode.Should().Be(HttpStatusCode.OK); - result.Content.Headers.ContentType?.MediaType.Should().Be("application/jsonl"); - - var content = await result.Content.ReadAsStringAsync(CancellationToken); - testOutput.WriteLine("Content:"); - testOutput.WriteLine(content); - var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); - lines.Should().HaveCount(2); - JsonNode.Parse(lines[0]).GetValue("#/Name").Should().Be("foo1"); - JsonNode.Parse(lines[1]).GetValue("#/Name").Should().Be("foo2"); - } - - [Fact] - public async Task ExportingFooEvents_ShouldReturnOkWithJsonSeq() - { - using var client = app.CreateClient(); - var request = new HttpRequestMessage - { - RequestUri = new Uri(client.BaseAddress!, "/foo/1/events"), - Method = HttpMethod.Get - }; - request.Headers.Accept.ParseAdd("application/json-seq"); - - var result = await client.SendAsync(request, CancellationToken); - - result.StatusCode.Should().Be(HttpStatusCode.OK); - result.Content.Headers.ContentType?.MediaType.Should().Be("application/json-seq"); + result.Content.Headers.ContentType?.MediaType.Should().Be(mediaType); var content = await result.Content.ReadAsStringAsync(CancellationToken); testOutput.WriteLine("Content:"); @@ -58,4 +38,4 @@ public async Task ExportingFooEvents_ShouldReturnOkWithJsonSeq() JsonNode.Parse(lines[0]).GetValue("#/Name").Should().Be("foo1"); JsonNode.Parse(lines[1]).GetValue("#/Name").Should().Be("foo2"); } -} +} \ No newline at end of file diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs index 75a5b72..06eb312 100644 --- a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs @@ -1,3 +1,5 @@ +using Example.OpenApi32.Components.Schemas; + namespace Example.OpenApi32.Paths.FooFooIdEvents.Get; internal partial class Operation @@ -9,16 +11,33 @@ internal partial Task HandleAsync(Request request, CancellationToken c case false: case true when matchedMediaType == Response.OK200.ApplicationJsonl.ContentMediaType: var jsonl = new Response.OK200.ApplicationJsonl(request); - jsonl.WriteItem(Components.Schemas.FooProperties.Create(name: "foo1")); - jsonl.WriteItem(Components.Schemas.FooProperties.Create(name: "foo2")); + WriteItems(jsonl.WriteItem); return Task.FromResult(jsonl); + case true when matchedMediaType == Response.OK200.ApplicationXJsonlines.ContentMediaType: + var jsonLines = new Response.OK200.ApplicationXJsonlines(request); + WriteItems(jsonLines.WriteItem); + return Task.FromResult(jsonLines); + case true when matchedMediaType == Response.OK200.ApplicationXNdjson.ContentMediaType: + var ndJson = new Response.OK200.ApplicationXNdjson(request); + WriteItems(ndJson.WriteItem); + return Task.FromResult(ndJson); case true when matchedMediaType == Response.OK200.ApplicationJsonSeq.ContentMediaType: var jsonSeq = new Response.OK200.ApplicationJsonSeq(request); - jsonSeq.WriteItem(Components.Schemas.FooProperties.Create(name: "foo1")); - jsonSeq.WriteItem(Components.Schemas.FooProperties.Create(name: "foo2")); + WriteItems(jsonSeq.WriteItem); return Task.FromResult(jsonSeq); + case true when matchedMediaType == Response.OK200.ApplicationGeoJsonSeq.ContentMediaType: + var geoJsonSeq = new Response.OK200.ApplicationGeoJsonSeq(request); + WriteItems(geoJsonSeq.WriteItem); + return Task.FromResult(geoJsonSeq); default: throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + + } + + void WriteItems(Action write) + { + write(FooProperties.Create(name: "foo1")); + write(FooProperties.Create(name: "foo2")); } } } \ No newline at end of file diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json index 7c526fa..5d00075 100644 --- a/tests/Example.OpenApi32/openapi.json +++ b/tests/Example.OpenApi32/openapi.json @@ -126,6 +126,26 @@ "itemSchema": { "$ref": "#/components/schemas/FooProperties" } + }, + "application/x-jsonlines": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/x-ndjson": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/json-seq": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/geo+json-seq": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } } } },