From 9ddc80945b668edf2e4a189447d21407f98f83b0 Mon Sep 17 00:00:00 2001 From: Levy Barbosa Date: Tue, 4 Aug 2026 19:39:33 -0300 Subject: [PATCH 01/11] Pass binding context to generated AsyncAPI consumers --- .../AsyncApi30CodeGenerator.cs | 48 +++++++++++++++++-- .../AsyncApi30CodeGeneratorTests.cs | 19 +++++++- .../TestData/nats-operation-bindings.json | 35 ++++++++++++++ 3 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/TestData/nats-operation-bindings.json diff --git a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs index 7d554475d6b..fcf8c1760cd 100644 --- a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs +++ b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs @@ -1967,6 +1967,8 @@ private GeneratedFile EmitConsumer(OperationInfo op) ? "string channel, CancellationToken cancellationToken = default" : "CancellationToken cancellationToken = default"; + bool hasBindingContext = op.ChannelBindingsJson is not null || op.OperationBindingsJson is not null; + if (op.SecuritySchemes.Count > 0) { w.WriteLine($"public async ValueTask StartAsync({startParams})"); @@ -1989,14 +1991,33 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.WriteLine(); string subscribeAddr = op.IsDynamicAddress ? "this.subscribedChannelUtf8" : "ChannelAddressUtf8"; + if (hasBindingContext) + { + w.WriteLine("MessageContext context = new()"); + w.OpenBrace(); + if (op.ChannelBindingsJson is not null) + { + w.WriteLine("ChannelBindingsJson = ChannelBindingsBytes,"); + } + + if (op.OperationBindingsJson is not null) + { + w.WriteLine("OperationBindingsJson = OperationBindingsBytes,"); + } + + w.CloseBraceWithSemicolon(); + } + if (op.Messages.Count == 1) { string payloadType = op.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; - w.WriteLine($"await this.transport.SubscribeAsync<{payloadType}>({subscribeAddr}, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false);"); + string contextArg = hasBindingContext ? ", context" : string.Empty; + w.WriteLine($"await this.transport.SubscribeAsync<{payloadType}>({subscribeAddr}, this.HandleMessageAsync{contextArg}, cancellationToken).ConfigureAwait(false);"); } else { - w.WriteLine($"await this.transport.SubscribeAsync({subscribeAddr}, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false);"); + string contextArg = hasBindingContext ? ", context" : string.Empty; + w.WriteLine($"await this.transport.SubscribeAsync({subscribeAddr}, this.HandleMessageAsync{contextArg}, cancellationToken).ConfigureAwait(false);"); } w.CloseBrace(); @@ -2013,14 +2034,33 @@ private GeneratedFile EmitConsumer(OperationInfo op) } string subscribeAddr = op.IsDynamicAddress ? "this.subscribedChannelUtf8" : "ChannelAddressUtf8"; + if (hasBindingContext) + { + w.WriteLine("MessageContext context = new()"); + w.OpenBrace(); + if (op.ChannelBindingsJson is not null) + { + w.WriteLine("ChannelBindingsJson = ChannelBindingsBytes,"); + } + + if (op.OperationBindingsJson is not null) + { + w.WriteLine("OperationBindingsJson = OperationBindingsBytes,"); + } + + w.CloseBraceWithSemicolon(); + } + if (op.Messages.Count == 1) { string payloadType = op.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; - w.WriteLine($"return this.transport.SubscribeAsync<{payloadType}>({subscribeAddr}, this.HandleMessageAsync, cancellationToken);"); + string contextArg = hasBindingContext ? ", context" : string.Empty; + w.WriteLine($"return this.transport.SubscribeAsync<{payloadType}>({subscribeAddr}, this.HandleMessageAsync{contextArg}, cancellationToken);"); } else { - w.WriteLine($"return this.transport.SubscribeAsync({subscribeAddr}, this.HandleMessageAsync, cancellationToken);"); + string contextArg = hasBindingContext ? ", context" : string.Empty; + w.WriteLine($"return this.transport.SubscribeAsync({subscribeAddr}, this.HandleMessageAsync{contextArg}, cancellationToken);"); } w.CloseBrace(); diff --git a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs index c41361eea7f..4d6ff0313b8 100644 --- a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs @@ -2102,6 +2102,23 @@ public void Generate_ConsumerDynamicMultiMessage_EmitsMultiMessageSubscribeWithA StringAssert.Contains(consumer.Content, "AuthenticateAsync"); } + [TestMethod] + public void Generate_ConsumerWithOperationBindings_PassesBindingContextToTransport() + { + byte[] bytes = File.ReadAllBytes(Path.Combine("TestData", "nats-operation-bindings.json")); + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(bytes); + + var generator = new AsyncApi30CodeGenerator("NatsBindings", new Dictionary()); + IReadOnlyList files = generator.Generate(doc.RootElement); + + GeneratedFile? consumer = files.FirstOrDefault(f => f.FileName.Contains("SubscribeOrdersConsumer")); + Assert.IsNotNull(consumer, "A receive operation should generate a Consumer class"); + + StringAssert.Contains(consumer.Content, "MessageContext context = new()"); + StringAssert.Contains(consumer.Content, "OperationBindingsJson = OperationBindingsBytes"); + StringAssert.Contains(consumer.Content, "this.HandleMessageAsync, context, cancellationToken"); + } + [TestMethod] public void Compile_ConsumerDynamicMultiMessage_GeneratedCodeCompiles() { @@ -2210,4 +2227,4 @@ public void Compile_RequestReplyDynamicAddress_GeneratedCodeCompiles() string stubs = DynamicCompiler.GenerateTypeStubs(schemaTypeMap); DynamicCompiler.AssertCompiles(files, "Rpc.DynReply.Generated", stubs); } -} \ No newline at end of file +} diff --git a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/TestData/nats-operation-bindings.json b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/TestData/nats-operation-bindings.json new file mode 100644 index 00000000000..62f1d32c382 --- /dev/null +++ b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/TestData/nats-operation-bindings.json @@ -0,0 +1,35 @@ +{ + "asyncapi": "3.0.0", + "info": { + "title": "NATS operation bindings", + "version": "1.0.0" + }, + "channels": { + "orders": { + "address": "orders.created", + "messages": { + "OrderCreated": { + "payload": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + } + }, + "operations": { + "subscribeOrders": { + "action": "receive", + "channel": { "$ref": "#/channels/orders" }, + "bindings": { + "nats": { + "x-mode": "jetstream", + "x-stream": "ORDERS", + "x-consumer": "orders-service" + } + } + } + } +} From 8b58bc866917ab32d04a9061501b87b2de282c26 Mon Sep 17 00:00:00 2001 From: Levy Barbosa Date: Wed, 5 Aug 2026 00:06:45 -0300 Subject: [PATCH 02/11] Support parameterized AsyncAPI consumer channels --- .../AsyncApi30CodeGenerator.cs | 85 ++++++++++++++++--- .../AsyncApi30CodeGeneratorTests.cs | 32 +++++++ .../TestData/parameterized-consumer.json | 36 ++++++++ 3 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/TestData/parameterized-consumer.json diff --git a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs index 7d554475d6b..c2af3cbfc22 100644 --- a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs +++ b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs @@ -1845,6 +1845,8 @@ private GeneratedFile EmitConsumer(OperationInfo op) { string className = $"{ToPascalCase(op.Name)}Consumer"; string handlerInterface = $"I{ToPascalCase(op.Name)}Handler"; + bool hasParameterizedAddress = op.Parameters.Count > 0; + bool hasRuntimeAddress = op.IsDynamicAddress || hasParameterizedAddress; IndentedWriter w = new(); w.WriteLine("// "); @@ -1884,14 +1886,24 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.WriteLine("private string? subscribedChannel;"); w.WriteLine("private byte[]? subscribedChannelUtf8;"); } + else if (hasParameterizedAddress) + { + w.WriteLine("private string? subscribedChannel;"); + w.WriteLine("private byte[]? subscribedChannelUtf8;"); + w.WriteLine("private byte[]? subscribedDeadLetterChannelUtf8;"); + w.WriteLine($"private const string ChannelAddressTemplate = \"{EscapeString(op.ChannelAddress)}\";"); + } else { w.WriteLine($"private const string ChannelAddress = \"{EscapeString(op.ChannelAddress)}\";"); w.WriteLine($"private static readonly byte[] ChannelAddressUtf8 = \"{EscapeString(op.ChannelAddress)}\"u8.ToArray();"); } - w.WriteLine($"private const string DeadLetterChannel = \"dead-letter.{EscapeString(op.ChannelAddress)}\";"); - w.WriteLine($"private static readonly byte[] DeadLetterChannelUtf8 = \"dead-letter.{EscapeString(op.ChannelAddress)}\"u8.ToArray();"); + if (!hasParameterizedAddress) + { + w.WriteLine($"private const string DeadLetterChannel = \"dead-letter.{EscapeString(op.ChannelAddress)}\";"); + w.WriteLine($"private static readonly byte[] DeadLetterChannelUtf8 = \"dead-letter.{EscapeString(op.ChannelAddress)}\"u8.ToArray();"); + } if (op.AllowedServers is { Count: > 0 }) { @@ -1961,11 +1973,32 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.WriteLine($"/// The channel address to subscribe to (dynamic routing)."); } + foreach (ChannelParameter p in op.Parameters) + { + w.WriteLine($"/// {p.Description ?? $"The {p.Name} channel parameter."}"); + } + w.WriteLine($"/// A cancellation token."); - string startParams = op.IsDynamicAddress - ? "string channel, CancellationToken cancellationToken = default" - : "CancellationToken cancellationToken = default"; + List startParamList = []; + if (op.IsDynamicAddress) + { + startParamList.Add("string channel"); + } + + foreach (ChannelParameter p in op.Parameters) + { + string paramDecl = $"string {ToCamelCase(p.Name)}"; + if (p.DefaultValue is not null) + { + paramDecl += $" = \"{EscapeString(p.DefaultValue)}\""; + } + + startParamList.Add(paramDecl); + } + + startParamList.Add("CancellationToken cancellationToken = default"); + string startParams = string.Join(", ", startParamList); if (op.SecuritySchemes.Count > 0) { @@ -1977,6 +2010,10 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.WriteLine("this.subscribedChannel = channel;"); w.WriteLine("this.subscribedChannelUtf8 = Encoding.UTF8.GetBytes(channel);"); } + else if (hasParameterizedAddress) + { + EmitParameterizedConsumerChannelConstruction(w, op); + } w.WriteLine("if (this.authProvider is not null)"); w.OpenBrace(); @@ -1988,7 +2025,7 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.CloseBrace(); w.WriteLine(); - string subscribeAddr = op.IsDynamicAddress ? "this.subscribedChannelUtf8" : "ChannelAddressUtf8"; + string subscribeAddr = hasRuntimeAddress ? "this.subscribedChannelUtf8!" : "ChannelAddressUtf8"; if (op.Messages.Count == 1) { string payloadType = op.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; @@ -2011,8 +2048,12 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.WriteLine("this.subscribedChannel = channel;"); w.WriteLine("this.subscribedChannelUtf8 = Encoding.UTF8.GetBytes(channel);"); } + else if (hasParameterizedAddress) + { + EmitParameterizedConsumerChannelConstruction(w, op); + } - string subscribeAddr = op.IsDynamicAddress ? "this.subscribedChannelUtf8" : "ChannelAddressUtf8"; + string subscribeAddr = hasRuntimeAddress ? "this.subscribedChannelUtf8!" : "ChannelAddressUtf8"; if (op.Messages.Count == 1) { string payloadType = op.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; @@ -2035,7 +2076,7 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.WriteLine($"public ValueTask StopAsync(CancellationToken cancellationToken = default)"); w.OpenBrace(); - if (op.IsDynamicAddress) + if (hasRuntimeAddress) { w.WriteLine("if (this.subscribedChannelUtf8 is null)"); w.OpenBrace(); @@ -2063,8 +2104,9 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.WriteLine($"private async ValueTask HandleMessageAsync({payloadType} payload, Corvus.Text.Json.JsonElement headers, CancellationToken cancellationToken)"); w.OpenBrace(); - string channelExpr = op.IsDynamicAddress ? "this.subscribedChannel!" : "ChannelAddress"; - string channelUtf8Expr = op.IsDynamicAddress ? "this.subscribedChannelUtf8!" : "ChannelAddressUtf8"; + string channelExpr = hasRuntimeAddress ? "this.subscribedChannel!" : "ChannelAddress"; + string channelUtf8Expr = hasRuntimeAddress ? "this.subscribedChannelUtf8!" : "ChannelAddressUtf8"; + string deadLetterChannelUtf8Expr = hasParameterizedAddress ? "this.subscribedDeadLetterChannelUtf8!" : "DeadLetterChannelUtf8"; w.WriteLine("try"); w.OpenBrace(); @@ -2111,7 +2153,7 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.PopIndent(); w.WriteLine("case MessageErrorAction.DeadLetter:"); w.PushIndent(); - w.WriteLine($"await this.transport.DeadLetterAsync(DeadLetterChannelUtf8, {channelUtf8Expr}, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false);"); + w.WriteLine($"await this.transport.DeadLetterAsync({deadLetterChannelUtf8Expr}, {channelUtf8Expr}, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false);"); w.WriteLine("return;"); w.PopIndent(); w.WriteLine("default:"); @@ -2125,8 +2167,9 @@ private GeneratedFile EmitConsumer(OperationInfo op) else { string messageTypeName = $"{ToPascalCase(op.Name)}ReceivedMessage"; - string channelExpr = op.IsDynamicAddress ? "this.subscribedChannel!" : "ChannelAddress"; - string channelUtf8Expr = op.IsDynamicAddress ? "this.subscribedChannelUtf8!" : "ChannelAddressUtf8"; + string channelExpr = hasRuntimeAddress ? "this.subscribedChannel!" : "ChannelAddress"; + string channelUtf8Expr = hasRuntimeAddress ? "this.subscribedChannelUtf8!" : "ChannelAddressUtf8"; + string deadLetterChannelUtf8Expr = hasParameterizedAddress ? "this.subscribedDeadLetterChannelUtf8!" : "DeadLetterChannelUtf8"; w.WriteLine("private async ValueTask HandleMessageAsync(Corvus.Text.Json.JsonElement payload, Corvus.Text.Json.JsonElement headers, CancellationToken cancellationToken)"); w.OpenBrace(); @@ -2152,7 +2195,7 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.PopIndent(); w.WriteLine("case MessageErrorAction.DeadLetter:"); w.PushIndent(); - w.WriteLine($"await this.transport.DeadLetterAsync(DeadLetterChannelUtf8, {channelUtf8Expr}, payload, headers, ex, cancellationToken).ConfigureAwait(false);"); + w.WriteLine($"await this.transport.DeadLetterAsync({deadLetterChannelUtf8Expr}, {channelUtf8Expr}, payload, headers, ex, cancellationToken).ConfigureAwait(false);"); w.WriteLine("return;"); w.PopIndent(); w.WriteLine("default:"); @@ -2738,6 +2781,20 @@ private void EmitParameterizedChannelConstruction(IndentedWriter w, OperationInf w.WriteLine("ReadOnlyMemory channelUtf8 = channelRental.AsMemory(0, channelPos);"); } + private void EmitParameterizedConsumerChannelConstruction(IndentedWriter w, OperationInfo op) + { + w.WriteLine("string channel = ChannelAddressTemplate;"); + foreach (ChannelParameter parameter in op.Parameters) + { + string parameterName = ToCamelCase(parameter.Name); + w.WriteLine($"channel = channel.Replace(\"{{{EscapeString(parameter.Name)}}}\", {parameterName}, StringComparison.Ordinal);"); + } + + w.WriteLine("this.subscribedChannel = channel;"); + w.WriteLine("this.subscribedChannelUtf8 = Encoding.UTF8.GetBytes(channel);"); + w.WriteLine("this.subscribedDeadLetterChannelUtf8 = Encoding.UTF8.GetBytes(\"dead-letter.\" + channel);"); + } + private static string EscapeString(string value) { return value.Replace("\\", "\\\\").Replace("\"", "\\\""); diff --git a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs index c41361eea7f..b44cbd7f6c3 100644 --- a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs @@ -2102,6 +2102,38 @@ public void Generate_ConsumerDynamicMultiMessage_EmitsMultiMessageSubscribeWithA StringAssert.Contains(consumer.Content, "AuthenticateAsync"); } + [TestMethod] + public void Generate_ParameterizedConsumer_EmitsChannelParameterAndConcreteAddress() + { + byte[] bytes = File.ReadAllBytes(Path.Combine("TestData", "parameterized-consumer.json")); + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(bytes); + + var generator = new AsyncApi30CodeGenerator("Parameterized", new Dictionary()); + IReadOnlyList files = generator.Generate(doc.RootElement); + + GeneratedFile? consumer = files.FirstOrDefault(f => f.FileName.Contains("SubscribeOrdersConsumer")); + Assert.IsNotNull(consumer, "A receive operation should generate a Consumer class"); + + StringAssert.Contains(consumer.Content, "public ValueTask StartAsync(string orderId, CancellationToken cancellationToken = default)"); + StringAssert.Contains(consumer.Content, "private const string ChannelAddressTemplate = \"orders.{orderId}.created\";"); + StringAssert.Contains(consumer.Content, "channel = channel.Replace(\"{orderId}\", orderId, StringComparison.Ordinal);"); + StringAssert.Contains(consumer.Content, "this.subscribedChannelUtf8"); + StringAssert.Contains(consumer.Content, "this.subscribedDeadLetterChannelUtf8"); + StringAssert.Contains(consumer.Content, "SubscribeAsync(this.subscribedChannelUtf8!, this.HandleMessageAsync, cancellationToken)"); + } + + [TestMethod] + public void Compile_ParameterizedConsumer_GeneratedCodeCompiles() + { + byte[] bytes = File.ReadAllBytes(Path.Combine("TestData", "parameterized-consumer.json")); + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(bytes); + + var generator = new AsyncApi30CodeGenerator("Parameterized", new Dictionary()); + IReadOnlyList files = generator.Generate(doc.RootElement); + + DynamicCompiler.AssertCompiles(files, "Parameterized.Generated"); + } + [TestMethod] public void Compile_ConsumerDynamicMultiMessage_GeneratedCodeCompiles() { diff --git a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/TestData/parameterized-consumer.json b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/TestData/parameterized-consumer.json new file mode 100644 index 00000000000..b0a72aa81e0 --- /dev/null +++ b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/TestData/parameterized-consumer.json @@ -0,0 +1,36 @@ +{ + "asyncapi": "3.0.0", + "info": { + "title": "Parameterized consumer", + "version": "1.0.0" + }, + "channels": { + "orders": { + "address": "orders.{orderId}.created", + "parameters": { + "orderId": { + "description": "The order identifier." + } + }, + "messages": { + "OrderCreated": { + "payload": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + } + }, + "operations": { + "subscribeOrders": { + "action": "receive", + "channel": { "$ref": "#/channels/orders" }, + "messages": [ + { "$ref": "#/channels/orders/messages/OrderCreated" } + ] + } + } +} From 6e66cedf1632732938eafa436cb120abcf3f2a98 Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Wed, 5 Aug 2026 06:52:50 +0100 Subject: [PATCH 03/11] Give a document its workspace, and let a builder hand one over (#803) The generation work that follows needs two things the document primitives did not offer. A document has to be able to say which workspace created it, so a caller can hand a value to something that owns a lifetime rather than guessing whether one is shared. And a builder has to be able to hand its rows to another document without a serialize-and-reparse round trip. JsonDocumentBuilder now implements IWorkspaceCreatedDocument, and the document interface gains the two operations that make the handover possible: reading a local element's contiguous UTF-8 without materialising it, and appending a local element's rows into another metadata database rebased to their new location. Both are additive. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq --- .../CodeGeneratorExtensions.Builder.cs | 37 + .../Corvus.Text.Json.CodeGeneration.csproj | 1 + .../JsonPathEvaluator.cs | 109 ++- .../Text/Json/Validator/DynamicCompiler.cs | 142 +++- src/Corvus.Text.Json/Corvus.Text.Json.csproj | 4 + .../Text/Json/Document/Internal/DbRow.cs | 22 + .../Json/Document/Internal/IJsonDocument.cs | 40 ++ .../Internal/IWorkspaceCreatedDocument.cs | 32 + .../Json/Document/Internal/JsonDocument.cs | 6 +- .../Document/Internal/JsonDocumentCloning.cs | 47 ++ .../Text/Json/Document/Internal/MetadataDb.cs | 17 + .../Corvus/Text/Json/Document/JsonElement.cs | 19 + .../Text/Json/Document/ParsedJsonDocument.cs | 59 ++ .../Internal/DefaultValueJsonDocument.cs | 12 + .../Internal/ParsedJsonDocumentBuilder.cs | 32 + .../DocumentBuilder/JsonDocumentBuilder.cs | 28 +- .../DocumentBuilder/JsonElement.Mutable.cs | 57 ++ .../Json/DocumentBuilder/JsonWorkspace.cs | 11 +- .../Internal/FixedJsonValueDocument.cs | 12 + .../Internal/FixedStringJsonDocument.cs | 12 + .../Internal/ValuelessJsonDocument.cs | 636 ++++++++++++++++++ .../Corvus/Text/Json/RefTuple.cs | 157 +++++ .../packages.lock.json | 90 +-- .../packages.lock.json | 51 +- .../CodeGenConformanceFixture.cs | 6 +- .../CodeGenConformanceFixture.cs | 6 +- .../CodeGenConformanceFixture.cs | 6 +- .../JsonPathUtf8QueryTests.cs | 70 ++ .../CodeGenConformanceFixture.cs | 6 +- .../packages.lock.json | 51 +- .../CloneAsBuilderTests.cs | 56 ++ .../Corvus.Text.Json.Tests.csproj | 3 + tests/Corvus.Text.Json.Tests/DummyDocument.cs | 3 + .../ParsedJsonDocumentBuilderTests.cs | 74 ++ tests/Corvus.Text.Json.Tests/RefTupleTests.cs | 80 +++ .../ValuelessJsonDocumentTests.cs | 48 ++ .../Tests/CompileToAssemblyBytesTests.cs | 56 ++ tests/Directory.Build.targets | 24 + 38 files changed, 1956 insertions(+), 166 deletions(-) create mode 100644 src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/IWorkspaceCreatedDocument.cs create mode 100644 src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/JsonDocumentCloning.cs create mode 100644 src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/ValuelessJsonDocument.cs create mode 100644 src/Corvus.Text.Json/Corvus/Text/Json/RefTuple.cs create mode 100644 tests/Corvus.Text.Json.JsonPath.Tests/JsonPathUtf8QueryTests.cs create mode 100644 tests/Corvus.Text.Json.Tests/CloneAsBuilderTests.cs create mode 100644 tests/Corvus.Text.Json.Tests/RefTupleTests.cs create mode 100644 tests/Corvus.Text.Json.Tests/ValuelessJsonDocumentTests.cs create mode 100644 tests/Corvus.Text.Json.Validator.Tests/Corvus/Text/Json/Validator/Tests/CompileToAssemblyBytesTests.cs diff --git a/src/Corvus.Text.Json.CodeGeneration/CodeGeneratorExtensions.Builder.cs b/src/Corvus.Text.Json.CodeGeneration/CodeGeneratorExtensions.Builder.cs index 6224b05b625..63e3fcbe14b 100644 --- a/src/Corvus.Text.Json.CodeGeneration/CodeGeneratorExtensions.Builder.cs +++ b/src/Corvus.Text.Json.CodeGeneration/CodeGeneratorExtensions.Builder.cs @@ -2058,6 +2058,43 @@ private static CodeGenerator AppendCommonCreateBuilder(this CodeGenerator genera } """); + // The context-threaded mirror of the above: materialise a Source that was assembled closure-free + // (the missing generic twin of the non-generic CreateBuilder(ws, in Source value)). A generated Ok + // result factory routes a context-threaded response body straight through this for a single materialisation. + // Only object/array types carry a Source (see AppendSourceOfContextRefStruct's guard), so gate on the + // same condition — a scalar has no Source to consume. + if (builders.Any(b => b.ArrayBuilderName is not null || b.ObjectBuilderName is not null) || + (typeDeclaration.ImpliedCoreTypesOrAny() & (CoreTypes.Object | CoreTypes.Array)) != 0) + { + generator + .AppendSeparatorLine() + .AppendBlockIndent( + $$""" + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder<{{generator.MutableClassName()}}> CreateBuilder( + JsonWorkspace workspace, scoped in {{generator.SourceClassName()}} value, int initialCapacity = {{initialCapacity}}) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder<{{generator.MutableClassName()}}> documentBuilder = workspace.CreateBuilder<{{generator.MutableClassName()}}>(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + """); + } + CoreTypes core = typeDeclaration.ImpliedCoreTypesOrAny(); bool isArray = (core & CoreTypes.Array) != 0; diff --git a/src/Corvus.Text.Json.CodeGeneration/Corvus.Text.Json.CodeGeneration.csproj b/src/Corvus.Text.Json.CodeGeneration/Corvus.Text.Json.CodeGeneration.csproj index 3719051bf2f..b6d17e42377 100644 --- a/src/Corvus.Text.Json.CodeGeneration/Corvus.Text.Json.CodeGeneration.csproj +++ b/src/Corvus.Text.Json.CodeGeneration/Corvus.Text.Json.CodeGeneration.csproj @@ -128,6 +128,7 @@ + diff --git a/src/Corvus.Text.Json.JsonPath/JsonPathEvaluator.cs b/src/Corvus.Text.Json.JsonPath/JsonPathEvaluator.cs index ad700f2c5ab..fdbce489fb5 100644 --- a/src/Corvus.Text.Json.JsonPath/JsonPathEvaluator.cs +++ b/src/Corvus.Text.Json.JsonPath/JsonPathEvaluator.cs @@ -3,6 +3,7 @@ // using System.Collections.Concurrent; +using System.Collections.Generic; namespace Corvus.Text.Json.JsonPath; @@ -15,6 +16,9 @@ public sealed class JsonPathEvaluator private static readonly JsonPathEvaluator DefaultInstance = new(); private static readonly JsonElement EmptyArray = JsonElement.ParseValue("[]"u8); private readonly ConcurrentDictionary cache = new(); +#if NET9_0_OR_GREATER + private readonly ConcurrentDictionary utf8Cache = new(Utf8KeyComparer.Instance); +#endif private readonly IReadOnlyDictionary? customFunctions; /// @@ -115,6 +119,56 @@ public JsonPathResult QueryNodes(string expression, in JsonElement data, Span + /// Evaluates a UTF-8 JSONPath expression against the given data, returning matched nodes in a + /// disposable . Avoids materializing a managed query string; + /// compiled queries are cached by UTF-8 content (looked up without allocation). + /// + /// The UTF-8 JSONPath expression (e.g., "$.store.book[*].title"u8). + /// The JSON data to query. + /// A containing the matched nodes; dispose to return rented memory. + /// Thrown if the expression is syntactically invalid. + public JsonPathResult QueryNodes(ReadOnlySpan utf8Expression, in JsonElement data) + { + Compiler.CompiledJsonPath compiled = this.GetOrCompile(utf8Expression); + JsonPathResult result = JsonPathResult.CreatePooled(16); + try + { + compiled.ExecuteNodes(data, ref result); + return result; + } + catch (Exception ex) when (ex is not JsonPathException) + { + result.Dispose(); + throw new JsonPathException($"Error evaluating JSONPath expression: {ex.Message}", ex); + } + } + + /// + /// Evaluates a UTF-8 JSONPath expression against the given data with a caller-provided initial + /// buffer (typically stack-allocated) for zero-heap-allocation queries. + /// + /// The UTF-8 JSONPath expression. + /// The JSON data to query. + /// A caller-provided span for result nodes; an rental is used if exceeded. + /// A containing the matched nodes; dispose to return rented memory. + /// Thrown if the expression is syntactically invalid. + public JsonPathResult QueryNodes(ReadOnlySpan utf8Expression, in JsonElement data, Span initialBuffer) + { + Compiler.CompiledJsonPath compiled = this.GetOrCompile(utf8Expression); + JsonPathResult result = new(initialBuffer); + try + { + compiled.ExecuteNodes(data, ref result); + return result; + } + catch (Exception ex) when (ex is not JsonPathException) + { + result.Dispose(); + throw new JsonPathException($"Error evaluating JSONPath expression: {ex.Message}", ex); + } + } + /// /// Evaluates a JSONPath expression against the given data, returning a JSON array /// of matched nodes. The returned element is backed by the provided workspace. @@ -157,6 +211,33 @@ private Compiler.CompiledJsonPath GetOrCompile(string expression) return compiled; } + private Compiler.CompiledJsonPath GetOrCompile(ReadOnlySpan utf8Expression) + { +#if NET9_0_OR_GREATER + if (this.utf8Cache.GetAlternateLookup>().TryGetValue(utf8Expression, out Compiler.CompiledJsonPath? existing)) + { + return existing; + } + + byte[] key = utf8Expression.ToArray(); + Compiler.CompiledJsonPath compiled; + try + { + compiled = Compiler.Compile(key, this.customFunctions); + } + catch (Exception ex) when (ex is not JsonPathException) + { + throw new JsonPathException($"Error compiling JSONPath expression: {ex.Message}", ex); + } + + this.utf8Cache.TryAdd(key, compiled); + return compiled; +#else + // Older targets lack alternate-key lookup; transcode and use the string cache. + return this.GetOrCompile(System.Text.Encoding.UTF8.GetString(utf8Expression.ToArray())); +#endif + } + private static void ValidateNoBuiltInOverrides(IReadOnlyDictionary functions) { ReadOnlySpan reserved = ["length", "count", "value", "match", "search"]; @@ -170,4 +251,30 @@ private static void ValidateNoBuiltInOverrides(IReadOnlyDictionary + /// Equality comparer for UTF-8 query keys that supports zero-allocation lookup by + /// (a byte array is materialized only when a new key is added). + /// + private sealed class Utf8KeyComparer : IEqualityComparer, IAlternateEqualityComparer, byte[]> + { + public static Utf8KeyComparer Instance { get; } = new(); + + public bool Equals(byte[]? x, byte[]? y) => x.AsSpan().SequenceEqual(y); + + public int GetHashCode(byte[] obj) => GetHashCode((ReadOnlySpan)obj); + + public bool Equals(ReadOnlySpan alternate, byte[] other) => alternate.SequenceEqual(other); + + public int GetHashCode(ReadOnlySpan alternate) + { + HashCode hash = default; + hash.AddBytes(alternate); + return hash.ToHashCode(); + } + + public byte[] Create(ReadOnlySpan alternate) => alternate.ToArray(); + } +#endif +} \ No newline at end of file diff --git a/src/Corvus.Text.Json.Validator/Corvus/Text/Json/Validator/DynamicCompiler.cs b/src/Corvus.Text.Json.Validator/Corvus/Text/Json/Validator/DynamicCompiler.cs index b0f737d9df1..157c4117b39 100644 --- a/src/Corvus.Text.Json.Validator/Corvus/Text/Json/Validator/DynamicCompiler.cs +++ b/src/Corvus.Text.Json.Validator/Corvus/Text/Json/Validator/DynamicCompiler.cs @@ -27,6 +27,9 @@ namespace Corvus.Text.Json.Validator; /// public static class DynamicCompiler { + // Mirror the .NET SDK's default ImplicitUsings set: generated code (e.g. OpenAPI clients and workflow + // executors) is authored to compile in a project with ImplicitUsings enabled, so it relies on these + // global usings rather than emitting its own (ValueTask, CancellationToken, HttpClient, …). private const string GlobalUsingStatements = """ // @@ -35,19 +38,20 @@ public static class DynamicCompiler global using global::System.Collections.Generic; global using global::System.IO; global using global::System.Linq; + global using global::System.Net.Http; + global using global::System.Threading; + global using global::System.Threading.Tasks; """; #if NET8_0_OR_GREATER private static readonly DynamicAssemblyLoadContext PluginAssemblyLoadContext = new(); #endif - // Cache metadata references per host assembly to avoid re-reading PE metadata - // from hundreds of DLLs on every compilation. The references depend only on the - // host assembly's compilation context, which doesn't change across compilations. + // Cache metadata references per (host assembly, portable) pair to avoid re-reading PE metadata + // from hundreds of DLLs on every compilation. The references depend only on the host assembly's + // compilation context and the portability mode, neither of which changes across compilations. private static readonly object s_referenceCacheLock = new(); - private static Assembly? s_cachedHostAssembly; - private static IReadOnlyList? s_cachedReferences; - private static IReadOnlyList? s_cachedDefines; + private static readonly Dictionary<(Assembly Host, bool Portable), (IReadOnlyList References, IReadOnlyList Defines)> s_referenceCache = new(); /// /// Compile the generated code files and return the exported type with the given fully-qualified name. @@ -61,9 +65,40 @@ public static Type CompileGeneratedType( string rootTypeName, IReadOnlyCollection generatedCode, Assembly hostAssembly) + { + using MemoryStream outputStream = EmitOrThrow(generatedCode, hostAssembly); + Assembly generatedAssembly = LoadAssembly(outputStream); + return generatedAssembly.ExportedTypes.Single(t => t.FullName == rootTypeName); + } + + /// + /// Compile the generated code files into a single assembly and return its raw PE (DLL) bytes, without + /// loading it. Used to produce a storable assembly artifact (e.g. baked into a workflow package). + /// + /// The generated code files. + /// The host assembly whose compilation context provides metadata references. + /// When , compile against reference assemblies only (the reference-assembly + /// BCL from the compilation context plus output-directory project references), never the AppDomain's loaded + /// implementation assemblies. The emitted assembly then references System.Runtime rather than + /// System.Private.CoreLib, so it can be compiled against reference assemblies downstream — required when the + /// assembly is shipped elsewhere and recompiled, e.g. a serverless executor native-AOT compiled in a build container + /// (ADR 0055). The default () keeps the implementation-assembly references an in-process load + /// needs. + /// The emitted assembly bytes. + /// Compilation failed. + public static byte[] CompileToAssemblyBytes( + IReadOnlyCollection generatedCode, + Assembly hostAssembly, + bool portable = false) + { + using MemoryStream outputStream = EmitOrThrow(generatedCode, hostAssembly, portable); + return outputStream.ToArray(); + } + + private static MemoryStream EmitOrThrow(IReadOnlyCollection generatedCode, Assembly hostAssembly, bool portable = false) { (IReadOnlyList references, IReadOnlyList defines) = - GetOrBuildMetadataReferences(hostAssembly); + GetOrBuildMetadataReferences(hostAssembly, portable); IEnumerable syntaxTrees = ParseSyntaxTrees(generatedCode, defines); @@ -76,11 +111,12 @@ public static Type CompileGeneratedType( references, options); - using MemoryStream outputStream = new(); + MemoryStream outputStream = new(); EmitResult result = compilation.Emit(outputStream); if (!result.Success) { + outputStream.Dispose(); string errors = BuildCompilationErrors(result); throw new InvalidOperationException( "Unable to compile generated code\r\n" + errors); @@ -88,34 +124,30 @@ public static Type CompileGeneratedType( outputStream.Flush(); outputStream.Position = 0; - - Assembly generatedAssembly = LoadAssembly(outputStream); - return generatedAssembly.ExportedTypes.Single(t => t.FullName == rootTypeName); + return outputStream; } private static (IReadOnlyList MetadataReferences, IReadOnlyList Defines) - GetOrBuildMetadataReferences(Assembly hostAssembly) + GetOrBuildMetadataReferences(Assembly hostAssembly, bool portable) { lock (s_referenceCacheLock) { - if (s_cachedReferences is not null && ReferenceEquals(s_cachedHostAssembly, hostAssembly)) + if (s_referenceCache.TryGetValue((hostAssembly, portable), out (IReadOnlyList References, IReadOnlyList Defines) cached)) { - return (s_cachedReferences, s_cachedDefines!); + return cached; } (IReadOnlyList references, IReadOnlyList defines) = - BuildMetadataReferencesAndDefines(hostAssembly); + BuildMetadataReferencesAndDefines(hostAssembly, portable); - s_cachedHostAssembly = hostAssembly; - s_cachedReferences = references; - s_cachedDefines = defines; + s_referenceCache[(hostAssembly, portable)] = (references, defines); return (references, defines); } } private static (IReadOnlyList MetadataReferences, IReadOnlyList Defines) - BuildMetadataReferencesAndDefines(Assembly hostAssembly) + BuildMetadataReferencesAndDefines(Assembly hostAssembly, bool portable) { DependencyContext? ctx = DependencyContext.Load(hostAssembly) ?? DependencyContext.Default; @@ -136,10 +168,22 @@ from r in TryResolveReferencePaths(l) refs = []; } - // Always supplement — even when DependencyContext resolves framework assemblies, - // they may be facades (e.g. mscorlib 2.0.0.0 from NuGet reference assembly packages) - // that cause CS1705 version mismatch. AppDomain has the real GAC versions. - SupplementWithDirectoryAndAppDomain(refs, hostAssembly); + if (portable) + { + // The DependencyContext compile libraries already resolve the reference-assembly BCL (System.Runtime, not the + // implementation System.Private.CoreLib) plus every package/project reference, so the emitted assembly is + // portable. Supplement only with output-directory project references DependencyContext may not surface — never + // the AppDomain's loaded implementation assemblies, which would stamp System.Private.CoreLib and break a + // downstream reference-assembly compile. + SupplementWithOutputDirectory(refs, hostAssembly, excludeImplementationCorlib: true); + } + else + { + // Always supplement — even when DependencyContext resolves framework assemblies, they may be facades (e.g. + // mscorlib 2.0.0.0 from NuGet reference assembly packages) that cause CS1705 version mismatch. AppDomain has the + // real GAC versions. The emitted assembly references implementation assemblies, so it must be loaded in-process. + SupplementWithDirectoryAndAppDomain(refs, hostAssembly); + } return (refs, defines); } @@ -237,6 +281,58 @@ peRef.FilePath is string p && ResolveTransitiveReferences(references, seenNames); } + // The portable supplement: add project/third-party assemblies from the output directory that the DependencyContext did + // not surface, WITHOUT touching the AppDomain's loaded implementation assemblies. The reference-assembly BCL from the + // compilation context stays the corlib, so the emitted assembly references System.Runtime (portable), not + // System.Private.CoreLib, and can be compiled against reference assemblies downstream. A framework-dependent output + // directory holds no BCL, and the implementation corlib is skipped defensively in case a self-contained one does. + private static void SupplementWithOutputDirectory(List references, Assembly hostAssembly, bool excludeImplementationCorlib) + { + string? directory = AppDomain.CurrentDomain.BaseDirectory; + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + string? assemblyLocation = hostAssembly.Location; + directory = !string.IsNullOrEmpty(assemblyLocation) ? Path.GetDirectoryName(assemblyLocation) : null; + } + + if (directory is null || !Directory.Exists(directory)) + { + return; + } + + HashSet seenNames = new(StringComparer.OrdinalIgnoreCase); + foreach (MetadataReference r in references) + { + if (r is PortableExecutableReference peRef && peRef.FilePath is string path) + { + seenNames.Add(Path.GetFileNameWithoutExtension(path)); + } + } + + foreach (string dll in Directory.EnumerateFiles(directory, "*.dll")) + { + string simpleName = Path.GetFileNameWithoutExtension(dll); + if (excludeImplementationCorlib && simpleName.Equals("System.Private.CoreLib", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (seenNames.Add(simpleName)) + { + try + { + // Validate the DLL is a managed assembly (a native DLL passes CreateFromFile but causes CS0009). + AssemblyName.GetAssemblyName(dll); + references.Add(MetadataReference.CreateFromFile(dll)); + } + catch + { + seenNames.Remove(simpleName); + } + } + } + } + private static void ResolveTransitiveReferences(List references, HashSet seenNames) { Queue toResolve = new(); diff --git a/src/Corvus.Text.Json/Corvus.Text.Json.csproj b/src/Corvus.Text.Json/Corvus.Text.Json.csproj index 97da8c38afa..23ae4c13b9f 100644 --- a/src/Corvus.Text.Json/Corvus.Text.Json.csproj +++ b/src/Corvus.Text.Json/Corvus.Text.Json.csproj @@ -185,7 +185,9 @@ + + @@ -198,6 +200,7 @@ + @@ -291,6 +294,7 @@ + diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/DbRow.cs b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/DbRow.cs index 90dc1fa076e..a6553a9450d 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/DbRow.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/DbRow.cs @@ -111,6 +111,28 @@ internal DbRow(JsonTokenType jsonTokenType, int externalIndex, int sizeOrLength, _numberOfRowsExternalDocumentIndexAndTypeUnion = (unchecked((uint)jsonTokenType << 28) + (unchecked((uint)workspaceDocumentIndex) & 0x0FFFFFFFU)); } + /// + /// Creates a fully-specified local row: an explicit number of rows and complex-children flag, for + /// copying an already-parsed row run into another database (the source rows carry correct structure; + /// only the location is rebased by the caller). + /// + /// The . + /// The (rebased) location of the value in the UTF8 backing. + /// The size or length of the entity (a property-map index must be normalized to the plain length by the caller). + /// The number of rows the entity occupies. + /// Whether the row carries the complex-children/escaped flag. + internal DbRow(JsonTokenType jsonTokenType, int location, int sizeOrLength, int numberOfRows, bool hasComplexChildren) + { + Debug.Assert(jsonTokenType > JsonTokenType.None && jsonTokenType <= JsonTokenType.Null, "The token type is out of the valid range."); + Debug.Assert(location >= 0, "The location must be >= 0"); + Debug.Assert(sizeOrLength >= 0, "The size or length must be >= 0 (normalize property-map indexes before copying)"); + Debug.Assert(numberOfRows >= 1, "The number of rows must be >= 1"); + + _locationAndFromExternalDocumentUnion = (uint)location; + _sizeLengthOrPropertyMapIndexUnion = hasComplexChildren ? sizeOrLength | int.MinValue : sizeOrLength; + _numberOfRowsExternalDocumentIndexAndTypeUnion = unchecked((uint)jsonTokenType << 28) | (unchecked((uint)numberOfRows) & 0x0FFFFFFFU); + } + /// /// Creates an instance of a DBRow. /// diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/IJsonDocument.cs b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/IJsonDocument.cs index cbe010d8976..048be8a5a8d 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/IJsonDocument.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/IJsonDocument.cs @@ -577,6 +577,46 @@ bool TryGetNamedPropertyValue(int index, ReadOnlySpan propertyNa /// The cloned JSON element. JsonElement CloneElement(int index); + /// + /// Creates a standalone copy of the element at the specified index as a new document + /// owned by . + /// + /// The index of the element to copy. + /// The workspace that will own the cloned document. + /// A workspace-owned builder containing the standalone copy. + /// + /// The returned document is fully independent of this document and its backing storage (both the + /// metadata and the value bytes are copied), so this document may be disposed without affecting + /// the copy. The copy lives for the lifetime of . + /// + JsonDocumentBuilder CloneElementAsBuilder(int index, JsonWorkspace workspace); + + /// + /// Attempts to expose the element at the specified index as a contiguous, fully-local segment: its + /// complete raw UTF-8 (including any enclosing quotes or braces) and the document-text offset that + /// segment starts at. Succeeds only when the element's metadata rows are local to this document and + /// their locations all fall within the returned segment — an immutable parsed document qualifies; + /// builders and synthetic documents return . + /// + /// The index of the element. + /// The element's complete raw UTF-8. + /// The offset of within this document's text (the base the element's row locations are relative to). + /// when the contiguous local segment is available (and a subsequent + /// is valid); otherwise . + bool TryGetContiguousLocalElement(int index, out ReadOnlyMemory utf8, out int sourceTextOffset); + + /// + /// Appends the element's complete row run to the given metadata database with every row's location + /// rebased by (property-map indexes normalized to plain lengths) — + /// the row-copy half of the contiguous-element blit. Valid only after + /// returned for the same index. + /// + /// The index of the element. + /// The destination metadata database. + /// The delta to add to each row's text location (destination text position minus the source segment offset). + /// The number of rows appended. + int AppendLocalElementRowsRebased(int index, ref MetadataDb db, int locationDelta); + /// /// Clones the element at the specified index. /// diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/IWorkspaceCreatedDocument.cs b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/IWorkspaceCreatedDocument.cs new file mode 100644 index 00000000000..95957e8b202 --- /dev/null +++ b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/IWorkspaceCreatedDocument.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +namespace Corvus.Text.Json.Internal; + +/// +/// A that records the workspace which created it, so that it is +/// disposed only by its creator — never by another workspace that merely referenced it. +/// +/// +/// +/// A workspace's document table () registers a document both when it creates one and +/// when it references one from another workspace to resolve a cross-workspace value (the referenced document's +/// value bytes are addressed by its index in the referencing workspace). Disposal must distinguish the two: +/// disposing a document that another workspace created — and still owns — corrupts that workspace. A document +/// implementing this interface is disposed by a workspace only when is that +/// workspace. +/// +/// +/// Implemented by builders (JsonDocumentBuilder), whose creator is fixed at construction and is never +/// changed by being referenced elsewhere. +/// +/// +[CLSCompliant(false)] +public interface IWorkspaceCreatedDocument : IWorkspaceManagedDocument +{ + /// + /// Gets the workspace that created this document and is therefore responsible for disposing it. + /// + JsonWorkspace CreatingWorkspace { get; } +} \ No newline at end of file diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/JsonDocument.cs b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/JsonDocument.cs index daaff0f73c6..f5c5812b63c 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/JsonDocument.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/JsonDocument.cs @@ -1437,8 +1437,12 @@ protected int EscapeAndStoreRawStringValue(ReadOnlySpan value, out bool re { requiredEscaping = true; + // Size the scratch buffer to the MAX ESCAPED length, not the raw length: escaping expands (e.g. ' -> ', + // 6x), so a buffer sized to valueLength overflows when an escapable char pushes the output past it (and past a + // pool bucket). This mirrors the writer's WriteStringEscapeValue and the _valueBacking sizing above. + int maxEscapedLength = JsonWriterHelper.GetMaxEscapedLength(valueLength, valueIdx); char[]? buffer = null; - Span escapedBuffer = valueLength <= JsonConstants.StackallocCharThreshold ? stackalloc char[JsonConstants.StackallocCharThreshold] : (buffer = ArrayPool.Shared.Rent(valueLength)).AsSpan(); + Span escapedBuffer = maxEscapedLength <= JsonConstants.StackallocCharThreshold ? stackalloc char[JsonConstants.StackallocCharThreshold] : (buffer = ArrayPool.Shared.Rent(maxEscapedLength)).AsSpan(); JsonWriterHelper.EscapeString(value, escapedBuffer, valueIdx, encoder, out written); JsonWriterHelper.ToUtf8(escapedBuffer.Slice(0, written), _valueBacking.AsSpan(index), out written); diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/JsonDocumentCloning.cs b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/JsonDocumentCloning.cs new file mode 100644 index 00000000000..71d9695fbfd --- /dev/null +++ b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/JsonDocumentCloning.cs @@ -0,0 +1,47 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +namespace Corvus.Text.Json.Internal; + +/// +/// Shared implementation of for +/// document types that have no cheaper specialisation: it serialises the element and re-parses it into +/// a workspace-owned document, producing a standalone copy with no dependency on the source document. +/// +internal static class JsonDocumentCloning +{ + /// + /// Clones the element at of into a new + /// workspace-owned document by serialising and re-parsing it. + /// + /// The source document. + /// The index of the element to clone. + /// The workspace that will own the clone. + /// A workspace-owned builder containing a standalone copy of the element. + public static JsonDocumentBuilder CloneElementAsBuilderBySerialization( + IJsonDocument document, + int index, + JsonWorkspace workspace) + { + if (workspace is null) + { + throw new ArgumentNullException(nameof(workspace)); + } + + // Rent the writer and its backing buffer from the workspace's pool rather than allocating a + // fresh ArrayBufferWriter/Utf8JsonWriter per call. Parse copies the written span into its own + // pooled buffer, so the rented buffer can be returned immediately afterwards. + Utf8JsonWriter writer = workspace.RentWriterAndBuffer(256, out IByteBufferWriter bufferWriter); + try + { + document.WriteElementTo(index, writer); + writer.Flush(); + return JsonDocumentBuilder.Parse(workspace, bufferWriter.WrittenSpan); + } + finally + { + workspace.ReturnWriterAndBuffer(writer, bufferWriter); + } + } +} \ No newline at end of file diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/MetadataDb.cs b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/MetadataDb.cs index d8dfd71b760..bb9596c8a4a 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/MetadataDb.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/Document/Internal/MetadataDb.cs @@ -278,6 +278,23 @@ internal void CompleteAllocations() } } + /// + /// Appends a fully-formed row (used when copying an already-parsed row run whose structure is + /// already correct — the caller has rebased the location and normalized any property-map index). + /// + /// The row to append. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Append(in DbRow row) + { + if (Length >= (_data.Length - DbRow.Size)) + { + Enlarge(); + } + + Unsafe.WriteUnaligned(ref _data[Length], row); + Length += DbRow.Size; + } + /// /// Appends a new token entry to the metadata database. /// diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/Document/JsonElement.cs b/src/Corvus.Text.Json/Corvus/Text/Json/Document/JsonElement.cs index 0ced5c470f7..f638c63f555 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/Document/JsonElement.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/Document/JsonElement.cs @@ -2295,6 +2295,25 @@ public JsonElement Clone() return _parent.CloneElement(_idx); } + /// + /// Creates a standalone copy of this element as a new document owned by + /// . + /// + /// The workspace that will own the cloned document. + /// A workspace-owned builder containing the standalone copy. + /// + /// The returned document is fully independent of this element's source document and its backing + /// storage (metadata and value bytes are copied), so the source document may be disposed without + /// affecting the copy. The copy lives for the lifetime of . + /// + [CLSCompliant(false)] + public JsonDocumentBuilder CloneAsBuilder(JsonWorkspace workspace) + { + CheckValidInstance(); + + return _parent.CloneElementAsBuilder(_idx, workspace); + } + /// /// Creates a frozen (immutable) copy of this element if it is backed by a mutable document, /// or returns this instance if it is already immutable. diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/Document/ParsedJsonDocument.cs b/src/Corvus.Text.Json/Corvus/Text/Json/Document/ParsedJsonDocument.cs index 5d257ef020d..6dea37defb9 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/Document/ParsedJsonDocument.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/Document/ParsedJsonDocument.cs @@ -1530,6 +1530,14 @@ JsonElement IJsonDocument.CloneElement(int index) return JsonElement.From(CloneElement(index)); } + /// + JsonDocumentBuilder IJsonDocument.CloneElementAsBuilder(int index, JsonWorkspace workspace) + { + // TODO(perf): an immutable parsed document can blit the value segment (CopySegment + a copy + // of the contiguous value bytes) into the workspace instead of serialising and re-parsing. + return Internal.JsonDocumentCloning.CloneElementAsBuilderBySerialization(this, index, workspace); + } + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] TElement IJsonDocument.CloneElement(int index) @@ -1902,6 +1910,57 @@ int IJsonDocument.GetHashCode(int index) /// ReadOnlyMemory IJsonDocument.GetRawSimpleValueUnsafe(int index) => GetRawSimpleValueUnsafe(index); + /// + bool IJsonDocument.TryGetContiguousLocalElement(int index, out ReadOnlyMemory utf8, out int sourceTextOffset) + { + CheckNotDisposed(); + + // A parsed document's rows are always fully local and an element's rows/text are contiguous, + // so the blit is always available. Mirror GetRawValueUnsafe's include-quotes segments. + DbRow row = _parsedData.Get(index); + if (row.IsSimpleValue) + { + if (row.TokenType == JsonTokenType.String) + { + sourceTextOffset = row.LocationOrIndex - 1; + utf8 = _utf8Json.Slice(sourceTextOffset, row.SizeOrLengthOrPropertyMapIndex + 2); + } + else + { + sourceTextOffset = row.LocationOrIndex; + utf8 = _utf8Json.Slice(sourceTextOffset, row.SizeOrLengthOrPropertyMapIndex); + } + + return true; + } + + int endElementIdx = index + GetDbSizeUnsafe(index, includeEndElement: false); + DbRow end = _parsedData.Get(endElementIdx); + sourceTextOffset = row.LocationOrIndex; + int endTokenLength = end.HasPropertyMap ? GetLengthOfEndToken(end.SizeOrLengthOrPropertyMapIndex) : end.SizeOrLengthOrPropertyMapIndex; + utf8 = _utf8Json.Slice(sourceTextOffset, end.LocationOrIndex - sourceTextOffset + endTokenLength); + return true; + } + + /// + int IJsonDocument.AppendLocalElementRowsRebased(int index, ref MetadataDb db, int locationDelta) + { + CheckNotDisposed(); + + int endExclusive = index + GetDbSizeUnsafe(index, includeEndElement: true); + int count = 0; + for (int i = index; i < endExclusive; i += DbRow.Size) + { + DbRow row = _parsedData.Get(i); + bool isEnd = row.TokenType is JsonTokenType.EndObject or JsonTokenType.EndArray; + int sizeOrLength = isEnd && row.HasPropertyMap ? GetLengthOfEndToken(row.SizeOrLengthOrPropertyMapIndex) : row.SizeOrLengthOrPropertyMapIndex; + db.Append(new DbRow(row.TokenType, row.LocationOrIndex + locationDelta, sizeOrLength, row.NumberOfRows, !isEnd && row.HasComplexChildren)); + count++; + } + + return count; + } + private int AppendElement(int index, ref MetadataDb db, int workspaceDocumentIndex) { switch (_parsedData.GetJsonTokenType(index)) diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/Internal/DefaultValueJsonDocument.cs b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/Internal/DefaultValueJsonDocument.cs index b66f1851740..58b8a5c54af 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/Internal/DefaultValueJsonDocument.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/Internal/DefaultValueJsonDocument.cs @@ -382,6 +382,18 @@ public TElement CloneElement(int index) where TElement : struct, IJsonElement => this.Create(index); + /// + JsonDocumentBuilder IJsonDocument.CloneElementAsBuilder(int index, JsonWorkspace workspace) + => this.inner.CloneElementAsBuilder(index, workspace); + + /// + bool IJsonDocument.TryGetContiguousLocalElement(int index, out ReadOnlyMemory utf8, out int sourceTextOffset) + => this.inner.TryGetContiguousLocalElement(index, out utf8, out sourceTextOffset); + + /// + int IJsonDocument.AppendLocalElementRowsRebased(int index, ref MetadataDb db, int locationDelta) + => this.inner.AppendLocalElementRowsRebased(index, ref db, locationDelta); + /// public int GetDbSize(int index, bool includeEndElement) => this.inner.GetDbSize(index, includeEndElement); diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/Internal/ParsedJsonDocumentBuilder.cs b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/Internal/ParsedJsonDocumentBuilder.cs index 142c33aa1d8..f489f8398ec 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/Internal/ParsedJsonDocumentBuilder.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/Internal/ParsedJsonDocumentBuilder.cs @@ -279,6 +279,29 @@ void IComplexValueConstructionCallbacks.AppendExternalElement(IJsonDocument sour { CheckNotDisposed(); + // Fast path: the element is a contiguous fully-local segment of an immutable parsed + // document, so its raw text can be copied wholesale and its rows rebased in place — + // no per-token re-tokenization. + if (sourceDocument.TryGetContiguousLocalElement(sourceIndex, out ReadOnlyMemory raw, out int sourceTextOffset)) + { + BeginValueToken(raw.Length); + int start = _textPos; + raw.Span.CopyTo(_text.AsSpan(_textPos, raw.Length)); + _textPos += raw.Length; + + int firstRowByteIndex = db.Length; + int rows = sourceDocument.AppendLocalElementRowsRebased(sourceIndex, ref db, start - sourceTextOffset); + for (int i = 0; i < rows; i++) + { + DbRow row = db.Get(firstRowByteIndex + (i * DbRow.Size)); + bool isContainerToken = row.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray or JsonTokenType.EndObject or JsonTokenType.EndArray; + PushPair(row.LocationOrIndex, isContainerToken ? PairKeepSize : row.SizeOrLengthOrPropertyMapIndex); + } + + CompleteValueToken(); + return; + } + // Expand the element to external reference rows (the standard mechanism), then resolve // them immediately: content into the text backing, fully-local rows into the metadata. var scratch = MetadataDb.CreateRented(16 * DbRow.Size, convertToAlloc: false); @@ -1617,6 +1640,15 @@ ReadOnlyMemory IJsonDocument.GetRawSimpleValue(int index) /// TElement IJsonDocument.CloneElement(int index) => throw ConstructionOnly(); + /// + JsonDocumentBuilder IJsonDocument.CloneElementAsBuilder(int index, JsonWorkspace workspace) => throw ConstructionOnly(); + + /// + bool IJsonDocument.TryGetContiguousLocalElement(int index, out ReadOnlyMemory utf8, out int sourceTextOffset) => throw ConstructionOnly(); + + /// + int IJsonDocument.AppendLocalElementRowsRebased(int index, ref MetadataDb db, int locationDelta) => throw ConstructionOnly(); + /// int IJsonDocument.GetDbSize(int index, bool includeEndElement) => throw ConstructionOnly(); diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonDocumentBuilder.cs b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonDocumentBuilder.cs index 6c399519320..da16a816886 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonDocumentBuilder.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonDocumentBuilder.cs @@ -23,7 +23,7 @@ namespace Corvus.Text.Json; /// /// The type of mutable JSON element this builder works with. [CLSCompliant(false)] -public sealed partial class JsonDocumentBuilder : JsonDocument, IMutableJsonDocument +public sealed partial class JsonDocumentBuilder : JsonDocument, IMutableJsonDocument, IWorkspaceCreatedDocument where T : struct, IMutableJsonElement { private readonly JsonWorkspace _workspace; @@ -67,6 +67,10 @@ internal JsonDocumentBuilder(JsonWorkspace workspace) [DebuggerBrowsable(DebuggerBrowsableState.Never)] JsonWorkspace IMutableJsonDocument.Workspace => _workspace; + /// + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + JsonWorkspace IWorkspaceCreatedDocument.CreatingWorkspace => _workspace; + /// /// Gets the root element of the JSON document. /// @@ -2076,6 +2080,28 @@ JsonElement IJsonDocument.CloneElement(int index) return CloneElement(index); } + /// + JsonDocumentBuilder IJsonDocument.CloneElementAsBuilder(int index, JsonWorkspace workspace) + { + // TODO(perf): write directly to a workspace-rented Utf8JsonWriter/buffer and Parse() taking + // ownership of the buffer memory, avoiding the intermediate ArrayBufferWriter + re-copy. + return Internal.JsonDocumentCloning.CloneElementAsBuilderBySerialization(this, index, workspace); + } + + /// + bool IJsonDocument.TryGetContiguousLocalElement(int index, out ReadOnlyMemory utf8, out int sourceTextOffset) + { + // A builder's element may span mutated segments and external references, so its text is not + // guaranteed contiguous; callers must take the token-by-token path. + utf8 = default; + sourceTextOffset = 0; + return false; + } + + /// + int IJsonDocument.AppendLocalElementRowsRebased(int index, ref MetadataDb db, int locationDelta) + => throw new NotSupportedException("Valid only after TryGetContiguousLocalElement returned true."); + /// TElement IJsonDocument.CloneElement(int index) { diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonElement.Mutable.cs b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonElement.Mutable.cs index 913bc10e344..9b759f1081e 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonElement.Mutable.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonElement.Mutable.cs @@ -1011,6 +1011,10 @@ public void AddAsProperty(ReadOnlySpan utf8Name, ref ComplexValueBuilder v { switch (_kind) { + case Kind.Unknown: + // An undefined source represents an absent optional property: omit it, matching the typed builders. + break; + case Kind.JsonElement: valueBuilder.AddProperty(utf8Name, _jsonElement, escapeName, nameRequiresUnescaping); break; @@ -1078,6 +1082,10 @@ public void AddAsPrebakedProperty(ReadOnlySpan prebakedPropertyName, ref C { switch (_kind) { + case Kind.Unknown: + // An undefined source represents an absent optional property: omit it, matching the typed builders. + break; + case Kind.JsonElement: valueBuilder.AddPrebakedProperty(prebakedPropertyName, _jsonElement); break; @@ -1155,6 +1163,10 @@ public void AddAsProperty(ReadOnlySpan name, ref ComplexValueBuilder value { switch (_kind) { + case Kind.Unknown: + // An undefined source represents an absent optional property: omit it, matching the typed builders. + break; + case Kind.JsonElement: valueBuilder.AddProperty(name, _jsonElement); break; @@ -1567,6 +1579,10 @@ public void AddAsProperty(ReadOnlySpan utf8Name, ref ComplexValueBuilder v { switch (_kind) { + case Kind.Unknown: + // An undefined source represents an absent optional property: omit it, matching the typed builders. + break; + case Kind.JsonArrayBuilderInstance: valueBuilder.AddProperty(utf8Name, BuildWithContext.Create(_context, _arrayBuilder!), static (in b, ref o) => ArrayBuilder.BuildValue(b.Context, b.Build, ref o), escapeName, nameRequiresUnescaping); break; @@ -1594,6 +1610,10 @@ public void AddAsPrebakedProperty(ReadOnlySpan prebakedPropertyName, ref C { switch (_kind) { + case Kind.Unknown: + // An undefined source represents an absent optional property: omit it, matching the typed builders. + break; + case Kind.JsonArrayBuilderInstance: valueBuilder.AddPrebakedProperty(prebakedPropertyName, BuildWithContext.Create(_context, _arrayBuilder!), static (in b, ref o) => ArrayBuilder.BuildValue(b.Context, b.Build, ref o)); break; @@ -1631,6 +1651,10 @@ public void AddAsProperty(ReadOnlySpan name, ref ComplexValueBuilder value { switch (_kind) { + case Kind.Unknown: + // An undefined source represents an absent optional property: omit it, matching the typed builders. + break; + case Kind.JsonArrayBuilderInstance: valueBuilder.AddProperty(name, BuildWithContext.Create(_context, _arrayBuilder!), static (in b, ref o) => ArrayBuilder.BuildValue(b.Context, b.Build, ref o)); break; @@ -1779,6 +1803,39 @@ public static JsonDocumentBuilder CreateBuilder(JsonWorkspace workspace return documentBuilder; } + /// + /// Creates a JSON document builder from a context-threaded source. + /// + /// The type of the context carried by the source. + /// The workspace. + /// The context-threaded source from which to create the document. + /// The (optional) estimated member count for the root value. + /// The initial size in bytes of the value buffer. + /// A new initialised with the given source. + /// The generic mirror of : a body assembled + /// closure-free as a is materialised in a single pass. The universal + /// needs this so a generated Ok<TContext> result factory whose response body is + /// an any-schema (resolved to ) can route the context-threaded body through it. + /// This method is not CLS compliant. + [CLSCompliant(false)] + public static JsonDocumentBuilder CreateBuilder(JsonWorkspace workspace, in Source source, int estimatedMemberCount = 30, int initialValueBufferSize = 8192) +#if NET9_0_OR_GREATER + where TContext : allows ref struct +#endif + { + // Create the document builder without a MetadataDb + if (source.IsUndefined) + { + ThrowHelper.ThrowArgumentException(SR.EmptyJsonIsInvalid); + } + + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1, initialValueBufferSize); + var cvb = ComplexValueBuilder.Create(documentBuilder, estimatedMemberCount); + source.AddAsItem(ref cvb); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates a JSON document builder from an array builder. /// diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonWorkspace.cs b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonWorkspace.cs index 4ace84a34a8..59f0fd1bfd3 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonWorkspace.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/DocumentBuilder/JsonWorkspace.cs @@ -410,7 +410,16 @@ private void DisposeMutable() { foreach (IJsonDocument document in _documents.AsSpan(0, _length)) { - if (document is IWorkspaceManagedDocument || _ownedDocuments?.Contains(document) == true) + // _documents holds both documents this workspace created and documents it merely referenced from + // another workspace to resolve a cross-workspace value (registered for index lookup only). A + // creator-tracked document (a builder) is disposed only by the workspace that created it — disposing + // one another workspace still owns would corrupt that workspace. Other managed documents (pooled + // value documents, frozen wrappers) keep their registration-based lifetime, and any document + // explicitly taken over via TakeOwnership is owned here regardless of who created it. + bool owned = document is IWorkspaceCreatedDocument created + ? ReferenceEquals(created.CreatingWorkspace, this) + : document is IWorkspaceManagedDocument; + if (owned || _ownedDocuments?.Contains(document) == true) { document.Dispose(); } diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/FixedJsonValueDocument.cs b/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/FixedJsonValueDocument.cs index d60f2ac62cc..6ce10499e4b 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/FixedJsonValueDocument.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/FixedJsonValueDocument.cs @@ -362,6 +362,18 @@ JsonElement IJsonDocument.CloneElement(int index) #pragma warning restore CS0618 } + JsonDocumentBuilder IJsonDocument.CloneElementAsBuilder(int index, JsonWorkspace workspace) => JsonDocumentCloning.CloneElementAsBuilderBySerialization(this, index, workspace); + + bool IJsonDocument.TryGetContiguousLocalElement(int index, out ReadOnlyMemory utf8, out int sourceTextOffset) + { + utf8 = default; + sourceTextOffset = 0; + return false; + } + + int IJsonDocument.AppendLocalElementRowsRebased(int index, ref MetadataDb db, int locationDelta) + => throw new NotSupportedException("Valid only after TryGetContiguousLocalElement returned true."); + TElement IJsonDocument.CloneElement(int index) { #pragma warning disable CS0618 // Type or member is obsolete diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/FixedStringJsonDocument.cs b/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/FixedStringJsonDocument.cs index d99d752d2bd..00ab4202de0 100644 --- a/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/FixedStringJsonDocument.cs +++ b/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/FixedStringJsonDocument.cs @@ -75,6 +75,18 @@ public static FixedStringJsonDocument Parse(ReadOnlyMemory rawJsonStrin JsonElement IJsonDocument.CloneElement(int index) => new(this, 0); + JsonDocumentBuilder IJsonDocument.CloneElementAsBuilder(int index, JsonWorkspace workspace) => JsonDocumentCloning.CloneElementAsBuilderBySerialization(this, index, workspace); + + bool IJsonDocument.TryGetContiguousLocalElement(int index, out ReadOnlyMemory utf8, out int sourceTextOffset) + { + utf8 = default; + sourceTextOffset = 0; + return false; + } + + int IJsonDocument.AppendLocalElementRowsRebased(int index, ref MetadataDb db, int locationDelta) + => throw new NotSupportedException("Valid only after TryGetContiguousLocalElement returned true."); + TElement IJsonDocument.CloneElement(int index) { #if NET diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/ValuelessJsonDocument.cs b/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/ValuelessJsonDocument.cs new file mode 100644 index 00000000000..4188bd2037f --- /dev/null +++ b/src/Corvus.Text.Json/Corvus/Text/Json/JsonSchema/Internal/ValuelessJsonDocument.cs @@ -0,0 +1,636 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using Corvus.Numerics; +using NodaTime; + +namespace Corvus.Text.Json.Internal; + +/// +/// Represents one of the three "valueless" JSON literals — true, false, and +/// null — whose value is fully determined by its token type and which therefore need no backing +/// buffer at all. +/// +/// The type of the root element in the document. +/// +/// +/// There is exactly one possible true, false, and null value, so this type exposes +/// them as shared , , and +/// singletons. Each is immutable and allocation-free — safe to hand out from a static readonly +/// field and never dispose. +/// +/// +/// Unlike this is not an +/// and is not pooled: a workspace will never dispose it on +/// reset, so the same singleton can be shared across many workspaces and runs. +/// is a no-op. +/// +/// +[CLSCompliant(false)] +public sealed class ValuelessJsonDocument : IJsonDocument + where T : struct, IJsonElement +{ + /// The shared true singleton. + public static readonly ValuelessJsonDocument BooleanTrue = new(JsonTokenType.True); + + /// The shared false singleton. + public static readonly ValuelessJsonDocument BooleanFalse = new(JsonTokenType.False); + + /// The shared null singleton. + public static readonly ValuelessJsonDocument Null = new(JsonTokenType.Null); + + private static readonly byte[] TrueBytes = "true"u8.ToArray(); + private static readonly byte[] FalseBytes = "false"u8.ToArray(); + private static readonly byte[] NullBytes = "null"u8.ToArray(); + + private readonly JsonTokenType _tokenType; + + private ValuelessJsonDocument(JsonTokenType tokenType) => _tokenType = tokenType; + + JsonWorkspace? IJsonDocument.CachedWorkspace { get; set; } + + int IJsonDocument.CachedWorkspaceDocumentIndex { get; set; } + + int IJsonDocument.CachedWorkspaceGeneration { get; set; } + + bool IJsonDocument.IsDisposable => false; + + bool IJsonDocument.IsImmutable => true; + +#if NET + /// Gets the root element of the document. + public T RootElement => T.CreateInstance(this, 0); +#else + /// Gets the root element of the document. + public T RootElement => JsonElementHelpers.CreateInstance(this, 0); +#endif + + private ReadOnlyMemory RawValue => _tokenType switch + { + JsonTokenType.True => TrueBytes, + JsonTokenType.False => FalseBytes, + _ => NullBytes, + }; + + void IDisposable.Dispose() + { + // Singletons are never disposed. + } + + void IJsonDocument.AppendElementToMetadataDb(int index, JsonWorkspace workspace, ref MetadataDb db) + { + Debug.Assert(index == 0); + int workspaceDocumentIndex = workspace.GetDocumentIndex(this); + db.AppendExternal(_tokenType, 0, RawValue.Length, workspaceDocumentIndex); + } + + int IJsonDocument.WriteElementToMetadataDb(int index, JsonWorkspace workspace, ref MetadataDb db, int writePosition) + { + Debug.Assert(index == 0); + int workspaceDocumentIndex = workspace.GetDocumentIndex(this); + db.WriteRowAt(writePosition, new DbRow(_tokenType, 0, RawValue.Length, workspaceDocumentIndex)); + return 1; + } + + int IJsonDocument.BuildRentedMetadataDb(int parentDocumentIndex, JsonWorkspace workspace, out byte[] rentedBacking) + { + var db = MetadataDb.CreateRented(DbRow.Size, false); + int workspaceDocumentIndex = workspace.GetDocumentIndex(this); + db.AppendExternal(_tokenType, 0, RawValue.Length, workspaceDocumentIndex); + return db.TakeOwnership(out rentedBacking); + } + + JsonElement IJsonDocument.CloneElement(int index) + { +#pragma warning disable CS0618 // Type or member is obsolete + return JsonElement.ParseValue(RawValue.Span); +#pragma warning restore CS0618 + } + + JsonDocumentBuilder IJsonDocument.CloneElementAsBuilder(int index, JsonWorkspace workspace) => JsonDocumentCloning.CloneElementAsBuilderBySerialization(this, index, workspace); + + bool IJsonDocument.TryGetContiguousLocalElement(int index, out ReadOnlyMemory utf8, out int sourceTextOffset) + { + utf8 = default; + sourceTextOffset = 0; + return false; + } + + int IJsonDocument.AppendLocalElementRowsRebased(int index, ref MetadataDb db, int locationDelta) + => throw new NotSupportedException("Valid only after TryGetContiguousLocalElement returned true."); + + TElement IJsonDocument.CloneElement(int index) + { +#pragma warning disable CS0618 // Type or member is obsolete + return JsonElementHelpers.ParseValue(RawValue.Span); +#pragma warning restore CS0618 + } + + void IJsonDocument.EnsurePropertyMap(int index) + { + } + + JsonElement IJsonDocument.GetArrayIndexElement(int currentIndex, int arrayIndex) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartArray, _tokenType); + return default; + } + + TElement IJsonDocument.GetArrayIndexElement(int currentIndex, int arrayIndex) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartArray, _tokenType); + return default; + } + + void IJsonDocument.GetArrayIndexElement(int currentIndex, int arrayIndex, out IJsonDocument parentDocument, out int parentDocumentIndex) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartArray, _tokenType); + parentDocument = default; + parentDocumentIndex = default; + } + + int IJsonDocument.GetArrayInsertionIndex(int currentIndex, int arrayIndex) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartArray, _tokenType); + return default; + } + + int IJsonDocument.GetArrayLength(int index) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartArray, _tokenType); + return default; + } + + int IJsonDocument.GetDbSize(int index, bool includeEndElement) + { + Debug.Assert(index == 0); + return DbRow.Size; + } + + bool IJsonDocument.TryFindNextDescendantPropertyValue(int elementIndex, ref int scanIndex, ReadOnlySpan utf8PropertyName, out int valueIndex) + { + valueIndex = -1; + return false; + } + + int IJsonDocument.GetHashCode(int index) + { + Debug.Assert(index == 0); + return _tokenType switch + { + JsonTokenType.True => true.GetHashCode(), + JsonTokenType.False => false.GetHashCode(), + _ => JsonDocument.s_nullHashCode, + }; + } + + JsonTokenType IJsonDocument.GetJsonTokenType(int index) + { + Debug.Assert(index == 0); + return _tokenType; + } + + string IJsonDocument.GetNameOfPropertyValue(int index) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + return default; + } + + int IJsonDocument.GetPropertyCount(int index) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + return default; + } + + JsonElement IJsonDocument.GetPropertyName(int index) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + return default; + } + + ReadOnlySpan IJsonDocument.GetPropertyNameRaw(int index) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + return default; + } + + ReadOnlyMemory IJsonDocument.GetPropertyNameRaw(int index, bool includeQuotes) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + return default; + } + + UnescapedUtf8JsonString IJsonDocument.GetPropertyNameUnescaped(int index) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + return default; + } + + string IJsonDocument.GetPropertyRawValueAsString(int valueIndex) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + return default; + } + + ReadOnlyMemory IJsonDocument.GetRawSimpleValue(int index, bool includeQuotes) + { + Debug.Assert(index == 0); + return RawValue; + } + + ReadOnlyMemory IJsonDocument.GetRawSimpleValue(int index) + { + Debug.Assert(index == 0); + return RawValue; + } + + ReadOnlyMemory IJsonDocument.GetRawSimpleValueUnsafe(int index) + { + Debug.Assert(index == 0); + return RawValue; + } + + RawUtf8JsonString IJsonDocument.GetRawValue(int index, bool includeQuotes) + { + Debug.Assert(index == 0); + return new(RawValue); + } + + string IJsonDocument.GetRawValueAsString(int index) + { + Debug.Assert(index == 0); + return JsonReaderHelper.TranscodeHelper(RawValue.Span); + } + + int IJsonDocument.GetStartIndex(int endIndex) + { + Debug.Assert(endIndex == 0); + return 0; + } + + string? IJsonDocument.GetString(int index, JsonTokenType expectedType) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.String, _tokenType); + return default; + } + + bool IJsonDocument.TryGetString(int index, JsonTokenType expectedType, [NotNullWhen(true)] out string? result) + { + result = default; + return false; + } + + UnescapedUtf8JsonString IJsonDocument.GetUtf8JsonString(int index, JsonTokenType expectedType) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.String, _tokenType); + return default; + } + + UnescapedUtf16JsonString IJsonDocument.GetUtf16JsonString(int index, JsonTokenType expectedType) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.String, _tokenType); + return default; + } + + bool IJsonDocument.TextEquals(int index, ReadOnlySpan otherText, bool isPropertyName) => false; + + bool IJsonDocument.TextEquals(int index, ReadOnlySpan otherUtf8Text, bool isPropertyName, bool shouldUnescape) => false; + + string IJsonDocument.ToString(int index) + { + Debug.Assert(index == 0); + return JsonReaderHelper.TranscodeHelper(RawValue.Span); + } + + bool IJsonDocument.TryGetNamedPropertyValue(int index, ReadOnlySpan propertyName, out JsonElement value) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + value = default; + return false; + } + + bool IJsonDocument.TryGetNamedPropertyValue(int index, ReadOnlySpan propertyName, out JsonElement value) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + value = default; + return false; + } + + bool IJsonDocument.TryGetNamedPropertyValue(int index, ReadOnlySpan propertyName, out TElement value) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + value = default; + return false; + } + + bool IJsonDocument.TryGetNamedPropertyValue(int index, ReadOnlySpan propertyName, out TElement value) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + value = default; + return false; + } + + bool IJsonDocument.TryGetNamedPropertyValue(int index, ReadOnlySpan propertyName, [NotNullWhen(true)] out IJsonDocument? elementParent, out int elementIndex) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + elementParent = default; + elementIndex = default; + return false; + } + + bool IJsonDocument.TryGetNamedPropertyValue(int index, ReadOnlySpan propertyName, [NotNullWhen(true)] out IJsonDocument? elementParent, out int elementIndex) + { + ThrowHelper.ThrowJsonElementWrongTypeException(JsonTokenType.StartObject, _tokenType); + elementParent = default; + elementIndex = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, [NotNullWhen(true)] out byte[]? value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out sbyte value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out byte value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out short value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out ushort value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out int value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out uint value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out long value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out ulong value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out double value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out float value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out decimal value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out BigInteger value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out BigNumber value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out DateTime value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out DateTimeOffset value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out OffsetDateTime value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out OffsetDate value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out OffsetTime value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out LocalDate value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out Period value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out Guid value) + { + value = default; + return false; + } + +#if NET + bool IJsonDocument.TryGetValue(int index, out DateOnly value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out TimeOnly value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out Int128 value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out UInt128 value) + { + value = default; + return false; + } + + bool IJsonDocument.TryGetValue(int index, out Half value) + { + value = default; + return false; + } +#endif + + bool IJsonDocument.ValueIsEscaped(int index, bool isPropertyName) + { + Debug.Assert(index == 0); + return false; + } + + void IJsonDocument.WriteElementTo(int index, Utf8JsonWriter writer) + { + Debug.Assert(index == 0); + + switch (_tokenType) + { + case JsonTokenType.True: + writer.WriteBooleanValue(value: true); + break; + + case JsonTokenType.False: + writer.WriteBooleanValue(value: false); + break; + + default: + writer.WriteNullValue(); + break; + } + } + + void IJsonDocument.WritePropertyName(int index, Utf8JsonWriter writer) => Debug.Fail("A valueless literal is never a property name."); + + bool IJsonDocument.TryResolveJsonPointer(ReadOnlySpan jsonPointer, int index, out TValue value) + { + if (jsonPointer.Length > 2 || + (jsonPointer.Length > 1 && jsonPointer[1] != (byte)'/') || + (jsonPointer.Length == 1 && jsonPointer[0] is not ((byte)'#' or (byte)'/'))) + { + value = default; + return false; + } + +#if NET + value = TValue.CreateInstance(this, 0); +#else + value = JsonElementHelpers.CreateInstance(this, 0); +#endif + + return true; + } + + bool IJsonDocument.TryGetLineAndOffset(int index, out int line, out int charOffset, out long lineByteOffset) + { + line = 0; + charOffset = 0; + lineByteOffset = 0; + return false; + } + + bool IJsonDocument.TryGetLineAndOffsetForPointer(ReadOnlySpan jsonPointer, int index, out int line, out int charOffset, out long lineByteOffset) + { + line = 0; + charOffset = 0; + lineByteOffset = 0; + return false; + } + + bool IJsonDocument.TryGetLine(int lineNumber, out ReadOnlyMemory line) + { + line = default; + return false; + } + + bool IJsonDocument.TryGetLine(int lineNumber, [NotNullWhen(true)] out string? line) + { + line = null; + return false; + } + + /// Tries to format the value into a character span. + /// The element index (always 0). + /// The destination span. + /// The number of characters written. + /// The format (ignored). + /// The format provider (ignored). + /// if formatting succeeded. + public bool TryFormat(int index, Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? formatProvider) + { + Debug.Assert(index == 0); + return JsonReaderHelper.TryTranscode(RawValue.Span, destination, out charsWritten); + } + + /// Tries to format the value into a UTF-8 byte span. + /// The element index (always 0). + /// The destination span. + /// The number of bytes written. + /// The format (ignored). + /// The format provider (ignored). + /// if formatting succeeded. + public bool TryFormat(int index, Span destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? formatProvider) + { + Debug.Assert(index == 0); + ReadOnlySpan content = RawValue.Span; + if (content.TryCopyTo(destination)) + { + bytesWritten = content.Length; + return true; + } + + bytesWritten = 0; + return false; + } + + /// Returns the JSON text of the value. + /// The element index (always 0). + /// The format (ignored). + /// The format provider (ignored). + /// The JSON text. + public string ToString(int index, string? format, IFormatProvider? formatProvider) + { + Debug.Assert(index == 0); + return ((IJsonDocument)this).ToString(index); + } +} \ No newline at end of file diff --git a/src/Corvus.Text.Json/Corvus/Text/Json/RefTuple.cs b/src/Corvus.Text.Json/Corvus/Text/Json/RefTuple.cs new file mode 100644 index 00000000000..75aedf24cfd --- /dev/null +++ b/src/Corvus.Text.Json/Corvus/Text/Json/RefTuple.cs @@ -0,0 +1,157 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +namespace Corvus.Text.Json; + +#if NET9_0_OR_GREATER + +/// +/// A two-element tuple that is itself a and whose elements may be ref +/// structs (allows ref struct) — the span-capable companion to , which +/// cannot carry a . +/// +/// The type of the first element. +/// The type of the second element. +/// +/// +/// Available on .NET 9.0 and later only: it relies on the allows ref struct generic constraint (C# 13), which is +/// what lets an element be a or other ref struct. On earlier targets use a bespoke +/// context instead. +/// +/// +/// Use it to thread several values (including spans) through a generated Build<TContext> / +/// CreateBuilder<TContext> / Ok<TContext> as the TContext, instead of declaring a bespoke +/// context per call site. recovers named locals at the use site: +/// var (page, access) = state;. +/// +/// +/// This is a minimal carrier: it deliberately has no equality, hashing, or ToString (those are meaningless when an +/// element is a span), and no (a, b) literal syntax (reserved by the compiler for ). +/// +/// +public readonly ref struct RefTuple + where T1 : allows ref struct + where T2 : allows ref struct +{ + /// Initializes a new instance of the struct. + /// The first element. + /// The second element. + public RefTuple(T1 item1, T2 item2) + { + this.Item1 = item1; + this.Item2 = item2; + } + + /// Gets the first element. + public T1 Item1 { get; } + + /// Gets the second element. + public T2 Item2 { get; } + + /// Deconstructs the tuple into named locals. + /// Receives the first element. + /// Receives the second element. + public void Deconstruct(out T1 item1, out T2 item2) + { + item1 = this.Item1; + item2 = this.Item2; + } +} + +/// +/// A three-element span-capable tuple (.NET 9.0+). See for the rationale. +/// +/// The type of the first element. +/// The type of the second element. +/// The type of the third element. +public readonly ref struct RefTuple + where T1 : allows ref struct + where T2 : allows ref struct + where T3 : allows ref struct +{ + /// Initializes a new instance of the struct. + /// The first element. + /// The second element. + /// The third element. + public RefTuple(T1 item1, T2 item2, T3 item3) + { + this.Item1 = item1; + this.Item2 = item2; + this.Item3 = item3; + } + + /// Gets the first element. + public T1 Item1 { get; } + + /// Gets the second element. + public T2 Item2 { get; } + + /// Gets the third element. + public T3 Item3 { get; } + + /// Deconstructs the tuple into named locals. + /// Receives the first element. + /// Receives the second element. + /// Receives the third element. + public void Deconstruct(out T1 item1, out T2 item2, out T3 item3) + { + item1 = this.Item1; + item2 = this.Item2; + item3 = this.Item3; + } +} + +/// +/// A four-element span-capable tuple (.NET 9.0+). See for the rationale. +/// +/// The type of the first element. +/// The type of the second element. +/// The type of the third element. +/// The type of the fourth element. +public readonly ref struct RefTuple + where T1 : allows ref struct + where T2 : allows ref struct + where T3 : allows ref struct + where T4 : allows ref struct +{ + /// Initializes a new instance of the struct. + /// The first element. + /// The second element. + /// The third element. + /// The fourth element. + public RefTuple(T1 item1, T2 item2, T3 item3, T4 item4) + { + this.Item1 = item1; + this.Item2 = item2; + this.Item3 = item3; + this.Item4 = item4; + } + + /// Gets the first element. + public T1 Item1 { get; } + + /// Gets the second element. + public T2 Item2 { get; } + + /// Gets the third element. + public T3 Item3 { get; } + + /// Gets the fourth element. + public T4 Item4 { get; } + + /// Deconstructs the tuple into named locals. + /// Receives the first element. + /// Receives the second element. + /// Receives the third element. + /// Receives the fourth element. + public void Deconstruct(out T1 item1, out T2 item2, out T3 item3, out T4 item4) + { + item1 = this.Item1; + item2 = this.Item2; + item3 = this.Item3; + item4 = this.Item4; + } +} + +#endif \ No newline at end of file diff --git a/tests-v4/Corvus.Json.Specs.Tests/packages.lock.json b/tests-v4/Corvus.Json.Specs.Tests/packages.lock.json index 5149635a967..a4f42d20588 100644 --- a/tests-v4/Corvus.Json.Specs.Tests/packages.lock.json +++ b/tests-v4/Corvus.Json.Specs.Tests/packages.lock.json @@ -45,6 +45,15 @@ "System.Text.Json": "10.0.7" } }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net481": "1.0.3" + } + }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.5.2, )", @@ -301,6 +310,11 @@ "System.Runtime.CompilerServices.Unsafe": "6.1.2" } }, + "Microsoft.NETFramework.ReferenceAssemblies.net481": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "Vv/20vgHS7VglVOVh8J3Iz/MA+VYKVRp9f7r2qiKBMuzviTOmocG70yq0Q8T5OTmCONkEAIJwETD1zhEfLkAXQ==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.2.2", @@ -481,6 +495,15 @@ "System.Text.Json": "[10.0.7, )" } }, + "corvus.json.codegeneration.openapi20": { + "type": "Project", + "dependencies": { + "Corvus.Json.CodeGeneration": "[1.0.0, )", + "Microsoft.Bcl.Memory": "[10.0.7, )", + "System.Collections.Immutable": "[10.0.7, )", + "System.Text.Json": "[10.0.7, )" + } + }, "corvus.json.codegeneration.openapi30": { "type": "Project", "dependencies": { @@ -707,54 +730,6 @@ } } }, - ".NETFramework,Version=v4.8.1/win-x86": { - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.5.2, )", - "resolved": "18.5.2", - "contentHash": "UNcGLx9pVtlXF8MPDR8KDp+/OKKNIJjpzwRyZSt609TSGvaD8mtuQMb5GKZvhMucPp0a5Juvn3kxXDceQZWmAg==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.3", - "Microsoft.Extensions.DependencyModel": "8.0.2", - "Microsoft.Testing.Platform": "2.1.0", - "System.Reflection.Metadata": "8.0.0" - } - }, - "System.Net.Http": { - "type": "Direct", - "requested": "[4.3.4, )", - "resolved": "4.3.4", - "contentHash": "aOa2d51SEbmM+H+Csw7yJOuNZoHkrP2XnAurye5HWYgGVVU54YZDvsLUYRv6h18X3sPnjNCANmN7ZhIPiqMcjA==", - "dependencies": { - "System.Security.Cryptography.X509Certificates": "4.3.0" - } - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "System.IO": "4.3.0", - "System.Runtime": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==" - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0" - } - } - }, "net10.0": { "Microsoft.CodeAnalysis.CSharp": { "type": "Direct", @@ -1105,6 +1080,12 @@ "Microsoft.CodeAnalysis.CSharp": "[5.3.0, )" } }, + "corvus.json.codegeneration.openapi20": { + "type": "Project", + "dependencies": { + "Corvus.Json.CodeGeneration": "[1.0.0, )" + } + }, "corvus.json.codegeneration.openapi30": { "type": "Project", "dependencies": { @@ -1203,19 +1184,6 @@ "resolved": "3.3.1", "contentHash": "7zkTEqmakybTTuDuifpnzl5s8MkmpAdyvoqIPIO2+M2ThF8ixavPcPt1afPfFyCI+A6t3ySujgpGq/5iWc4/RQ==" } - }, - "net10.0/win-x86": { - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.5.2, )", - "resolved": "18.5.2", - "contentHash": "UNcGLx9pVtlXF8MPDR8KDp+/OKKNIJjpzwRyZSt609TSGvaD8mtuQMb5GKZvhMucPp0a5Juvn3kxXDceQZWmAg==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.3", - "Microsoft.Extensions.DependencyModel": "8.0.2", - "Microsoft.Testing.Platform": "2.1.0" - } - } } } } \ No newline at end of file diff --git a/tests/Corvus.Text.Json.Analyzers.Tests/packages.lock.json b/tests/Corvus.Text.Json.Analyzers.Tests/packages.lock.json index 5b3c1c48635..c8f68249f6b 100644 --- a/tests/Corvus.Text.Json.Analyzers.Tests/packages.lock.json +++ b/tests/Corvus.Text.Json.Analyzers.Tests/packages.lock.json @@ -60,6 +60,15 @@ "System.Threading.Tasks.Extensions": "4.5.4" } }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net481": "1.0.3" + } + }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.5.2, )", @@ -220,6 +229,11 @@ "resolved": "2.2.3", "contentHash": "bhwzJfzyiJM0nXJyNB7Y9OfsEXyxLdDBHG99soIp5JjnPydwkOaBdRCtRtWgQh3noSLi2cSIZ/wpbHNNE9knxQ==" }, + "Microsoft.NETFramework.ReferenceAssemblies.net481": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "Vv/20vgHS7VglVOVh8J3Iz/MA+VYKVRp9f7r2qiKBMuzviTOmocG70yq0Q8T5OTmCONkEAIJwETD1zhEfLkAXQ==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.2.2", @@ -587,20 +601,6 @@ } } }, - ".NETFramework,Version=v4.8.1/win-x86": { - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.5.2, )", - "resolved": "18.5.2", - "contentHash": "UNcGLx9pVtlXF8MPDR8KDp+/OKKNIJjpzwRyZSt609TSGvaD8mtuQMb5GKZvhMucPp0a5Juvn3kxXDceQZWmAg==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.3", - "Microsoft.Extensions.DependencyModel": "8.0.2", - "Microsoft.Testing.Platform": "2.1.0", - "System.Reflection.Metadata": "8.0.0" - } - } - }, "net10.0": { "Microsoft.CodeAnalysis.CSharp.Analyzer.Testing": { "type": "Direct", @@ -993,29 +993,6 @@ "resolved": "13.0.1", "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" } - }, - "net10.0/win-x86": { - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.5.2, )", - "resolved": "18.5.2", - "contentHash": "UNcGLx9pVtlXF8MPDR8KDp+/OKKNIJjpzwRyZSt609TSGvaD8mtuQMb5GKZvhMucPp0a5Juvn3kxXDceQZWmAg==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.3", - "Microsoft.Extensions.DependencyModel": "8.0.2", - "Microsoft.Testing.Platform": "2.1.0" - } - }, - "System.Security.Cryptography.Pkcs": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "9TPLGjBCGKmNvG8pjwPeuYy0SMVmGZRwlTZvyPHDbYv/DRkoeumJdfumaaDNQzVGMEmbWtg07zUpSW9q70IlDQ==" - }, - "System.Security.Cryptography.ProtectedData": { - "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "cJV7ScGW7EhatRsjehfvvYVBvtiSMKgN8bOVI0bQhnF5bU7vnHVIsH49Kva7i7GWaWYvmEzkYVk1TC+gZYBEog==" - } } } } \ No newline at end of file diff --git a/tests/Corvus.Text.Json.JMESPath.CodeGeneration.Tests/CodeGenConformanceFixture.cs b/tests/Corvus.Text.Json.JMESPath.CodeGeneration.Tests/CodeGenConformanceFixture.cs index 9ac1c7d0903..59d9f3b3b92 100644 --- a/tests/Corvus.Text.Json.JMESPath.CodeGeneration.Tests/CodeGenConformanceFixture.cs +++ b/tests/Corvus.Text.Json.JMESPath.CodeGeneration.Tests/CodeGenConformanceFixture.cs @@ -36,7 +36,11 @@ public sealed class CodeGenConformanceFixture : IDisposable private static readonly Lazy<(IEnumerable References, CSharpParseOptions ParseOptions)> CompilationContext = new(BuildCompilationContext); - private static readonly TimeSpan CompilationTimeout = TimeSpan.FromSeconds(30); + // Generous by design. This budget exists to fail a HUNG pipeline rather than to assert compile speed, and a + // wall-clock assertion is load-dependent by construction: at 30s these tests failed nondeterministically during a + // full-solution run, where many projects compile in parallel and Roslyn is saturating every core. A hang is still + // caught just as surely at five minutes, and a loaded machine no longer reports one that is not there. + private static readonly TimeSpan CompilationTimeout = TimeSpan.FromMinutes(5); /// public void Dispose() diff --git a/tests/Corvus.Text.Json.JsonLogic.CodeGeneration.Tests/CodeGenConformanceFixture.cs b/tests/Corvus.Text.Json.JsonLogic.CodeGeneration.Tests/CodeGenConformanceFixture.cs index 1a212aa6bb8..4f84ace9172 100644 --- a/tests/Corvus.Text.Json.JsonLogic.CodeGeneration.Tests/CodeGenConformanceFixture.cs +++ b/tests/Corvus.Text.Json.JsonLogic.CodeGeneration.Tests/CodeGenConformanceFixture.cs @@ -35,7 +35,11 @@ public sealed class CodeGenConformanceFixture : IDisposable private static readonly Lazy<(IEnumerable References, CSharpParseOptions ParseOptions)> CompilationContext = new(BuildCompilationContext); - private static readonly TimeSpan CompilationTimeout = TimeSpan.FromSeconds(30); + // Generous by design. This budget exists to fail a HUNG pipeline rather than to assert compile speed, and a + // wall-clock assertion is load-dependent by construction: at 30s these tests failed nondeterministically during a + // full-solution run, where many projects compile in parallel and Roslyn is saturating every core. A hang is still + // caught just as surely at five minutes, and a loaded machine no longer reports one that is not there. + private static readonly TimeSpan CompilationTimeout = TimeSpan.FromMinutes(5); /// public void Dispose() diff --git a/tests/Corvus.Text.Json.JsonPath.CodeGeneration.Tests/CodeGenConformanceFixture.cs b/tests/Corvus.Text.Json.JsonPath.CodeGeneration.Tests/CodeGenConformanceFixture.cs index e33fb6ab36e..14d6fdd2797 100644 --- a/tests/Corvus.Text.Json.JsonPath.CodeGeneration.Tests/CodeGenConformanceFixture.cs +++ b/tests/Corvus.Text.Json.JsonPath.CodeGeneration.Tests/CodeGenConformanceFixture.cs @@ -36,7 +36,11 @@ public sealed class CodeGenConformanceFixture : IDisposable private static readonly Lazy<(IEnumerable References, CSharpParseOptions ParseOptions)> CompilationContext = new(BuildCompilationContext); - private static readonly TimeSpan CompilationTimeout = TimeSpan.FromSeconds(30); + // Generous by design. This budget exists to fail a HUNG pipeline rather than to assert compile speed, and a + // wall-clock assertion is load-dependent by construction: at 30s these tests failed nondeterministically during a + // full-solution run, where many projects compile in parallel and Roslyn is saturating every core. A hang is still + // caught just as surely at five minutes, and a loaded machine no longer reports one that is not there. + private static readonly TimeSpan CompilationTimeout = TimeSpan.FromMinutes(5); /// public void Dispose() diff --git a/tests/Corvus.Text.Json.JsonPath.Tests/JsonPathUtf8QueryTests.cs b/tests/Corvus.Text.Json.JsonPath.Tests/JsonPathUtf8QueryTests.cs new file mode 100644 index 00000000000..db4b1fee409 --- /dev/null +++ b/tests/Corvus.Text.Json.JsonPath.Tests/JsonPathUtf8QueryTests.cs @@ -0,0 +1,70 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Corvus.Text.Json.JsonPath.Tests; + +/// +/// Tests for the UTF-8 +/// overloads, which evaluate a query without materializing a managed string. +/// +[TestClass] +public class JsonPathUtf8QueryTests +{ + [TestMethod] + public void Utf8QueryMatchesStringQuery() + { + JsonElement data = JsonElement.ParseValue("{\"items\":[1,2,3],\"name\":\"x\"}"); + + using JsonPathResult viaString = JsonPathEvaluator.Default.QueryNodes("$.items[*]", data); + using JsonPathResult viaUtf8 = JsonPathEvaluator.Default.QueryNodes("$.items[*]"u8, data); + + Assert.AreEqual(viaString.Count, viaUtf8.Count); + Assert.AreEqual(3, viaUtf8.Count); + } + + [TestMethod] + public void Utf8QueryNoMatchIsEmpty() + { + JsonElement data = JsonElement.ParseValue("{\"items\":[]}"); + + using JsonPathResult result = JsonPathEvaluator.Default.QueryNodes("$.items[*]"u8, data); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void Utf8QueryWithCallerBuffer() + { + JsonElement data = JsonElement.ParseValue("{\"items\":[1,2]}"); + Span buffer = new JsonElement[8]; + + using JsonPathResult result = JsonPathEvaluator.Default.QueryNodes("$.items[*]"u8, data, buffer); + + Assert.AreEqual(2, result.Count); + } + + [TestMethod] + public void Utf8QueryFilterWithValue() + { + JsonElement data = JsonElement.ParseValue("[{\"status\":\"ok\"},{\"status\":\"bad\"}]"); + + using JsonPathResult result = JsonPathEvaluator.Default.QueryNodes("$[?@.status == \"ok\"]"u8, data); + + Assert.AreEqual(1, result.Count); + } + + [TestMethod] + public void Utf8QueryIsCachedAcrossCalls() + { + JsonElement data = JsonElement.ParseValue("{\"items\":[1,2,3]}"); + + for (int i = 0; i < 3; i++) + { + using JsonPathResult result = JsonPathEvaluator.Default.QueryNodes("$.items[*]"u8, data); + Assert.AreEqual(3, result.Count); + } + } +} \ No newline at end of file diff --git a/tests/Corvus.Text.Json.Jsonata.CodeGeneration.Tests/CodeGenConformanceFixture.cs b/tests/Corvus.Text.Json.Jsonata.CodeGeneration.Tests/CodeGenConformanceFixture.cs index 73cee58cd57..bf448e8b97d 100644 --- a/tests/Corvus.Text.Json.Jsonata.CodeGeneration.Tests/CodeGenConformanceFixture.cs +++ b/tests/Corvus.Text.Json.Jsonata.CodeGeneration.Tests/CodeGenConformanceFixture.cs @@ -64,7 +64,11 @@ public void Dispose() /// /// Gets the timeout for the entire compile pipeline (code gen + Roslyn emit). /// - private static readonly TimeSpan CompilationTimeout = TimeSpan.FromSeconds(30); + // Generous by design. This budget exists to fail a HUNG pipeline rather than to assert compile speed, and a + // wall-clock assertion is load-dependent by construction: at 30s these tests failed nondeterministically during a + // full-solution run, where many projects compile in parallel and Roslyn is saturating every core. A hang is still + // caught just as surely at five minutes, and a loaded machine no longer reports one that is not there. + private static readonly TimeSpan CompilationTimeout = TimeSpan.FromMinutes(5); public CompiledExpression GetOrCompile(string expression) { diff --git a/tests/Corvus.Text.Json.Migration.Analyzers.Tests/packages.lock.json b/tests/Corvus.Text.Json.Migration.Analyzers.Tests/packages.lock.json index 6b1ea94370b..619b0cce702 100644 --- a/tests/Corvus.Text.Json.Migration.Analyzers.Tests/packages.lock.json +++ b/tests/Corvus.Text.Json.Migration.Analyzers.Tests/packages.lock.json @@ -49,6 +49,15 @@ "System.Threading.Tasks.Extensions": "4.5.4" } }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net481": "1.0.3" + } + }, "Microsoft.Testing.Extensions.CodeCoverage": { "type": "Direct", "requested": "[18.5.2, )", @@ -183,6 +192,11 @@ "resolved": "2.2.3", "contentHash": "bhwzJfzyiJM0nXJyNB7Y9OfsEXyxLdDBHG99soIp5JjnPydwkOaBdRCtRtWgQh3noSLi2cSIZ/wpbHNNE9knxQ==" }, + "Microsoft.NETFramework.ReferenceAssemblies.net481": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "Vv/20vgHS7VglVOVh8J3Iz/MA+VYKVRp9f7r2qiKBMuzviTOmocG70yq0Q8T5OTmCONkEAIJwETD1zhEfLkAXQ==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.2.2", @@ -565,20 +579,6 @@ } } }, - ".NETFramework,Version=v4.8.1/win-x86": { - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.5.2, )", - "resolved": "18.5.2", - "contentHash": "UNcGLx9pVtlXF8MPDR8KDp+/OKKNIJjpzwRyZSt609TSGvaD8mtuQMb5GKZvhMucPp0a5Juvn3kxXDceQZWmAg==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.3", - "Microsoft.Extensions.DependencyModel": "8.0.2", - "Microsoft.Testing.Platform": "2.1.0", - "System.Reflection.Metadata": "8.0.0" - } - } - }, "net10.0": { "Microsoft.CodeAnalysis.CSharp.Analyzer.Testing": { "type": "Direct", @@ -946,29 +946,6 @@ "resolved": "13.0.1", "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" } - }, - "net10.0/win-x86": { - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.5.2, )", - "resolved": "18.5.2", - "contentHash": "UNcGLx9pVtlXF8MPDR8KDp+/OKKNIJjpzwRyZSt609TSGvaD8mtuQMb5GKZvhMucPp0a5Juvn3kxXDceQZWmAg==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.3", - "Microsoft.Extensions.DependencyModel": "8.0.2", - "Microsoft.Testing.Platform": "2.1.0" - } - }, - "System.Security.Cryptography.Pkcs": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "9TPLGjBCGKmNvG8pjwPeuYy0SMVmGZRwlTZvyPHDbYv/DRkoeumJdfumaaDNQzVGMEmbWtg07zUpSW9q70IlDQ==" - }, - "System.Security.Cryptography.ProtectedData": { - "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "cJV7ScGW7EhatRsjehfvvYVBvtiSMKgN8bOVI0bQhnF5bU7vnHVIsH49Kva7i7GWaWYvmEzkYVk1TC+gZYBEog==" - } } } } \ No newline at end of file diff --git a/tests/Corvus.Text.Json.Tests/CloneAsBuilderTests.cs b/tests/Corvus.Text.Json.Tests/CloneAsBuilderTests.cs new file mode 100644 index 00000000000..978202535ee --- /dev/null +++ b/tests/Corvus.Text.Json.Tests/CloneAsBuilderTests.cs @@ -0,0 +1,56 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.Text; +using Corvus.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Corvus.Text.Json.Tests; + +[TestClass] +public class CloneAsBuilderTests +{ + [TestMethod] + public void Clone_survives_disposal_of_the_source_document() + { + using JsonWorkspace workspace = JsonWorkspace.Create(); + + JsonElement clone; + using (ParsedJsonDocument source = + ParsedJsonDocument.Parse(Encoding.UTF8.GetBytes("""{"name":"Fido","age":3,"tags":["a","b"]}"""))) + { + clone = source.RootElement.CloneAsBuilder(workspace).RootElement; + + // Valid while the source is alive. + Assert.IsTrue(clone.TryGetProperty("name", out JsonElement liveName)); + Assert.AreEqual("Fido", liveName.GetString()); + } + + // The source document is now disposed; the clone is a standalone, workspace-owned copy and + // must remain fully readable (no ObjectDisposedException). + Assert.IsTrue(clone.TryGetProperty("name", out JsonElement name)); + Assert.AreEqual("Fido", name.GetString()); + Assert.IsTrue(clone.TryGetProperty("age", out JsonElement age)); + Assert.AreEqual(3, age.GetInt32()); + Assert.IsTrue(clone.TryGetProperty("tags", out JsonElement tags)); + Assert.AreEqual(2, tags.GetArrayLength()); + } + + [TestMethod] + public void Clone_of_a_nested_element_is_independent_of_the_source() + { + using JsonWorkspace workspace = JsonWorkspace.Create(); + + JsonElement clone; + using (ParsedJsonDocument source = + ParsedJsonDocument.Parse(Encoding.UTF8.GetBytes("""{"outer":{"inner":"value"}}"""))) + { + Assert.IsTrue(source.RootElement.TryGetProperty("outer", out JsonElement outer)); + clone = outer.CloneAsBuilder(workspace).RootElement; + } + + Assert.IsTrue(clone.TryGetProperty("inner", out JsonElement inner)); + Assert.AreEqual("value", inner.GetString()); + } +} \ No newline at end of file diff --git a/tests/Corvus.Text.Json.Tests/Corvus.Text.Json.Tests.csproj b/tests/Corvus.Text.Json.Tests/Corvus.Text.Json.Tests.csproj index a56324d8984..9ed25540e0b 100644 --- a/tests/Corvus.Text.Json.Tests/Corvus.Text.Json.Tests.csproj +++ b/tests/Corvus.Text.Json.Tests/Corvus.Text.Json.Tests.csproj @@ -295,6 +295,7 @@ + @@ -309,6 +310,8 @@ + + diff --git a/tests/Corvus.Text.Json.Tests/DummyDocument.cs b/tests/Corvus.Text.Json.Tests/DummyDocument.cs index a18fd3abbb0..0a972063778 100644 --- a/tests/Corvus.Text.Json.Tests/DummyDocument.cs +++ b/tests/Corvus.Text.Json.Tests/DummyDocument.cs @@ -224,6 +224,9 @@ public bool TryGetNamedPropertyValue(int index, ReadOnlySpan pro } public JsonElement GetPropertyName(int index) => default; + JsonDocumentBuilder IJsonDocument.CloneElementAsBuilder(int index, JsonWorkspace workspace) => throw new NotImplementedException(); + bool IJsonDocument.TryGetContiguousLocalElement(int index, out ReadOnlyMemory utf8, out int sourceTextOffset) => throw new NotImplementedException(); + int IJsonDocument.AppendLocalElementRowsRebased(int index, ref MetadataDb db, int locationDelta) => throw new NotImplementedException(); void IJsonDocument.EnsurePropertyMap(int index) => throw new NotImplementedException(); int IJsonDocument.GetHashCode(int index) => throw new NotImplementedException(); string IJsonDocument.ToString(int index) => throw new NotImplementedException(); diff --git a/tests/Corvus.Text.Json.Tests/ParsedJsonDocumentBuilderTests.cs b/tests/Corvus.Text.Json.Tests/ParsedJsonDocumentBuilderTests.cs index 2c458e60d3b..9de29a67f9e 100644 --- a/tests/Corvus.Text.Json.Tests/ParsedJsonDocumentBuilderTests.cs +++ b/tests/Corvus.Text.Json.Tests/ParsedJsonDocumentBuilderTests.cs @@ -476,6 +476,80 @@ public void Build_EmbeddingElementWithEscapedPropertyName_PreservesEscaping() Assert.IsTrue(value.ValueEquals("value"u8)); } + [TestMethod] + public void Build_EmbeddingParsedScalarsAndEmptyContainers_BlitsContentIntoResult() + { + // Every simple token kind plus the degenerate containers, embedded from positions deep + // in the source text so the copied rows need a real location rebase. + using var external = ParsedJsonDocument.Parse("""{"pad":"xxxxxxxxxx","n":-12.5e3,"t":true,"f":false,"z":null,"eo":{},"ea":[]}"""); + JsonElement root = external.RootElement; + JsonElement number = root.GetProperty("n"u8); + JsonElement trueValue = root.GetProperty("t"u8); + JsonElement falseValue = root.GetProperty("f"u8); + JsonElement nullValue = root.GetProperty("z"u8); + JsonElement emptyObject = root.GetProperty("eo"u8); + JsonElement emptyArray = root.GetProperty("ea"u8); + + using ParsedJsonDocument doc = BuildDocument((ref cvb) => + { + cvb.StartArray(); + cvb.AddItem(in number); + cvb.AddItem(in trueValue); + cvb.AddItem(in falseValue); + cvb.AddItem(in nullValue); + cvb.AddItem(in emptyObject); + cvb.AddItem(in emptyArray); + cvb.EndArray(); + }); + + Assert.AreEqual("""[-12.5e3,true,false,null,{},[]]""", doc.RootElement.ToString()); + Assert.AreEqual(-12500d, doc.RootElement[0].GetDouble()); + Assert.AreEqual(JsonValueKind.True, doc.RootElement[1].ValueKind); + Assert.AreEqual(JsonValueKind.False, doc.RootElement[2].ValueKind); + Assert.AreEqual(JsonValueKind.Null, doc.RootElement[3].ValueKind); + Assert.AreEqual(0, doc.RootElement[4].GetPropertyCount()); + Assert.AreEqual(0, doc.RootElement[5].GetArrayLength()); + + // The result must re-parse to an identical document. + string json = doc.RootElement.ToString(); + using ParsedJsonDocument reparsed = ParsedJsonDocument.Parse(json); + Assert.AreEqual(json, reparsed.RootElement.ToString()); + } + + [TestMethod] + public void Build_EmbeddedComplexElement_SupportsNavigationOnResult() + { + // Navigation over the embedded rows (property lookup, indexing, enumeration) exercises + // the copied NumberOfRows/size metadata rather than just the raw text. + using var external = ParsedJsonDocument.Parse("""{"skip":[0],"v":{"list":[{"id":1},{"id":2},{"id":3}],"name":"deep\tname","count":3}}"""); + JsonElement embedded = external.RootElement.GetProperty("v"u8); + + using ParsedJsonDocument doc = BuildDocument((ref cvb) => + { + cvb.StartObject(); + cvb.AddProperty("before"u8, 0); + cvb.AddProperty("payload"u8, in embedded); + cvb.AddProperty("after"u8, "end"u8); + cvb.EndObject(); + }); + + JsonElement payload = doc.RootElement.GetProperty("payload"u8); + Assert.AreEqual(3, payload.GetPropertyCount()); + Assert.AreEqual(3, payload.GetProperty("count"u8).GetInt32()); + Assert.IsTrue(payload.GetProperty("name"u8).ValueEquals("deep\tname"u8)); + + JsonElement list = payload.GetProperty("list"u8); + Assert.AreEqual(3, list.GetArrayLength()); + int expectedId = 1; + foreach (JsonElement item in list.EnumerateArray()) + { + Assert.AreEqual(expectedId++, item.GetProperty("id"u8).GetInt32()); + } + + // Siblings after the embed must still resolve (their rows follow the copied run). + Assert.IsTrue(doc.RootElement.GetProperty("after"u8).ValueEquals("end"u8)); + } + [TestMethod] public void Build_AddItemFromJson_ParsesAndEmbedsContent() { diff --git a/tests/Corvus.Text.Json.Tests/RefTupleTests.cs b/tests/Corvus.Text.Json.Tests/RefTupleTests.cs new file mode 100644 index 00000000000..159943231bd --- /dev/null +++ b/tests/Corvus.Text.Json.Tests/RefTupleTests.cs @@ -0,0 +1,80 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +#if NET9_0_OR_GREATER + +using Corvus.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Corvus.Text.Json.Tests; + +/// +/// Tests for and its higher arities — the span-capable companion to +/// used to thread context through generated Build<TContext> / +/// Ok<TContext> calls without a bespoke context struct. The type is .NET 9.0+ only (it relies on the +/// allows ref struct constraint), so the whole fixture is compiled only there. +/// +[TestClass] +public class RefTupleTests +{ + [TestMethod] + public void TwoElements_ItemAccessAndDeconstruct() + { + var tuple = new RefTuple(7, "x"); + + Assert.AreEqual(7, tuple.Item1); + Assert.AreEqual("x", tuple.Item2); + + var (first, second) = tuple; + Assert.AreEqual(7, first); + Assert.AreEqual("x", second); + } + + [TestMethod] + public void ThreeElements_ItemAccessAndDeconstruct() + { + var tuple = new RefTuple(1, 2, 3); + + Assert.AreEqual(1, tuple.Item1); + Assert.AreEqual(2, tuple.Item2); + Assert.AreEqual(3, tuple.Item3); + + var (a, b, c) = tuple; + Assert.AreEqual(6, a + b + c); + } + + [TestMethod] + public void FourElements_ItemAccessAndDeconstruct() + { + var tuple = new RefTuple(1, 2, 3, 4); + + Assert.AreEqual(1, tuple.Item1); + Assert.AreEqual(2, tuple.Item2); + Assert.AreEqual(3, tuple.Item3); + Assert.AreEqual(4, tuple.Item4); + + var (a, b, c, d) = tuple; + Assert.AreEqual(10, a + b + c + d); + } + + [TestMethod] + public void CarriesSpanElements_WhichValueTupleCannot() + { + ReadOnlySpan value = "hello"u8; + ReadOnlySpan label = "world"u8; + + var tuple = new RefTuple, ReadOnlySpan, int>(value, label, 3); + + Assert.IsTrue(tuple.Item1.SequenceEqual("hello"u8)); + Assert.IsTrue(tuple.Item2.SequenceEqual("world"u8)); + Assert.AreEqual(3, tuple.Item3); + + var (v, l, n) = tuple; + Assert.IsTrue(v.SequenceEqual("hello"u8)); + Assert.IsTrue(l.SequenceEqual("world"u8)); + Assert.AreEqual(3, n); + } +} + +#endif \ No newline at end of file diff --git a/tests/Corvus.Text.Json.Tests/ValuelessJsonDocumentTests.cs b/tests/Corvus.Text.Json.Tests/ValuelessJsonDocumentTests.cs new file mode 100644 index 00000000000..1fa82302c61 --- /dev/null +++ b/tests/Corvus.Text.Json.Tests/ValuelessJsonDocumentTests.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using Corvus.Text.Json; +using Corvus.Text.Json.Internal; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Corvus.Text.Json.Tests; + +[TestClass] +public class ValuelessJsonDocumentTests +{ + [TestMethod] + public void BooleanTrue_singleton_is_a_true_boolean() + { + JsonElement element = ValuelessJsonDocument.BooleanTrue.RootElement; + + Assert.AreEqual(JsonValueKind.True, element.ValueKind); + Assert.IsTrue(element.GetBoolean()); + Assert.AreEqual("true", element.ToString()); + } + + [TestMethod] + public void BooleanFalse_singleton_is_a_false_boolean() + { + JsonElement element = ValuelessJsonDocument.BooleanFalse.RootElement; + + Assert.AreEqual(JsonValueKind.False, element.ValueKind); + Assert.IsFalse(element.GetBoolean()); + Assert.AreEqual("false", element.ToString()); + } + + [TestMethod] + public void Null_singleton_is_a_null() + { + JsonElement element = ValuelessJsonDocument.Null.RootElement; + + Assert.AreEqual(JsonValueKind.Null, element.ValueKind); + } + + [TestMethod] + public void Singletons_are_shared_instances() + { + Assert.AreSame(ValuelessJsonDocument.BooleanTrue, ValuelessJsonDocument.BooleanTrue); + Assert.AreSame(ValuelessJsonDocument.Null, ValuelessJsonDocument.Null); + } +} \ No newline at end of file diff --git a/tests/Corvus.Text.Json.Validator.Tests/Corvus/Text/Json/Validator/Tests/CompileToAssemblyBytesTests.cs b/tests/Corvus.Text.Json.Validator.Tests/Corvus/Text/Json/Validator/Tests/CompileToAssemblyBytesTests.cs new file mode 100644 index 00000000000..820a2a8dfc3 --- /dev/null +++ b/tests/Corvus.Text.Json.Validator.Tests/Corvus/Text/Json/Validator/Tests/CompileToAssemblyBytesTests.cs @@ -0,0 +1,56 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.Reflection; +using Corvus.Json.CodeGeneration; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Corvus.Text.Json.Validator.Tests; + +/// +/// Tests — compiling a set of source files into a single +/// assembly and returning its raw PE bytes (so the assembly can be stored, not just loaded). +/// +[TestClass] +public class CompileToAssemblyBytesTests +{ + [TestMethod] + public void Compiles_sources_to_loadable_assembly_bytes() + { + GeneratedCodeFile[] files = + [ + new("Foo.cs", "namespace Demo { public static class Foo { public static int Answer() => 42; } }"), + ]; + + byte[] bytes = DynamicCompiler.CompileToAssemblyBytes(files, typeof(CompileToAssemblyBytesTests).Assembly); + + Assert.IsTrue(bytes.Length > 0); + Assert.AreEqual((byte)'M', bytes[0]); // PE/DLL header "MZ" + Assert.AreEqual((byte)'Z', bytes[1]); + + Assembly assembly = Assembly.Load(bytes); + Type fooType = assembly.GetType("Demo.Foo"); + object result = fooType.GetMethod("Answer").Invoke(null, null); + Assert.AreEqual(42, result); + } + + [TestMethod] + public void Throws_on_invalid_source() + { + GeneratedCodeFile[] files = [new("Bad.cs", "namespace Demo { public class Bad { this is not c# } }")]; + + InvalidOperationException caught = null; + try + { + DynamicCompiler.CompileToAssemblyBytes(files, typeof(CompileToAssemblyBytesTests).Assembly); + } + catch (InvalidOperationException ex) + { + caught = ex; + } + + Assert.IsNotNull(caught); + StringAssert.Contains(caught.Message, "Unable to compile generated code"); + } +} diff --git a/tests/Directory.Build.targets b/tests/Directory.Build.targets index a3fdbc8ca18..2c604a78937 100644 --- a/tests/Directory.Build.targets +++ b/tests/Directory.Build.targets @@ -40,6 +40,30 @@ + + + + + + + + + + + + + + + + + + + + + From 187fec311e6f564aa199e8fd2c6273868fea3786 Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Wed, 5 Aug 2026 06:53:08 +0100 Subject: [PATCH 04/11] Generate OpenAPI clients and servers that describe themselves (#803) Four changes to what the OpenAPI generators emit. A generated client can now take a context-threaded request body. The server result factory has long offered Ok(Source, workspace), so a caller assembles a body lazily with its context threaded through and it materialises in one pass with no per-item closure. A client had no counterpart, so anyone with a collection to put in a REQUEST body had to close over it. The machinery was already there: the generators take the set of body pointers whose type is an object or array, and emit the generic overload only for those. The server command computed that set and the client command never did, so the client path silently opted out under what its own doc comment called the conservative default. OpenAPI 2.0 was worse and is worth naming: the parameter did not exist there at all, so a 2.0 SERVER was also missing the closure-free response factories every 3.x server has had. A binary response now carries its body through the result factory, which is a breaking change. The old parameterless Ok() could not express a body at all; the shipped example recipe said so in a comment and returned Ok() anyway. It now takes the bytes or a writer, and the content type the handler chooses. An optional request body is optional. A body not marked required generated a mandatory parameter, so a caller had to supply something for a body the specification says may be absent. Descriptions in the source document become XML doc comments on the generated members, escaped so a description containing markup does not break the build. The schema classifier takes the document root alongside the schema so it can follow a reference rather than classifying the reference itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq --- .../Corvus.Json.Cli.Core.csproj | 3 + .../OpenApiGenerateCommand.cs | 53 +- .../OpenApiServerCommand.cs | 53 +- src/Corvus.Json.Cli/Corvus.Json.Cli.csproj | 2 +- .../CodeEmitHelpers.cs | 47 +- .../ExternalReferenceResolver.cs | 48 +- .../GeneratedClientTypeNaming.cs | 36 + .../OperationDescriptor.cs | 112 +++ .../OperationSummary.cs | 14 +- .../SchemaClassifier.cs | 320 ++++++-- ...vus.Text.Json.OpenApi.HttpTransport.csproj | 1 + .../HttpClientApiTransportFactory.cs | 36 + .../HttpClientTransport.cs | 93 ++- .../Corvus.Text.Json.OpenApi.csproj | 1 + .../IApiTransportFactory.cs | 18 + .../InstrumentedApiTransport.cs | 198 +++++ .../OpenApiTelemetry.cs | 72 ++ .../OpenApi20CodeGenerator.cs | 191 ++++- .../OpenApi30CodeGenerator.cs | 620 ++++++++++++++-- .../OpenApi31CodeGenerator.cs | 608 ++++++++++++++-- .../OpenApi32CodeGenerator.cs | 687 +++++++++++++++++- .../ExternalReferenceResolverTests.cs | 51 ++ .../OpenApi30CodeGeneratorTests.cs | 240 ++++++ .../OpenApi31CodeGeneratorTests.cs | 300 ++++++++ .../OpenApi32CodeGeneratorTests.cs | 292 ++++++++ .../TestData/covspec-3.0.json | 26 + .../TestData/covspec-3.1.json | 26 + .../TestData/covspec-3.2.json | 45 +- .../HttpClientApiTransportFactoryTests.cs | 46 ++ .../HttpClientTransportTests.cs | 100 +++ .../GeneratedClientEndToEndTests.cs | 35 + .../TestData/canonicalization-spec-3.0.json | 39 + .../ConfigureEndpointHookTests.cs | 2 +- .../GeneratedServerEndToEndTests.cs | 31 +- .../MockHandlers.cs | 7 +- .../GeneratedClientEndToEndTests.cs | 35 + .../TestData/canonicalization-spec.json | 39 + .../ConfigureEndpointHookTests.cs | 2 +- .../GeneratedServerEndToEndTests.cs | 36 +- .../MockHandlers.cs | 9 +- .../GeneratedClientEndToEndTests.cs | 35 + .../TestData/canonicalization-spec-3.2.json | 39 + .../ConfigureEndpointHookTests.cs | 2 +- .../GeneratedServerEndToEndTests.cs | 33 + .../GenericResultFactoryTests.cs | 94 +++ .../MockHandlers.cs | 11 +- 46 files changed, 4479 insertions(+), 309 deletions(-) create mode 100644 src/Corvus.Text.Json.OpenApi.CodeGeneration/GeneratedClientTypeNaming.cs create mode 100644 src/Corvus.Text.Json.OpenApi.CodeGeneration/OperationDescriptor.cs create mode 100644 src/Corvus.Text.Json.OpenApi.HttpTransport/HttpClientApiTransportFactory.cs create mode 100644 src/Corvus.Text.Json.OpenApi/IApiTransportFactory.cs create mode 100644 src/Corvus.Text.Json.OpenApi/InstrumentedApiTransport.cs create mode 100644 src/Corvus.Text.Json.OpenApi/OpenApiTelemetry.cs create mode 100644 tests/Corvus.Text.Json.OpenApi.HttpTransport.Tests/HttpClientApiTransportFactoryTests.cs create mode 100644 tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/GenericResultFactoryTests.cs diff --git a/src/Corvus.Json.Cli.Core/Corvus.Json.Cli.Core.csproj b/src/Corvus.Json.Cli.Core/Corvus.Json.Cli.Core.csproj index bc804ad524e..4504d4df5cc 100644 --- a/src/Corvus.Json.Cli.Core/Corvus.Json.Cli.Core.csproj +++ b/src/Corvus.Json.Cli.Core/Corvus.Json.Cli.Core.csproj @@ -34,6 +34,9 @@ + + + diff --git a/src/Corvus.Json.Cli.Core/OpenApiGenerateCommand.cs b/src/Corvus.Json.Cli.Core/OpenApiGenerateCommand.cs index 652841a9804..aae474ffcae 100644 --- a/src/Corvus.Json.Cli.Core/OpenApiGenerateCommand.cs +++ b/src/Corvus.Json.Cli.Core/OpenApiGenerateCommand.cs @@ -131,9 +131,10 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) AnsiConsole.MarkupLine($"[green]Schemas:[/] {schemaRefs.Length}"); Dictionary? schemaTypeMap = null; + IReadOnlySet? contextBodies = null; if (schemaRefs.Length > 0) { - (schemaTypeMap, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, useYaml, cancellationToken, syntheticDocuments) + (schemaTypeMap, contextBodies, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, useYaml, cancellationToken, syntheticDocuments) .ConfigureAwait(false); } @@ -152,7 +153,8 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) rootNamespace, schemaTypeMap ?? new Dictionary(), settings.ClientName, - settings.IgnoreEmptyFormUrlEncodedBody); + settings.IgnoreEmptyFormUrlEncodedBody, + contextBodies); files = generator.Generate(specRoot, filter, referenceResolver); } else if (specVersion is "3.2") @@ -164,9 +166,10 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) AnsiConsole.MarkupLine($"[green]Schemas:[/] {schemaRefs.Length}"); Dictionary? schemaTypeMap = null; + IReadOnlySet? contextBodies = null; if (schemaRefs.Length > 0) { - (schemaTypeMap, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, useYaml, cancellationToken) + (schemaTypeMap, contextBodies, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, useYaml, cancellationToken) .ConfigureAwait(false); } @@ -179,7 +182,8 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) rootNamespace, schemaTypeMap ?? new Dictionary(), settings.ClientName, - settings.IgnoreEmptyFormUrlEncodedBody); + settings.IgnoreEmptyFormUrlEncodedBody, + contextBodies); files = generator.Generate(specRoot, filter, referenceResolver); } else if (specVersion is "3.1" or not "3.0") @@ -191,9 +195,10 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) AnsiConsole.MarkupLine($"[green]Schemas:[/] {schemaRefs.Length}"); Dictionary? schemaTypeMap = null; + IReadOnlySet? contextBodies = null; if (schemaRefs.Length > 0) { - (schemaTypeMap, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, useYaml, cancellationToken) + (schemaTypeMap, contextBodies, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, useYaml, cancellationToken) .ConfigureAwait(false); } @@ -206,7 +211,8 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) rootNamespace, schemaTypeMap ?? new Dictionary(), settings.ClientName, - settings.IgnoreEmptyFormUrlEncodedBody); + settings.IgnoreEmptyFormUrlEncodedBody, + contextBodies); files = generator.Generate(specRoot, filter, referenceResolver); } else @@ -218,9 +224,10 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) AnsiConsole.MarkupLine($"[green]Schemas:[/] {schemaRefs.Length}"); Dictionary? schemaTypeMap = null; + IReadOnlySet? contextBodies = null; if (schemaRefs.Length > 0) { - (schemaTypeMap, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, useYaml, cancellationToken) + (schemaTypeMap, contextBodies, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, useYaml, cancellationToken) .ConfigureAwait(false); } @@ -233,7 +240,8 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) rootNamespace, schemaTypeMap ?? new Dictionary(), settings.ClientName, - settings.IgnoreEmptyFormUrlEncodedBody); + settings.IgnoreEmptyFormUrlEncodedBody, + contextBodies); files = generator.Generate(specRoot, filter, referenceResolver); } @@ -283,7 +291,7 @@ await File.WriteAllTextAsync(filePath, file.Content, cancellationToken) } } - private static async Task<(Dictionary SchemaTypeMap, IReadOnlyList GeneratedFileNames)> GenerateSchemaTypesAsync( + private static async Task<(Dictionary SchemaTypeMap, IReadOnlySet ContextSourceBodyPointers, IReadOnlyList GeneratedFileNames)> GenerateSchemaTypesAsync( string specFile, string specVersion, string rootNamespace, @@ -430,6 +438,11 @@ await File.WriteAllTextAsync(filePath, codeFile.FileContent, cancellationToken) // Build the pointer → fully qualified type name map Dictionary schemaTypeMap = new(StringComparer.Ordinal); + // The subset whose type is an object or array — i.e. types for which the model generator emits a + // context-threaded Source. A client operation emits a closure-free, single-materialisation generic + // overload only for a request body in this set; a scalar body has no Source to thread. + HashSet contextSourceBodyPointers = new(StringComparer.Ordinal); + foreach ((string pointerStr, TypeDeclaration td) in pointerToType) { TypeDeclaration reduced = td.ReducedTypeDeclaration().ReducedType; @@ -437,27 +450,31 @@ await File.WriteAllTextAsync(filePath, codeFile.FileContent, cancellationToken) if (reduced.HasDotnetTypeName()) { schemaTypeMap[pointerStr] = reduced.FullyQualifiedDotnetTypeName(); + if ((reduced.ImpliedCoreTypesOrAny() & (CoreTypes.Object | CoreTypes.Array)) != 0) + { + contextSourceBodyPointers.Add(pointerStr); + } } // Also add child types (items, additionalProperties, etc.) so that // deeply nested header/parameter element types can be resolved. - AddChildTypesToMap(reduced, schemaTypeMap); + AddChildTypesToMap(reduced, schemaTypeMap, contextSourceBodyPointers); } - return (schemaTypeMap, schemaFileNames); + return (schemaTypeMap, contextSourceBodyPointers, schemaFileNames); } /// /// Recursively walks child type declarations and adds their pointer mappings to the schema type map. /// This ensures sub-schema types (e.g., array items, additionalProperties) are resolvable by pointer. /// - private static void AddChildTypesToMap(TypeDeclaration parentType, Dictionary schemaTypeMap) + private static void AddChildTypesToMap(TypeDeclaration parentType, Dictionary schemaTypeMap, HashSet contextSourceBodyPointers) { HashSet visited = []; - AddChildTypesToMapCore(parentType, schemaTypeMap, visited); + AddChildTypesToMapCore(parentType, schemaTypeMap, contextSourceBodyPointers, visited); } - private static void AddChildTypesToMapCore(TypeDeclaration parentType, Dictionary schemaTypeMap, HashSet visited) + private static void AddChildTypesToMapCore(TypeDeclaration parentType, Dictionary schemaTypeMap, HashSet contextSourceBodyPointers, HashSet visited) { foreach (TypeDeclaration child in parentType.Children()) { @@ -474,11 +491,15 @@ private static void AddChildTypesToMapCore(TypeDeclaration parentType, Dictionar string key = "#" + rootPointer; // Don't overwrite existing entries (root pointers take precedence) - schemaTypeMap.TryAdd(key, reducedChild.FullyQualifiedDotnetTypeName()); + if (schemaTypeMap.TryAdd(key, reducedChild.FullyQualifiedDotnetTypeName()) + && (reducedChild.ImpliedCoreTypesOrAny() & (CoreTypes.Object | CoreTypes.Array)) != 0) + { + contextSourceBodyPointers.Add(key); + } } // Recurse into grandchildren - AddChildTypesToMapCore(reducedChild, schemaTypeMap, visited); + AddChildTypesToMapCore(reducedChild, schemaTypeMap, contextSourceBodyPointers, visited); } } diff --git a/src/Corvus.Json.Cli.Core/OpenApiServerCommand.cs b/src/Corvus.Json.Cli.Core/OpenApiServerCommand.cs index a5e0b2fd6c6..47d063045fd 100644 --- a/src/Corvus.Json.Cli.Core/OpenApiServerCommand.cs +++ b/src/Corvus.Json.Cli.Core/OpenApiServerCommand.cs @@ -97,9 +97,10 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) AnsiConsole.MarkupLine($"[green]Schemas:[/] {schemaRefs.Length}"); Dictionary? schemaTypeMap = null; + IReadOnlySet? contextBodies = null; if (schemaRefs.Length > 0) { - (schemaTypeMap, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, cancellationToken, syntheticDocuments) + (schemaTypeMap, contextBodies, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, cancellationToken, syntheticDocuments) .ConfigureAwait(false); } @@ -112,7 +113,8 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) rootNamespace, schemaTypeMap ?? new Dictionary(), settings.ClientName, - settings.IgnoreEmptyFormUrlEncodedBody); + settings.IgnoreEmptyFormUrlEncodedBody, + contextBodies); files = generator.GenerateServer(specRoot, filter, referenceResolver); } else if (specVersion is "3.2") @@ -123,9 +125,10 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) AnsiConsole.MarkupLine($"[green]Schemas:[/] {schemaRefs.Length}"); Dictionary? schemaTypeMap = null; + IReadOnlySet? contextBodies = null; if (schemaRefs.Length > 0) { - (schemaTypeMap, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, cancellationToken) + (schemaTypeMap, contextBodies, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, cancellationToken) .ConfigureAwait(false); } @@ -138,7 +141,8 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) rootNamespace, schemaTypeMap ?? new Dictionary(), settings.ClientName, - settings.IgnoreEmptyFormUrlEncodedBody); + settings.IgnoreEmptyFormUrlEncodedBody, + contextBodies); files = generator.GenerateServer(specRoot, filter, referenceResolver); } else if (specVersion is "3.1" or not "3.0") @@ -149,9 +153,10 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) AnsiConsole.MarkupLine($"[green]Schemas:[/] {schemaRefs.Length}"); Dictionary? schemaTypeMap = null; + IReadOnlySet? contextBodies = null; if (schemaRefs.Length > 0) { - (schemaTypeMap, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, cancellationToken) + (schemaTypeMap, contextBodies, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, cancellationToken) .ConfigureAwait(false); } @@ -164,7 +169,8 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) rootNamespace, schemaTypeMap ?? new Dictionary(), settings.ClientName, - settings.IgnoreEmptyFormUrlEncodedBody); + settings.IgnoreEmptyFormUrlEncodedBody, + contextBodies); files = generator.GenerateServer(specRoot, filter, referenceResolver); } else @@ -175,9 +181,10 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) AnsiConsole.MarkupLine($"[green]Schemas:[/] {schemaRefs.Length}"); Dictionary? schemaTypeMap = null; + IReadOnlySet? contextBodies = null; if (schemaRefs.Length > 0) { - (schemaTypeMap, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, cancellationToken) + (schemaTypeMap, contextBodies, modelFileNames) = await GenerateSchemaTypesAsync(specFilePath, specVersion, rootNamespace, modelsPath, schemaRefs, parameterNames, cancellationToken) .ConfigureAwait(false); } @@ -190,7 +197,8 @@ await File.WriteAllBytesAsync(settings.SpecFile, remoteBytes, cancellationToken) rootNamespace, schemaTypeMap ?? new Dictionary(), settings.ClientName, - settings.IgnoreEmptyFormUrlEncodedBody); + settings.IgnoreEmptyFormUrlEncodedBody, + contextBodies); files = generator.GenerateServer(specRoot, filter, referenceResolver); } @@ -223,7 +231,7 @@ await File.WriteAllTextAsync(filePath, file.Content, cancellationToken) return 0; } - private static async Task<(Dictionary SchemaTypeMap, IReadOnlyList GeneratedFileNames)> GenerateSchemaTypesAsync( + private static async Task<(Dictionary SchemaTypeMap, IReadOnlySet ContextSourceBodyPointers, IReadOnlyList GeneratedFileNames)> GenerateSchemaTypesAsync( string specFile, string specVersion, string rootNamespace, @@ -348,6 +356,11 @@ await File.WriteAllTextAsync(filePath, codeFile.FileContent, cancellationToken) Dictionary schemaTypeMap = new(StringComparer.Ordinal); + // The subset of map pointers whose type is an object or array — i.e. types for which the model generator emits a + // context-threaded Source. The server generator emits a closure-free, single-materialisation + // Ok response factory only for a body in this set; a scalar body has no Source. + HashSet contextSourceBodyPointers = new(StringComparer.Ordinal); + foreach ((string pointerStr, TypeDeclaration td) in pointerToType) { TypeDeclaration reduced = td.ReducedTypeDeclaration().ReducedType; @@ -355,21 +368,25 @@ await File.WriteAllTextAsync(filePath, codeFile.FileContent, cancellationToken) if (reduced.HasDotnetTypeName()) { schemaTypeMap[pointerStr] = reduced.FullyQualifiedDotnetTypeName(); + if ((reduced.ImpliedCoreTypesOrAny() & (CoreTypes.Object | CoreTypes.Array)) != 0) + { + contextSourceBodyPointers.Add(pointerStr); + } } - AddChildTypesToMap(reduced, schemaTypeMap); + AddChildTypesToMap(reduced, schemaTypeMap, contextSourceBodyPointers); } - return (schemaTypeMap, schemaFileNames); + return (schemaTypeMap, contextSourceBodyPointers, schemaFileNames); } - private static void AddChildTypesToMap(TypeDeclaration parentType, Dictionary schemaTypeMap) + private static void AddChildTypesToMap(TypeDeclaration parentType, Dictionary schemaTypeMap, HashSet contextSourceBodyPointers) { HashSet visited = []; - AddChildTypesToMapCore(parentType, schemaTypeMap, visited); + AddChildTypesToMapCore(parentType, schemaTypeMap, contextSourceBodyPointers, visited); } - private static void AddChildTypesToMapCore(TypeDeclaration parentType, Dictionary schemaTypeMap, HashSet visited) + private static void AddChildTypesToMapCore(TypeDeclaration parentType, Dictionary schemaTypeMap, HashSet contextSourceBodyPointers, HashSet visited) { foreach (TypeDeclaration child in parentType.Children()) { @@ -384,10 +401,14 @@ private static void AddChildTypesToMapCore(TypeDeclaration parentType, Dictionar && reducedChild.LocatedSchema.RootDocumentPointer is { Length: > 0 } rootPointer) { string key = "#" + rootPointer; - schemaTypeMap.TryAdd(key, reducedChild.FullyQualifiedDotnetTypeName()); + if (schemaTypeMap.TryAdd(key, reducedChild.FullyQualifiedDotnetTypeName()) + && (reducedChild.ImpliedCoreTypesOrAny() & (CoreTypes.Object | CoreTypes.Array)) != 0) + { + contextSourceBodyPointers.Add(key); + } } - AddChildTypesToMapCore(reducedChild, schemaTypeMap, visited); + AddChildTypesToMapCore(reducedChild, schemaTypeMap, contextSourceBodyPointers, visited); } } diff --git a/src/Corvus.Json.Cli/Corvus.Json.Cli.csproj b/src/Corvus.Json.Cli/Corvus.Json.Cli.csproj index 7af8f813001..1ce97af2c2e 100644 --- a/src/Corvus.Json.Cli/Corvus.Json.Cli.csproj +++ b/src/Corvus.Json.Cli/Corvus.Json.Cli.csproj @@ -2,7 +2,7 @@ Exe - net9.0;net10.0 + net10.0 Corvus.Json.Cli enable enable diff --git a/src/Corvus.Text.Json.OpenApi.CodeGeneration/CodeEmitHelpers.cs b/src/Corvus.Text.Json.OpenApi.CodeGeneration/CodeEmitHelpers.cs index eddc6f8134e..808d0763d44 100644 --- a/src/Corvus.Text.Json.OpenApi.CodeGeneration/CodeEmitHelpers.cs +++ b/src/Corvus.Text.Json.OpenApi.CodeGeneration/CodeEmitHelpers.cs @@ -116,15 +116,45 @@ public static string EscapeCSharpKeyword(string name) => }; /// - /// Escapes text for inclusion in an XML doc comment. + /// Escapes text for inclusion in an XML doc comment, and flattens it onto one line. /// /// The text to escape. - /// The XML-safe text. - public static string EscapeXml(string text) => - text.Replace("&", "&", StringComparison.Ordinal) + /// The XML-safe text, free of line breaks. + /// + /// An OpenAPI description is CommonMark, so multi-line prose is ordinary in a specification. Emitting it + /// verbatim produced C# that did not compile: every line after the first landed outside the /// prefix, so + /// the comment terminated mid-sentence and the rest of the paragraph was parsed as code. The text is therefore + /// flattened onto a single line here rather than at each call site, because it is written at more than twenty of + /// them across four generators and the indentation each one is emitted at differs. + /// + public static string EscapeXml(string text) + { + string escaped = text.Replace("&", "&", StringComparison.Ordinal) .Replace("<", "<", StringComparison.Ordinal) .Replace(">", ">", StringComparison.Ordinal); + if (escaped.AsSpan().IndexOfAny('\r', '\n') < 0) + { + return escaped; + } + + // Blank lines separate paragraphs in CommonMark, so they become elements rather than being collapsed + // away; a line break within a paragraph is a wrap, and becomes a space. + string[] paragraphs = escaped + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split("\n\n", StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < paragraphs.Length; i++) + { + paragraphs[i] = paragraphs[i].Replace('\n', ' ').Trim(); + } + + return paragraphs.Length == 1 + ? paragraphs[0] + : $"{string.Join("", paragraphs)}"; + } + /// /// Formats a C# string literal (including surrounding quotes), using the Roslyn /// method. @@ -247,6 +277,15 @@ public static string StatusCodeToName(string statusCode) => _ => $"Status{statusCode}", }; + /// + /// Gets the name of the generated property that holds a response's typed JSON body for a status + /// code (e.g. 200OkBody). The single source of truth for that convention, used + /// both when emitting the response struct and when describing it to downstream generators. + /// + /// The response status code, or default. + /// The generated body property name. + public static string ResponseBodyPropertyName(string statusCode) => $"{StatusCodeToName(statusCode)}Body"; + /// /// Converts a header name (e.g. X-Rate-Limit) to a PascalCase property name. /// diff --git a/src/Corvus.Text.Json.OpenApi.CodeGeneration/ExternalReferenceResolver.cs b/src/Corvus.Text.Json.OpenApi.CodeGeneration/ExternalReferenceResolver.cs index 99c369a09de..9562982a8f1 100644 --- a/src/Corvus.Text.Json.OpenApi.CodeGeneration/ExternalReferenceResolver.cs +++ b/src/Corvus.Text.Json.OpenApi.CodeGeneration/ExternalReferenceResolver.cs @@ -53,6 +53,11 @@ public sealed class ExternalReferenceResolver : IOpenApiReferenceResolver, IDisp private readonly JsonElement entryDocumentRoot; private readonly Uri baseUri; + // Optional hook for loading an external document (of any URI scheme) from a virtualized source — + // e.g. an in-memory registry, an HTTP client, or a build-artifact store. Consulted before the + // file-system fallback, so documents that "came from anywhere" resolve without touching disk. + private readonly Func? externalDocumentLoader; + // Pre-registered documents keyed by canonical URI string. // These are NOT owned by the resolver — the caller manages their lifetime. private readonly Dictionary registeredDocuments = new(StringComparer.Ordinal); @@ -74,6 +79,36 @@ public sealed class ExternalReferenceResolver : IOpenApiReferenceResolver, IDisp /// /// is not an absolute path. public ExternalReferenceResolver(JsonElement entryDocumentRoot, string entryDocumentPath) + : this(entryDocumentRoot, ToFileBaseUri(entryDocumentPath), null) + { + } + + /// + /// Initializes a new instance of the class with an explicit + /// base URI (which need not be a file path) and an optional loader for virtualized external documents. + /// + /// The root element of the entry (main) OpenAPI document. + /// + /// The absolute base URI the entry document was retrieved from — relative $refs resolve against + /// it (RFC 3986 §5). May be a file:, http(s):, or any other absolute URI, so the entry + /// document need not live on disk. + /// + /// + /// An optional callback that loads an external document's raw UTF-8 JSON bytes by its resolved + /// absolute URI, or returns when it cannot. When supplied it is consulted + /// before the file-system fallback, letting external $refs resolve from any source (an + /// in-memory registry, HTTP, an embedded resource). Documents it returns are owned (and disposed) by + /// this resolver. + /// + public ExternalReferenceResolver(JsonElement entryDocumentRoot, Uri baseUri, Func? externalDocumentLoader = null) + { + ArgumentNullException.ThrowIfNull(baseUri); + this.entryDocumentRoot = entryDocumentRoot; + this.baseUri = baseUri; + this.externalDocumentLoader = externalDocumentLoader; + } + + private static Uri ToFileBaseUri(string entryDocumentPath) { if (!Path.IsPathFullyQualified(entryDocumentPath)) { @@ -82,8 +117,7 @@ public ExternalReferenceResolver(JsonElement entryDocumentRoot, string entryDocu nameof(entryDocumentPath)); } - this.entryDocumentRoot = entryDocumentRoot; - this.baseUri = new Uri(entryDocumentPath); + return new Uri(entryDocumentPath); } private Uri CurrentBaseUri => this.baseStack.Count > 0 @@ -374,7 +408,15 @@ private bool TryResolveExternal(string refValue, out JsonElement result) return NavigateFragment(loaded.RootElement, fragment, out result); } - // 3. Fall back to file-system loading for file:// URIs + // 3. Try the injected loader (a virtualized document source — in-memory, HTTP, …) for any scheme. + if (this.externalDocumentLoader is { } loader && loader(resolvedUri) is { } bytes) + { + ParsedJsonDocument doc = ParsedJsonDocument.Parse(bytes); + this.loadedDocuments[key] = doc; + return NavigateFragment(doc.RootElement, fragment, out result); + } + + // 4. Fall back to file-system loading for file:// URIs if (resolvedUri.IsFile) { string filePath = resolvedUri.LocalPath; diff --git a/src/Corvus.Text.Json.OpenApi.CodeGeneration/GeneratedClientTypeNaming.cs b/src/Corvus.Text.Json.OpenApi.CodeGeneration/GeneratedClientTypeNaming.cs new file mode 100644 index 00000000000..7fd9138a56f --- /dev/null +++ b/src/Corvus.Text.Json.OpenApi.CodeGeneration/GeneratedClientTypeNaming.cs @@ -0,0 +1,36 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +namespace Corvus.Text.Json.OpenApi.CodeGeneration; + +/// +/// The single source of truth for the names of the per-operation types the OpenAPI client +/// generator emits. The generators use these suffixes when they emit the request/response structs, +/// and ListOperations uses them when it populates +/// and , so emission and the reported mapping can +/// never drift — and downstream generators (e.g. Arazzo) consume the names rather than re-deriving +/// the convention. +/// +public static class GeneratedClientTypeNaming +{ + /// The suffix appended to a method name to form its request struct name. + public const string RequestSuffix = "Request"; + + /// The suffix appended to a method name to form its response struct name. + public const string ResponseSuffix = "Response"; + + /// + /// Gets the simple (unqualified) request type name for a generated method name. + /// + /// The generated method name. + /// The request type name. + public static string RequestTypeName(string methodName) => methodName + RequestSuffix; + + /// + /// Gets the simple (unqualified) response type name for a generated method name. + /// + /// The generated method name. + /// The response type name. + public static string ResponseTypeName(string methodName) => methodName + ResponseSuffix; +} \ No newline at end of file diff --git a/src/Corvus.Text.Json.OpenApi.CodeGeneration/OperationDescriptor.cs b/src/Corvus.Text.Json.OpenApi.CodeGeneration/OperationDescriptor.cs new file mode 100644 index 00000000000..b911ea9e43e --- /dev/null +++ b/src/Corvus.Text.Json.OpenApi.CodeGeneration/OperationDescriptor.cs @@ -0,0 +1,112 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +namespace Corvus.Text.Json.OpenApi.CodeGeneration; + +/// +/// A fully-resolved description of a generated operation: its identity, the generated +/// request/response type names, and the request parameters — everything a downstream generator (such +/// as the Arazzo workflow generator) needs to call the operation, taken verbatim from the generator +/// so the convention is never re-derived. Unlike this is produced by a +/// generator instance, because the parameter and type names depend on the schema type map +/// and root namespace the instance was configured with. +/// +/// The API path template (e.g. /pets/{petId}). +/// The HTTP method. +/// The operationId, or if not specified. +/// The generated method name (PascalCase, without the Async suffix). +/// The fully-qualified generated request type name. +/// The fully-qualified generated response type name. +/// The request parameters, in document order. +/// Whether the operation declares a request body. +/// The operation's responses, in document order. +/// +/// The fully-qualified type of the generated client class that exposes this operation (e.g. +/// Acme.Pets.PetsClient). An operation grouped under several tags is exposed on a client per +/// tag; this is the canonical client (the operation's first tag, or the default client when it has +/// none). Constructed with a single IApiTransport argument. +/// +/// +/// The name of the generated client method that invokes this operation (e.g. +/// GetPetByIdAsync) — the with the Async suffix the generator +/// actually emitted. It builds and validates the request, sends it, and validates the response, +/// returning the generated response type. +/// +/// +/// The fully-qualified generated type whose .Source is the client method's body +/// parameter (so a caller binds a body via {RequestBodyTypeName}.From(source)), or +/// when the operation has no JSON request body (no body, or a raw stream body). +/// +/// +/// The response headers the operation declares, each described by the generated response property that +/// exposes it — so a caller can resolve $response.header.<name> against the response object +/// without introspecting the emitted struct. (or empty) when the operation +/// declares no response headers. Deduplicated by generated property name across all responses (the +/// generated response struct flattens headers, so one property serves every status that declares it). +/// +public readonly record struct OperationDescriptor( + string Path, + OperationMethod Method, + string? OperationId, + string MethodName, + string RequestTypeName, + string ResponseTypeName, + IReadOnlyList RequestParameters, + bool HasRequestBody, + IReadOnlyList Responses, + string ClientTypeName, + string ClientMethodName, + string? RequestBodyTypeName, + IReadOnlyList? ResponseHeaders = null); + +/// +/// A response of a generated operation, described by the generated type of its JSON body — so a +/// caller can infer the type of a value projected from $response.body without introspecting +/// the emitted struct. +/// +/// The response status code (e.g. 200) or default. +/// The fully-qualified generated type of the JSON response body, or if the response has no JSON body. +/// The name of the generated response property that holds the JSON body (e.g. OkBody), or if the response has no JSON body. +public readonly record struct ResponseDescriptor( + string StatusCode, + string? BodyTypeName, + string? BodyPropertyName); + +/// +/// A response header of a generated operation, described by the generated response property that +/// exposes it — so a caller can resolve $response.header.<name> without re-deriving the +/// header-to-property naming convention. +/// +/// The OpenAPI header name (e.g. X-Total-Count). +/// The generated response property that returns the header value (e.g. XTotalCountHeader). +/// The fully-qualified type of that property — the header's generated schema type, or string for a header with no schema. +/// Whether the property is a plain string? (a header with no schema) rather than a generated JSON type. +public readonly record struct ResponseHeaderInfo( + string HeaderName, + string PropertyName, + string TypeName, + bool IsString); + +/// +/// A request parameter of a generated operation, described by the names and type the generator +/// actually emitted for it — so a caller can bind a value to the right request property via +/// TTarget.From(source) without re-deriving the naming or type convention. +/// +/// The OpenAPI parameter name. +/// The parameter location (path, query, header, or cookie). +/// The name of the generated request property (e.g. PetId). +/// The fully-qualified type of the generated request property (e.g. Acme.Pets.Models.JsonInt64). +/// Whether the parameter is required (and therefore a constructor argument). +/// +/// The C# identifier the generator emitted for this parameter on the client method (e.g. petId, +/// keyword-escaped where necessary) — so a caller can pass it as a named argument without re-deriving +/// the naming convention. +/// +public readonly record struct RequestParameterInfo( + string Name, + ParameterLocation Location, + string PropertyName, + string TypeName, + bool IsRequired, + string ParameterName); \ No newline at end of file diff --git a/src/Corvus.Text.Json.OpenApi.CodeGeneration/OperationSummary.cs b/src/Corvus.Text.Json.OpenApi.CodeGeneration/OperationSummary.cs index c7c2229cfcd..fc3a49f3397 100644 --- a/src/Corvus.Text.Json.OpenApi.CodeGeneration/OperationSummary.cs +++ b/src/Corvus.Text.Json.OpenApi.CodeGeneration/OperationSummary.cs @@ -5,7 +5,11 @@ namespace Corvus.Text.Json.OpenApi.CodeGeneration; /// -/// A lightweight summary of an API operation for display purposes (e.g. the show command). +/// A lightweight summary of an API operation. Used both for display (e.g. the show command) +/// and as the authoritative mapping from an operation to the generated request/response type names — +/// , , and are +/// computed by the generator itself, so consumers (such as the Arazzo workflow generator) never +/// re-derive the naming heuristic. /// /// The API path template (e.g. /pets/{petId}). /// The HTTP method. @@ -15,6 +19,9 @@ namespace Corvus.Text.Json.OpenApi.CodeGeneration; /// The number of parameters declared on the operation. /// Whether the operation declares a request body. /// The operation summary text, or . +/// The generated method name (PascalCase) for this operation. +/// The generated request type's simple (unqualified) name. +/// The generated response type's simple (unqualified) name. public readonly record struct OperationSummary( string Path, OperationMethod Method, @@ -23,4 +30,7 @@ public readonly record struct OperationSummary( bool IsDeprecated, int ParameterCount, bool HasRequestBody, - string? Summary); \ No newline at end of file + string? Summary, + string MethodName, + string RequestTypeName, + string ResponseTypeName); \ No newline at end of file diff --git a/src/Corvus.Text.Json.OpenApi.CodeGeneration/SchemaClassifier.cs b/src/Corvus.Text.Json.OpenApi.CodeGeneration/SchemaClassifier.cs index 5bff0bf9d1a..3bb572e8c25 100644 --- a/src/Corvus.Text.Json.OpenApi.CodeGeneration/SchemaClassifier.cs +++ b/src/Corvus.Text.Json.OpenApi.CodeGeneration/SchemaClassifier.cs @@ -15,12 +15,27 @@ namespace Corvus.Text.Json.OpenApi.CodeGeneration; /// extraction, using the schema element obtained through typed model access. /// /// +/// When a schema is a local $ref (e.g. {"$ref":"#/components/schemas/Foo"}) +/// it has no type keyword of its own. The classifier resolves such references +/// against the supplied OpenAPI document root before inspecting type/format, +/// so a parameter referencing a named integer/array/object schema is classified by the +/// resolved schema rather than falling back to . +/// Nested sub-schemas (items, additionalProperties, properties values) +/// are resolved the same way. +/// +/// /// No strings are allocated. All comparisons use ValueEquals on /// UTF-8 byte sequences. /// /// public static class SchemaClassifier { + /// + /// The maximum number of transitive $ref hops to follow before giving up. + /// Guards against reference cycles and pathologically deep chains. + /// + private const int MaxRefDepth = 32; + /// /// Classifies a schema element's type and format keywords /// into a . @@ -30,9 +45,16 @@ public static class SchemaClassifier /// a type keyword, or the type is unrecognised, returns /// . /// + /// + /// The root element of the OpenAPI document, used to resolve local $ref + /// schemas. When (the default), no + /// reference resolution is performed and the schema is classified as-is. + /// /// The serialization kind for the parameter. - public static ParameterSerializationKind Classify(JsonElement schema) + public static ParameterSerializationKind Classify(JsonElement schema, JsonElement documentRoot = default) { + schema = ResolveRef(schema, documentRoot); + if (!schema.TryGetProperty("type"u8, out JsonElement typeElement) || typeElement.ValueKind != JsonValueKind.String) { @@ -130,6 +152,169 @@ ParameterSerializationKind.Double or ParameterSerializationKind.Decimal; } + /// + /// Resolves a (possibly chained) local $ref schema against the OpenAPI + /// document root. + /// + /// The schema element, which may be a $ref object. + /// + /// The root element of the OpenAPI document. When + /// , the input is returned unchanged. + /// + /// + /// The resolved schema element. If is not a local + /// $ref, or the reference cannot be resolved, the input is returned + /// unchanged. Transitive references are followed up to + /// hops to guard against cycles. + /// + /// + /// Only local JSON-pointer references of the form "#/..." are followed. + /// Pointer tokens are unescaped per RFC 6901 (~1/, ~0~). + /// External references (anything not beginning with #/, or a bare #) + /// are left unresolved. + /// + public static JsonElement ResolveRef(JsonElement schema, JsonElement documentRoot) + { + if (documentRoot.ValueKind == JsonValueKind.Undefined + || schema.ValueKind != JsonValueKind.Object) + { + return schema; + } + + JsonElement current = schema; + for (int depth = 0; depth < MaxRefDepth; depth++) + { + if (!current.TryGetProperty("$ref"u8, out JsonElement refElement) + || refElement.ValueKind != JsonValueKind.String) + { + return current; + } + + if (!TryResolvePointer(refElement, documentRoot, out JsonElement resolved)) + { + // Unresolvable / external reference: return the $ref object unchanged. + return current; + } + + current = resolved; + } + + // Depth cap reached (likely a cycle): return whatever we have. + return current; + } + + /// + /// Classifies the element type for an array schema by inspecting its + /// items sub-schema. + /// + /// The array schema element. + /// + /// The root element of the OpenAPI document, used to resolve local $ref + /// schemas on the array itself and on its items sub-schema. + /// + /// + /// The serialization kind for the array element type. + /// Returns if no + /// items sub-schema is present. + /// + public static ParameterSerializationKind ClassifyArrayElement(JsonElement schema, JsonElement documentRoot = default) + { + schema = ResolveRef(schema, documentRoot); + + if (schema.TryGetProperty("items"u8, out JsonElement items) + && items.ValueKind == JsonValueKind.Object) + { + return Classify(items, documentRoot); + } + + return ParameterSerializationKind.String; + } + + /// + /// Classifies the value type for an object schema by inspecting its + /// additionalProperties sub-schema. + /// + /// The object schema element. + /// + /// The root element of the OpenAPI document, used to resolve local $ref + /// schemas on the object itself and on its additionalProperties sub-schema. + /// + /// + /// The serialization kind for the object value type. + /// Returns if no + /// additionalProperties sub-schema is present. + /// + public static ParameterSerializationKind ClassifyObjectValue(JsonElement schema, JsonElement documentRoot = default) + { + schema = ResolveRef(schema, documentRoot); + + if (schema.TryGetProperty("additionalProperties"u8, out JsonElement addlProps) + && addlProps.ValueKind == JsonValueKind.Object) + { + return Classify(addlProps, documentRoot); + } + + return ParameterSerializationKind.String; + } + + /// + /// Checks whether a schema classified as + /// or contains nested composite types + /// (objects or arrays within its property values or array items). + /// + /// The schema element. + /// + /// The root element of the OpenAPI document, used to resolve local $ref + /// schemas on the schema itself and on its nested sub-schemas. + /// + /// + /// if the schema has nested composite types whose behaviour + /// is undefined under OpenAPI style serialization. + /// + /// + /// + /// The OpenAPI specification states that behaviour is undefined for deeply nested + /// objects and arrays in style serialization (query, path, header, cookie). + /// When this method returns , the code generator should emit + /// a #warning directive to alert consumers. + /// + /// + public static bool HasDeepNesting(JsonElement schema, JsonElement documentRoot = default) + { + schema = ResolveRef(schema, documentRoot); + + // For objects: check if any declared property has a composite schema. + if (schema.TryGetProperty("properties"u8, out JsonElement properties) + && properties.ValueKind == JsonValueKind.Object) + { + foreach (var prop in properties.EnumerateObject()) + { + if (IsCompositeType(prop.Value, documentRoot)) + { + return true; + } + } + } + + // For objects: check additionalProperties if it is a schema (not bool). + if (schema.TryGetProperty("additionalProperties"u8, out JsonElement addlProps) + && addlProps.ValueKind == JsonValueKind.Object + && IsCompositeType(addlProps, documentRoot)) + { + return true; + } + + // For arrays: check if items schema is composite. + if (schema.TryGetProperty("items"u8, out JsonElement items) + && items.ValueKind == JsonValueKind.Object + && IsCompositeType(items, documentRoot)) + { + return true; + } + + return false; + } + private static ParameterSerializationKind ClassifyIntegerFormat(JsonElement schema) { if (schema.TryGetProperty("format"u8, out JsonElement fmt) @@ -218,104 +403,85 @@ private static ParameterSerializationKind ClassifyNumberFormat(JsonElement schem return ParameterSerializationKind.UnboundedNumber; } - /// - /// Classifies the element type for an array schema by inspecting its - /// items sub-schema. - /// - /// The array schema element. - /// - /// The serialization kind for the array element type. - /// Returns if no - /// items sub-schema is present. - /// - public static ParameterSerializationKind ClassifyArrayElement(JsonElement schema) + private static bool IsCompositeType(JsonElement schema, JsonElement documentRoot) { - if (schema.TryGetProperty("items"u8, out JsonElement items) - && items.ValueKind == JsonValueKind.Object) - { - return Classify(items); - } + schema = ResolveRef(schema, documentRoot); - return ParameterSerializationKind.String; + return schema.TryGetProperty("type"u8, out JsonElement typeElement) + && typeElement.ValueKind == JsonValueKind.String + && (typeElement.ValueEquals("object"u8) || typeElement.ValueEquals("array"u8)); } /// - /// Classifies the value type for an object schema by inspecting its - /// additionalProperties sub-schema. + /// Resolves a single local JSON-pointer $ref (one hop) against the document root. /// - /// The object schema element. + /// The $ref string element. + /// The OpenAPI document root. + /// On success, the element the pointer addresses. /// - /// The serialization kind for the object value type. - /// Returns if no - /// additionalProperties sub-schema is present. + /// if the reference is a local #/... pointer that + /// resolves to an existing element; otherwise . /// - public static ParameterSerializationKind ClassifyObjectValue(JsonElement schema) + private static bool TryResolvePointer(JsonElement refElement, JsonElement documentRoot, out JsonElement resolved) { - if (schema.TryGetProperty("additionalProperties"u8, out JsonElement addlProps) - && addlProps.ValueKind == JsonValueKind.Object) + resolved = default; + + string reference = refElement.GetString() ?? string.Empty; + + // Only local fragment pointers of the form "#/..." are followed. + if (reference.Length < 2 || reference[0] != '#' || reference[1] != '/') { - return Classify(addlProps); + return false; } - return ParameterSerializationKind.String; - } + JsonElement current = documentRoot; - /// - /// Checks whether a schema classified as - /// or contains nested composite types - /// (objects or arrays within its property values or array items). - /// - /// The schema element. - /// - /// if the schema has nested composite types whose behaviour - /// is undefined under OpenAPI style serialization. - /// - /// - /// - /// The OpenAPI specification states that behaviour is undefined for deeply nested - /// objects and arrays in style serialization (query, path, header, cookie). - /// When this method returns , the code generator should emit - /// a #warning directive to alert consumers. - /// - /// - public static bool HasDeepNesting(JsonElement schema) - { - // For objects: check if any declared property has a composite schema. - if (schema.TryGetProperty("properties"u8, out JsonElement properties) - && properties.ValueKind == JsonValueKind.Object) + // Skip the leading "#/" then walk each "/"-separated token. + int index = 2; + int length = reference.Length; + while (index <= length) { - foreach (var prop in properties.EnumerateObject()) + int slash = reference.IndexOf('/', index); + string rawToken = slash < 0 + ? reference[index..] + : reference[index..slash]; + + string token = UnescapePointerToken(rawToken); + + if (current.ValueKind != JsonValueKind.Object + || !current.TryGetProperty(token, out JsonElement next)) { - if (IsCompositeType(prop.Value)) - { - return true; - } + return false; } - } - // For objects: check additionalProperties if it is a schema (not bool). - if (schema.TryGetProperty("additionalProperties"u8, out JsonElement addlProps) - && addlProps.ValueKind == JsonValueKind.Object - && IsCompositeType(addlProps)) - { - return true; - } + current = next; - // For arrays: check if items schema is composite. - if (schema.TryGetProperty("items"u8, out JsonElement items) - && items.ValueKind == JsonValueKind.Object - && IsCompositeType(items)) - { - return true; + if (slash < 0) + { + break; + } + + index = slash + 1; } - return false; + resolved = current; + return true; } - private static bool IsCompositeType(JsonElement schema) + /// + /// Unescapes a single JSON-pointer reference token per RFC 6901 + /// (~1/, ~0~). + /// + /// The raw (escaped) token. + /// The unescaped token. + private static string UnescapePointerToken(string token) { - return schema.TryGetProperty("type"u8, out JsonElement typeElement) - && typeElement.ValueKind == JsonValueKind.String - && (typeElement.ValueEquals("object"u8) || typeElement.ValueEquals("array"u8)); + if (token.IndexOf('~') < 0) + { + return token; + } + + // Order matters: ~1 -> / must precede ~0 -> ~ to avoid double-unescaping. + return token.Replace("~1", "/").Replace("~0", "~"); } } \ No newline at end of file diff --git a/src/Corvus.Text.Json.OpenApi.HttpTransport/Corvus.Text.Json.OpenApi.HttpTransport.csproj b/src/Corvus.Text.Json.OpenApi.HttpTransport/Corvus.Text.Json.OpenApi.HttpTransport.csproj index a4b77f7bd2a..cade9c087de 100644 --- a/src/Corvus.Text.Json.OpenApi.HttpTransport/Corvus.Text.Json.OpenApi.HttpTransport.csproj +++ b/src/Corvus.Text.Json.OpenApi.HttpTransport/Corvus.Text.Json.OpenApi.HttpTransport.csproj @@ -2,6 +2,7 @@ net10.0 + true enable enable preview diff --git a/src/Corvus.Text.Json.OpenApi.HttpTransport/HttpClientApiTransportFactory.cs b/src/Corvus.Text.Json.OpenApi.HttpTransport/HttpClientApiTransportFactory.cs new file mode 100644 index 00000000000..88f508bbcc3 --- /dev/null +++ b/src/Corvus.Text.Json.OpenApi.HttpTransport/HttpClientApiTransportFactory.cs @@ -0,0 +1,36 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using Corvus.Text.Json.OpenApi; + +namespace Corvus.Text.Json.OpenApi.HttpTransport; + +/// +/// An that produces instances over a +/// shared, host-owned (whose is the source's +/// base URL). Each created transport leaves the client open (disposeClient: false), so a caller that +/// disposes the per-use transport does not tear down the shared client. +/// +public sealed class HttpClientApiTransportFactory : IApiTransportFactory +{ + private readonly HttpClient httpClient; + private readonly IHttpAuthenticationProvider? authenticationProvider; + private readonly Func>? baseUrlOverride; + + /// Initializes a new instance of the class. + /// The shared client to send through; its is the source's base URL. The host owns its lifetime. + /// An optional authentication provider applied to each request; for unauthenticated sources. + /// An optional per-environment base URL override resolver (design §8); when it yields a non-null URI, relative requests resolve against it instead of the client's base address. + public HttpClientApiTransportFactory(HttpClient httpClient, IHttpAuthenticationProvider? authenticationProvider = null, Func>? baseUrlOverride = null) + { + ArgumentNullException.ThrowIfNull(httpClient); + this.httpClient = httpClient; + this.authenticationProvider = authenticationProvider; + this.baseUrlOverride = baseUrlOverride; + } + + /// + public IApiTransport CreateTransport() + => new HttpClientTransport(this.httpClient, this.authenticationProvider, disposeClient: false, baseUrlOverride: this.baseUrlOverride); +} \ No newline at end of file diff --git a/src/Corvus.Text.Json.OpenApi.HttpTransport/HttpClientTransport.cs b/src/Corvus.Text.Json.OpenApi.HttpTransport/HttpClientTransport.cs index 3c8343e338d..f20e4193e49 100644 --- a/src/Corvus.Text.Json.OpenApi.HttpTransport/HttpClientTransport.cs +++ b/src/Corvus.Text.Json.OpenApi.HttpTransport/HttpClientTransport.cs @@ -55,6 +55,9 @@ public sealed class HttpClientTransport : IApiTransport private readonly HttpClient httpClient; private readonly IHttpAuthenticationProvider? authenticationProvider; private readonly bool disposeClient; + private readonly Func>? baseUrlOverride; + private Uri? resolvedBaseUrlOverride; + private bool baseUrlOverrideResolved; /// /// Initializes a new instance of the class. @@ -67,14 +70,22 @@ public sealed class HttpClientTransport : IApiTransport /// to dispose when this transport /// is disposed; (the default) to leave it to the caller. /// + /// + /// An optional resolver for a per-transport base URL override. When supplied and it yields a non-null absolute URI, + /// each relative request is resolved against it instead of (the same combine + /// semantics). Resolved once on first send and reused. (the default) leaves the client's + /// base address authoritative — so existing callers are unaffected. + /// public HttpClientTransport( HttpClient httpClient, IHttpAuthenticationProvider? authenticationProvider = null, - bool disposeClient = false) + bool disposeClient = false, + Func>? baseUrlOverride = null) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.authenticationProvider = authenticationProvider; this.disposeClient = disposeClient; + this.baseUrlOverride = baseUrlOverride; } /// @@ -248,6 +259,16 @@ private async ValueTask SendCoreAsync( CancellationToken cancellationToken) where TResponse : struct, IApiResponse { + if (this.baseUrlOverride is not null && !this.baseUrlOverrideResolved) + { + // A per-environment base URL override (§8): resolve once; ApplyBaseAddress combines each relative + // request against it instead of HttpClient.BaseAddress, with the same prefix-preserving composition, + // so the run reaches this environment's endpoint. Resolving before authentication means providers + // that sign the request see the final absolute URI. + this.resolvedBaseUrlOverride = await this.baseUrlOverride(cancellationToken).ConfigureAwait(false); + this.baseUrlOverrideResolved = true; + } + this.ApplyBaseAddress(httpRequest); if (this.authenticationProvider is not null) @@ -286,9 +307,10 @@ await this.authenticationProvider } /// - /// Composes the final absolute request URI from - /// and the relative operation URI produced by , - /// preserving any path prefix carried by the base address. + /// Composes the final absolute request URI from the effective base — the resolved + /// per-transport base URL override when one is configured, else + /// — and the relative operation URI produced by + /// , preserving any path prefix carried by the base. /// /// The request whose /// is rewritten in place. @@ -303,9 +325,13 @@ await this.authenticationProvider /// segment the result is identical to the RFC 3986 resolution. /// /// - /// If the client has no , or the request URI is - /// already absolute or does not begin with /, the request is left untouched and - /// behaves exactly as before. + /// If there is no effective base (no override resolved and no + /// ), or the request URI is already absolute, the + /// request is left untouched and behaves exactly as before. A + /// relative reference that does not begin with / has no prefix-dropping hazard — + /// RFC 3986 merge keeps the base path — so it is resolved against the override when one + /// is present (only this method knows the override) and otherwise left for + /// to resolve against its own base address. /// /// /// The composition allocates exactly one string per request (the composed URI passed to @@ -317,34 +343,45 @@ await this.authenticationProvider /// private void ApplyBaseAddress(HttpRequestMessage httpRequest) { - if (this.httpClient.BaseAddress is Uri baseAddress - && httpRequest.RequestUri is { IsAbsoluteUri: false } relativeUri - && relativeUri.OriginalString.StartsWith('/')) + if ((this.resolvedBaseUrlOverride ?? this.httpClient.BaseAddress) is not Uri baseAddress + || httpRequest.RequestUri is not { IsAbsoluteUri: false } relativeUri) { - string baseUri = baseAddress.AbsoluteUri; - int baseLength = baseUri.AsSpan().IndexOfAny('?', '#'); - if (baseLength < 0) - { - baseLength = baseUri.Length; - } + return; + } - while (baseLength > 0 && baseUri[baseLength - 1] == '/') + if (!relativeUri.OriginalString.StartsWith('/')) + { + if (this.resolvedBaseUrlOverride is not null) { - baseLength--; + httpRequest.RequestUri = new Uri(baseAddress, relativeUri); } - string relative = relativeUri.OriginalString; - string composed = string.Create( - baseLength + relative.Length, - (baseUri, baseLength, relative), - static (span, state) => - { - state.baseUri.AsSpan(0, state.baseLength).CopyTo(span); - state.relative.CopyTo(span[state.baseLength..]); - }); + return; + } + + string baseUri = baseAddress.AbsoluteUri; + int baseLength = baseUri.AsSpan().IndexOfAny('?', '#'); + if (baseLength < 0) + { + baseLength = baseUri.Length; + } - httpRequest.RequestUri = new Uri(composed, UriKind.Absolute); + while (baseLength > 0 && baseUri[baseLength - 1] == '/') + { + baseLength--; } + + string relative = relativeUri.OriginalString; + string composed = string.Create( + baseLength + relative.Length, + (baseUri, baseLength, relative), + static (span, state) => + { + state.baseUri.AsSpan(0, state.baseLength).CopyTo(span); + state.relative.CopyTo(span[state.baseLength..]); + }); + + httpRequest.RequestUri = new Uri(composed, UriKind.Absolute); } /// diff --git a/src/Corvus.Text.Json.OpenApi/Corvus.Text.Json.OpenApi.csproj b/src/Corvus.Text.Json.OpenApi/Corvus.Text.Json.OpenApi.csproj index e34deaa0f15..7e888debe4c 100644 --- a/src/Corvus.Text.Json.OpenApi/Corvus.Text.Json.OpenApi.csproj +++ b/src/Corvus.Text.Json.OpenApi/Corvus.Text.Json.OpenApi.csproj @@ -2,6 +2,7 @@ net10.0 + true enable enable preview diff --git a/src/Corvus.Text.Json.OpenApi/IApiTransportFactory.cs b/src/Corvus.Text.Json.OpenApi/IApiTransportFactory.cs new file mode 100644 index 00000000000..42fc68b3b32 --- /dev/null +++ b/src/Corvus.Text.Json.OpenApi/IApiTransportFactory.cs @@ -0,0 +1,18 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +namespace Corvus.Text.Json.OpenApi; + +/// +/// Creates an for a single use. A caller that owns the per-use transport +/// lifetime (for example a workflow runner that disposes the transport after each run) takes a factory +/// rather than a shared instance, so each use gets a fresh transport over the host's shared, long-lived +/// resources (such as an ). +/// +public interface IApiTransportFactory +{ + /// Creates a new . The caller owns and disposes it. + /// The new transport. + IApiTransport CreateTransport(); +} \ No newline at end of file diff --git a/src/Corvus.Text.Json.OpenApi/InstrumentedApiTransport.cs b/src/Corvus.Text.Json.OpenApi/InstrumentedApiTransport.cs new file mode 100644 index 00000000000..f23c7b2ddb3 --- /dev/null +++ b/src/Corvus.Text.Json.OpenApi/InstrumentedApiTransport.cs @@ -0,0 +1,198 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.Diagnostics; +using System.Globalization; +using System.Text; +using Corvus.Text.Json.Internal; + +namespace Corvus.Text.Json.OpenApi; + +/// +/// A decorator that adds OpenTelemetry-compliant distributed tracing and metrics to any +/// . The HTTP-client analogue of +/// Corvus.Text.Json.AsyncApi.InstrumentedMessageTransport. +/// +/// +/// +/// Wrap any transport with this decorator to gain, per operation, a span +/// named {method} {route} with HTTP semantic tags (http.request.method, url.template, +/// http.response.status_code), plus a request-duration histogram and a request counter — so a +/// workflow's step spans correlate end-to-end with the operations they invoke. The span nests under the +/// ambient (the workflow step), so no explicit context is threaded. +/// +/// +/// Unlike the AsyncAPI decorator, this does not inject W3C trace context: the outbound request headers are +/// written inside the underlying HTTP transport, whose own client instrumentation (e.g. ) +/// performs downstream traceparent propagation. All instrumentation is zero-cost when no listener is +/// attached. +/// +/// +/// Example usage: +/// +/// IApiTransport raw = new HttpApiTransport(httpClient, baseUri); +/// IApiTransport transport = new InstrumentedApiTransport(raw); +/// +/// +/// +public sealed class InstrumentedApiTransport : IApiTransport +{ + private readonly IApiTransport inner; + + /// + /// Initializes a new instance of the class. + /// + /// The transport to decorate with instrumentation. + public InstrumentedApiTransport(IApiTransport inner) + { + ArgumentNullException.ThrowIfNull(inner); + this.inner = inner; + } + + /// + public ValueTask SendAsync( + in TRequest request, + CancellationToken cancellationToken = default) + where TRequest : struct, IApiRequest + where TResponse : struct, IApiResponse + { + TRequest requestCopy = request; + return this.InstrumentAsync( + ct => this.inner.SendAsync(in requestCopy, ct), + cancellationToken); + } + + /// + public ValueTask SendAsync( + in TRequest request, + in TBody body, + CancellationToken cancellationToken = default) + where TRequest : struct, IApiRequest + where TBody : struct, IJsonElement + where TResponse : struct, IApiResponse + { + TRequest requestCopy = request; + TBody bodyCopy = body; + return this.InstrumentAsync( + ct => this.inner.SendAsync(in requestCopy, in bodyCopy, ct), + cancellationToken); + } + + /// + public ValueTask SendAsync( + in TRequest request, + Stream body, + string contentType, + CancellationToken cancellationToken = default) + where TRequest : struct, IApiRequest + where TResponse : struct, IApiResponse + { + TRequest requestCopy = request; + return this.InstrumentAsync( + ct => this.inner.SendAsync(in requestCopy, body, contentType, ct), + cancellationToken); + } + + /// + public ValueTask SendAsync( + in TRequest request, + Func bodyWriter, + string contentType, + CancellationToken cancellationToken = default) + where TRequest : struct, IApiRequest + where TResponse : struct, IApiResponse + { + TRequest requestCopy = request; + return this.InstrumentAsync( + ct => this.inner.SendAsync(in requestCopy, bodyWriter, contentType, ct), + cancellationToken); + } + + /// + public ValueTask DisposeAsync() => this.inner.DisposeAsync(); + + private async ValueTask InstrumentAsync( + Func> send, + CancellationToken cancellationToken) + where TRequest : struct, IApiRequest + where TResponse : struct, IApiResponse + { + string method = MethodName(); + string route = Encoding.UTF8.GetString(TRequest.PathTemplateUtf8); + + using Activity? activity = OpenApiTelemetry.ActivitySource.StartActivity( + $"{method} {route}", + ActivityKind.Client); + + if (activity is { IsAllDataRequested: true }) + { + activity.SetTag("http.request.method", method); + activity.SetTag("url.template", route); + } + + long startTimestamp = Stopwatch.GetTimestamp(); + int statusCode = 0; + string? errorType = null; + try + { + TResponse response = await send(cancellationToken).ConfigureAwait(false); + statusCode = response.StatusCode; + + if (statusCode >= 400) + { + errorType = statusCode.ToString(CultureInfo.InvariantCulture); + } + + if (activity is { IsAllDataRequested: true }) + { + activity.SetTag("http.response.status_code", statusCode); + if (errorType is not null) + { + activity.SetStatus(ActivityStatusCode.Error); + activity.SetTag("error.type", errorType); + } + } + + return response; + } + catch (Exception ex) + { + errorType = ex.GetType().FullName; + if (activity is not null) + { + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.SetTag("error.type", errorType); + } + + throw; + } + finally + { + var tags = new TagList + { + { "http.request.method", method }, + { "url.template", route }, + }; + + if (statusCode != 0) + { + tags.Add("http.response.status_code", statusCode); + } + + if (errorType is not null) + { + tags.Add("error.type", errorType); + } + + OpenApiTelemetry.RequestDuration.Record(Stopwatch.GetElapsedTime(startTimestamp).TotalSeconds, tags); + OpenApiTelemetry.Requests.Add(1, tags); + } + } + + private static string MethodName() + where TRequest : struct, IApiRequest + => TRequest.Method == OperationMethod.Custom && !TRequest.CustomMethodNameUtf8.IsEmpty + ? Encoding.UTF8.GetString(TRequest.CustomMethodNameUtf8) + : TRequest.Method.ToString().ToUpperInvariant(); +} \ No newline at end of file diff --git a/src/Corvus.Text.Json.OpenApi/OpenApiTelemetry.cs b/src/Corvus.Text.Json.OpenApi/OpenApiTelemetry.cs new file mode 100644 index 00000000000..32256986154 --- /dev/null +++ b/src/Corvus.Text.Json.OpenApi/OpenApiTelemetry.cs @@ -0,0 +1,72 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Corvus.Text.Json.OpenApi; + +/// +/// Provides OpenTelemetry-compliant instrumentation for OpenAPI transports (the HTTP-client analogue of +/// Corvus.Text.Json.AsyncApi.AsyncApiTelemetry). +/// +/// +/// +/// Register with the OpenTelemetry pipeline using: +/// +/// services.AddOpenTelemetry() +/// .WithTracing(b => b.AddSource(OpenApiTelemetry.ActivitySourceName)) +/// .WithMetrics(b => b.AddMeter(OpenApiTelemetry.MeterName)); +/// +/// +/// +/// All instruments are zero-cost when no listener is attached: +/// returns without a listener, and is a no-op +/// without a . +/// +/// +public static class OpenApiTelemetry +{ + /// + /// The name. Use with AddSource("Corvus.OpenApi"). + /// + public const string ActivitySourceName = "Corvus.OpenApi"; + + /// + /// The name. Use with AddMeter("Corvus.OpenApi"). + /// + public const string MeterName = "Corvus.OpenApi"; + + private static readonly string Version = + typeof(OpenApiTelemetry).Assembly.GetName().Version?.ToString() ?? "1.0.0"; + + /// + /// Gets the for distributed tracing of outbound API operations. + /// + public static ActivitySource ActivitySource { get; } = new(ActivitySourceName, Version); + + /// + /// Gets the for API-client metrics. + /// + public static Meter Meter { get; } = new(MeterName, Version); + + /// + /// Gets the histogram measuring the duration of outbound HTTP client requests. + /// + /// OTel semantic convention: http.client.request.duration. Unit: seconds. + public static Histogram RequestDuration { get; } = + Meter.CreateHistogram( + "http.client.request.duration", + "s", + "Duration of outbound HTTP client requests"); + + /// + /// Gets the counter for outbound HTTP client requests. + /// + public static Counter Requests { get; } = + Meter.CreateCounter( + "http.client.requests", + "{request}", + "Number of outbound HTTP client requests"); +} \ No newline at end of file diff --git a/src/Corvus.Text.Json.OpenApi20/OpenApi20CodeGenerator.cs b/src/Corvus.Text.Json.OpenApi20/OpenApi20CodeGenerator.cs index e3cc94ad1bd..3e0b3a589de 100644 --- a/src/Corvus.Text.Json.OpenApi20/OpenApi20CodeGenerator.cs +++ b/src/Corvus.Text.Json.OpenApi20/OpenApi20CodeGenerator.cs @@ -35,6 +35,7 @@ public sealed class OpenApi20CodeGenerator private readonly string rootNamespace; private readonly string? clientNamePrefix; private readonly bool ignoreEmptyFormUrlEncodedBody; + private readonly IReadOnlySet contextSourceBodyPointers; private readonly IReadOnlyDictionary schemaTypeMap; /// @@ -58,12 +59,14 @@ public OpenApi20CodeGenerator( string rootNamespace, IReadOnlyDictionary schemaTypeMap, string? clientNamePrefix = null, - bool ignoreEmptyFormUrlEncodedBody = false) + bool ignoreEmptyFormUrlEncodedBody = false, + IReadOnlySet? contextSourceBodyPointers = null) { this.rootNamespace = rootNamespace; this.schemaTypeMap = schemaTypeMap; this.clientNamePrefix = clientNamePrefix; this.ignoreEmptyFormUrlEncodedBody = ignoreEmptyFormUrlEncodedBody; + this.contextSourceBodyPointers = contextSourceBodyPointers ?? new HashSet(StringComparer.Ordinal); } // ── Walk-phase reference (typed model objects, no strings extracted) ── @@ -353,6 +356,8 @@ public static OperationSummary[] ListOperations( bool hasBody = OperationHasBodyOrFormData(opRef.Operation, opRef.PathItem); + string methodName = GetMethodName(operationId, opRef.Method, path); + result.Add(new OperationSummary( path, opRef.Method, @@ -361,7 +366,10 @@ public static OperationSummary[] ListOperations( isDeprecated, paramCount, hasBody, - summary)); + summary, + methodName, + GeneratedClientTypeNaming.RequestTypeName(methodName), + GeneratedClientTypeNaming.ResponseTypeName(methodName))); } return [.. result]; @@ -1961,6 +1969,51 @@ private string ResolveSchemaTypeName(string? schemaPointer) return "JsonElement"; } + // The JSON-ish media type's schema pointer for a RESPONSE body, the key into contextSourceBodyPointers. + private static string? ResolveResponseBodySchemaPointer(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType)) + { + return content.SchemaPointer; + } + } + + return null; + } + + // The request body's schema pointer (the key into contextSourceBodyPointers), picked by the same rule that picks its + // type name so the two always describe the same content entry. A 2.0 body arrives as an `in: "body"` parameter, or + // as aggregated `formData` fields, but by this point both are normalised into the same RequestBodyInfo the 3.x + // generators use, so the rule is identical. + private static string? ResolveRequestBodySchemaPointer(RequestBodyInfo requestBody) + { + foreach (ContentInfo content in requestBody.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType) + || CodeEmitHelpers.IsFormUrlEncodedMediaType(content.MediaType) + || CodeEmitHelpers.IsMultipartMediaType(content.MediaType)) + { + return content.SchemaPointer; + } + } + + return null; + } + + // Whether the operation can also be offered as a closure-free, single-materialisation generic overload. + private bool HasContextThreadedBody(OperationInfo op) + { + if (op.RequestBody is not { } requestBody || IsRawStreamRequestBody(requestBody)) + { + return false; + } + + return ResolveRequestBodySchemaPointer(requestBody) is { } pointer + && this.contextSourceBodyPointers.Contains(pointer); + } + private string ResolveRequestBodyTypeName(RequestBodyInfo requestBody) { foreach (ContentInfo content in requestBody.Content) @@ -2062,6 +2115,21 @@ private static bool IsMultipartRequestBody(RequestBodyInfo requestBody) /// /// Returns the distinct content categories present in a response's content entries. /// + // The JSON-ish media type the response declares for its body, picked by the same rule that picked the body's + // schema so the two always describe the same content entry. + private static string DeclaredJsonMediaType(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType)) + { + return content.MediaType; + } + } + + return "application/json"; + } + private static ContentCategory[] GetDistinctContentCategories(ResponseInfo resp) { return resp.Content @@ -3415,6 +3483,12 @@ private GeneratedFile EmitInterface( } this.EmitInterfaceMethodSignature(w, operations[i]); + + if (this.HasContextThreadedBody(operations[i])) + { + w.WriteLine(); + this.EmitInterfaceMethodSignature(w, operations[i], contextThreaded: true); + } } w.CloseBrace(); @@ -3436,7 +3510,7 @@ private static void EmitCreateServerUri(IndentedWriter w, ServerInfo serverInfo) w.WriteLine(); } - private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op) + private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op, bool contextThreaded = false) { string responseName = $"{op.MethodName}Response"; @@ -3447,9 +3521,25 @@ private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op) w.WriteLine("[Obsolete(\"This operation is deprecated.\")]"); } - List paramParts = this.BuildParameterList(op); + List paramParts = this.BuildParameterList(op, contextThreaded); + string generic = contextThreaded ? "" : string.Empty; w.WriteLine( - $"ValueTask<{responseName}> {op.MethodName}Async({string.Join(", ", paramParts)});"); + $"ValueTask<{responseName}> {op.MethodName}Async{generic}({string.Join(", ", paramParts)})" + + (contextThreaded ? string.Empty : ";")); + + if (contextThreaded) + { + EmitContextConstraint(w); + w.WriteLine(";"); + } + } + + // The constraint that lets a caller thread a ref struct through as context, guarded because it is a C# 13 feature. + private static void EmitContextConstraint(IndentedWriter w) + { + w.WriteLine("#if NET9_0_OR_GREATER"); + w.WriteLine(" where TContext : allows ref struct"); + w.WriteLine("#endif"); } // ── Implementation emission ───────────────────────────────────────── @@ -3492,6 +3582,13 @@ private GeneratedFile EmitImplementation( { w.WriteLine(); this.EmitClientMethod(w, operations[i], encodingFieldNames); + + // A body whose type carries a Source also gets the closure-free, single-materialisation form. + if (this.HasContextThreadedBody(operations[i])) + { + w.WriteLine(); + this.EmitClientMethod(w, operations[i], encodingFieldNames, contextThreaded: true); + } } w.WriteLine(); @@ -3537,7 +3634,7 @@ private GeneratedFile EmitImplementation( return new GeneratedFile($"{clientName}Client.cs", w.ToString()); } - private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary encodingFieldNames) + private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary encodingFieldNames, bool contextThreaded = false) { string requestName = $"{op.MethodName}Request"; string responseName = $"{op.MethodName}Response"; @@ -3549,12 +3646,17 @@ private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary paramParts = this.BuildParameterList(op); + List paramParts = this.BuildParameterList(op, contextThreaded); w.WriteLine( - $"public ValueTask<{responseName}> {op.MethodName}Async(" + + $"public ValueTask<{responseName}> {op.MethodName}Async{(contextThreaded ? "" : string.Empty)}(" + $"{string.Join(", ", paramParts)})"); + if (contextThreaded) + { + EmitContextConstraint(w); + } + w.OpenBrace(); bool hasParams = op.Parameters.Length > 0; @@ -3572,7 +3674,7 @@ private void EmitClientMethod(IndentedWriter w, OperationInfo op, DictionaryA cancellation token."); } - private List BuildParameterList(OperationInfo op) + private List BuildParameterList(OperationInfo op, bool contextThreaded = false) { List paramParts = []; @@ -3913,7 +4015,8 @@ private List BuildParameterList(OperationInfo op) { string bodyTypeName = this.ResolveRequestBodyTypeName(op.RequestBody.Value); string suffix = bodyRequired ? string.Empty : " = default"; - paramParts.Add($"{bodyTypeName}.Source body{suffix}"); + string sourceType = contextThreaded ? "Source" : "Source"; + paramParts.Add($"{bodyTypeName}.{sourceType} body{suffix}"); } } @@ -4286,7 +4389,7 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine("/// Creates a default error result."); w.WriteLine("/// "); - this.EmitServerResultFactory(w, structName, factoryName, typeName, respHeaders, resp.StatusCode, hasHeaders); + this.EmitServerResultFactory(w, structName, factoryName, typeName, resp, respHeaders, resp.StatusCode, hasHeaders); } else { @@ -4295,7 +4398,7 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine($"/// {CodeEmitHelpers.EscapeXml(desc)}"); w.WriteLine("/// "); - this.EmitServerResultFactory(w, structName, factoryName, typeName, respHeaders, resp.StatusCode, hasHeaders); + this.EmitServerResultFactory(w, structName, factoryName, typeName, resp, respHeaders, resp.StatusCode, hasHeaders); } } @@ -4393,6 +4496,7 @@ private void EmitServerResultFactory( string structName, string factoryName, string? bodyTypeName, + ResponseInfo response, List<(HeaderInfo Header, string TypeName, string FieldName, string PropertyName)> respHeaders, string statusCode, bool structHasHeaders) @@ -4400,6 +4504,11 @@ private void EmitServerResultFactory( bool isDefault = statusCode == "default"; bool hasBody = bodyTypeName is not null; + // The body carries a Source (object/array type) iff its schema pointer is in the context-source set. + bool bodyHasContextSource = hasBody + && ResolveResponseBodySchemaPointer(response) is { } bodySchemaPointer + && this.contextSourceBodyPointers.Contains(bodySchemaPointer); + StringBuilder paramList = new(); if (isDefault) { @@ -4461,7 +4570,11 @@ private void EmitServerResultFactory( string bodyExpr = hasBody ? $"{bodyTypeName}.CreateBuilder(workspace, body, 30).RootElement" : "default"; - string contentTypeExpr = hasBody ? "\"application/json\"" : "null"; + + // The media type the response actually declares, not a literal "application/json". A specification declaring + // RFC 9457 problem documents means it: answering them as application/json tells a client the body is an + // ordinary result, and a client branching on the media type to find the problem shape never sees one. + string contentTypeExpr = hasBody ? $"\"{DeclaredJsonMediaType(response)}\"" : "null"; if (structHasHeaders) { @@ -4478,6 +4591,56 @@ private void EmitServerResultFactory( { w.WriteLine($"public static {structName} {factoryName}({paramList}) => new({statusExpr}, {bodyExpr}, {contentTypeExpr});"); } + + // Closure-free, single-materialisation sibling: takes the response body already assembled as a context-threaded + // Source and routes it straight through CreateBuilder — no per-item closure on the caller + // side and no re-materialisation here. Only for object/array bodies; a scalar body has no Source. + if (!bodyHasContextSource) + { + return; + } + + string genericParamList = paramList.ToString().Replace($"{bodyTypeName}.Source body", $"{bodyTypeName}.Source body"); + string genericBodyExpr = $"{bodyTypeName}.CreateBuilder(workspace, in body, 30).RootElement"; + + w.WriteLine("/// "); + w.WriteLine($"/// Creates a {statusCode} {factoryName} result from a context-threaded body, materialised in a single pass."); + w.WriteLine("/// "); + w.WriteLine("/// The type of the context carried by the body."); + if (isDefault) + { + w.WriteLine("/// The HTTP status code."); + } + + w.WriteLine("/// The context-threaded response body."); + w.WriteLine("/// The workspace for building the response value."); + foreach (var (header, _, fieldName, _) in respHeaders) + { + w.WriteLine($"/// The value for the {header.HeaderName} response header."); + } + + w.WriteLine($"/// A with status {statusCode}."); + + string genericCtorArgs; + if (structHasHeaders) + { + StringBuilder genericArgs = new(); + genericArgs.Append($"{statusExpr}, {genericBodyExpr}, {contentTypeExpr}"); + foreach (var (_, typeName, fieldName, _) in respHeaders) + { + genericArgs.Append($", {fieldName}: {fieldName}.IsUndefined ? default : {typeName}.CreateBuilder(workspace, {fieldName}, 30).RootElement"); + } + + genericCtorArgs = genericArgs.ToString(); + } + else + { + genericCtorArgs = $"{statusExpr}, {genericBodyExpr}, {contentTypeExpr}"; + } + + w.WriteLine($"public static {structName} {factoryName}({genericParamList})"); + EmitContextConstraint(w); + w.WriteLine($" => new({genericCtorArgs});"); } private GeneratedFile EmitServerEndpointRegistration( diff --git a/src/Corvus.Text.Json.OpenApi30/OpenApi30CodeGenerator.cs b/src/Corvus.Text.Json.OpenApi30/OpenApi30CodeGenerator.cs index c5146e091fb..ef7f661d6f5 100644 --- a/src/Corvus.Text.Json.OpenApi30/OpenApi30CodeGenerator.cs +++ b/src/Corvus.Text.Json.OpenApi30/OpenApi30CodeGenerator.cs @@ -36,6 +36,7 @@ public sealed class OpenApi30CodeGenerator private readonly string? clientNamePrefix; private readonly bool ignoreEmptyFormUrlEncodedBody; private readonly IReadOnlyDictionary schemaTypeMap; + private readonly IReadOnlySet contextSourceBodyPointers; /// /// Initializes a new instance of the class. @@ -54,16 +55,25 @@ public sealed class OpenApi30CodeGenerator /// When , form-urlencoded request bodies whose schema defines /// no properties are treated as if the body were absent. /// + /// + /// The subset of pointers whose type is an object or array — i.e. types for + /// which the model generator emits a context-threaded Source<TContext>. A server result factory emits + /// a closure-free, single-materialisation Ok<TContext> overload only for a response body in this set; + /// a scalar body (no Source<TContext>) falls back to the non-generic factory. When + /// no generic overloads are emitted (the conservative default for client/model generation). + /// public OpenApi30CodeGenerator( string rootNamespace, IReadOnlyDictionary schemaTypeMap, string? clientNamePrefix = null, - bool ignoreEmptyFormUrlEncodedBody = false) + bool ignoreEmptyFormUrlEncodedBody = false, + IReadOnlySet? contextSourceBodyPointers = null) { this.rootNamespace = rootNamespace; this.schemaTypeMap = schemaTypeMap; this.clientNamePrefix = clientNamePrefix; this.ignoreEmptyFormUrlEncodedBody = ignoreEmptyFormUrlEncodedBody; + this.contextSourceBodyPointers = contextSourceBodyPointers ?? new HashSet(StringComparer.Ordinal); } // ── Walk-phase reference (typed model objects, no strings extracted) ── @@ -296,6 +306,8 @@ public static OperationSummary[] ListOperations( bool hasBody = opRef.Operation.RequestBody.IsNotUndefined(); + string methodName = GetMethodName(operationId, opRef.Method, path); + result.Add(new OperationSummary( path, opRef.Method, @@ -304,7 +316,10 @@ public static OperationSummary[] ListOperations( isDeprecated, paramCount, hasBody, - summary)); + summary, + methodName, + GeneratedClientTypeNaming.RequestTypeName(methodName), + GeneratedClientTypeNaming.ResponseTypeName(methodName))); } return [.. result]; @@ -409,6 +424,111 @@ public IReadOnlyList Generate( return files; } + /// + /// Describes every operation for downstream generators: its identity, the generated + /// request/response type names, and the request parameters — the authoritative mapping the + /// generator emits, so callers never re-derive the naming or type convention. + /// + /// The root element of the parsed spec document. + /// Optional operation filter. + /// + /// Optional reference resolver. If , a is used. + /// + /// The operation descriptors. + public IReadOnlyList DescribeOperations( + JsonElement specRoot, + OperationFilter? filter = null, + IOpenApiReferenceResolver? referenceResolver = null) + { + referenceResolver ??= new LocalReferenceResolver(specRoot); + ServerInfo? rootServer = GetDefaultServerInfo(specRoot); + List result = []; + + foreach (OperationRef opRef in WalkOperationRefs(specRoot, filter, referenceResolver)) + { + OperationInfo op = PrepareOperation(opRef, referenceResolver, rootServer, specRoot); + + var parameters = new RequestParameterInfo[op.Parameters.Length]; + for (int i = 0; i < op.Parameters.Length; i++) + { + ParameterInfo parameter = op.Parameters[i]; + parameters[i] = new RequestParameterInfo( + parameter.Name, + parameter.Location, + CodeEmitHelpers.SanitizeIdentifier(parameter.Name), + this.GetParameterTypeName(parameter), + parameter.IsRequired, + CodeEmitHelpers.EscapeCSharpKeyword(CodeEmitHelpers.SanitizeParameterName(parameter.Name))); + } + + var responses = new ResponseDescriptor[op.Responses.Length]; + for (int r = 0; r < op.Responses.Length; r++) + { + ResponseInfo response = op.Responses[r]; + string? bodyTypeName = this.ResolveResponseTypeName(response); + responses[r] = new ResponseDescriptor( + response.StatusCode, + bodyTypeName, + bodyTypeName is null ? null : CodeEmitHelpers.ResponseBodyPropertyName(response.StatusCode)); + } + + ResponseHeaderInfo[] responseHeaders = this.DescribeResponseHeaders(op.Responses); + + string clientTag = op.Tags.Length > 0 ? op.Tags[0] : "default"; + string? requestBodyTypeName = op.RequestBody is { } rb && !IsRawStreamRequestBody(rb) + ? this.ResolveRequestBodyTypeName(rb) + : null; + + result.Add(new OperationDescriptor( + op.PathTemplate, + op.Method, + op.OperationId, + op.MethodName, + $"{this.rootNamespace}.{GeneratedClientTypeNaming.RequestTypeName(op.MethodName)}", + $"{this.rootNamespace}.{GeneratedClientTypeNaming.ResponseTypeName(op.MethodName)}", + parameters, + op.RequestBody is not null, + responses, + $"{this.rootNamespace}.{this.GetClientName(clientTag)}Client", + $"{op.MethodName}Async", + requestBodyTypeName, + responseHeaders)); + } + + return result; + } + + /// + /// Describes the response headers an operation declares — mirroring 's + /// naming and typing so a caller can resolve $response.header.<name> against the generated + /// response property. Deduplicated by generated property name across all responses. + /// + /// The operation's responses. + /// The described response headers. + private ResponseHeaderInfo[] DescribeResponseHeaders(ResponseInfo[] responses) + { + var headers = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (ResponseInfo response in responses) + { + foreach (HeaderInfo header in response.Headers) + { + string propertyName = CodeEmitHelpers.HeaderNameToPropertyName(header.HeaderName) + "Header"; + if (!seen.Add(propertyName)) + { + continue; + } + + bool isString = header.SchemaPointer is null; + string typeName = isString ? "string" : this.ResolveSchemaTypeName(header.SchemaPointer); + headers.Add(new ResponseHeaderInfo(header.HeaderName, propertyName, typeName, isString)); + } + } + + return [.. headers]; + } + // ═══════════════════════════════════════════════════════════════════ // Schema pointer collection — walks typed model directly // ═══════════════════════════════════════════════════════════════════ @@ -1271,11 +1391,11 @@ private OperationInfo PrepareOperation( string methodName = GetMethodName(operationId, opRef.Method, pathTemplate); ParameterInfo[] parameters = PrepareParameters( - opRef.Operation, opRef.PathItem, pathNameUtf8, opRef.Method, referenceResolver); + opRef.Operation, opRef.PathItem, pathNameUtf8, opRef.Method, referenceResolver, specRoot); RequestBodyInfo? requestBody = PrepareRequestBody( opRef.Operation, pathNameUtf8, opRef.Method, referenceResolver, this.ignoreEmptyFormUrlEncodedBody); ResponseInfo[] responses = PrepareResponses( - opRef.Operation, pathNameUtf8, opRef.Method, referenceResolver); + opRef.Operation, pathNameUtf8, opRef.Method, referenceResolver, specRoot); ServerInfo? effectiveServer = ResolveEffectiveServer( opRef.Operation, opRef.PathItem, rootServer); @@ -1414,7 +1534,8 @@ private static ParameterInfo[] PrepareParameters( OpenApiDocument.PathItem pathItem, ReadOnlySpan pathNameUtf8, OperationMethod method, - IOpenApiReferenceResolver referenceResolver) + IOpenApiReferenceResolver referenceResolver, + JsonElement specRoot) { var merged = MergeParameters(operation, pathItem, referenceResolver); @@ -1434,19 +1555,19 @@ private static ParameterInfo[] PrepareParameters( bool hasSchema = param.Schema.IsNotUndefined(); JsonElement schemaElement = hasSchema ? JsonElement.From(param.Schema) : default; ParameterSerializationKind serializationKind = hasSchema - ? SchemaClassifier.Classify(schemaElement) + ? SchemaClassifier.Classify(schemaElement, specRoot) : ParameterSerializationKind.String; ParameterSerializationKind elementKind = serializationKind switch { - ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaElement), - ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaElement), + ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaElement, specRoot), + ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaElement, specRoot), _ => ParameterSerializationKind.String, }; bool deepNesting = hasSchema && serializationKind is ParameterSerializationKind.Object or ParameterSerializationKind.Array - && SchemaClassifier.HasDeepNesting(schemaElement); + && SchemaClassifier.HasDeepNesting(schemaElement, specRoot); string? schemaPointer = hasSchema ? SchemaPointerBuilder.BuildParameterSchemaPointer( @@ -1584,7 +1705,8 @@ private static ResponseInfo[] PrepareResponses( OpenApiDocument.Operation operation, ReadOnlySpan pathNameUtf8, OperationMethod method, - IOpenApiReferenceResolver referenceResolver) + IOpenApiReferenceResolver referenceResolver, + JsonElement specRoot) { if (operation.ResponsesValue.IsUndefined()) { @@ -1621,7 +1743,7 @@ private static ResponseInfo[] PrepareResponses( response.Content, pathNameUtf8, method, statusCodeUtf8.Span); HeaderInfo[] headers = PrepareResponseHeaders( - response.Headers, pathNameUtf8, method, statusCodeUtf8.Span, referenceResolver); + response.Headers, pathNameUtf8, method, statusCodeUtf8.Span, referenceResolver, specRoot); LinkInfo[] links = PrepareLinks(response.Links, referenceResolver, statusCode); @@ -1665,6 +1787,21 @@ private static ContentInfo[] PrepareContentEntries( return [.. result]; } + // The JSON-ish media type the response declares for its body, picked by the same rule that picked the body's + // schema so the two always describe the same content entry. + private static string DeclaredJsonMediaType(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType)) + { + return content.MediaType; + } + } + + return "application/json"; + } + private static ContentInfo[] PrepareResponseContentEntries( OpenApiDocument.Response.ContentEntity contentMap, ReadOnlySpan pathNameUtf8, @@ -1740,7 +1877,8 @@ private static HeaderInfo[] PrepareResponseHeaders( ReadOnlySpan pathNameUtf8, OperationMethod method, ReadOnlySpan statusCodeUtf8, - IOpenApiReferenceResolver referenceResolver) + IOpenApiReferenceResolver referenceResolver, + JsonElement specRoot) { if (headersMap.IsUndefined()) { @@ -1784,19 +1922,19 @@ private static HeaderInfo[] PrepareResponseHeaders( JsonElement schemaEl = hasSchema ? JsonElement.From(header.Schema) : default; ParameterSerializationKind serializationKind = hasSchema - ? SchemaClassifier.Classify(schemaEl) + ? SchemaClassifier.Classify(schemaEl, specRoot) : ParameterSerializationKind.String; ParameterSerializationKind elementKind = serializationKind switch { - ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaEl), - ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaEl), + ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaEl, specRoot), + ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaEl, specRoot), _ => ParameterSerializationKind.String, }; bool deepNesting = hasSchema && serializationKind is ParameterSerializationKind.Object or ParameterSerializationKind.Array - && SchemaClassifier.HasDeepNesting(schemaEl); + && SchemaClassifier.HasDeepNesting(schemaEl, specRoot); // Extract header name at the emit boundary string name = headerProp.Name; @@ -2112,6 +2250,37 @@ private string ResolveSchemaTypeName(string? schemaPointer) return "JsonElement"; } + // The request body's schema pointer (the key into contextSourceBodyPointers), picked by the same rule that picks its + // type name so the two always describe the same content entry. + private static string? ResolveRequestBodySchemaPointer(RequestBodyInfo requestBody) + { + foreach (ContentInfo content in requestBody.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType) + || CodeEmitHelpers.IsFormUrlEncodedMediaType(content.MediaType) + || CodeEmitHelpers.IsMultipartMediaType(content.MediaType)) + { + return content.SchemaPointer; + } + } + + return null; + } + + // Whether the operation can also be offered as a closure-free, single-materialisation generic overload: it must + // have a body that is materialised from a Source (not a raw stream), and that body's type must be one the model + // generator gives a Source. + private bool HasContextThreadedBody(OperationInfo op) + { + if (op.RequestBody is not { } requestBody || IsRawStreamRequestBody(requestBody)) + { + return false; + } + + return ResolveRequestBodySchemaPointer(requestBody) is { } pointer + && this.contextSourceBodyPointers.Contains(pointer); + } + private string ResolveRequestBodyTypeName(RequestBodyInfo requestBody) { foreach (ContentInfo content in requestBody.Content) @@ -2248,6 +2417,38 @@ private static bool IsMultipartRequestBody(RequestBodyInfo requestBody) return null; } + // The JSON response body's schema pointer (the key into contextSourceBodyPointers) — used to decide whether the body + // type carries a Source, and so whether a closure-free Ok overload can be emitted for it. + private string? ResolveResponseBodySchemaPointer(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType)) + { + return content.SchemaPointer; + } + } + + return null; + } + + /// + /// Returns if the response's content is classified as + /// (raw binary). + /// + private static bool IsOctetStreamResponse(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.ClassifyMediaType(content.MediaType) == ContentCategory.OctetStream) + { + return true; + } + } + + return false; + } + /// /// Returns the distinct content categories present in a response's content entries. /// @@ -2262,7 +2463,7 @@ private static ContentCategory[] GetDistinctContentCategories(ResponseInfo resp) // ── Request struct emission ───────────────────────────────────────── private GeneratedFile EmitRequestStruct(OperationInfo op) { - string structName = $"{op.MethodName}Request"; + string structName = $"{op.MethodName}{GeneratedClientTypeNaming.RequestSuffix}"; IndentedWriter w = new(); CodeEmitHelpers.EmitHeader(w); @@ -2801,7 +3002,7 @@ private static void EmitBodyValidation( // ── Response struct emission ──────────────────────────────────────── private GeneratedFile EmitResponseStruct(OperationInfo op, List allOperations) { - string structName = $"{op.MethodName}Response"; + string structName = $"{op.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; IndentedWriter w = new(); CodeEmitHelpers.EmitHeader(w); @@ -3106,8 +3307,8 @@ private void EmitLinkMethod( } OperationInfo target = targetOp.Value; - string targetResponseType = $"{target.MethodName}Response"; - string targetRequestType = $"{target.MethodName}Request"; + string targetResponseType = $"{target.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; + string targetRequestType = $"{target.MethodName}{GeneratedClientTypeNaming.RequestSuffix}"; // Determine which target parameters are NOT satisfied by link bindings. HashSet boundParams = new(StringComparer.OrdinalIgnoreCase); @@ -4003,6 +4204,12 @@ private GeneratedFile EmitInterface( } this.EmitInterfaceMethodSignature(w, operations[i]); + + if (this.HasContextThreadedBody(operations[i])) + { + w.WriteLine(); + this.EmitInterfaceMethodSignature(w, operations[i], contextThreaded: true); + } } w.CloseBrace(); @@ -4078,9 +4285,9 @@ private static void EmitCreateServerUri(IndentedWriter w, ServerInfo serverInfo) w.WriteLine(); } - private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op) + private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op, bool contextThreaded = false) { - string responseName = $"{op.MethodName}Response"; + string responseName = $"{op.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; EmitMethodDoc(w, op); @@ -4089,9 +4296,25 @@ private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op) w.WriteLine("[Obsolete(\"This operation is deprecated.\")]"); } - List paramParts = this.BuildParameterList(op); + List paramParts = this.BuildParameterList(op, contextThreaded); + string generic = contextThreaded ? "" : string.Empty; w.WriteLine( - $"ValueTask<{responseName}> {op.MethodName}Async({string.Join(", ", paramParts)});"); + $"ValueTask<{responseName}> {op.MethodName}Async{generic}({string.Join(", ", paramParts)})" + + (contextThreaded ? string.Empty : ";")); + + if (contextThreaded) + { + EmitContextConstraint(w); + w.WriteLine(";"); + } + } + + // The constraint that lets a caller thread a ref struct through as context, guarded because it is a C# 13 feature. + private static void EmitContextConstraint(IndentedWriter w) + { + w.WriteLine("#if NET9_0_OR_GREATER"); + w.WriteLine(" where TContext : allows ref struct"); + w.WriteLine("#endif"); } // ── Implementation emission ───────────────────────────────────────── @@ -4134,6 +4357,14 @@ private GeneratedFile EmitImplementation( { w.WriteLine(); this.EmitClientMethod(w, operations[i], encodingFieldNames); + + // A body whose type carries a Source also gets the closure-free, single-materialisation form, + // matching what a server result factory already offers for a response body. + if (this.HasContextThreadedBody(operations[i])) + { + w.WriteLine(); + this.EmitClientMethod(w, operations[i], encodingFieldNames, contextThreaded: true); + } } w.WriteLine(); @@ -4164,6 +4395,12 @@ private GeneratedFile EmitImplementation( else if (hasBody) { needsSendWithBody = true; + + // An optional JSON body also emits a bodyless SendAsyncCore path for when the caller omits it. + if (!op.RequestBody!.Value.IsRequired && !HasRequestBasedExpressions(op)) + { + needsSendAsync = true; + } } else { @@ -4179,10 +4416,10 @@ private GeneratedFile EmitImplementation( return new GeneratedFile($"{clientName}Client.cs", w.ToString()); } - private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary encodingFieldNames) + private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary encodingFieldNames, bool contextThreaded = false) { - string requestName = $"{op.MethodName}Request"; - string responseName = $"{op.MethodName}Response"; + string requestName = $"{op.MethodName}{GeneratedClientTypeNaming.RequestSuffix}"; + string responseName = $"{op.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; EmitMethodDoc(w, op); @@ -4191,12 +4428,17 @@ private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary paramParts = this.BuildParameterList(op); + List paramParts = this.BuildParameterList(op, contextThreaded); w.WriteLine( - $"public ValueTask<{responseName}> {op.MethodName}Async(" + + $"public ValueTask<{responseName}> {op.MethodName}Async{(contextThreaded ? "" : string.Empty)}(" + $"{string.Join(", ", paramParts)})"); + if (contextThreaded) + { + EmitContextConstraint(w); + } + w.OpenBrace(); bool hasParams = op.Parameters.Length > 0; @@ -4209,6 +4451,14 @@ private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary(JsonWorkspace.CreateUnrented(), request, bodyValue, responseValidationMode, cancellationToken), request, {(hasRequestBodyExprLinks ? "bodyValue, " : "")}workspace);"); } + else if (optionalJsonBody) + { + // The caller supplied a body → send it; otherwise send the request bodyless (the server's + // optional-body path). This is the client counterpart to the optional request-body server fix. + w.WriteLine("if (hasBodyValue)"); + w.OpenBrace(); + w.WriteLine( + $"return SendWithBodyAsyncCore<{requestName}, {bodyTypeName}, " + + $"{responseName}>(workspace, request, bodyValue, responseValidationMode, cancellationToken);"); + w.CloseBrace(); + w.WriteLine(); + w.WriteLine( + $"return SendAsyncCore<{requestName}, " + + $"{responseName}>(workspace, request, responseValidationMode, cancellationToken);"); + } else { w.WriteLine( @@ -4640,7 +4926,7 @@ private static void EmitMethodDoc(IndentedWriter w, OperationInfo op) w.WriteLine("/// A cancellation token."); } - private List BuildParameterList(OperationInfo op) + private List BuildParameterList(OperationInfo op, bool contextThreaded = false) { List paramParts = []; @@ -4668,7 +4954,8 @@ private List BuildParameterList(OperationInfo op) { string bodyTypeName = this.ResolveRequestBodyTypeName(op.RequestBody.Value); string suffix = bodyRequired ? string.Empty : " = default"; - paramParts.Add($"{bodyTypeName}.Source body{suffix}"); + string sourceType = contextThreaded ? "Source" : "Source"; + paramParts.Add($"{bodyTypeName}.{sourceType} body{suffix}"); } } @@ -5046,6 +5333,8 @@ public static OperationSummary[] ListWebhookAndCallbackOperations( bool hasBody = opRef.Operation.RequestBody.IsNotUndefined(); + string methodName = GetMethodName(operationId, opRef.Method, path); + result.Add(new OperationSummary( path, opRef.Method, @@ -5054,7 +5343,10 @@ public static OperationSummary[] ListWebhookAndCallbackOperations( isDeprecated, paramCount, hasBody, - summary)); + summary, + methodName, + GeneratedClientTypeNaming.RequestTypeName(methodName), + GeneratedClientTypeNaming.ResponseTypeName(methodName))); } return [.. result]; @@ -5166,6 +5458,20 @@ private GeneratedFile EmitServerOperationParams(OperationInfo op) w.WriteLine($"/// {CodeEmitHelpers.EscapeXml(bodyDesc)}"); w.WriteLine("/// "); w.WriteLine($"public {bodyTypeName} Body {{ get; init; }}"); + + // For multipart/form-data bodies with format:binary parts, expose each binary part + // as a separate ReadOnlyMemory property so the handler can read the raw bytes. + if (IsMultipartRequestBody(rb)) + { + foreach (BinaryPropertyInfo binaryProp in rb.BinaryProperties) + { + w.WriteLine(); + w.WriteLine("/// "); + w.WriteLine($"/// Gets the binary content of the '{binaryProp.PropertyName}' part."); + w.WriteLine("/// "); + w.WriteLine($"public ReadOnlyMemory {CodeEmitHelpers.ToPascalCase(binaryProp.PropertyName)} {{ get; init; }}"); + } + } } w.CloseBrace(); @@ -5178,7 +5484,24 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) string structName = $"{op.MethodName}Result"; IndentedWriter w = new(); + // Detect a 2xx octet-stream success response: the server must be able to write raw bytes. + // Computed before the header so the extra usings the binary writer needs can be emitted + // before the file-scoped namespace declaration. + bool hasOctetStreamResponse = op.Responses.Any(r => + r.StatusCode.Length == 3 && r.StatusCode[0] == '2' && IsOctetStreamResponse(r)); + CodeEmitHelpers.EmitHeader(w); + if (hasOctetStreamResponse) + { + // The binary-body writer uses Func; the shared + // header does not emit these usings, so add them here (this file only). + w.WriteLine("using System;"); + w.WriteLine("using System.IO;"); + w.WriteLine("using System.Threading;"); + w.WriteLine("using System.Threading.Tasks;"); + w.WriteLine(); + } + w.WriteLine($"namespace {this.rootNamespace};"); w.WriteLine(); @@ -5216,6 +5539,11 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) if (hasHeaders) { w.Write($"private {structName}(int statusCode, JsonElement body, string? contentType"); + if (hasOctetStreamResponse) + { + w.Write(", bool hasBinaryBody, Func? binaryWriter"); + } + foreach (var (_, typeName, fieldName, _) in allHeaders) { w.Write($", {typeName} {fieldName} = default"); @@ -5226,6 +5554,12 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine("this.StatusCode = statusCode;"); w.WriteLine("this.Body = body;"); w.WriteLine("this.ContentType = contentType;"); + if (hasOctetStreamResponse) + { + w.WriteLine("this.HasBinaryBody = hasBinaryBody;"); + w.WriteLine("this.binaryWriter = binaryWriter;"); + } + foreach (var (_, _, fieldName, propertyName) in allHeaders) { w.WriteLine($"this.{propertyName} = {fieldName};"); @@ -5233,6 +5567,17 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.CloseBrace(); } + else if (hasOctetStreamResponse) + { + w.WriteLine($"private {structName}(int statusCode, JsonElement body, string? contentType, bool hasBinaryBody, Func? binaryWriter)"); + w.OpenBrace(); + w.WriteLine("this.StatusCode = statusCode;"); + w.WriteLine("this.Body = body;"); + w.WriteLine("this.ContentType = contentType;"); + w.WriteLine("this.HasBinaryBody = hasBinaryBody;"); + w.WriteLine("this.binaryWriter = binaryWriter;"); + w.CloseBrace(); + } else { w.WriteLine($"private {structName}(int statusCode, JsonElement body = default, string? contentType = null)"); @@ -5243,6 +5588,12 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.CloseBrace(); } + if (hasOctetStreamResponse) + { + w.WriteLine(); + w.WriteLine("private readonly Func? binaryWriter;"); + } + w.WriteLine(); w.WriteLine("/// Gets the HTTP status code."); w.WriteLine("public int StatusCode { get; }"); @@ -5253,6 +5604,21 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine("/// Gets the content type for the response body."); w.WriteLine("public string? ContentType { get; }"); + if (hasOctetStreamResponse) + { + w.WriteLine(); + w.WriteLine("/// Gets a value indicating whether the result carries a raw binary body."); + w.WriteLine("public bool HasBinaryBody { get; }"); + w.WriteLine(); + w.WriteLine("/// "); + w.WriteLine("/// Writes the raw binary response body to the supplied stream."); + w.WriteLine("/// "); + w.WriteLine("/// The stream to write the body to."); + w.WriteLine("/// A token used to cancel the operation."); + w.WriteLine("/// A that completes when the body has been written."); + w.WriteLine("public ValueTask WriteBinaryBodyAsync(Stream stream, CancellationToken cancellationToken) => this.binaryWriter is { } writer ? writer(stream, cancellationToken) : ValueTask.CompletedTask;"); + } + // Header properties foreach (var (header, typeName, _, propertyName) in allHeaders) { @@ -5269,6 +5635,32 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) string factoryName = CodeEmitHelpers.StatusCodeToName(resp.StatusCode); string? typeName = this.ResolveResponseTypeName(resp); + // For an octet-stream success response, emit a factory that takes raw bytes instead + // of the default no-arg/JSON factory, so the handler can return binary content. + bool isOctetStreamSuccess = resp.StatusCode.Length == 3 + && resp.StatusCode[0] == '2' + && IsOctetStreamResponse(resp); + if (isOctetStreamSuccess) + { + w.WriteLine(); + w.WriteLine("/// "); + w.WriteLine($"/// Creates a {resp.StatusCode} {factoryName} result with a raw binary body."); + w.WriteLine("/// "); + w.WriteLine("/// The raw binary response body."); + w.WriteLine("/// The content type for the response body."); + w.WriteLine($"/// A with status {resp.StatusCode}."); + w.WriteLine($"public static {structName} {factoryName}(ReadOnlyMemory body, string? contentType = \"application/octet-stream\") => new({resp.StatusCode}, default, contentType, hasBinaryBody: true, binaryWriter: (stream, cancellationToken) => stream.WriteAsync(body, cancellationToken));"); + w.WriteLine(); + w.WriteLine("/// "); + w.WriteLine($"/// Creates a {resp.StatusCode} {factoryName} result that streams a raw binary body."); + w.WriteLine("/// "); + w.WriteLine("/// A callback that writes the raw binary response body to the response stream."); + w.WriteLine("/// The content type for the response body."); + w.WriteLine($"/// A with status {resp.StatusCode}."); + w.WriteLine($"public static {structName} {factoryName}(Func writeBody, string? contentType = \"application/octet-stream\") => new({resp.StatusCode}, default, contentType, hasBinaryBody: true, binaryWriter: writeBody);"); + continue; + } + List<(HeaderInfo Header, string TypeName, string FieldName, string PropertyName)> respHeaders = []; foreach (HeaderInfo header in resp.Headers) { @@ -5284,7 +5676,7 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine("/// Creates a default error result."); w.WriteLine("/// "); - this.EmitServerResultFactory(w, structName, factoryName, typeName, respHeaders, resp.StatusCode, hasHeaders); + this.EmitServerResultFactory(w, structName, factoryName, typeName, resp, respHeaders, resp.StatusCode, hasHeaders, hasOctetStreamResponse); } else { @@ -5293,7 +5685,7 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine($"/// {CodeEmitHelpers.EscapeXml(desc)}"); w.WriteLine("/// "); - this.EmitServerResultFactory(w, structName, factoryName, typeName, respHeaders, resp.StatusCode, hasHeaders); + this.EmitServerResultFactory(w, structName, factoryName, typeName, resp, respHeaders, resp.StatusCode, hasHeaders, hasOctetStreamResponse); } } @@ -5391,13 +5783,21 @@ private void EmitServerResultFactory( string structName, string factoryName, string? bodyTypeName, + ResponseInfo response, List<(HeaderInfo Header, string TypeName, string FieldName, string PropertyName)> respHeaders, string statusCode, - bool structHasHeaders) + bool structHasHeaders, + bool structHasOctetStreamResponse) { bool isDefault = statusCode == "default"; bool hasBody = bodyTypeName is not null; + // The body carries a Source (object/array type) iff its schema pointer is in the context-source set; + // only then can a closure-free, single-materialisation generic overload be emitted alongside the non-generic one. + bool bodyHasContextSource = hasBody + && this.ResolveResponseBodySchemaPointer(response) is { } bodySchemaPointer + && this.contextSourceBodyPointers.Contains(bodySchemaPointer); + StringBuilder paramList = new(); if (isDefault) { @@ -5459,12 +5859,21 @@ private void EmitServerResultFactory( string bodyExpr = hasBody ? $"{bodyTypeName}.CreateBuilder(workspace, body, 30).RootElement" : "default"; - string contentTypeExpr = hasBody ? "\"application/json\"" : "null"; + + // The media type the response actually declares, not a literal "application/json". A specification declaring + // RFC 9457 problem documents means it: answering them as application/json tells a client the body is an + // ordinary result, and a client branching on the media type to find the problem shape never sees one. + string contentTypeExpr = hasBody ? $"\"{DeclaredJsonMediaType(response)}\"" : "null"; + + // When the struct exposes an octet-stream binary body, the private constructor has two + // extra required parameters (hasBinaryBody, binaryWriter) immediately after contentType. + // Non-binary factories pass false/null for those. + string binaryArgs = structHasOctetStreamResponse ? ", false, null" : string.Empty; if (structHasHeaders) { StringBuilder ctorArgs = new(); - ctorArgs.Append($"{statusExpr}, {bodyExpr}, {contentTypeExpr}"); + ctorArgs.Append($"{statusExpr}, {bodyExpr}, {contentTypeExpr}{binaryArgs}"); foreach (var (_, typeName, fieldName, _) in respHeaders) { ctorArgs.Append($", {fieldName}: {fieldName}.IsUndefined ? default : {typeName}.CreateBuilder(workspace, {fieldName}, 30).RootElement"); @@ -5474,7 +5883,58 @@ private void EmitServerResultFactory( } else { - w.WriteLine($"public static {structName} {factoryName}({paramList}) => new({statusExpr}, {bodyExpr}, {contentTypeExpr});"); + w.WriteLine($"public static {structName} {factoryName}({paramList}) => new({statusExpr}, {bodyExpr}, {contentTypeExpr}{binaryArgs});"); + } + + // Closure-free, single-materialisation sibling: takes the response body already assembled as a context-threaded + // Source (built via the model's Build) and routes it straight through CreateBuilder + // — no per-item closure on the caller side and no re-materialisation here. Only emitted for object/array bodies + // (bodyHasContextSource); a scalar body has no Source and uses the non-generic factory above. + if (bodyHasContextSource) + { + string genericParamList = paramList.ToString().Replace($"{bodyTypeName}.Source body", $"{bodyTypeName}.Source body"); + string genericBodyExpr = $"{bodyTypeName}.CreateBuilder(workspace, in body, 30).RootElement"; + + w.WriteLine("/// "); + w.WriteLine($"/// Creates a {statusCode} {factoryName} result from a context-threaded body, materialised in a single pass."); + w.WriteLine("/// "); + w.WriteLine("/// The type of the context carried by the body."); + if (isDefault) + { + w.WriteLine("/// The HTTP status code."); + } + + w.WriteLine("/// The context-threaded response body."); + w.WriteLine("/// The workspace for building the response value."); + foreach (var (header, _, fieldName, _) in respHeaders) + { + w.WriteLine($"/// The value for the {header.HeaderName} response header."); + } + + w.WriteLine($"/// A with status {statusCode}."); + + string genericCtorArgs; + if (structHasHeaders) + { + StringBuilder ctorArgs = new(); + ctorArgs.Append($"{statusExpr}, {genericBodyExpr}, {contentTypeExpr}{binaryArgs}"); + foreach (var (_, typeName, fieldName, _) in respHeaders) + { + ctorArgs.Append($", {fieldName}: {fieldName}.IsUndefined ? default : {typeName}.CreateBuilder(workspace, {fieldName}, 30).RootElement"); + } + + genericCtorArgs = ctorArgs.ToString(); + } + else + { + genericCtorArgs = $"{statusExpr}, {genericBodyExpr}, {contentTypeExpr}{binaryArgs}"; + } + + w.WriteLine($"public static {structName} {factoryName}({genericParamList})"); + w.WriteLine("#if NET9_0_OR_GREATER"); + w.WriteLine(" where TContext : allows ref struct"); + w.WriteLine("#endif"); + w.WriteLine($" => new({genericCtorArgs});"); } } @@ -5668,6 +6128,10 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) // (mirrors client response pattern: workspace manages param/header document lifetimes) bool isRawStreamBody = hasBody && IsRawStreamRequestBody(op.RequestBody!.Value); string? bodyTypeName = hasBody && !isRawStreamBody ? this.ResolveRequestBodyTypeName(op.RequestBody!.Value) : null; + + // An optional (required: false) non-raw-stream request body is read only when the request actually + // carries one; an absent body must leave the body parameter undefined, not fail on an empty stream. + bool bodyOptional = hasBody && !isRawStreamBody && !op.RequestBody!.Value.IsRequired; w.WriteLine("JsonWorkspace workspace = JsonWorkspace.CreateUnrented();"); if (hasBody && !isRawStreamBody) { @@ -5732,6 +6196,14 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); } + if (bodyOptional) + { + w.WriteLine("// An optional request body is read only when the request actually carries one;"); + w.WriteLine("// an absent body leaves the body parameter undefined rather than failing to parse."); + w.WriteLine("if ((context.Request.ContentLength ?? 0) > 0 || context.Request.Headers.ContainsKey(\"Transfer-Encoding\"))"); + w.OpenBrace(); + } + if (IsRawStreamRequestBody(op.RequestBody!.Value)) { // Raw stream body — no parsing needed, pass context.Request.Body directly. @@ -5752,9 +6224,37 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) } else if (IsMultipartRequestBody(op.RequestBody!.Value)) { + BinaryPropertyInfo[] binaryParts = op.RequestBody!.Value.BinaryProperties; + if (binaryParts.Length > 0) + { + foreach (BinaryPropertyInfo binaryPart in binaryParts) + { + w.WriteLine($"byte[]? __binary_{binaryPart.PropertyName} = null;"); + } + } + w.WriteLine("try"); w.OpenBrace(); - w.WriteLine($"bodyDoc = await MultipartFormDataSerializer.DeserializeAsync<{bodyTypeName}>(context.Request.Body, context.Request.ContentType, cancellationToken: context.RequestAborted).ConfigureAwait(false);"); + if (binaryParts.Length > 0) + { + w.WriteLine($"bodyDoc = await MultipartFormDataSerializer.DeserializeAsync<{bodyTypeName}>(context.Request.Body, context.Request.ContentType, binaryPartCallback: part =>"); + w.OpenBrace(); + bool first = true; + foreach (BinaryPropertyInfo binaryPart in binaryParts) + { + string keyword = first ? "if" : "else if"; + first = false; + w.WriteLine($"{keyword} (part.Name.SequenceEqual(\"{binaryPart.PropertyName}\"u8)) {{ __binary_{binaryPart.PropertyName} = part.Data.ToArray(); }}"); + } + + w.CloseBraceNoNewline().Write(", cancellationToken: context.RequestAborted).ConfigureAwait(false);"); + w.WriteLine(); + } + else + { + w.WriteLine($"bodyDoc = await MultipartFormDataSerializer.DeserializeAsync<{bodyTypeName}>(context.Request.Body, context.Request.ContentType, cancellationToken: context.RequestAborted).ConfigureAwait(false);"); + } + w.CloseBrace(); w.WriteLine("catch"); w.OpenBrace(); @@ -5780,6 +6280,11 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); EmitRequestBodySchemaValidation(w, bodyTypeName); } + + if (bodyOptional) + { + w.CloseBrace(); + } } w.WriteLine(); @@ -5802,10 +6307,23 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) { w.WriteLine("Body = context.Request.Body,"); } + else if (bodyOptional) + { + w.WriteLine("Body = bodyDoc is null ? default : bodyDoc.RootElement,"); + } else { w.WriteLine("Body = bodyDoc!.RootElement,"); } + + // Bind any multipart binary parts captured by the deserializer callback. + if (!isRawStreamBody && IsMultipartRequestBody(op.RequestBody!.Value)) + { + foreach (BinaryPropertyInfo binaryPart in op.RequestBody!.Value.BinaryProperties) + { + w.WriteLine($"{CodeEmitHelpers.ToPascalCase(binaryPart.PropertyName)} = __binary_{binaryPart.PropertyName} ?? ReadOnlyMemory.Empty,"); + } + } } w.CloseBrace().Write(";"); @@ -5846,7 +6364,23 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); } - w.WriteLine("if (!result.Body.IsUndefined())"); + // For operations with an octet-stream success response, write raw bytes directly. + bool opHasOctetStreamResponse = op.Responses.Any(r => + r.StatusCode.Length == 3 && r.StatusCode[0] == '2' && IsOctetStreamResponse(r)); + if (opHasOctetStreamResponse) + { + w.WriteLine("if (result.HasBinaryBody)"); + w.OpenBrace(); + w.WriteLine("context.Response.ContentType = result.ContentType ?? \"application/octet-stream\";"); + w.WriteLine("await result.WriteBinaryBodyAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);"); + w.CloseBrace(); + w.WriteLine("else if (!result.Body.IsUndefined())"); + } + else + { + w.WriteLine("if (!result.Body.IsUndefined())"); + } + w.OpenBrace(); w.WriteLine("context.Response.ContentType = result.ContentType ?? \"application/json\";"); w.WriteLine("Utf8JsonWriter writer = workspace.RentWriter(context.Response.BodyWriter);"); diff --git a/src/Corvus.Text.Json.OpenApi31/OpenApi31CodeGenerator.cs b/src/Corvus.Text.Json.OpenApi31/OpenApi31CodeGenerator.cs index 257bd1b1026..410eae17e43 100644 --- a/src/Corvus.Text.Json.OpenApi31/OpenApi31CodeGenerator.cs +++ b/src/Corvus.Text.Json.OpenApi31/OpenApi31CodeGenerator.cs @@ -36,6 +36,7 @@ public sealed class OpenApi31CodeGenerator private readonly string? clientNamePrefix; private readonly bool ignoreEmptyFormUrlEncodedBody; private readonly IReadOnlyDictionary schemaTypeMap; + private readonly IReadOnlySet contextSourceBodyPointers; /// /// Initializes a new instance of the class. @@ -54,16 +55,25 @@ public sealed class OpenApi31CodeGenerator /// When , form-urlencoded request bodies whose schema defines /// no properties are treated as if the body were absent. /// + /// + /// The subset of pointers whose type is an object or array — i.e. types for + /// which the model generator emits a context-threaded Source<TContext>. A server result factory emits + /// a closure-free, single-materialisation Ok<TContext> overload only for a response body in this set; + /// a scalar body (no Source<TContext>) falls back to the non-generic factory. When + /// no generic overloads are emitted (the conservative default for client/model generation). + /// public OpenApi31CodeGenerator( string rootNamespace, IReadOnlyDictionary schemaTypeMap, string? clientNamePrefix = null, - bool ignoreEmptyFormUrlEncodedBody = false) + bool ignoreEmptyFormUrlEncodedBody = false, + IReadOnlySet? contextSourceBodyPointers = null) { this.rootNamespace = rootNamespace; this.schemaTypeMap = schemaTypeMap; this.clientNamePrefix = clientNamePrefix; this.ignoreEmptyFormUrlEncodedBody = ignoreEmptyFormUrlEncodedBody; + this.contextSourceBodyPointers = contextSourceBodyPointers ?? new HashSet(StringComparer.Ordinal); } // ── Walk-phase reference (typed model objects, no strings extracted) ── @@ -280,6 +290,8 @@ public static OperationSummary[] ListOperations( bool hasBody = opRef.Operation.RequestBody.IsNotUndefined(); + string methodName = GetMethodName(operationId, opRef.Method, path); + result.Add(new OperationSummary( path, opRef.Method, @@ -288,7 +300,10 @@ public static OperationSummary[] ListOperations( isDeprecated, paramCount, hasBody, - summary)); + summary, + methodName, + GeneratedClientTypeNaming.RequestTypeName(methodName), + GeneratedClientTypeNaming.ResponseTypeName(methodName))); } return [.. result]; @@ -393,6 +408,111 @@ public IReadOnlyList Generate( return files; } + /// + /// Describes every operation for downstream generators: its identity, the generated + /// request/response type names, and the request parameters — the authoritative mapping the + /// generator emits, so callers never re-derive the naming or type convention. + /// + /// The root element of the parsed spec document. + /// Optional operation filter. + /// + /// Optional reference resolver. If , a is used. + /// + /// The operation descriptors. + public IReadOnlyList DescribeOperations( + JsonElement specRoot, + OperationFilter? filter = null, + IOpenApiReferenceResolver? referenceResolver = null) + { + referenceResolver ??= new LocalReferenceResolver(specRoot); + ServerInfo? rootServer = GetDefaultServerInfo(specRoot); + List result = []; + + foreach (OperationRef opRef in WalkOperationRefs(specRoot, filter, referenceResolver)) + { + OperationInfo op = PrepareOperation(opRef, referenceResolver, rootServer, specRoot); + + var parameters = new RequestParameterInfo[op.Parameters.Length]; + for (int i = 0; i < op.Parameters.Length; i++) + { + ParameterInfo parameter = op.Parameters[i]; + parameters[i] = new RequestParameterInfo( + parameter.Name, + parameter.Location, + CodeEmitHelpers.SanitizeIdentifier(parameter.Name), + this.GetParameterTypeName(parameter), + parameter.IsRequired, + CodeEmitHelpers.EscapeCSharpKeyword(CodeEmitHelpers.SanitizeParameterName(parameter.Name))); + } + + var responses = new ResponseDescriptor[op.Responses.Length]; + for (int r = 0; r < op.Responses.Length; r++) + { + ResponseInfo response = op.Responses[r]; + string? bodyTypeName = this.ResolveResponseTypeName(response); + responses[r] = new ResponseDescriptor( + response.StatusCode, + bodyTypeName, + bodyTypeName is null ? null : CodeEmitHelpers.ResponseBodyPropertyName(response.StatusCode)); + } + + ResponseHeaderInfo[] responseHeaders = this.DescribeResponseHeaders(op.Responses); + + string clientTag = op.Tags.Length > 0 ? op.Tags[0] : "default"; + string? requestBodyTypeName = op.RequestBody is { } rb && !IsRawStreamRequestBody(rb) + ? this.ResolveRequestBodyTypeName(rb) + : null; + + result.Add(new OperationDescriptor( + op.PathTemplate, + op.Method, + op.OperationId, + op.MethodName, + $"{this.rootNamespace}.{GeneratedClientTypeNaming.RequestTypeName(op.MethodName)}", + $"{this.rootNamespace}.{GeneratedClientTypeNaming.ResponseTypeName(op.MethodName)}", + parameters, + op.RequestBody is not null, + responses, + $"{this.rootNamespace}.{this.GetClientName(clientTag)}Client", + $"{op.MethodName}Async", + requestBodyTypeName, + responseHeaders)); + } + + return result; + } + + /// + /// Describes the response headers an operation declares — mirroring 's + /// naming and typing so a caller can resolve $response.header.<name> against the generated + /// response property. Deduplicated by generated property name across all responses. + /// + /// The operation's responses. + /// The described response headers. + private ResponseHeaderInfo[] DescribeResponseHeaders(ResponseInfo[] responses) + { + var headers = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (ResponseInfo response in responses) + { + foreach (HeaderInfo header in response.Headers) + { + string propertyName = CodeEmitHelpers.HeaderNameToPropertyName(header.HeaderName) + "Header"; + if (!seen.Add(propertyName)) + { + continue; + } + + bool isString = header.SchemaPointer is null; + string typeName = isString ? "string" : this.ResolveSchemaTypeName(header.SchemaPointer); + headers.Add(new ResponseHeaderInfo(header.HeaderName, propertyName, typeName, isString)); + } + } + + return [.. headers]; + } + // ═══════════════════════════════════════════════════════════════════ // Schema pointer collection — walks typed model directly // ═══════════════════════════════════════════════════════════════════ @@ -1276,11 +1396,11 @@ private OperationInfo PrepareOperation( string methodName = GetMethodName(operationId, opRef.Method, pathTemplate); ParameterInfo[] parameters = PrepareParameters( - opRef.Operation, opRef.PathItem, pathNameUtf8, opRef.Method, referenceResolver); + opRef.Operation, opRef.PathItem, pathNameUtf8, opRef.Method, referenceResolver, specRoot); RequestBodyInfo? requestBody = PrepareRequestBody( opRef.Operation, pathNameUtf8, opRef.Method, referenceResolver, this.ignoreEmptyFormUrlEncodedBody); ResponseInfo[] responses = PrepareResponses( - opRef.Operation, pathNameUtf8, opRef.Method, referenceResolver); + opRef.Operation, pathNameUtf8, opRef.Method, referenceResolver, specRoot); ServerInfo? effectiveServer = ResolveEffectiveServer( opRef.Operation, opRef.PathItem, rootServer); @@ -1419,7 +1539,8 @@ private static ParameterInfo[] PrepareParameters( OpenApiDocument.PathItem pathItem, ReadOnlySpan pathNameUtf8, OperationMethod method, - IOpenApiReferenceResolver referenceResolver) + IOpenApiReferenceResolver referenceResolver, + JsonElement specRoot) { var merged = MergeParameters(operation, pathItem, referenceResolver); @@ -1439,19 +1560,19 @@ private static ParameterInfo[] PrepareParameters( bool hasSchema = param.SchemaValue.IsNotUndefined(); JsonElement schemaElement = hasSchema ? JsonElement.From(param.SchemaValue) : default; ParameterSerializationKind serializationKind = hasSchema - ? SchemaClassifier.Classify(schemaElement) + ? SchemaClassifier.Classify(schemaElement, specRoot) : ParameterSerializationKind.String; ParameterSerializationKind elementKind = serializationKind switch { - ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaElement), - ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaElement), + ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaElement, specRoot), + ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaElement, specRoot), _ => ParameterSerializationKind.String, }; bool deepNesting = hasSchema && serializationKind is ParameterSerializationKind.Object or ParameterSerializationKind.Array - && SchemaClassifier.HasDeepNesting(schemaElement); + && SchemaClassifier.HasDeepNesting(schemaElement, specRoot); string? schemaPointer = hasSchema ? SchemaPointerBuilder.BuildParameterSchemaPointer( @@ -1589,7 +1710,8 @@ private static ResponseInfo[] PrepareResponses( OpenApiDocument.Operation operation, ReadOnlySpan pathNameUtf8, OperationMethod method, - IOpenApiReferenceResolver referenceResolver) + IOpenApiReferenceResolver referenceResolver, + JsonElement specRoot) { if (operation.ResponsesValue.IsUndefined()) { @@ -1621,7 +1743,7 @@ private static ResponseInfo[] PrepareResponses( response.ContentValue, pathNameUtf8, method, statusCodeUtf8.Span); HeaderInfo[] headers = PrepareResponseHeaders( - response.Headers, pathNameUtf8, method, statusCodeUtf8.Span, referenceResolver); + response.Headers, pathNameUtf8, method, statusCodeUtf8.Span, referenceResolver, specRoot); LinkInfo[] links = PrepareLinks(response.Links, referenceResolver, statusCode); @@ -1668,6 +1790,21 @@ private static ContentInfo[] PrepareContentEntries( return [.. result]; } + // The JSON-ish media type the response declares for its body, picked by the same rule that picked the body's + // schema so the two always describe the same content entry. + private static string DeclaredJsonMediaType(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType)) + { + return content.MediaType; + } + } + + return "application/json"; + } + private static ContentInfo[] PrepareResponseContentEntries( OpenApiDocument.Content contentMap, ReadOnlySpan pathNameUtf8, @@ -1744,7 +1881,8 @@ private static HeaderInfo[] PrepareResponseHeaders( ReadOnlySpan pathNameUtf8, OperationMethod method, ReadOnlySpan statusCodeUtf8, - IOpenApiReferenceResolver referenceResolver) + IOpenApiReferenceResolver referenceResolver, + JsonElement specRoot) { if (headersMap.IsUndefined()) { @@ -1790,19 +1928,19 @@ private static HeaderInfo[] PrepareResponseHeaders( JsonElement schemaEl = hasSchema ? JsonElement.From(header.SchemaValue) : default; ParameterSerializationKind serializationKind = hasSchema - ? SchemaClassifier.Classify(schemaEl) + ? SchemaClassifier.Classify(schemaEl, specRoot) : ParameterSerializationKind.String; ParameterSerializationKind elementKind = serializationKind switch { - ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaEl), - ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaEl), + ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaEl, specRoot), + ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaEl, specRoot), _ => ParameterSerializationKind.String, }; bool deepNesting = hasSchema && serializationKind is ParameterSerializationKind.Object or ParameterSerializationKind.Array - && SchemaClassifier.HasDeepNesting(schemaEl); + && SchemaClassifier.HasDeepNesting(schemaEl, specRoot); // Extract header name at the emit boundary string name = headerProp.Name; @@ -2113,6 +2251,37 @@ private string ResolveSchemaTypeName(string? schemaPointer) return "JsonElement"; } + // The request body's schema pointer (the key into contextSourceBodyPointers), picked by the same rule that picks its + // type name so the two always describe the same content entry. + private static string? ResolveRequestBodySchemaPointer(RequestBodyInfo requestBody) + { + foreach (ContentInfo content in requestBody.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType) + || CodeEmitHelpers.IsFormUrlEncodedMediaType(content.MediaType) + || CodeEmitHelpers.IsMultipartMediaType(content.MediaType)) + { + return content.SchemaPointer; + } + } + + return null; + } + + // Whether the operation can also be offered as a closure-free, single-materialisation generic overload: it must + // have a body that is materialised from a Source (not a raw stream), and that body's type must be one the model + // generator gives a Source. + private bool HasContextThreadedBody(OperationInfo op) + { + if (op.RequestBody is not { } requestBody || IsRawStreamRequestBody(requestBody)) + { + return false; + } + + return ResolveRequestBodySchemaPointer(requestBody) is { } pointer + && this.contextSourceBodyPointers.Contains(pointer); + } + private string ResolveRequestBodyTypeName(RequestBodyInfo requestBody) { foreach (ContentInfo content in requestBody.Content) @@ -2236,6 +2405,23 @@ private static bool IsMultipartRequestBody(RequestBodyInfo requestBody) return null; } + /// + /// Returns if the response's content is classified as + /// (raw binary). + /// + private static bool IsOctetStreamResponse(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.ClassifyMediaType(content.MediaType) == ContentCategory.OctetStream) + { + return true; + } + } + + return false; + } + private string? ResolveResponseTypeName(ResponseInfo resp) { foreach (ContentInfo content in resp.Content) @@ -2249,6 +2435,21 @@ private static bool IsMultipartRequestBody(RequestBodyInfo requestBody) return null; } + // The JSON response body's schema pointer (the key into contextSourceBodyPointers) — used to decide whether the body + // type carries a Source, and so whether a closure-free Ok overload can be emitted for it. + private string? ResolveResponseBodySchemaPointer(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType)) + { + return content.SchemaPointer; + } + } + + return null; + } + /// /// Returns the distinct content categories present in a response's content entries. /// @@ -2263,7 +2464,7 @@ private static ContentCategory[] GetDistinctContentCategories(ResponseInfo resp) // ── Request struct emission ───────────────────────────────────────── private GeneratedFile EmitRequestStruct(OperationInfo op) { - string structName = $"{op.MethodName}Request"; + string structName = $"{op.MethodName}{GeneratedClientTypeNaming.RequestSuffix}"; IndentedWriter w = new(); CodeEmitHelpers.EmitHeader(w); @@ -2802,7 +3003,7 @@ private static void EmitBodyValidation( // ── Response struct emission ──────────────────────────────────────── private GeneratedFile EmitResponseStruct(OperationInfo op, List allOperations) { - string structName = $"{op.MethodName}Response"; + string structName = $"{op.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; IndentedWriter w = new(); CodeEmitHelpers.EmitHeader(w); @@ -3108,8 +3309,8 @@ private void EmitLinkMethod( } OperationInfo target = targetOp.Value; - string targetResponseType = $"{target.MethodName}Response"; - string targetRequestType = $"{target.MethodName}Request"; + string targetResponseType = $"{target.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; + string targetRequestType = $"{target.MethodName}{GeneratedClientTypeNaming.RequestSuffix}"; // Determine which target parameters are NOT satisfied by link bindings. HashSet boundParams = new(StringComparer.OrdinalIgnoreCase); @@ -4080,6 +4281,12 @@ private GeneratedFile EmitInterface( } this.EmitInterfaceMethodSignature(w, operations[i]); + + if (this.HasContextThreadedBody(operations[i])) + { + w.WriteLine(); + this.EmitInterfaceMethodSignature(w, operations[i], contextThreaded: true); + } } w.CloseBrace(); @@ -4157,9 +4364,9 @@ private static void EmitCreateServerUri(IndentedWriter w, ServerInfo serverInfo) w.WriteLine(); } - private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op) + private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op, bool contextThreaded = false) { - string responseName = $"{op.MethodName}Response"; + string responseName = $"{op.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; EmitMethodDoc(w, op); @@ -4168,9 +4375,25 @@ private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op) w.WriteLine("[Obsolete(\"This operation is deprecated.\")]"); } - List paramParts = this.BuildParameterList(op); + List paramParts = this.BuildParameterList(op, contextThreaded); + string generic = contextThreaded ? "" : string.Empty; w.WriteLine( - $"ValueTask<{responseName}> {op.MethodName}Async({string.Join(", ", paramParts)});"); + $"ValueTask<{responseName}> {op.MethodName}Async{generic}({string.Join(", ", paramParts)})" + + (contextThreaded ? string.Empty : ";")); + + if (contextThreaded) + { + EmitContextConstraint(w); + w.WriteLine(";"); + } + } + + // The constraint that lets a caller thread a ref struct through as context, guarded because it is a C# 13 feature. + private static void EmitContextConstraint(IndentedWriter w) + { + w.WriteLine("#if NET9_0_OR_GREATER"); + w.WriteLine(" where TContext : allows ref struct"); + w.WriteLine("#endif"); } // ── Implementation emission ───────────────────────────────────────── @@ -4213,6 +4436,14 @@ private GeneratedFile EmitImplementation( { w.WriteLine(); this.EmitClientMethod(w, operations[i], encodingFieldNames); + + // A body whose type carries a Source also gets the closure-free, single-materialisation form, + // matching what a server result factory already offers for a response body. + if (this.HasContextThreadedBody(operations[i])) + { + w.WriteLine(); + this.EmitClientMethod(w, operations[i], encodingFieldNames, contextThreaded: true); + } } w.WriteLine(); @@ -4243,6 +4474,12 @@ private GeneratedFile EmitImplementation( else if (hasBody) { needsSendWithBody = true; + + // An optional JSON body also emits a bodyless SendAsyncCore path for when the caller omits it. + if (!op.RequestBody!.Value.IsRequired && !HasRequestBasedExpressions(op)) + { + needsSendAsync = true; + } } else { @@ -4258,10 +4495,10 @@ private GeneratedFile EmitImplementation( return new GeneratedFile($"{clientName}Client.cs", w.ToString()); } - private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary encodingFieldNames) + private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary encodingFieldNames, bool contextThreaded = false) { - string requestName = $"{op.MethodName}Request"; - string responseName = $"{op.MethodName}Response"; + string requestName = $"{op.MethodName}{GeneratedClientTypeNaming.RequestSuffix}"; + string responseName = $"{op.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; EmitMethodDoc(w, op); @@ -4270,14 +4507,19 @@ private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary paramParts = this.BuildParameterList(op); + List paramParts = this.BuildParameterList(op, contextThreaded); bool hasRequestExprLinks = HasRequestBasedExpressions(op); w.WriteLine( - $"public ValueTask<{responseName}> {op.MethodName}Async(" + + $"public ValueTask<{responseName}> {op.MethodName}Async{(contextThreaded ? "" : string.Empty)}(" + $"{string.Join(", ", paramParts)})"); + if (contextThreaded) + { + EmitContextConstraint(w); + } + w.OpenBrace(); bool hasParams = op.Parameters.Length > 0; @@ -4289,6 +4531,14 @@ private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary(JsonWorkspace.CreateUnrented(), request, bodyValue, responseValidationMode, cancellationToken), request, {(hasRequestBodyExprLinks ? "bodyValue, " : "")}workspace);"); } + else if (optionalJsonBody) + { + // The caller supplied a body → send it; otherwise send the request bodyless (the server's + // optional-body path). This is the client counterpart to the optional request-body server fix. + w.WriteLine("if (hasBodyValue)"); + w.OpenBrace(); + w.WriteLine( + $"return SendWithBodyAsyncCore<{requestName}, {bodyTypeName}, " + + $"{responseName}>(workspace, request, bodyValue, responseValidationMode, cancellationToken);"); + w.CloseBrace(); + w.WriteLine(); + w.WriteLine( + $"return SendAsyncCore<{requestName}, " + + $"{responseName}>(workspace, request, responseValidationMode, cancellationToken);"); + } else { w.WriteLine( @@ -4720,7 +5006,7 @@ private static void EmitMethodDoc(IndentedWriter w, OperationInfo op) w.WriteLine("/// A cancellation token."); } - private List BuildParameterList(OperationInfo op) + private List BuildParameterList(OperationInfo op, bool contextThreaded = false) { List paramParts = []; @@ -4748,7 +5034,8 @@ private List BuildParameterList(OperationInfo op) { string bodyTypeName = this.ResolveRequestBodyTypeName(op.RequestBody.Value); string suffix = bodyRequired ? string.Empty : " = default"; - paramParts.Add($"{bodyTypeName}.Source body{suffix}"); + string sourceType = contextThreaded ? "Source" : "Source"; + paramParts.Add($"{bodyTypeName}.{sourceType} body{suffix}"); } } @@ -5068,6 +5355,8 @@ public static OperationSummary[] ListWebhookAndCallbackOperations( bool hasBody = opRef.Operation.RequestBody.IsNotUndefined(); + string methodName = GetMethodName(operationId, opRef.Method, path); + result.Add(new OperationSummary( path, opRef.Method, @@ -5076,7 +5365,10 @@ public static OperationSummary[] ListWebhookAndCallbackOperations( isDeprecated, paramCount, hasBody, - summary)); + summary, + methodName, + GeneratedClientTypeNaming.RequestTypeName(methodName), + GeneratedClientTypeNaming.ResponseTypeName(methodName))); } return [.. result]; @@ -5188,6 +5480,20 @@ private GeneratedFile EmitServerOperationParams(OperationInfo op) w.WriteLine($"/// {CodeEmitHelpers.EscapeXml(bodyDesc)}"); w.WriteLine("/// "); w.WriteLine($"public {bodyTypeName} Body {{ get; init; }}"); + + // For multipart/form-data bodies with format:binary parts, expose each binary part + // as a separate ReadOnlyMemory property so the handler can read the raw bytes. + if (IsMultipartRequestBody(rb)) + { + foreach (BinaryPropertyInfo binaryProp in rb.BinaryProperties) + { + w.WriteLine(); + w.WriteLine("/// "); + w.WriteLine($"/// Gets the binary content of the '{binaryProp.PropertyName}' part."); + w.WriteLine("/// "); + w.WriteLine($"public ReadOnlyMemory {CodeEmitHelpers.ToPascalCase(binaryProp.PropertyName)} {{ get; init; }}"); + } + } } w.CloseBrace(); @@ -5201,6 +5507,11 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) IndentedWriter w = new(); CodeEmitHelpers.EmitHeader(w); + w.WriteLine("using System;"); + w.WriteLine("using System.IO;"); + w.WriteLine("using System.Threading;"); + w.WriteLine("using System.Threading.Tasks;"); + w.WriteLine(); w.WriteLine($"namespace {this.rootNamespace};"); w.WriteLine(); @@ -5228,6 +5539,10 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) bool hasHeaders = allHeaders.Count > 0; + // Detect a 2xx octet-stream success response: the server must be able to write raw bytes. + bool hasOctetStreamResponse = op.Responses.Any(r => + r.StatusCode.Length == 3 && r.StatusCode[0] == '2' && IsOctetStreamResponse(r)); + w.WriteLine("/// "); w.WriteLine($"/// Result type for the {op.MethodName} operation."); w.WriteLine("/// "); @@ -5238,6 +5553,11 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) if (hasHeaders) { w.Write($"private {structName}(int statusCode, JsonElement body, string? contentType"); + if (hasOctetStreamResponse) + { + w.Write(", bool hasBinaryBody, Func? binaryWriter"); + } + foreach (var (_, typeName, fieldName, _) in allHeaders) { w.Write($", {typeName} {fieldName} = default"); @@ -5248,6 +5568,12 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine("this.StatusCode = statusCode;"); w.WriteLine("this.Body = body;"); w.WriteLine("this.ContentType = contentType;"); + if (hasOctetStreamResponse) + { + w.WriteLine("this.HasBinaryBody = hasBinaryBody;"); + w.WriteLine("this.binaryWriter = binaryWriter;"); + } + foreach (var (_, _, fieldName, propertyName) in allHeaders) { w.WriteLine($"this.{propertyName} = {fieldName};"); @@ -5255,6 +5581,17 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.CloseBrace(); } + else if (hasOctetStreamResponse) + { + w.WriteLine($"private {structName}(int statusCode, JsonElement body, string? contentType, bool hasBinaryBody, Func? binaryWriter)"); + w.OpenBrace(); + w.WriteLine("this.StatusCode = statusCode;"); + w.WriteLine("this.Body = body;"); + w.WriteLine("this.ContentType = contentType;"); + w.WriteLine("this.HasBinaryBody = hasBinaryBody;"); + w.WriteLine("this.binaryWriter = binaryWriter;"); + w.CloseBrace(); + } else { w.WriteLine($"private {structName}(int statusCode, JsonElement body = default, string? contentType = null)"); @@ -5275,6 +5612,21 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine("/// Gets the content type for the response body."); w.WriteLine("public string? ContentType { get; }"); + if (hasOctetStreamResponse) + { + w.WriteLine(); + w.WriteLine("private readonly Func? binaryWriter;"); + w.WriteLine(); + w.WriteLine("/// Gets a value indicating whether the result carries a raw binary body."); + w.WriteLine("public bool HasBinaryBody { get; }"); + w.WriteLine(); + w.WriteLine("/// Writes the raw binary response body to the supplied stream."); + w.WriteLine("/// The stream to write the binary body to."); + w.WriteLine("/// A token to cancel the write."); + w.WriteLine("/// A that completes when the body has been written."); + w.WriteLine("public ValueTask WriteBinaryBodyAsync(Stream stream, CancellationToken cancellationToken) => this.binaryWriter is { } writer ? writer(stream, cancellationToken) : ValueTask.CompletedTask;"); + } + // Header properties foreach (var (header, typeName, _, propertyName) in allHeaders) { @@ -5291,6 +5643,35 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) string factoryName = CodeEmitHelpers.StatusCodeToName(resp.StatusCode); string? typeName = this.ResolveResponseTypeName(resp); + // For an octet-stream success response, emit a factory that takes raw bytes instead + // of the default no-arg/JSON factory, so the handler can return binary content. + bool isOctetStreamSuccess = resp.StatusCode.Length == 3 + && resp.StatusCode[0] == '2' + && IsOctetStreamResponse(resp); + if (isOctetStreamSuccess) + { + string ctorPrefix = $"{resp.StatusCode}, default, contentType, hasBinaryBody: true, binaryWriter: "; + + w.WriteLine(); + w.WriteLine("/// "); + w.WriteLine($"/// Creates a {resp.StatusCode} {factoryName} result with a buffered raw binary body."); + w.WriteLine("/// "); + w.WriteLine("/// The raw binary response body."); + w.WriteLine("/// The content type for the response body."); + w.WriteLine($"/// A with status {resp.StatusCode}."); + w.WriteLine($"public static {structName} {factoryName}(ReadOnlyMemory body, string? contentType = \"application/octet-stream\") => new({ctorPrefix}(stream, cancellationToken) => stream.WriteAsync(body, cancellationToken));"); + + w.WriteLine(); + w.WriteLine("/// "); + w.WriteLine($"/// Creates a {resp.StatusCode} {factoryName} result that streams a raw binary body."); + w.WriteLine("/// "); + w.WriteLine("/// A callback that writes the raw binary body to the response stream."); + w.WriteLine("/// The content type for the response body."); + w.WriteLine($"/// A with status {resp.StatusCode}."); + w.WriteLine($"public static {structName} {factoryName}(Func writeBody, string? contentType = \"application/octet-stream\") => new({ctorPrefix}writeBody);"); + continue; + } + List<(HeaderInfo Header, string TypeName, string FieldName, string PropertyName)> respHeaders = []; foreach (HeaderInfo header in resp.Headers) { @@ -5306,7 +5687,7 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine("/// Creates a default error result."); w.WriteLine("/// "); - this.EmitServerResultFactory(w, structName, factoryName, typeName, respHeaders, resp.StatusCode, hasHeaders); + this.EmitServerResultFactory(w, structName, factoryName, typeName, resp, respHeaders, resp.StatusCode, hasHeaders, hasOctetStreamResponse); } else { @@ -5315,7 +5696,7 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine($"/// {CodeEmitHelpers.EscapeXml(desc)}"); w.WriteLine("/// "); - this.EmitServerResultFactory(w, structName, factoryName, typeName, respHeaders, resp.StatusCode, hasHeaders); + this.EmitServerResultFactory(w, structName, factoryName, typeName, resp, respHeaders, resp.StatusCode, hasHeaders, hasOctetStreamResponse); } } @@ -5413,13 +5794,21 @@ private void EmitServerResultFactory( string structName, string factoryName, string? bodyTypeName, + ResponseInfo response, List<(HeaderInfo Header, string TypeName, string FieldName, string PropertyName)> respHeaders, string statusCode, - bool structHasHeaders) + bool structHasHeaders, + bool structHasBinaryBody) { bool isDefault = statusCode == "default"; bool hasBody = bodyTypeName is not null; + // The body carries a Source (object/array type) iff its schema pointer is in the context-source set; + // only then can a closure-free, single-materialisation generic overload be emitted alongside the non-generic one. + bool bodyHasContextSource = hasBody + && this.ResolveResponseBodySchemaPointer(response) is { } bodySchemaPointer + && this.contextSourceBodyPointers.Contains(bodySchemaPointer); + StringBuilder paramList = new(); if (isDefault) { @@ -5481,12 +5870,20 @@ private void EmitServerResultFactory( string bodyExpr = hasBody ? $"{bodyTypeName}.CreateBuilder(workspace, body, 30).RootElement" : "default"; - string contentTypeExpr = hasBody ? "\"application/json\"" : "null"; + + // The media type the response actually declares, not a literal "application/json". A specification declaring + // RFC 9457 problem documents means it: answering them as application/json tells a client the body is an + // ordinary result, and a client branching on the media type to find the problem shape never sees one. + string contentTypeExpr = hasBody ? $"\"{DeclaredJsonMediaType(response)}\"" : "null"; + + // When the struct also carries an octet-stream binary body, the private ctor has two + // extra required parameters; non-binary factories pass the no-body defaults for them. + string binaryArgs = structHasBinaryBody ? ", false, null" : string.Empty; if (structHasHeaders) { StringBuilder ctorArgs = new(); - ctorArgs.Append($"{statusExpr}, {bodyExpr}, {contentTypeExpr}"); + ctorArgs.Append($"{statusExpr}, {bodyExpr}, {contentTypeExpr}{binaryArgs}"); foreach (var (_, typeName, fieldName, _) in respHeaders) { ctorArgs.Append($", {fieldName}: {fieldName}.IsUndefined ? default : {typeName}.CreateBuilder(workspace, {fieldName}, 30).RootElement"); @@ -5496,7 +5893,58 @@ private void EmitServerResultFactory( } else { - w.WriteLine($"public static {structName} {factoryName}({paramList}) => new({statusExpr}, {bodyExpr}, {contentTypeExpr});"); + w.WriteLine($"public static {structName} {factoryName}({paramList}) => new({statusExpr}, {bodyExpr}, {contentTypeExpr}{binaryArgs});"); + } + + // Closure-free, single-materialisation sibling: takes the response body already assembled as a context-threaded + // Source (built via the model's Build) and routes it straight through CreateBuilder + // — no per-item closure on the caller side and no re-materialisation here. Only emitted for object/array bodies + // (bodyHasContextSource); a scalar body has no Source and uses the non-generic factory above. + if (bodyHasContextSource) + { + string genericParamList = paramList.ToString().Replace($"{bodyTypeName}.Source body", $"{bodyTypeName}.Source body"); + string genericBodyExpr = $"{bodyTypeName}.CreateBuilder(workspace, in body, 30).RootElement"; + + w.WriteLine("/// "); + w.WriteLine($"/// Creates a {statusCode} {factoryName} result from a context-threaded body, materialised in a single pass."); + w.WriteLine("/// "); + w.WriteLine("/// The type of the context carried by the body."); + if (isDefault) + { + w.WriteLine("/// The HTTP status code."); + } + + w.WriteLine("/// The context-threaded response body."); + w.WriteLine("/// The workspace for building the response value."); + foreach (var (header, _, fieldName, _) in respHeaders) + { + w.WriteLine($"/// The value for the {header.HeaderName} response header."); + } + + w.WriteLine($"/// A with status {statusCode}."); + + string genericCtorArgs; + if (structHasHeaders) + { + StringBuilder ctorArgs = new(); + ctorArgs.Append($"{statusExpr}, {genericBodyExpr}, {contentTypeExpr}{binaryArgs}"); + foreach (var (_, typeName, fieldName, _) in respHeaders) + { + ctorArgs.Append($", {fieldName}: {fieldName}.IsUndefined ? default : {typeName}.CreateBuilder(workspace, {fieldName}, 30).RootElement"); + } + + genericCtorArgs = ctorArgs.ToString(); + } + else + { + genericCtorArgs = $"{statusExpr}, {genericBodyExpr}, {contentTypeExpr}{binaryArgs}"; + } + + w.WriteLine($"public static {structName} {factoryName}({genericParamList})"); + w.WriteLine("#if NET9_0_OR_GREATER"); + w.WriteLine(" where TContext : allows ref struct"); + w.WriteLine("#endif"); + w.WriteLine($" => new({genericCtorArgs});"); } } @@ -5690,6 +6138,10 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) // (mirrors client response pattern: workspace manages param/header document lifetimes) bool isRawStreamBody = hasBody && IsRawStreamRequestBody(op.RequestBody!.Value); string? bodyTypeName = hasBody && !isRawStreamBody ? this.ResolveRequestBodyTypeName(op.RequestBody!.Value) : null; + + // An optional (required: false) non-raw-stream request body is read only when the request actually + // carries one; an absent body must leave the body parameter undefined, not fail on an empty stream. + bool bodyOptional = hasBody && !isRawStreamBody && !op.RequestBody!.Value.IsRequired; w.WriteLine("JsonWorkspace workspace = JsonWorkspace.CreateUnrented();"); if (hasBody && !isRawStreamBody) { @@ -5754,6 +6206,14 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); } + if (bodyOptional) + { + w.WriteLine("// An optional request body is read only when the request actually carries one;"); + w.WriteLine("// an absent body leaves the body parameter undefined rather than failing to parse."); + w.WriteLine("if ((context.Request.ContentLength ?? 0) > 0 || context.Request.Headers.ContainsKey(\"Transfer-Encoding\"))"); + w.OpenBrace(); + } + if (IsRawStreamRequestBody(op.RequestBody!.Value)) { // Raw stream body — no parsing needed, pass context.Request.Body directly. @@ -5774,9 +6234,37 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) } else if (IsMultipartRequestBody(op.RequestBody!.Value)) { + BinaryPropertyInfo[] binaryParts = op.RequestBody!.Value.BinaryProperties; + if (binaryParts.Length > 0) + { + foreach (BinaryPropertyInfo binaryPart in binaryParts) + { + w.WriteLine($"byte[]? __binary_{binaryPart.PropertyName} = null;"); + } + } + w.WriteLine("try"); w.OpenBrace(); - w.WriteLine($"bodyDoc = await MultipartFormDataSerializer.DeserializeAsync<{bodyTypeName}>(context.Request.Body, context.Request.ContentType, cancellationToken: context.RequestAborted).ConfigureAwait(false);"); + if (binaryParts.Length > 0) + { + w.WriteLine($"bodyDoc = await MultipartFormDataSerializer.DeserializeAsync<{bodyTypeName}>(context.Request.Body, context.Request.ContentType, binaryPartCallback: part =>"); + w.OpenBrace(); + bool first = true; + foreach (BinaryPropertyInfo binaryPart in binaryParts) + { + string keyword = first ? "if" : "else if"; + first = false; + w.WriteLine($"{keyword} (part.Name.SequenceEqual(\"{binaryPart.PropertyName}\"u8)) {{ __binary_{binaryPart.PropertyName} = part.Data.ToArray(); }}"); + } + + w.CloseBraceNoNewline().Write(", cancellationToken: context.RequestAborted).ConfigureAwait(false);"); + w.WriteLine(); + } + else + { + w.WriteLine($"bodyDoc = await MultipartFormDataSerializer.DeserializeAsync<{bodyTypeName}>(context.Request.Body, context.Request.ContentType, cancellationToken: context.RequestAborted).ConfigureAwait(false);"); + } + w.CloseBrace(); w.WriteLine("catch"); w.OpenBrace(); @@ -5802,6 +6290,11 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); EmitRequestBodySchemaValidation(w, bodyTypeName); } + + if (bodyOptional) + { + w.CloseBrace(); + } } w.WriteLine(); @@ -5824,10 +6317,23 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) { w.WriteLine("Body = context.Request.Body,"); } + else if (bodyOptional) + { + w.WriteLine("Body = bodyDoc is null ? default : bodyDoc.RootElement,"); + } else { w.WriteLine("Body = bodyDoc!.RootElement,"); } + + // Bind any multipart binary parts captured by the deserializer callback. + if (!isRawStreamBody && IsMultipartRequestBody(op.RequestBody!.Value)) + { + foreach (BinaryPropertyInfo binaryPart in op.RequestBody!.Value.BinaryProperties) + { + w.WriteLine($"{CodeEmitHelpers.ToPascalCase(binaryPart.PropertyName)} = __binary_{binaryPart.PropertyName} ?? ReadOnlyMemory.Empty,"); + } + } } w.CloseBrace().Write(";"); @@ -5868,7 +6374,23 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); } - w.WriteLine("if (!result.Body.IsUndefined())"); + // For operations with an octet-stream success response, write raw bytes directly. + bool opHasOctetStreamResponse = op.Responses.Any(r => + r.StatusCode.Length == 3 && r.StatusCode[0] == '2' && IsOctetStreamResponse(r)); + if (opHasOctetStreamResponse) + { + w.WriteLine("if (result.HasBinaryBody)"); + w.OpenBrace(); + w.WriteLine("context.Response.ContentType = result.ContentType ?? \"application/octet-stream\";"); + w.WriteLine("await result.WriteBinaryBodyAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);"); + w.CloseBrace(); + w.WriteLine("else if (!result.Body.IsUndefined())"); + } + else + { + w.WriteLine("if (!result.Body.IsUndefined())"); + } + w.OpenBrace(); w.WriteLine("context.Response.ContentType = result.ContentType ?? \"application/json\";"); w.WriteLine("Utf8JsonWriter writer = workspace.RentWriter(context.Response.BodyWriter);"); diff --git a/src/Corvus.Text.Json.OpenApi32/OpenApi32CodeGenerator.cs b/src/Corvus.Text.Json.OpenApi32/OpenApi32CodeGenerator.cs index 7f1cda70f6a..0a0db7ffd29 100644 --- a/src/Corvus.Text.Json.OpenApi32/OpenApi32CodeGenerator.cs +++ b/src/Corvus.Text.Json.OpenApi32/OpenApi32CodeGenerator.cs @@ -37,6 +37,7 @@ public sealed class OpenApi32CodeGenerator private readonly string? clientNamePrefix; private readonly bool ignoreEmptyFormUrlEncodedBody; private readonly IReadOnlyDictionary schemaTypeMap; + private readonly IReadOnlySet contextSourceBodyPointers; /// /// Initializes a new instance of the class. @@ -56,16 +57,25 @@ public sealed class OpenApi32CodeGenerator /// no properties are treated as if the body were absent. Useful for real-world APIs /// (e.g. Stripe) that emit empty body definitions. /// + /// + /// The subset of pointers whose type is an object or array — i.e. types for + /// which the model generator emits a context-threaded Source<TContext>. A server result factory emits + /// a closure-free, single-materialisation Ok<TContext> overload only for a response body in this set; + /// a scalar body (no Source<TContext>) falls back to the non-generic factory. When + /// no generic overloads are emitted (the conservative default for client/model generation). + /// public OpenApi32CodeGenerator( string rootNamespace, IReadOnlyDictionary schemaTypeMap, string? clientNamePrefix = null, - bool ignoreEmptyFormUrlEncodedBody = false) + bool ignoreEmptyFormUrlEncodedBody = false, + IReadOnlySet? contextSourceBodyPointers = null) { this.rootNamespace = rootNamespace; this.schemaTypeMap = schemaTypeMap; this.clientNamePrefix = clientNamePrefix; this.ignoreEmptyFormUrlEncodedBody = ignoreEmptyFormUrlEncodedBody; + this.contextSourceBodyPointers = contextSourceBodyPointers ?? new HashSet(StringComparer.Ordinal); } // ── Walk-phase reference (typed model objects, no strings extracted) ── @@ -311,6 +321,8 @@ public static OperationSummary[] ListOperations( bool hasBody = opRef.Operation.RequestBody.IsNotUndefined(); + string methodName = GetMethodName(operationId, opRef.Method, path); + result.Add(new OperationSummary( path, opRef.Method, @@ -319,7 +331,10 @@ public static OperationSummary[] ListOperations( isDeprecated, paramCount, hasBody, - summary)); + summary, + methodName, + GeneratedClientTypeNaming.RequestTypeName(methodName), + GeneratedClientTypeNaming.ResponseTypeName(methodName))); } return [.. result]; @@ -405,6 +420,111 @@ public IReadOnlyList Generate( return this.EmitClientFiles(operations, specRoot, referenceResolver); } + /// + /// Describes every operation for downstream generators: its identity, the generated + /// request/response type names, and the request parameters — the authoritative mapping the + /// generator emits, so callers never re-derive the naming or type convention. + /// + /// The root element of the parsed spec document. + /// Optional operation filter. + /// + /// Optional reference resolver. If , a is used. + /// + /// The operation descriptors. + public IReadOnlyList DescribeOperations( + JsonElement specRoot, + OperationFilter? filter = null, + IOpenApiReferenceResolver? referenceResolver = null) + { + referenceResolver ??= new LocalReferenceResolver(specRoot); + ServerInfo? rootServer = GetDefaultServerInfo(specRoot); + List result = []; + + foreach (OperationRef opRef in WalkOperationRefs(specRoot, filter, referenceResolver)) + { + OperationInfo op = PrepareOperation(opRef, referenceResolver, rootServer, specRoot); + + var parameters = new RequestParameterInfo[op.Parameters.Length]; + for (int i = 0; i < op.Parameters.Length; i++) + { + ParameterInfo parameter = op.Parameters[i]; + parameters[i] = new RequestParameterInfo( + parameter.Name, + parameter.Location, + CodeEmitHelpers.SanitizeIdentifier(parameter.Name), + this.GetParameterTypeName(parameter), + parameter.IsRequired, + CodeEmitHelpers.EscapeCSharpKeyword(CodeEmitHelpers.SanitizeParameterName(parameter.Name))); + } + + var responses = new ResponseDescriptor[op.Responses.Length]; + for (int r = 0; r < op.Responses.Length; r++) + { + ResponseInfo response = op.Responses[r]; + string? bodyTypeName = this.ResolveResponseTypeName(response); + responses[r] = new ResponseDescriptor( + response.StatusCode, + bodyTypeName, + bodyTypeName is null ? null : CodeEmitHelpers.ResponseBodyPropertyName(response.StatusCode)); + } + + ResponseHeaderInfo[] responseHeaders = this.DescribeResponseHeaders(op.Responses); + + string clientTag = op.Tags.Length > 0 ? op.Tags[0] : "default"; + string? requestBodyTypeName = op.RequestBody is { } rb && !IsRawStreamRequestBody(rb) + ? this.ResolveRequestBodyTypeName(rb) + : null; + + result.Add(new OperationDescriptor( + op.PathTemplate, + op.Method, + op.OperationId, + op.MethodName, + $"{this.rootNamespace}.{GeneratedClientTypeNaming.RequestTypeName(op.MethodName)}", + $"{this.rootNamespace}.{GeneratedClientTypeNaming.ResponseTypeName(op.MethodName)}", + parameters, + op.RequestBody is not null, + responses, + $"{this.rootNamespace}.{this.GetClientName(clientTag)}Client", + $"{op.MethodName}Async", + requestBodyTypeName, + responseHeaders)); + } + + return result; + } + + /// + /// Describes the response headers an operation declares — mirroring 's + /// naming and typing so a caller can resolve $response.header.<name> against the generated + /// response property. Deduplicated by generated property name across all responses. + /// + /// The operation's responses. + /// The described response headers. + private ResponseHeaderInfo[] DescribeResponseHeaders(ResponseInfo[] responses) + { + var headers = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (ResponseInfo response in responses) + { + foreach (HeaderInfo header in response.Headers) + { + string propertyName = CodeEmitHelpers.HeaderNameToPropertyName(header.HeaderName) + "Header"; + if (!seen.Add(propertyName)) + { + continue; + } + + bool isString = header.SchemaPointer is null; + string typeName = isString ? "string" : this.ResolveSchemaTypeName(header.SchemaPointer); + headers.Add(new ResponseHeaderInfo(header.HeaderName, propertyName, typeName, isString)); + } + } + + return [.. headers]; + } + private IReadOnlyList EmitClientFiles( List operations, JsonElement specRoot, @@ -1801,11 +1921,11 @@ private OperationInfo PrepareOperation( string methodName = GetMethodName(operationId, opRef.Method, pathTemplate, opRef.CustomMethodName); ParameterInfo[] parameters = PrepareParameters( - opRef.Operation, opRef.PathItem, pathNameUtf8, opRef.Method, referenceResolver, opRef.CustomMethodName); + opRef.Operation, opRef.PathItem, pathNameUtf8, opRef.Method, referenceResolver, specRoot, opRef.CustomMethodName); RequestBodyInfo? requestBody = PrepareRequestBody( opRef.Operation, pathNameUtf8, opRef.Method, referenceResolver, opRef.CustomMethodName, this.ignoreEmptyFormUrlEncodedBody); ResponseInfo[] responses = PrepareResponses( - opRef.Operation, pathNameUtf8, opRef.Method, referenceResolver, opRef.CustomMethodName); + opRef.Operation, pathNameUtf8, opRef.Method, referenceResolver, specRoot, opRef.CustomMethodName); ServerInfo? effectiveServer = ResolveEffectiveServer( opRef.Operation, opRef.PathItem, rootServer); @@ -1837,6 +1957,7 @@ private static ParameterInfo[] PrepareParameters( ReadOnlySpan pathNameUtf8, OperationMethod method, IOpenApiReferenceResolver referenceResolver, + JsonElement specRoot, string? customMethodName = null) { var merged = MergeParameters(operation, pathItem, referenceResolver); @@ -1872,19 +1993,19 @@ private static ParameterInfo[] PrepareParameters( bool hasSchema = param.SchemaValue.IsNotUndefined(); JsonElement schemaElement = hasSchema ? JsonElement.From(param.SchemaValue) : default; ParameterSerializationKind serializationKind = hasSchema - ? SchemaClassifier.Classify(schemaElement) + ? SchemaClassifier.Classify(schemaElement, specRoot) : ParameterSerializationKind.String; ParameterSerializationKind elementKind = serializationKind switch { - ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaElement), - ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaElement), + ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaElement, specRoot), + ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaElement, specRoot), _ => ParameterSerializationKind.String, }; bool deepNesting = hasSchema && serializationKind is ParameterSerializationKind.Object or ParameterSerializationKind.Array - && SchemaClassifier.HasDeepNesting(schemaElement); + && SchemaClassifier.HasDeepNesting(schemaElement, specRoot); string? schemaPointerRegular = hasSchema ? (customMethodName is not null @@ -2081,6 +2202,7 @@ private static ResponseInfo[] PrepareResponses( ReadOnlySpan pathNameUtf8, OperationMethod method, IOpenApiReferenceResolver referenceResolver, + JsonElement specRoot, string? customMethodName = null) { if (operation.ResponsesValue.IsUndefined()) @@ -2120,7 +2242,7 @@ private static ResponseInfo[] PrepareResponses( response.ContentValue, pathNameUtf8, method, statusCodeUtf8.Span, customMethodName); HeaderInfo[] headers = PrepareResponseHeaders( - response.Headers, pathNameUtf8, method, statusCodeUtf8.Span, referenceResolver, customMethodName); + response.Headers, pathNameUtf8, method, statusCodeUtf8.Span, referenceResolver, specRoot, customMethodName); LinkInfo[] links = PrepareLinks(response.Links, referenceResolver, statusCode); @@ -2260,6 +2382,7 @@ private static HeaderInfo[] PrepareResponseHeaders( OperationMethod method, ReadOnlySpan statusCodeUtf8, IOpenApiReferenceResolver referenceResolver, + JsonElement specRoot, string? customMethodName = null) { if (headersMap.IsUndefined()) @@ -2308,19 +2431,19 @@ private static HeaderInfo[] PrepareResponseHeaders( JsonElement schemaEl = hasSchema ? JsonElement.From(header.SchemaValue) : default; ParameterSerializationKind serializationKind = hasSchema - ? SchemaClassifier.Classify(schemaEl) + ? SchemaClassifier.Classify(schemaEl, specRoot) : ParameterSerializationKind.String; ParameterSerializationKind elementKind = serializationKind switch { - ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaEl), - ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaEl), + ParameterSerializationKind.Array => SchemaClassifier.ClassifyArrayElement(schemaEl, specRoot), + ParameterSerializationKind.Object => SchemaClassifier.ClassifyObjectValue(schemaEl, specRoot), _ => ParameterSerializationKind.String, }; bool deepNesting = hasSchema && serializationKind is ParameterSerializationKind.Object or ParameterSerializationKind.Array - && SchemaClassifier.HasDeepNesting(schemaEl); + && SchemaClassifier.HasDeepNesting(schemaEl, specRoot); // Extract header name at the emit boundary string name = headerProp.Name; @@ -2908,6 +3031,39 @@ private string ResolveSchemaTypeName(string? schemaPointer) return "JsonElement"; } + // The request body's schema pointer (the key into contextSourceBodyPointers), picked by the same rule that picks its + // type name so the two always describe the same content entry. + private static string? ResolveRequestBodySchemaPointer(RequestBodyInfo requestBody) + { + foreach (ContentInfo content in requestBody.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType) + || CodeEmitHelpers.IsFormUrlEncodedMediaType(content.MediaType) + || CodeEmitHelpers.IsMultipartMediaType(content.MediaType)) + { + return content.SchemaPointer; + } + } + + return null; + } + + // Whether the operation can also be offered as a closure-free, single-materialisation generic overload: it must + // have a body that is materialised from a Source (not a raw stream, not multipart/mixed, whose parts are + // materialised individually), and that body's type must be one the model generator gives a Source. + private bool HasContextThreadedBody(OperationInfo op) + { + if (op.RequestBody is not { } requestBody + || IsRawStreamRequestBody(requestBody) + || IsMultipartMixedRequestBody(requestBody)) + { + return false; + } + + return ResolveRequestBodySchemaPointer(requestBody) is { } pointer + && this.contextSourceBodyPointers.Contains(pointer); + } + private string ResolveRequestBodyTypeName(RequestBodyInfo requestBody) { foreach (ContentInfo content in requestBody.Content) @@ -3207,6 +3363,36 @@ private static string EscapeJsonPointerSegment(string segment) return null; } + // The JSON response body's schema pointer (the key into contextSourceBodyPointers) — used to decide whether the body + // type carries a Source, and so whether a closure-free Ok overload can be emitted for it. + private string? ResolveResponseBodySchemaPointer(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType)) + { + return content.SchemaPointer; + } + } + + return null; + } + + // The JSON-ish media type the response declares for its body, picked by the same rule that picked the body's + // schema so the two always describe the same content entry. + private static string DeclaredJsonMediaType(ResponseInfo resp) + { + foreach (ContentInfo content in resp.Content) + { + if (CodeEmitHelpers.IsJsonMediaType(content.MediaType)) + { + return content.MediaType; + } + } + + return "application/json"; + } + private string? ResolveItemSchemaTypeName(ResponseInfo resp) { foreach (ContentInfo content in resp.Content) @@ -3275,10 +3461,37 @@ private static ContentCategory[] GetDistinctContentCategories(ResponseInfo resp) .ToArray(); } + /// + /// Determines whether a response is classified as a raw application/octet-stream body + /// (the same classification the client uses to expose a response ). + /// + private static bool IsOctetStreamResponse(ResponseInfo resp) + { + return Array.IndexOf(GetDistinctContentCategories(resp), ContentCategory.OctetStream) >= 0; + } + + /// + /// Returns if any 2xx success response of the operation is classified as a + /// raw application/octet-stream body, in which case the server result/endpoint emit a binary + /// response path mirroring the client's accessor. + /// + private static bool HasOctetStreamSuccessResponse(OperationInfo op) + { + foreach (ResponseInfo resp in op.Responses) + { + if (resp.StatusCode.Length > 0 && resp.StatusCode[0] == '2' && IsOctetStreamResponse(resp)) + { + return true; + } + } + + return false; + } + // ── Request struct emission ───────────────────────────────────────── private GeneratedFile EmitRequestStruct(OperationInfo op) { - string structName = $"{op.MethodName}Request"; + string structName = $"{op.MethodName}{GeneratedClientTypeNaming.RequestSuffix}"; IndentedWriter w = new(); CodeEmitHelpers.EmitHeader(w); @@ -3865,7 +4078,7 @@ private static void EmitBodyValidation( // ── Response struct emission ──────────────────────────────────────── private GeneratedFile EmitResponseStruct(OperationInfo op, List allOperations) { - string structName = $"{op.MethodName}Response"; + string structName = $"{op.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; IndentedWriter w = new(); CodeEmitHelpers.EmitHeader(w); @@ -4224,8 +4437,8 @@ private void EmitLinkMethod( } OperationInfo target = targetOp.Value; - string targetResponseType = $"{target.MethodName}Response"; - string targetRequestType = $"{target.MethodName}Request"; + string targetResponseType = $"{target.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; + string targetRequestType = $"{target.MethodName}{GeneratedClientTypeNaming.RequestSuffix}"; // Determine which target parameters are NOT satisfied by link bindings. HashSet boundParams = new(StringComparer.OrdinalIgnoreCase); @@ -5297,6 +5510,12 @@ private GeneratedFile EmitInterface( } this.EmitInterfaceMethodSignature(w, operations[i]); + + if (this.HasContextThreadedBody(operations[i])) + { + w.WriteLine(); + this.EmitInterfaceMethodSignature(w, operations[i], contextThreaded: true); + } } w.CloseBrace(); @@ -5581,9 +5800,9 @@ private static void EmitSecurityRequirements( w.WriteLine(); } - private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op) + private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op, bool contextThreaded = false) { - string responseName = $"{op.MethodName}Response"; + string responseName = $"{op.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; EmitMethodDoc(w, op); @@ -5592,9 +5811,25 @@ private void EmitInterfaceMethodSignature(IndentedWriter w, OperationInfo op) w.WriteLine("[Obsolete(\"This operation is deprecated.\")]"); } - List paramParts = this.BuildParameterList(op); + List paramParts = this.BuildParameterList(op, contextThreaded); + string generic = contextThreaded ? "" : string.Empty; w.WriteLine( - $"ValueTask<{responseName}> {op.MethodName}Async({string.Join(", ", paramParts)});"); + $"ValueTask<{responseName}> {op.MethodName}Async{generic}({string.Join(", ", paramParts)})" + + (contextThreaded ? string.Empty : ";")); + + if (contextThreaded) + { + EmitContextConstraint(w); + w.WriteLine(";"); + } + } + + // The constraint that lets a caller thread a ref struct through as context, guarded because it is a C# 13 feature. + private static void EmitContextConstraint(IndentedWriter w) + { + w.WriteLine("#if NET9_0_OR_GREATER"); + w.WriteLine(" where TContext : allows ref struct"); + w.WriteLine("#endif"); } // ── Implementation emission ───────────────────────────────────────── @@ -5637,6 +5872,14 @@ private GeneratedFile EmitImplementation( { w.WriteLine(); this.EmitClientMethod(w, operations[i], encodingFieldNames); + + // A body whose type carries a Source also gets the closure-free, single-materialisation form, + // matching what a server result factory already offers for a response body. + if (this.HasContextThreadedBody(operations[i])) + { + w.WriteLine(); + this.EmitClientMethod(w, operations[i], encodingFieldNames, contextThreaded: true); + } } w.WriteLine(); @@ -5668,6 +5911,12 @@ private GeneratedFile EmitImplementation( else if (hasBody) { needsSendWithBody = true; + + // An optional JSON body also emits a bodyless SendAsyncCore path for when the caller omits it. + if (!op.RequestBody!.Value.IsRequired && !HasRequestBasedExpressions(op)) + { + needsSendAsync = true; + } } else { @@ -5683,10 +5932,10 @@ private GeneratedFile EmitImplementation( return new GeneratedFile($"{clientName}Client.cs", w.ToString()); } - private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary encodingFieldNames) + private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary encodingFieldNames, bool contextThreaded = false) { - string requestName = $"{op.MethodName}Request"; - string responseName = $"{op.MethodName}Response"; + string requestName = $"{op.MethodName}{GeneratedClientTypeNaming.RequestSuffix}"; + string responseName = $"{op.MethodName}{GeneratedClientTypeNaming.ResponseSuffix}"; EmitMethodDoc(w, op); @@ -5695,16 +5944,22 @@ private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary paramParts = this.BuildParameterList(op); + List paramParts = this.BuildParameterList(op, contextThreaded); bool hasRequestExprLinks = HasRequestBasedExpressions(op); bool isMultipartMixedBody = op.RequestBody is not null && IsMultipartMixedRequestBody(op.RequestBody!.Value); + string generic = contextThreaded ? "" : string.Empty; w.WriteLine( - $"public ValueTask<{responseName}> {op.MethodName}Async(" + + $"public ValueTask<{responseName}> {op.MethodName}Async{generic}(" + $"{string.Join(", ", paramParts)})"); + if (contextThreaded) + { + EmitContextConstraint(w); + } + w.OpenBrace(); bool hasParams = op.Parameters.Length > 0; @@ -5739,14 +5994,32 @@ private void EmitClientMethod(IndentedWriter w, OperationInfo op, Dictionary(JsonWorkspace.CreateUnrented(), request, bodyValue, responseValidationMode, cancellationToken), request, {(hasRequestBodyExprLinks ? "bodyValue, " : "")}workspace);"); } + else if (optionalJsonBody) + { + // The caller supplied a body → send it; otherwise send the request bodyless (the server's + // optional-body path). This is the client counterpart to the optional request-body server fix. + w.WriteLine("if (hasBodyValue)"); + w.OpenBrace(); + w.WriteLine( + $"return SendWithBodyAsyncCore<{requestName}, {bodyTypeName}, " + + $"{responseName}>(workspace, request, bodyValue, responseValidationMode, cancellationToken);"); + w.CloseBrace(); + w.WriteLine(); + w.WriteLine( + $"return SendAsyncCore<{requestName}, " + + $"{responseName}>(workspace, request, responseValidationMode, cancellationToken);"); + } else { w.WriteLine( @@ -6191,7 +6491,7 @@ private static void EmitMethodDoc(IndentedWriter w, OperationInfo op) w.WriteLine("/// A cancellation token."); } - private List BuildParameterList(OperationInfo op) + private List BuildParameterList(OperationInfo op, bool contextThreaded = false) { List paramParts = []; @@ -6254,7 +6554,8 @@ private List BuildParameterList(OperationInfo op) { string bodyTypeName = this.ResolveRequestBodyTypeName(op.RequestBody.Value); string suffix = bodyRequired ? string.Empty : " = default"; - paramParts.Add($"{bodyTypeName}.Source body{suffix}"); + string sourceType = contextThreaded ? "Source" : "Source"; + paramParts.Add($"{bodyTypeName}.{sourceType} body{suffix}"); } } @@ -6742,6 +7043,8 @@ public static OperationSummary[] ListWebhookAndCallbackOperations( bool hasBody = opRef.Operation.RequestBody.IsNotUndefined(); + string methodName = GetMethodName(operationId, opRef.Method, path); + result.Add(new OperationSummary( path, opRef.Method, @@ -6750,7 +7053,10 @@ public static OperationSummary[] ListWebhookAndCallbackOperations( isDeprecated, paramCount, hasBody, - summary)); + summary, + methodName, + GeneratedClientTypeNaming.RequestTypeName(methodName), + GeneratedClientTypeNaming.ResponseTypeName(methodName))); } return [.. result]; @@ -6864,6 +7170,21 @@ private GeneratedFile EmitServerOperationParams(OperationInfo op) w.WriteLine($"/// {CodeEmitHelpers.EscapeXml(bodyDesc)}"); w.WriteLine("/// "); w.WriteLine($"public {bodyTypeName} Body {{ get; init; }}"); + + // For multipart/form-data bodies with format:binary parts, expose each binary part's + // raw bytes (mirrors the client sending BinaryPartData for the same parts). + if (IsMultipartRequestBody(rb) && !IsMultipartMixedRequestBody(rb)) + { + foreach (BinaryPropertyInfo binaryProp in rb.BinaryProperties) + { + string propName = CodeEmitHelpers.ToPascalCase(binaryProp.PropertyName); + w.WriteLine(); + w.WriteLine("/// "); + w.WriteLine($"/// Gets the binary content of the '{binaryProp.PropertyName}' part."); + w.WriteLine("/// "); + w.WriteLine($"public ReadOnlyMemory {propName} {{ get; init; }}"); + } + } } w.CloseBrace(); @@ -6905,6 +7226,7 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) bool hasHeaders = allHeaders.Count > 0; bool hasStreamingResponses = op.Responses.Any(r => GetStreamingContent(r).Count > 0); + bool hasBinaryResponse = HasOctetStreamSuccessResponse(op); string streamTypeName = $"{op.MethodName}Stream"; string streamWriterDelegateName = $"{op.MethodName}StreamWriter"; string streamWriterInvokerName = $"{op.MethodName}StreamWriterInvoker"; @@ -6929,6 +7251,11 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.Write($", {typeName} {fieldName} = default"); } + if (hasBinaryResponse) + { + w.Write(", bool hasBinaryBody = false, Func? binaryWriter = null"); + } + w.WriteLine(")"); w.OpenBrace(); w.WriteLine("this.StatusCode = statusCode;"); @@ -6945,6 +7272,12 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine($"this.{propertyName} = {fieldName};"); } + if (hasBinaryResponse) + { + w.WriteLine("this.HasBinaryBody = hasBinaryBody;"); + w.WriteLine("this.binaryWriter = binaryWriter;"); + } + w.CloseBrace(); } else @@ -6955,6 +7288,11 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.Write($", {streamWriterInvokerName}? streamWriter = null, object? streamWriterContext = null"); } + if (hasBinaryResponse) + { + w.Write(", bool hasBinaryBody = false, Func? binaryWriter = null"); + } + w.WriteLine(")"); w.OpenBrace(); w.WriteLine("this.StatusCode = statusCode;"); @@ -6966,6 +7304,12 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine("this.streamWriterContext = streamWriterContext;"); } + if (hasBinaryResponse) + { + w.WriteLine("this.HasBinaryBody = hasBinaryBody;"); + w.WriteLine("this.binaryWriter = binaryWriter;"); + } + w.CloseBrace(); } @@ -6977,6 +7321,12 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine(); } + if (hasBinaryResponse) + { + w.WriteLine("private readonly Func? binaryWriter;"); + w.WriteLine(); + } + w.WriteLine("/// Gets the HTTP status code."); w.WriteLine("public int StatusCode { get; }"); w.WriteLine(); @@ -6986,6 +7336,13 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.WriteLine("/// Gets the content type for the response body."); w.WriteLine("public string? ContentType { get; }"); + if (hasBinaryResponse) + { + w.WriteLine(); + w.WriteLine("/// Gets a value indicating whether this result has a raw binary (octet-stream) response body."); + w.WriteLine("public bool HasBinaryBody { get; }"); + } + if (hasStreamingResponses) { w.WriteLine(); @@ -7126,6 +7483,18 @@ private GeneratedFile EmitServerOperationResult(OperationInfo op) w.CloseBrace(); } + if (hasBinaryResponse) + { + w.WriteLine(); + w.WriteLine("/// "); + w.WriteLine("/// Writes the raw binary (octet-stream) response body to the specified stream."); + w.WriteLine("/// "); + w.WriteLine("/// The response stream."); + w.WriteLine("/// The cancellation token."); + w.WriteLine("/// A value task that completes when the body has been written."); + w.WriteLine("public ValueTask WriteBinaryBodyAsync(Stream stream, CancellationToken cancellationToken) => this.binaryWriter is { } writer ? writer(stream, cancellationToken) : ValueTask.CompletedTask;"); + } + if (hasStreamingResponses) { w.WriteLine(); @@ -7279,6 +7648,12 @@ private void EmitServerResultFactory( bool hasBody = bodyTypeName is not null && !hasStreamingBody; bool hasRespHeaders = respHeaders.Count > 0; + // The body carries a Source (object/array type) iff its schema pointer is in the context-source set; + // only then can a closure-free, single-materialisation generic overload be emitted alongside the non-generic one. + bool bodyHasContextSource = hasBody + && this.ResolveResponseBodySchemaPointer(response) is { } bodySchemaPointer + && this.contextSourceBodyPointers.Contains(bodySchemaPointer); + if (hasStreamingBody) { this.EmitServerStreamingResultFactories( @@ -7293,6 +7668,20 @@ private void EmitServerResultFactory( return; } + // For a 2xx success response classified as application/octet-stream, emit a factory that takes + // raw bytes (mirrors the client exposing a response Stream for the same response). + if (!isDefault && statusCode.Length > 0 && statusCode[0] == '2' && IsOctetStreamResponse(response)) + { + this.EmitServerBinaryResultFactory( + w, + structName, + factoryName, + respHeaders, + statusCode, + structHasHeaders); + return; + } + // Parameters StringBuilder paramList = new(); if (isDefault) @@ -7358,7 +7747,11 @@ private void EmitServerResultFactory( string bodyExpr = hasBody ? $"{bodyTypeName}.CreateBuilder(workspace, body, 30).RootElement" : "default"; - string contentTypeExpr = hasBody ? "\"application/json\"" : "null"; + + // The media type the response actually declares, not a literal "application/json". A specification declaring + // RFC 9457 problem documents means it: answering them as application/json tells a client the body is an + // ordinary result, and a client branching on the media type to find the problem shape never sees one. + string contentTypeExpr = hasBody ? $"\"{DeclaredJsonMediaType(response)}\"" : "null"; if (structHasHeaders) { @@ -7376,6 +7769,141 @@ private void EmitServerResultFactory( { w.WriteLine($"public static {structName} {factoryName}({paramList}) => new({statusExpr}, {bodyExpr}, {contentTypeExpr});"); } + + // Closure-free, single-materialisation sibling: takes the response body already assembled as a context-threaded + // Source (built via the model's Build) and routes it straight through CreateBuilder + // — no per-item closure on the caller side and no re-materialisation here. Only emitted for object/array bodies + // (bodyHasContextSource); a scalar body has no Source and uses the non-generic factory above. + if (bodyHasContextSource) + { + string genericParamList = paramList.ToString().Replace($"{bodyTypeName}.Source body", $"{bodyTypeName}.Source body"); + string genericBodyExpr = $"{bodyTypeName}.CreateBuilder(workspace, in body, 30).RootElement"; + + w.WriteLine("/// "); + w.WriteLine($"/// Creates a {statusCode} {factoryName} result from a context-threaded body, materialised in a single pass."); + w.WriteLine("/// "); + w.WriteLine("/// The type of the context carried by the body."); + if (isDefault) + { + w.WriteLine("/// The HTTP status code."); + } + + w.WriteLine("/// The context-threaded response body."); + w.WriteLine("/// The workspace for building the response value."); + foreach (var (header, _, fieldName, _) in respHeaders) + { + w.WriteLine($"/// The value for the {header.HeaderName} response header."); + } + + w.WriteLine($"/// A with status {statusCode}."); + + string genericCtorArgs; + if (structHasHeaders) + { + StringBuilder ctorArgs = new(); + ctorArgs.Append($"{statusExpr}, {genericBodyExpr}, {contentTypeExpr}"); + foreach (var (_, typeName, fieldName, _) in respHeaders) + { + ctorArgs.Append($", {fieldName}: {fieldName}.IsUndefined ? default : {typeName}.CreateBuilder(workspace, {fieldName}, 30).RootElement"); + } + + genericCtorArgs = ctorArgs.ToString(); + } + else + { + genericCtorArgs = $"{statusExpr}, {genericBodyExpr}, {contentTypeExpr}"; + } + + w.WriteLine($"public static {structName} {factoryName}({genericParamList})"); + w.WriteLine("#if NET9_0_OR_GREATER"); + w.WriteLine(" where TContext : allows ref struct"); + w.WriteLine("#endif"); + w.WriteLine($" => new({genericCtorArgs});"); + } + } + + /// + /// Emits the factories for a 2xx application/octet-stream response. Two overloads are + /// generated: one buffered (taking a ReadOnlyMemory<byte>) and one streaming + /// (taking a Func<Stream, CancellationToken, ValueTask> that writes the body + /// directly to the response stream). Both store a writer that the endpoint invokes via + /// WriteBinaryBodyAsync. + /// + private void EmitServerBinaryResultFactory( + IndentedWriter w, + string structName, + string factoryName, + List<(HeaderInfo Header, string TypeName, string FieldName, string PropertyName)> respHeaders, + string statusCode, + bool structHasHeaders) + { + bool hasRespHeaders = respHeaders.Count > 0; + + // Build the trailing constructor arguments (headers + binary flags) shared by both overloads. + // The writer expression varies between overloads, so it is appended by each call. + string HeaderCtorArgs() + { + StringBuilder sb = new(); + if (structHasHeaders) + { + foreach (var (_, typeName, fieldName, _) in respHeaders) + { + sb.Append($", {fieldName}: {fieldName}.IsUndefined ? default : {typeName}.CreateBuilder(workspace, {fieldName}, 30).RootElement"); + } + } + + return sb.ToString(); + } + + // The workspace is REQUIRED and contentType is optional, so the workspace has to precede it: emitting the + // header parameters as one trailing block put a required parameter after an optional one (CS1737), which the + // JSON factories never hit because they have no optional parameter before the workspace. + string RequiredParams() => hasRespHeaders ? ", JsonWorkspace workspace" : string.Empty; + + string OptionalHeaderParams() + { + StringBuilder sb = new(); + foreach (var (_, typeName, fieldName, _) in respHeaders) + { + sb.Append($", {typeName}.Source {fieldName} = default"); + } + + return sb.ToString(); + } + + void EmitHeaderDocs() + { + if (hasRespHeaders) + { + w.WriteLine("/// The workspace for building header values."); + } + + foreach (var (header, _, fieldName, _) in respHeaders) + { + w.WriteLine($"/// The value for the {header.HeaderName} response header."); + } + } + + string headerCtorArgs = HeaderCtorArgs(); + string requiredParams = RequiredParams(); + string optionalHeaderParams = OptionalHeaderParams(); + + // Buffered overload — copies the supplied bytes to the response stream when invoked. + w.WriteLine("/// The raw binary response body."); + w.WriteLine("/// The content type for the response body."); + EmitHeaderDocs(); + w.WriteLine($"/// A with status {statusCode}."); + w.WriteLine($"public static {structName} {factoryName}(ReadOnlyMemory body{requiredParams}, string? contentType = \"application/octet-stream\"{optionalHeaderParams}) => new({statusCode}, default, contentType{headerCtorArgs}, hasBinaryBody: true, binaryWriter: (stream, cancellationToken) => stream.WriteAsync(body, cancellationToken));"); + + w.WriteLine(); + + // Streaming overload — invokes the supplied callback to write the body directly. + w.WriteLine($"/// Creates a {statusCode} {factoryName} result whose body is streamed directly to the response."); + w.WriteLine("/// A callback that writes the response body to the supplied stream."); + w.WriteLine("/// The content type for the response body."); + EmitHeaderDocs(); + w.WriteLine($"/// A with status {statusCode}."); + w.WriteLine($"public static {structName} {factoryName}(Func writeBody{requiredParams}, string? contentType = \"application/octet-stream\"{optionalHeaderParams}) => new({statusCode}, default, contentType{headerCtorArgs}, hasBinaryBody: true, binaryWriter: writeBody);"); } private void EmitServerStreamingResultFactories( @@ -7696,6 +8224,10 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) // (mirrors client response pattern: workspace manages param/header document lifetimes) bool isRawStreamBody = hasBody && IsRawStreamRequestBody(op.RequestBody!.Value); string? bodyTypeName = hasBody && !isRawStreamBody ? this.ResolveRequestBodyTypeName(op.RequestBody!.Value) : null; + + // An optional (required: false) non-raw-stream request body is read only when the request actually + // carries one; an absent body must leave the body parameter undefined, not fail on an empty stream. + bool bodyOptional = hasBody && !isRawStreamBody && !op.RequestBody!.Value.IsRequired; w.WriteLine("JsonWorkspace workspace = JsonWorkspace.CreateUnrented();"); if (hasBody && !isRawStreamBody) { @@ -7760,6 +8292,14 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); } + if (bodyOptional) + { + w.WriteLine("// An optional request body is read only when the request actually carries one;"); + w.WriteLine("// an absent body leaves the body parameter undefined rather than failing to parse."); + w.WriteLine("if ((context.Request.ContentLength ?? 0) > 0 || context.Request.Headers.ContainsKey(\"Transfer-Encoding\"))"); + w.OpenBrace(); + } + if (IsRawStreamRequestBody(op.RequestBody!.Value)) { // Raw stream body — no parsing needed, pass context.Request.Body directly. @@ -7780,9 +8320,41 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) } else if (IsMultipartRequestBody(op.RequestBody!.Value)) { + BinaryPropertyInfo[] multipartBinaryParts = op.RequestBody!.Value.BinaryProperties; + bool hasBinaryParts = multipartBinaryParts.Length > 0; + + if (hasBinaryParts) + { + // Capture each format:binary part's bytes via the deserializer callback + // (mirrors the client sending BinaryPartData for the same parts). + foreach (BinaryPropertyInfo binaryPart in multipartBinaryParts) + { + w.WriteLine($"byte[]? __binary_{binaryPart.PropertyName} = null;"); + } + } + w.WriteLine("try"); w.OpenBrace(); - w.WriteLine($"bodyDoc = await MultipartFormDataSerializer.DeserializeAsync<{bodyTypeName}>(context.Request.Body, context.Request.ContentType, cancellationToken: context.RequestAborted).ConfigureAwait(false);"); + if (hasBinaryParts) + { + w.WriteLine($"bodyDoc = await MultipartFormDataSerializer.DeserializeAsync<{bodyTypeName}>(context.Request.Body, context.Request.ContentType, binaryPartCallback: part =>"); + w.OpenBrace(); + bool firstPart = true; + foreach (BinaryPropertyInfo binaryPart in multipartBinaryParts) + { + string keyword = firstPart ? "if" : "else if"; + firstPart = false; + w.WriteLine($"{keyword} (part.Name.SequenceEqual(\"{binaryPart.PropertyName}\"u8)) {{ __binary_{binaryPart.PropertyName} = part.Data.ToArray(); }}"); + } + + w.CloseBraceNoNewline().Write(", cancellationToken: context.RequestAborted).ConfigureAwait(false);"); + w.WriteLine(); + } + else + { + w.WriteLine($"bodyDoc = await MultipartFormDataSerializer.DeserializeAsync<{bodyTypeName}>(context.Request.Body, context.Request.ContentType, cancellationToken: context.RequestAborted).ConfigureAwait(false);"); + } + w.CloseBrace(); w.WriteLine("catch"); w.OpenBrace(); @@ -7822,6 +8394,11 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); EmitRequestBodySchemaValidation(w, bodyTypeName); } + + if (bodyOptional) + { + w.CloseBrace(); + } } w.WriteLine(); @@ -7844,10 +8421,24 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) { w.WriteLine("Body = context.Request.Body,"); } + else if (bodyOptional) + { + w.WriteLine("Body = bodyDoc is null ? default : bodyDoc.RootElement,"); + } else { w.WriteLine("Body = bodyDoc!.RootElement,"); } + + // Bind captured binary parts for multipart/form-data bodies. + if (IsMultipartRequestBody(op.RequestBody!.Value) && !IsMultipartMixedRequestBody(op.RequestBody!.Value)) + { + foreach (BinaryPropertyInfo binaryPart in op.RequestBody!.Value.BinaryProperties) + { + string propName = CodeEmitHelpers.ToPascalCase(binaryPart.PropertyName); + w.WriteLine($"{propName} = __binary_{binaryPart.PropertyName} ?? ReadOnlyMemory.Empty,"); + } + } } w.CloseBrace().Write(";"); @@ -7888,6 +8479,7 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); } + bool opHasBinaryResponse = HasOctetStreamSuccessResponse(op); if (opHasStreamingResponses) { w.WriteLine("if (result.HasStreamingBody)"); @@ -7912,6 +8504,21 @@ void EmitMapEndpointsDocComment(bool includeConfigureEndpoint) w.WriteLine(); w.WriteLine("await context.Response.BodyWriter.FlushAsync(context.RequestAborted).ConfigureAwait(false);"); w.CloseBrace(); + if (opHasBinaryResponse) + { + w.WriteLine("else if (result.HasBinaryBody)"); + EmitBinaryResponseWriteBlock(w); + w.WriteLine("else if (!result.Body.IsUndefined())"); + } + else + { + w.WriteLine("else if (!result.Body.IsUndefined())"); + } + } + else if (opHasBinaryResponse) + { + w.WriteLine("if (result.HasBinaryBody)"); + EmitBinaryResponseWriteBlock(w); w.WriteLine("else if (!result.Body.IsUndefined())"); } else @@ -8594,6 +9201,18 @@ private static bool ContainsRuntimeExpression(string pathTemplate) /// /// Emits an RFC 9457 Problem Details JSON response with the given status, title, and detail. /// + /// + /// Emits the response-writing block for a raw binary (octet-stream) result body, writing the + /// bytes directly to the response stream (mirrors the client reading a response stream). + /// + private static void EmitBinaryResponseWriteBlock(IndentedWriter w) + { + w.OpenBrace(); + w.WriteLine("context.Response.ContentType = result.ContentType ?? \"application/octet-stream\";"); + w.WriteLine("await result.WriteBinaryBodyAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);"); + w.CloseBrace(); + } + private static void EmitProblemDetailsResponse(IndentedWriter w, int statusCode, string title, string detail) { w.WriteLine($"context.Response.StatusCode = {statusCode};"); diff --git a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/ExternalReferenceResolverTests.cs b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/ExternalReferenceResolverTests.cs index 106c6f0dfa1..cb1a1286c85 100644 --- a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/ExternalReferenceResolverTests.cs +++ b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/ExternalReferenceResolverTests.cs @@ -891,4 +891,55 @@ public void PushResolvedBase_MultiLevel_ExternalRefsChainedAcrossThreeDocuments( resolver.TryResolve("#/components/schemas/Pet", out _), "After all pops, fragment should resolve against entry doc"); } + + // ══════════════════════════════════════════════════════════════════ + // Uri base constructor + virtualized external-document loader + // ══════════════════════════════════════════════════════════════════ + [TestMethod] + public void Constructor_NullBaseUri_ThrowsArgumentNullException() + { + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(EntryDoc); + + Assert.ThrowsExactly(() => + new ExternalReferenceResolver(doc.RootElement, (Uri)null!)); + } + + [TestMethod] + public void UriBase_FragmentOnly_ResolvesInEntryDoc() + { + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(EntryDoc); + using ExternalReferenceResolver resolver = new(doc.RootElement, new Uri("https://example.com/api/openapi.json")); + + Assert.IsTrue(resolver.TryResolve("#/components/schemas/Pet", out JsonElement pet)); + Assert.IsTrue(pet.TryGetProperty("properties"u8, out _)); + } + + [TestMethod] + public void ExternalDocumentLoader_ResolvesRelativeRefAgainstNonFileBase() + { + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(EntryDoc); + byte[] externalBytes = Encoding.UTF8.GetBytes(ExternalSchemaDoc); + + // The loader serves the document the relative ref resolves to (an http URI, never fetched). + using ExternalReferenceResolver resolver = new( + doc.RootElement, + new Uri("https://example.com/api/openapi.json"), + uri => uri.AbsoluteUri == "https://example.com/api/common.json" ? externalBytes : null); + + Assert.IsTrue(resolver.TryResolve("./common.json#/definitions/Error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("properties"u8, out _)); + + // A second resolution of the same doc is served from the owned cache (loader not needed again). + Assert.IsTrue(resolver.TryResolve("./common.json#/properties/id", out _)); + } + + [TestMethod] + public void ExternalDocumentLoader_ReturningNull_DoesNotResolve() + { + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(EntryDoc); + using ExternalReferenceResolver resolver = new( + doc.RootElement, new Uri("https://example.com/api/openapi.json"), _ => null); + + Assert.IsFalse(resolver.TryResolve("./missing.json#/definitions/Error", out _)); + } } \ No newline at end of file diff --git a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi30CodeGeneratorTests.cs b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi30CodeGeneratorTests.cs index 5a355d621aa..f17102b836e 100644 --- a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi30CodeGeneratorTests.cs +++ b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi30CodeGeneratorTests.cs @@ -7207,6 +7207,32 @@ public void GenerateServer_ProducesResultStructs() "Expected CreateItemResult.cs"); } + [TestMethod] + public void GenerateServer_ContextSourceBody_EmitsClosureFreeGenericFactory() + { + HashSet contextBodies = new( + CoverageSchemaTypeMap.Keys.Where(k => k.Contains("/responses/") && k.Contains("/content/")), + StringComparer.Ordinal); + OpenApi30CodeGenerator gen = new("CovTest.Server", CoverageSchemaTypeMap, contextSourceBodyPointers: contextBodies); + IReadOnlyList files = gen.GenerateServer(GetCoverageRoot()); + + string results = string.Concat(files.Where(f => f.FileName.EndsWith("Result.cs")).Select(f => f.Content)); + Assert.IsTrue(results.Contains(".Source body"), "Expected a Source body parameter on a generic result factory"); + Assert.IsTrue(results.Contains("where TContext : allows ref struct"), "Expected the allows-ref-struct constraint"); + Assert.IsTrue(results.Contains("CreateBuilder(workspace, in body, 30).RootElement"), "Expected a single-pass materialisation via CreateBuilder(in body)"); + Assert.IsTrue(results.Contains("Ok("), "Expected the non-generic factory to remain alongside the generic one"); + } + + [TestMethod] + public void GenerateServer_NoContextSourceSet_OmitsGenericFactory() + { + IReadOnlyList files = GenerateServerCoverageSpec(); + + string results = string.Concat(files.Where(f => f.FileName.EndsWith("Result.cs")).Select(f => f.Content)); + Assert.IsFalse(results.Contains(".Source body"), "Expected no generic result factory when no context-source set is supplied"); + Assert.IsTrue(results.Contains("Ok("), "Expected the non-generic factories"); + } + [TestMethod] public void GenerateServer_ResultStruct_HasFactoryMethods() { @@ -7708,4 +7734,218 @@ public void GenerateCallbackServer_RuntimeExpressionPathUsesRouteParameter() registration.Content.Contains("Route", StringComparison.Ordinal), "Generated MapApiEndpoints must accept a route parameter for runtime expression paths"); } + + [TestMethod] + public void GenerateServer_MultipartBinaryPart_EmitsBinaryParamAndCallback() + { + const string spec = """ + { + "openapi": "3.0.3", + "info": { "title": "MultiBin", "version": "1.0" }, + "paths": { + "/upload": { + "post": { + "operationId": "uploadFile", + "tags": ["uploads"], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "package": { "type": "string", "format": "binary" }, + "description": { "type": "string" } + } + } + } + } + }, + "responses": { "201": { "description": "Created" } } + } + } + } + } + """; + + JsonElement root = ParseSpec(spec); + Dictionary typeMap = new(StringComparer.Ordinal) + { + ["#/paths/~1upload/post/requestBody/content/multipart~1form-data/schema"] = "Test.UploadBody", + }; + OpenApi30CodeGenerator gen = new("Test", typeMap); + IReadOnlyList files = gen.GenerateServer(root); + + // Gap 1a: the Params struct exposes the binary part as a ReadOnlyMemory property. + GeneratedFile prms = GetFile(files, "UploadFileParams.cs"); + Assert.IsTrue( + prms.Content.Contains("public ReadOnlyMemory Package { get; init; }", StringComparison.Ordinal), + "Params should expose the 'package' binary part as a ReadOnlyMemory property"); + + // Gap 1b: the endpoint passes a binaryPartCallback that captures the part by name. + GeneratedFile registration = files.First(f => f.FileName == "ApiEndpointRegistration.cs"); + Assert.IsTrue( + registration.Content.Contains("binaryPartCallback: part =>", StringComparison.Ordinal), + "Endpoint should pass a binaryPartCallback to DeserializeAsync"); + Assert.IsTrue( + registration.Content.Contains("part.Name.SequenceEqual(\"package\"u8)", StringComparison.Ordinal), + "Callback should match the 'package' part by name"); + Assert.IsTrue( + registration.Content.Contains("Package = __binary_package ?? ReadOnlyMemory.Empty,", StringComparison.Ordinal), + "Params construction should bind the captured binary bytes"); + } + + [TestMethod] + public void GenerateServer_OctetStreamResponse_EmitsBinaryBodyFactoryAndWrite() + { + const string spec = """ + { + "openapi": "3.0.3", + "info": { "title": "StreamResp", "version": "1.0" }, + "paths": { + "/download": { + "get": { + "operationId": "download", + "tags": ["files"], + "responses": { + "200": { + "description": "The file", + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + } + } + } + } + } + } + } + """; + + JsonElement root = ParseSpec(spec); + OpenApi30CodeGenerator gen = new("Test", new Dictionary(StringComparer.Ordinal)); + IReadOnlyList files = gen.GenerateServer(root); + + // Gap 2a: the Result struct's Ok factory carries a binary body, offering both a buffered + // (ReadOnlyMemory) and a streaming (Func) + // overload, plus a WriteBinaryBodyAsync method to stream the body to the response. + GeneratedFile result = GetFile(files, "DownloadResult.cs"); + Assert.IsTrue( + result.Content.Contains("public bool HasBinaryBody { get; }", StringComparison.Ordinal), + "Result should expose HasBinaryBody"); + Assert.IsTrue( + result.Content.Contains("public static DownloadResult Ok(ReadOnlyMemory body", StringComparison.Ordinal), + "Result should expose an Ok(ReadOnlyMemory) factory"); + Assert.IsTrue( + result.Content.Contains("public static DownloadResult Ok(Func writeBody", StringComparison.Ordinal) + || result.Content.Contains("public static DownloadResult Ok(System.Func writeBody", StringComparison.Ordinal), + "Result should expose an Ok(Func) streaming factory"); + Assert.IsTrue( + result.Content.Contains("public ValueTask WriteBinaryBodyAsync(Stream stream, CancellationToken cancellationToken)", StringComparison.Ordinal), + "Result should expose WriteBinaryBodyAsync"); + + // Gap 2b: the endpoint streams the raw bytes to the response body via WriteBinaryBodyAsync. + GeneratedFile registration = files.First(f => f.FileName == "ApiEndpointRegistration.cs"); + Assert.IsTrue( + registration.Content.Contains("if (result.HasBinaryBody)", StringComparison.Ordinal), + "Endpoint should branch on result.HasBinaryBody"); + Assert.IsTrue( + registration.Content.Contains("await result.WriteBinaryBodyAsync(context.Response.Body", StringComparison.Ordinal), + "Endpoint should stream the raw binary body to the response stream"); + } + + private const string ParamRefSpecJson = """ + { + "openapi": "3.0.3", + "info": { "title": "ParamRef", "version": "1.0.0" }, + "paths": { + "/items/{id}": { + "get": { + "operationId": "getItem", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/VersionNumber" } }, + { "name": "limit", "in": "query", "schema": { "$ref": "#/components/schemas/PageLimit" } }, + { "name": "tag", "in": "query", "style": "form", "explode": true, "schema": { "$ref": "#/components/schemas/TagList" } }, + { "name": "filter", "in": "query", "style": "deepObject", "explode": true, "schema": { "$ref": "#/components/schemas/FilterObject" } } + ], + "responses": { "200": { "description": "ok" } } + } + } + }, + "components": { + "schemas": { + "VersionNumber": { "type": "integer", "format": "int32" }, + "PageLimit": { "type": "integer", "format": "int32" }, + "TagList": { "type": "array", "items": { "type": "string" } }, + "FilterObject": { + "type": "object", + "properties": { "name": { "type": "string" }, "active": { "type": "boolean" } } + } + } + } + } + """; + + private static Dictionary ParamRefSchemaTypeMap() => new(StringComparer.Ordinal) + { + ["#/paths/~1items~1{id}/get/parameters/0/schema"] = "ParamRef.Client.JsonInt32", + ["#/paths/~1items~1{id}/get/parameters/1/schema"] = "ParamRef.Client.JsonInt32", + ["#/paths/~1items~1{id}/get/parameters/2/schema"] = "ParamRef.Client.TagList", + ["#/paths/~1items~1{id}/get/parameters/3/schema"] = "ParamRef.Client.FilterObject", + }; + + [TestMethod] + public void Generate_ParamRefIntegerSchemasUseFormattingNotGetUtf8String() + { + JsonElement root = ParseSpec(ParamRefSpecJson); + OpenApi30CodeGenerator generator = new("ParamRef.Client", ParamRefSchemaTypeMap()); + IReadOnlyList files = generator.Generate(root); + + GeneratedFile requestFile = GetFile(files, "GetItemRequest.cs"); + + Assert.IsFalse( + requestFile.Content.Contains("GetUtf8String"), + "Expected no GetUtf8String() calls for $ref'd integer parameters"); + Assert.IsTrue( + requestFile.Content.Contains("this.Id.TryFormat("), + "Expected the int32 path param to be rendered with TryFormat"); + Assert.IsTrue( + requestFile.Content.Contains("this.Limit.TryFormat("), + "Expected the int32 query param to be rendered with TryFormat"); + } + + [TestMethod] + public void Generate_ParamRefArrayFormExplodeEmitsRepeatedQueryEntries() + { + JsonElement root = ParseSpec(ParamRefSpecJson); + OpenApi30CodeGenerator generator = new("ParamRef.Client", ParamRefSchemaTypeMap()); + IReadOnlyList files = generator.Generate(root); + + GeneratedFile requestFile = GetFile(files, "GetItemRequest.cs"); + + Assert.IsTrue( + requestFile.Content.Contains("((JsonElement)this.Tag).EnumerateArray()"), + "Expected the array param to be enumerated for form/explode serialization"); + Assert.IsTrue( + requestFile.Content.Contains("\"tag=\"u8"), + "Expected repeated 'tag=' query entries for the form/explode array param"); + } + + [TestMethod] + public void Generate_ParamRefObjectDeepObjectExplodeEmitsBracketedKeys() + { + JsonElement root = ParseSpec(ParamRefSpecJson); + OpenApi30CodeGenerator generator = new("ParamRef.Client", ParamRefSchemaTypeMap()); + IReadOnlyList files = generator.Generate(root); + + GeneratedFile requestFile = GetFile(files, "GetItemRequest.cs"); + + Assert.IsTrue( + requestFile.Content.Contains("((JsonElement)this.Filter).EnumerateObject()"), + "Expected the object param to be enumerated for deepObject serialization"); + Assert.IsTrue( + requestFile.Content.Contains("\"filter%5B\"u8"), + "Expected deepObject bracketed keys (filter[...]) for the object param"); + } } \ No newline at end of file diff --git a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi31CodeGeneratorTests.cs b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi31CodeGeneratorTests.cs index 5effcf382a0..05dfc402966 100644 --- a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi31CodeGeneratorTests.cs +++ b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi31CodeGeneratorTests.cs @@ -240,6 +240,66 @@ public void Generate_ProducesRequestFiles() Assert.IsTrue(files.Any(f => f.FileName == "ShowPetByIdRequest.cs")); } + [TestMethod] + public void DescribeOperations_SurfacesRequestPropertyMetadata() + { + OpenApi31CodeGenerator gen = CreateGenerator(); + IReadOnlyList operations = gen.DescribeOperations(petstoreRoot); + + OperationDescriptor showPet = operations.First(o => o.OperationId == "showPetById"); + Assert.AreEqual("/pets/{petId}", showPet.Path); + Assert.AreEqual(OperationMethod.Get, showPet.Method); + Assert.AreEqual("ShowPetById", showPet.MethodName); + Assert.AreEqual("Petstore.Client.ShowPetByIdRequest", showPet.RequestTypeName); + Assert.AreEqual("Petstore.Client.ShowPetByIdResponse", showPet.ResponseTypeName); + + RequestParameterInfo petId = showPet.RequestParameters.First(p => p.Name == "petId"); + Assert.AreEqual(ParameterLocation.Path, petId.Location); + Assert.AreEqual("PetId", petId.PropertyName); + Assert.AreEqual("Petstore.Client.JsonString", petId.TypeName); + Assert.IsTrue(petId.IsRequired); + Assert.AreEqual("petId", petId.ParameterName); + + ResponseDescriptor ok = showPet.Responses.First(r => r.StatusCode == "200"); + Assert.AreEqual("Petstore.Client.Pet", ok.BodyTypeName); + Assert.AreEqual("OkBody", ok.BodyPropertyName); + + // The generated client that exposes this operation (tag 'pets', default 'Api' prefix), the + // client method name (with the Async suffix), and — for a GET — no JSON request body. + Assert.AreEqual("Petstore.Client.ApiPetsClient", showPet.ClientTypeName); + Assert.AreEqual("ShowPetByIdAsync", showPet.ClientMethodName); + Assert.IsNull(showPet.RequestBodyTypeName); + } + + [TestMethod] + public void DescribeOperations_SurfacesRequestBodyTypeName() + { + OpenApi31CodeGenerator gen = CreateGenerator(); + IReadOnlyList operations = gen.DescribeOperations(petstoreRoot); + + OperationDescriptor createPet = operations.First(o => o.OperationId == "createPet"); + Assert.IsTrue(createPet.HasRequestBody); + + // The body type whose .Source is the client method's `body` parameter, so a caller binds via + // RequestBodyTypeName.From(source). + Assert.AreEqual("Petstore.Client.NewPet", createPet.RequestBodyTypeName); + Assert.AreEqual("CreatePetAsync", createPet.ClientMethodName); + Assert.AreEqual("Petstore.Client.ApiPetsClient", createPet.ClientTypeName); + } + + [TestMethod] + public void DescribeOperations_MarksOptionalQueryParameter() + { + OpenApi31CodeGenerator gen = CreateGenerator(); + IReadOnlyList operations = gen.DescribeOperations(petstoreRoot); + + OperationDescriptor listPets = operations.First(o => o.OperationId == "listPets"); + RequestParameterInfo limit = listPets.RequestParameters.First(); + Assert.AreEqual(ParameterLocation.Query, limit.Location); + Assert.IsFalse(limit.IsRequired); + Assert.AreEqual("Petstore.Client.JsonInt32", limit.TypeName); + } + [TestMethod] public void Generate_ProducesResponseFiles() { @@ -8209,6 +8269,32 @@ public void GenerateServer_ProducesResultStructs() "Expected CreateItemResult.cs"); } + [TestMethod] + public void GenerateServer_ContextSourceBody_EmitsClosureFreeGenericFactory() + { + HashSet contextBodies = new( + CoverageSchemaTypeMap.Keys.Where(k => k.Contains("/responses/") && k.Contains("/content/")), + StringComparer.Ordinal); + OpenApi31CodeGenerator gen = new("CovTest.Server", CoverageSchemaTypeMap, contextSourceBodyPointers: contextBodies); + IReadOnlyList files = gen.GenerateServer(GetCoverageRoot()); + + string results = string.Concat(files.Where(f => f.FileName.EndsWith("Result.cs")).Select(f => f.Content)); + Assert.IsTrue(results.Contains(".Source body"), "Expected a Source body parameter on a generic result factory"); + Assert.IsTrue(results.Contains("where TContext : allows ref struct"), "Expected the allows-ref-struct constraint"); + Assert.IsTrue(results.Contains("CreateBuilder(workspace, in body, 30).RootElement"), "Expected a single-pass materialisation via CreateBuilder(in body)"); + Assert.IsTrue(results.Contains("Ok("), "Expected the non-generic factory to remain alongside the generic one"); + } + + [TestMethod] + public void GenerateServer_NoContextSourceSet_OmitsGenericFactory() + { + IReadOnlyList files = GenerateServerCoverageSpec(); + + string results = string.Concat(files.Where(f => f.FileName.EndsWith("Result.cs")).Select(f => f.Content)); + Assert.IsFalse(results.Contains(".Source body"), "Expected no generic result factory when no context-source set is supplied"); + Assert.IsTrue(results.Contains("Ok("), "Expected the non-generic factories"); + } + [TestMethod] public void GenerateServer_ResultStruct_HasFactoryMethods() { @@ -8828,4 +8914,218 @@ public void GenerateCallbackServer_StaticPathUsesLiteralRoute() registration.Content.Contains("\"inventoryUpdate\"", StringComparison.Ordinal), "Static webhook path 'inventoryUpdate' should appear as a literal route"); } + + [TestMethod] + public void GenerateServer_MultipartBinaryPart_EmitsBinaryParamAndCallback() + { + const string spec = """ + { + "openapi": "3.1.0", + "info": { "title": "MultiBin", "version": "1.0" }, + "paths": { + "/upload": { + "post": { + "operationId": "uploadFile", + "tags": ["uploads"], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "package": { "type": "string", "format": "binary" }, + "description": { "type": "string" } + } + } + } + } + }, + "responses": { "201": { "description": "Created" } } + } + } + } + } + """; + + JsonElement root = ParseSpec(spec); + Dictionary typeMap = new(StringComparer.Ordinal) + { + ["#/paths/~1upload/post/requestBody/content/multipart~1form-data/schema"] = "Test.UploadBody", + }; + OpenApi31CodeGenerator gen = new("Test", typeMap); + IReadOnlyList files = gen.GenerateServer(root); + + // Gap 1a: the Params struct exposes the binary part as a ReadOnlyMemory property. + GeneratedFile prms = GetFile(files, "UploadFileParams.cs"); + Assert.IsTrue( + prms.Content.Contains("public ReadOnlyMemory Package { get; init; }", StringComparison.Ordinal), + "Params should expose the 'package' binary part as a ReadOnlyMemory property"); + + // Gap 1b: the endpoint passes a binaryPartCallback that captures the part by name. + GeneratedFile registration = files.First(f => f.FileName == "ApiEndpointRegistration.cs"); + Assert.IsTrue( + registration.Content.Contains("binaryPartCallback: part =>", StringComparison.Ordinal), + "Endpoint should pass a binaryPartCallback to DeserializeAsync"); + Assert.IsTrue( + registration.Content.Contains("part.Name.SequenceEqual(\"package\"u8)", StringComparison.Ordinal), + "Callback should match the 'package' part by name"); + Assert.IsTrue( + registration.Content.Contains("Package = __binary_package ?? ReadOnlyMemory.Empty,", StringComparison.Ordinal), + "Params construction should bind the captured binary bytes"); + } + + [TestMethod] + public void GenerateServer_OctetStreamResponse_EmitsBinaryBodyFactoryAndWrite() + { + const string spec = """ + { + "openapi": "3.1.0", + "info": { "title": "StreamResp", "version": "1.0" }, + "paths": { + "/download": { + "get": { + "operationId": "download", + "tags": ["files"], + "responses": { + "200": { + "description": "The file", + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + } + } + } + } + } + } + } + """; + + JsonElement root = ParseSpec(spec); + OpenApi31CodeGenerator gen = new("Test", new Dictionary(StringComparer.Ordinal)); + IReadOnlyList files = gen.GenerateServer(root); + + // Gap 2a: the Result struct exposes both a buffered and a streaming Ok factory and a + // WriteBinaryBodyAsync method that streams the body to a response stream. + GeneratedFile result = GetFile(files, "DownloadResult.cs"); + Assert.IsTrue( + result.Content.Contains("public bool HasBinaryBody { get; }", StringComparison.Ordinal), + "Result should expose HasBinaryBody"); + Assert.IsTrue( + result.Content.Contains("public static DownloadResult Ok(ReadOnlyMemory body", StringComparison.Ordinal), + "Result should expose a buffered Ok(ReadOnlyMemory) factory"); + Assert.IsTrue( + result.Content.Contains("public static DownloadResult Ok(Func) factory"); + Assert.IsTrue( + result.Content.Contains("public ValueTask WriteBinaryBodyAsync(Stream", StringComparison.Ordinal) + || result.Content.Contains("public ValueTask WriteBinaryBodyAsync(System.IO.Stream", StringComparison.Ordinal), + "Result should expose WriteBinaryBodyAsync(Stream, CancellationToken)"); + + // Gap 2b: the endpoint streams the raw body to the response. + GeneratedFile registration = files.First(f => f.FileName == "ApiEndpointRegistration.cs"); + Assert.IsTrue( + registration.Content.Contains("if (result.HasBinaryBody)", StringComparison.Ordinal), + "Endpoint should branch on result.HasBinaryBody"); + Assert.IsTrue( + registration.Content.Contains("await result.WriteBinaryBodyAsync(context.Response.Body", StringComparison.Ordinal), + "Endpoint should stream the binary body to the response"); + } + + private const string ParamRefSpecJson = """ + { + "openapi": "3.1.0", + "info": { "title": "ParamRef", "version": "1.0.0" }, + "paths": { + "/items/{id}": { + "get": { + "operationId": "getItem", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/VersionNumber" } }, + { "name": "limit", "in": "query", "schema": { "$ref": "#/components/schemas/PageLimit" } }, + { "name": "tag", "in": "query", "style": "form", "explode": true, "schema": { "$ref": "#/components/schemas/TagList" } }, + { "name": "filter", "in": "query", "style": "deepObject", "explode": true, "schema": { "$ref": "#/components/schemas/FilterObject" } } + ], + "responses": { "200": { "description": "ok" } } + } + } + }, + "components": { + "schemas": { + "VersionNumber": { "type": "integer", "format": "int32" }, + "PageLimit": { "type": "integer", "format": "int32" }, + "TagList": { "type": "array", "items": { "type": "string" } }, + "FilterObject": { + "type": "object", + "properties": { "name": { "type": "string" }, "active": { "type": "boolean" } } + } + } + } + } + """; + + private static Dictionary ParamRefSchemaTypeMap() => new(StringComparer.Ordinal) + { + ["#/paths/~1items~1{id}/get/parameters/0/schema"] = "ParamRef.Client.JsonInt32", + ["#/paths/~1items~1{id}/get/parameters/1/schema"] = "ParamRef.Client.JsonInt32", + ["#/paths/~1items~1{id}/get/parameters/2/schema"] = "ParamRef.Client.TagList", + ["#/paths/~1items~1{id}/get/parameters/3/schema"] = "ParamRef.Client.FilterObject", + }; + + [TestMethod] + public void Generate_ParamRefIntegerSchemasUseFormattingNotGetUtf8String() + { + JsonElement root = ParseSpec(ParamRefSpecJson); + OpenApi31CodeGenerator generator = new("ParamRef.Client", ParamRefSchemaTypeMap()); + IReadOnlyList files = generator.Generate(root); + + GeneratedFile requestFile = GetFile(files, "GetItemRequest.cs"); + + Assert.IsFalse( + requestFile.Content.Contains("GetUtf8String"), + "Expected no GetUtf8String() calls for $ref'd integer parameters"); + Assert.IsTrue( + requestFile.Content.Contains("this.Id.TryFormat("), + "Expected the int32 path param to be rendered with TryFormat"); + Assert.IsTrue( + requestFile.Content.Contains("this.Limit.TryFormat("), + "Expected the int32 query param to be rendered with TryFormat"); + } + + [TestMethod] + public void Generate_ParamRefArrayFormExplodeEmitsRepeatedQueryEntries() + { + JsonElement root = ParseSpec(ParamRefSpecJson); + OpenApi31CodeGenerator generator = new("ParamRef.Client", ParamRefSchemaTypeMap()); + IReadOnlyList files = generator.Generate(root); + + GeneratedFile requestFile = GetFile(files, "GetItemRequest.cs"); + + Assert.IsTrue( + requestFile.Content.Contains("((JsonElement)this.Tag).EnumerateArray()"), + "Expected the array param to be enumerated for form/explode serialization"); + Assert.IsTrue( + requestFile.Content.Contains("\"tag=\"u8"), + "Expected repeated 'tag=' query entries for the form/explode array param"); + } + + [TestMethod] + public void Generate_ParamRefObjectDeepObjectExplodeEmitsBracketedKeys() + { + JsonElement root = ParseSpec(ParamRefSpecJson); + OpenApi31CodeGenerator generator = new("ParamRef.Client", ParamRefSchemaTypeMap()); + IReadOnlyList files = generator.Generate(root); + + GeneratedFile requestFile = GetFile(files, "GetItemRequest.cs"); + + Assert.IsTrue( + requestFile.Content.Contains("((JsonElement)this.Filter).EnumerateObject()"), + "Expected the object param to be enumerated for deepObject serialization"); + Assert.IsTrue( + requestFile.Content.Contains("\"filter%5B\"u8"), + "Expected deepObject bracketed keys (filter[...]) for the object param"); + } } \ No newline at end of file diff --git a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi32CodeGeneratorTests.cs b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi32CodeGeneratorTests.cs index fba89c182e3..4e8caa981cc 100644 --- a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi32CodeGeneratorTests.cs +++ b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/OpenApi32CodeGeneratorTests.cs @@ -1023,6 +1023,42 @@ public void GenerateServer_ResultStruct_HasFactoryMethods() "Expected Created() factory method in CreateItemResult"); } + [TestMethod] + public void GenerateServer_ContextSourceBody_EmitsClosureFreeGenericFactory() + { + Dictionary schemaTypeMap = BuildFullCovspecSchemaTypeMap(); + + // Mark every JSON response-body schema pointer as carrying a Source (the object/array case); the + // generator must then emit the closure-free, single-materialisation generic factory beside the non-generic one. + HashSet contextBodies = new( + schemaTypeMap.Keys.Where(k => k.Contains("/responses/") && k.Contains("/content/")), + StringComparer.Ordinal); + OpenApi32CodeGenerator generator = new("CovTest.Server", schemaTypeMap, contextSourceBodyPointers: contextBodies); + IReadOnlyList files = generator.GenerateServer(covspecRoot); + + string results = string.Concat(files.Where(f => f.FileName.EndsWith("Result.cs")).Select(f => f.Content)); + + Assert.IsTrue(results.Contains(".Source body"), "Expected a Source body parameter on a generic result factory"); + Assert.IsTrue(results.Contains("where TContext : allows ref struct"), "Expected the allows-ref-struct constraint"); + Assert.IsTrue(results.Contains("CreateBuilder(workspace, in body, 30).RootElement"), "Expected a single-pass materialisation via CreateBuilder(in body)"); + Assert.IsTrue(results.Contains("Ok("), "Expected the non-generic factory to remain alongside the generic one"); + } + + [TestMethod] + public void GenerateServer_NoContextSourceSet_OmitsGenericFactory() + { + Dictionary schemaTypeMap = BuildFullCovspecSchemaTypeMap(); + + // No context-source set (the conservative default) — only the non-generic factories are emitted. + OpenApi32CodeGenerator generator = new("CovTest.Server", schemaTypeMap); + IReadOnlyList files = generator.GenerateServer(covspecRoot); + + string results = string.Concat(files.Where(f => f.FileName.EndsWith("Result.cs")).Select(f => f.Content)); + + Assert.IsFalse(results.Contains(".Source body"), "Expected no generic result factory when no context-source set is supplied"); + Assert.IsTrue(results.Contains("Ok("), "Expected the non-generic factories"); + } + [TestMethod] public void GenerateServer_StreamingResultStruct_HasPushWriterFactory() { @@ -2919,4 +2955,260 @@ public void GenerateCallbackServer_NoInvalidAspNetRouteCharacters() } } } + + [TestMethod] + public void GenerateServer_MultipartBinaryPart_EmitsBinaryParamAndCallback() + { + // A multipart/form-data request with a format:binary part should expose the part's bytes + // on the *Params struct and pass a binaryPartCallback in the endpoint registration. + JsonElement spec = ParseSpec(""" + { + "openapi": "3.2.0", + "info": { "title": "Multipart Binary", "version": "1.0" }, + "paths": { + "/upload": { + "post": { + "operationId": "uploadPackage", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "package": { "type": "string", "format": "binary" } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } + } + } + } + } + } + } + } + } + """); + + SchemaReference[] refs = [.. OpenApi32CodeGenerator.CollectSchemaPointers(spec, out _)]; + Dictionary map = new(StringComparer.Ordinal); + int i = 0; + foreach (SchemaReference r in refs) + { + map[r.PositionalPointer] = $"MpBin.Type{i}"; + i++; + } + + OpenApi32CodeGenerator gen = new("MpBin", map); + IReadOnlyList files = gen.GenerateServer(spec); + + GeneratedFile paramsFile = files.First(f => f.FileName == "UploadPackageParams.cs"); + Assert.IsTrue( + paramsFile.Content.Contains("public ReadOnlyMemory Package { get; init; }"), + "Expected ReadOnlyMemory Package property on Params for the binary multipart part"); + + GeneratedFile registration = files.First(f => f.FileName == "ApiEndpointRegistration.cs"); + Assert.IsTrue( + registration.Content.Contains("binaryPartCallback: part =>"), + "Expected a binaryPartCallback passed to MultipartFormDataSerializer.DeserializeAsync"); + Assert.IsTrue( + registration.Content.Contains("part.Name.SequenceEqual(\"package\"u8)"), + "Expected the callback to match the 'package' part by name"); + Assert.IsTrue( + registration.Content.Contains("Package = __binary_package ?? ReadOnlyMemory.Empty,"), + "Expected the captured binary part to be bound onto the Params object"); + } + + [TestMethod] + public void GenerateServer_OctetStreamResponse_EmitsBinaryFactoryAndWrite() + { + // A 2xx application/octet-stream success response should produce BOTH a buffered + // Ok(ReadOnlyMemory) factory and a streaming Ok(Func) + // factory, plus a WriteBinaryBodyAsync method, and an endpoint that streams the body directly + // to the response stream. + JsonElement spec = ParseSpec(""" + { + "openapi": "3.2.0", + "info": { "title": "Octet Response", "version": "1.0" }, + "paths": { + "/download": { + "get": { + "operationId": "downloadBlob", + "responses": { + "200": { + "description": "OK", + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { "type": "object", "properties": { "message": { "type": "string" } } } + } + } + } + } + } + } + } + } + """); + + SchemaReference[] refs = [.. OpenApi32CodeGenerator.CollectSchemaPointers(spec, out _)]; + Dictionary map = new(StringComparer.Ordinal); + int i = 0; + foreach (SchemaReference r in refs) + { + map[r.PositionalPointer] = $"OctRsp.Type{i}"; + i++; + } + + OpenApi32CodeGenerator gen = new("OctRsp", map); + IReadOnlyList files = gen.GenerateServer(spec); + + GeneratedFile resultFile = files.First(f => f.FileName == "DownloadBlobResult.cs"); + Assert.IsTrue( + resultFile.Content.Contains("public bool HasBinaryBody { get; }"), + "Expected HasBinaryBody property on the Result struct"); + Assert.IsFalse( + resultFile.Content.Contains("public ReadOnlyMemory BinaryBody { get; }"), + "Did not expect a BinaryBody property on the Result struct after revision"); + Assert.IsTrue( + resultFile.Content.Contains("public static DownloadBlobResult Ok(ReadOnlyMemory body, string? contentType = \"application/octet-stream\")"), + "Expected a buffered Ok(ReadOnlyMemory) factory for the octet-stream success response"); + Assert.IsTrue( + resultFile.Content.Contains("public static DownloadBlobResult Ok(Func writeBody, string? contentType = \"application/octet-stream\")"), + "Expected a streaming Ok(Func) factory for the octet-stream success response"); + Assert.IsTrue( + resultFile.Content.Contains("public ValueTask WriteBinaryBodyAsync(Stream stream, CancellationToken cancellationToken)"), + "Expected a WriteBinaryBodyAsync method on the Result struct"); + + GeneratedFile registration = files.First(f => f.FileName == "ApiEndpointRegistration.cs"); + Assert.IsTrue( + registration.Content.Contains("if (result.HasBinaryBody)"), + "Expected a HasBinaryBody branch in the endpoint response-writing block"); + Assert.IsTrue( + registration.Content.Contains("await result.WriteBinaryBodyAsync(context.Response.Body, context.RequestAborted)"), + "Expected the endpoint to stream the body directly to the response stream"); + } + + private const string ParamRefSpecJson = """ + { + "openapi": "3.2.0", + "info": { "title": "ParamRef", "version": "1.0.0" }, + "paths": { + "/items/{id}": { + "get": { + "operationId": "getItem", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/VersionNumber" } }, + { "name": "limit", "in": "query", "schema": { "$ref": "#/components/schemas/PageLimit" } }, + { "name": "tag", "in": "query", "style": "form", "explode": true, "schema": { "$ref": "#/components/schemas/TagList" } }, + { "name": "filter", "in": "query", "style": "deepObject", "explode": true, "schema": { "$ref": "#/components/schemas/FilterObject" } } + ], + "responses": { "200": { "description": "ok" } } + } + } + }, + "components": { + "schemas": { + "VersionNumber": { "type": "integer", "format": "int32" }, + "PageLimit": { "type": "integer", "format": "int32" }, + "TagList": { "type": "array", "items": { "type": "string" } }, + "FilterObject": { + "type": "object", + "properties": { "name": { "type": "string" }, "active": { "type": "boolean" } } + } + } + } + } + """; + + private static Dictionary ParamRefSchemaTypeMap() => new(StringComparer.Ordinal) + { + ["#/paths/~1items~1{id}/get/parameters/0/schema"] = "ParamRef.Client.JsonInt32", + ["#/paths/~1items~1{id}/get/parameters/1/schema"] = "ParamRef.Client.JsonInt32", + ["#/paths/~1items~1{id}/get/parameters/2/schema"] = "ParamRef.Client.TagList", + ["#/paths/~1items~1{id}/get/parameters/3/schema"] = "ParamRef.Client.FilterObject", + }; + + [TestMethod] + public void Generate_ParamRefIntegerSchemasUseFormattingNotGetUtf8String() + { + JsonElement root = ParseSpec(ParamRefSpecJson); + OpenApi32CodeGenerator generator = new("ParamRef.Client", ParamRefSchemaTypeMap()); + IReadOnlyList files = generator.Generate(root); + + GeneratedFile requestFile = GetFile(files, "GetItemRequest.cs"); + + // A $ref to an integer schema must classify as a formattable number, so the + // generated request must NOT serialize the parameter via GetUtf8String(), which + // throws at runtime for a JSON number. + Assert.IsFalse( + requestFile.Content.Contains("GetUtf8String"), + "Expected no GetUtf8String() calls for $ref'd integer parameters"); + + // The int32 path param is rendered via TryFormat (the non-String scalar path). + Assert.IsTrue( + requestFile.Content.Contains("this.Id.TryFormat("), + "Expected the int32 path param to be rendered with TryFormat"); + + // The int32 query param is rendered via TryFormat. + Assert.IsTrue( + requestFile.Content.Contains("this.Limit.TryFormat("), + "Expected the int32 query param to be rendered with TryFormat"); + } + + [TestMethod] + public void Generate_ParamRefArrayFormExplodeEmitsRepeatedQueryEntries() + { + JsonElement root = ParseSpec(ParamRefSpecJson); + OpenApi32CodeGenerator generator = new("ParamRef.Client", ParamRefSchemaTypeMap()); + IReadOnlyList files = generator.Generate(root); + + GeneratedFile requestFile = GetFile(files, "GetItemRequest.cs"); + + // A $ref to an array-of-string schema with style:form, explode:true must take the + // emitter's Form && Explode && Array branch: enumerate the array and emit a repeated + // "tag=" entry per element. + Assert.IsTrue( + requestFile.Content.Contains("((JsonElement)this.Tag).EnumerateArray()"), + "Expected the array param to be enumerated for form/explode serialization"); + Assert.IsTrue( + requestFile.Content.Contains("\"tag=\"u8"), + "Expected repeated 'tag=' query entries for the form/explode array param"); + } + + [TestMethod] + public void Generate_ParamRefObjectDeepObjectExplodeEmitsBracketedKeys() + { + JsonElement root = ParseSpec(ParamRefSpecJson); + OpenApi32CodeGenerator generator = new("ParamRef.Client", ParamRefSchemaTypeMap()); + IReadOnlyList files = generator.Generate(root); + + GeneratedFile requestFile = GetFile(files, "GetItemRequest.cs"); + + // A $ref to an object schema with style:deepObject, explode:true must take the + // emitter's deepObject branch: enumerate the object and emit "filter[key]=value" + // (the '[' and ']' are percent-encoded as %5B / %5D). + Assert.IsTrue( + requestFile.Content.Contains("((JsonElement)this.Filter).EnumerateObject()"), + "Expected the object param to be enumerated for deepObject serialization"); + Assert.IsTrue( + requestFile.Content.Contains("\"filter%5B\"u8"), + "Expected deepObject bracketed keys (filter[...]) for the object param"); + } } \ No newline at end of file diff --git a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.0.json b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.0.json index 7cc47a9b87e..39f6ec5593b 100644 --- a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.0.json +++ b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.0.json @@ -23,6 +23,32 @@ } ], "paths": { + "/optional-body-probe": { + "post": { + "operationId": "optionalBodyProbe", + "summary": "Regression probe: an optional request body that is absent must not fail to parse.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "204": { + "description": "Accepted, with or without a body." + } + } + } + }, "/items": { "servers": [ { diff --git a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.1.json b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.1.json index d6ecbf8a35a..1b1fb32041b 100644 --- a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.1.json +++ b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.1.json @@ -23,6 +23,32 @@ } ], "paths": { + "/optional-body-probe": { + "post": { + "operationId": "optionalBodyProbe", + "summary": "Regression probe: an optional request body that is absent must not fail to parse.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "204": { + "description": "Accepted, with or without a body." + } + } + } + }, "/items": { "servers": [ { diff --git a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.2.json b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.2.json index eaedce698b8..b5ae7adaf75 100644 --- a/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.2.json +++ b/tests/Corvus.Text.Json.OpenApi.CodeGeneration.Tests/TestData/covspec-3.2.json @@ -23,6 +23,32 @@ } ], "paths": { + "/optional-body-probe": { + "post": { + "operationId": "optionalBodyProbe", + "summary": "Regression probe: an optional request body that is absent must not fail to parse.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "204": { + "description": "Accepted, with or without a body." + } + } + } + }, "/items": { "servers": [ { @@ -108,7 +134,7 @@ "apiKeyAuth": [] } ], - "description": "Returns all items matching optional filters." + "description": "Returns all items matching optional filters.\n\nA second paragraph, because an OpenAPI description is CommonMark and multi-line prose is ordinary.\nA wrapped line within that paragraph, which is a wrap rather than a new paragraph.\n\nA third paragraph, so more than one paragraph boundary is exercised." }, "post": { "operationId": "createItem", @@ -268,7 +294,7 @@ } }, "X-Request-Id": { - "description": "Request correlation ID (no schema — raw string)" + "description": "Request correlation ID (no schema \u00e2\u20ac\u201d raw string)" }, "X-Tags": { "schema": { @@ -560,6 +586,21 @@ "format": "binary" } } + }, + "headers": { + "ETag": { + "description": "The exported representation's entity tag.", + "schema": { + "type": "string" + } + }, + "X-Export-Sequence": { + "description": "The export's monotonic sequence.", + "schema": { + "type": "integer", + "format": "int64" + } + } } }, "default": { diff --git a/tests/Corvus.Text.Json.OpenApi.HttpTransport.Tests/HttpClientApiTransportFactoryTests.cs b/tests/Corvus.Text.Json.OpenApi.HttpTransport.Tests/HttpClientApiTransportFactoryTests.cs new file mode 100644 index 00000000000..f79a82cee52 --- /dev/null +++ b/tests/Corvus.Text.Json.OpenApi.HttpTransport.Tests/HttpClientApiTransportFactoryTests.cs @@ -0,0 +1,46 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using Corvus.Text.Json.OpenApi; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Corvus.Text.Json.OpenApi.HttpTransport.Tests; + +[TestClass] +public class HttpClientApiTransportFactoryTests +{ + [TestMethod] + public async Task Creates_a_fresh_transport_per_call_over_the_shared_client() + { + using var client = new HttpClient { BaseAddress = new Uri("https://example.test/") }; + var factory = new HttpClientApiTransportFactory(client); + + await using IApiTransport first = factory.CreateTransport(); + await using IApiTransport second = factory.CreateTransport(); + + Assert.IsNotNull(first); + Assert.IsNotNull(second); + Assert.AreNotSame(first, second); + } + + [TestMethod] + public async Task Disposing_a_created_transport_leaves_the_shared_client_open() + { + using var client = new HttpClient { BaseAddress = new Uri("https://example.test/") }; + var factory = new HttpClientApiTransportFactory(client); + + IApiTransport transport = factory.CreateTransport(); + await transport.DisposeAsync(); + + // The client was not disposed by the transport (disposeClient: false), so the factory can keep using it. + await using IApiTransport next = factory.CreateTransport(); + Assert.IsNotNull(next); + } + + [TestMethod] + public void A_null_client_is_rejected() + { + Assert.ThrowsExactly(() => new HttpClientApiTransportFactory(null!)); + } +} \ No newline at end of file diff --git a/tests/Corvus.Text.Json.OpenApi.HttpTransport.Tests/HttpClientTransportTests.cs b/tests/Corvus.Text.Json.OpenApi.HttpTransport.Tests/HttpClientTransportTests.cs index b4bc4304447..25656db20e7 100644 --- a/tests/Corvus.Text.Json.OpenApi.HttpTransport.Tests/HttpClientTransportTests.cs +++ b/tests/Corvus.Text.Json.OpenApi.HttpTransport.Tests/HttpClientTransportTests.cs @@ -350,6 +350,106 @@ public async Task SendAsync_PathAndQueryParameters_WithPathPrefixedBaseAddress_A Assert.AreEqual("https://apim.example/inventory/pets/42?limit=10", captured.RequestUri?.AbsoluteUri); } + [TestMethod] + public async Task SendAsync_BaseUrlOverrideWithPathPrefix_PrefixPreserved() + { + HttpRequestMessage? captured = null; + + using HttpClient client = CreateMockClient( + HttpStatusCode.OK, + "[]"u8.ToArray(), + onRequest: req => { captured = req; return Task.CompletedTask; }); + + await using HttpClientTransport transport = new( + client, + baseUrlOverride: _ => new ValueTask(new Uri("https://dev.example/env/"))); + + TestGetRequest request = default; + await using TestResponse response = + await transport.SendAsync(in request); + + Assert.IsNotNull(captured); + Assert.AreEqual("https://dev.example/env/pets/1", captured.RequestUri?.AbsoluteUri); + } + + [TestMethod] + public async Task SendAsync_BaseUrlOverride_TakesPrecedenceOverPathPrefixedBaseAddress() + { + HttpRequestMessage? captured = null; + + using HttpClient client = CreateMockClient( + HttpStatusCode.OK, + "[]"u8.ToArray(), + onRequest: req => { captured = req; return Task.CompletedTask; }, + baseAddress: "https://apim.example/inventory/"); + + await using HttpClientTransport transport = new( + client, + baseUrlOverride: _ => new ValueTask(new Uri("https://dev.example/env/"))); + + TestGetRequest request = default; + await using TestResponse response = + await transport.SendAsync(in request); + + Assert.IsNotNull(captured); + Assert.AreEqual("https://dev.example/env/pets/1", captured.RequestUri?.AbsoluteUri); + } + + [TestMethod] + public async Task SendAsync_BaseUrlOverrideResolvesNull_FallsBackToBaseAddress() + { + HttpRequestMessage? captured = null; + + using HttpClient client = CreateMockClient( + HttpStatusCode.OK, + "[]"u8.ToArray(), + onRequest: req => { captured = req; return Task.CompletedTask; }, + baseAddress: "https://apim.example/inventory/"); + + await using HttpClientTransport transport = new( + client, + baseUrlOverride: _ => new ValueTask((Uri?)null)); + + TestGetRequest request = default; + await using TestResponse response = + await transport.SendAsync(in request); + + Assert.IsNotNull(captured); + Assert.AreEqual("https://apim.example/inventory/pets/1", captured.RequestUri?.AbsoluteUri); + } + + [TestMethod] + public async Task SendAsync_BaseUrlOverride_ResolvedOnceAcrossSends() + { + int resolutions = 0; + + using HttpClient client = CreateMockClient( + HttpStatusCode.OK, + "[]"u8.ToArray(), + onRequest: _ => Task.CompletedTask); + + await using HttpClientTransport transport = new( + client, + baseUrlOverride: _ => + { + resolutions++; + return new ValueTask(new Uri("https://dev.example/env/")); + }); + + TestGetRequest request = default; + await using (TestResponse response = + await transport.SendAsync(in request)) + { + } + + await using (TestResponse response = + await transport.SendAsync(in request)) + { + } + + Assert.AreEqual(1, resolutions); + } + [TestMethod] public async Task SendAsync_NoBaseAddress_ThrowsInvalidOperationException() { diff --git a/tests/Corvus.Text.Json.OpenApi30.Runtime.Tests/GeneratedClientEndToEndTests.cs b/tests/Corvus.Text.Json.OpenApi30.Runtime.Tests/GeneratedClientEndToEndTests.cs index efd2bbac764..037add787fb 100644 --- a/tests/Corvus.Text.Json.OpenApi30.Runtime.Tests/GeneratedClientEndToEndTests.cs +++ b/tests/Corvus.Text.Json.OpenApi30.Runtime.Tests/GeneratedClientEndToEndTests.cs @@ -2002,6 +2002,41 @@ public async Task Client_ApiItemsClient_OptionsItemsAsync() Assert.AreEqual("http://localhost/items", harness.CapturedRequest.RequestUri!.OriginalString); } + [TestMethod] + public async Task Client_ApiItemsClient_AddItemNoteAsync_OmittedOptionalBody_SendsNoBody() + { + using var harness = new TestHarness(HttpStatusCode.NoContent, string.Empty); + var client = new ApiItemsClient(harness.Transport); + + // The optional request body is omitted → the request must go out with NO body (the server treats the body + // as optional too). Regression for the client-side optional request-body generator fix: previously the + // generated client always materialised + sent a body, faulting on the undefined Source. + await using AddItemNoteResponse response = await client.AddItemNoteAsync("item-1"); + + Assert.AreEqual(204, response.StatusCode); + Assert.AreEqual(HttpMethod.Post, harness.CapturedRequest!.Method); + Assert.AreEqual("http://localhost/items/item-1/note", harness.CapturedRequest.RequestUri!.OriginalString); + Assert.IsNull(harness.CapturedRequest.Content, "an omitted optional body must not send a request body"); + } + + [TestMethod] + public async Task Client_ApiItemsClient_AddItemNoteAsync_SuppliedOptionalBody_SendsJsonBody() + { + using var harness = new TestHarness(HttpStatusCode.NoContent, string.Empty); + var client = new ApiItemsClient(harness.Transport); + + using var bodyDoc = ParsedJsonDocument.Parse("""{"note":"ship it"}"""); + + // The same optional-body operation still sends the body when one IS supplied. + await using AddItemNoteResponse response = await client.AddItemNoteAsync("item-1", bodyDoc.RootElement); + + Assert.AreEqual(204, response.StatusCode); + Assert.AreEqual(HttpMethod.Post, harness.CapturedRequest!.Method); + Assert.AreEqual("application/json", harness.CapturedRequest.Content?.Headers.ContentType?.MediaType); + byte[] sent = await harness.CapturedRequest.Content!.ReadAsByteArrayAsync(); + Assert.IsTrue(Encoding.UTF8.GetString(sent).Contains("\"note\":\"ship it\"", StringComparison.Ordinal)); + } + [TestMethod] public async Task Client_ApiOrdersClient_GetOrderAsync() { diff --git a/tests/Corvus.Text.Json.OpenApi30.Runtime.Tests/TestData/canonicalization-spec-3.0.json b/tests/Corvus.Text.Json.OpenApi30.Runtime.Tests/TestData/canonicalization-spec-3.0.json index d5bd106d007..27c6d5df501 100644 --- a/tests/Corvus.Text.Json.OpenApi30.Runtime.Tests/TestData/canonicalization-spec-3.0.json +++ b/tests/Corvus.Text.Json.OpenApi30.Runtime.Tests/TestData/canonicalization-spec-3.0.json @@ -388,6 +388,45 @@ } } }, + "/items/{itemId}/note": { + "post": { + "operationId": "addItemNote", + "summary": "Optional request body — omitting it must send no body", + "tags": [ + "items" + ], + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/items/{itemId}/details": { "get": { "operationId": "getItemDetails", diff --git a/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/ConfigureEndpointHookTests.cs b/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/ConfigureEndpointHookTests.cs index 143be35985d..97d77230352 100644 --- a/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/ConfigureEndpointHookTests.cs +++ b/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/ConfigureEndpointHookTests.cs @@ -57,7 +57,7 @@ [.. endpoint.SecurityRequirements.SelectMany(s => s.Requirements).Select(r => r. using IHost host = await BuildHostAsync(Capture, withAuth: false); // The callback fires once per generated endpoint. - Assert.AreEqual(22, recorded.Count, "Callback should be invoked once per generated endpoint"); + Assert.AreEqual(23, recorded.Count, "Callback should be invoked once per generated endpoint"); // Descriptor fields are accurate for a known operation (GET /items -> ListItems). RecordedEndpoint listItems = recorded.Single(r => r.MethodName == "ListItems"); diff --git a/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs b/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs index 6101a9c02e5..928b3fb8a92 100644 --- a/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs +++ b/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs @@ -124,7 +124,29 @@ public async Task UploadItemData_WithMultipartBody_ReturnsCreated() public async Task DownloadFile_WhenRequested_ReturnsOk() { HttpResponseMessage response = await Client.GetAsync("/download"); - await AssertEmptyResponseAsync(response, HttpStatusCode.OK); + + // The download endpoint returns its file content (the mock returns "file-content"); the previous assertion + // expected an empty body, which never matched the handler. Assert the real contract: status + body. + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("file-content", await response.Content.ReadAsStringAsync()); + } + + [TestMethod] + public async Task OptionalBodyProbe_WithoutBody_ReturnsNoContent() + { + // Regression (optional request bodies): a body-less POST to an operation whose requestBody is required:false + // must not be rejected by the dispatch — the empty body binds undefined and the handler runs, returning 204. + HttpResponseMessage response = await Client.PostAsync("/optional-body-probe", null); + Assert.AreEqual(HttpStatusCode.NoContent, response.StatusCode); + } + + [TestMethod] + public async Task OptionalBodyProbe_WithBody_ReturnsNoContent() + { + // The same operation still accepts (and parses) a body when one is supplied. + StringContent content = new("""{"note":"hello"}""", Encoding.UTF8, "application/json"); + HttpResponseMessage response = await Client.PostAsync("/optional-body-probe", content); + Assert.AreEqual(HttpStatusCode.NoContent, response.StatusCode); } [TestMethod] @@ -760,13 +782,6 @@ private static async Task AssertJsonResponseAsync(HttpResponseMessage response, Assert.AreEqual(expectedBody, body); } - private static async Task AssertEmptyResponseAsync(HttpResponseMessage response, HttpStatusCode expectedStatusCode) - { - string body = await response.Content.ReadAsStringAsync(); - Assert.AreEqual(expectedStatusCode, response.StatusCode); - Assert.AreEqual(string.Empty, body); - } - private static async Task AssertProblemDetailsAsync(HttpResponseMessage response, HttpStatusCode expectedStatusCode, string expectedDetail) { string title = expectedStatusCode == HttpStatusCode.BadRequest ? "Bad Request" : "Internal Server Error"; diff --git a/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/MockHandlers.cs b/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/MockHandlers.cs index b30461f8d33..d0c6af8e982 100644 --- a/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/MockHandlers.cs +++ b/tests/Corvus.Text.Json.OpenApi30.Server.Runtime.Tests/MockHandlers.cs @@ -64,7 +64,12 @@ public ValueTask HandleUploadItemDataAsync(UploadItemDataP } public ValueTask HandleDownloadFileAsync(DownloadFileParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) - => new(DownloadFileResult.Ok()); + => new(DownloadFileResult.Ok("file-content"u8.ToArray())); + + // The optional-body probe runs only if the dispatch did not reject a body-less request: an absent optional body + // must bind undefined, not 400. The handler ignores the body and returns 204. + public ValueTask HandleOptionalBodyProbeAsync(OptionalBodyProbeParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) + => new(OptionalBodyProbeResult.NoContent()); public ValueTask HandleGetQuirkyAsync(GetQuirkyParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) => new(GetQuirkyResult.Ok(ReturnInvalidResponse ? InvalidItemBody : DefaultItem, workspace)); diff --git a/tests/Corvus.Text.Json.OpenApi31.Runtime.Tests/GeneratedClientEndToEndTests.cs b/tests/Corvus.Text.Json.OpenApi31.Runtime.Tests/GeneratedClientEndToEndTests.cs index edf79b1f564..bcd9aca073f 100644 --- a/tests/Corvus.Text.Json.OpenApi31.Runtime.Tests/GeneratedClientEndToEndTests.cs +++ b/tests/Corvus.Text.Json.OpenApi31.Runtime.Tests/GeneratedClientEndToEndTests.cs @@ -1940,6 +1940,41 @@ public async Task Client_ApiItemsClient_UpdateItemAsync() Assert.AreEqual(HttpMethod.Put, harness.CapturedRequest!.Method); } + [TestMethod] + public async Task Client_ApiItemsClient_AddItemNoteAsync_OmittedOptionalBody_SendsNoBody() + { + using var harness = new TestHarness(HttpStatusCode.NoContent, string.Empty); + var client = new ApiItemsClient(harness.Transport); + + // The optional request body is omitted → the request must go out with NO body (the server treats the body + // as optional too). Regression for the client-side optional request-body generator fix: previously the + // generated client always materialised + sent a body, faulting on the undefined Source. + await using AddItemNoteResponse response = await client.AddItemNoteAsync("item-1"); + + Assert.AreEqual(204, response.StatusCode); + Assert.AreEqual(HttpMethod.Post, harness.CapturedRequest!.Method); + Assert.AreEqual("http://localhost/items/item-1/note", harness.CapturedRequest.RequestUri!.OriginalString); + Assert.IsNull(harness.CapturedRequest.Content, "an omitted optional body must not send a request body"); + } + + [TestMethod] + public async Task Client_ApiItemsClient_AddItemNoteAsync_SuppliedOptionalBody_SendsJsonBody() + { + using var harness = new TestHarness(HttpStatusCode.NoContent, string.Empty); + var client = new ApiItemsClient(harness.Transport); + + using var bodyDoc = ParsedJsonDocument.Parse("""{"note":"ship it"}"""); + + // The same optional-body operation still sends the body when one IS supplied. + await using AddItemNoteResponse response = await client.AddItemNoteAsync("item-1", bodyDoc.RootElement); + + Assert.AreEqual(204, response.StatusCode); + Assert.AreEqual(HttpMethod.Post, harness.CapturedRequest!.Method); + Assert.AreEqual("application/json", harness.CapturedRequest.Content?.Headers.ContentType?.MediaType); + byte[] sent = await harness.CapturedRequest.Content!.ReadAsByteArrayAsync(); + Assert.IsTrue(Encoding.UTF8.GetString(sent).Contains("\"note\":\"ship it\"", StringComparison.Ordinal)); + } + [TestMethod] public async Task Client_ApiOrdersClient_GetOrderAsync() { diff --git a/tests/Corvus.Text.Json.OpenApi31.Runtime.Tests/TestData/canonicalization-spec.json b/tests/Corvus.Text.Json.OpenApi31.Runtime.Tests/TestData/canonicalization-spec.json index def4579c064..7fe988f6e7f 100644 --- a/tests/Corvus.Text.Json.OpenApi31.Runtime.Tests/TestData/canonicalization-spec.json +++ b/tests/Corvus.Text.Json.OpenApi31.Runtime.Tests/TestData/canonicalization-spec.json @@ -388,6 +388,45 @@ } } }, + "/items/{itemId}/note": { + "post": { + "operationId": "addItemNote", + "summary": "Optional request body — omitting it must send no body", + "tags": [ + "items" + ], + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/items/{itemId}/details": { "get": { "operationId": "getItemDetails", diff --git a/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/ConfigureEndpointHookTests.cs b/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/ConfigureEndpointHookTests.cs index 29360b32c3a..1b5a5a6005d 100644 --- a/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/ConfigureEndpointHookTests.cs +++ b/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/ConfigureEndpointHookTests.cs @@ -57,7 +57,7 @@ [.. endpoint.SecurityRequirements.SelectMany(s => s.Requirements).Select(r => r. using IHost host = await BuildHostAsync(Capture, withAuth: false); // The callback fires once per generated endpoint. - Assert.AreEqual(23, recorded.Count, "Callback should be invoked once per generated endpoint"); + Assert.AreEqual(24, recorded.Count, "Callback should be invoked once per generated endpoint"); // Descriptor fields are accurate for a known operation (GET /items -> ListItems). RecordedEndpoint listItems = recorded.Single(r => r.MethodName == "ListItems"); diff --git a/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs b/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs index 232888ffd6d..d13b24ce0f0 100644 --- a/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs +++ b/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs @@ -136,7 +136,29 @@ public async Task UploadItemData_WithMultipartBody_ReturnsCreated() public async Task DownloadFile_WhenRequested_ReturnsOk() { HttpResponseMessage response = await Client.GetAsync("/download"); - await AssertEmptyResponseAsync(response, HttpStatusCode.OK); + + // The download endpoint returns its file content (the mock returns "file-content"); the previous assertion + // expected an empty body, which never matched the handler. Assert the real contract: status + body. + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("file-content", await response.Content.ReadAsStringAsync()); + } + + [TestMethod] + public async Task OptionalBodyProbe_WithoutBody_ReturnsNoContent() + { + // Regression (optional request bodies): a body-less POST to an operation whose requestBody is required:false + // must not be rejected by the dispatch — the empty body binds undefined and the handler runs, returning 204. + HttpResponseMessage response = await Client.PostAsync("/optional-body-probe", null); + Assert.AreEqual(HttpStatusCode.NoContent, response.StatusCode); + } + + [TestMethod] + public async Task OptionalBodyProbe_WithBody_ReturnsNoContent() + { + // The same operation still accepts (and parses) a body when one is supplied. + StringContent content = new("""{"note":"hello"}""", Encoding.UTF8, "application/json"); + HttpResponseMessage response = await Client.PostAsync("/optional-body-probe", content); + Assert.AreEqual(HttpStatusCode.NoContent, response.StatusCode); } [TestMethod] @@ -451,7 +473,10 @@ public async Task GetStyledQuirky_WithStyledQueryParameter_ReturnsOk() public async Task ExportData_WhenRequested_ReturnsOk() { HttpResponseMessage response = await Client.GetAsync("/export"); - await AssertEmptyResponseAsync(response, HttpStatusCode.OK); + + // The export endpoint returns its data body (the mock returns "export-data"); assert the real contract. + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("export-data", await response.Content.ReadAsStringAsync()); } [TestMethod] @@ -752,11 +777,4 @@ private static async Task AssertProblemDetailsAsync(HttpResponseMessage response string title = expectedStatusCode == HttpStatusCode.BadRequest ? "Bad Request" : "Internal Server Error"; await AssertJsonResponseAsync(response, expectedStatusCode, $$"""{"type":"about:blank","title":"{{title}}","status":{{(int)expectedStatusCode}},"detail":"{{expectedDetail}}"}"""); } - - private static async Task AssertEmptyResponseAsync(HttpResponseMessage response, HttpStatusCode expectedStatusCode) - { - string body = await response.Content.ReadAsStringAsync(); - Assert.AreEqual(expectedStatusCode, response.StatusCode); - Assert.AreEqual(string.Empty, body); - } } \ No newline at end of file diff --git a/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/MockHandlers.cs b/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/MockHandlers.cs index 7ce7ea6344f..9222c04d402 100644 --- a/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/MockHandlers.cs +++ b/tests/Corvus.Text.Json.OpenApi31.Server.Runtime.Tests/MockHandlers.cs @@ -66,7 +66,12 @@ public ValueTask HandleUploadItemDataAsync(UploadItemDataP } public ValueTask HandleDownloadFileAsync(DownloadFileParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) - => new(DownloadFileResult.Ok()); + => new(DownloadFileResult.Ok("file-content"u8.ToArray())); + + // The optional-body probe runs only if the dispatch did not reject a body-less request: an absent optional body + // must bind undefined, not 400. The handler ignores the body and returns 204. + public ValueTask HandleOptionalBodyProbeAsync(OptionalBodyProbeParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) + => new(OptionalBodyProbeResult.NoContent()); public ValueTask HandleGetQuirkyAsync(GetQuirkyParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) => new(GetQuirkyResult.Ok(ReturnInvalidResponse ? ItemEntity.ParseValue("""{}"""u8) : DefaultItem, workspace)); @@ -75,7 +80,7 @@ public ValueTask HandleGetStyledQuirkyAsync(GetStyledQuir => new(GetStyledQuirkyResult.Ok(ReturnInvalidResponse ? ItemEntity.ParseValue("""{}"""u8) : DefaultItem, workspace)); public ValueTask HandleExportDataAsync(ExportDataParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) - => new(ExportDataResult.Ok()); + => new(ExportDataResult.Ok("export-data"u8.ToArray())); public ValueTask HandleGetEmptyServersAsync(GetEmptyServersParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) => new(GetEmptyServersResult.Ok(ReturnInvalidResponse ? GetEmptyServersOk.ParseValue("""[]"""u8) : EmptyServersBody, workspace)); diff --git a/tests/Corvus.Text.Json.OpenApi32.Runtime.Tests/GeneratedClientEndToEndTests.cs b/tests/Corvus.Text.Json.OpenApi32.Runtime.Tests/GeneratedClientEndToEndTests.cs index b20ed794eff..9544bb47586 100644 --- a/tests/Corvus.Text.Json.OpenApi32.Runtime.Tests/GeneratedClientEndToEndTests.cs +++ b/tests/Corvus.Text.Json.OpenApi32.Runtime.Tests/GeneratedClientEndToEndTests.cs @@ -3779,6 +3779,41 @@ public async Task Client_ApiItemsClient_OptionsItemsAsync() Assert.AreEqual("http://localhost/items", harness.CapturedRequest.RequestUri!.OriginalString); } + [TestMethod] + public async Task Client_ApiItemsClient_AddItemNoteAsync_OmittedOptionalBody_SendsNoBody() + { + using var harness = new TestHarness(HttpStatusCode.NoContent, string.Empty); + var client = new ApiItemsClient(harness.Transport); + + // The optional request body is omitted → the request must go out with NO body (the server treats the body + // as optional too). Regression for the client-side optional request-body generator fix: previously the + // generated client always materialised + sent a body, faulting on the undefined Source. + await using AddItemNoteResponse response = await client.AddItemNoteAsync("item-1"); + + Assert.AreEqual(204, response.StatusCode); + Assert.AreEqual(HttpMethod.Post, harness.CapturedRequest!.Method); + Assert.AreEqual("http://localhost/items/item-1/note", harness.CapturedRequest.RequestUri!.OriginalString); + Assert.IsNull(harness.CapturedRequest.Content, "an omitted optional body must not send a request body"); + } + + [TestMethod] + public async Task Client_ApiItemsClient_AddItemNoteAsync_SuppliedOptionalBody_SendsJsonBody() + { + using var harness = new TestHarness(HttpStatusCode.NoContent, string.Empty); + var client = new ApiItemsClient(harness.Transport); + + using var bodyDoc = ParsedJsonDocument.Parse("""{"note":"ship it"}"""); + + // The same optional-body operation still sends the body when one IS supplied. + await using AddItemNoteResponse response = await client.AddItemNoteAsync("item-1", bodyDoc.RootElement); + + Assert.AreEqual(204, response.StatusCode); + Assert.AreEqual(HttpMethod.Post, harness.CapturedRequest!.Method); + Assert.AreEqual("application/json", harness.CapturedRequest.Content?.Headers.ContentType?.MediaType); + byte[] sent = await harness.CapturedRequest.Content!.ReadAsByteArrayAsync(); + Assert.IsTrue(Encoding.UTF8.GetString(sent).Contains("\"note\":\"ship it\"", StringComparison.Ordinal)); + } + [TestMethod] public async Task Client_ApiSearchClient_QuerySearchAsync() { diff --git a/tests/Corvus.Text.Json.OpenApi32.Runtime.Tests/TestData/canonicalization-spec-3.2.json b/tests/Corvus.Text.Json.OpenApi32.Runtime.Tests/TestData/canonicalization-spec-3.2.json index 6c72d80fa7d..bd97a940d01 100644 --- a/tests/Corvus.Text.Json.OpenApi32.Runtime.Tests/TestData/canonicalization-spec-3.2.json +++ b/tests/Corvus.Text.Json.OpenApi32.Runtime.Tests/TestData/canonicalization-spec-3.2.json @@ -481,6 +481,45 @@ } } }, + "/items/{itemId}/note": { + "post": { + "operationId": "addItemNote", + "summary": "Optional request body — omitting it must send no body", + "tags": [ + "items" + ], + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "note": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/items/{itemId}/details": { "get": { "operationId": "getItemDetails", diff --git a/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/ConfigureEndpointHookTests.cs b/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/ConfigureEndpointHookTests.cs index 73fcdf6ce91..7811dfc89b0 100644 --- a/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/ConfigureEndpointHookTests.cs +++ b/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/ConfigureEndpointHookTests.cs @@ -60,7 +60,7 @@ [.. endpoint.SecurityRequirements.SelectMany(s => s.Requirements).Select(r => r. using IHost host = await BuildHostAsync(Capture, withAuth: false); // The callback fires once per generated endpoint. - Assert.AreEqual(44, recorded.Count, "Callback should be invoked once per generated endpoint"); + Assert.AreEqual(45, recorded.Count, "Callback should be invoked once per generated endpoint"); // Descriptor fields are accurate for a known operation (GET /items -> ListItems). RecordedEndpoint listItems = recorded.Single(r => r.MethodName == "ListItems"); diff --git a/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs b/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs index c4b11aa9222..770dd4bf993 100644 --- a/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs +++ b/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/GeneratedServerEndToEndTests.cs @@ -2,6 +2,7 @@ // Copyright (c) Endjin Limited. All rights reserved. // +using System.Linq; using System.Net; using System.Text; using CanonTests32.Server; @@ -140,6 +141,24 @@ public async Task DownloadFile_ReturnsOk() Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); } + [TestMethod] + public async Task OptionalBodyProbe_WithoutBody_ReturnsNoContent() + { + // Regression (optional request bodies): a body-less POST to an operation whose requestBody is required:false + // must not be rejected by the dispatch — the empty body binds undefined and the handler runs, returning 204. + HttpResponseMessage response = await client!.PostAsync("/optional-body-probe", null); + Assert.AreEqual(HttpStatusCode.NoContent, response.StatusCode); + } + + [TestMethod] + public async Task OptionalBodyProbe_WithBody_ReturnsNoContent() + { + // The same operation still accepts (and parses) a body when one is supplied. + StringContent content = new("""{"note":"hello"}""", Encoding.UTF8, "application/json"); + HttpResponseMessage response = await client!.PostAsync("/optional-body-probe", content); + Assert.AreEqual(HttpStatusCode.NoContent, response.StatusCode); + } + [TestMethod] public async Task SearchItems_ReturnsOk() { @@ -645,6 +664,20 @@ public async Task ExportData_ReturnsOk() Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); } + [TestMethod] + public async Task ExportData_CarriesItsResponseHeaders() + { + // A binary response body AND response headers together. The factory for that combination emitted a required + // parameter after an optional one and did not compile at all, so nothing downstream of it was ever exercised; + // compiling again is necessary but not sufficient, and this asserts the header values reach the wire. + HttpResponseMessage response = await client!.GetAsync("/export"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("\"export-1\"", response.Headers.ETag?.ToString()); + Assert.AreEqual("7", response.Headers.GetValues("X-Export-Sequence").Single()); + Assert.AreEqual("export-data", await response.Content.ReadAsStringAsync()); + } + [TestMethod] public async Task StreamEvents_ReturnsOk() { diff --git a/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/GenericResultFactoryTests.cs b/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/GenericResultFactoryTests.cs new file mode 100644 index 00000000000..11b93ace6a0 --- /dev/null +++ b/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/GenericResultFactoryTests.cs @@ -0,0 +1,94 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using CanonTests32.Server; +using CanonTests32.Server.Models; +using Corvus.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Corvus.Text.Json.OpenApi32.Server.Runtime.Tests; + +/// +/// Behaviour of the generated context-threaded materialisation surface: the model's +/// CreateBuilder<TContext>(in Source<TContext>) and the server result factory's +/// Ok<TContext>(Source<TContext> body, workspace). Both let a caller assemble a body closure-free +/// (threading values through a ref-struct context and a static builder) and materialise it in a single pass — +/// producing a document byte-identical to the non-generic path. +/// +[TestClass] +public class GenericResultFactoryTests +{ + /// The model's context-threaded CreateBuilder<TContext> materialises the same document as the non-generic overload. + [TestMethod] + public void CreateBuilder_ContextThreaded_MaterialisesSameDocumentAsNonGeneric() + { + using JsonWorkspace ws = JsonWorkspace.Create(); + + // Reference: the non-generic field-form path. + ItemSchema reference = ItemSchema.CreateBuilder(ws, ItemSchema.Build(progress: 42, status: "active")).RootElement; + + // Under test: thread the values through a (value-tuple) context + static builder, then materialise via the new + // CreateBuilder(in Source). No closure is captured. + var context = (Progress: 42, Status: "active"); + ItemSchema.Source<(int Progress, string Status)> source = ItemSchema.Build( + in context, + static (in (int Progress, string Status) c, ref ItemSchema.Builder b) => b.Create(progress: c.Progress, status: c.Status)); + ItemSchema viaContext = ItemSchema.CreateBuilder(ws, in source).RootElement; + + Assert.AreEqual(42, ((JsonElement)viaContext).GetProperty("progress"u8).GetInt32()); + Assert.AreEqual("active", ((JsonElement)viaContext).GetProperty("status"u8).GetString()); + + // Identical to the non-generic path. + Assert.AreEqual( + ((JsonElement)reference).GetProperty("progress"u8).GetInt32(), + ((JsonElement)viaContext).GetProperty("progress"u8).GetInt32()); + Assert.AreEqual( + ((JsonElement)reference).GetProperty("status"u8).GetString(), + ((JsonElement)viaContext).GetProperty("status"u8).GetString()); + } + + /// The server result factory's Ok<TContext> materialises the same body as the non-generic Ok, routing the context-threaded body through a single materialisation. + [TestMethod] + public void Ok_ContextThreadedBody_ProducesSameBodyAsNonGeneric() + { + using JsonWorkspace wsNonGeneric = JsonWorkspace.Create(); + using JsonWorkspace wsGeneric = JsonWorkspace.Create(); + + // Non-generic factory. + GetEmptyServersResult nonGeneric = GetEmptyServersResult.Ok(GetEmptyServersOk.Build(ok: true), wsNonGeneric); + + // Generic factory: a closure-free body Source, materialised once by Ok. + var context = new System.ValueTuple(true); + GetEmptyServersOk.Source> body = GetEmptyServersOk.Build( + in context, + static (in System.ValueTuple c, ref GetEmptyServersOk.Builder b) => b.Create(ok: c.Item1)); + GetEmptyServersResult generic = GetEmptyServersResult.Ok(body, wsGeneric); + + Assert.AreEqual(200, generic.StatusCode); + Assert.AreEqual("application/json", generic.ContentType); + Assert.IsTrue(generic.Body.GetProperty("ok"u8).GetBoolean()); + + // Identical body to the non-generic path. + Assert.AreEqual( + nonGeneric.Body.GetProperty("ok"u8).GetBoolean(), + generic.Body.GetProperty("ok"u8).GetBoolean()); + } + + /// An any-schema (universal ) body routes the generic factory through the core + /// JsonElement.CreateBuilder<TContext>(in Source<TContext>) — the hand-written mirror added for parity. + [TestMethod] + public void Created_AnySchemaBody_RoutesThroughJsonElementCreateBuilder() + { + using JsonWorkspace ws = JsonWorkspace.Create(); + using ParsedJsonDocument doc = ParsedJsonDocument.Parse("{\"rating\":5}"); + + // JsonElement -> Source -> Source (the any-schema body path), materialised by Created. + JsonElement.Source bodySource = doc.RootElement; + JsonElement.Source body = bodySource; + SubmitFeedbackResult result = SubmitFeedbackResult.Created(body, ws); + + Assert.AreEqual(201, result.StatusCode); + Assert.AreEqual(5, result.Body.GetProperty("rating"u8).GetInt32()); + } +} \ No newline at end of file diff --git a/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/MockHandlers.cs b/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/MockHandlers.cs index 0f7f072d047..037a9ab8545 100644 --- a/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/MockHandlers.cs +++ b/tests/Corvus.Text.Json.OpenApi32.Server.Runtime.Tests/MockHandlers.cs @@ -54,6 +54,11 @@ public ValueTask HandleOptionsItemsAsync(OptionsItemsParams public ValueTask HandlePurgeItemsAsync(PurgeItemsParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) => new(PurgeItemsResult.NoContent()); + // The optional-body probe runs only if the dispatch did not reject a body-less request: an absent optional body + // must bind undefined, not 400. The handler ignores the body and returns 204. + public ValueTask HandleOptionalBodyProbeAsync(OptionalBodyProbeParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) + => new(OptionalBodyProbeResult.NoContent()); + public ValueTask HandleGetItemAsync(GetItemParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) { ItemEntity body = ReturnInvalidResponse @@ -78,7 +83,7 @@ public ValueTask HandleUploadItemDataAsync(UploadItemDataP => new(UploadItemDataResult.Created(ReturnInvalidResponse ? ItemEntity.ParseValue("""{}"""u8) : DefaultItem, workspace)); public ValueTask HandleDownloadFileAsync(DownloadFileParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) - => new(DownloadFileResult.Ok()); + => new(DownloadFileResult.Ok("file-content"u8.ToArray())); public ValueTask HandleGetQuirkyAsync(GetQuirkyParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) => new(GetQuirkyResult.Ok(ReturnInvalidResponse ? ItemEntity.ParseValue("""{}"""u8) : DefaultItem, workspace)); @@ -87,7 +92,7 @@ public ValueTask HandleGetStyledQuirkyAsync(GetStyledQuir => new(GetStyledQuirkyResult.Ok(ReturnInvalidResponse ? ItemEntity.ParseValue("""{}"""u8) : DefaultItem, workspace)); public ValueTask HandleExportDataAsync(ExportDataParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) - => new(ExportDataResult.Ok()); + => new(ExportDataResult.Ok("export-data"u8.ToArray(), workspace, eTag: "\"export-1\"", xExportSequence: 7)); public ValueTask HandleGetEmptyServersAsync(GetEmptyServersParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) { @@ -101,7 +106,7 @@ public ValueTask HandleHeadHealthAsync(HeadHealthParams parame => new(HeadHealthResult.Ok()); public ValueTask HandleTraceHealthAsync(TraceHealthParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) - => new(TraceHealthResult.Ok()); + => new(TraceHealthResult.Ok("trace-health"u8.ToArray())); public ValueTask HandleGetAdvancedStylesAsync(GetAdvancedStylesParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default) { From b0514d66bd53759499bea8b1840e2e711d38ff2b Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Wed, 5 Aug 2026 06:53:30 +0100 Subject: [PATCH 05/11] Give AsyncAPI a responder, and its request a workspace (#803) Request/reply had only one half. A caller could send a request and await the correlated reply, but nothing in the transport surface let a service BE the responder: subscribe to a channel, handle each request, and publish the reply on the correlated reply channel. SubscribeReplyAsync is that half. It ships with a default implementation that throws NotSupportedException, so a transport that does not support responders is unaffected and an existing custom transport still compiles. RequestAsync now takes the JsonWorkspace that owns the reply's lifetime, which is a breaking change: it is a required parameter ahead of the optional headers and cancellation token, so every call site and every custom implementation of the abstract overload needs it. The reply was previously materialised against a lifetime the caller could not control, which is the wrong shape for a caller folding the reply into a document it owns. The generated methods for a parameterised channel address now take a span or UTF-8 memory as well as a string, so an address composed from bytes does not have to become a string on the way to the transport. The producer's byte overload rents a pooled buffer, because the send outlives the call that started it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq --- .../AmqpMessageTransport.cs | 286 ++++++- .../AmqpTransportOptions.cs | 8 + .../AzureServiceBusMessageTransport.cs | 280 ++++++- .../AsyncApi26CodeGenerator.cs | 48 +- .../AsyncApi30CodeGenerator.cs | 785 ++++++++++++++---- .../AsyncApiChannelDescriptor.cs | 50 ++ .../AsyncApiExternalReferenceResolver.cs | 42 +- .../KafkaMessageTransport.cs | 259 +++++- .../MqttMessageTransport.cs | 240 +++++- .../NatsChannelTransportFactory.cs | 59 ++ .../NatsMessageTransport.cs | 238 +++++- .../NatsTransportOptions.cs | 28 + .../Corvus.Text.Json.AsyncApi.Polly.csproj | 1 + .../InMemoryMessageTransport.cs | 83 +- .../WebSocketMessageTransport.cs | 139 +++- .../Corvus.Text.Json.AsyncApi.csproj | 1 + .../IChannelTransportFactory.cs | 52 ++ .../IMessageTransport.cs | 44 +- .../InstrumentedMessageTransport.cs | 12 +- .../MessageTransportReceiveExtensions.cs | 179 ++++ .../AsyncApi26CodeGeneratorTests.cs | 28 + .../AsyncApi30CodeGeneratorTests.cs | 139 ++++ .../AsyncApiExternalReferenceResolverTests.cs | 77 ++ .../CancellationAndConcurrencyTests.cs | 2 + .../InMemoryMessageTransport.cs | 13 +- .../InMemoryMessageTransportTests.cs | 123 ++- .../MessageContextTests.cs | 3 +- .../TypedPayloadAccessTests.cs | 6 +- .../InstrumentedMessageTransportTests.cs | 5 +- .../AmqpTransportTests.cs | 150 +++- .../AzureServiceBusTransportTests.cs | 136 +++ .../KafkaTransportTests.cs | 156 +++- .../MqttTransportTests.cs | 141 +++- .../NatsTransportTests.cs | 127 ++- .../WebSocketTransportTests.cs | 142 +++- 35 files changed, 3824 insertions(+), 258 deletions(-) create mode 100644 src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApiChannelDescriptor.cs create mode 100644 src/Corvus.Text.Json.AsyncApi.Nats/NatsChannelTransportFactory.cs create mode 100644 src/Corvus.Text.Json.AsyncApi/IChannelTransportFactory.cs create mode 100644 src/Corvus.Text.Json.AsyncApi/MessageTransportReceiveExtensions.cs create mode 100644 tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApiExternalReferenceResolverTests.cs diff --git a/src/Corvus.Text.Json.AsyncApi.Amqp/AmqpMessageTransport.cs b/src/Corvus.Text.Json.AsyncApi.Amqp/AmqpMessageTransport.cs index b2b181885f8..609538595b1 100644 --- a/src/Corvus.Text.Json.AsyncApi.Amqp/AmqpMessageTransport.cs +++ b/src/Corvus.Text.Json.AsyncApi.Amqp/AmqpMessageTransport.cs @@ -153,6 +153,7 @@ public ValueTask PublishAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -168,7 +169,7 @@ public ValueTask PublishAsync( ? SerializeToOwnedBytes(in headers) : null; - return RequestCoreAsync(requestChannel, replyChannel, requestRented, requestLen, correlationId, correlationIdUtf8, headerBytes, cancellationToken); + return RequestCoreAsync(requestChannel, replyChannel, requestRented, requestLen, correlationId, correlationIdUtf8, headerBytes, workspace, cancellationToken); } /// @@ -183,6 +184,19 @@ public ValueTask SubscribeAsync( return SubscribeCoreAsync(channel, channelUtf8, handler, cancellationToken); } + /// + public ValueTask SubscribeReplyAsync( + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + ObjectDisposedException.ThrowIf(this.disposed, this); + string channel = Encoding.UTF8.GetString(channelUtf8.Span); + return SubscribeReplyCoreAsync(channel, channelUtf8, handler, cancellationToken); + } + /// public async ValueTask UnsubscribeAsync(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken = default) { @@ -311,6 +325,7 @@ await this.publishChannel.BasicPublishAsync( string correlationId, ReadOnlyMemory correlationIdUtf8, byte[]? headerBytes, + JsonWorkspace workspace, CancellationToken cancellationToken) where TReply : struct, IJsonElement { @@ -348,16 +363,25 @@ await this.publishChannel.BasicPublishAsync( body: new ReadOnlyMemory(requestRented, 0, requestLength), cancellationToken: cancellationToken).ConfigureAwait(false); - // Wait for reply - BasicDeliverEventArgs reply = await replyTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + // Wait for the correlated reply, bounded by the request timeout so a lost reply surfaces as a fast + // cancellation rather than waiting forever (the caller's token still cancels earlier if it fires first). + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(this.options.RequestTimeout); + BasicDeliverEventArgs reply = await replyTcs.Task.WaitAsync(timeoutCts.Token).ConfigureAwait(false); // Parse reply with error handling try { ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(reply.Body.ToArray()); + workspace.TakeOwnership(replyDoc); TReply replyPayload = replyDoc.RootElement; ParsedJsonDocument? headersDoc = ExtractHeadersDocument(reply); + if (headersDoc is not null) + { + workspace.TakeOwnership(headersDoc); + } + JsonElement replyHeaders = headersDoc?.RootElement ?? default; return (replyPayload, replyHeaders); @@ -670,6 +694,262 @@ await replyConsumerChannel.QueueBindAsync( this.subscriptions[replyChannel] = state; } + private async ValueTask SubscribeReplyCoreAsync( + string channel, + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + string dlChannel = channel + this.options.DeadLetterRoutingKeySuffix; + IChannel consumerChannel = await this.connection.CreateChannelAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + await consumerChannel.BasicQosAsync(prefetchSize: 0, prefetchCount: this.options.PrefetchCount, global: false, cancellationToken: cancellationToken).ConfigureAwait(false); + + string queueName = $"{this.options.ConsumerTagPrefix}.{channel}"; + + await consumerChannel.QueueDeclareAsync( + queue: queueName, + durable: this.options.QueueDurable, + exclusive: false, + autoDelete: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(this.options.ExchangeName)) + { + await consumerChannel.QueueBindAsync( + queue: queueName, + exchange: this.options.ExchangeName, + routingKey: channel, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + AsyncEventingBasicConsumer consumer = new(consumerChannel); + string? actualTag = null; + consumer.ReceivedAsync += async (_, args) => + { + if (cts.Token.IsCancellationRequested) + { + return; + } + + this.options.Heartbeat?.Tick(channel, "amqp"); + + ParsedJsonDocument requestDoc; + try + { + requestDoc = ParsedJsonDocument.Parse(args.Body); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Deserialization); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cts.Token).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterRawAsync(dlChannel, channelUtf8, args.Body, ex, cts.Token).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "amqp"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "amqp", dlEx); + } + + await consumerChannel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cts.Token).ConfigureAwait(false); + } + else if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "amqp", MessageErrorKind.Deserialization); + if (actualTag is not null) + { + await consumerChannel.BasicCancelAsync(actualTag, cancellationToken: CancellationToken.None).ConfigureAwait(false); + } + + await cts.CancelAsync().ConfigureAwait(false); + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "amqp", MessageErrorKind.Deserialization); + await consumerChannel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cts.Token).ConfigureAwait(false); + } + + return; + } + + using (requestDoc) + { + TRequest request = requestDoc.RootElement; + JsonElement requestElement = JsonElement.From(in request); + + ParsedJsonDocument? headersDoc; + try + { + headersDoc = ExtractHeadersDocument(args); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Deserialization); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cts.Token).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterRawAsync(dlChannel, channelUtf8, args.Body, ex, cts.Token).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "amqp"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "amqp", dlEx); + } + + await consumerChannel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cts.Token).ConfigureAwait(false); + } + else if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "amqp", MessageErrorKind.Deserialization); + if (actualTag is not null) + { + await consumerChannel.BasicCancelAsync(actualTag, cancellationToken: CancellationToken.None).ConfigureAwait(false); + } + + await cts.CancelAsync().ConfigureAwait(false); + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "amqp", MessageErrorKind.Deserialization); + await consumerChannel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cts.Token).ConfigureAwait(false); + } + + return; + } + + using (headersDoc) + { + JsonElement headers = headersDoc?.RootElement ?? default; + + try + { + TReply reply; + if (this.middleware is not null) + { + // Capture the typed reply produced inside the middleware pipeline. + TReply captured = default; + await this.middleware( + async (ct) => captured = await handler(request, headers, ct).ConfigureAwait(false), + cts.Token).ConfigureAwait(false); + reply = captured; + } + else + { + reply = await handler(request, headers, cts.Token).ConfigureAwait(false); + } + + // Publish the reply to the request's reply-to routing key, echoing the + // request's correlation ID so the requester's reply consumer can correlate it. + string? replyTo = args.BasicProperties?.ReplyTo; + string? corrId = args.BasicProperties?.CorrelationId; + if (!string.IsNullOrEmpty(replyTo)) + { + (byte[] replyRented, int replyLen) = SerializeToRented(in reply); + try + { + BasicProperties replyProps = new() + { + ContentType = "application/json", + DeliveryMode = DeliveryModes.Persistent, + }; + + if (corrId is not null) + { + replyProps.CorrelationId = corrId; + } + + // Publish the reply on the shared publish channel with CancellationToken.None, not + // this subscription's cts: a one-shot responder (ReceiveOneAndReplyAsync) signals its + // completion the instant the handler returns, so the caller can unsubscribe - which + // cancels cts - while this reply is still in flight. Using cts here let that teardown + // abort the reply, so the requester never received it and (before RequestTimeout) + // waited forever. The publish channel is only torn down on full DisposeAsync, well + // after the reply has gone out, so None is safe. + await this.publishChannel.BasicPublishAsync( + exchange: this.options.ExchangeName, + routingKey: replyTo, + mandatory: false, + basicProperties: replyProps, + body: new ReadOnlyMemory(replyRented, 0, replyLen), + cancellationToken: CancellationToken.None).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(replyRented); + } + } + + await consumerChannel.BasicAckAsync(args.DeliveryTag, multiple: false, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + // Shutting down — don't ack + } + catch (Exception ex) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Handler, requestElement, headers); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cts.Token).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + (byte[] payloadRented, int payloadLen) = SerializeToRented(in requestElement); + byte[]? headerBytes = headers.ValueKind != JsonValueKind.Undefined + ? SerializeToOwnedBytes(in headers) + : null; + await this.DeadLetterCoreAsync(dlChannel, channelUtf8, payloadRented, payloadLen, headerBytes, ex, cts.Token).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "amqp"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "amqp", dlEx); + } + + await consumerChannel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cts.Token).ConfigureAwait(false); + } + else if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "amqp", MessageErrorKind.Handler); + if (actualTag is not null) + { + await consumerChannel.BasicCancelAsync(actualTag, cancellationToken: CancellationToken.None).ConfigureAwait(false); + } + + await cts.CancelAsync().ConfigureAwait(false); + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "amqp", MessageErrorKind.Handler); + await consumerChannel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cts.Token).ConfigureAwait(false); + } + } + } + } + }; + + string consumerTag = $"{this.options.ConsumerTagPrefix}.{channel}"; + actualTag = await consumerChannel.BasicConsumeAsync( + queue: queueName, + autoAck: false, + consumerTag: consumerTag, + consumer: consumer, + cancellationToken: cancellationToken).ConfigureAwait(false); + + this.options.Heartbeat?.Start(channel, "amqp"); + + SubscriptionState state = new(consumerChannel, cts, actualTag); + this.subscriptions[channel] = state; + } + private async ValueTask DeadLetterCoreAsync( string deadLetterChannel, ReadOnlyMemory originalChannelUtf8, diff --git a/src/Corvus.Text.Json.AsyncApi.Amqp/AmqpTransportOptions.cs b/src/Corvus.Text.Json.AsyncApi.Amqp/AmqpTransportOptions.cs index 0a416be91d5..9acfc369cde 100644 --- a/src/Corvus.Text.Json.AsyncApi.Amqp/AmqpTransportOptions.cs +++ b/src/Corvus.Text.Json.AsyncApi.Amqp/AmqpTransportOptions.cs @@ -55,6 +55,14 @@ public sealed class AmqpTransportOptions : ITransportOptions /// public string ConsumerTagPrefix { get; set; } = "corvus-asyncapi"; + /// + /// Gets or sets how long waits for a correlated reply before it + /// fails. Without this bound a lost reply would wait forever; matching the other transports (and Azure Service + /// Bus / NATS), the request is cancelled after this timeout so a missing reply surfaces as a fast failure rather + /// than a hang. Defaults to 30 seconds. + /// + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// public IMessageErrorPolicy? ErrorPolicy { get; set; } diff --git a/src/Corvus.Text.Json.AsyncApi.AzureServiceBus/AzureServiceBusMessageTransport.cs b/src/Corvus.Text.Json.AsyncApi.AzureServiceBus/AzureServiceBusMessageTransport.cs index 0a50a990cbc..318b006348e 100644 --- a/src/Corvus.Text.Json.AsyncApi.AzureServiceBus/AzureServiceBusMessageTransport.cs +++ b/src/Corvus.Text.Json.AsyncApi.AzureServiceBus/AzureServiceBusMessageTransport.cs @@ -19,23 +19,24 @@ public sealed class AzureServiceBusMessageTransport : IMessageTransport private readonly AzureServiceBusTransportOptions options; private readonly ServiceBusClient client; private readonly ServiceBusSender sender; - private readonly ServiceBusProcessor? processor; private readonly IMessageErrorPolicy errorPolicy; private readonly MessageHandlerMiddleware? middleware; private readonly byte[] deadLetterSuffixUtf8; - private readonly ConcurrentDictionary subscriptions = new(StringComparer.Ordinal); + + // Each subscription keeps its own processor so Unsubscribe/Dispose can actually stop it. A single shared + // processor field could not: a transport may hold several subscriptions, and it was never assigned (created + // per-subscribe), so subscriptions leaked - their processors kept consuming and stole other work's messages. + private readonly ConcurrentDictionary subscriptions = new(StringComparer.Ordinal); private bool disposed; private AzureServiceBusMessageTransport( AzureServiceBusTransportOptions options, ServiceBusClient client, - ServiceBusSender sender, - ServiceBusProcessor? processor) + ServiceBusSender sender) { this.options = options; this.client = client; this.sender = sender; - this.processor = processor; this.errorPolicy = options.ErrorPolicy ?? new DefaultMessageErrorPolicy(); this.middleware = options.HandlerMiddleware; this.deadLetterSuffixUtf8 = Encoding.UTF8.GetBytes(options.DeadLetterSuffix); @@ -84,11 +85,8 @@ public static ValueTask CreateAsync( string entityPath = options.UseTopic ? options.TopicName! : options.QueueName!; ServiceBusSender sender = client.CreateSender(entityPath); - // Processor created only when subscribing - ServiceBusProcessor? processor = null; - return new ValueTask( - new AzureServiceBusMessageTransport(options, client, sender, processor)); + new AzureServiceBusMessageTransport(options, client, sender)); } /// @@ -154,6 +152,7 @@ private async ValueTask PublishCoreAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -245,9 +244,12 @@ private async ValueTask PublishCoreAsync( // Parse reply with error handling try { - using ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyMessage.Body); + // The parsed reply is returned to the caller and used after this method returns, so its document is + // owned by the caller's workspace (disposed when the workspace is) rather than disposed here. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyMessage.Body); + workspace.TakeOwnership(replyDoc); TReply replyPayload = replyDoc.RootElement; - JsonElement replyHeaders = BuildHeadersElement(replyMessage.ApplicationProperties); + JsonElement replyHeaders = BuildHeadersElement(replyMessage.ApplicationProperties, workspace); await replyReceiver.CompleteMessageAsync(replyMessage, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -337,7 +339,7 @@ public async ValueTask SubscribeAsync( : this.client.CreateProcessor(this.options.QueueName!); TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - this.subscriptions[channel] = tcs; + this.subscriptions[channel] = (tcs, processor); processor.ProcessMessageAsync += async args => { @@ -386,9 +388,10 @@ public async ValueTask SubscribeAsync( // Handle using (payloadDoc) + using (JsonWorkspace headerWorkspace = JsonWorkspace.CreateUnrented()) { TPayload payload = payloadDoc.RootElement; - JsonElement headersElement = BuildHeadersElement(args.Message.ApplicationProperties); + JsonElement headersElement = BuildHeadersElement(args.Message.ApplicationProperties, headerWorkspace); try { @@ -453,20 +456,185 @@ await this.middleware( await processor.StartProcessingAsync(cancellationToken).ConfigureAwait(false); } - /// - public async ValueTask UnsubscribeAsync(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken = default) + /// + /// Subscribes to request messages on a channel and replies to each — the responder counterpart of + /// . + /// + /// + /// For every request the processor delivers, this reads the native , + /// and fields + /// (the same ones RequestAsync sets), invokes the handler to obtain the typed reply, and sends that reply to the + /// request's reply-to entity echoing the request's session and correlation identifiers so the requester's session + /// receiver correlates it. + /// + /// The request payload type the responder parses into. + /// The reply payload type the handler returns. + /// The request channel address as UTF-8 bytes. + /// The handler invoked with each request payload and its headers, returning the reply payload. + /// A cancellation token. + /// A representing the asynchronous operation. + public async ValueTask SubscribeReplyAsync( + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement { + ObjectDisposedException.ThrowIf(this.disposed, this); + string channel = Encoding.UTF8.GetString(channelUtf8.Span); - if (this.subscriptions.TryRemove(channel, out TaskCompletionSource? tcs)) + // Build dead-letter channel UTF-8 bytes + Span dlChannelUtf8 = stackalloc byte[channelUtf8.Length + this.deadLetterSuffixUtf8.Length]; + channelUtf8.Span.CopyTo(dlChannelUtf8); + this.deadLetterSuffixUtf8.CopyTo(dlChannelUtf8[channelUtf8.Length..]); + string dlChannel = Encoding.UTF8.GetString(dlChannelUtf8); + + this.options.Heartbeat?.Start(channel, "azureservicebus"); + + ServiceBusProcessor processor = this.options.UseTopic + ? this.client.CreateProcessor(this.options.TopicName!, this.options.SubscriptionName!) + : this.client.CreateProcessor(this.options.QueueName!); + + TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + this.subscriptions[channel] = (tcs, processor); + + processor.ProcessMessageAsync += async args => { - tcs.TrySetResult(); + this.options.Heartbeat?.Tick(channel, "azureservicebus"); + + ReadOnlyMemory bodyBytes = args.Message.Body; + + // Parse the request + ParsedJsonDocument requestDoc; + try + { + requestDoc = ParsedJsonDocument.Parse(bodyBytes); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Deserialization); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + + if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "azureservicebus", MessageErrorKind.Deserialization); + tcs.TrySetResult(); + await args.DeadLetterMessageAsync(args.Message, "Deserialization failed", ex.Message).ConfigureAwait(false); + return; + } + + if (action == MessageErrorAction.DeadLetter) + { + try + { + await args.DeadLetterMessageAsync(args.Message, "Deserialization failed", ex.Message).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "azureservicebus"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "azureservicebus", dlEx); + } + + return; + } + + AsyncApiTelemetry.RecordSkip(channel, "azureservicebus", MessageErrorKind.Deserialization); + await args.CompleteMessageAsync(args.Message).ConfigureAwait(false); + return; + } - if (this.processor is not null) + // Handle the request and publish the reply + using (requestDoc) + using (JsonWorkspace headerWorkspace = JsonWorkspace.CreateUnrented()) { - await this.processor.StopProcessingAsync(cancellationToken).ConfigureAwait(false); + TRequest request = requestDoc.RootElement; + JsonElement headersElement = BuildHeadersElement(args.Message.ApplicationProperties, headerWorkspace); + + try + { + TReply reply; + if (this.middleware is not null) + { + TReply captured = default; + await this.middleware( + async (ct) => captured = await handler(request, headersElement, ct).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + reply = captured; + } + else + { + reply = await handler(request, headersElement, cancellationToken).ConfigureAwait(false); + } + + // Echo the request's reply-to address, session ID and correlation ID so the requester's + // session receiver (keyed on the correlation ID it used as the session ID) receives the reply. + string? replyTo = args.Message.ReplyTo; + if (!string.IsNullOrEmpty(replyTo)) + { + await this.SendReplyAsync(replyTo, reply, args.Message.SessionId, args.Message.CorrelationId, cancellationToken).ConfigureAwait(false); + } + + await args.CompleteMessageAsync(args.Message).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + tcs.TrySetResult(); + await args.AbandonMessageAsync(args.Message).ConfigureAwait(false); + } + catch (Exception ex) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Handler); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + + if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "azureservicebus", MessageErrorKind.Handler); + tcs.TrySetResult(); + await args.DeadLetterMessageAsync(args.Message, "Handler failed", ex.Message).ConfigureAwait(false); + return; + } + + if (action == MessageErrorAction.DeadLetter) + { + try + { + await args.DeadLetterMessageAsync(args.Message, "Handler failed", ex.Message).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "azureservicebus"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "azureservicebus", dlEx); + } + + return; + } + + AsyncApiTelemetry.RecordSkip(channel, "azureservicebus", MessageErrorKind.Handler); + await args.CompleteMessageAsync(args.Message).ConfigureAwait(false); + } } + }; + + processor.ProcessErrorAsync += args => + { + // Log error but don't fail - processor will continue + return Task.CompletedTask; + }; + + await processor.StartProcessingAsync(cancellationToken).ConfigureAwait(false); + } + /// + public async ValueTask UnsubscribeAsync(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken = default) + { + string channel = Encoding.UTF8.GetString(channelUtf8.Span); + + if (this.subscriptions.TryRemove(channel, out (TaskCompletionSource Completion, ServiceBusProcessor Processor) subscription)) + { + subscription.Completion.TrySetResult(); + await subscription.Processor.StopProcessingAsync(cancellationToken).ConfigureAwait(false); + await subscription.Processor.DisposeAsync().ConfigureAwait(false); this.options.Heartbeat?.Stop(channel, "azureservicebus"); } } @@ -497,12 +665,15 @@ public async ValueTask DisposeAsync() this.disposed = true; - if (this.processor is not null) + foreach ((TaskCompletionSource Completion, ServiceBusProcessor Processor) subscription in this.subscriptions.Values) { - await this.processor.StopProcessingAsync().ConfigureAwait(false); - await this.processor.DisposeAsync().ConfigureAwait(false); + subscription.Completion.TrySetResult(); + await subscription.Processor.StopProcessingAsync().ConfigureAwait(false); + await subscription.Processor.DisposeAsync().ConfigureAwait(false); } + this.subscriptions.Clear(); + await this.sender.DisposeAsync().ConfigureAwait(false); await this.client.DisposeAsync().ConfigureAwait(false); } @@ -556,6 +727,64 @@ private async ValueTask DeadLetterCoreAsync( } } + private async ValueTask SendReplyAsync( + string replyChannel, + TReply reply, + string? sessionId, + string? correlationId, + CancellationToken cancellationToken) + where TReply : struct, IJsonElement + { + byte[]? rentedArray = null; + + try + { + // Serialize the reply, mirroring RequestAsync's request serialization. + ArrayBufferWriter buffer = new(); + Utf8JsonWriter writer = new(buffer); + reply.WriteTo(writer); + writer.Flush(); + + int length = buffer.WrittenCount; + rentedArray = length <= 256 // StackallocByteThreshold + ? null + : ArrayPool.Shared.Rent(length); + + ReadOnlyMemory payload = rentedArray is null + ? buffer.WrittenMemory + : new ReadOnlyMemory(rentedArray, 0, length); + + if (rentedArray is not null) + { + buffer.WrittenSpan.CopyTo(rentedArray); + } + + // Echo the request's session and correlation identifiers so the requester's session + // receiver (which accepts the session whose ID equals the correlation ID) gets the reply. + ServiceBusMessage message = new(payload); + + if (!string.IsNullOrEmpty(sessionId)) + { + message.SessionId = sessionId; + } + + if (!string.IsNullOrEmpty(correlationId)) + { + message.CorrelationId = correlationId; + } + + await using ServiceBusSender replySender = this.client.CreateSender(replyChannel); + await replySender.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + finally + { + if (rentedArray is not null) + { + ArrayPool.Shared.Return(rentedArray); + } + } + } + private static int EstimateSerializedSize(TPayload payload) where TPayload : struct, IJsonElement { @@ -576,7 +805,7 @@ private static int SerializeToBuffer(TPayload payload, byte[] buffer) return writer.WrittenCount; } - private static JsonElement BuildHeadersElement(IReadOnlyDictionary applicationProperties) + private static JsonElement BuildHeadersElement(IReadOnlyDictionary applicationProperties, JsonWorkspace workspace) { if (applicationProperties.Count == 0) { @@ -595,7 +824,10 @@ private static JsonElement BuildHeadersElement(IReadOnlyDictionary doc = ParsedJsonDocument.Parse(buffer.WrittenMemory); + // The returned element is used after this method returns (by the caller's handler, or by RequestAsync's + // caller), so its document is owned by the caller's workspace rather than disposed here. + ParsedJsonDocument doc = ParsedJsonDocument.Parse(buffer.WrittenMemory); + workspace.TakeOwnership(doc); return doc.RootElement; } } \ No newline at end of file diff --git a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi26CodeGenerator.cs b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi26CodeGenerator.cs index e45e270b120..9df3c9bbfbd 100644 --- a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi26CodeGenerator.cs +++ b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi26CodeGenerator.cs @@ -159,6 +159,39 @@ public IReadOnlyList Generate( JsonElement doc, OperationFilter? filter = null, IAsyncApiReferenceResolver? referenceResolver = null) + { + (List sendOps, List receiveOps) = + this.CollectOperations(doc, filter, referenceResolver); + + return this.emitter.GenerateOperations(sendOps, receiveOps, []); + } + + /// + /// Describes the document's channel operations with the generated producer/consumer details an Arazzo + /// channel step needs (channel address, action, producer class + publish method, message payload + /// types) — the AsyncAPI 2.6 counterpart of + /// . Collects operations from the 2.6 + /// channel/publish/subscribe shape, then maps them through the shared descriptor builder. + /// + /// The parsed AsyncAPI 2.6 document. + /// An optional channel filter. + /// An optional reference resolver for cross-document $refs. + /// One descriptor per channel operation. + public IReadOnlyList DescribeChannelOperations( + JsonElement doc, + OperationFilter? filter = null, + IAsyncApiReferenceResolver? referenceResolver = null) + { + (List sendOps, List receiveOps) = + this.CollectOperations(doc, filter, referenceResolver); + + return this.emitter.BuildChannelDescriptors(sendOps, receiveOps); + } + + private (List Send, List Receive) CollectOperations( + JsonElement doc, + OperationFilter? filter, + IAsyncApiReferenceResolver? referenceResolver) { List sendOps = []; List receiveOps = []; @@ -213,7 +246,7 @@ public IReadOnlyList Generate( } } - return this.emitter.GenerateOperations(sendOps, receiveOps, []); + return (sendOps, receiveOps); } private static IEnumerable EnumerateChannelOperations( @@ -322,7 +355,18 @@ private AsyncApi30CodeGenerator.MessageInfo CreateMessageInfo( } } - return new(messageName, payloadPointer, payloadTypeName, headersPointer, headersTypeName, contentType, messageBindingsJson); + // The message's correlationId is inline ({ location, ... }) or a $ref to a named definition under + // components.correlationIds — the $ref key is the name an Arazzo receive step's `correlationId` matches. + string? correlationIdName = null; + string? correlationIdLocation = null; + if (resolved.TryGetProperty("correlationId"u8, out JsonElement correlationId) && + correlationId.ValueKind != JsonValueKind.Undefined) + { + correlationIdName = ExtractLastPointerSegment(TryGetRef(correlationId, resolver)); + correlationIdLocation = GetString(ResolveRef(correlationId, doc, resolver), "location"u8); + } + + return new(messageName, payloadPointer, payloadTypeName, headersPointer, headersTypeName, contentType, messageBindingsJson, correlationIdName, correlationIdLocation); } private AsyncApi30CodeGenerator.ReplyInfo? CollectReplyInfo( diff --git a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs index 7d554475d6b..9f8b170eb12 100644 --- a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs +++ b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApi30CodeGenerator.cs @@ -478,9 +478,31 @@ public IReadOnlyList Generate( return files; } + (List sendOps, List receiveOps) = CollectOperations(doc, filter, referenceResolver); + + files.AddRange(this.GenerateOperations(sendOps, receiveOps, ListServers(doc))); + + return files; + } + + /// + /// Collects the document's send/receive operations (channel address, messages with resolved payload + /// type names, parameters, reply, bindings) — the shared walk behind and + /// . + /// + internal (List Send, List Receive) CollectOperations( + AsyncApiDocument doc, + OperationFilter? filter = null, + IAsyncApiReferenceResolver? referenceResolver = null) + { List sendOps = []; List receiveOps = []; + if (doc.OperationsValue.IsUndefined()) + { + return (sendOps, receiveOps); + } + foreach (var operationProp in doc.OperationsValue.EnumerateObject()) { AsyncApiDocument.Type300Operation operation = operationProp.Value.Match( @@ -564,9 +586,81 @@ public IReadOnlyList Generate( } } - files.AddRange(this.GenerateOperations(sendOps, receiveOps, ListServers(doc))); + return (sendOps, receiveOps); + } - return files; + /// + /// Describes the document's channel operations with the generated producer/consumer details an + /// Arazzo channel step needs to call: the channel address, the operation action, the producer class + /// (for send operations), and per-message the resolved payload type name and producer publish method. + /// + /// The AsyncAPI document. + /// An optional channel filter. + /// An optional reference resolver for cross-document $refs. + /// One descriptor per channel operation. + public IReadOnlyList DescribeChannelOperations( + AsyncApiDocument doc, + OperationFilter? filter = null, + IAsyncApiReferenceResolver? referenceResolver = null) + { + (List sendOps, List receiveOps) = CollectOperations(doc, filter, referenceResolver); + return this.BuildChannelDescriptors(sendOps, receiveOps); + } + + /// + /// Maps collected records to s — + /// the version-independent half of , shared with the AsyncAPI + /// 2.6 generator (which collects its own operations from the older channel/publish/subscribe shape but + /// emits and describes them through this generator). + /// + /// The send operations. + /// The receive operations. + /// One descriptor per operation. + internal IReadOnlyList BuildChannelDescriptors( + IReadOnlyList sendOps, + IReadOnlyList receiveOps) + { + var descriptors = new List(sendOps.Count + receiveOps.Count); + foreach (OperationInfo op in sendOps.Concat(receiveOps)) + { + bool isSend = op.Action == OperationAction.Send; + string? producerClassName = isSend ? $"{this.rootNamespace}.{ToPascalCase(op.Name)}Producer" : null; + + // An operation that declares a reply is request/reply. On the send side the producer exposes a + // SendAndReceive{Message}Async method; on the receive side the descriptor still carries the reply + // payload type so a responder (consumer / Arazzo responder step) knows what reply to produce. + bool hasReply = op.Reply is not null; + bool isSendRequestReply = isSend && hasReply; + string? replyPayloadTypeName = hasReply + ? (op.Reply!.Value.Messages.Count == 1 ? op.Reply.Value.Messages[0].PayloadTypeName : null) ?? "Corvus.Text.Json.JsonElement" + : null; + + var messages = new List(op.Messages.Count); + foreach (MessageInfo message in op.Messages) + { + messages.Add(new AsyncApiChannelMessageDescriptor( + message.Name, + message.PayloadTypeName, + message.HeadersTypeName, + message.ContentType, + isSend ? $"Publish{ToPascalCase(message.Name)}Async" : null, + isSendRequestReply ? $"SendAndReceive{ToPascalCase(message.Name)}Async" : null, + message.CorrelationIdName, + message.CorrelationIdLocation)); + } + + descriptors.Add(new AsyncApiChannelDescriptor( + op.ChannelAddress, + op.Action, + op.Name, + producerClassName, + op.IsDynamicAddress, + op.Parameters.Select(static p => p.Name).ToList(), + messages, + replyPayloadTypeName)); + } + + return descriptors; } internal IReadOnlyList GenerateOperations( @@ -646,7 +740,9 @@ internal readonly record struct MessageInfo( string? HeadersPointer, string? HeadersTypeName, string? ContentType, - string? MessageBindingsJson); + string? MessageBindingsJson, + string? CorrelationIdName = null, + string? CorrelationIdLocation = null); internal readonly record struct ChannelParameter( string Name, @@ -902,7 +998,9 @@ private List CollectOperationMessages(JsonElement operation, AsyncA } } - messages.Add(new MessageInfo(messageName, payloadPointer, payloadTypeName, headersPointer, headersTypeName, contentType, messageBindingsJson)); + (string? correlationIdName, string? correlationIdLocation) = ExtractCorrelationId(resolved, doc, resolver); + + messages.Add(new MessageInfo(messageName, payloadPointer, payloadTypeName, headersPointer, headersTypeName, contentType, messageBindingsJson, correlationIdName, correlationIdLocation)); index++; } @@ -1219,6 +1317,38 @@ private static JsonElement ResolveRef(JsonElement element, AsyncApiDocument doc, return ResolveRef(element, doc, resolver, out _); } + // Extracts a message's AsyncAPI Correlation ID as (name, location). The message's `correlationId` is + // either inline ({ location, ... }, which has no referable name) or a $ref to a named definition under + // components.correlationIds — the latter's key is the name an Arazzo receive step's `correlationId` + // matches. The location is the runtime expression (e.g. $message.header#/correlationId) saying where the + // correlation token lives in the message. + internal static (string? Name, string? Location) ExtractCorrelationId(JsonElement message, AsyncApiDocument doc, IAsyncApiReferenceResolver? resolver) + { + if (!TryGetPropertyWithTraits(message, "correlationId"u8, doc, resolver, out JsonElement corrEl)) + { + return (null, null); + } + + JsonElement resolved = ResolveRef(corrEl, doc, resolver, out string? refPointer); + + string? name = null; + if (refPointer is { } pointer) + { + int slash = pointer.LastIndexOf('/'); + name = slash >= 0 ? pointer[(slash + 1)..] : pointer; + } + + string? location = null; + if (resolved.ValueKind == JsonValueKind.Object && + resolved.TryGetProperty("location"u8, out JsonElement locEl) && + locEl.ValueKind == JsonValueKind.String) + { + location = locEl.GetString(); + } + + return (name, location); + } + private static JsonElement ResolveRef( JsonElement element, AsyncApiDocument doc, @@ -1432,157 +1562,182 @@ private GeneratedFile EmitProducer(OperationInfo op) w.WriteLine(); string payloadType = msg.PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; string methodName = $"Publish{ToPascalCase(msg.Name)}Async"; - w.WriteLine($"/// "); - w.WriteLine($"/// Publishes a {msg.Name} message."); - w.WriteLine($"/// "); - w.WriteLine($"/// The message payload."); - if (msg.HeadersTypeName is not null) - { - w.WriteLine($"/// The message headers."); - } + // A dynamic-address operation with no channel-template parameters takes a + // caller-provided channel. For that case we emit a triple of overloads + // (string / ReadOnlySpan / ReadOnlySpan) that all delegate to a + // shared private Core which receives the channel as already-built UTF-8. + bool dynamicNoParams = op.IsDynamicAddress && op.Parameters.Count == 0; - if (op.IsDynamicAddress) + // Local function that emits the shared body (workspace + payload + validation + + // MessageContext + the PublishAsyncCore call). For the dynamic triple this is the + // Core body and the channel is already supplied as channelUtf8/channelRental. + void EmitPublishBody() { - w.WriteLine($"/// The target channel address (dynamic routing)."); - } + w.WriteLine($"JsonWorkspace workspace = JsonWorkspace.CreateUnrented();"); + w.WriteLine($"{payloadType} payloadValue = {payloadType}.CreateBuilder(workspace, payload, 30).RootElement;"); - foreach (ChannelParameter p in op.Parameters) - { - w.WriteLine($"/// {p.Description ?? $"The {p.Name} channel parameter."}"); - } + if (msg.HeadersTypeName is not null) + { + w.WriteLine($"{msg.HeadersTypeName} headersValue = {msg.HeadersTypeName}.CreateBuilder(workspace, headers, 10).RootElement;"); + } - w.WriteLine($"/// A cancellation token."); + // Validation + w.WriteLine(); + w.WriteLine("if (this.validationMode != ValidationMode.None)"); + w.OpenBrace(); + w.WriteLine("ValidatePayload(payloadValue, this.validationMode);"); - // Build method signature - List methodParams = [$"{payloadType}.Source payload"]; + if (msg.HeadersTypeName is not null) + { + w.WriteLine("ValidateHeaders(headersValue, this.validationMode);"); + } - if (msg.HeadersTypeName is not null) - { - methodParams.Add($"{msg.HeadersTypeName}.Source headers"); - } + w.CloseBrace(); - if (op.IsDynamicAddress) - { - methodParams.Add("string channel"); - } + // Build channel address as UTF-8 bytes (unless already supplied by the Core overloads). + w.WriteLine(); + if (op.Parameters.Count > 0) + { + // Split the template into segments around parameters and encode directly to a rented buffer + EmitParameterizedChannelConstruction(w, op); + } + else if (op.IsDynamicAddress && !dynamicNoParams) + { + // Dynamic: convert user-provided string to UTF-8 bytes (one allocation) + w.WriteLine("int channelByteCount = Encoding.UTF8.GetByteCount(channel);"); + w.WriteLine("byte[] channelRental = ArrayPool.Shared.Rent(channelByteCount);"); + w.WriteLine("int channelLen = Encoding.UTF8.GetBytes(channel, channelRental);"); + w.WriteLine("ReadOnlyMemory channelUtf8 = channelRental.AsMemory(0, channelLen);"); + } - foreach (ChannelParameter p in op.Parameters) - { - string paramDecl = $"string {ToCamelCase(p.Name)}"; - if (p.DefaultValue is not null) + // Call PublishAsyncCore with message context + string headersArg = msg.HeadersTypeName is not null + ? "Corvus.Text.Json.JsonElement.From(headersValue)" + : "default"; + + w.WriteLine($"MessageContext context = new()"); + w.OpenBrace(); + if (msg.ContentType is not null) { - paramDecl += $" = \"{EscapeString(p.DefaultValue)}\""; + w.WriteLine($"ContentType = \"{EscapeString(msg.ContentType)}\","); } - methodParams.Add(paramDecl); - } + if (op.ChannelBindingsJson is not null) + { + w.WriteLine("ChannelBindingsJson = ChannelBindingsBytes,"); + } - methodParams.Add("CancellationToken cancellationToken = default"); + if (op.OperationBindingsJson is not null) + { + w.WriteLine("OperationBindingsJson = OperationBindingsBytes,"); + } - w.WriteLine($"public ValueTask {methodName}({string.Join(", ", methodParams)})"); - w.OpenBrace(); + if (msg.MessageBindingsJson is not null) + { + string wrapperClassName = $"{ToPascalCase(op.Name)}{ToPascalCase(msg.Name)}Message"; + w.WriteLine($"MessageBindingsJson = {wrapperClassName}.MessageBindingsBytes,"); + } - w.WriteLine($"JsonWorkspace workspace = JsonWorkspace.CreateUnrented();"); - w.WriteLine($"{payloadType} payloadValue = {payloadType}.CreateBuilder(workspace, payload, 30).RootElement;"); + w.CloseBraceWithSemicolon(); - if (msg.HeadersTypeName is not null) - { - w.WriteLine($"{msg.HeadersTypeName} headersValue = {msg.HeadersTypeName}.CreateBuilder(workspace, headers, 10).RootElement;"); - } + string channelArg = op.IsDynamicAddress || op.Parameters.Count > 0 + ? "channelUtf8" + : "ChannelAddressUtf8"; + string rentalArg = op.IsDynamicAddress || op.Parameters.Count > 0 + ? "channelRental" + : "null"; - // Validation - w.WriteLine(); - w.WriteLine("if (this.validationMode != ValidationMode.None)"); - w.OpenBrace(); - w.WriteLine("ValidatePayload(payloadValue, this.validationMode);"); + w.WriteLine($"return PublishAsyncCore(workspace, {channelArg}, {rentalArg}, payloadValue, {headersArg}, context, cancellationToken);"); + } - if (msg.HeadersTypeName is not null) + if (dynamicNoParams) { - w.WriteLine("ValidateHeaders(headersValue, this.validationMode);"); - } + // Common leading params (payload, optional headers) shared by all overloads. + string leadingParams = $"{payloadType}.Source payload"; + string leadingArgs = "payload"; + if (msg.HeadersTypeName is not null) + { + leadingParams += $", {msg.HeadersTypeName}.Source headers"; + leadingArgs += ", headers"; + } - w.CloseBrace(); + void EmitLeadingDocs(string summary) + { + w.WriteLine($"/// "); + w.WriteLine($"/// {summary}"); + w.WriteLine($"/// "); + w.WriteLine($"/// The message payload."); + if (msg.HeadersTypeName is not null) + { + w.WriteLine($"/// The message headers."); + } + } - // Build channel address as UTF-8 bytes - w.WriteLine(); - if (op.Parameters.Count > 0) - { - // Split the template into segments around parameters and encode directly to a rented buffer - EmitParameterizedChannelConstruction(w, op); - } - else if (op.IsDynamicAddress) - { - // Dynamic: convert user-provided string to UTF-8 bytes (one allocation) + // string overload — delegates to the ReadOnlySpan overload. + EmitLeadingDocs($"Publishes a {msg.Name} message."); + w.WriteLine($"/// The target channel address (dynamic routing)."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"public ValueTask {methodName}({leadingParams}, string channel, CancellationToken cancellationToken = default)"); + w.OpenBrace(); + w.WriteLine($"return {methodName}({leadingArgs}, channel.AsSpan(), cancellationToken);"); + w.CloseBrace(); + + // ReadOnlySpan overload — transcodes to UTF-8 then calls the Core. + w.WriteLine(); + EmitLeadingDocs($"Publishes a {msg.Name} message."); + w.WriteLine($"/// The target channel address (dynamic routing)."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"public ValueTask {methodName}({leadingParams}, ReadOnlySpan channel, CancellationToken cancellationToken = default)"); + w.OpenBrace(); w.WriteLine("int channelByteCount = Encoding.UTF8.GetByteCount(channel);"); w.WriteLine("byte[] channelRental = ArrayPool.Shared.Rent(channelByteCount);"); w.WriteLine("int channelLen = Encoding.UTF8.GetBytes(channel, channelRental);"); - w.WriteLine("ReadOnlyMemory channelUtf8 = channelRental.AsMemory(0, channelLen);"); - } - - // Call PublishAsyncCore with message context - string headersArg = msg.HeadersTypeName is not null - ? "Corvus.Text.Json.JsonElement.From(headersValue)" - : "default"; - - w.WriteLine($"MessageContext context = new()"); - w.OpenBrace(); - if (msg.ContentType is not null) - { - w.WriteLine($"ContentType = \"{EscapeString(msg.ContentType)}\","); - } + w.WriteLine($"return {methodName}Core({leadingArgs}, channelRental.AsMemory(0, channelLen), channelRental, cancellationToken);"); + w.CloseBrace(); - if (op.ChannelBindingsJson is not null) - { - w.WriteLine("ChannelBindingsJson = ChannelBindingsBytes,"); - } + // ReadOnlySpan overload — channel is already UTF-8; copy it into a pooled rental (no + // encode), which the Core hands to the transport and returns to the pool after the send. + w.WriteLine(); + EmitLeadingDocs($"Publishes a {msg.Name} message."); + w.WriteLine($"/// The target channel address as UTF-8 bytes."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"public ValueTask {methodName}({leadingParams}, ReadOnlySpan channelUtf8, CancellationToken cancellationToken = default)"); + w.OpenBrace(); + w.WriteLine("byte[] channelRental = ArrayPool.Shared.Rent(channelUtf8.Length);"); + w.WriteLine("channelUtf8.CopyTo(channelRental);"); + w.WriteLine($"return {methodName}Core({leadingArgs}, channelRental.AsMemory(0, channelUtf8.Length), channelRental, cancellationToken);"); + w.CloseBrace(); - if (op.OperationBindingsJson is not null) - { - w.WriteLine("OperationBindingsJson = OperationBindingsBytes,"); - } + // Private Core — shared body, receives the channel as already-built UTF-8. + w.WriteLine(); + w.WriteLine($"/// "); + w.WriteLine($"/// Publishes a {msg.Name} message to the supplied (already UTF-8 encoded) channel."); + w.WriteLine($"/// "); + w.WriteLine($"/// The message payload."); + if (msg.HeadersTypeName is not null) + { + w.WriteLine($"/// The message headers."); + } - if (msg.MessageBindingsJson is not null) - { - string wrapperClassName = $"{ToPascalCase(op.Name)}{ToPascalCase(msg.Name)}Message"; - w.WriteLine($"MessageBindingsJson = {wrapperClassName}.MessageBindingsBytes,"); + w.WriteLine($"/// The target channel address as UTF-8 bytes."); + w.WriteLine($"/// The rented buffer backing to return to the pool after the send."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"private ValueTask {methodName}Core({leadingParams}, ReadOnlyMemory channelUtf8, byte[] channelRental, CancellationToken cancellationToken)"); + w.OpenBrace(); + EmitPublishBody(); + w.CloseBrace(); } - - w.CloseBraceWithSemicolon(); - - string channelArg = op.IsDynamicAddress || op.Parameters.Count > 0 - ? "channelUtf8" - : "ChannelAddressUtf8"; - string rentalArg = op.IsDynamicAddress || op.Parameters.Count > 0 - ? "channelRental" - : "null"; - - w.WriteLine($"return PublishAsyncCore(workspace, {channelArg}, {rentalArg}, payloadValue, {headersArg}, context, cancellationToken);"); - w.CloseBrace(); - } - - // Emit request/reply methods if this operation has a reply - if (op.Reply is { } reply) - { - foreach (MessageInfo msg in op.Messages) + else { - string payloadType = msg.PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; - string requestMethodName = $"SendAndReceive{ToPascalCase(msg.Name)}Async"; - - // Determine reply type - string replyType = reply.Messages.Count == 1 - ? reply.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement" - : "Corvus.Text.Json.JsonElement"; - - w.WriteLine(); w.WriteLine($"/// "); - w.WriteLine($"/// Sends a {msg.Name} request and waits for a reply."); + w.WriteLine($"/// Publishes a {msg.Name} message."); w.WriteLine($"/// "); - w.WriteLine($"/// The request payload."); + w.WriteLine($"/// The message payload."); if (msg.HeadersTypeName is not null) { - w.WriteLine($"/// The request headers."); + w.WriteLine($"/// The message headers."); } if (op.IsDynamicAddress) @@ -1596,18 +1751,18 @@ private GeneratedFile EmitProducer(OperationInfo op) } w.WriteLine($"/// A cancellation token."); - w.WriteLine($"/// The reply payload."); // Build method signature - List reqParams = [$"{payloadType}.Source payload"]; + List methodParams = [$"{payloadType}.Source payload"]; + if (msg.HeadersTypeName is not null) { - reqParams.Add($"{msg.HeadersTypeName}.Source headers"); + methodParams.Add($"{msg.HeadersTypeName}.Source headers"); } if (op.IsDynamicAddress) { - reqParams.Add("string channel"); + methodParams.Add("string channel"); } foreach (ChannelParameter p in op.Parameters) @@ -1618,14 +1773,41 @@ private GeneratedFile EmitProducer(OperationInfo op) paramDecl += $" = \"{EscapeString(p.DefaultValue)}\""; } - reqParams.Add(paramDecl); + methodParams.Add(paramDecl); } - reqParams.Add("CancellationToken cancellationToken = default"); + methodParams.Add("CancellationToken cancellationToken = default"); - w.WriteLine($"public ValueTask<{replyType}> {requestMethodName}({string.Join(", ", reqParams)})"); - w.OpenBrace(); + w.WriteLine($"public ValueTask {methodName}({string.Join(", ", methodParams)})"); + w.OpenBrace(); + EmitPublishBody(); + w.CloseBrace(); + } + } + + // Emit request/reply methods if this operation has a reply + if (op.Reply is { } reply) + { + foreach (MessageInfo msg in op.Messages) + { + string payloadType = msg.PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; + string requestMethodName = $"SendAndReceive{ToPascalCase(msg.Name)}Async"; + + // Determine reply type + string replyType = reply.Messages.Count == 1 + ? reply.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement" + : "Corvus.Text.Json.JsonElement"; + // As with the publish methods, a dynamic-address operation with no channel-template + // parameters emits a string / ReadOnlySpan / ReadOnlySpan triple that + // all delegate to a shared private Core taking the channel as already-built UTF-8. + bool dynamicNoParams = op.IsDynamicAddress && op.Parameters.Count == 0; + + // Local function emitting the shared body (workspace + payload + validation + reply + // address derivation + the RequestAsyncCore call). For the dynamic triple this is the + // Core body and the channel is already supplied as channelUtf8/channelRental. + void EmitRequestBody() + { w.WriteLine($"JsonWorkspace workspace = JsonWorkspace.CreateUnrented();"); w.WriteLine($"{payloadType} payloadValue = {payloadType}.CreateBuilder(workspace, payload, 30).RootElement;"); @@ -1646,13 +1828,13 @@ private GeneratedFile EmitProducer(OperationInfo op) w.CloseBrace(); - // Build channel address as UTF-8 bytes + // Build channel address as UTF-8 bytes (unless already supplied by the Core overloads). w.WriteLine(); if (op.Parameters.Count > 0) { EmitParameterizedChannelConstruction(w, op); } - else if (op.IsDynamicAddress) + else if (op.IsDynamicAddress && !dynamicNoParams) { w.WriteLine("int channelByteCount = Encoding.UTF8.GetByteCount(channel);"); w.WriteLine("byte[] channelRental = ArrayPool.Shared.Rent(channelByteCount);"); @@ -1711,8 +1893,145 @@ private GeneratedFile EmitProducer(OperationInfo op) : "null"; w.WriteLine($"return RequestAsyncCore<{payloadType}, {replyType}>(workspace, {channelArg}, {rentalArg}, {replyAddr}, payloadValue, {headersArg}, cancellationToken);"); + } + w.WriteLine(); + + if (dynamicNoParams) + { + string leadingParams = $"{payloadType}.Source payload"; + string leadingArgs = "payload"; + if (msg.HeadersTypeName is not null) + { + leadingParams += $", {msg.HeadersTypeName}.Source headers"; + leadingArgs += ", headers"; + } + + void EmitLeadingDocs() + { + w.WriteLine($"/// "); + w.WriteLine($"/// Sends a {msg.Name} request and waits for a reply."); + w.WriteLine($"/// "); + w.WriteLine($"/// The request payload."); + if (msg.HeadersTypeName is not null) + { + w.WriteLine($"/// The request headers."); + } + } + + // string overload — delegates to the ReadOnlySpan overload. + EmitLeadingDocs(); + w.WriteLine($"/// The target channel address (dynamic routing)."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"/// The reply payload."); + w.WriteLine($"public ValueTask<{replyType}> {requestMethodName}({leadingParams}, string channel, CancellationToken cancellationToken = default)"); + w.OpenBrace(); + w.WriteLine($"return {requestMethodName}({leadingArgs}, channel.AsSpan(), cancellationToken);"); + w.CloseBrace(); + + // ReadOnlySpan overload — transcodes to UTF-8 then calls the Core. + w.WriteLine(); + EmitLeadingDocs(); + w.WriteLine($"/// The target channel address (dynamic routing)."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"/// The reply payload."); + w.WriteLine($"public ValueTask<{replyType}> {requestMethodName}({leadingParams}, ReadOnlySpan channel, CancellationToken cancellationToken = default)"); + w.OpenBrace(); + w.WriteLine("int channelByteCount = Encoding.UTF8.GetByteCount(channel);"); + w.WriteLine("byte[] channelRental = ArrayPool.Shared.Rent(channelByteCount);"); + w.WriteLine("int channelLen = Encoding.UTF8.GetBytes(channel, channelRental);"); + w.WriteLine($"return {requestMethodName}Core({leadingArgs}, channelRental.AsMemory(0, channelLen), channelRental, cancellationToken);"); w.CloseBrace(); + + // ReadOnlySpan overload — channel is already UTF-8; copy it into a pooled rental (no + // encode), which the Core hands to the transport and returns to the pool after the send. + w.WriteLine(); + EmitLeadingDocs(); + w.WriteLine($"/// The target channel address as UTF-8 bytes."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"/// The reply payload."); + w.WriteLine($"public ValueTask<{replyType}> {requestMethodName}({leadingParams}, ReadOnlySpan channelUtf8, CancellationToken cancellationToken = default)"); + w.OpenBrace(); + w.WriteLine("byte[] channelRental = ArrayPool.Shared.Rent(channelUtf8.Length);"); + w.WriteLine("channelUtf8.CopyTo(channelRental);"); + w.WriteLine($"return {requestMethodName}Core({leadingArgs}, channelRental.AsMemory(0, channelUtf8.Length), channelRental, cancellationToken);"); + w.CloseBrace(); + + // Private Core — shared body, receives the channel as already-built UTF-8. + w.WriteLine(); + w.WriteLine($"/// "); + w.WriteLine($"/// Sends a {msg.Name} request to the supplied (already UTF-8 encoded) channel and waits for a reply."); + w.WriteLine($"/// "); + w.WriteLine($"/// The request payload."); + if (msg.HeadersTypeName is not null) + { + w.WriteLine($"/// The request headers."); + } + + w.WriteLine($"/// The target channel address as UTF-8 bytes."); + w.WriteLine($"/// The rented buffer backing to return to the pool after the send."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"/// The reply payload."); + w.WriteLine($"private ValueTask<{replyType}> {requestMethodName}Core({leadingParams}, ReadOnlyMemory channelUtf8, byte[] channelRental, CancellationToken cancellationToken)"); + w.OpenBrace(); + EmitRequestBody(); + w.CloseBrace(); + } + else + { + w.WriteLine($"/// "); + w.WriteLine($"/// Sends a {msg.Name} request and waits for a reply."); + w.WriteLine($"/// "); + w.WriteLine($"/// The request payload."); + + if (msg.HeadersTypeName is not null) + { + w.WriteLine($"/// The request headers."); + } + + if (op.IsDynamicAddress) + { + w.WriteLine($"/// The target channel address (dynamic routing)."); + } + + foreach (ChannelParameter p in op.Parameters) + { + w.WriteLine($"/// {p.Description ?? $"The {p.Name} channel parameter."}"); + } + + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"/// The reply payload."); + + // Build method signature + List reqParams = [$"{payloadType}.Source payload"]; + if (msg.HeadersTypeName is not null) + { + reqParams.Add($"{msg.HeadersTypeName}.Source headers"); + } + + if (op.IsDynamicAddress) + { + reqParams.Add("string channel"); + } + + foreach (ChannelParameter p in op.Parameters) + { + string paramDecl = $"string {ToCamelCase(p.Name)}"; + if (p.DefaultValue is not null) + { + paramDecl += $" = \"{EscapeString(p.DefaultValue)}\""; + } + + reqParams.Add(paramDecl); + } + + reqParams.Add("CancellationToken cancellationToken = default"); + + w.WriteLine($"public ValueTask<{replyType}> {requestMethodName}({string.Join(", ", reqParams)})"); + w.OpenBrace(); + EmitRequestBody(); + w.CloseBrace(); + } } } @@ -1759,7 +2078,7 @@ private GeneratedFile EmitProducer(OperationInfo op) w.WriteLine("System.Guid.NewGuid().TryFormat(correlationIdUtf8, out _, \"D\");"); w.WriteLine("try"); w.OpenBrace(); - w.WriteLine("var (replyPayload, _) = await this.transport.RequestAsync(channelUtf8, replyChannelUtf8, payload, correlationIdUtf8.AsMemory(0, 36), headers, cancellationToken).ConfigureAwait(false);"); + w.WriteLine("var (replyPayload, _) = await this.transport.RequestAsync(channelUtf8, replyChannelUtf8, payload, correlationIdUtf8.AsMemory(0, 36), workspace, headers, cancellationToken).ConfigureAwait(false);"); w.WriteLine("return replyPayload;"); w.CloseBrace(); w.WriteLine("finally"); @@ -1779,6 +2098,16 @@ private GeneratedFile EmitProducer(OperationInfo op) return new GeneratedFile($"{className}.cs", w.ToString()); } + // A single-message receive operation that declares a reply is a responder: its handler returns the + // reply payload (the consumer publishes it via SubscribeReplyAsync). + private static bool IsResponderOperation(in OperationInfo op) => op.Reply is not null && op.Messages.Count == 1; + + private static string ReplyPayloadTypeNameOf(in OperationInfo op) + => (op.Reply!.Value.Messages.Count == 1 ? op.Reply.Value.Messages[0].PayloadTypeName : null) ?? "Corvus.Text.Json.JsonElement"; + + private static string ReplyHandlerReturnType(in OperationInfo op) + => IsResponderOperation(op) ? $"ValueTask<{ReplyPayloadTypeNameOf(op)}>" : "ValueTask"; + private GeneratedFile EmitConsumerHandler(OperationInfo op) { string interfaceName = $"I{ToPascalCase(op.Name)}Handler"; @@ -1801,12 +2130,14 @@ private GeneratedFile EmitConsumerHandler(OperationInfo op) if (op.Messages.Count == 1) { - // Single message: handler receives the typed payload directly + // Single message: handler receives the typed payload directly. A receive operation that + // declares a reply returns the reply payload (the consumer publishes it); otherwise void. MessageInfo msg = op.Messages[0]; string payloadType = msg.PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; string methodName = $"Handle{ToPascalCase(msg.Name)}Async"; + string returnType = ReplyHandlerReturnType(op); w.WriteLine($"/// "); - w.WriteLine($"/// Handles a {msg.Name} message."); + w.WriteLine($"/// Handles a {msg.Name} message{(op.Reply is not null ? " and returns the reply payload" : string.Empty)}."); w.WriteLine($"/// "); w.WriteLine($"/// The deserialized message payload."); @@ -1814,12 +2145,12 @@ private GeneratedFile EmitConsumerHandler(OperationInfo op) { w.WriteLine($"/// The deserialized message headers."); w.WriteLine($"/// A cancellation token."); - w.WriteLine($"ValueTask {methodName}({payloadType} payload, {msg.HeadersTypeName} headers, CancellationToken cancellationToken = default);"); + w.WriteLine($"{returnType} {methodName}({payloadType} payload, {msg.HeadersTypeName} headers, CancellationToken cancellationToken = default);"); } else { w.WriteLine($"/// A cancellation token."); - w.WriteLine($"ValueTask {methodName}({payloadType} payload, CancellationToken cancellationToken = default);"); + w.WriteLine($"{returnType} {methodName}({payloadType} payload, CancellationToken cancellationToken = default);"); } } else @@ -1882,7 +2213,7 @@ private GeneratedFile EmitConsumer(OperationInfo op) if (op.IsDynamicAddress) { w.WriteLine("private string? subscribedChannel;"); - w.WriteLine("private byte[]? subscribedChannelUtf8;"); + w.WriteLine("private ReadOnlyMemory subscribedChannelUtf8;"); } else { @@ -1963,67 +2294,131 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.WriteLine($"/// A cancellation token."); - string startParams = op.IsDynamicAddress - ? "string channel, CancellationToken cancellationToken = default" - : "CancellationToken cancellationToken = default"; + // A dynamic-address consumer takes a caller-provided channel. As with the producer we + // emit a string / ReadOnlySpan / ReadOnlySpan triple delegating to a private + // Core that receives the (retained) UTF-8 channel bytes. Static channels are unchanged. + bool dynamicNoParams = op.IsDynamicAddress; - if (op.SecuritySchemes.Count > 0) + // Local function emitting the subscribe body. For the dynamic case the channel bytes have + // already been stored in this.subscribedChannelUtf8 by the Core/overloads. + void EmitStartBody(bool async) { - w.WriteLine($"public async ValueTask StartAsync({startParams})"); - w.OpenBrace(); - - if (op.IsDynamicAddress) + string subscribeAddr = op.IsDynamicAddress ? "this.subscribedChannelUtf8" : "ChannelAddressUtf8"; + string keyword = async ? "await " : "return "; + string suffix = async ? ".ConfigureAwait(false)" : string.Empty; + if (IsResponderOperation(op)) { - w.WriteLine("this.subscribedChannel = channel;"); - w.WriteLine("this.subscribedChannelUtf8 = Encoding.UTF8.GetBytes(channel);"); + string payloadType = op.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; + w.WriteLine($"{keyword}this.transport.SubscribeReplyAsync<{payloadType}, {ReplyPayloadTypeNameOf(op)}>({subscribeAddr}, this.HandleMessageAsync, cancellationToken){suffix};"); } - - w.WriteLine("if (this.authProvider is not null)"); - w.OpenBrace(); - foreach (SecuritySchemeInfo scheme in op.SecuritySchemes) + else if (op.Messages.Count == 1) { - w.WriteLine($"await this.authProvider.AuthenticateAsync({ToPascalCase(scheme.Name)}AuthContext, cancellationToken).ConfigureAwait(false);"); + string payloadType = op.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; + w.WriteLine($"{keyword}this.transport.SubscribeAsync<{payloadType}>({subscribeAddr}, this.HandleMessageAsync, cancellationToken){suffix};"); + } + else + { + w.WriteLine($"{keyword}this.transport.SubscribeAsync({subscribeAddr}, this.HandleMessageAsync, cancellationToken){suffix};"); } + } + if (dynamicNoParams) + { + // string overload — delegates to the ReadOnlySpan overload (and retains the channel string). + w.WriteLine($"public ValueTask StartAsync(string channel, CancellationToken cancellationToken = default)"); + w.OpenBrace(); + w.WriteLine("this.subscribedChannel = channel;"); + w.WriteLine("return this.StartAsync(channel.AsSpan(), cancellationToken);"); w.CloseBrace(); + + // ReadOnlySpan overload — transcodes to a retained UTF-8 array then calls the Core. w.WriteLine(); + w.WriteLine($"/// "); + w.WriteLine($"/// Starts consuming messages from the channel."); + w.WriteLine($"/// "); + w.WriteLine($"/// The channel address to subscribe to (dynamic routing)."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"/// A task that completes when the subscription is established."); + w.WriteLine($"public ValueTask StartAsync(ReadOnlySpan channel, CancellationToken cancellationToken = default)"); + w.OpenBrace(); + w.WriteLine("byte[] channelUtf8 = new byte[Encoding.UTF8.GetByteCount(channel)];"); + w.WriteLine("Encoding.UTF8.GetBytes(channel, channelUtf8);"); + w.WriteLine("return this.StartAsyncCore(channelUtf8, cancellationToken);"); + w.CloseBrace(); - string subscribeAddr = op.IsDynamicAddress ? "this.subscribedChannelUtf8" : "ChannelAddressUtf8"; - if (op.Messages.Count == 1) + // ReadOnlyMemory overload — channel is already UTF-8; retain it directly (no copy). The + // caller owns the memory and must keep it valid for the lifetime of the subscription. + w.WriteLine(); + w.WriteLine($"/// "); + w.WriteLine($"/// Starts consuming messages from the channel."); + w.WriteLine($"/// "); + w.WriteLine($"/// The channel address to subscribe to as UTF-8 bytes (dynamic routing); must remain valid until the subscription is stopped."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"/// A task that completes when the subscription is established."); + w.WriteLine($"public ValueTask StartAsync(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken = default)"); + w.OpenBrace(); + w.WriteLine("return this.StartAsyncCore(channelUtf8, cancellationToken);"); + w.CloseBrace(); + + // Private Core — retains the channel bytes and subscribes. + w.WriteLine(); + w.WriteLine($"/// "); + w.WriteLine($"/// Starts consuming messages from the supplied (already UTF-8 encoded) channel."); + w.WriteLine($"/// "); + w.WriteLine($"/// The channel address to subscribe to as UTF-8 bytes."); + w.WriteLine($"/// A cancellation token."); + w.WriteLine($"/// A task that completes when the subscription is established."); + + if (op.SecuritySchemes.Count > 0) { - string payloadType = op.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; - w.WriteLine($"await this.transport.SubscribeAsync<{payloadType}>({subscribeAddr}, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false);"); + w.WriteLine($"private async ValueTask StartAsyncCore(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken)"); + w.OpenBrace(); + w.WriteLine("this.subscribedChannelUtf8 = channelUtf8;"); + w.WriteLine(); + w.WriteLine("if (this.authProvider is not null)"); + w.OpenBrace(); + foreach (SecuritySchemeInfo scheme in op.SecuritySchemes) + { + w.WriteLine($"await this.authProvider.AuthenticateAsync({ToPascalCase(scheme.Name)}AuthContext, cancellationToken).ConfigureAwait(false);"); + } + + w.CloseBrace(); + w.WriteLine(); + EmitStartBody(async: true); + w.CloseBrace(); } else { - w.WriteLine($"await this.transport.SubscribeAsync({subscribeAddr}, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false);"); + w.WriteLine($"private ValueTask StartAsyncCore(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken)"); + w.OpenBrace(); + w.WriteLine("this.subscribedChannelUtf8 = channelUtf8;"); + EmitStartBody(async: false); + w.CloseBrace(); } - - w.CloseBrace(); } - else + else if (op.SecuritySchemes.Count > 0) { - w.WriteLine($"public ValueTask StartAsync({startParams})"); + w.WriteLine($"public async ValueTask StartAsync(CancellationToken cancellationToken = default)"); w.OpenBrace(); - if (op.IsDynamicAddress) - { - w.WriteLine("this.subscribedChannel = channel;"); - w.WriteLine("this.subscribedChannelUtf8 = Encoding.UTF8.GetBytes(channel);"); - } - - string subscribeAddr = op.IsDynamicAddress ? "this.subscribedChannelUtf8" : "ChannelAddressUtf8"; - if (op.Messages.Count == 1) - { - string payloadType = op.Messages[0].PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; - w.WriteLine($"return this.transport.SubscribeAsync<{payloadType}>({subscribeAddr}, this.HandleMessageAsync, cancellationToken);"); - } - else + w.WriteLine("if (this.authProvider is not null)"); + w.OpenBrace(); + foreach (SecuritySchemeInfo scheme in op.SecuritySchemes) { - w.WriteLine($"return this.transport.SubscribeAsync({subscribeAddr}, this.HandleMessageAsync, cancellationToken);"); + w.WriteLine($"await this.authProvider.AuthenticateAsync({ToPascalCase(scheme.Name)}AuthContext, cancellationToken).ConfigureAwait(false);"); } w.CloseBrace(); + w.WriteLine(); + EmitStartBody(async: true); + w.CloseBrace(); + } + else + { + w.WriteLine($"public ValueTask StartAsync(CancellationToken cancellationToken = default)"); + w.OpenBrace(); + EmitStartBody(async: false); + w.CloseBrace(); } // StopAsync @@ -2037,7 +2432,7 @@ private GeneratedFile EmitConsumer(OperationInfo op) if (op.IsDynamicAddress) { - w.WriteLine("if (this.subscribedChannelUtf8 is null)"); + w.WriteLine("if (this.subscribedChannelUtf8.IsEmpty)"); w.OpenBrace(); w.WriteLine("ThrowHelper.ThrowConsumerNotStarted();"); w.CloseBrace(); @@ -2054,7 +2449,39 @@ private GeneratedFile EmitConsumer(OperationInfo op) // HandleMessageAsync — with error policy w.WriteLine(); - if (op.Messages.Count == 1) + if (IsResponderOperation(op)) + { + // Responder: validate the request, return the handler's reply payload. The transport + // publishes it correlated; an exception propagates to the requester (no dead-letter path). + MessageInfo msg = op.Messages[0]; + string payloadType = msg.PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; + string handlerMethod = $"Handle{ToPascalCase(msg.Name)}Async"; + string replyType = ReplyPayloadTypeNameOf(op); + + w.WriteLine($"private ValueTask<{replyType}> HandleMessageAsync({payloadType} payload, Corvus.Text.Json.JsonElement headers, CancellationToken cancellationToken)"); + w.OpenBrace(); + w.WriteLine("if (this.validationMode != ValidationMode.None)"); + w.OpenBrace(); + w.WriteLine("ValidatePayload(payload, this.validationMode);"); + if (msg.HeadersTypeName is not null) + { + w.WriteLine($"ValidateHeaders({msg.HeadersTypeName}.From(headers), this.validationMode);"); + } + + w.CloseBrace(); + w.WriteLine(); + if (msg.HeadersTypeName is not null) + { + w.WriteLine($"return this.handler.{handlerMethod}(payload, {msg.HeadersTypeName}.From(headers), cancellationToken);"); + } + else + { + w.WriteLine($"return this.handler.{handlerMethod}(payload, cancellationToken);"); + } + + w.CloseBrace(); + } + else if (op.Messages.Count == 1) { MessageInfo msg = op.Messages[0]; string payloadType = msg.PayloadTypeName ?? "Corvus.Text.Json.JsonElement"; @@ -2064,7 +2491,7 @@ private GeneratedFile EmitConsumer(OperationInfo op) w.OpenBrace(); string channelExpr = op.IsDynamicAddress ? "this.subscribedChannel!" : "ChannelAddress"; - string channelUtf8Expr = op.IsDynamicAddress ? "this.subscribedChannelUtf8!" : "ChannelAddressUtf8"; + string channelUtf8Expr = op.IsDynamicAddress ? "this.subscribedChannelUtf8" : "ChannelAddressUtf8"; w.WriteLine("try"); w.OpenBrace(); @@ -2126,7 +2553,7 @@ private GeneratedFile EmitConsumer(OperationInfo op) { string messageTypeName = $"{ToPascalCase(op.Name)}ReceivedMessage"; string channelExpr = op.IsDynamicAddress ? "this.subscribedChannel!" : "ChannelAddress"; - string channelUtf8Expr = op.IsDynamicAddress ? "this.subscribedChannelUtf8!" : "ChannelAddressUtf8"; + string channelUtf8Expr = op.IsDynamicAddress ? "this.subscribedChannelUtf8" : "ChannelAddressUtf8"; w.WriteLine("private async ValueTask HandleMessageAsync(Corvus.Text.Json.JsonElement payload, Corvus.Text.Json.JsonElement headers, CancellationToken cancellationToken)"); w.OpenBrace(); diff --git a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApiChannelDescriptor.cs b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApiChannelDescriptor.cs new file mode 100644 index 00000000000..3dc495973b0 --- /dev/null +++ b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApiChannelDescriptor.cs @@ -0,0 +1,50 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +namespace Corvus.Text.Json.AsyncApi.CodeGeneration; + +/// +/// Describes a generated AsyncAPI channel operation for a consumer of the code generator (for example, +/// the Arazzo workflow generator binding a channel step): the channel address, the operation action, +/// the generated producer class (for send operations), and the operation's messages with their resolved +/// .NET payload type names and producer publish methods. +/// +/// The channel address (with any {parameter} templates intact). +/// Whether the operation sends or receives on the channel. +/// The AsyncAPI operation name. +/// The fully-qualified generated producer class for a send operation, or for a receive operation. +/// Whether the channel address is supplied at call time (no fixed address) rather than templated/static. +/// The channel address parameter names (each becomes an argument of the producer publish method). +/// The operation's messages. +/// For a send operation that declares a reply (request/reply), the fully-qualified generated .NET reply payload type; for fire-and-forget send and for receive operations. +public readonly record struct AsyncApiChannelDescriptor( + string ChannelAddress, + OperationAction Action, + string OperationName, + string? ProducerClassName, + bool IsDynamicAddress, + IReadOnlyList ChannelParameters, + IReadOnlyList Messages, + string? ReplyPayloadTypeName = null); + +/// +/// Describes one message of an AsyncAPI channel operation. +/// +/// The message name. +/// The fully-qualified generated .NET payload type, or when the payload schema produced no named type. +/// The fully-qualified generated .NET headers type, or when there are no typed headers. +/// The message content type, if declared. +/// The generated producer's publish method for this message (e.g. PublishTurnOnOffAsync), or for a receive operation. +/// The generated producer's request/reply method for this message (e.g. SendAndReceiveQueryAsync) when the operation declares a reply; otherwise . +/// The name of the AsyncAPI Correlation ID this message declares (the components.correlationIds key it $refs), or when the message declares no named correlation id. An Arazzo receive step's correlationId matches this name. +/// The correlation id's location runtime expression (e.g. $message.header#/correlationId) — where the correlation token lives in the message — or . +public readonly record struct AsyncApiChannelMessageDescriptor( + string MessageName, + string? PayloadTypeName, + string? HeadersTypeName, + string? ContentType, + string? ProducerMethodName, + string? RequestReplyMethodName = null, + string? CorrelationIdName = null, + string? CorrelationIdLocation = null); \ No newline at end of file diff --git a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApiExternalReferenceResolver.cs b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApiExternalReferenceResolver.cs index 61e69dcdab0..af0df2971d8 100644 --- a/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApiExternalReferenceResolver.cs +++ b/src/Corvus.Text.Json.AsyncApi.CodeGeneration/AsyncApiExternalReferenceResolver.cs @@ -31,6 +31,10 @@ public sealed class AsyncApiExternalReferenceResolver : IAsyncApiReferenceResolv private readonly JsonElement entryDocumentRoot; private readonly Uri baseUri; + // Optional hook for loading an external document (of any URI scheme) from a virtualized source, + // consulted before the file-system fallback (see the OpenAPI resolver for the rationale). + private readonly Func? externalDocumentLoader; + private readonly Dictionary registeredDocuments = new(StringComparer.Ordinal); private readonly Dictionary> loadedDocuments = new(StringComparer.Ordinal); private readonly Stack<(string Key, JsonElement Root)> baseStack = new(); @@ -47,6 +51,31 @@ public sealed class AsyncApiExternalReferenceResolver : IAsyncApiReferenceResolv /// /// is not an absolute path. public AsyncApiExternalReferenceResolver(JsonElement entryDocumentRoot, string entryDocumentPath) + : this(entryDocumentRoot, ToFileBaseUri(entryDocumentPath), null) + { + } + + /// + /// Initializes a new instance of the class with an + /// explicit base URI (which need not be a file path) and an optional loader for virtualized external + /// documents. + /// + /// The root element of the entry (main) AsyncAPI document. + /// The absolute base URI the entry document was retrieved from (RFC 3986 §5). + /// + /// An optional callback that loads an external document's raw UTF-8 JSON bytes by its resolved + /// absolute URI (or when it cannot), consulted before the file-system fallback. + /// Documents it returns are owned (and disposed) by this resolver. + /// + public AsyncApiExternalReferenceResolver(JsonElement entryDocumentRoot, Uri baseUri, Func? externalDocumentLoader = null) + { + ArgumentNullException.ThrowIfNull(baseUri); + this.entryDocumentRoot = entryDocumentRoot; + this.baseUri = baseUri; + this.externalDocumentLoader = externalDocumentLoader; + } + + private static Uri ToFileBaseUri(string entryDocumentPath) { if (!Path.IsPathFullyQualified(entryDocumentPath)) { @@ -55,8 +84,7 @@ public AsyncApiExternalReferenceResolver(JsonElement entryDocumentRoot, string e nameof(entryDocumentPath)); } - this.entryDocumentRoot = entryDocumentRoot; - this.baseUri = new Uri(entryDocumentPath); + return new Uri(entryDocumentPath); } private Uri CurrentBaseUri => this.baseStack.Count > 0 @@ -341,7 +369,15 @@ private bool TryResolveExternal(string refValue, out JsonElement result) return NavigateFragment(loaded.RootElement, fragment, out result); } - // 3. Fall back to file-system loading for file:// URIs + // 3. Try the injected loader (a virtualized document source) for any scheme. + if (this.externalDocumentLoader is { } loader && loader(resolvedUri) is { } bytes) + { + ParsedJsonDocument doc = ParsedJsonDocument.Parse(bytes); + this.loadedDocuments[key] = doc; + return NavigateFragment(doc.RootElement, fragment, out result); + } + + // 4. Fall back to file-system loading for file:// URIs if (resolvedUri.IsFile) { string filePath = resolvedUri.LocalPath; diff --git a/src/Corvus.Text.Json.AsyncApi.Kafka/KafkaMessageTransport.cs b/src/Corvus.Text.Json.AsyncApi.Kafka/KafkaMessageTransport.cs index cc6a3ec6940..53a1fc4dde2 100644 --- a/src/Corvus.Text.Json.AsyncApi.Kafka/KafkaMessageTransport.cs +++ b/src/Corvus.Text.Json.AsyncApi.Kafka/KafkaMessageTransport.cs @@ -38,6 +38,8 @@ public sealed class KafkaMessageTransport : IMessageTransport, IHealthCheckableT private const string HeadersKeyString = "corvus-headers"; private static readonly byte[] CorrelationIdKey = "corvus-correlation-id"u8.ToArray(); private const string CorrelationIdKeyString = "corvus-correlation-id"; + private static readonly byte[] ReplyToKey = "corvus-reply-to"u8.ToArray(); + private const string ReplyToKeyString = "corvus-reply-to"; private readonly KafkaTransportOptions options; private readonly IProducer producer; @@ -120,6 +122,7 @@ public ValueTask PublishAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -135,7 +138,7 @@ public ValueTask PublishAsync( ? SerializeToOwnedBytes(in headers) : null; - return RequestCoreAsync(requestChannel, replyChannel, requestBytes, correlationIdUtf8, headerBytes, cancellationToken); + return RequestCoreAsync(requestChannel, replyChannel, requestBytes, correlationIdUtf8, headerBytes, workspace, cancellationToken); } /// @@ -161,6 +164,30 @@ public ValueTask SubscribeAsync( return ValueTask.CompletedTask; } + /// + public ValueTask SubscribeReplyAsync( + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + ObjectDisposedException.ThrowIf(this.disposed, this); + + string channel = Encoding.UTF8.GetString(channelUtf8.Span); + CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + IConsumer consumer = CreateConsumer(channel); + + Task consumeTask = Task.Run( + () => this.ReplyResponderLoop(channel, channelUtf8, consumer, handler, cts.Token), + CancellationToken.None); + + SubscriptionState state = new(consumer, cts, consumeTask); + this.subscriptions[channel] = state; + + return ValueTask.CompletedTask; + } + /// public async ValueTask UnsubscribeAsync(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken = default) { @@ -302,6 +329,7 @@ private ValueTask DeadLetterCoreAsync( byte[] requestBytes, ReadOnlyMemory correlationIdUtf8, byte[]? headerBytes, + JsonWorkspace workspace, CancellationToken cancellationToken) where TReply : struct, IJsonElement { @@ -333,6 +361,7 @@ private ValueTask DeadLetterCoreAsync( Headers = [ new Header(CorrelationIdKeyString, corrIdHeaderBytes), + new Header(ReplyToKeyString, Encoding.UTF8.GetBytes(replyChannel)), ], }; @@ -349,12 +378,18 @@ private ValueTask DeadLetterCoreAsync( // Parse reply with error handling try { - // Cold path — documents not disposed; returned values reference their memory. - // The caller owns the lifetime of the returned elements. + // The returned reply and headers are used after this method returns, so the caller's workspace + // owns their documents (disposed with the workspace) rather than this method disposing them. ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(reply.Message.Value); + workspace.TakeOwnership(replyDoc); TReply replyPayload = replyDoc.RootElement; ParsedJsonDocument? headersDoc = ExtractHeadersDocument(reply); + if (headersDoc is not null) + { + workspace.TakeOwnership(headersDoc); + } + JsonElement replyHeaders = headersDoc?.RootElement ?? default; return (replyPayload, replyHeaders); @@ -603,6 +638,224 @@ private async Task ConsumeLoop( } } + private async Task ReplyResponderLoop( + string channel, + ReadOnlyMemory channelUtf8, + IConsumer consumer, + Func> handler, + CancellationToken cancellationToken) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + // Pre-compute dead-letter channel string once (for Kafka SDK which takes string) + string dlChannel = channel + this.options.DeadLetterSuffix; + + this.options.Heartbeat?.Start(channel, "kafka"); + + try + { + while (!cancellationToken.IsCancellationRequested) + { + this.options.Heartbeat?.Tick(channel, "kafka"); + + ConsumeResult? result; + try + { + result = consumer.Consume(TimeSpan.FromMilliseconds(this.options.PollTimeoutMs)); + } + catch (ConsumeException ex) when (!cancellationToken.IsCancellationRequested) + { + // Transport-level errors (e.g., topic not yet auto-created, broker unavailable). + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Transport); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "kafka", MessageErrorKind.Transport); + break; + } + + // Skip or DeadLetter → continue polling after brief delay + AsyncApiTelemetry.RecordSkip(channel, "kafka", MessageErrorKind.Transport); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + continue; + } + + if (result?.IsPartitionEOF != false) + { + continue; + } + + ParsedJsonDocument requestDoc; + try + { + requestDoc = ParsedJsonDocument.Parse(result.Message.Value); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Deserialization); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterRawAsync(dlChannel, channelUtf8, result.Message.Value, ex, cancellationToken).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "kafka"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "kafka", dlEx); + } + } + + consumer.Commit(result); + if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "kafka", MessageErrorKind.Deserialization); + break; + } + + if (action != MessageErrorAction.DeadLetter) + { + AsyncApiTelemetry.RecordSkip(channel, "kafka", MessageErrorKind.Deserialization); + } + + continue; + } + + using (requestDoc) + { + TRequest request = requestDoc.RootElement; + JsonElement requestElement = JsonElement.From(in request); + + ParsedJsonDocument? headersDoc; + try + { + headersDoc = ExtractHeadersDocument(result); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Deserialization); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterRawAsync(dlChannel, channelUtf8, result.Message.Value, ex, cancellationToken).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "kafka"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "kafka", dlEx); + } + } + + consumer.Commit(result); + if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "kafka", MessageErrorKind.Deserialization); + break; + } + + if (action != MessageErrorAction.DeadLetter) + { + AsyncApiTelemetry.RecordSkip(channel, "kafka", MessageErrorKind.Deserialization); + } + + continue; + } + + using (headersDoc) + { + JsonElement headers = headersDoc?.RootElement ?? default; + + try + { + TReply reply; + if (this.middleware is not null) + { + TReply captured = default; + await this.middleware( + async (ct) => captured = await handler(request, headers, ct).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + reply = captured; + } + else + { + reply = await handler(request, headers, cancellationToken).ConfigureAwait(false); + } + + // Route the reply back to the requester. The reply-to topic and + // correlation id come from the headers RequestAsync set on the request. + if (result.Message.Headers?.TryGetLastBytes(ReplyToKeyString, out byte[]? replyToBytes) == true) + { + string replyChannel = Encoding.UTF8.GetString(replyToBytes); + byte[] replyBytes = SerializeToOwnedBytes(in reply); + + Message replyMessage = new() + { + Value = replyBytes, + Headers = [], + }; + + if (result.Message.Headers.TryGetLastBytes(CorrelationIdKeyString, out byte[]? corrBytes)) + { + replyMessage.Headers.Add(CorrelationIdKeyString, corrBytes); + } + + await this.producer.ProduceAsync(replyChannel, replyMessage, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Handler, requestElement, headers); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterCoreAsync(dlChannel, channelUtf8, in requestElement, in headers, ex, cancellationToken).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "kafka"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "kafka", dlEx); + } + } + + consumer.Commit(result); + if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "kafka", MessageErrorKind.Handler); + break; + } + + if (action != MessageErrorAction.DeadLetter) + { + AsyncApiTelemetry.RecordSkip(channel, "kafka", MessageErrorKind.Handler); + } + + continue; + } + } + } + + consumer.Commit(result); + } + } + catch (OperationCanceledException) + { + // Normal shutdown + } + finally + { + this.options.Heartbeat?.Stop(channel, "kafka"); + } + } + private static ParsedJsonDocument? ExtractHeadersDocument(ConsumeResult result) { if (result.Message.Headers is null) diff --git a/src/Corvus.Text.Json.AsyncApi.Mqtt/MqttMessageTransport.cs b/src/Corvus.Text.Json.AsyncApi.Mqtt/MqttMessageTransport.cs index 7c1400f5d73..27df5832701 100644 --- a/src/Corvus.Text.Json.AsyncApi.Mqtt/MqttMessageTransport.cs +++ b/src/Corvus.Text.Json.AsyncApi.Mqtt/MqttMessageTransport.cs @@ -125,6 +125,7 @@ public ValueTask PublishAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -139,7 +140,7 @@ public ValueTask PublishAsync( : null; (byte[] rented, int length) = SerializeToRented(in request); - return RequestCoreAsync(requestChannel, replyChannel, rented, length, correlationIdUtf8, headersBase64, cancellationToken); + return RequestCoreAsync(requestChannel, replyChannel, rented, length, correlationIdUtf8, headersBase64, workspace, cancellationToken); } /// @@ -154,6 +155,19 @@ public ValueTask SubscribeAsync( return SubscribeCoreAsync(channel, channelUtf8, handler, cancellationToken); } + /// + public ValueTask SubscribeReplyAsync( + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + ObjectDisposedException.ThrowIf(this.disposed, this); + string channel = Encoding.UTF8.GetString(channelUtf8.Span); + return SubscribeReplyCoreAsync(channel, channelUtf8, handler, cancellationToken); + } + /// public async ValueTask UnsubscribeAsync(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken = default) { @@ -249,6 +263,7 @@ private async ValueTask PublishAndReturnAsync( int length, ReadOnlyMemory correlationIdUtf8, string? headersBase64, + JsonWorkspace workspace, CancellationToken cancellationToken) where TReply : struct, IJsonElement { @@ -267,8 +282,9 @@ private async ValueTask PublishAndReturnAsync( await this.client.SubscribeAsync(subOptions, cancellationToken).ConfigureAwait(false); } - // Publish the request - MqttApplicationMessage requestMsg = BuildMessage(requestChannel, rented, length, headersBase64, correlationIdUtf8); + // Publish the request, advertising the reply topic via the native MQTT 5.0 ResponseTopic + // so a Corvus responder (SubscribeReplyAsync) knows where to send the correlated reply. + MqttApplicationMessage requestMsg = BuildMessage(requestChannel, rented, length, headersBase64, correlationIdUtf8, replyChannel); await this.client.PublishAsync(requestMsg, cancellationToken).ConfigureAwait(false); // Wait for correlated reply @@ -280,9 +296,11 @@ private async ValueTask PublishAndReturnAsync( TReply replyPayload; if (reply.PayloadSegment is { Count: > 0 }) { - // Cold path — document not disposed; returned values reference its memory + // Cold path — the caller's workspace owns the returned reply document (disposed with it), + // since the returned value is used after this method returns. ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse( reply.PayloadSegment.Array!.AsMemory(reply.PayloadSegment.Offset, reply.PayloadSegment.Count)); + workspace.TakeOwnership(replyDoc); replyPayload = replyDoc.RootElement; } else @@ -291,6 +309,11 @@ private async ValueTask PublishAndReturnAsync( } ParsedJsonDocument? headersDoc = DecodeHeadersDocument(reply); + if (headersDoc is not null) + { + workspace.TakeOwnership(headersDoc); + } + JsonElement replyHeaders = headersDoc?.RootElement ?? default; return (replyPayload, replyHeaders); } @@ -370,6 +393,26 @@ private async ValueTask SubscribeCoreAsync( await this.client.SubscribeAsync(subOptions, cancellationToken).ConfigureAwait(false); } + private async ValueTask SubscribeReplyCoreAsync( + string channel, + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + string dlChannel = channel + this.options.DeadLetterSuffix; + this.handlers[channel] = (message, ct) => this.DispatchToResponderAsync(channel, channelUtf8, dlChannel, handler, message, ct); + + this.options.Heartbeat?.Start(channel, "mqtt"); + + MqttClientSubscribeOptions subOptions = new MqttFactory().CreateSubscribeOptionsBuilder() + .WithTopicFilter(channel, this.options.QualityOfServiceLevel) + .Build(); + + await this.client.SubscribeAsync(subOptions, cancellationToken).ConfigureAwait(false); + } + private async ValueTask DeadLetterCoreAsync( string deadLetterChannel, string originalChannel, @@ -442,7 +485,8 @@ private MqttApplicationMessage BuildMessage( byte[] rented, int length, string? headersBase64, - ReadOnlyMemory correlationIdUtf8) + ReadOnlyMemory correlationIdUtf8, + string? responseTopic = null) { MqttApplicationMessage message = new() { @@ -451,6 +495,7 @@ private MqttApplicationMessage BuildMessage( QualityOfServiceLevel = this.options.QualityOfServiceLevel, Retain = this.options.Retain, ContentType = "application/json", + ResponseTopic = responseTopic, }; if (headersBase64 is not null) @@ -648,6 +693,191 @@ private async ValueTask DispatchToHandlerAsync( } } + private async ValueTask DispatchToResponderAsync( + string channel, + ReadOnlyMemory channelUtf8, + string deadLetterChannel, + Func> handler, + MqttApplicationMessage message, + CancellationToken cancellationToken) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + this.options.Heartbeat?.Tick(channel, "mqtt"); + + ParsedJsonDocument requestDoc; + try + { + ArraySegment payloadSegment = message.PayloadSegment; + ReadOnlyMemory payloadMemory = payloadSegment.Array is null + ? ReadOnlyMemory.Empty + : payloadSegment.Array.AsMemory(payloadSegment.Offset, payloadSegment.Count); + requestDoc = ParsedJsonDocument.Parse(payloadMemory); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Deserialization); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterRawAsync(deadLetterChannel, channel, message.PayloadSegment, ex, cancellationToken).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(deadLetterChannel, channel, "mqtt"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(deadLetterChannel, channel, "mqtt", dlEx); + } + } + else if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "mqtt", MessageErrorKind.Deserialization); + await this.UnsubscribeAsync(channelUtf8, cancellationToken).ConfigureAwait(false); + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "mqtt", MessageErrorKind.Deserialization); + } + + return; + } + + using (requestDoc) + { + TRequest request = requestDoc.RootElement; + JsonElement requestElement = JsonElement.From(in request); + + ParsedJsonDocument? headersDoc; + try + { + headersDoc = this.DecodeHeadersDocument(message); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Deserialization); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterRawAsync(deadLetterChannel, channel, message.PayloadSegment, ex, cancellationToken).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(deadLetterChannel, channel, "mqtt"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(deadLetterChannel, channel, "mqtt", dlEx); + } + } + else if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "mqtt", MessageErrorKind.Deserialization); + await this.UnsubscribeAsync(channelUtf8, cancellationToken).ConfigureAwait(false); + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "mqtt", MessageErrorKind.Deserialization); + } + + return; + } + + using (headersDoc) + { + JsonElement headers = headersDoc?.RootElement ?? default; + + try + { + TReply reply; + if (this.middleware is not null) + { + TReply captured = default; + await this.middleware(async (ct) => captured = await handler(request, headers, ct).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + reply = captured; + } + else + { + reply = await handler(request, headers, cancellationToken).ConfigureAwait(false); + } + + // Determine where to send the reply: the requester advertises the reply topic via + // the native MQTT 5.0 ResponseTopic, and correlates the reply by CorrelationData. + string? responseTopic = message.ResponseTopic; + if (string.IsNullOrEmpty(responseTopic)) + { + // No reply address — nothing to respond to. + AsyncApiTelemetry.RecordSkip(channel, "mqtt", MessageErrorKind.Handler); + return; + } + + ReadOnlyMemory correlationIdUtf8 = message.CorrelationData is { Length: > 0 } corr + ? corr + : default; + + (byte[] rented, int length) = SerializeToRented(in reply); + try + { + MqttApplicationMessage replyMsg = BuildMessage(responseTopic, rented, length, null, correlationIdUtf8); + await this.client.PublishAsync(replyMsg, cancellationToken).ConfigureAwait(false); + } + finally + { + if (rented.Length > 0) + { + ArrayPool.Shared.Return(rented); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Handler, requestElement, headers); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + byte[] rented; + int length; + if (requestElement.ValueKind != JsonValueKind.Undefined) + { + (rented, length) = SerializeToRented(in requestElement); + } + else + { + rented = []; + length = 0; + } + + string? headersBase64 = headers.ValueKind != JsonValueKind.Undefined + ? SerializeToBase64String(in headers) + : null; + + await this.DeadLetterCoreAsync(deadLetterChannel, channel, rented, length, headersBase64, ex, cancellationToken).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(deadLetterChannel, channel, "mqtt"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(deadLetterChannel, channel, "mqtt", dlEx); + } + } + else if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "mqtt", MessageErrorKind.Handler); + await this.UnsubscribeAsync(channelUtf8, cancellationToken).ConfigureAwait(false); + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "mqtt", MessageErrorKind.Handler); + } + } + } + } + } + private ParsedJsonDocument? DecodeHeadersDocument(MqttApplicationMessage message) { if (message.UserProperties is null) diff --git a/src/Corvus.Text.Json.AsyncApi.Nats/NatsChannelTransportFactory.cs b/src/Corvus.Text.Json.AsyncApi.Nats/NatsChannelTransportFactory.cs new file mode 100644 index 00000000000..96b2a6e6a1b --- /dev/null +++ b/src/Corvus.Text.Json.AsyncApi.Nats/NatsChannelTransportFactory.cs @@ -0,0 +1,59 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +namespace Corvus.Text.Json.AsyncApi.Nats; + +/// +/// The NATS (ADR 0051): builds a connected, JetStream-enabled +/// from a channel credential's settings. The credential shapes map to the +/// CONNECT handshake: bearer presents its value secret as the connection token, and basic +/// presents the username config with its password secret; the other shapes are not yet supported +/// for NATS and fail closed. +/// +public sealed class NatsChannelTransportFactory : IChannelTransportFactory +{ + /// + public string Protocol => "nats"; + + /// + public async ValueTask CreateTransportAsync(ChannelTransportSettings settings, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + + // JetStream is on by default: workflow channel steps are durable (a message published before its + // consumer subscribes must not be lost), and the stream name derives per subject when unset. + NatsTransportOptions options = new() + { + Url = settings.ServerUrl, + Name = $"{settings.SourceName}-{settings.Environment}", + UseJetStream = true, + StorageType = StorageType.File, + }; + + switch (settings.AuthKind) + { + case "bearer": + options.Token = RequireSecret(settings, "value"); + break; + + case "basic": + options.Username = settings.Config.TryGetValue("username", out string? username) && username.Length > 0 + ? username + : throw new InvalidOperationException($"The 'basic' channel credential for source '{settings.SourceName}' requires a 'username' config entry."); + options.Password = RequireSecret(settings, "password"); + break; + + default: + throw new NotSupportedException( + $"The NATS transport does not support the '{settings.AuthKind}' credential shape for source '{settings.SourceName}'; bind 'bearer' (a token presented at connect) or 'basic' (username/password)."); + } + + return await NatsMessageTransport.CreateAsync(options, cancellationToken).ConfigureAwait(false); + } + + private static string RequireSecret(ChannelTransportSettings settings, string role) + => settings.Secrets.TryGetValue(role, out string? value) && value.Length > 0 + ? value + : throw new InvalidOperationException($"The '{settings.AuthKind}' channel credential for source '{settings.SourceName}' requires a '{role}' secret reference."); +} \ No newline at end of file diff --git a/src/Corvus.Text.Json.AsyncApi.Nats/NatsMessageTransport.cs b/src/Corvus.Text.Json.AsyncApi.Nats/NatsMessageTransport.cs index 5664071265b..0e311ac10bb 100644 --- a/src/Corvus.Text.Json.AsyncApi.Nats/NatsMessageTransport.cs +++ b/src/Corvus.Text.Json.AsyncApi.Nats/NatsMessageTransport.cs @@ -105,6 +105,14 @@ public static async ValueTask CreateAsync( { Url = options.Url, Name = options.Name ?? string.Empty, + AuthOpts = new NatsAuthOpts + { + Token = options.Token, + Username = options.Username, + Password = options.Password, + Jwt = options.Jwt, + Seed = options.NKeySeed, + }, }; NatsConnection connection = new(natsOpts); @@ -243,6 +251,7 @@ private async ValueTask EnsureStreamExistsAsync(string streamName, string subjec ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -262,7 +271,7 @@ private async ValueTask EnsureStreamExistsAsync(string streamName, string subjec natsHeaders[HeadersKey] = SerializeToBase64String(in headers); } - return RequestCoreAsync(requestChannel, request, natsHeaders, cancellationToken); + return RequestCoreAsync(requestChannel, request, natsHeaders, workspace, cancellationToken); } /// @@ -290,6 +299,20 @@ public ValueTask SubscribeAsync( } } + /// + public ValueTask SubscribeReplyAsync( + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + ObjectDisposedException.ThrowIf(this.disposed, this); + + string channel = Encoding.UTF8.GetString(channelUtf8.Span); + return this.SubscribeReplyToCoreNatsAsync(channel, channelUtf8, handler, cancellationToken); + } + /// public async ValueTask UnsubscribeAsync(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken = default) { @@ -383,6 +406,7 @@ public async ValueTask DisposeAsync() string subject, TRequest request, NatsHeaders headers, + JsonWorkspace workspace, CancellationToken cancellationToken) where TRequest : struct, IJsonElement where TReply : struct, IJsonElement @@ -405,8 +429,10 @@ public async ValueTask DisposeAsync() { try { - // Cold path — document not disposed; returned values reference its memory + // Cold path — the caller's workspace owns the returned reply document (disposed with it), since + // the returned value is used after this method returns. ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(reply.Data); + workspace.TakeOwnership(replyDoc); replyPayload = replyDoc.RootElement; } catch (Exception parseEx) @@ -463,6 +489,11 @@ public async ValueTask DisposeAsync() } ParsedJsonDocument? headersDoc = DecodeHeadersDocument(reply.Headers); + if (headersDoc is not null) + { + workspace.TakeOwnership(headersDoc); + } + JsonElement replyHeaders = headersDoc?.RootElement ?? default; return (replyPayload, replyHeaders); } @@ -870,6 +901,209 @@ await this.middleware( this.subscriptions[channel] = state; } + private async ValueTask SubscribeReplyToCoreNatsAsync( + string channel, + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // Build dead-letter channel UTF-8 bytes + Span dlChannelUtf8 = stackalloc byte[channelUtf8.Length + this.deadLetterSuffixUtf8.Length]; + channelUtf8.Span.CopyTo(dlChannelUtf8); + this.deadLetterSuffixUtf8.CopyTo(dlChannelUtf8[channelUtf8.Length..]); + string dlChannel = Encoding.UTF8.GetString(dlChannelUtf8); + + this.options.Heartbeat?.Start(channel, "nats"); + + // Register the subscription with the server before starting the background + // consumption loop so the SUB command is sent before this method returns, + // matching SubscribeToCoreNatsAsync. + INatsSub sub = await this.connection.SubscribeCoreAsync( + subject: channel, + cancellationToken: cts.Token).ConfigureAwait(false); + + Task consumeTask = Task.Run( + async () => + { + try + { + await using (sub) + { + await foreach (NatsMsg msg in sub.Msgs.ReadAllAsync(cts.Token).ConfigureAwait(false)) + { + this.options.Heartbeat?.Tick(channel, "nats"); + + if (msg.Data is null) + { + continue; + } + + // Parse the request + ParsedJsonDocument requestDoc; + try + { + requestDoc = ParsedJsonDocument.Parse(msg.Data); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Deserialization); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cts.Token).ConfigureAwait(false); + if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "nats", MessageErrorKind.Deserialization); + break; + } + + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterRawAsync(dlChannel, channel, msg.Data, ex, cts.Token).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "nats"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "nats", dlEx); + } + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "nats", MessageErrorKind.Deserialization); + } + + continue; + } + + // Handle the request and publish the reply + using (requestDoc) + { + TRequest request = requestDoc.RootElement; + + ParsedJsonDocument? headersDoc; + try + { + headersDoc = DecodeHeadersDocument(msg.Headers); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Deserialization); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cts.Token).ConfigureAwait(false); + if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "nats", MessageErrorKind.Deserialization); + break; + } + + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterRawAsync(dlChannel, channel, msg.Data, ex, cts.Token).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "nats"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "nats", dlEx); + } + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "nats", MessageErrorKind.Deserialization); + } + + continue; + } + + try + { + using (headersDoc) + { + JsonElement headers = headersDoc?.RootElement ?? default; + + TReply reply; + if (this.middleware is not null) + { + TReply captured = default; + await this.middleware( + async (ct) => captured = await handler(request, headers, ct).ConfigureAwait(false), + cts.Token).ConfigureAwait(false); + reply = captured; + } + else + { + reply = await handler(request, headers, cts.Token).ConfigureAwait(false); + } + + // Publish the reply to the request's reply-to subject. NATS correlates + // the reply with the original RequestAsync caller via its inbox subject, + // so a Corvus requester receives this without any explicit correlation id. + if (msg.ReplyTo is not null) + { + await this.connection.PublishAsync( + subject: msg.ReplyTo, + data: reply, + headers: null, + replyTo: null, + serializer: JsonElementSerializer.Instance, + opts: default, + cancellationToken: cts.Token).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Handler); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cts.Token).ConfigureAwait(false); + if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "nats", MessageErrorKind.Handler); + break; + } + + if (action == MessageErrorAction.DeadLetter) + { + try + { + await this.DeadLetterRawAsync(dlChannel, channel, msg.Data, ex, cts.Token).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "nats"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(dlChannel, channel, "nats", dlEx); + } + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "nats", MessageErrorKind.Handler); + } + } + } + } + } + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + // Normal shutdown via UnsubscribeAsync or parent cancellation + } + finally + { + this.options.Heartbeat?.Stop(channel, "nats"); + } + }, + CancellationToken.None); + + SubscriptionState state = new(cts, consumeTask); + this.subscriptions[channel] = state; + } + private static ParsedJsonDocument? DecodeHeadersDocument(NatsHeaders? headers) { if (headers is null) diff --git a/src/Corvus.Text.Json.AsyncApi.Nats/NatsTransportOptions.cs b/src/Corvus.Text.Json.AsyncApi.Nats/NatsTransportOptions.cs index 91546b0f490..1aa542ab710 100644 --- a/src/Corvus.Text.Json.AsyncApi.Nats/NatsTransportOptions.cs +++ b/src/Corvus.Text.Json.AsyncApi.Nats/NatsTransportOptions.cs @@ -71,6 +71,34 @@ public sealed class NatsTransportOptions : ITransportOptions /// public string? Name { get; set; } + /// + /// Gets or sets the authentication token presented in the CONNECT handshake (the bearer credential + /// shape, ADR 0051). Mutually exclusive with the other auth settings. + /// + public string? Token { get; set; } + + /// + /// Gets or sets the username presented in the CONNECT handshake (the basic credential shape, + /// ADR 0051); pairs with . + /// + public string? Username { get; set; } + + /// + /// Gets or sets the password presented in the CONNECT handshake; pairs with . + /// + public string? Password { get; set; } + + /// + /// Gets or sets the user JWT for decentralized (NKey) authentication; pairs with . + /// + public string? Jwt { get; set; } + + /// + /// Gets or sets the NKey seed used to sign the server's challenge; pairs with (or stands + /// alone for bare NKey auth). + /// + public string? NKeySeed { get; set; } + /// /// Gets or sets the dead-letter subject suffix. /// diff --git a/src/Corvus.Text.Json.AsyncApi.Polly/Corvus.Text.Json.AsyncApi.Polly.csproj b/src/Corvus.Text.Json.AsyncApi.Polly/Corvus.Text.Json.AsyncApi.Polly.csproj index f6107ddbe35..e67276e7091 100644 --- a/src/Corvus.Text.Json.AsyncApi.Polly/Corvus.Text.Json.AsyncApi.Polly.csproj +++ b/src/Corvus.Text.Json.AsyncApi.Polly/Corvus.Text.Json.AsyncApi.Polly.csproj @@ -2,6 +2,7 @@ net10.0 + true enable enable preview diff --git a/src/Corvus.Text.Json.AsyncApi.Testing/InMemoryMessageTransport.cs b/src/Corvus.Text.Json.AsyncApi.Testing/InMemoryMessageTransport.cs index e4d44ebbfe6..02946cd2bab 100644 --- a/src/Corvus.Text.Json.AsyncApi.Testing/InMemoryMessageTransport.cs +++ b/src/Corvus.Text.Json.AsyncApi.Testing/InMemoryMessageTransport.cs @@ -31,6 +31,7 @@ public sealed class InMemoryMessageTransport : IMessageTransport, IHealthCheckab private readonly List publishedMessages = []; private readonly List deadLetteredMessages = []; private readonly Dictionary subscriptions = new(StringComparer.Ordinal); + private readonly Dictionary replySubscriptions = new(StringComparer.Ordinal); private readonly Dictionary> pendingRequests = new(StringComparer.Ordinal); /// @@ -118,6 +119,7 @@ private async ValueTask DeliverToSubscriberAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -135,6 +137,19 @@ private async ValueTask DeliverToSubscriberAsync( this.publishedMessages.Add(new PublishedMessage(requestChannel, requestBytes, headerBytes)); } + // If a responder is registered on the request channel, deliver the request to it in-process and + // route its reply back; otherwise park the request for the test helper CompleteRequest. + Delegate? responder; + lock (this.syncRoot) + { + this.replySubscriptions.TryGetValue(requestChannel, out responder); + } + + if (responder is not null) + { + return RespondAsync(responder, requestBytes, headerBytes, workspace, cancellationToken); + } + TaskCompletionSource<(byte[] Payload, byte[] Headers)> tcs = new(); lock (this.syncRoot) @@ -142,7 +157,63 @@ private async ValueTask DeliverToSubscriberAsync( this.pendingRequests[correlationId] = tcs; } - return CompleteRequestAsync(tcs, cancellationToken); + return CompleteRequestAsync(tcs, workspace, cancellationToken); + } + + /// + public ValueTask SubscribeReplyAsync( + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + ArgumentNullException.ThrowIfNull(handler); + string channel = Encoding.UTF8.GetString(channelUtf8.Span); + + lock (this.syncRoot) + { + this.replySubscriptions[channel] = handler; + } + + return ValueTask.CompletedTask; + } + + // Parses a delivered request, invokes the responder handler, and returns its reply (the request and + // reply documents are GC-backed, matching CompleteRequestAsync's semantics). + private static async ValueTask<(TReply Payload, JsonElement Headers)> RespondAsync( + Delegate responder, + byte[] requestBytes, + byte[] headerBytes, + JsonWorkspace workspace, + CancellationToken cancellationToken) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse(requestBytes); + JsonElement requestHeaders = default; + ParsedJsonDocument? headersDoc = null; + if (headerBytes.Length > 0) + { + headersDoc = ParsedJsonDocument.Parse(headerBytes); + requestHeaders = headersDoc.RootElement; + } + + try + { + var handler = (Func>)responder; + TReply reply = await handler(requestDoc.RootElement, requestHeaders, cancellationToken).ConfigureAwait(false); + + // Re-parse the reply into a document owned by the caller's workspace so it outlives this handler. + byte[] replyBytes = SerializeToOwnedBytes(in reply); + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyBytes); + workspace.TakeOwnership(replyDoc); + return (replyDoc.RootElement, default); + } + finally + { + headersDoc?.Dispose(); + } } /// @@ -170,6 +241,7 @@ public ValueTask UnsubscribeAsync(ReadOnlyMemory channelUtf8, Cancellation lock (this.syncRoot) { this.subscriptions.Remove(channel); + this.replySubscriptions.Remove(channel); } return ValueTask.CompletedTask; @@ -374,22 +446,23 @@ public void Reset() private static async ValueTask<(TReply Payload, JsonElement Headers)> CompleteRequestAsync( TaskCompletionSource<(byte[] Payload, byte[] Headers)> tcs, + JsonWorkspace workspace, CancellationToken cancellationToken) where TReply : struct, IJsonElement { (byte[] replyBytes, byte[] headerBytes) = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - // Documents are not disposed because the returned values reference their memory. - // The backing buffers will be collected by the GC when the caller releases the - // returned values. This is acceptable for request/reply (not a streaming hot path) - // and matches the InMemory testing transport's semantics. + // The returned reply and headers reference their documents' memory, so the caller's workspace owns + // those documents (disposed when the workspace is) rather than leaving them to the GC. ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyBytes); + workspace.TakeOwnership(replyDoc); TReply reply = replyDoc.RootElement; JsonElement headers = default; if (headerBytes.Length > 0) { ParsedJsonDocument headersDoc = ParsedJsonDocument.Parse(headerBytes); + workspace.TakeOwnership(headersDoc); headers = headersDoc.RootElement; } diff --git a/src/Corvus.Text.Json.AsyncApi.WebSocket/WebSocketMessageTransport.cs b/src/Corvus.Text.Json.AsyncApi.WebSocket/WebSocketMessageTransport.cs index b424b2b74a6..895ee6a58e2 100644 --- a/src/Corvus.Text.Json.AsyncApi.WebSocket/WebSocketMessageTransport.cs +++ b/src/Corvus.Text.Json.AsyncApi.WebSocket/WebSocketMessageTransport.cs @@ -40,6 +40,7 @@ public sealed class WebSocketMessageTransport : IMessageTransport private readonly IMessageErrorPolicy errorPolicy; private readonly MessageHandlerMiddleware? middleware; private readonly ConcurrentDictionary> handlers = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> replyHandlers = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> pendingReplies = new(StringComparer.Ordinal); private readonly SemaphoreSlim sendSemaphore = new(1, 1); private CancellationTokenSource? receiveCts; @@ -96,6 +97,7 @@ public ValueTask PublishAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -106,9 +108,9 @@ public ValueTask PublishAsync( string correlationId = Encoding.UTF8.GetString(correlationIdUtf8.Span); ObjectDisposedException.ThrowIf(this.disposed, this); - (byte[] rented, int length) = BuildPublishEnvelopeRented(requestChannel, in request, in headers, correlationId); + (byte[] rented, int length) = BuildPublishEnvelopeRented(requestChannel, in request, in headers, correlationId, replyChannel); - return RequestCoreAsync(replyChannel, rented, length, correlationId, cancellationToken); + return RequestCoreAsync(replyChannel, rented, length, correlationId, workspace, cancellationToken); } /// @@ -129,11 +131,32 @@ public ValueTask SubscribeAsync( return SendAndReturnAsync(rented, length, cancellationToken); } + /// + public ValueTask SubscribeReplyAsync( + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + string channel = Encoding.UTF8.GetString(channelUtf8.Span); + ObjectDisposedException.ThrowIf(this.disposed, this); + this.replyHandlers[channel] = (payload, headers, replyChannel, correlationId, ct) => + this.DispatchToReplyHandlerAsync(channel, channelUtf8, handler, payload, headers, replyChannel, correlationId, ct); + + this.options.Heartbeat?.Start(channel, "websocket"); + + // Send a subscribe envelope to the server so requests are routed to this connection + (byte[] rented, int length) = BuildControlEnvelopeRented(channel, "subscribe"u8); + return SendAndReturnAsync(rented, length, cancellationToken); + } + /// public ValueTask UnsubscribeAsync(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken = default) { string channel = Encoding.UTF8.GetString(channelUtf8.Span); this.handlers.TryRemove(channel, out _); + this.replyHandlers.TryRemove(channel, out _); this.options.Heartbeat?.Stop(channel, "websocket"); @@ -171,6 +194,7 @@ public async ValueTask DisposeAsync() this.disposed = true; this.handlers.Clear(); + this.replyHandlers.Clear(); this.pendingReplies.Clear(); if (this.receiveCts is not null) @@ -270,11 +294,12 @@ private async Task DispatchEnvelopeAsync(byte[] envelopeBytes, CancellationToken channelUtf8 = Encoding.UTF8.GetBytes(channel); JsonString corrIdProp = envelope.CorrelationId; + string? envelopeCorrelationId = null; if (corrIdProp.ValueKind != JsonValueKind.Undefined) { - string? correlationId = corrIdProp.GetString(); - if (correlationId is not null && - this.pendingReplies.TryRemove(correlationId, out TaskCompletionSource? tcs)) + envelopeCorrelationId = corrIdProp.GetString(); + if (envelopeCorrelationId is not null && + this.pendingReplies.TryRemove(envelopeCorrelationId, out TaskCompletionSource? tcs)) { tcs.SetResult(envelopeBytes); return; @@ -284,6 +309,21 @@ private async Task DispatchEnvelopeAsync(byte[] envelopeBytes, CancellationToken JsonElement payload = envelope.Payload; JsonElement headers = envelope.Headers; + if (payload.ValueKind != JsonValueKind.Undefined && + this.replyHandlers.TryGetValue(channel, out Func? replyHandler)) + { + string? replyChannel = null; + if (envelope.TryGetProperty("replyChannel"u8, out JsonElement replyChannelEl) && + replyChannelEl.ValueKind == JsonValueKind.String) + { + replyChannel = replyChannelEl.GetString(); + } + + this.options.Heartbeat?.Tick(channel, "websocket"); + await replyHandler(payload, headers, replyChannel, envelopeCorrelationId, cancellationToken).ConfigureAwait(false); + return; + } + if (payload.ValueKind != JsonValueKind.Undefined && this.handlers.TryGetValue(channel, out Func? handler)) { @@ -381,6 +421,83 @@ private async ValueTask DispatchToHandlerAsync( } } + private async ValueTask DispatchToReplyHandlerAsync( + string channel, + ReadOnlyMemory channelUtf8, + Func> handler, + JsonElement payload, + JsonElement headers, + string? replyChannel, + string? correlationId, + CancellationToken cancellationToken) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + try + { + TRequest typedRequest = JsonElementHelpers.Reinterpret(in payload); + + TReply reply; + if (this.middleware is not null) + { + TReply captured = default; + await this.middleware( + async (ct) => captured = await handler(typedRequest, headers, ct).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + reply = captured; + } + else + { + reply = await handler(typedRequest, headers, cancellationToken).ConfigureAwait(false); + } + + // Without a reply channel there is nowhere to send the response; the request cannot be answered. + if (replyChannel is null) + { + AsyncApiTelemetry.RecordSkip(channel, "websocket", MessageErrorKind.Handler); + return; + } + + // Send the reply on the reply channel with the request's correlation id so the requester's + // RequestAsync (which correlates by that id) receives it. + JsonElement replyHeaders = default; + (byte[] rented, int length) = BuildPublishEnvelopeRented(replyChannel, in reply, in replyHeaders, correlationId); + await this.SendAndReturnAsync(rented, length, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + MessageErrorContext ctx = new(channelUtf8, MessageErrorKind.Handler, payload, headers); + MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, ctx, cancellationToken).ConfigureAwait(false); + if (action == MessageErrorAction.DeadLetter) + { + try + { + string dlChannel = channel + this.options.DeadLetterSuffix; + (byte[] rented, int length) = BuildDeadLetterEnvelopeRented(dlChannel, channel, in payload, in headers, ex); + await this.SendAndReturnAsync(rented, length, cancellationToken).ConfigureAwait(false); + AsyncApiTelemetry.RecordDeadLetter(dlChannel, channel, "websocket"); + } + catch (Exception dlEx) when (dlEx is not OperationCanceledException) + { + AsyncApiTelemetry.RecordDeadLetterFailure(channel + this.options.DeadLetterSuffix, channel, "websocket", dlEx); + } + } + else if (action == MessageErrorAction.Abort) + { + AsyncApiTelemetry.RecordAbort(channel, "websocket", MessageErrorKind.Handler); + await this.UnsubscribeAsync(channelUtf8, cancellationToken).ConfigureAwait(false); + } + else + { + AsyncApiTelemetry.RecordSkip(channel, "websocket", MessageErrorKind.Handler); + } + } + } + private ValueTask DeadLetterRawAsync( string deadLetterChannel, string originalChannel, @@ -409,6 +526,7 @@ private async ValueTask SendAndReturnAsync(byte[] rented, int length, Cancellati byte[] envelopeRented, int envelopeLength, string correlationId, + JsonWorkspace workspace, CancellationToken cancellationToken) where TReply : struct, IJsonElement { @@ -432,7 +550,10 @@ private async ValueTask SendAndReturnAsync(byte[] rented, int length, Cancellati // Parse the reply envelope with error handling try { + // One envelope document backs both the returned payload and headers, and both are used after this + // method returns, so the caller's workspace owns it (disposed with the workspace). ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyEnvelopeBytes); + workspace.TakeOwnership(replyDoc); WebSocketEnvelope replyEnvelope = replyDoc.RootElement; JsonElement payloadEl = replyEnvelope.Payload; @@ -516,7 +637,8 @@ private static (byte[] Rented, int Length) BuildPublishEnvelopeRented( string channel, in TPayload payload, in JsonElement headers, - string? correlationId) + string? correlationId, + string? replyChannel = null) where TPayload : struct, IJsonElement { ArrayBufferWriter buffer = t_serializeBuffer ??= new(512); @@ -541,6 +663,11 @@ private static (byte[] Rented, int Length) BuildPublishEnvelopeRented( writer.WriteString("correlationId"u8, correlationId); } + if (replyChannel is not null) + { + writer.WriteString("replyChannel"u8, replyChannel); + } + writer.WriteEndObject(); writer.Flush(); diff --git a/src/Corvus.Text.Json.AsyncApi/Corvus.Text.Json.AsyncApi.csproj b/src/Corvus.Text.Json.AsyncApi/Corvus.Text.Json.AsyncApi.csproj index 35b95ff3ba5..5d840dcd045 100644 --- a/src/Corvus.Text.Json.AsyncApi/Corvus.Text.Json.AsyncApi.csproj +++ b/src/Corvus.Text.Json.AsyncApi/Corvus.Text.Json.AsyncApi.csproj @@ -2,6 +2,7 @@ net10.0 + true enable enable preview diff --git a/src/Corvus.Text.Json.AsyncApi/IChannelTransportFactory.cs b/src/Corvus.Text.Json.AsyncApi/IChannelTransportFactory.cs new file mode 100644 index 00000000000..db344c1182f --- /dev/null +++ b/src/Corvus.Text.Json.AsyncApi/IChannelTransportFactory.cs @@ -0,0 +1,52 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +namespace Corvus.Text.Json.AsyncApi; + +/// +/// The connection settings a protocol's builds an +/// from (ADR 0051): the environment's broker endpoint, the credential shape, the +/// resolved secret values by role, and any non-secret configuration. Protocol-neutral by design — each factory +/// interprets the auth kind and config in its protocol's terms (a bearer secret is a NATS token or an +/// Azure Service Bus SAS; a basic pair is a NATS user/password or Kafka SASL/PLAIN). +/// +/// The channel source's sourceDescriptions name (for connection naming/diagnostics). +/// The deployment environment the connection serves (for connection naming/diagnostics). +/// The broker endpoint from the binding's serverUrl config — protocol-interpreted +/// (a Kafka value may be a bootstrap list). +/// The credential shape's wire token (bearer, basic, +/// oauth2ClientCredentials, mtls) — how the secrets become an authenticator. +/// The resolved secret values by role name (e.g. value, password). Revealed +/// once at connection time and held only by the built transport's connection, mirroring the OAuth +/// client-credential posture; never logged. +/// The binding's non-secret configuration entries (e.g. username, a SASL +/// mechanism), excluding secrets by construction. +public sealed record ChannelTransportSettings( + string SourceName, + string Environment, + string ServerUrl, + string AuthKind, + IReadOnlyDictionary Secrets, + IReadOnlyDictionary Config); + +/// +/// Builds a connected for one broker protocol (ADR 0051) — the channel analogue +/// of the HTTP authentication providers. The protocol is declared by the channel source's AsyncAPI document +/// (servers[].protocol) and baked into the workflow's descriptor; a host registers one factory per +/// protocol it serves (each in its own package with its own broker SDK), and the runner's channel-transport +/// cache dispatches to the factory matching the source's protocol. An unregistered protocol or an unsupported +/// (protocol, auth-kind) combination fails closed. +/// +public interface IChannelTransportFactory +{ + /// Gets the AsyncAPI protocol identifier this factory serves (e.g. nats, kafka). + string Protocol { get; } + + /// Builds and connects a transport from the settings. + /// The connection settings resolved from the source's channel credential. + /// A cancellation token. + /// The connected transport; the caller owns its lifetime. + /// The auth kind is not supported for this protocol. + ValueTask CreateTransportAsync(ChannelTransportSettings settings, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Corvus.Text.Json.AsyncApi/IMessageTransport.cs b/src/Corvus.Text.Json.AsyncApi/IMessageTransport.cs index d1fb0c7e08e..d7cba3c02d6 100644 --- a/src/Corvus.Text.Json.AsyncApi/IMessageTransport.cs +++ b/src/Corvus.Text.Json.AsyncApi/IMessageTransport.cs @@ -91,14 +91,18 @@ ValueTask PublishAsync( /// The memory must remain valid until this method completes. For GUIDs, use /// Guid.TryFormat(Span<byte>, out _, "D") to format directly to a byte[36] /// without allocating an intermediate string. + /// Takes ownership of the parsed reply's payload and headers documents. The returned + /// reply is a view over documents this workspace owns, so it stays valid until the workspace is disposed - + /// dispose the workspace once the reply is no longer needed (a generated caller threads the run's workspace). /// Optional message headers. /// A cancellation token. - /// The reply payload and headers. + /// The reply payload and headers, owned by . ValueTask<(TReply Payload, JsonElement Headers)> RequestAsync( ReadOnlyMemory requestChannelUtf8, ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -114,21 +118,24 @@ ValueTask PublishAsync( /// The request payload. /// A correlation identifier linking request to reply, as UTF-8 bytes. /// The message context containing bindings and content type. + /// Takes ownership of the parsed reply's payload and headers documents; dispose it once + /// the reply is no longer needed. /// Optional message headers. /// A cancellation token. - /// The reply payload and headers. + /// The reply payload and headers, owned by . ValueTask<(TReply Payload, JsonElement Headers)> RequestAsync( ReadOnlyMemory requestChannelUtf8, ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, in MessageContext context, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement where TReply : struct, IJsonElement { - return RequestAsync(requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, headers, cancellationToken); + return RequestAsync(requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, workspace, headers, cancellationToken); } /// @@ -166,6 +173,37 @@ ValueTask SubscribeAsync( return SubscribeAsync(channelUtf8, handler, cancellationToken); } + /// + /// Subscribes to request messages on a channel and replies to each — the responder counterpart of + /// . + /// + /// + /// + /// For every request delivered on the transport parses the typed + /// request, invokes to obtain the reply payload, and publishes that reply + /// to the request's reply-to address correlated to the request. The transport owns correlation: it + /// reads the request's reply-to address and correlation identifier from the native broker fields (the + /// same ones RequestAsync sets) — the handler never sees the correlation plumbing. + /// + /// + /// This is an optional capability: a transport opts in by overriding this member. The default + /// implementation throws . + /// + /// + /// The request payload type the responder parses into. + /// The reply payload type the handler returns. + /// The request channel address as UTF-8 bytes. + /// The handler invoked with each request payload and its headers, returning the reply payload. + /// A cancellation token. + /// A representing the asynchronous operation. + ValueTask SubscribeReplyAsync( + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + => throw new NotSupportedException("This transport does not support request/reply responders (SubscribeReplyAsync)."); + /// /// Unsubscribes from messages on the specified channel. /// diff --git a/src/Corvus.Text.Json.AsyncApi/InstrumentedMessageTransport.cs b/src/Corvus.Text.Json.AsyncApi/InstrumentedMessageTransport.cs index b57649189a9..ee10cf613f1 100644 --- a/src/Corvus.Text.Json.AsyncApi/InstrumentedMessageTransport.cs +++ b/src/Corvus.Text.Json.AsyncApi/InstrumentedMessageTransport.cs @@ -85,13 +85,14 @@ public ValueTask PublishAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers, CancellationToken cancellationToken) where TRequest : struct, IJsonElement where TReply : struct, IJsonElement { return RequestCoreAsync( - requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, headers, cancellationToken); + requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, workspace, headers, cancellationToken); } /// @@ -101,6 +102,7 @@ public ValueTask PublishAsync( TRequest request, ReadOnlyMemory correlationIdUtf8, in MessageContext context, + JsonWorkspace workspace, JsonElement headers, CancellationToken cancellationToken) where TRequest : struct, IJsonElement @@ -108,7 +110,7 @@ public ValueTask PublishAsync( { MessageContext contextCopy = context; return RequestWithContextCoreAsync( - requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, contextCopy, headers, cancellationToken); + requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, contextCopy, workspace, headers, cancellationToken); } /// @@ -265,6 +267,7 @@ await this.inner.PublishAsync(channelUtf8, in payload, in context, in headers, c ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers, CancellationToken cancellationToken) where TRequest : struct, IJsonElement @@ -286,7 +289,7 @@ await this.inner.PublishAsync(channelUtf8, in payload, in context, in headers, c try { var result = await this.inner.RequestAsync( - requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, headers, cancellationToken) + requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, workspace, headers, cancellationToken) .ConfigureAwait(false); AsyncApiTelemetry.MessagesSent.Add( @@ -317,6 +320,7 @@ await this.inner.PublishAsync(channelUtf8, in payload, in context, in headers, c TRequest request, ReadOnlyMemory correlationIdUtf8, MessageContext context, + JsonWorkspace workspace, JsonElement headers, CancellationToken cancellationToken) where TRequest : struct, IJsonElement @@ -338,7 +342,7 @@ await this.inner.PublishAsync(channelUtf8, in payload, in context, in headers, c try { var result = await this.inner.RequestAsync( - requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, in context, headers, cancellationToken) + requestChannelUtf8, replyChannelUtf8, request, correlationIdUtf8, in context, workspace, headers, cancellationToken) .ConfigureAwait(false); AsyncApiTelemetry.MessagesSent.Add( diff --git a/src/Corvus.Text.Json.AsyncApi/MessageTransportReceiveExtensions.cs b/src/Corvus.Text.Json.AsyncApi/MessageTransportReceiveExtensions.cs new file mode 100644 index 00000000000..e8d4928d0b0 --- /dev/null +++ b/src/Corvus.Text.Json.AsyncApi/MessageTransportReceiveExtensions.cs @@ -0,0 +1,179 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.Runtime.ExceptionServices; +using Corvus.Text.Json.Internal; + +namespace Corvus.Text.Json.AsyncApi; + +/// +/// One-shot receive helpers over the strongly-typed subscriber. +/// +public static class MessageTransportReceiveExtensions +{ + /// + /// Awaits a single message on a channel: subscribes with the strongly-typed subscriber, invokes + /// with the first delivered message, then unsubscribes. + /// + /// + /// + /// The delivered payload is valid only for the duration of — the + /// transport recycles its parse buffer once the handler returns. A caller that needs the message (or + /// values projected from it) to outlive the receive must copy what it needs into its own workspace + /// inside the handler. Running the projection in-handler lets a caller copy only the values + /// it actually uses rather than cloning the whole message first. + /// + /// + /// The handler runs inline on the delivering thread (continuations are not forced asynchronous), so a + /// thread-affine pooled the handler writes into stays on its owning thread. + /// An exception thrown by the handler is captured and re-thrown to the awaiting caller after the + /// subscription is torn down. + /// + /// + /// The message payload type the subscriber parses into. + /// The message transport. + /// The channel address as UTF-8 bytes. + /// The handler invoked with the first delivered payload and its headers while the payload is live. + /// A cancellation token. + /// An optional predicate that selects which delivered message to handle (e.g. a correlation match). Messages for which it returns are ignored and the subscription keeps waiting; when the first message is handled. + /// A that completes once a message has been handled and the subscription removed. + public static async ValueTask ReceiveOneAsync( + this IMessageTransport transport, + ReadOnlyMemory channelUtf8, + Func onMessage, + CancellationToken cancellationToken = default, + Func? accept = null) + where TPayload : struct, IJsonElement + { + ArgumentNullException.ThrowIfNull(transport); + ArgumentNullException.ThrowIfNull(onMessage); + + // Continuations run inline on the delivering thread so the awaiting caller resumes there rather + // than hopping to the thread pool (a pooled JsonWorkspace the handler writes into is thread-affine). + var completion = new TaskCompletionSource(); + ExceptionDispatchInfo? failure = null; + + await transport.SubscribeAsync( + channelUtf8, + async (payload, headers, ct) => + { + // A selector (e.g. a correlation-id match) lets non-matching messages flow past: ignore them + // and leave the subscription open so a later, matching message completes the receive. + if (accept is not null && !accept(payload, headers)) + { + return; + } + + try + { + await onMessage(payload, headers).ConfigureAwait(false); + } + catch (Exception ex) + { + failure = ExceptionDispatchInfo.Capture(ex); + } + finally + { + completion.TrySetResult(); + } + }, + cancellationToken).ConfigureAwait(false); + + try + { + await completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + await transport.UnsubscribeAsync(channelUtf8, cancellationToken).ConfigureAwait(false); + } + + failure?.Throw(); + } + + /// + /// Awaits a single request on a channel and replies to it once: subscribes with the request/reply + /// responder (), invokes + /// with the first delivered request to obtain the reply, lets the transport + /// publish that reply correlated to the request, then unsubscribes — the responder counterpart of + /// . + /// + /// + /// + /// The delivered request is valid only for the duration of ; the reply it + /// returns is serialized by the transport synchronously as the handler completes (before the request and + /// the caller's workspace are recycled), so the reply may reference the live request, the workflow + /// inputs, or prior step outputs without being copied. A caller that needs values projected from the + /// request to outlive the receive must copy them into its own workspace inside the handler. + /// + /// + /// The handler runs inline on the delivering thread (continuations are not forced asynchronous), so a + /// thread-affine pooled the handler writes into stays on its owning thread. + /// An exception thrown by the handler is captured and re-thrown to the awaiting caller after the + /// subscription is torn down; the transport still publishes whatever reply the responder produced. + /// + /// + /// The request payload type the responder parses into. + /// The reply payload type the handler returns. + /// The message transport. + /// The request channel address as UTF-8 bytes. + /// The handler invoked with the first delivered request and its headers, returning the reply payload. + /// A cancellation token. + /// A that completes once a request has been handled and the subscription removed. + public static async ValueTask ReceiveOneAndReplyAsync( + this IMessageTransport transport, + ReadOnlyMemory channelUtf8, + Func> onRequest, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement + { + ArgumentNullException.ThrowIfNull(transport); + ArgumentNullException.ThrowIfNull(onRequest); + + // Continuations run asynchronously: the unsubscribe (and the caller's post-receive code) must NOT run + // inline within the transport's delivery callback, because a transport publishes the reply right + // AFTER the handler returns and tears the subscription down on its own cancellation source — an inline + // unsubscribe would cancel that in-flight reply (observed as a hang on AMQP). Running the continuation + // off the delivery thread lets the transport finish publishing the reply before this method + // unsubscribes. Because the caller resumes off the delivering thread, a responder workflow must build + // its products in a non-thread-affine JsonWorkspace (JsonWorkspace.CreateUnrented). + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ExceptionDispatchInfo? failure = null; + + await transport.SubscribeReplyAsync( + channelUtf8, + async (request, headers, ct) => + { + try + { + return await onRequest(request, headers).ConfigureAwait(false); + } + catch (Exception ex) + { + // Capture the failure for the awaiting workflow, then let it propagate to the transport + // so its error policy suppresses the reply rather than publishing an Undefined default + // — a failed responder sends no reply (the requester times out), like a failed consumer. + failure = ExceptionDispatchInfo.Capture(ex); + throw; + } + finally + { + completion.TrySetResult(); + } + }, + cancellationToken).ConfigureAwait(false); + + try + { + await completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + await transport.UnsubscribeAsync(channelUtf8, cancellationToken).ConfigureAwait(false); + } + + failure?.Throw(); + } +} \ No newline at end of file diff --git a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi26CodeGeneratorTests.cs b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi26CodeGeneratorTests.cs index 8bdead687a9..6cb88a96c5a 100644 --- a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi26CodeGeneratorTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi26CodeGeneratorTests.cs @@ -126,6 +126,34 @@ public void Compile_RequestReplyExtension_GeneratedCodeCompiles() DynamicCompiler.AssertCompiles(files, "Calculator.AsyncApi26.Generated", stubs); } + [TestMethod] + public void DescribeChannelOperations_ResolvesProducerAndPayloadType() + { + var schemaTypeMap = new Dictionary + { + ["#/components/schemas/turnOnOffPayload"] = "Streetlights.TurnOnOffPayload", + ["#/components/schemas/lightMeasuredPayload"] = "Streetlights.LightMeasuredPayload", + }; + + var generator = new AsyncApi26CodeGenerator("Streetlights", schemaTypeMap); + IReadOnlyList channels = generator.DescribeChannelOperations(streetlightsRoot); + + Assert.AreEqual(2, channels.Count); + + // 'subscribe' maps to a send operation, which gets a producer. + AsyncApiChannelDescriptor send = channels.Single(c => c.Action == OperationAction.Send); + Assert.AreEqual("Streetlights.TurnOnProducer", send.ProducerClassName); + + AsyncApiChannelMessageDescriptor message = send.Messages.Single(); + Assert.AreEqual("Streetlights.TurnOnOffPayload", message.PayloadTypeName); + Assert.AreEqual("PublishTurnOnOffAsync", message.ProducerMethodName); + + // 'publish' maps to a receive operation, which has no producer. + AsyncApiChannelDescriptor receive = channels.Single(c => c.Action == OperationAction.Receive); + Assert.IsNull(receive.ProducerClassName); + Assert.AreEqual("Streetlights.LightMeasuredPayload", receive.Messages.Single().PayloadTypeName); + } + private static Dictionary CreateRequestReplySchemaTypeMap() { return new() diff --git a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs index c41361eea7f..99ef9e30a1c 100644 --- a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApi30CodeGeneratorTests.cs @@ -178,6 +178,34 @@ public void Generate_ProducerContainsPublishMethod() Assert.IsTrue(producerFile.Content.Contains("TurnOnOffPayload"), "Producer should reference the payload type"); } + [TestMethod] + public void DescribeChannelOperations_ResolvesProducerAndPayloadType() + { + var schemaTypeMap = new Dictionary + { + ["#/components/schemas/turnOnOffPayload"] = "Streetlights.TurnOnOffPayload", + ["#/components/schemas/lightMeasuredPayload"] = "Streetlights.LightMeasuredPayload", + }; + + var generator = new AsyncApi30CodeGenerator("Streetlights", schemaTypeMap); + IReadOnlyList channels = generator.DescribeChannelOperations(streetlightsRoot); + + Assert.AreEqual(2, channels.Count); + + AsyncApiChannelDescriptor send = channels.Single(c => c.Action == OperationAction.Send); + Assert.IsNotNull(send.ProducerClassName); + Assert.IsTrue(send.ProducerClassName!.StartsWith("Streetlights.", StringComparison.Ordinal), send.ProducerClassName); + Assert.IsTrue(send.ProducerClassName.EndsWith("Producer", StringComparison.Ordinal), send.ProducerClassName); + + AsyncApiChannelMessageDescriptor message = send.Messages.Single(); + Assert.AreEqual("Streetlights.TurnOnOffPayload", message.PayloadTypeName); + Assert.AreEqual("PublishTurnOnOffAsync", message.ProducerMethodName); + + // A receive operation has no producer. + AsyncApiChannelDescriptor receive = channels.Single(c => c.Action == OperationAction.Receive); + Assert.IsNull(receive.ProducerClassName); + } + [TestMethod] public void Generate_ConsumerHandlerContainsHandleMethod() { @@ -248,6 +276,90 @@ public void Generate_UnsupportedSchemaFormat_ThrowsNotSupportedException() StringAssert.Contains(ex.Message, "application/vnd.apache.avro"); } + [TestMethod] + public void DescribeChannelOperations_PopulatesReplyForRequestReply() + { + byte[] bytes = File.ReadAllBytes(Path.Combine("TestData", "request-reply.json")); + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(bytes); + JsonElement root = doc.RootElement.Clone(); + + var schemaTypeMap = new Dictionary + { + ["#/components/messages/CalculateRequest/payload"] = "Calculator.CalculateRequest", + ["#/components/messages/CalculateResponse/payload"] = "Calculator.CalculateResponse", + }; + + var generator = new AsyncApi30CodeGenerator("Calculator", schemaTypeMap); + IReadOnlyList channels = generator.DescribeChannelOperations(root); + + AsyncApiChannelDescriptor send = channels.Single(c => c.Action == OperationAction.Send && c.ReplyPayloadTypeName is not null); + Assert.AreEqual("Calculator.CalculateResponse", send.ReplyPayloadTypeName); + Assert.AreEqual("SendAndReceiveCalculateRequestAsync", send.Messages.Single().RequestReplyMethodName); + } + + [TestMethod] + public void DescribeChannelOperations_SurfacesMessageCorrelationId() + { + // A message references a named correlation id ($ref to components.correlationIds); the descriptor + // surfaces both the name (the $ref key — what an Arazzo receive step's correlationId matches) and + // its location runtime expression. + const string document = """ + { + "asyncapi": "3.0.0", + "info": { "title": "t", "version": "1.0.0" }, + "channels": { + "replies": { "address": "replies", "messages": { "reply": { "$ref": "#/components/messages/Reply" } } } + }, + "operations": { + "onReply": { "action": "receive", "channel": { "$ref": "#/channels/replies" }, "messages": [ { "$ref": "#/channels/replies/messages/reply" } ] } + }, + "components": { + "messages": { "Reply": { "payload": { "type": "object" }, "correlationId": { "$ref": "#/components/correlationIds/myCorr" } } }, + "correlationIds": { "myCorr": { "location": "$message.payload#/correlationId" } } + } + } + """; + + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(System.Text.Encoding.UTF8.GetBytes(document)); + var generator = new AsyncApi30CodeGenerator("Replies", new Dictionary()); + + IReadOnlyList channels = generator.DescribeChannelOperations(doc.RootElement.Clone()); + + AsyncApiChannelMessageDescriptor message = channels.Single(c => c.Action == OperationAction.Receive).Messages.Single(); + Assert.AreEqual("myCorr", message.CorrelationIdName); + Assert.AreEqual("$message.payload#/correlationId", message.CorrelationIdLocation); + } + + [TestMethod] + public void Generate_ReceiveWithReply_HandlerReturnsReplyAndConsumerUsesSubscribeReply() + { + byte[] bytes = File.ReadAllBytes(Path.Combine("TestData", "receive-request-reply.json")); + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(bytes); + JsonElement root = doc.RootElement.Clone(); + + var schemaTypeMap = new Dictionary + { + ["#/components/messages/CalculateRequest/payload"] = "Worker.CalculateRequest", + ["#/components/messages/CalculateRequest/headers"] = "Worker.CalculateRequestHeaders", + ["#/components/messages/CalculateResponse/payload"] = "Worker.CalculateResponse", + }; + + var generator = new AsyncApi30CodeGenerator("Worker", schemaTypeMap); + IReadOnlyList files = generator.Generate(root); + + // The handler returns the reply payload (responder), not void. + GeneratedFile handler = files.First(f => f.FileName.Contains("Handler")); + Assert.IsTrue( + handler.Content.Contains("ValueTask HandleCalculateRequestAsync"), + handler.Content); + + // The consumer subscribes through the responder primitive. + GeneratedFile consumer = files.First(f => f.FileName.Contains("Consumer")); + Assert.IsTrue( + consumer.Content.Contains("SubscribeReplyAsync"), + consumer.Content); + } + [TestMethod] public void Generate_RequestReply_ProducerContainsSendAndReceiveMethod() { @@ -465,6 +577,13 @@ public void DynamicAddress_ProducerHasChannelParameter() // Dynamic address: method should accept 'string channel' parameter StringAssert.Contains(producer.Content, "string channel"); + // Dynamic address: also emits ReadOnlySpan and byte-span/memory overloads + StringAssert.Contains(producer.Content, "ReadOnlySpan channel"); + StringAssert.Contains(producer.Content, "ReadOnlySpan channelUtf8"); + // The string overload delegates to the ReadOnlySpan overload + StringAssert.Contains(producer.Content, "channel.AsSpan()"); + // All overloads delegate to a shared private Core + StringAssert.Contains(producer.Content, "Core("); // Should NOT have a const ChannelAddress Assert.IsFalse(producer.Content.Contains("const string ChannelAddress")); } @@ -485,6 +604,13 @@ public void DynamicAddress_ConsumerStartAcceptsChannel() // Dynamic: StartAsync should accept channel parameter StringAssert.Contains(consumer.Content, "StartAsync(string channel"); + // Dynamic: also emits ReadOnlySpan and byte-span/memory overloads + StringAssert.Contains(consumer.Content, "StartAsync(ReadOnlySpan channel"); + StringAssert.Contains(consumer.Content, "StartAsync(ReadOnlyMemory channelUtf8"); + // The string overload delegates to the ReadOnlySpan overload + StringAssert.Contains(consumer.Content, "channel.AsSpan()"); + // All overloads delegate to a shared private Core + StringAssert.Contains(consumer.Content, "StartAsyncCore("); // Should store the channel for stop StringAssert.Contains(consumer.Content, "subscribedChannel"); } @@ -2074,6 +2200,12 @@ public void Generate_ConsumerDynamicMultiMessage_EmitsDynamicAddressWithAuth() // Dynamic address: StartAsync takes a channel parameter and stores it StringAssert.Contains(consumer.Content, "string channel"); StringAssert.Contains(consumer.Content, "this.subscribedChannel = channel;"); + // Dynamic address: also emits ReadOnlySpan and byte-span/memory overloads (auth path) + StringAssert.Contains(consumer.Content, "StartAsync(ReadOnlySpan channel"); + StringAssert.Contains(consumer.Content, "StartAsync(ReadOnlyMemory channelUtf8"); + StringAssert.Contains(consumer.Content, "channel.AsSpan()"); + // The Core is the async (auth) variant that performs authentication + StringAssert.Contains(consumer.Content, "private async ValueTask StartAsyncCore(ReadOnlyMemory channelUtf8"); } [TestMethod] @@ -2189,6 +2321,13 @@ public void Generate_RequestReplyDynamicAddress_EmitsDynamicChannelParam() // Dynamic address: request method takes a channel parameter StringAssert.Contains(producer.Content, "SendAndReceiveRpcRequestAsync"); StringAssert.Contains(producer.Content, "string channel"); + // Dynamic address: also emits ReadOnlySpan and byte-span/memory overloads + StringAssert.Contains(producer.Content, "ReadOnlySpan channel"); + StringAssert.Contains(producer.Content, "ReadOnlySpan channelUtf8"); + // The string overload delegates to the ReadOnlySpan overload + StringAssert.Contains(producer.Content, "channel.AsSpan()"); + // All overloads delegate to a shared private Core + StringAssert.Contains(producer.Content, "SendAndReceiveRpcRequestAsyncCore("); } [TestMethod] diff --git a/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApiExternalReferenceResolverTests.cs b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApiExternalReferenceResolverTests.cs new file mode 100644 index 00000000000..a58163e7950 --- /dev/null +++ b/tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/AsyncApiExternalReferenceResolverTests.cs @@ -0,0 +1,77 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.Text; +using Corvus.Text.Json; +using Corvus.Text.Json.AsyncApi.CodeGeneration; + +namespace Corvus.Text.Json.AsyncApi.CodeGeneration.Tests; + +[TestClass] +public class AsyncApiExternalReferenceResolverTests +{ + private static readonly string EntryDoc = """ + { + "components": { + "schemas": { + "Pet": { "type": "object", "properties": { "name": { "type": "string" } } } + } + } + } + """; + + private static readonly string ExternalSchemaDoc = """ + { + "definitions": { + "Error": { "type": "object", "properties": { "code": { "type": "integer" } } } + }, + "type": "object", + "properties": { "id": { "type": "integer" } } + } + """; + + [TestMethod] + public void Constructor_NullBaseUri_ThrowsArgumentNullException() + { + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(EntryDoc); + + Assert.ThrowsExactly(() => + new AsyncApiExternalReferenceResolver(doc.RootElement, (Uri)null!)); + } + + [TestMethod] + public void UriBase_FragmentOnly_ResolvesInEntryDoc() + { + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(EntryDoc); + using AsyncApiExternalReferenceResolver resolver = new(doc.RootElement, new Uri("https://example.com/api/asyncapi.json")); + + Assert.IsTrue(resolver.TryResolve("#/components/schemas/Pet", out JsonElement pet)); + Assert.IsTrue(pet.TryGetProperty("properties"u8, out _)); + } + + [TestMethod] + public void ExternalDocumentLoader_ResolvesRelativeRefAgainstNonFileBase() + { + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(EntryDoc); + byte[] externalBytes = Encoding.UTF8.GetBytes(ExternalSchemaDoc); + + using AsyncApiExternalReferenceResolver resolver = new( + doc.RootElement, + new Uri("https://example.com/api/asyncapi.json"), + uri => uri.AbsoluteUri == "https://example.com/api/common.json" ? externalBytes : null); + + Assert.IsTrue(resolver.TryResolve("./common.json#/definitions/Error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("properties"u8, out _)); + } + + [TestMethod] + public void ExternalDocumentLoader_ReturningNull_DoesNotResolve() + { + using ParsedJsonDocument doc = ParsedJsonDocument.Parse(EntryDoc); + using AsyncApiExternalReferenceResolver resolver = new( + doc.RootElement, new Uri("https://example.com/api/asyncapi.json"), _ => null); + + Assert.IsFalse(resolver.TryResolve("./missing.json#/definitions/Error", out _)); + } +} \ No newline at end of file diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/CancellationAndConcurrencyTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/CancellationAndConcurrencyTests.cs index 118674243dc..106eacaf6a2 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/CancellationAndConcurrencyTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/CancellationAndConcurrencyTests.cs @@ -24,6 +24,7 @@ public class CancellationAndConcurrencyTests [TestMethod] public async Task RequestAsync_CancellationToken_ThrowsOperationCanceledException() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); await using Testing.InMemoryMessageTransport transport = new(); JsonElement request = JsonElement.ParseValue("""{"lumens":100,"sentAt":"2024-01-01T00:00:00Z"}"""u8); @@ -37,6 +38,7 @@ public async Task RequestAsync_CancellationToken_ThrowsOperationCanceledExceptio "reply/channel"u8.ToArray(), request, "corr-cancel-test"u8.ToArray(), + workspace, cancellationToken: cts.Token).AsTask(); // Cancel before reply arrives diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/InMemoryMessageTransport.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/InMemoryMessageTransport.cs index 67472f528d9..7c83f80de87 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/InMemoryMessageTransport.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/InMemoryMessageTransport.cs @@ -70,6 +70,7 @@ public ValueTask PublishAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -94,7 +95,7 @@ public ValueTask PublishAsync( this.pendingRequests[correlationId] = tcs; } - return CompleteRequestAsync(tcs, cancellationToken); + return CompleteRequestAsync(tcs, workspace, cancellationToken); } /// @@ -220,17 +221,23 @@ public void CompleteRequest(string correlationId, byte[] replyPayloadJson, byte[ private static async ValueTask<(TReply Payload, JsonElement Headers)> CompleteRequestAsync( TaskCompletionSource<(byte[] Payload, byte[] Headers)> tcs, + JsonWorkspace workspace, CancellationToken cancellationToken) where TReply : struct, IJsonElement { (byte[] replyBytes, byte[] headerBytes) = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - TReply reply = JsonElementHelpers.ParseValue(replyBytes); + // The returned reply and headers are owned by the caller's workspace (disposed with it). + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyBytes); + workspace.TakeOwnership(replyDoc); + TReply reply = replyDoc.RootElement; JsonElement headers = default; if (headerBytes.Length > 0) { - headers = JsonElementHelpers.ParseValue(headerBytes); + ParsedJsonDocument headersDoc = ParsedJsonDocument.Parse(headerBytes); + workspace.TakeOwnership(headersDoc); + headers = headersDoc.RootElement; } return (reply, headers); diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/InMemoryMessageTransportTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/InMemoryMessageTransportTests.cs index db814e707ec..9cc3107421e 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/InMemoryMessageTransportTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/InMemoryMessageTransportTests.cs @@ -97,6 +97,7 @@ await transport.SubscribeAsync( [TestMethod] public async Task RequestAsync_CompletesWhenReplyDelivered() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); await using Testing.InMemoryMessageTransport transport = new(); JsonElement request = JsonElement.ParseValue("""{"question":"ping"}"""u8); @@ -107,7 +108,7 @@ public async Task RequestAsync_CompletesWhenReplyDelivered() "request/channel"u8.ToArray(), "reply/channel"u8.ToArray(), request, - "corr-123"u8.ToArray()).AsTask(); + "corr-123"u8.ToArray(), workspace).AsTask(); // Deliver a reply as byte[] byte[] replyBytes = Encoding.UTF8.GetBytes("""{"answer":"pong"}"""); @@ -117,6 +118,123 @@ public async Task RequestAsync_CompletesWhenReplyDelivered() Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); } + [TestMethod] + public async Task SubscribeReplyAsync_RespondsToRequestInProcess() + { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + await using Testing.InMemoryMessageTransport transport = new(); + + // A responder echoes the request's value back, doubled, as the reply. + await transport.SubscribeReplyAsync( + "rpc/double"u8.ToArray(), + (request, _, _) => + { + int n = request.GetProperty("n"u8).GetInt32(); + JsonElement reply = JsonElement.ParseValue(Encoding.UTF8.GetBytes($$"""{"result":{{n * 2}}}""")); + return ValueTask.FromResult(reply); + }); + + JsonElement request = JsonElement.ParseValue("""{"n":21}"""u8); + (JsonElement reply, JsonElement _) = await transport.RequestAsync( + "rpc/double"u8.ToArray(), + "rpc/double/replies"u8.ToArray(), + request, + "corr-rr"u8.ToArray(), workspace); + + Assert.AreEqual(42, reply.GetProperty("result"u8).GetInt32()); + + // The request was still recorded as a published message. + Assert.AreEqual(1, transport.PublishedMessages.Count); + Assert.AreEqual("rpc/double", transport.PublishedMessages[0].Channel); + } + + [TestMethod] + public async Task ReceiveOneAndReplyAsync_RepliesToOneRequestThenUnsubscribes() + { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + await using Testing.InMemoryMessageTransport transport = new(); + + // The one-shot responder wrapper handles exactly one request, replies, and unsubscribes. + ValueTask responder = transport.ReceiveOneAndReplyAsync( + "rpc/once"u8.ToArray(), + (request, _) => + { + int n = request.GetProperty("n"u8).GetInt32(); + JsonElement reply = JsonElement.ParseValue(Encoding.UTF8.GetBytes($$"""{"result":{{n * 2}}}""")); + return new ValueTask(reply); + }); + + JsonElement request = JsonElement.ParseValue("""{"n":21}"""u8); + (JsonElement reply, JsonElement _) = await transport.RequestAsync( + "rpc/once"u8.ToArray(), + "rpc/once/replies"u8.ToArray(), + request, + "corr-once"u8.ToArray(), workspace); + + await responder; + Assert.AreEqual(42, reply.GetProperty("result"u8).GetInt32()); + + // After the one-shot responder unsubscribed, a further request parks for CompleteRequest (it is no + // longer routed to the now-removed responder). + JsonElement second = JsonElement.ParseValue("""{"n":5}"""u8); + Task<(JsonElement Payload, JsonElement Headers)> parked = + transport.RequestAsync( + "rpc/once"u8.ToArray(), + "rpc/once/replies"u8.ToArray(), + second, + "corr-once-2"u8.ToArray(), workspace).AsTask(); + Assert.IsFalse(parked.IsCompleted); + transport.CompleteRequest("corr-once-2", Encoding.UTF8.GetBytes("""{"result":99}""")); + (JsonElement parkedReply, JsonElement _) = await parked; + Assert.AreEqual(99, parkedReply.GetProperty("result"u8).GetInt32()); + } + + [TestMethod] + public async Task ReceiveOneAndReplyAsync_RethrowsHandlerFailure() + { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + await using Testing.InMemoryMessageTransport transport = new(); + + ValueTask responder = transport.ReceiveOneAndReplyAsync( + "rpc/boom"u8.ToArray(), + (_, _) => throw new InvalidOperationException("handler failed")); + + // The handler failure propagates (no bogus default reply is produced): in-process the requester + // observes it directly... + JsonElement request = JsonElement.ParseValue("""{"n":1}"""u8); + InvalidOperationException requesterEx = await Assert.ThrowsExactlyAsync(async () => + await transport.RequestAsync( + "rpc/boom"u8.ToArray(), + "rpc/boom/replies"u8.ToArray(), + request, + "corr-boom"u8.ToArray(), workspace)); + Assert.AreEqual("handler failed", requesterEx.Message); + + // ...and the captured failure is re-thrown to the awaiting responder. + InvalidOperationException ex = await Assert.ThrowsExactlyAsync(async () => await responder); + Assert.AreEqual("handler failed", ex.Message); + } + + [TestMethod] + public async Task RequestAsync_WithoutResponder_StillParksForCompleteRequest() + { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + await using Testing.InMemoryMessageTransport transport = new(); + + JsonElement request = JsonElement.ParseValue("""{"n":1}"""u8); + Task<(JsonElement Payload, JsonElement Headers)> requestTask = + transport.RequestAsync( + "rpc/none"u8.ToArray(), + "rpc/none/replies"u8.ToArray(), + request, + "corr-none"u8.ToArray(), workspace).AsTask(); + + transport.CompleteRequest("corr-none", Encoding.UTF8.GetBytes("""{"result":7}""")); + + (JsonElement reply, JsonElement _) = await requestTask; + Assert.AreEqual(7, reply.GetProperty("result"u8).GetInt32()); + } + [TestMethod] public async Task Reset_ClearsAllState() { @@ -185,6 +303,7 @@ await transport.SubscribeAsync( [TestMethod] public async Task CompleteRequest_WithHeaders_ReturnsHeaders() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); await using Testing.InMemoryMessageTransport transport = new(); JsonElement request = JsonElement.ParseValue("""{"req":true}"""u8); @@ -194,7 +313,7 @@ public async Task CompleteRequest_WithHeaders_ReturnsHeaders() "req/ch"u8.ToArray(), "rep/ch"u8.ToArray(), request, - "corr-headers"u8.ToArray()).AsTask(); + "corr-headers"u8.ToArray(), workspace).AsTask(); byte[] replyBytes = Encoding.UTF8.GetBytes("""{"ok":true}"""); byte[] headerBytes = Encoding.UTF8.GetBytes("""{"status":"200"}"""); diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MessageContextTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MessageContextTests.cs index 3360f6830f5..26e9423c610 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MessageContextTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MessageContextTests.cs @@ -101,6 +101,7 @@ public async Task SubscribeAsync_WithContext_DelegatesToSimpleOverload() [TestMethod] public async Task RequestAsync_WithContext_DelegatesToSimpleOverload() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); await using InMemoryMessageTransport transport = new(); JsonElement request = JsonElement.ParseValue("""{"q":1}"""u8); @@ -112,7 +113,7 @@ public async Task RequestAsync_WithContext_DelegatesToSimpleOverload() // Use the default interface method overload that accepts MessageContext Task<(JsonElement Payload, JsonElement Headers)> requestTask = ((IMessageTransport)transport).RequestAsync( - "req"u8.ToArray(), "rep"u8.ToArray(), request, "c-1"u8.ToArray(), in context).AsTask(); + "req"u8.ToArray(), "rep"u8.ToArray(), request, "c-1"u8.ToArray(), in context, workspace).AsTask(); byte[] replyBytes = Encoding.UTF8.GetBytes("""{"a":2}"""); transport.CompleteRequest("c-1", replyBytes); diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/TypedPayloadAccessTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/TypedPayloadAccessTests.cs index b2221edf5dd..023047ee50b 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/TypedPayloadAccessTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/TypedPayloadAccessTests.cs @@ -157,6 +157,7 @@ await transport.DeliverAsync( [TestMethod] public async Task RequestReply_TypedAccess_OnReplyPayload() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); await using InMemoryMessageTransport transport = new(); LightMeasuredPayload request = LightMeasuredPayload.ParseValue( @@ -167,7 +168,7 @@ public async Task RequestReply_TypedAccess_OnReplyPayload() "request/ch"u8.ToArray(), "reply/ch"u8.ToArray(), request, - "corr-typed-access"u8.ToArray()).AsTask(); + "corr-typed-access"u8.ToArray(), workspace).AsTask(); transport.CompleteRequest( "corr-typed-access", @@ -182,6 +183,7 @@ public async Task RequestReply_TypedAccess_OnReplyPayload() [TestMethod] public async Task RequestReply_WithHeaders_ReturnsTypedReplyAndHeaders() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); await using InMemoryMessageTransport transport = new(); LightMeasuredPayload request = LightMeasuredPayload.ParseValue( @@ -192,7 +194,7 @@ public async Task RequestReply_WithHeaders_ReturnsTypedReplyAndHeaders() "req/ch"u8.ToArray(), "rep/ch"u8.ToArray(), request, - "corr-with-headers"u8.ToArray()).AsTask(); + "corr-with-headers"u8.ToArray(), workspace).AsTask(); transport.CompleteRequest( "corr-with-headers", diff --git a/tests/Corvus.Text.Json.AsyncApi.Telemetry.Tests/InstrumentedMessageTransportTests.cs b/tests/Corvus.Text.Json.AsyncApi.Telemetry.Tests/InstrumentedMessageTransportTests.cs index 860e8f8e987..13a5a74d5b2 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Telemetry.Tests/InstrumentedMessageTransportTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Telemetry.Tests/InstrumentedMessageTransportTests.cs @@ -152,6 +152,7 @@ await transport.SubscribeAsync( [TestMethod] public async Task RequestAsync_CreatesActivityWithConversationId() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); List activities = []; using ActivityListener listener = CreateActivityListener(activities); @@ -167,7 +168,7 @@ public async Task RequestAsync_CreatesActivityWithConversationId() try { await transport.RequestAsync( - TestChannel, replyChannel, request, correlationId, noHeaders, cts.Token); + TestChannel, replyChannel, request, correlationId, workspace, noHeaders, cts.Token); } catch (OperationCanceledException) { @@ -302,6 +303,7 @@ public ValueTask PublishAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement @@ -362,6 +364,7 @@ public ValueTask PublishAsync( ReadOnlyMemory replyChannelUtf8, TRequest request, ReadOnlyMemory correlationIdUtf8, + JsonWorkspace workspace, JsonElement headers = default, CancellationToken cancellationToken = default) where TRequest : struct, IJsonElement diff --git a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/AmqpTransportTests.cs b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/AmqpTransportTests.cs index b5fe512e77d..ea63f5767ea 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/AmqpTransportTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/AmqpTransportTests.cs @@ -719,6 +719,8 @@ await transport.SubscribeAsync( [TestMethod] public async Task RequestReplyTimeoutThrows() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + AmqpMessageTransport transport = await AmqpMessageTransport.CreateAsync(new AmqpTransportOptions { ConnectionUri = AmqpFixture.ConnectionUri, @@ -740,6 +742,7 @@ await transport.RequestAsync( replyChannel, requestDoc.RootElement, correlationId, + workspace, cancellationToken: cts.Token)); await transport.DisposeAsync(); @@ -748,6 +751,8 @@ await transport.RequestAsync( [TestMethod] public async Task OperationsAfterDisposeThrowObjectDisposedException() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + AmqpMessageTransport transport = await AmqpMessageTransport.CreateAsync(new AmqpTransportOptions { ConnectionUri = AmqpFixture.ConnectionUri, @@ -773,12 +778,15 @@ await transport.RequestAsync( channel, channel, doc.RootElement, - "corr"u8.ToArray())); + "corr"u8.ToArray(), + workspace)); } [TestMethod] public async Task RequestReplyRoundtripWithResponder() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + // Arrange — create a fresh transport for request/reply AmqpMessageTransport requesterTransport = await AmqpMessageTransport.CreateAsync(new AmqpTransportOptions { @@ -853,7 +861,8 @@ await responderChannel.BasicConsumeAsync( requestChannel, replyChannel, requestDoc.RootElement, - correlationId); + correlationId, + workspace); // Assert Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); @@ -862,9 +871,145 @@ await responderChannel.BasicConsumeAsync( await requesterTransport.DisposeAsync(); } + [TestMethod] + public async Task RequestReplyResponderRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — model two services on the same broker exchange: a responder service and a + // separate requester service. The reply routes back via the request's ReplyTo/CorrelationId. + static AmqpTransportOptions Options() => new() + { + ConnectionUri = AmqpFixture.ConnectionUri, + ExchangeName = "corvus.test.responder", + ExchangeType = "topic", + ExchangeDurable = false, + ConsumerTagPrefix = "corvus-responder", + }; + + AmqpMessageTransport responder = await AmqpMessageTransport.CreateAsync(Options()); + AmqpMessageTransport requester = await AmqpMessageTransport.CreateAsync(Options()); + + try + { + ReadOnlyMemory requestChannel = "amqp.test.responder.request"u8.ToArray(); + ReadOnlyMemory replyChannel = "amqp.test.responder.reply"u8.ToArray(); + byte[] correlationId = "amqp-responder-001"u8.ToArray(); + + // The responder service: doubles the request's "value" field into the reply. + await responder.SubscribeReplyAsync( + requestChannel, + (request, headers, ct) => + { + int value = request.GetProperty("value"u8).GetInt32(); + + // The reply document must outlive the handler (the transport serialises the returned element + // afterward), so hand it to the test's workspace, which disposes it after the round-trip. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse( + Encoding.UTF8.GetBytes($$"""{"doubled":{{value * 2}}}""")); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }); + + await Task.Delay(500); + + // Act — the requester service sends a request. + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":21}"""u8.ToArray()); + (JsonElement replyPayload, _) = await requester.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert — the responder doubled the value. + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("doubled"u8).GetInt32()); + + await responder.UnsubscribeAsync(requestChannel); + } + finally + { + await responder.DisposeAsync(); + await requester.DisposeAsync(); + } + } + + [TestMethod] + public async Task ReceiveOneAndReplyRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — same two-transport setup as RequestReplyResponderRoundTrip, but drives the + // one-shot primitive ReceiveOneAndReplyAsync that generated Arazzo responder steps call. + static AmqpTransportOptions Options() => new() + { + ConnectionUri = AmqpFixture.ConnectionUri, + ExchangeName = "corvus.test.responder", + ExchangeType = "topic", + ExchangeDurable = false, + ConsumerTagPrefix = "corvus-responder", + }; + + AmqpMessageTransport responder = await AmqpMessageTransport.CreateAsync(Options()); + AmqpMessageTransport requester = await AmqpMessageTransport.CreateAsync(Options()); + + try + { + ReadOnlyMemory requestChannel = "amqp.test.responder.request.once"u8.ToArray(); + ReadOnlyMemory replyChannel = "amqp.test.responder.reply.once"u8.ToArray(); + byte[] correlationId = "amqp-responder-once-001"u8.ToArray(); + + // The responder service: start as a background task — it will handle one request + // then unsubscribe itself. + System.Threading.Tasks.Task responderTask = responder.ReceiveOneAndReplyAsync( + requestChannel, + (request, headers) => + { + int value = request.GetProperty("value"u8).GetInt32(); + + // The reply document must outlive the handler (the transport serialises the returned element + // afterward), so hand it to the test's workspace, which disposes it after the round-trip. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse( + Encoding.UTF8.GetBytes($$"""{"doubled":{{value * 2}}}""")); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }).AsTask(); + + await Task.Delay(500); + + // Act — the requester service sends a request. + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":21}"""u8.ToArray()); + (JsonElement replyPayload, _) = await requester.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert — the responder doubled the value. + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("doubled"u8).GetInt32()); + + // Await the one-shot wrapper to confirm it completed cleanly. + await responderTask; + } + finally + { + await responder.DisposeAsync(); + await requester.DisposeAsync(); + } + } + [TestMethod] public async Task RequestReplyRoundtripWithHeadersForwardsHeaders() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + // Arrange — verify that headers are included in request messages AmqpMessageTransport requesterTransport = await AmqpMessageTransport.CreateAsync(new AmqpTransportOptions { @@ -947,6 +1092,7 @@ await responderChannel.BasicConsumeAsync( replyChannel, requestDoc.RootElement, correlationId, + workspace, headersDoc.RootElement); // Assert — reply received and headers were in the request diff --git a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/AzureServiceBusTransportTests.cs b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/AzureServiceBusTransportTests.cs index ab3ba79808a..428f91d615f 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/AzureServiceBusTransportTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/AzureServiceBusTransportTests.cs @@ -72,6 +72,142 @@ await s_transport.SubscribeAsync( await s_transport.UnsubscribeAsync(channel); } + [TestMethod] + public async Task RequestReplyResponderRoundTrip() + { + // The reply the handler builds must outlive the handler (the transport serialises it after the handler + // returns) yet still be cleaned up deterministically, so the handler hands its document to this workspace, + // which is disposed once the round-trip is done - mirroring how a generated responder owns its reply. + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — a responder transport hosts the SubscribeReplyAsync handler on the request queue, + // and a separate requester transport drives RequestAsync. The reply queue is session-enabled + // so the requester's session receiver (keyed on the correlation ID) correlates the reply. + AzureServiceBusMessageTransport responderTransport = await AzureServiceBusMessageTransport.CreateAsync(new AzureServiceBusTransportOptions + { + ConnectionString = AzureServiceBusFixture.ConnectionString, + QueueName = "test-queue", + }); + + AzureServiceBusMessageTransport requesterTransport = await AzureServiceBusMessageTransport.CreateAsync(new AzureServiceBusTransportOptions + { + ConnectionString = AzureServiceBusFixture.ConnectionString, + QueueName = "test-queue", + }); + + try + { + ReadOnlyMemory requestChannel = "test-queue"u8.ToArray(); + ReadOnlyMemory replyChannel = "test-reply-queue"u8.ToArray(); + + // Register a responder that computes a reply from the request. + await responderTransport.SubscribeReplyAsync( + requestChannel, + (request, headers, ct) => + { + int value = request.GetProperty("value"u8).GetInt32(); + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse( + Encoding.UTF8.GetBytes($$"""{"result":{{value * 2}}}""")); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }); + + // Allow the processor to start. + await Task.Delay(500); + + // Act — send a request and await the correlated reply. + byte[] correlationId = "asb-responder-roundtrip-001"u8.ToArray(); + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":21}"""u8.ToArray()); + + (JsonElement replyPayload, JsonElement replyHeaders) = await requesterTransport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("result"u8).GetInt32()); + + await responderTransport.UnsubscribeAsync(requestChannel); + } + finally + { + await responderTransport.DisposeAsync(); + await requesterTransport.DisposeAsync(); + } + } + + [TestMethod] + public async Task ReceiveOneAndReplyRoundTrip() + { + // The reply the handler builds must outlive the handler (the transport serialises it after the handler + // returns) yet still be cleaned up deterministically, so the handler hands its document to this workspace, + // which is disposed once the round-trip is done - mirroring how a generated responder owns its reply. + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — mirrors RequestReplyResponderRoundTrip but drives the one-shot + // ReceiveOneAndReplyAsync primitive instead of the persistent SubscribeReplyAsync. + AzureServiceBusMessageTransport responderTransport = await AzureServiceBusMessageTransport.CreateAsync(new AzureServiceBusTransportOptions + { + ConnectionString = AzureServiceBusFixture.ConnectionString, + QueueName = "test-queue", + }); + + AzureServiceBusMessageTransport requesterTransport = await AzureServiceBusMessageTransport.CreateAsync(new AzureServiceBusTransportOptions + { + ConnectionString = AzureServiceBusFixture.ConnectionString, + QueueName = "test-queue", + }); + + try + { + ReadOnlyMemory requestChannel = "test-queue"u8.ToArray(); + ReadOnlyMemory replyChannel = "test-reply-queue"u8.ToArray(); + + // Start the one-shot responder as a background task. It will handle exactly one + // request, send the reply, and then complete — no explicit unsubscribe needed. + System.Threading.Tasks.Task responderTask = responderTransport.ReceiveOneAndReplyAsync( + requestChannel, + (request, headers) => + { + int value = request.GetProperty("value"u8).GetInt32(); + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse( + Encoding.UTF8.GetBytes($$"""{"result":{{value * 2}}}""")); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }).AsTask(); + + // Allow the processor to start. + await Task.Delay(500); + + // Act — send a request and await the correlated reply. + byte[] correlationId = "asb-responder-roundtrip-001-once"u8.ToArray(); + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":21}"""u8.ToArray()); + + (JsonElement replyPayload, JsonElement replyHeaders) = await requesterTransport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("result"u8).GetInt32()); + + // The one-shot responder unsubscribes itself after handling a single request; + // await its completion rather than calling UnsubscribeAsync. + await responderTask; + } + finally + { + await responderTransport.DisposeAsync(); + await requesterTransport.DisposeAsync(); + } + } + [TestMethod] public async Task DoubleDisposeDoesNotThrow() { diff --git a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/KafkaTransportTests.cs b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/KafkaTransportTests.cs index e4f346cc0a7..d9e146e6889 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/KafkaTransportTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/KafkaTransportTests.cs @@ -546,6 +546,8 @@ await transport.SubscribeAsync( [TestMethod] public async Task RequestReplyTimeoutThrows() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + string topicSuffix = Guid.NewGuid().ToString("N")[..8]; KafkaMessageTransport transport = new(new KafkaTransportOptions { @@ -572,6 +574,7 @@ await transport.RequestAsync( replyChannel, requestDoc.RootElement, correlationId, + workspace, cancellationToken: cts.Token)); await transport.DisposeAsync(); @@ -580,6 +583,8 @@ await transport.RequestAsync( [TestMethod] public async Task OperationsAfterDisposeThrowObjectDisposedException() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + string topicSuffix = Guid.NewGuid().ToString("N")[..8]; KafkaMessageTransport transport = new(new KafkaTransportOptions { @@ -601,7 +606,7 @@ await Assert.ThrowsExactlyAsync(async () => await Assert.ThrowsExactlyAsync(async () => await transport.RequestAsync( - channel, channel, doc.RootElement, "corr"u8.ToArray())); + channel, channel, doc.RootElement, "corr"u8.ToArray(), workspace)); } [TestMethod] @@ -653,6 +658,8 @@ public async Task DoubleDisposeIsIdempotent() [TestMethod] public async Task RequestReplyRoundtrip() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + string topicSuffix = Guid.NewGuid().ToString("N")[..8]; string requestTopic = $"kafka-reqrep-req-{topicSuffix}"; string replyTopic = $"kafka-reqrep-rep-{topicSuffix}"; @@ -767,6 +774,7 @@ await responderTransport.SubscribeAsync( replyChannel, requestDoc.RootElement, correlationId, + workspace, requestHeaders.RootElement, requestCts.Token); @@ -780,6 +788,152 @@ await responderTransport.SubscribeAsync( await clientTransport.DisposeAsync(); } + [TestMethod] + public async Task RequestReplyResponderRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + string topicSuffix = Guid.NewGuid().ToString("N")[..8]; + string requestTopic = $"kafka-resp-req-{topicSuffix}"; + string replyTopic = $"kafka-resp-rep-{topicSuffix}"; + await KafkaFixture.CreateTopicAsync(requestTopic); + await KafkaFixture.CreateTopicAsync(replyTopic); + + ReadOnlyMemory requestChannel = Encoding.UTF8.GetBytes(requestTopic); + ReadOnlyMemory replyChannel = Encoding.UTF8.GetBytes(replyTopic); + + // Set up a Corvus responder via SubscribeReplyAsync: it reads each request, + // computes a reply, and the transport routes it back correlated to the request. + KafkaMessageTransport responderTransport = new(new KafkaTransportOptions + { + BootstrapServers = KafkaFixture.BootstrapServers, + GroupId = "corvus-responder-reply-group-" + topicSuffix, + AutoOffsetReset = AutoOffsetReset.Earliest, + ConsumerConfig = new ConsumerConfig { TopicMetadataRefreshIntervalMs = 1000 }, + }); + + await responderTransport.SubscribeReplyAsync( + requestChannel, + (request, headers, ct) => + { + // Compute a reply from the request: double the supplied number. + int n = request.GetProperty("n"u8).GetInt32(); + byte[] replyJson = Encoding.UTF8.GetBytes($$"""{"doubled":{{n * 2}}}"""); + + // The reply document is handed to the test's workspace so it outlives the handler (the transport + // serialises the returned element afterward) and is disposed with the workspace. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyJson); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }); + + // Give the responder time to start (group coordination + partition assignment). + await Task.Delay(5000); + + // Create a client transport that issues the request via RequestAsync. + KafkaMessageTransport clientTransport = new(new KafkaTransportOptions + { + BootstrapServers = KafkaFixture.BootstrapServers, + GroupId = "corvus-responder-client-" + topicSuffix, + AutoOffsetReset = AutoOffsetReset.Earliest, + ConsumerConfig = new ConsumerConfig { TopicMetadataRefreshIntervalMs = 1000 }, + }); + + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"n":21}"""u8.ToArray()); + byte[] correlationId = Guid.NewGuid().ToString("D").Substring(0, 36).Select(c => (byte)c).ToArray(); + + using CancellationTokenSource requestCts = new(TimeSpan.FromSeconds(30)); + (JsonElement replyPayloadElement, JsonElement replyHeaders) = await clientTransport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace, + cancellationToken: requestCts.Token); + + Assert.AreEqual(JsonValueKind.Object, replyPayloadElement.ValueKind); + Assert.AreEqual(42, replyPayloadElement.GetProperty("doubled"u8).GetInt32()); + + await responderTransport.UnsubscribeAsync(requestChannel); + await responderTransport.DisposeAsync(); + await clientTransport.DisposeAsync(); + } + + [TestMethod] + public async Task ReceiveOneAndReplyRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + string topicSuffix = Guid.NewGuid().ToString("N")[..8]; + string requestTopic = $"kafka-resp-req-{topicSuffix}-once"; + string replyTopic = $"kafka-resp-rep-{topicSuffix}-once"; + await KafkaFixture.CreateTopicAsync(requestTopic); + await KafkaFixture.CreateTopicAsync(replyTopic); + + ReadOnlyMemory requestChannel = Encoding.UTF8.GetBytes(requestTopic); + ReadOnlyMemory replyChannel = Encoding.UTF8.GetBytes(replyTopic); + + // Set up a Corvus responder via ReceiveOneAndReplyAsync: it reads exactly one request, + // computes a reply, unsubscribes itself, and completes. + KafkaMessageTransport responderTransport = new(new KafkaTransportOptions + { + BootstrapServers = KafkaFixture.BootstrapServers, + GroupId = "corvus-responder-reply-group-" + topicSuffix + "-once", + AutoOffsetReset = AutoOffsetReset.Earliest, + ConsumerConfig = new ConsumerConfig { TopicMetadataRefreshIntervalMs = 1000 }, + }); + + System.Threading.Tasks.Task responderTask = responderTransport.ReceiveOneAndReplyAsync( + requestChannel, + (request, headers) => + { + // Compute a reply from the request: double the supplied number. + int n = request.GetProperty("n"u8).GetInt32(); + byte[] replyJson = Encoding.UTF8.GetBytes($$"""{"doubled":{{n * 2}}}"""); + + // The reply document is handed to the test's workspace so it outlives the handler (the transport + // serialises the returned element afterward) and is disposed with the workspace. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyJson); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }).AsTask(); + + // Give the responder time to start (group coordination + partition assignment). + await Task.Delay(5000); + + // Create a client transport that issues the request via RequestAsync. + KafkaMessageTransport clientTransport = new(new KafkaTransportOptions + { + BootstrapServers = KafkaFixture.BootstrapServers, + GroupId = "corvus-responder-client-" + topicSuffix + "-once", + AutoOffsetReset = AutoOffsetReset.Earliest, + ConsumerConfig = new ConsumerConfig { TopicMetadataRefreshIntervalMs = 1000 }, + }); + + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"n":21}"""u8.ToArray()); + byte[] correlationId = Guid.NewGuid().ToString("D").Substring(0, 36).Select(c => (byte)c).ToArray(); + + using CancellationTokenSource requestCts = new(TimeSpan.FromSeconds(30)); + (JsonElement replyPayloadElement, JsonElement replyHeaders) = await clientTransport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace, + cancellationToken: requestCts.Token); + + Assert.AreEqual(JsonValueKind.Object, replyPayloadElement.ValueKind); + Assert.AreEqual(42, replyPayloadElement.GetProperty("doubled"u8).GetInt32()); + + await responderTask; + await responderTransport.DisposeAsync(); + await clientTransport.DisposeAsync(); + } + [TestMethod] public async Task ExplicitDeadLetterPublishesMessage() { diff --git a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/MqttTransportTests.cs b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/MqttTransportTests.cs index e96a1c7e4b7..f874a81669a 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/MqttTransportTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/MqttTransportTests.cs @@ -680,6 +680,8 @@ await transport.SubscribeAsync( [TestMethod] public async Task RequestReplyRoundtripWithResponder() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + // Arrange — create a requester transport MqttMessageTransport requesterTransport = await MqttMessageTransport.CreateAsync(new MqttTransportOptions { @@ -732,7 +734,8 @@ await responderClient.SubscribeAsync( requestChannel, replyChannel, requestDoc.RootElement, - correlationId); + correlationId, + workspace); // Assert Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); @@ -743,9 +746,139 @@ await responderClient.SubscribeAsync( responderClient.Dispose(); } + [TestMethod] + public async Task RequestReplyResponderRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — a Corvus responder transport answers requests via SubscribeReplyAsync, + // and a separate Corvus requester transport issues the request via RequestAsync. + MqttMessageTransport responderTransport = await MqttMessageTransport.CreateAsync(new MqttTransportOptions + { + Host = MqttFixture.Host, + Port = MqttFixture.Port, + ClientId = "corvus-responder-typed-" + Guid.NewGuid().ToString("N")[..8], + }); + + MqttMessageTransport requesterTransport = await MqttMessageTransport.CreateAsync(new MqttTransportOptions + { + Host = MqttFixture.Host, + Port = MqttFixture.Port, + ClientId = "corvus-requester-typed-" + Guid.NewGuid().ToString("N")[..8], + }); + + ReadOnlyMemory requestChannel = "mqtt/test/reqreply-typed/request"u8.ToArray(); + ReadOnlyMemory replyChannel = "mqtt/test/reqreply-typed/reply"u8.ToArray(); + + // Register the responder: it reads the request's "value" and returns value * 2. + await responderTransport.SubscribeReplyAsync( + requestChannel, + (request, headers, ct) => + { + int input = request.GetProperty("value"u8).GetInt32(); + byte[] replyJson = Encoding.UTF8.GetBytes($$"""{"result":{{input * 2}}}"""); + + // The reply document is handed to the test's workspace so it outlives the handler (the transport + // serialises the returned element afterward) and is disposed with the workspace. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyJson); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }); + + await Task.Delay(500); + + // Act — send a request through the requester transport. + byte[] correlationId = "mqtt-typed-roundtrip-1"u8.ToArray(); + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":21}"""u8.ToArray()); + + (JsonElement replyPayload, JsonElement replyHeaders) = await requesterTransport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert — the responder computed 21 * 2 = 42. + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("result"u8).GetInt32()); + + await requesterTransport.DisposeAsync(); + await responderTransport.UnsubscribeAsync(requestChannel); + await responderTransport.DisposeAsync(); + } + + [TestMethod] + public async Task ReceiveOneAndReplyRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — a Corvus responder transport answers a single request via + // ReceiveOneAndReplyAsync (the primitive used by generated Arazzo responder steps), + // and a separate Corvus requester transport issues the request via RequestAsync. + MqttMessageTransport responderTransport = await MqttMessageTransport.CreateAsync(new MqttTransportOptions + { + Host = MqttFixture.Host, + Port = MqttFixture.Port, + ClientId = "corvus-responder-typed-" + Guid.NewGuid().ToString("N")[..8], + }); + + MqttMessageTransport requesterTransport = await MqttMessageTransport.CreateAsync(new MqttTransportOptions + { + Host = MqttFixture.Host, + Port = MqttFixture.Port, + ClientId = "corvus-requester-typed-" + Guid.NewGuid().ToString("N")[..8], + }); + + ReadOnlyMemory requestChannel = "mqtt/test/reqreply-typed/request/once"u8.ToArray(); + ReadOnlyMemory replyChannel = "mqtt/test/reqreply-typed/reply/once"u8.ToArray(); + + // Start the one-shot responder as a background task before the requester sends. + // The handler reads the request's "value" and returns value * 2. + System.Threading.Tasks.Task responderTask = responderTransport.ReceiveOneAndReplyAsync( + requestChannel, + (request, headers) => + { + int input = request.GetProperty("value"u8).GetInt32(); + byte[] replyJson = Encoding.UTF8.GetBytes($$"""{"result":{{input * 2}}}"""); + + // The reply document is handed to the test's workspace so it outlives the handler (the transport + // serialises the returned element afterward) and is disposed with the workspace. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyJson); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }).AsTask(); + + await Task.Delay(500); + + // Act — send a request through the requester transport. + byte[] correlationId = "mqtt-typed-roundtrip-once-1"u8.ToArray(); + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":21}"""u8.ToArray()); + + (JsonElement replyPayload, JsonElement replyHeaders) = await requesterTransport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert — the responder computed 21 * 2 = 42. + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("result"u8).GetInt32()); + + await requesterTransport.DisposeAsync(); + await responderTask; + await responderTransport.DisposeAsync(); + } + [TestMethod] public async Task RequestReplyTimeoutThrows() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + MqttMessageTransport transport = await MqttMessageTransport.CreateAsync(new MqttTransportOptions { Host = MqttFixture.Host, @@ -766,6 +899,7 @@ await transport.RequestAsync( replyChannel, requestDoc.RootElement, correlationId, + workspace, cancellationToken: cts.Token)); await transport.DisposeAsync(); @@ -774,6 +908,8 @@ await transport.RequestAsync( [TestMethod] public async Task OperationsAfterDisposeThrowObjectDisposedException() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + MqttMessageTransport transport = await MqttMessageTransport.CreateAsync(new MqttTransportOptions { Host = MqttFixture.Host, @@ -797,7 +933,8 @@ await transport.RequestAsync( channel, channel, doc.RootElement, - "corr"u8.ToArray())); + "corr"u8.ToArray(), + workspace)); } [TestMethod] diff --git a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/NatsTransportTests.cs b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/NatsTransportTests.cs index f44ed467d76..504080b2a47 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/NatsTransportTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/NatsTransportTests.cs @@ -213,6 +213,8 @@ await s_transport.SubscribeAsync( [TestMethod] public async Task RequestReplyWithCorrelationId() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + // Arrange — set up a responder on the request channel ReadOnlyMemory requestChannel = "test.request"u8.ToArray(); ReadOnlyMemory replyChannel = "test.reply"u8.ToArray(); @@ -243,6 +245,7 @@ await s_transport.SubscribeAsync( replyChannel, requestDoc.RootElement, correlationId, + workspace, cancellationToken: cts.Token); // If we get here, a reply was received (the subscription handler or NATS handled it) @@ -257,6 +260,113 @@ await s_transport.SubscribeAsync( await s_transport.UnsubscribeAsync(requestChannel); } + [TestMethod] + public async Task RequestReplyResponderRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — model two services: a responder service (its own connection) registers a + // SubscribeReplyAsync that doubles the supplied number, and a separate requester service + // (s_transport) calls RequestAsync. The two round-trip through the broker's native request/reply. + ReadOnlyMemory requestChannel = "test.responder-roundtrip"u8.ToArray(); + ReadOnlyMemory replyChannel = "test.responder-roundtrip-reply"u8.ToArray(); + + await using NatsMessageTransport responder = await NatsMessageTransport.CreateAsync(new NatsTransportOptions + { + Url = NatsFixture.ConnectionString, + }); + + await responder.SubscribeReplyAsync( + requestChannel, + (request, headers, ct) => + { + int value = request.GetProperty("value"u8).GetInt32(); + byte[] replyJson = Encoding.UTF8.GetBytes($$"""{"doubled":{{value * 2}}}"""); + + // The reply document is handed to the test's workspace so it outlives the handler (the transport + // serialises the returned element after the handler returns) and is disposed with the workspace. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyJson); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }); + + await Task.Delay(500); + + // Act — the requester service sends a request and captures the computed reply. + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":21}"""u8.ToArray()); + byte[] correlationId = "responder-corr-001"u8.ToArray(); + + (JsonElement replyPayload, JsonElement replyHeaders) = await s_transport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert — the responder doubled the number and the requester received the exact value + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("doubled"u8).GetInt32()); + + await responder.UnsubscribeAsync(requestChannel); + } + + [TestMethod] + public async Task ReceiveOneAndReplyRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — model two services: a responder service (its own connection) registers a + // ReceiveOneAndReplyAsync that doubles the supplied number, and a separate requester + // service (s_transport) calls RequestAsync. The two round-trip through the broker's + // native request/reply. Unlike SubscribeReplyAsync the one-shot wrapper unsubscribes + // itself after handling a single request — exactly the primitive the generated Arazzo + // responder step calls. + ReadOnlyMemory requestChannel = "test.responder-roundtrip-once"u8.ToArray(); + ReadOnlyMemory replyChannel = "test.responder-roundtrip-reply-once"u8.ToArray(); + + await using NatsMessageTransport responder = await NatsMessageTransport.CreateAsync(new NatsTransportOptions + { + Url = NatsFixture.ConnectionString, + }); + + System.Threading.Tasks.Task responderTask = responder.ReceiveOneAndReplyAsync( + requestChannel, + (request, headers) => + { + int value = request.GetProperty("value"u8).GetInt32(); + byte[] replyJson = Encoding.UTF8.GetBytes($$"""{"doubled":{{value * 2}}}"""); + + // The reply document is handed to the test's workspace so it outlives the handler (the transport + // serialises the returned element after the handler returns) and is disposed with the workspace. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyJson); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }).AsTask(); + + await Task.Delay(500); + + // Act — the requester service sends a request and captures the computed reply. + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":21}"""u8.ToArray()); + byte[] correlationId = "responder-once-corr-001"u8.ToArray(); + + (JsonElement replyPayload, JsonElement replyHeaders) = await s_transport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert — the responder doubled the number and the requester received the exact value + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("doubled"u8).GetInt32()); + + await responderTask; + } + [TestMethod] public async Task MultipleSubscribersOnDifferentChannels() { @@ -790,6 +900,8 @@ await transport.SubscribeAsync( [TestMethod] public async Task RequestReplyTimeoutThrowsOperationCanceledException() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + // Arrange — no responder, should timeout NatsMessageTransport transport = await NatsMessageTransport.CreateAsync(new NatsTransportOptions { @@ -811,7 +923,8 @@ await transport.RequestAsync( requestChannel, replyChannel, requestDoc.RootElement, - correlationId); + correlationId, + workspace); Assert.Fail("Expected an exception for request with no responder."); } @@ -827,6 +940,8 @@ await transport.RequestAsync( [TestMethod] public async Task OperationsAfterDisposeThrowObjectDisposedException() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + // Arrange NatsMessageTransport transport = await NatsMessageTransport.CreateAsync(new NatsTransportOptions { @@ -847,7 +962,7 @@ await Assert.ThrowsExactlyAsync(async () => await Assert.ThrowsExactlyAsync(async () => await transport.RequestAsync( - channel, channel, doc.RootElement, "corr"u8.ToArray())); + channel, channel, doc.RootElement, "corr"u8.ToArray(), workspace)); } [TestMethod] @@ -903,6 +1018,8 @@ await transport.SubscribeAsync( [TestMethod] public async Task RequestReplyRoundtripWithResponder() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + // Arrange — transport for the requester NatsMessageTransport requesterTransport = await NatsMessageTransport.CreateAsync(new NatsTransportOptions { @@ -938,7 +1055,8 @@ public async Task RequestReplyRoundtripWithResponder() requestChannel, replyChannel, requestDoc.RootElement, - correlationId); + correlationId, + workspace); // Assert Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); @@ -951,6 +1069,8 @@ public async Task RequestReplyRoundtripWithResponder() [TestMethod] public async Task RequestReplyRoundtripWithHeaders() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + // Arrange — verify that headers are forwarded in request/reply NatsMessageTransport requesterTransport = await NatsMessageTransport.CreateAsync(new NatsTransportOptions { @@ -988,6 +1108,7 @@ public async Task RequestReplyRoundtripWithHeaders() replyChannel, requestDoc.RootElement, correlationId, + workspace, headersDoc.RootElement); // Assert — reply received and request headers were forwarded diff --git a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/WebSocketTransportTests.cs b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/WebSocketTransportTests.cs index 788561fa269..3ccceaa3c24 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/WebSocketTransportTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Transport.IntegrationTests/WebSocketTransportTests.cs @@ -668,6 +668,8 @@ await subscriber.SubscribeAsync( [TestMethod] public async Task RequestReplyRoundtripWithResponder() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + // Arrange — requester transport subscribes to the reply channel so the relay forwards replies to it WebSocketMessageTransport requesterTransport = await WebSocketMessageTransport.CreateAsync(new WebSocketTransportOptions { @@ -755,7 +757,8 @@ await responderWs.SendAsync( requestChannel, replyChannel, requestDoc.RootElement, - correlationId); + correlationId, + workspace); // Assert Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); @@ -765,9 +768,140 @@ await responderWs.SendAsync( await requesterTransport.DisposeAsync(); } + [TestMethod] + public async Task RequestReplyResponderRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — a responder transport subscribes to the request channel via SubscribeReplyAsync, + // and a requester transport subscribes to the reply channel so the relay forwards replies to it. + WebSocketMessageTransport responderTransport = await WebSocketMessageTransport.CreateAsync(new WebSocketTransportOptions + { + ServerUri = WebSocketFixture.ServerUri, + }); + + WebSocketMessageTransport requesterTransport = await WebSocketMessageTransport.CreateAsync(new WebSocketTransportOptions + { + ServerUri = WebSocketFixture.ServerUri, + }); + + ReadOnlyMemory requestChannel = "ws/test/reqreply-responder/request"u8.ToArray(); + ReadOnlyMemory replyChannel = "ws/test/reqreply-responder/reply"u8.ToArray(); + + // The requester must be subscribed to the reply channel so the relay forwards the reply to it + // (correlationId match takes priority over the dummy handler). + await requesterTransport.SubscribeAsync( + replyChannel, + (_, _, _) => ValueTask.CompletedTask); + + // Register the responder: it reads the request's "value", computes value + 1, and replies. + await responderTransport.SubscribeReplyAsync( + requestChannel, + (request, headers, ct) => + { + int input = request.GetProperty("value"u8).GetInt32(); + byte[] replyJson = Encoding.UTF8.GetBytes($$"""{"result":{{input + 1}}}"""); + + // The reply document is handed to the test's workspace so it outlives the handler (the transport + // serialises the returned element afterward) and is disposed with the workspace. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyJson); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }); + + await Task.Delay(500); + + // Act — send a request through the requester transport. + byte[] correlationId = "ws-responder-001"u8.ToArray(); + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":41}"""u8.ToArray()); + + (JsonElement replyPayload, JsonElement replyHeaders) = await requesterTransport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("result"u8).GetInt32()); + + await responderTransport.DisposeAsync(); + await requesterTransport.DisposeAsync(); + } + + [TestMethod] + public async Task ReceiveOneAndReplyRoundTrip() + { + // Owns the reply document the handler builds so it outlives the handler yet is still cleaned up + // deterministically (disposed with this workspace once the round-trip is done). + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + + // Arrange — a responder transport handles exactly one request via ReceiveOneAndReplyAsync, + // and a requester transport subscribes to the reply channel so the relay forwards replies to it. + WebSocketMessageTransport responderTransport = await WebSocketMessageTransport.CreateAsync(new WebSocketTransportOptions + { + ServerUri = WebSocketFixture.ServerUri, + }); + + WebSocketMessageTransport requesterTransport = await WebSocketMessageTransport.CreateAsync(new WebSocketTransportOptions + { + ServerUri = WebSocketFixture.ServerUri, + }); + + ReadOnlyMemory requestChannel = "ws/test/reqreply-responder/request/once"u8.ToArray(); + ReadOnlyMemory replyChannel = "ws/test/reqreply-responder/reply/once"u8.ToArray(); + + // The requester must be subscribed to the reply channel so the relay forwards the reply to it + // (correlationId match takes priority over the dummy handler). + await requesterTransport.SubscribeAsync( + replyChannel, + (_, _, _) => ValueTask.CompletedTask); + + // Start the one-shot responder: it reads the request's "value", computes value + 1, and replies. + System.Threading.Tasks.Task responderTask = responderTransport.ReceiveOneAndReplyAsync( + requestChannel, + (request, headers) => + { + int input = request.GetProperty("value"u8).GetInt32(); + byte[] replyJson = Encoding.UTF8.GetBytes($$"""{"result":{{input + 1}}}"""); + + // The reply document is handed to the test's workspace so it outlives the handler (the transport + // serialises the returned element afterward) and is disposed with the workspace. + ParsedJsonDocument replyDoc = ParsedJsonDocument.Parse(replyJson); + workspace.TakeOwnership(replyDoc); + return ValueTask.FromResult(replyDoc.RootElement); + }).AsTask(); + + await Task.Delay(500); + + // Act — send a request through the requester transport. + byte[] correlationId = "ws-responder-once-001"u8.ToArray(); + using ParsedJsonDocument requestDoc = ParsedJsonDocument.Parse("""{"value":41}"""u8.ToArray()); + + (JsonElement replyPayload, JsonElement replyHeaders) = await requesterTransport.RequestAsync( + requestChannel, + replyChannel, + requestDoc.RootElement, + correlationId, + workspace); + + // Assert + Assert.AreEqual(JsonValueKind.Object, replyPayload.ValueKind); + Assert.AreEqual(42, replyPayload.GetProperty("result"u8).GetInt32()); + + await responderTask; + await responderTransport.DisposeAsync(); + await requesterTransport.DisposeAsync(); + } + [TestMethod] public async Task RequestReplyTimeoutThrows() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + WebSocketMessageTransport transport = await WebSocketMessageTransport.CreateAsync(new WebSocketTransportOptions { ServerUri = WebSocketFixture.ServerUri, @@ -786,6 +920,7 @@ await transport.RequestAsync( replyChannel, requestDoc.RootElement, correlationId, + workspace, default, cts.Token)); @@ -795,6 +930,8 @@ await transport.RequestAsync( [TestMethod] public async Task OperationsAfterDisposeThrowObjectDisposedException() { + using JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + WebSocketMessageTransport transport = await WebSocketMessageTransport.CreateAsync(new WebSocketTransportOptions { ServerUri = WebSocketFixture.ServerUri, @@ -816,7 +953,8 @@ await transport.RequestAsync( channel, channel, doc.RootElement, - "corr"u8.ToArray())); + "corr"u8.ToArray(), + workspace)); } [TestMethod] From 45504a465ddc1e57b24913a4fef1797b714e586e Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Wed, 5 Aug 2026 06:53:43 +0100 Subject: [PATCH 06/11] Add Corvus.Text.Json.OpenApi.Polly (#803) AsyncAPI has had a Polly package since its transports did: a resilience pipeline wrapped around the transport, so a deployment configures retry and circuit-breaking without every call site knowing about it. OpenAPI had the same need and no counterpart, so a caller wanting a resilient transport wrapped IApiTransport by hand. ResilientApiTransport is that counterpart, decorating an IApiTransport with a Polly pipeline and passing every operation through unchanged otherwise. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq --- Corvus.Text.Json.slnx | 1 + .../Corvus.Text.Json.OpenApi.Polly.csproj | 26 +++++ .../ResilientApiTransport.cs | 108 ++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 src/Corvus.Text.Json.OpenApi.Polly/Corvus.Text.Json.OpenApi.Polly.csproj create mode 100644 src/Corvus.Text.Json.OpenApi.Polly/ResilientApiTransport.cs diff --git a/Corvus.Text.Json.slnx b/Corvus.Text.Json.slnx index 1ab2dda8799..c947d8a6e30 100644 --- a/Corvus.Text.Json.slnx +++ b/Corvus.Text.Json.slnx @@ -128,6 +128,7 @@ + diff --git a/src/Corvus.Text.Json.OpenApi.Polly/Corvus.Text.Json.OpenApi.Polly.csproj b/src/Corvus.Text.Json.OpenApi.Polly/Corvus.Text.Json.OpenApi.Polly.csproj new file mode 100644 index 00000000000..702f74bf87c --- /dev/null +++ b/src/Corvus.Text.Json.OpenApi.Polly/Corvus.Text.Json.OpenApi.Polly.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + true + enable + enable + preview + true + $(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904 + $(NoWarn);NU1504;CS8500;CS8969;IDE0065;IDE0290;IDE0079;IDE0130 + true + true + Apache-2.0 + Polly-based resilience for OpenAPI transports. Wraps API operations with retry, circuit-breaker, and timeout policies via Polly.Core. + + + + + + + + + + + \ No newline at end of file diff --git a/src/Corvus.Text.Json.OpenApi.Polly/ResilientApiTransport.cs b/src/Corvus.Text.Json.OpenApi.Polly/ResilientApiTransport.cs new file mode 100644 index 00000000000..119091837cc --- /dev/null +++ b/src/Corvus.Text.Json.OpenApi.Polly/ResilientApiTransport.cs @@ -0,0 +1,108 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using Corvus.Text.Json.Internal; +using global::Polly; + +namespace Corvus.Text.Json.OpenApi.Polly; + +/// +/// A decorator that wraps every operation of an in a Polly +/// (retry, circuit-breaker, timeout, rate-limiter, hedging, etc.). The +/// HTTP-client analogue of Corvus.Text.Json.AsyncApi.Polly.PollyResilienceMiddleware. +/// +/// +/// +/// The whole SendAsync call is executed through the pipeline, so a workflow step's operation gains +/// the pipeline's resilience without the executor knowing about it. This composes with — and is orthogonal +/// to — Arazzo's declarative onFailure/retry actions: the pipeline governs transport-level +/// retries/breaking, while the step actions govern workflow control flow. +/// +/// +/// Example usage: +/// +/// ResiliencePipeline pipeline = new ResiliencePipelineBuilder() +/// .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Exponential }) +/// .AddCircuitBreaker(new CircuitBreakerStrategyOptions()) +/// .Build(); +/// +/// IApiTransport transport = new ResilientApiTransport(rawTransport, pipeline); +/// +/// +/// +/// Each operation passes its state explicitly to +/// with a static callback, so no per-call closure is allocated. +/// +/// +public sealed class ResilientApiTransport : IApiTransport +{ + private readonly IApiTransport inner; + private readonly ResiliencePipeline pipeline; + + /// + /// Initializes a new instance of the class. + /// + /// The transport to decorate. + /// The Polly resilience pipeline applied around each operation. + public ResilientApiTransport(IApiTransport inner, ResiliencePipeline pipeline) + { + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(pipeline); + this.inner = inner; + this.pipeline = pipeline; + } + + /// + public ValueTask SendAsync( + in TRequest request, + CancellationToken cancellationToken = default) + where TRequest : struct, IApiRequest + where TResponse : struct, IApiResponse + => this.pipeline.ExecuteAsync( + static (state, token) => state.inner.SendAsync(in state.request, token), + (inner: this.inner, request), + cancellationToken); + + /// + public ValueTask SendAsync( + in TRequest request, + in TBody body, + CancellationToken cancellationToken = default) + where TRequest : struct, IApiRequest + where TBody : struct, IJsonElement + where TResponse : struct, IApiResponse + => this.pipeline.ExecuteAsync( + static (state, token) => state.inner.SendAsync(in state.request, in state.body, token), + (inner: this.inner, request, body), + cancellationToken); + + /// + public ValueTask SendAsync( + in TRequest request, + Stream body, + string contentType, + CancellationToken cancellationToken = default) + where TRequest : struct, IApiRequest + where TResponse : struct, IApiResponse + => this.pipeline.ExecuteAsync( + static (state, token) => state.inner.SendAsync(in state.request, state.body, state.contentType, token), + (inner: this.inner, request, body, contentType), + cancellationToken); + + /// + public ValueTask SendAsync( + in TRequest request, + Func bodyWriter, + string contentType, + CancellationToken cancellationToken = default) + where TRequest : struct, IApiRequest + where TResponse : struct, IApiResponse + => this.pipeline.ExecuteAsync( + static (state, token) => state.inner.SendAsync(in state.request, state.bodyWriter, state.contentType, token), + (inner: this.inner, request, bodyWriter, contentType), + cancellationToken); + + /// + public ValueTask DisposeAsync() => this.inner.DisposeAsync(); +} \ No newline at end of file From ddb9970ce36d5fb865cb66bb4471a410c400860c Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Wed, 5 Aug 2026 06:53:54 +0100 Subject: [PATCH 07/11] Regenerate the example recipes against the updated generators (#803) Mechanical, with one exception. The recipes carry committed generator output, so a generator change leaves them stale until they are regenerated. The exception is the advanced-server recipe, whose download handler returned the parameterless Ok() a binary response used to generate, with a comment saying the streaming was handled elsewhere and a discarded local standing in for the photo it could not send. The factory now takes the body, so the recipe sends the photo and its content type, which is what it was describing all along. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq --- .../Generated/ApiPetsClient.cs | 32 +++++++++ .../Generated/IApiPetsClient.cs | 11 ++++ .../Generated/Models/Error.Mutable.cs | 23 +++++++ .../Generated/Models/NewPet.Mutable.cs | 23 +++++++ .../Generated/Models/Pet.Mutable.cs | 23 +++++++ .../Generated/Models/Pets.Mutable.cs | 23 +++++++ .../Generated/corvusjson-openapi.lock | 4 +- .../Generated/CreatePetResult.cs | 25 +++++++ .../Generated/ListPetsResult.cs | 26 ++++++++ .../Generated/Models/Error.Mutable.cs | 23 +++++++ .../Generated/Models/NewPet.Mutable.cs | 23 +++++++ .../Generated/Models/Pet.Mutable.cs | 23 +++++++ .../Generated/Models/Pets.Mutable.cs | 23 +++++++ .../Generated/ShowPetByIdResult.cs | 25 +++++++ .../Generated/ApiAdoptionClient.cs | 32 +++++++++ .../Generated/ApiChatClient.cs | 36 ++++++++++ .../Generated/ApiPetsClient.cs | 34 ++++++++++ .../Generated/ApiPhotosClient.cs | 42 ++++++++++++ .../Generated/IApiAdoptionClient.cs | 11 ++++ .../Generated/IApiChatClient.cs | 13 ++++ .../Generated/IApiPetsClient.cs | 12 ++++ .../Generated/IApiPhotosClient.cs | 14 ++++ .../Generated/Models/ActivityEvent.Mutable.cs | 23 +++++++ .../Generated/Models/ChatChunk.Mutable.cs | 23 +++++++ .../Generated/Models/Error.Mutable.cs | 23 +++++++ .../Models/GetPetsBatchByIdsIds.Mutable.cs | 23 +++++++ .../Generated/Models/GetPetsFilter.Mutable.cs | 23 +++++++ .../Generated/Models/GetPetsTags.Mutable.cs | 23 +++++++ .../Models/NewPet.JsonStringArray.Mutable.cs | 23 +++++++ .../Generated/Models/NewPet.Mutable.cs | 23 +++++++ .../Models/Pet.JsonStringArray.Mutable.cs | 23 +++++++ .../Generated/Models/Pet.Mutable.cs | 23 +++++++ .../Models/Pet.TagsJsonStArray.Mutable.cs | 23 +++++++ .../Generated/Models/PetList.Mutable.cs | 23 +++++++ .../Generated/Models/PhotoMetadata.Mutable.cs | 23 +++++++ .../PostAdoptionApplyAccepted.Mutable.cs | 23 +++++++ .../Models/PostAdoptionApplyBody.Mutable.cs | 23 +++++++ .../Models/PostPetsByPetIdChatBody.Mutable.cs | 23 +++++++ ...ody.RequiredContentAndRoleArray.Mutable.cs | 23 +++++++ ...oleArray.RequiredContentAndRole.Mutable.cs | 23 +++++++ .../PostPetsByPetIdPhotosBody.Mutable.cs | 23 +++++++ .../Generated/corvusjson-openapi.lock | 4 +- .../Generated/ApiEndpointRegistration.cs | 19 +++++- .../Generated/CreatePetResult.cs | 37 +++++++++++ .../Generated/DownloadPhotoResult.cs | 39 ++++++++++- .../Generated/GetPetsBatchResult.cs | 12 ++++ .../Generated/ListPetsResult.cs | 27 ++++++++ .../Generated/Models/ActivityEvent.Mutable.cs | 23 +++++++ .../Generated/Models/ChatChunk.Mutable.cs | 23 +++++++ .../Generated/Models/Error.Mutable.cs | 23 +++++++ .../Models/GetPetsBatchByIdsIds.Mutable.cs | 23 +++++++ .../Generated/Models/GetPetsFilter.Mutable.cs | 23 +++++++ .../Generated/Models/GetPetsTags.Mutable.cs | 23 +++++++ .../Models/NewPet.JsonStringArray.Mutable.cs | 23 +++++++ .../Generated/Models/NewPet.Mutable.cs | 23 +++++++ .../Models/Pet.JsonStringArray.Mutable.cs | 23 +++++++ .../Generated/Models/Pet.Mutable.cs | 23 +++++++ .../Models/Pet.TagsJsonStArray.Mutable.cs | 23 +++++++ .../Generated/Models/PetList.Mutable.cs | 23 +++++++ .../Generated/Models/PhotoMetadata.Mutable.cs | 23 +++++++ .../PostAdoptionApplyAccepted.Mutable.cs | 23 +++++++ .../Models/PostAdoptionApplyBody.Mutable.cs | 23 +++++++ .../Models/PostPetsByPetIdChatBody.Mutable.cs | 23 +++++++ ...ody.RequiredContentAndRoleArray.Mutable.cs | 23 +++++++ ...oleArray.RequiredContentAndRole.Mutable.cs | 23 +++++++ .../PostPetsByPetIdPhotosBody.Mutable.cs | 23 +++++++ .../Generated/ShowPetByIdResult.cs | 24 +++++++ .../Generated/StartVetChatResult.cs | 12 ++++ .../Generated/StreamPetActivityResult.cs | 17 ++++- .../SubmitAdoptionApplicationResult.cs | 25 +++++++ .../Generated/UploadPetPhotoParams.cs | 5 ++ .../Generated/UploadPetPhotoResult.cs | 24 +++++++ .../032-OpenApiAdvancedServer/Program.cs | 9 ++- .../Generated/Client/ApiAdoptionClient.cs | 32 +++++++++ .../Generated/Client/ApiChatClient.cs | 36 ++++++++++ .../Generated/Client/ApiPetsClient.cs | 34 ++++++++++ .../Generated/Client/ApiPhotosClient.cs | 42 ++++++++++++ .../Generated/Client/IApiAdoptionClient.cs | 11 ++++ .../Generated/Client/IApiChatClient.cs | 13 ++++ .../Generated/Client/IApiPetsClient.cs | 12 ++++ .../Generated/Client/IApiPhotosClient.cs | 14 ++++ .../Client/Models/ActivityEvent.Mutable.cs | 23 +++++++ .../Client/Models/ChatChunk.Mutable.cs | 23 +++++++ .../Generated/Client/Models/Error.Mutable.cs | 23 +++++++ .../Models/GetPetsBatchByIdsIds.Mutable.cs | 23 +++++++ .../Client/Models/GetPetsFilter.Mutable.cs | 23 +++++++ .../Client/Models/GetPetsTags.Mutable.cs | 23 +++++++ .../Models/NewPet.JsonStringArray.Mutable.cs | 23 +++++++ .../Generated/Client/Models/NewPet.Mutable.cs | 23 +++++++ .../Models/Pet.JsonStringArray.Mutable.cs | 23 +++++++ .../Generated/Client/Models/Pet.Mutable.cs | 23 +++++++ .../Models/Pet.TagsJsonStArray.Mutable.cs | 23 +++++++ .../Client/Models/PetList.Mutable.cs | 23 +++++++ .../Client/Models/PhotoMetadata.Mutable.cs | 23 +++++++ .../PostAdoptionApplyAccepted.Mutable.cs | 23 +++++++ .../Models/PostAdoptionApplyBody.Mutable.cs | 23 +++++++ .../Models/PostPetsByPetIdChatBody.Mutable.cs | 23 +++++++ ...ody.RequiredContentAndRoleArray.Mutable.cs | 23 +++++++ ...oleArray.RequiredContentAndRole.Mutable.cs | 23 +++++++ .../PostPetsByPetIdPhotosBody.Mutable.cs | 23 +++++++ .../Generated/Client/corvusjson-openapi.lock | 4 +- .../Server/ApiEndpointRegistration.cs | 19 +++++- .../Generated/Server/CreatePetResult.cs | 37 +++++++++++ .../Generated/Server/DownloadPhotoResult.cs | 39 ++++++++++- .../Generated/Server/GetPetsBatchResult.cs | 12 ++++ .../Generated/Server/ListPetsResult.cs | 27 ++++++++ .../Server/Models/ActivityEvent.Mutable.cs | 23 +++++++ .../Server/Models/ChatChunk.Mutable.cs | 23 +++++++ .../Generated/Server/Models/Error.Mutable.cs | 23 +++++++ .../Models/GetPetsBatchByIdsIds.Mutable.cs | 23 +++++++ .../Server/Models/GetPetsFilter.Mutable.cs | 23 +++++++ .../Server/Models/GetPetsTags.Mutable.cs | 23 +++++++ .../Models/NewPet.JsonStringArray.Mutable.cs | 23 +++++++ .../Generated/Server/Models/NewPet.Mutable.cs | 23 +++++++ .../Models/Pet.JsonStringArray.Mutable.cs | 23 +++++++ .../Generated/Server/Models/Pet.Mutable.cs | 23 +++++++ .../Models/Pet.TagsJsonStArray.Mutable.cs | 23 +++++++ .../Server/Models/PetList.Mutable.cs | 23 +++++++ .../Server/Models/PhotoMetadata.Mutable.cs | 23 +++++++ .../PostAdoptionApplyAccepted.Mutable.cs | 23 +++++++ .../Models/PostAdoptionApplyBody.Mutable.cs | 23 +++++++ .../Models/PostPetsByPetIdChatBody.Mutable.cs | 23 +++++++ ...ody.RequiredContentAndRoleArray.Mutable.cs | 23 +++++++ ...oleArray.RequiredContentAndRole.Mutable.cs | 23 +++++++ .../PostPetsByPetIdPhotosBody.Mutable.cs | 23 +++++++ .../Generated/Server/ShowPetByIdResult.cs | 24 +++++++ .../Generated/Server/StartVetChatResult.cs | 12 ++++ .../Server/StreamPetActivityResult.cs | 17 ++++- .../Server/SubmitAdoptionApplicationResult.cs | 25 +++++++ .../Generated/Server/UploadPetPhotoParams.cs | 5 ++ .../Generated/Server/UploadPetPhotoResult.cs | 24 +++++++ .../Generated/Models/JsonObject.Mutable.cs | 23 +++++++ .../Generated/Models/Schema.Mutable.cs | 23 +++++++ .../Generated/Models/Schema1.Mutable.cs | 23 +++++++ .../Generated/Models/JsonObject.Mutable.cs | 23 +++++++ .../Generated/Models/Schema.Mutable.cs | 23 +++++++ .../Generated/Models/Schema1.Mutable.cs | 23 +++++++ .../Models/LightMeasuredPayload.Mutable.cs | 23 +++++++ .../Models/TurnOnOffPayload.Mutable.cs | 23 +++++++ .../Generated/corvusjson-asyncapi.lock | 4 +- .../Models/LightMeasuredPayload.Mutable.cs | 23 +++++++ .../Models/TurnOnOffPayload.Mutable.cs | 23 +++++++ .../Generated/corvusjson-asyncapi.lock | 4 +- .../Models/LightMeasuredPayload.Mutable.cs | 23 +++++++ .../Models/TurnOnOffPayload.Mutable.cs | 23 +++++++ .../Generated/corvusjson-asyncapi.lock | 4 +- .../Models/LightMeasuredPayload.Mutable.cs | 23 +++++++ .../Models/TurnOnOffPayload.Mutable.cs | 23 +++++++ .../Generated/corvusjson-asyncapi.lock | 4 +- .../Generated/ApiPetsClient.cs | 66 +++++++++++++++++++ .../Generated/IApiPetsClient.cs | 23 +++++++ .../Generated/Models/Error.Mutable.cs | 23 +++++++ .../Generated/Models/GetPetsTags.Mutable.cs | 23 +++++++ .../Generated/Models/NewPet.Mutable.cs | 23 +++++++ .../Generated/Models/Pet.Mutable.cs | 23 +++++++ .../Generated/Models/Pets.Mutable.cs | 23 +++++++ .../UpdatePetWithFormFormBody.Mutable.cs | 23 +++++++ .../Generated/corvusjson-openapi.lock | 4 +- 158 files changed, 3480 insertions(+), 31 deletions(-) diff --git a/docs/ExampleRecipes/029-OpenApiClient/Generated/ApiPetsClient.cs b/docs/ExampleRecipes/029-OpenApiClient/Generated/ApiPetsClient.cs index eddc487269d..23094714566 100644 --- a/docs/ExampleRecipes/029-OpenApiClient/Generated/ApiPetsClient.cs +++ b/docs/ExampleRecipes/029-OpenApiClient/Generated/ApiPetsClient.cs @@ -78,6 +78,38 @@ public ValueTask CreatePetAsync(Petstore.Client.Models.NewPet return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); } + /// + /// Create a pet + /// + /// The request body.. + /// A cancellation token. + public ValueTask CreatePetAsync(Petstore.Client.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.Client.Models.NewPet bodyValue = Petstore.Client.Models.NewPet.CreateBuilder(workspace, in body, 30).RootElement; + CreatePetRequest request = new(); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); + } + /// /// Info for a specific pet /// diff --git a/docs/ExampleRecipes/029-OpenApiClient/Generated/IApiPetsClient.cs b/docs/ExampleRecipes/029-OpenApiClient/Generated/IApiPetsClient.cs index 13cf537d2d4..36a1bf948e7 100644 --- a/docs/ExampleRecipes/029-OpenApiClient/Generated/IApiPetsClient.cs +++ b/docs/ExampleRecipes/029-OpenApiClient/Generated/IApiPetsClient.cs @@ -39,6 +39,17 @@ public interface IApiPetsClient : IAsyncDisposable /// A cancellation token. ValueTask CreatePetAsync(Petstore.Client.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + /// + /// Create a pet + /// + /// The request body.. + /// A cancellation token. + ValueTask CreatePetAsync(Petstore.Client.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; + /// /// Info for a specific pet /// diff --git a/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Error.Mutable.cs b/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Error.Mutable.cs index 82276e4ed97..6d2c08be31b 100644 --- a/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Error.Mutable.cs +++ b/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Error.Mutable.cs @@ -1206,6 +1206,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/NewPet.Mutable.cs b/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/NewPet.Mutable.cs index b48d5125d12..d5f16bafa24 100644 --- a/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/NewPet.Mutable.cs +++ b/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/NewPet.Mutable.cs @@ -1215,6 +1215,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Pet.Mutable.cs b/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Pet.Mutable.cs index da2bebe261a..5accafc827f 100644 --- a/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Pet.Mutable.cs +++ b/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Pet.Mutable.cs @@ -1276,6 +1276,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Pets.Mutable.cs b/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Pets.Mutable.cs index ad29320605f..b1c5dba00e9 100644 --- a/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Pets.Mutable.cs +++ b/docs/ExampleRecipes/029-OpenApiClient/Generated/Models/Pets.Mutable.cs @@ -1056,6 +1056,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/029-OpenApiClient/Generated/corvusjson-openapi.lock b/docs/ExampleRecipes/029-OpenApiClient/Generated/corvusjson-openapi.lock index 1ae199b456d..60789a79925 100644 --- a/docs/ExampleRecipes/029-OpenApiClient/Generated/corvusjson-openapi.lock +++ b/docs/ExampleRecipes/029-OpenApiClient/Generated/corvusjson-openapi.lock @@ -1,6 +1,6 @@ { "excludePaths": [], - "generatedAt": "2026-07-12T10:23:13.4219506\u002B00:00", + "generatedAt": "2026-08-05T05:33:42.2654770\u002B00:00", "generatedFiles": [ "ListPetsRequest.cs", "ListPetsResponse.cs", @@ -36,7 +36,7 @@ "Models/GetPetsLimit.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B681ffbb74b55fe58f5012c08c907f016a7f0dc58", + "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", "includePaths": [], "rootNamespace": "Petstore.Client", "specFileHash": "199092ff57f7e5d812b7164055f1065eb27cdc0a7be4303bea5e4edc070217d8", diff --git a/docs/ExampleRecipes/030-OpenApiServer/Generated/CreatePetResult.cs b/docs/ExampleRecipes/030-OpenApiServer/Generated/CreatePetResult.cs index db0cda97597..71e516c7bb9 100644 --- a/docs/ExampleRecipes/030-OpenApiServer/Generated/CreatePetResult.cs +++ b/docs/ExampleRecipes/030-OpenApiServer/Generated/CreatePetResult.cs @@ -42,6 +42,18 @@ private CreatePetResult(int statusCode, JsonElement body = default, string? cont /// The workspace for building the response value. /// A with status 201. public static CreatePetResult Created(Petstore.Server.Models.Pet.Source body, JsonWorkspace workspace) => new(201, Petstore.Server.Models.Pet.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 201 Created result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 201. + public static CreatePetResult Created(Petstore.Server.Models.Pet.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(201, Petstore.Server.Models.Pet.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a default error result. @@ -51,6 +63,19 @@ private CreatePetResult(int statusCode, JsonElement body = default, string? cont /// The workspace for building the response value. /// A with status default. public static CreatePetResult Default(int statusCode, Petstore.Server.Models.Error.Source body, JsonWorkspace workspace) => new(statusCode, Petstore.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a default Default result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The HTTP status code. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status default. + public static CreatePetResult Default(int statusCode, Petstore.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(statusCode, Petstore.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/030-OpenApiServer/Generated/ListPetsResult.cs b/docs/ExampleRecipes/030-OpenApiServer/Generated/ListPetsResult.cs index f3192e5cd65..06795180828 100644 --- a/docs/ExampleRecipes/030-OpenApiServer/Generated/ListPetsResult.cs +++ b/docs/ExampleRecipes/030-OpenApiServer/Generated/ListPetsResult.cs @@ -49,6 +49,19 @@ private ListPetsResult(int statusCode, JsonElement body, string? contentType, Pe /// The value for the x-next response header. /// A with status 200. public static ListPetsResult Ok(Petstore.Server.Models.Pets.Source body, JsonWorkspace workspace, Petstore.Server.Models.JsonString.Source xNext = default) => new(200, Petstore.Server.Models.Pets.CreateBuilder(workspace, body, 30).RootElement, "application/json", xNext: xNext.IsUndefined ? default : Petstore.Server.Models.JsonString.CreateBuilder(workspace, xNext, 30).RootElement); + /// + /// Creates a 200 Ok result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// The value for the x-next response header. + /// A with status 200. + public static ListPetsResult Ok(Petstore.Server.Models.Pets.Source body, JsonWorkspace workspace, Petstore.Server.Models.JsonString.Source xNext = default) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(200, Petstore.Server.Models.Pets.CreateBuilder(workspace, in body, 30).RootElement, "application/json", xNext: xNext.IsUndefined ? default : Petstore.Server.Models.JsonString.CreateBuilder(workspace, xNext, 30).RootElement); /// /// Creates a default error result. @@ -58,6 +71,19 @@ private ListPetsResult(int statusCode, JsonElement body, string? contentType, Pe /// The workspace for building the response value. /// A with status default. public static ListPetsResult Default(int statusCode, Petstore.Server.Models.Error.Source body, JsonWorkspace workspace) => new(statusCode, Petstore.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a default Default result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The HTTP status code. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status default. + public static ListPetsResult Default(int statusCode, Petstore.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(statusCode, Petstore.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Error.Mutable.cs b/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Error.Mutable.cs index f9224b5232f..2cf2acd4e46 100644 --- a/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Error.Mutable.cs +++ b/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Error.Mutable.cs @@ -1206,6 +1206,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/NewPet.Mutable.cs b/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/NewPet.Mutable.cs index 1c30437c817..d77a6f2539f 100644 --- a/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/NewPet.Mutable.cs +++ b/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/NewPet.Mutable.cs @@ -1215,6 +1215,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Pet.Mutable.cs b/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Pet.Mutable.cs index 5122eb8cfb2..99e59c64fa8 100644 --- a/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Pet.Mutable.cs +++ b/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Pet.Mutable.cs @@ -1276,6 +1276,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Pets.Mutable.cs b/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Pets.Mutable.cs index f03f7ebcd59..6028a65bad7 100644 --- a/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Pets.Mutable.cs +++ b/docs/ExampleRecipes/030-OpenApiServer/Generated/Models/Pets.Mutable.cs @@ -1056,6 +1056,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/030-OpenApiServer/Generated/ShowPetByIdResult.cs b/docs/ExampleRecipes/030-OpenApiServer/Generated/ShowPetByIdResult.cs index cbbca2ea836..46c231c8e80 100644 --- a/docs/ExampleRecipes/030-OpenApiServer/Generated/ShowPetByIdResult.cs +++ b/docs/ExampleRecipes/030-OpenApiServer/Generated/ShowPetByIdResult.cs @@ -42,6 +42,18 @@ private ShowPetByIdResult(int statusCode, JsonElement body = default, string? co /// The workspace for building the response value. /// A with status 200. public static ShowPetByIdResult Ok(Petstore.Server.Models.Pet.Source body, JsonWorkspace workspace) => new(200, Petstore.Server.Models.Pet.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 200 Ok result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 200. + public static ShowPetByIdResult Ok(Petstore.Server.Models.Pet.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(200, Petstore.Server.Models.Pet.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a default error result. @@ -51,6 +63,19 @@ private ShowPetByIdResult(int statusCode, JsonElement body = default, string? co /// The workspace for building the response value. /// A with status default. public static ShowPetByIdResult Default(int statusCode, Petstore.Server.Models.Error.Source body, JsonWorkspace workspace) => new(statusCode, Petstore.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a default Default result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The HTTP status code. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status default. + public static ShowPetByIdResult Default(int statusCode, Petstore.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(statusCode, Petstore.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiAdoptionClient.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiAdoptionClient.cs index 2b142b28401..b2de8f0aa6a 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiAdoptionClient.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiAdoptionClient.cs @@ -59,6 +59,38 @@ public ValueTask SubmitAdoptionApplicationAsy return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => { FormUrlEncodedSerializer.Serialize(bodyValue, stream); return default; }, "application/x-www-form-urlencoded", responseValidationMode, cancellationToken); } + /// + /// Submit an adoption application (URL-encoded form) + /// + /// The request body.. + /// A cancellation token. + public ValueTask SubmitAdoptionApplicationAsync(Petstore.Extended.Models.PostAdoptionApplyBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.Extended.Models.PostAdoptionApplyBody bodyValue = Petstore.Extended.Models.PostAdoptionApplyBody.CreateBuilder(workspace, in body, 30).RootElement; + SubmitAdoptionApplicationRequest request = new(); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => { FormUrlEncodedSerializer.Serialize(bodyValue, stream); return default; }, "application/x-www-form-urlencoded", responseValidationMode, cancellationToken); + } + /// public ValueTask DisposeAsync() => default; diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiChatClient.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiChatClient.cs index 1787cbdf00d..56e357ee056 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiChatClient.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiChatClient.cs @@ -63,6 +63,42 @@ public ValueTask StartVetChatAsync(Petstore.Extended.Model return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); } + /// + /// Start a vet support chat session (SSE streaming response) + /// + /// The petId parameter. + /// The session_token parameter. + /// The request body.. + /// A cancellation token. + public ValueTask StartVetChatAsync(Petstore.Extended.Models.JsonString.Source petId, Petstore.Extended.Models.JsonString.Source session_token, Petstore.Extended.Models.PostPetsByPetIdChatBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.Extended.Models.PostPetsByPetIdChatBody bodyValue = Petstore.Extended.Models.PostPetsByPetIdChatBody.CreateBuilder(workspace, in body, 30).RootElement; + Petstore.Extended.Models.JsonString PetIdValue = Petstore.Extended.Models.JsonString.CreateBuilder(workspace, petId, 30).RootElement; + Petstore.Extended.Models.JsonString SessionTokenValue = Petstore.Extended.Models.JsonString.CreateBuilder(workspace, session_token, 30).RootElement; + StartVetChatRequest request = new(PetIdValue, SessionTokenValue); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); + } + /// /// Stream live activity updates for a pet (NDJSON) /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiPetsClient.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiPetsClient.cs index b37812045a8..6eca00d4a54 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiPetsClient.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiPetsClient.cs @@ -86,6 +86,40 @@ public ValueTask CreatePetAsync(Petstore.Extended.Models.Json return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); } + /// + /// Create a new pet listing + /// + /// The session_token parameter. + /// The request body.. + /// A cancellation token. + public ValueTask CreatePetAsync(Petstore.Extended.Models.JsonString.Source session_token, Petstore.Extended.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.Extended.Models.NewPet bodyValue = Petstore.Extended.Models.NewPet.CreateBuilder(workspace, in body, 30).RootElement; + Petstore.Extended.Models.JsonString SessionTokenValue = Petstore.Extended.Models.JsonString.CreateBuilder(workspace, session_token, 30).RootElement; + CreatePetRequest request = new(SessionTokenValue); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); + } + /// /// Get multiple pets by IDs (path array parameter) /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiPhotosClient.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiPhotosClient.cs index 774f82f457d..08ee1d698c9 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiPhotosClient.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/ApiPhotosClient.cs @@ -69,6 +69,48 @@ public ValueTask UploadPetPhotoAsync(Petstore.Extended.M return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => MultipartFormDataSerializer.SerializeAsync(bodyValue, stream, boundary, null, binaryParts, ct), "multipart/form-data; boundary=" + boundary, responseValidationMode, cancellationToken); } + /// + /// Upload a photo for a pet (multipart with metadata) + /// + /// The petId parameter. + /// The session_token parameter. + /// The request body.. + /// Binary data for the 'file' part. + /// A cancellation token. + public ValueTask UploadPetPhotoAsync(Petstore.Extended.Models.JsonString.Source petId, Petstore.Extended.Models.JsonString.Source session_token, Petstore.Extended.Models.PostPetsByPetIdPhotosBody.Source body, BinaryPartData file, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.Extended.Models.PostPetsByPetIdPhotosBody bodyValue = Petstore.Extended.Models.PostPetsByPetIdPhotosBody.CreateBuilder(workspace, in body, 30).RootElement; + Petstore.Extended.Models.JsonString PetIdValue = Petstore.Extended.Models.JsonString.CreateBuilder(workspace, petId, 30).RootElement; + Petstore.Extended.Models.JsonString SessionTokenValue = Petstore.Extended.Models.JsonString.CreateBuilder(workspace, session_token, 30).RootElement; + UploadPetPhotoRequest request = new(PetIdValue, SessionTokenValue); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + string boundary = MultipartFormDataSerializer.GenerateBoundary(); + Dictionary binaryParts = new(StringComparer.Ordinal) + { + ["file"] = file, + }; + return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => MultipartFormDataSerializer.SerializeAsync(bodyValue, stream, boundary, null, binaryParts, ct), "multipart/form-data; boundary=" + boundary, responseValidationMode, cancellationToken); + } + /// /// Download a pet photo (binary stream) /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiAdoptionClient.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiAdoptionClient.cs index 29a65110294..31cb0c8f16c 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiAdoptionClient.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiAdoptionClient.cs @@ -31,4 +31,15 @@ public interface IApiAdoptionClient : IAsyncDisposable /// The request body.. /// A cancellation token. ValueTask SubmitAdoptionApplicationAsync(Petstore.Extended.Models.PostAdoptionApplyBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + + /// + /// Submit an adoption application (URL-encoded form) + /// + /// The request body.. + /// A cancellation token. + ValueTask SubmitAdoptionApplicationAsync(Petstore.Extended.Models.PostAdoptionApplyBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; } diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiChatClient.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiChatClient.cs index ccb5a0a6694..b1bc02e1bff 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiChatClient.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiChatClient.cs @@ -34,6 +34,19 @@ public interface IApiChatClient : IAsyncDisposable /// A cancellation token. ValueTask StartVetChatAsync(Petstore.Extended.Models.JsonString.Source petId, Petstore.Extended.Models.JsonString.Source session_token, Petstore.Extended.Models.PostPetsByPetIdChatBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + /// + /// Start a vet support chat session (SSE streaming response) + /// + /// The petId parameter. + /// The session_token parameter. + /// The request body.. + /// A cancellation token. + ValueTask StartVetChatAsync(Petstore.Extended.Models.JsonString.Source petId, Petstore.Extended.Models.JsonString.Source session_token, Petstore.Extended.Models.PostPetsByPetIdChatBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; + /// /// Stream live activity updates for a pet (NDJSON) /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiPetsClient.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiPetsClient.cs index ace90183146..834fb1eb3d6 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiPetsClient.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiPetsClient.cs @@ -43,6 +43,18 @@ public interface IApiPetsClient : IAsyncDisposable /// A cancellation token. ValueTask CreatePetAsync(Petstore.Extended.Models.JsonString.Source session_token, Petstore.Extended.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + /// + /// Create a new pet listing + /// + /// The session_token parameter. + /// The request body.. + /// A cancellation token. + ValueTask CreatePetAsync(Petstore.Extended.Models.JsonString.Source session_token, Petstore.Extended.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; + /// /// Get multiple pets by IDs (path array parameter) /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiPhotosClient.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiPhotosClient.cs index 9c3f3119a1a..eb4733debdb 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiPhotosClient.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/IApiPhotosClient.cs @@ -35,6 +35,20 @@ public interface IApiPhotosClient : IAsyncDisposable /// A cancellation token. ValueTask UploadPetPhotoAsync(Petstore.Extended.Models.JsonString.Source petId, Petstore.Extended.Models.JsonString.Source session_token, Petstore.Extended.Models.PostPetsByPetIdPhotosBody.Source body, BinaryPartData file, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + /// + /// Upload a photo for a pet (multipart with metadata) + /// + /// The petId parameter. + /// The session_token parameter. + /// The request body.. + /// Binary data for the 'file' part. + /// A cancellation token. + ValueTask UploadPetPhotoAsync(Petstore.Extended.Models.JsonString.Source petId, Petstore.Extended.Models.JsonString.Source session_token, Petstore.Extended.Models.PostPetsByPetIdPhotosBody.Source body, BinaryPartData file, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; + /// /// Download a pet photo (binary stream) /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/ActivityEvent.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/ActivityEvent.Mutable.cs index cf9932c75fe..92637ae73da 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/ActivityEvent.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/ActivityEvent.Mutable.cs @@ -1335,6 +1335,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/ChatChunk.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/ChatChunk.Mutable.cs index d7d5e6201be..d15b466d016 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/ChatChunk.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/ChatChunk.Mutable.cs @@ -1285,6 +1285,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Error.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Error.Mutable.cs index 87f9faec4f3..caa9ec76961 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Error.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Error.Mutable.cs @@ -1206,6 +1206,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsBatchByIdsIds.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsBatchByIdsIds.Mutable.cs index 1fc622232b3..de85a5d1ee7 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsBatchByIdsIds.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsBatchByIdsIds.Mutable.cs @@ -1112,6 +1112,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsFilter.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsFilter.Mutable.cs index 13a1fee74e9..b0a8dbe0010 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsFilter.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsFilter.Mutable.cs @@ -1362,6 +1362,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsTags.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsTags.Mutable.cs index b34712e0b8e..a38558299f5 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsTags.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/GetPetsTags.Mutable.cs @@ -1045,6 +1045,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/NewPet.JsonStringArray.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/NewPet.JsonStringArray.Mutable.cs index 830931b33e7..8ab73eb9d56 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/NewPet.JsonStringArray.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/NewPet.JsonStringArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/NewPet.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/NewPet.Mutable.cs index 1b6a07f35f2..9e15e41a073 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/NewPet.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/NewPet.Mutable.cs @@ -1579,6 +1579,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.JsonStringArray.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.JsonStringArray.Mutable.cs index 9e83eacbe38..5cfd0be1a06 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.JsonStringArray.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.JsonStringArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.Mutable.cs index e041110c1dd..7a1d7a5e8fe 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.Mutable.cs @@ -1756,6 +1756,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.TagsJsonStArray.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.TagsJsonStArray.Mutable.cs index 3068bf50c1e..183928411e5 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.TagsJsonStArray.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/Pet.TagsJsonStArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PetList.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PetList.Mutable.cs index cf063978682..4a0055b142f 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PetList.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PetList.Mutable.cs @@ -1056,6 +1056,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PhotoMetadata.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PhotoMetadata.Mutable.cs index a08d7a1386f..1e31de00826 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PhotoMetadata.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PhotoMetadata.Mutable.cs @@ -1403,6 +1403,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostAdoptionApplyAccepted.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostAdoptionApplyAccepted.Mutable.cs index f87ba98a045..4cd0d130d36 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostAdoptionApplyAccepted.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostAdoptionApplyAccepted.Mutable.cs @@ -1276,6 +1276,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostAdoptionApplyBody.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostAdoptionApplyBody.Mutable.cs index e7d74277c1f..f8296ab8ca7 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostAdoptionApplyBody.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostAdoptionApplyBody.Mutable.cs @@ -1530,6 +1530,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.Mutable.cs index b9b3065ce4d..21026535dd5 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.Mutable.cs @@ -1361,6 +1361,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs index 9e5d71968d7..2fcb24b94c6 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs @@ -1062,6 +1062,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs index cf73771756f..97a03184dd2 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs @@ -1217,6 +1217,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdPhotosBody.Mutable.cs b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdPhotosBody.Mutable.cs index c9529ac7c78..671fce343cf 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdPhotosBody.Mutable.cs +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/Models/PostPetsByPetIdPhotosBody.Mutable.cs @@ -1285,6 +1285,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/corvusjson-openapi.lock b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/corvusjson-openapi.lock index 3da6207b017..89bd857746a 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/corvusjson-openapi.lock +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/corvusjson-openapi.lock @@ -1,6 +1,6 @@ { "excludePaths": [], - "generatedAt": "2026-07-12T10:23:16.1008908\u002B00:00", + "generatedAt": "2026-08-05T05:33:45.0009931\u002B00:00", "generatedFiles": [ "ListPetsRequest.cs", "ListPetsResponse.cs", @@ -135,7 +135,7 @@ "Models/PostPetsByPetIdPhotosBody.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B681ffbb74b55fe58f5012c08c907f016a7f0dc58", + "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", "includePaths": [], "rootNamespace": "Petstore.Extended", "specFileHash": "27f81b7eb15fe66d4e3b2ffb3572a80fe7e0c6b564966f8b785c7496c1631ff8", diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ApiEndpointRegistration.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ApiEndpointRegistration.cs index 3cac0ef49cc..729dbaef40a 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ApiEndpointRegistration.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ApiEndpointRegistration.cs @@ -517,9 +517,13 @@ public static IEndpointRouteBuilder MapApiEndpoints(this IEndpointRouteBuilder a } + byte[]? __binary_file = null; try { - bodyDoc = await MultipartFormDataSerializer.DeserializeAsync(context.Request.Body, context.Request.ContentType, cancellationToken: context.RequestAborted).ConfigureAwait(false); + bodyDoc = await MultipartFormDataSerializer.DeserializeAsync(context.Request.Body, context.Request.ContentType, binaryPartCallback: part => + { + if (part.Name.SequenceEqual("file"u8)) { __binary_file = part.Data.ToArray(); } + }, cancellationToken: context.RequestAborted).ConfigureAwait(false); } catch { @@ -534,6 +538,7 @@ public static IEndpointRouteBuilder MapApiEndpoints(this IEndpointRouteBuilder a PetId = PetIdValue, SessionToken = SessionTokenValue, Body = bodyDoc!.RootElement, + File = __binary_file ?? ReadOnlyMemory.Empty, } ; @@ -628,7 +633,12 @@ public static IEndpointRouteBuilder MapApiEndpoints(this IEndpointRouteBuilder a } context.Response.StatusCode = result.StatusCode; - if (!result.Body.IsUndefined()) + if (result.HasBinaryBody) + { + context.Response.ContentType = result.ContentType ?? "application/octet-stream"; + await result.WriteBinaryBodyAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); + } + else if (!result.Body.IsUndefined()) { context.Response.ContentType = result.ContentType ?? "application/json"; Utf8JsonWriter writer = workspace.RentWriter(context.Response.BodyWriter); @@ -880,6 +890,11 @@ public static IEndpointRouteBuilder MapApiEndpoints(this IEndpointRouteBuilder a await context.Response.BodyWriter.FlushAsync(context.RequestAborted).ConfigureAwait(false); } + else if (result.HasBinaryBody) + { + context.Response.ContentType = result.ContentType ?? "application/octet-stream"; + await result.WriteBinaryBodyAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); + } else if (!result.Body.IsUndefined()) { context.Response.ContentType = result.ContentType ?? "application/json"; diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/CreatePetResult.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/CreatePetResult.cs index 83062b232dc..4703aeb6400 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/CreatePetResult.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/CreatePetResult.cs @@ -42,6 +42,18 @@ private CreatePetResult(int statusCode, JsonElement body = default, string? cont /// The workspace for building the response value. /// A with status 201. public static CreatePetResult Created(Petstore.Extended.Server.Models.Pet.Source body, JsonWorkspace workspace) => new(201, Petstore.Extended.Server.Models.Pet.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 201 Created result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 201. + public static CreatePetResult Created(Petstore.Extended.Server.Models.Pet.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(201, Petstore.Extended.Server.Models.Pet.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a 401 Unauthorized result. @@ -50,6 +62,18 @@ private CreatePetResult(int statusCode, JsonElement body = default, string? cont /// The workspace for building the response value. /// A with status 401. public static CreatePetResult Unauthorized(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) => new(401, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 401 Unauthorized result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 401. + public static CreatePetResult Unauthorized(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(401, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a default error result. @@ -59,6 +83,19 @@ private CreatePetResult(int statusCode, JsonElement body = default, string? cont /// The workspace for building the response value. /// A with status default. public static CreatePetResult Default(int statusCode, Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) => new(statusCode, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a default Default result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The HTTP status code. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status default. + public static CreatePetResult Default(int statusCode, Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(statusCode, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/DownloadPhotoResult.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/DownloadPhotoResult.cs index 3ce35374c9a..18e77e4d088 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/DownloadPhotoResult.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/DownloadPhotoResult.cs @@ -19,13 +19,17 @@ namespace Petstore.Extended.Server; /// public readonly struct DownloadPhotoResult { - private DownloadPhotoResult(int statusCode, JsonElement body = default, string? contentType = null) + private DownloadPhotoResult(int statusCode, JsonElement body = default, string? contentType = null, bool hasBinaryBody = false, Func? binaryWriter = null) { this.StatusCode = statusCode; this.Body = body; this.ContentType = contentType; + this.HasBinaryBody = hasBinaryBody; + this.binaryWriter = binaryWriter; } + private readonly Func? binaryWriter; + /// Gets the HTTP status code. public int StatusCode { get; } @@ -35,11 +39,22 @@ private DownloadPhotoResult(int statusCode, JsonElement body = default, string? /// Gets the content type for the response body. public string? ContentType { get; } + /// Gets a value indicating whether this result has a raw binary (octet-stream) response body. + public bool HasBinaryBody { get; } + /// /// Creates a 200 Ok result. /// + /// The raw binary response body. + /// The content type for the response body. + /// A with status 200. + public static DownloadPhotoResult Ok(ReadOnlyMemory body, string? contentType = "application/octet-stream") => new(200, default, contentType, hasBinaryBody: true, binaryWriter: (stream, cancellationToken) => stream.WriteAsync(body, cancellationToken)); + + /// Creates a 200 Ok result whose body is streamed directly to the response. + /// A callback that writes the response body to the supplied stream. + /// The content type for the response body. /// A with status 200. - public static DownloadPhotoResult Ok() => new(200, default, null); + public static DownloadPhotoResult Ok(Func writeBody, string? contentType = "application/octet-stream") => new(200, default, contentType, hasBinaryBody: true, binaryWriter: writeBody); /// /// Creates a 404 NotFound result. @@ -48,6 +63,18 @@ private DownloadPhotoResult(int statusCode, JsonElement body = default, string? /// The workspace for building the response value. /// A with status 404. public static DownloadPhotoResult NotFound(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) => new(404, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 404 NotFound result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 404. + public static DownloadPhotoResult NotFound(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(404, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. @@ -74,4 +101,12 @@ public void WriteBody(Utf8JsonWriter writer) this.Body.WriteTo(writer); } } + + /// + /// Writes the raw binary (octet-stream) response body to the specified stream. + /// + /// The response stream. + /// The cancellation token. + /// A value task that completes when the body has been written. + public ValueTask WriteBinaryBodyAsync(Stream stream, CancellationToken cancellationToken) => this.binaryWriter is { } writer ? writer(stream, cancellationToken) : ValueTask.CompletedTask; } diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/GetPetsBatchResult.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/GetPetsBatchResult.cs index 02710e93a0e..8573f1db489 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/GetPetsBatchResult.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/GetPetsBatchResult.cs @@ -42,6 +42,18 @@ private GetPetsBatchResult(int statusCode, JsonElement body = default, string? c /// The workspace for building the response value. /// A with status 200. public static GetPetsBatchResult Ok(Petstore.Extended.Server.Models.PetList.Source body, JsonWorkspace workspace) => new(200, Petstore.Extended.Server.Models.PetList.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 200 Ok result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 200. + public static GetPetsBatchResult Ok(Petstore.Extended.Server.Models.PetList.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(200, Petstore.Extended.Server.Models.PetList.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ListPetsResult.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ListPetsResult.cs index 704af04f725..65a774c3629 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ListPetsResult.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ListPetsResult.cs @@ -56,6 +56,20 @@ private ListPetsResult(int statusCode, JsonElement body, string? contentType, Pe /// The value for the x-next response header. /// A with status 200. public static ListPetsResult Ok(Petstore.Extended.Server.Models.PetList.Source body, JsonWorkspace workspace, Petstore.Extended.Server.Models.JsonInteger.Source xTotalCount = default, Petstore.Extended.Server.Models.JsonString.Source xNext = default) => new(200, Petstore.Extended.Server.Models.PetList.CreateBuilder(workspace, body, 30).RootElement, "application/json", xTotalCount: xTotalCount.IsUndefined ? default : Petstore.Extended.Server.Models.JsonInteger.CreateBuilder(workspace, xTotalCount, 30).RootElement, xNext: xNext.IsUndefined ? default : Petstore.Extended.Server.Models.JsonString.CreateBuilder(workspace, xNext, 30).RootElement); + /// + /// Creates a 200 Ok result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// The value for the x-total-count response header. + /// The value for the x-next response header. + /// A with status 200. + public static ListPetsResult Ok(Petstore.Extended.Server.Models.PetList.Source body, JsonWorkspace workspace, Petstore.Extended.Server.Models.JsonInteger.Source xTotalCount = default, Petstore.Extended.Server.Models.JsonString.Source xNext = default) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(200, Petstore.Extended.Server.Models.PetList.CreateBuilder(workspace, in body, 30).RootElement, "application/json", xTotalCount: xTotalCount.IsUndefined ? default : Petstore.Extended.Server.Models.JsonInteger.CreateBuilder(workspace, xTotalCount, 30).RootElement, xNext: xNext.IsUndefined ? default : Petstore.Extended.Server.Models.JsonString.CreateBuilder(workspace, xNext, 30).RootElement); /// /// Creates a default error result. @@ -65,6 +79,19 @@ private ListPetsResult(int statusCode, JsonElement body, string? contentType, Pe /// The workspace for building the response value. /// A with status default. public static ListPetsResult Default(int statusCode, Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) => new(statusCode, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a default Default result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The HTTP status code. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status default. + public static ListPetsResult Default(int statusCode, Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(statusCode, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/ActivityEvent.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/ActivityEvent.Mutable.cs index 69ca1cc7858..6278a6e61f4 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/ActivityEvent.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/ActivityEvent.Mutable.cs @@ -1335,6 +1335,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/ChatChunk.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/ChatChunk.Mutable.cs index 06068022748..0c26ef2837e 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/ChatChunk.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/ChatChunk.Mutable.cs @@ -1285,6 +1285,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Error.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Error.Mutable.cs index d88f05bab94..c60d629ccdc 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Error.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Error.Mutable.cs @@ -1206,6 +1206,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsBatchByIdsIds.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsBatchByIdsIds.Mutable.cs index e8a085e406a..148709f0165 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsBatchByIdsIds.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsBatchByIdsIds.Mutable.cs @@ -1112,6 +1112,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsFilter.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsFilter.Mutable.cs index 972bb452948..02aea396d2f 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsFilter.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsFilter.Mutable.cs @@ -1362,6 +1362,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsTags.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsTags.Mutable.cs index 034471d7f42..9ef89ae4280 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsTags.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/GetPetsTags.Mutable.cs @@ -1045,6 +1045,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/NewPet.JsonStringArray.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/NewPet.JsonStringArray.Mutable.cs index 3390dac9f4c..1b012458ab8 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/NewPet.JsonStringArray.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/NewPet.JsonStringArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/NewPet.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/NewPet.Mutable.cs index 3b8ae26fe7d..8afeaa38e45 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/NewPet.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/NewPet.Mutable.cs @@ -1579,6 +1579,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.JsonStringArray.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.JsonStringArray.Mutable.cs index 22b9d3a259d..31e7eb1a439 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.JsonStringArray.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.JsonStringArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.Mutable.cs index 6c70655851c..10031c67e6d 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.Mutable.cs @@ -1756,6 +1756,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.TagsJsonStArray.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.TagsJsonStArray.Mutable.cs index 6923607767d..096ad8072c3 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.TagsJsonStArray.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/Pet.TagsJsonStArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PetList.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PetList.Mutable.cs index af273885f60..107d3c31d71 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PetList.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PetList.Mutable.cs @@ -1056,6 +1056,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PhotoMetadata.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PhotoMetadata.Mutable.cs index c5ab6ea190a..b6ea80f2105 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PhotoMetadata.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PhotoMetadata.Mutable.cs @@ -1403,6 +1403,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostAdoptionApplyAccepted.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostAdoptionApplyAccepted.Mutable.cs index 68184670d17..16ee7949ce6 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostAdoptionApplyAccepted.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostAdoptionApplyAccepted.Mutable.cs @@ -1276,6 +1276,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostAdoptionApplyBody.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostAdoptionApplyBody.Mutable.cs index 90316cf8bbf..5968188a0ab 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostAdoptionApplyBody.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostAdoptionApplyBody.Mutable.cs @@ -1530,6 +1530,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.Mutable.cs index 4692b5f4921..40b1a3db474 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.Mutable.cs @@ -1361,6 +1361,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs index f56d044fdee..ef320837dd0 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs @@ -1062,6 +1062,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs index 99159252c09..9a1bba97b44 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs @@ -1217,6 +1217,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdPhotosBody.Mutable.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdPhotosBody.Mutable.cs index fb6f08f6f7b..6b337570c69 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdPhotosBody.Mutable.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/Models/PostPetsByPetIdPhotosBody.Mutable.cs @@ -1285,6 +1285,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ShowPetByIdResult.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ShowPetByIdResult.cs index 7f57d737d40..61fb8b2059c 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ShowPetByIdResult.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/ShowPetByIdResult.cs @@ -42,6 +42,18 @@ private ShowPetByIdResult(int statusCode, JsonElement body = default, string? co /// The workspace for building the response value. /// A with status 200. public static ShowPetByIdResult Ok(Petstore.Extended.Server.Models.Pet.Source body, JsonWorkspace workspace) => new(200, Petstore.Extended.Server.Models.Pet.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 200 Ok result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 200. + public static ShowPetByIdResult Ok(Petstore.Extended.Server.Models.Pet.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(200, Petstore.Extended.Server.Models.Pet.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a 404 NotFound result. @@ -50,6 +62,18 @@ private ShowPetByIdResult(int statusCode, JsonElement body = default, string? co /// The workspace for building the response value. /// A with status 404. public static ShowPetByIdResult NotFound(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) => new(404, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 404 NotFound result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 404. + public static ShowPetByIdResult NotFound(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(404, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/StartVetChatResult.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/StartVetChatResult.cs index 57b411165bc..e2c206829ef 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/StartVetChatResult.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/StartVetChatResult.cs @@ -68,6 +68,18 @@ private StartVetChatResult(int statusCode, JsonElement body = default, string? c /// The workspace for building the response value. /// A with status 401. public static StartVetChatResult Unauthorized(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) => new(401, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 401 Unauthorized result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 401. + public static StartVetChatResult Unauthorized(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(401, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/StreamPetActivityResult.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/StreamPetActivityResult.cs index 693e5a0c112..9f55d21c461 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/StreamPetActivityResult.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/StreamPetActivityResult.cs @@ -19,18 +19,22 @@ namespace Petstore.Extended.Server; /// public readonly struct StreamPetActivityResult { - private StreamPetActivityResult(int statusCode, JsonElement body = default, string? contentType = null, StreamPetActivityStreamWriterInvoker? streamWriter = null, object? streamWriterContext = null) + private StreamPetActivityResult(int statusCode, JsonElement body = default, string? contentType = null, StreamPetActivityStreamWriterInvoker? streamWriter = null, object? streamWriterContext = null, bool hasBinaryBody = false, Func? binaryWriter = null) { this.StatusCode = statusCode; this.Body = body; this.ContentType = contentType; this.streamWriter = streamWriter; this.streamWriterContext = streamWriterContext; + this.HasBinaryBody = hasBinaryBody; + this.binaryWriter = binaryWriter; } private readonly StreamPetActivityStreamWriterInvoker? streamWriter; private readonly object? streamWriterContext; + private readonly Func? binaryWriter; + /// Gets the HTTP status code. public int StatusCode { get; } @@ -40,6 +44,9 @@ private StreamPetActivityResult(int statusCode, JsonElement body = default, stri /// Gets the content type for the response body. public string? ContentType { get; } + /// Gets a value indicating whether this result has a raw binary (octet-stream) response body. + public bool HasBinaryBody { get; } + /// Gets a value indicating whether this result has a streaming response body. public bool HasStreamingBody => this.streamWriter is not null; @@ -83,6 +90,14 @@ public void WriteBody(Utf8JsonWriter writer) } } + /// + /// Writes the raw binary (octet-stream) response body to the specified stream. + /// + /// The response stream. + /// The cancellation token. + /// A value task that completes when the body has been written. + public ValueTask WriteBinaryBodyAsync(Stream stream, CancellationToken cancellationToken) => this.binaryWriter is { } writer ? writer(stream, cancellationToken) : ValueTask.CompletedTask; + /// /// Writes the streaming response body. /// diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/SubmitAdoptionApplicationResult.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/SubmitAdoptionApplicationResult.cs index 89ab6868018..2d193fa68a0 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/SubmitAdoptionApplicationResult.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/SubmitAdoptionApplicationResult.cs @@ -42,6 +42,18 @@ private SubmitAdoptionApplicationResult(int statusCode, JsonElement body = defau /// The workspace for building the response value. /// A with status 202. public static SubmitAdoptionApplicationResult Accepted(Petstore.Extended.Server.Models.PostAdoptionApplyAccepted.Source body, JsonWorkspace workspace) => new(202, Petstore.Extended.Server.Models.PostAdoptionApplyAccepted.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 202 Accepted result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 202. + public static SubmitAdoptionApplicationResult Accepted(Petstore.Extended.Server.Models.PostAdoptionApplyAccepted.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(202, Petstore.Extended.Server.Models.PostAdoptionApplyAccepted.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a default error result. @@ -51,6 +63,19 @@ private SubmitAdoptionApplicationResult(int statusCode, JsonElement body = defau /// The workspace for building the response value. /// A with status default. public static SubmitAdoptionApplicationResult Default(int statusCode, Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) => new(statusCode, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a default Default result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The HTTP status code. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status default. + public static SubmitAdoptionApplicationResult Default(int statusCode, Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(statusCode, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/UploadPetPhotoParams.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/UploadPetPhotoParams.cs index b5675602574..8afc990c9a9 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/UploadPetPhotoParams.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/UploadPetPhotoParams.cs @@ -35,4 +35,9 @@ public readonly struct UploadPetPhotoParams /// Gets the request body. /// public Petstore.Extended.Server.Models.PostPetsByPetIdPhotosBody Body { get; init; } + + /// + /// Gets the binary content of the 'file' part. + /// + public ReadOnlyMemory File { get; init; } } diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/UploadPetPhotoResult.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/UploadPetPhotoResult.cs index a72fce89e6a..b90fb2a788f 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/UploadPetPhotoResult.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Generated/UploadPetPhotoResult.cs @@ -42,6 +42,18 @@ private UploadPetPhotoResult(int statusCode, JsonElement body = default, string? /// The workspace for building the response value. /// A with status 201. public static UploadPetPhotoResult Created(Petstore.Extended.Server.Models.PhotoMetadata.Source body, JsonWorkspace workspace) => new(201, Petstore.Extended.Server.Models.PhotoMetadata.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 201 Created result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 201. + public static UploadPetPhotoResult Created(Petstore.Extended.Server.Models.PhotoMetadata.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(201, Petstore.Extended.Server.Models.PhotoMetadata.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a 401 Unauthorized result. @@ -50,6 +62,18 @@ private UploadPetPhotoResult(int statusCode, JsonElement body = default, string? /// The workspace for building the response value. /// A with status 401. public static UploadPetPhotoResult Unauthorized(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) => new(401, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 401 Unauthorized result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 401. + public static UploadPetPhotoResult Unauthorized(Petstore.Extended.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(401, Petstore.Extended.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Program.cs b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Program.cs index 2d25209e988..06c82efa5dd 100644 --- a/docs/ExampleRecipes/032-OpenApiAdvancedServer/Program.cs +++ b/docs/ExampleRecipes/032-OpenApiAdvancedServer/Program.cs @@ -321,11 +321,10 @@ public ValueTask HandleDownloadPhotoAsync( workspace)); } - // For binary responses, the generated infrastructure streams the data - // to the client. Here we return Ok() — the actual streaming is handled - // by the endpoint registration middleware. - _ = photo; // In production, you'd stream photo.Data - return ValueTask.FromResult(DownloadPhotoResult.Ok()); + // A binary response carries its body through the result factory. Pass the bytes and the + // generated infrastructure writes them to the response stream, with the content type the + // handler chooses rather than the one the specification happened to list first. + return ValueTask.FromResult(DownloadPhotoResult.Ok(photo.Data, photo.ContentType)); } } diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiAdoptionClient.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiAdoptionClient.cs index 24856d21c43..eb97277bd73 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiAdoptionClient.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiAdoptionClient.cs @@ -59,6 +59,38 @@ public ValueTask SubmitAdoptionApplicationAsy return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => { FormUrlEncodedSerializer.Serialize(bodyValue, stream); return default; }, "application/x-www-form-urlencoded", responseValidationMode, cancellationToken); } + /// + /// Submit an adoption application (URL-encoded form) + /// + /// The request body.. + /// A cancellation token. + public ValueTask SubmitAdoptionApplicationAsync(Petstore.EndToEnd.Client.Models.PostAdoptionApplyBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.EndToEnd.Client.Models.PostAdoptionApplyBody bodyValue = Petstore.EndToEnd.Client.Models.PostAdoptionApplyBody.CreateBuilder(workspace, in body, 30).RootElement; + SubmitAdoptionApplicationRequest request = new(); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => { FormUrlEncodedSerializer.Serialize(bodyValue, stream); return default; }, "application/x-www-form-urlencoded", responseValidationMode, cancellationToken); + } + /// public ValueTask DisposeAsync() => default; diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiChatClient.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiChatClient.cs index a0f076ebd90..63d80098908 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiChatClient.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiChatClient.cs @@ -63,6 +63,42 @@ public ValueTask StartVetChatAsync(Petstore.EndToEnd.Clien return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); } + /// + /// Start a vet support chat session (SSE streaming response) + /// + /// The petId parameter. + /// The session_token parameter. + /// The request body.. + /// A cancellation token. + public ValueTask StartVetChatAsync(Petstore.EndToEnd.Client.Models.JsonString.Source petId, Petstore.EndToEnd.Client.Models.JsonString.Source session_token, Petstore.EndToEnd.Client.Models.PostPetsByPetIdChatBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.EndToEnd.Client.Models.PostPetsByPetIdChatBody bodyValue = Petstore.EndToEnd.Client.Models.PostPetsByPetIdChatBody.CreateBuilder(workspace, in body, 30).RootElement; + Petstore.EndToEnd.Client.Models.JsonString PetIdValue = Petstore.EndToEnd.Client.Models.JsonString.CreateBuilder(workspace, petId, 30).RootElement; + Petstore.EndToEnd.Client.Models.JsonString SessionTokenValue = Petstore.EndToEnd.Client.Models.JsonString.CreateBuilder(workspace, session_token, 30).RootElement; + StartVetChatRequest request = new(PetIdValue, SessionTokenValue); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); + } + /// /// Stream live activity updates for a pet (NDJSON) /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiPetsClient.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiPetsClient.cs index eebe58cc846..90f0d56fd0c 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiPetsClient.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiPetsClient.cs @@ -86,6 +86,40 @@ public ValueTask CreatePetAsync(Petstore.EndToEnd.Client.Mode return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); } + /// + /// Create a new pet listing + /// + /// The session_token parameter. + /// The request body.. + /// A cancellation token. + public ValueTask CreatePetAsync(Petstore.EndToEnd.Client.Models.JsonString.Source session_token, Petstore.EndToEnd.Client.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.EndToEnd.Client.Models.NewPet bodyValue = Petstore.EndToEnd.Client.Models.NewPet.CreateBuilder(workspace, in body, 30).RootElement; + Petstore.EndToEnd.Client.Models.JsonString SessionTokenValue = Petstore.EndToEnd.Client.Models.JsonString.CreateBuilder(workspace, session_token, 30).RootElement; + CreatePetRequest request = new(SessionTokenValue); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); + } + /// /// Get multiple pets by IDs (path array parameter) /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiPhotosClient.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiPhotosClient.cs index 9bdd81dd389..40930e329b0 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiPhotosClient.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/ApiPhotosClient.cs @@ -69,6 +69,48 @@ public ValueTask UploadPetPhotoAsync(Petstore.EndToEnd.C return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => MultipartFormDataSerializer.SerializeAsync(bodyValue, stream, boundary, null, binaryParts, ct), "multipart/form-data; boundary=" + boundary, responseValidationMode, cancellationToken); } + /// + /// Upload a photo for a pet (multipart with metadata) + /// + /// The petId parameter. + /// The session_token parameter. + /// The request body.. + /// Binary data for the 'file' part. + /// A cancellation token. + public ValueTask UploadPetPhotoAsync(Petstore.EndToEnd.Client.Models.JsonString.Source petId, Petstore.EndToEnd.Client.Models.JsonString.Source session_token, Petstore.EndToEnd.Client.Models.PostPetsByPetIdPhotosBody.Source body, BinaryPartData file, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.EndToEnd.Client.Models.PostPetsByPetIdPhotosBody bodyValue = Petstore.EndToEnd.Client.Models.PostPetsByPetIdPhotosBody.CreateBuilder(workspace, in body, 30).RootElement; + Petstore.EndToEnd.Client.Models.JsonString PetIdValue = Petstore.EndToEnd.Client.Models.JsonString.CreateBuilder(workspace, petId, 30).RootElement; + Petstore.EndToEnd.Client.Models.JsonString SessionTokenValue = Petstore.EndToEnd.Client.Models.JsonString.CreateBuilder(workspace, session_token, 30).RootElement; + UploadPetPhotoRequest request = new(PetIdValue, SessionTokenValue); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + string boundary = MultipartFormDataSerializer.GenerateBoundary(); + Dictionary binaryParts = new(StringComparer.Ordinal) + { + ["file"] = file, + }; + return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => MultipartFormDataSerializer.SerializeAsync(bodyValue, stream, boundary, null, binaryParts, ct), "multipart/form-data; boundary=" + boundary, responseValidationMode, cancellationToken); + } + /// /// Download a pet photo (binary stream) /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiAdoptionClient.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiAdoptionClient.cs index 34dd1db2dc7..9b44be8025c 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiAdoptionClient.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiAdoptionClient.cs @@ -31,4 +31,15 @@ public interface IApiAdoptionClient : IAsyncDisposable /// The request body.. /// A cancellation token. ValueTask SubmitAdoptionApplicationAsync(Petstore.EndToEnd.Client.Models.PostAdoptionApplyBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + + /// + /// Submit an adoption application (URL-encoded form) + /// + /// The request body.. + /// A cancellation token. + ValueTask SubmitAdoptionApplicationAsync(Petstore.EndToEnd.Client.Models.PostAdoptionApplyBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; } diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiChatClient.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiChatClient.cs index 3d4226a1d4c..f6041ec4078 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiChatClient.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiChatClient.cs @@ -34,6 +34,19 @@ public interface IApiChatClient : IAsyncDisposable /// A cancellation token. ValueTask StartVetChatAsync(Petstore.EndToEnd.Client.Models.JsonString.Source petId, Petstore.EndToEnd.Client.Models.JsonString.Source session_token, Petstore.EndToEnd.Client.Models.PostPetsByPetIdChatBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + /// + /// Start a vet support chat session (SSE streaming response) + /// + /// The petId parameter. + /// The session_token parameter. + /// The request body.. + /// A cancellation token. + ValueTask StartVetChatAsync(Petstore.EndToEnd.Client.Models.JsonString.Source petId, Petstore.EndToEnd.Client.Models.JsonString.Source session_token, Petstore.EndToEnd.Client.Models.PostPetsByPetIdChatBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; + /// /// Stream live activity updates for a pet (NDJSON) /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiPetsClient.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiPetsClient.cs index 13acd1c6ef2..f632d31977e 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiPetsClient.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiPetsClient.cs @@ -43,6 +43,18 @@ public interface IApiPetsClient : IAsyncDisposable /// A cancellation token. ValueTask CreatePetAsync(Petstore.EndToEnd.Client.Models.JsonString.Source session_token, Petstore.EndToEnd.Client.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + /// + /// Create a new pet listing + /// + /// The session_token parameter. + /// The request body.. + /// A cancellation token. + ValueTask CreatePetAsync(Petstore.EndToEnd.Client.Models.JsonString.Source session_token, Petstore.EndToEnd.Client.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; + /// /// Get multiple pets by IDs (path array parameter) /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiPhotosClient.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiPhotosClient.cs index 830e6be2f8b..b7d728a1bc0 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiPhotosClient.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/IApiPhotosClient.cs @@ -35,6 +35,20 @@ public interface IApiPhotosClient : IAsyncDisposable /// A cancellation token. ValueTask UploadPetPhotoAsync(Petstore.EndToEnd.Client.Models.JsonString.Source petId, Petstore.EndToEnd.Client.Models.JsonString.Source session_token, Petstore.EndToEnd.Client.Models.PostPetsByPetIdPhotosBody.Source body, BinaryPartData file, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + /// + /// Upload a photo for a pet (multipart with metadata) + /// + /// The petId parameter. + /// The session_token parameter. + /// The request body.. + /// Binary data for the 'file' part. + /// A cancellation token. + ValueTask UploadPetPhotoAsync(Petstore.EndToEnd.Client.Models.JsonString.Source petId, Petstore.EndToEnd.Client.Models.JsonString.Source session_token, Petstore.EndToEnd.Client.Models.PostPetsByPetIdPhotosBody.Source body, BinaryPartData file, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; + /// /// Download a pet photo (binary stream) /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/ActivityEvent.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/ActivityEvent.Mutable.cs index 18b56781b2f..ae2bffa09b9 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/ActivityEvent.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/ActivityEvent.Mutable.cs @@ -1335,6 +1335,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/ChatChunk.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/ChatChunk.Mutable.cs index 7b542d3550d..1b2562a6533 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/ChatChunk.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/ChatChunk.Mutable.cs @@ -1285,6 +1285,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Error.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Error.Mutable.cs index 87059db03d9..b8b6a302e3e 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Error.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Error.Mutable.cs @@ -1206,6 +1206,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsBatchByIdsIds.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsBatchByIdsIds.Mutable.cs index 3a2bbc7e6f2..b435415527a 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsBatchByIdsIds.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsBatchByIdsIds.Mutable.cs @@ -1112,6 +1112,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsFilter.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsFilter.Mutable.cs index a1996122246..862e8f6d7a0 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsFilter.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsFilter.Mutable.cs @@ -1362,6 +1362,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsTags.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsTags.Mutable.cs index 51f9305e3ca..e898cac8902 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsTags.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/GetPetsTags.Mutable.cs @@ -1045,6 +1045,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/NewPet.JsonStringArray.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/NewPet.JsonStringArray.Mutable.cs index 7c32d7c7695..cea1813b71f 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/NewPet.JsonStringArray.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/NewPet.JsonStringArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/NewPet.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/NewPet.Mutable.cs index b0b0294e604..7d8fb32f565 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/NewPet.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/NewPet.Mutable.cs @@ -1579,6 +1579,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.JsonStringArray.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.JsonStringArray.Mutable.cs index df066b91250..2def8569ad2 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.JsonStringArray.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.JsonStringArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.Mutable.cs index cb47ec700bf..86ef49202e6 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.Mutable.cs @@ -1756,6 +1756,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.TagsJsonStArray.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.TagsJsonStArray.Mutable.cs index 9f4eb72e489..f29130d821f 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.TagsJsonStArray.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/Pet.TagsJsonStArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PetList.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PetList.Mutable.cs index 082a9e9122f..c8eddf33369 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PetList.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PetList.Mutable.cs @@ -1056,6 +1056,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PhotoMetadata.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PhotoMetadata.Mutable.cs index 52051bd02fa..04069d556ee 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PhotoMetadata.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PhotoMetadata.Mutable.cs @@ -1403,6 +1403,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostAdoptionApplyAccepted.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostAdoptionApplyAccepted.Mutable.cs index 0ec3a560d81..6a6e36b28fd 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostAdoptionApplyAccepted.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostAdoptionApplyAccepted.Mutable.cs @@ -1276,6 +1276,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostAdoptionApplyBody.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostAdoptionApplyBody.Mutable.cs index febfcdeadc2..b108563dfa5 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostAdoptionApplyBody.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostAdoptionApplyBody.Mutable.cs @@ -1530,6 +1530,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.Mutable.cs index c254f05fe8d..dffe1d923d0 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.Mutable.cs @@ -1361,6 +1361,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs index 180ef774016..1571b02ec69 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs @@ -1062,6 +1062,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs index c74c5c56161..1d8e97549dc 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs @@ -1217,6 +1217,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdPhotosBody.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdPhotosBody.Mutable.cs index 5abe35f3600..eeb7439fffa 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdPhotosBody.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/Models/PostPetsByPetIdPhotosBody.Mutable.cs @@ -1285,6 +1285,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/corvusjson-openapi.lock b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/corvusjson-openapi.lock index 8bf41d9a648..fa8864c26e4 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/corvusjson-openapi.lock +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/corvusjson-openapi.lock @@ -1,6 +1,6 @@ { "excludePaths": [], - "generatedAt": "2026-07-12T10:23:18.9080155\u002B00:00", + "generatedAt": "2026-08-05T05:33:47.8483521\u002B00:00", "generatedFiles": [ "ListPetsRequest.cs", "ListPetsResponse.cs", @@ -135,7 +135,7 @@ "Models/PostPetsByPetIdPhotosBody.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B681ffbb74b55fe58f5012c08c907f016a7f0dc58", + "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", "includePaths": [], "rootNamespace": "Petstore.EndToEnd.Client", "specFileHash": "27f81b7eb15fe66d4e3b2ffb3572a80fe7e0c6b564966f8b785c7496c1631ff8", diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ApiEndpointRegistration.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ApiEndpointRegistration.cs index d008715c0ee..ffd38010a54 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ApiEndpointRegistration.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ApiEndpointRegistration.cs @@ -517,9 +517,13 @@ public static IEndpointRouteBuilder MapApiEndpoints(this IEndpointRouteBuilder a } + byte[]? __binary_file = null; try { - bodyDoc = await MultipartFormDataSerializer.DeserializeAsync(context.Request.Body, context.Request.ContentType, cancellationToken: context.RequestAborted).ConfigureAwait(false); + bodyDoc = await MultipartFormDataSerializer.DeserializeAsync(context.Request.Body, context.Request.ContentType, binaryPartCallback: part => + { + if (part.Name.SequenceEqual("file"u8)) { __binary_file = part.Data.ToArray(); } + }, cancellationToken: context.RequestAborted).ConfigureAwait(false); } catch { @@ -534,6 +538,7 @@ public static IEndpointRouteBuilder MapApiEndpoints(this IEndpointRouteBuilder a PetId = PetIdValue, SessionToken = SessionTokenValue, Body = bodyDoc!.RootElement, + File = __binary_file ?? ReadOnlyMemory.Empty, } ; @@ -628,7 +633,12 @@ public static IEndpointRouteBuilder MapApiEndpoints(this IEndpointRouteBuilder a } context.Response.StatusCode = result.StatusCode; - if (!result.Body.IsUndefined()) + if (result.HasBinaryBody) + { + context.Response.ContentType = result.ContentType ?? "application/octet-stream"; + await result.WriteBinaryBodyAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); + } + else if (!result.Body.IsUndefined()) { context.Response.ContentType = result.ContentType ?? "application/json"; Utf8JsonWriter writer = workspace.RentWriter(context.Response.BodyWriter); @@ -880,6 +890,11 @@ public static IEndpointRouteBuilder MapApiEndpoints(this IEndpointRouteBuilder a await context.Response.BodyWriter.FlushAsync(context.RequestAborted).ConfigureAwait(false); } + else if (result.HasBinaryBody) + { + context.Response.ContentType = result.ContentType ?? "application/octet-stream"; + await result.WriteBinaryBodyAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); + } else if (!result.Body.IsUndefined()) { context.Response.ContentType = result.ContentType ?? "application/json"; diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/CreatePetResult.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/CreatePetResult.cs index 7ec343a8091..80a640be6d9 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/CreatePetResult.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/CreatePetResult.cs @@ -42,6 +42,18 @@ private CreatePetResult(int statusCode, JsonElement body = default, string? cont /// The workspace for building the response value. /// A with status 201. public static CreatePetResult Created(Petstore.EndToEnd.Server.Models.Pet.Source body, JsonWorkspace workspace) => new(201, Petstore.EndToEnd.Server.Models.Pet.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 201 Created result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 201. + public static CreatePetResult Created(Petstore.EndToEnd.Server.Models.Pet.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(201, Petstore.EndToEnd.Server.Models.Pet.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a 401 Unauthorized result. @@ -50,6 +62,18 @@ private CreatePetResult(int statusCode, JsonElement body = default, string? cont /// The workspace for building the response value. /// A with status 401. public static CreatePetResult Unauthorized(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) => new(401, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 401 Unauthorized result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 401. + public static CreatePetResult Unauthorized(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(401, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a default error result. @@ -59,6 +83,19 @@ private CreatePetResult(int statusCode, JsonElement body = default, string? cont /// The workspace for building the response value. /// A with status default. public static CreatePetResult Default(int statusCode, Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) => new(statusCode, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a default Default result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The HTTP status code. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status default. + public static CreatePetResult Default(int statusCode, Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(statusCode, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/DownloadPhotoResult.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/DownloadPhotoResult.cs index 1e9b0970c2a..38001875d11 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/DownloadPhotoResult.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/DownloadPhotoResult.cs @@ -19,13 +19,17 @@ namespace Petstore.EndToEnd.Server; /// public readonly struct DownloadPhotoResult { - private DownloadPhotoResult(int statusCode, JsonElement body = default, string? contentType = null) + private DownloadPhotoResult(int statusCode, JsonElement body = default, string? contentType = null, bool hasBinaryBody = false, Func? binaryWriter = null) { this.StatusCode = statusCode; this.Body = body; this.ContentType = contentType; + this.HasBinaryBody = hasBinaryBody; + this.binaryWriter = binaryWriter; } + private readonly Func? binaryWriter; + /// Gets the HTTP status code. public int StatusCode { get; } @@ -35,11 +39,22 @@ private DownloadPhotoResult(int statusCode, JsonElement body = default, string? /// Gets the content type for the response body. public string? ContentType { get; } + /// Gets a value indicating whether this result has a raw binary (octet-stream) response body. + public bool HasBinaryBody { get; } + /// /// Creates a 200 Ok result. /// + /// The raw binary response body. + /// The content type for the response body. + /// A with status 200. + public static DownloadPhotoResult Ok(ReadOnlyMemory body, string? contentType = "application/octet-stream") => new(200, default, contentType, hasBinaryBody: true, binaryWriter: (stream, cancellationToken) => stream.WriteAsync(body, cancellationToken)); + + /// Creates a 200 Ok result whose body is streamed directly to the response. + /// A callback that writes the response body to the supplied stream. + /// The content type for the response body. /// A with status 200. - public static DownloadPhotoResult Ok() => new(200, default, null); + public static DownloadPhotoResult Ok(Func writeBody, string? contentType = "application/octet-stream") => new(200, default, contentType, hasBinaryBody: true, binaryWriter: writeBody); /// /// Creates a 404 NotFound result. @@ -48,6 +63,18 @@ private DownloadPhotoResult(int statusCode, JsonElement body = default, string? /// The workspace for building the response value. /// A with status 404. public static DownloadPhotoResult NotFound(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) => new(404, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 404 NotFound result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 404. + public static DownloadPhotoResult NotFound(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(404, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. @@ -74,4 +101,12 @@ public void WriteBody(Utf8JsonWriter writer) this.Body.WriteTo(writer); } } + + /// + /// Writes the raw binary (octet-stream) response body to the specified stream. + /// + /// The response stream. + /// The cancellation token. + /// A value task that completes when the body has been written. + public ValueTask WriteBinaryBodyAsync(Stream stream, CancellationToken cancellationToken) => this.binaryWriter is { } writer ? writer(stream, cancellationToken) : ValueTask.CompletedTask; } diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/GetPetsBatchResult.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/GetPetsBatchResult.cs index 75306dc1342..341076e6a4d 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/GetPetsBatchResult.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/GetPetsBatchResult.cs @@ -42,6 +42,18 @@ private GetPetsBatchResult(int statusCode, JsonElement body = default, string? c /// The workspace for building the response value. /// A with status 200. public static GetPetsBatchResult Ok(Petstore.EndToEnd.Server.Models.PetList.Source body, JsonWorkspace workspace) => new(200, Petstore.EndToEnd.Server.Models.PetList.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 200 Ok result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 200. + public static GetPetsBatchResult Ok(Petstore.EndToEnd.Server.Models.PetList.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(200, Petstore.EndToEnd.Server.Models.PetList.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ListPetsResult.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ListPetsResult.cs index 05abc8e86b2..83e75e50f33 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ListPetsResult.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ListPetsResult.cs @@ -56,6 +56,20 @@ private ListPetsResult(int statusCode, JsonElement body, string? contentType, Pe /// The value for the x-next response header. /// A with status 200. public static ListPetsResult Ok(Petstore.EndToEnd.Server.Models.PetList.Source body, JsonWorkspace workspace, Petstore.EndToEnd.Server.Models.JsonInteger.Source xTotalCount = default, Petstore.EndToEnd.Server.Models.JsonString.Source xNext = default) => new(200, Petstore.EndToEnd.Server.Models.PetList.CreateBuilder(workspace, body, 30).RootElement, "application/json", xTotalCount: xTotalCount.IsUndefined ? default : Petstore.EndToEnd.Server.Models.JsonInteger.CreateBuilder(workspace, xTotalCount, 30).RootElement, xNext: xNext.IsUndefined ? default : Petstore.EndToEnd.Server.Models.JsonString.CreateBuilder(workspace, xNext, 30).RootElement); + /// + /// Creates a 200 Ok result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// The value for the x-total-count response header. + /// The value for the x-next response header. + /// A with status 200. + public static ListPetsResult Ok(Petstore.EndToEnd.Server.Models.PetList.Source body, JsonWorkspace workspace, Petstore.EndToEnd.Server.Models.JsonInteger.Source xTotalCount = default, Petstore.EndToEnd.Server.Models.JsonString.Source xNext = default) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(200, Petstore.EndToEnd.Server.Models.PetList.CreateBuilder(workspace, in body, 30).RootElement, "application/json", xTotalCount: xTotalCount.IsUndefined ? default : Petstore.EndToEnd.Server.Models.JsonInteger.CreateBuilder(workspace, xTotalCount, 30).RootElement, xNext: xNext.IsUndefined ? default : Petstore.EndToEnd.Server.Models.JsonString.CreateBuilder(workspace, xNext, 30).RootElement); /// /// Creates a default error result. @@ -65,6 +79,19 @@ private ListPetsResult(int statusCode, JsonElement body, string? contentType, Pe /// The workspace for building the response value. /// A with status default. public static ListPetsResult Default(int statusCode, Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) => new(statusCode, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a default Default result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The HTTP status code. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status default. + public static ListPetsResult Default(int statusCode, Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(statusCode, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/ActivityEvent.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/ActivityEvent.Mutable.cs index 42a51c2cb0c..cdf2e3b910b 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/ActivityEvent.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/ActivityEvent.Mutable.cs @@ -1335,6 +1335,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/ChatChunk.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/ChatChunk.Mutable.cs index 21403ff010a..52ae64e0cf8 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/ChatChunk.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/ChatChunk.Mutable.cs @@ -1285,6 +1285,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Error.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Error.Mutable.cs index 3440c2dbeec..99e08e49fc0 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Error.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Error.Mutable.cs @@ -1206,6 +1206,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsBatchByIdsIds.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsBatchByIdsIds.Mutable.cs index ffa3233d1a0..f468f5699a8 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsBatchByIdsIds.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsBatchByIdsIds.Mutable.cs @@ -1112,6 +1112,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsFilter.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsFilter.Mutable.cs index 16bb46ee378..ae6ca9f7817 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsFilter.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsFilter.Mutable.cs @@ -1362,6 +1362,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsTags.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsTags.Mutable.cs index 161fe914fab..9c5dc35fa43 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsTags.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/GetPetsTags.Mutable.cs @@ -1045,6 +1045,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/NewPet.JsonStringArray.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/NewPet.JsonStringArray.Mutable.cs index 2fa3f64ae19..73902e503c7 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/NewPet.JsonStringArray.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/NewPet.JsonStringArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/NewPet.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/NewPet.Mutable.cs index 1afeafaba3a..e5d0c57b54c 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/NewPet.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/NewPet.Mutable.cs @@ -1579,6 +1579,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.JsonStringArray.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.JsonStringArray.Mutable.cs index a3c5ad208fe..11556858541 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.JsonStringArray.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.JsonStringArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.Mutable.cs index ce2f3da9264..2639bc8ef15 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.Mutable.cs @@ -1756,6 +1756,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.TagsJsonStArray.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.TagsJsonStArray.Mutable.cs index 86a53112da8..56085983691 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.TagsJsonStArray.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/Pet.TagsJsonStArray.Mutable.cs @@ -1051,6 +1051,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PetList.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PetList.Mutable.cs index 94b0fd1ef77..c2129809a86 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PetList.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PetList.Mutable.cs @@ -1056,6 +1056,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PhotoMetadata.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PhotoMetadata.Mutable.cs index 88336c59398..a8143492dcc 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PhotoMetadata.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PhotoMetadata.Mutable.cs @@ -1403,6 +1403,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostAdoptionApplyAccepted.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostAdoptionApplyAccepted.Mutable.cs index cf0261ea26c..9cfc090f24c 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostAdoptionApplyAccepted.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostAdoptionApplyAccepted.Mutable.cs @@ -1276,6 +1276,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostAdoptionApplyBody.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostAdoptionApplyBody.Mutable.cs index 086001989b6..65a25e23c4e 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostAdoptionApplyBody.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostAdoptionApplyBody.Mutable.cs @@ -1530,6 +1530,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.Mutable.cs index 7927171d691..61c25e8b26f 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.Mutable.cs @@ -1361,6 +1361,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs index d7ea512a5c0..fccfa712add 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.Mutable.cs @@ -1062,6 +1062,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs index feca5f893a2..cc2c709115a 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdChatBody.RequiredContentAndRoleArray.RequiredContentAndRole.Mutable.cs @@ -1217,6 +1217,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdPhotosBody.Mutable.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdPhotosBody.Mutable.cs index d8e58165573..dbe6085cf41 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdPhotosBody.Mutable.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/Models/PostPetsByPetIdPhotosBody.Mutable.cs @@ -1285,6 +1285,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ShowPetByIdResult.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ShowPetByIdResult.cs index e57fbc7e9db..c4221055d92 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ShowPetByIdResult.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/ShowPetByIdResult.cs @@ -42,6 +42,18 @@ private ShowPetByIdResult(int statusCode, JsonElement body = default, string? co /// The workspace for building the response value. /// A with status 200. public static ShowPetByIdResult Ok(Petstore.EndToEnd.Server.Models.Pet.Source body, JsonWorkspace workspace) => new(200, Petstore.EndToEnd.Server.Models.Pet.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 200 Ok result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 200. + public static ShowPetByIdResult Ok(Petstore.EndToEnd.Server.Models.Pet.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(200, Petstore.EndToEnd.Server.Models.Pet.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a 404 NotFound result. @@ -50,6 +62,18 @@ private ShowPetByIdResult(int statusCode, JsonElement body = default, string? co /// The workspace for building the response value. /// A with status 404. public static ShowPetByIdResult NotFound(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) => new(404, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 404 NotFound result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 404. + public static ShowPetByIdResult NotFound(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(404, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/StartVetChatResult.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/StartVetChatResult.cs index 93faf8a62ea..cdce24ab04a 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/StartVetChatResult.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/StartVetChatResult.cs @@ -68,6 +68,18 @@ private StartVetChatResult(int statusCode, JsonElement body = default, string? c /// The workspace for building the response value. /// A with status 401. public static StartVetChatResult Unauthorized(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) => new(401, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 401 Unauthorized result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 401. + public static StartVetChatResult Unauthorized(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(401, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/StreamPetActivityResult.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/StreamPetActivityResult.cs index 0ce609452db..d4fe1ad4b0f 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/StreamPetActivityResult.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/StreamPetActivityResult.cs @@ -19,18 +19,22 @@ namespace Petstore.EndToEnd.Server; /// public readonly struct StreamPetActivityResult { - private StreamPetActivityResult(int statusCode, JsonElement body = default, string? contentType = null, StreamPetActivityStreamWriterInvoker? streamWriter = null, object? streamWriterContext = null) + private StreamPetActivityResult(int statusCode, JsonElement body = default, string? contentType = null, StreamPetActivityStreamWriterInvoker? streamWriter = null, object? streamWriterContext = null, bool hasBinaryBody = false, Func? binaryWriter = null) { this.StatusCode = statusCode; this.Body = body; this.ContentType = contentType; this.streamWriter = streamWriter; this.streamWriterContext = streamWriterContext; + this.HasBinaryBody = hasBinaryBody; + this.binaryWriter = binaryWriter; } private readonly StreamPetActivityStreamWriterInvoker? streamWriter; private readonly object? streamWriterContext; + private readonly Func? binaryWriter; + /// Gets the HTTP status code. public int StatusCode { get; } @@ -40,6 +44,9 @@ private StreamPetActivityResult(int statusCode, JsonElement body = default, stri /// Gets the content type for the response body. public string? ContentType { get; } + /// Gets a value indicating whether this result has a raw binary (octet-stream) response body. + public bool HasBinaryBody { get; } + /// Gets a value indicating whether this result has a streaming response body. public bool HasStreamingBody => this.streamWriter is not null; @@ -83,6 +90,14 @@ public void WriteBody(Utf8JsonWriter writer) } } + /// + /// Writes the raw binary (octet-stream) response body to the specified stream. + /// + /// The response stream. + /// The cancellation token. + /// A value task that completes when the body has been written. + public ValueTask WriteBinaryBodyAsync(Stream stream, CancellationToken cancellationToken) => this.binaryWriter is { } writer ? writer(stream, cancellationToken) : ValueTask.CompletedTask; + /// /// Writes the streaming response body. /// diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/SubmitAdoptionApplicationResult.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/SubmitAdoptionApplicationResult.cs index 9e020b3eca9..155262783eb 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/SubmitAdoptionApplicationResult.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/SubmitAdoptionApplicationResult.cs @@ -42,6 +42,18 @@ private SubmitAdoptionApplicationResult(int statusCode, JsonElement body = defau /// The workspace for building the response value. /// A with status 202. public static SubmitAdoptionApplicationResult Accepted(Petstore.EndToEnd.Server.Models.PostAdoptionApplyAccepted.Source body, JsonWorkspace workspace) => new(202, Petstore.EndToEnd.Server.Models.PostAdoptionApplyAccepted.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 202 Accepted result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 202. + public static SubmitAdoptionApplicationResult Accepted(Petstore.EndToEnd.Server.Models.PostAdoptionApplyAccepted.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(202, Petstore.EndToEnd.Server.Models.PostAdoptionApplyAccepted.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a default error result. @@ -51,6 +63,19 @@ private SubmitAdoptionApplicationResult(int statusCode, JsonElement body = defau /// The workspace for building the response value. /// A with status default. public static SubmitAdoptionApplicationResult Default(int statusCode, Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) => new(statusCode, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a default Default result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The HTTP status code. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status default. + public static SubmitAdoptionApplicationResult Default(int statusCode, Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(statusCode, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/UploadPetPhotoParams.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/UploadPetPhotoParams.cs index 5f3c92a5bc0..17f83b8f9f2 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/UploadPetPhotoParams.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/UploadPetPhotoParams.cs @@ -35,4 +35,9 @@ public readonly struct UploadPetPhotoParams /// Gets the request body. /// public Petstore.EndToEnd.Server.Models.PostPetsByPetIdPhotosBody Body { get; init; } + + /// + /// Gets the binary content of the 'file' part. + /// + public ReadOnlyMemory File { get; init; } } diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/UploadPetPhotoResult.cs b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/UploadPetPhotoResult.cs index 941b45dc9d6..202086353b1 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/UploadPetPhotoResult.cs +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Server/UploadPetPhotoResult.cs @@ -42,6 +42,18 @@ private UploadPetPhotoResult(int statusCode, JsonElement body = default, string? /// The workspace for building the response value. /// A with status 201. public static UploadPetPhotoResult Created(Petstore.EndToEnd.Server.Models.PhotoMetadata.Source body, JsonWorkspace workspace) => new(201, Petstore.EndToEnd.Server.Models.PhotoMetadata.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 201 Created result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 201. + public static UploadPetPhotoResult Created(Petstore.EndToEnd.Server.Models.PhotoMetadata.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(201, Petstore.EndToEnd.Server.Models.PhotoMetadata.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Creates a 401 Unauthorized result. @@ -50,6 +62,18 @@ private UploadPetPhotoResult(int statusCode, JsonElement body = default, string? /// The workspace for building the response value. /// A with status 401. public static UploadPetPhotoResult Unauthorized(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) => new(401, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, body, 30).RootElement, "application/json"); + /// + /// Creates a 401 Unauthorized result from a context-threaded body, materialised in a single pass. + /// + /// The type of the context carried by the body. + /// The context-threaded response body. + /// The workspace for building the response value. + /// A with status 401. + public static UploadPetPhotoResult Unauthorized(Petstore.EndToEnd.Server.Models.Error.Source body, JsonWorkspace workspace) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + => new(401, Petstore.EndToEnd.Server.Models.Error.CreateBuilder(workspace, in body, 30).RootElement, "application/json"); /// /// Validates the response body against the schema for the current status code. diff --git a/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/JsonObject.Mutable.cs b/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/JsonObject.Mutable.cs index 919b1f924a9..806da07edbc 100644 --- a/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/JsonObject.Mutable.cs +++ b/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/JsonObject.Mutable.cs @@ -1013,6 +1013,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/Schema.Mutable.cs b/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/Schema.Mutable.cs index a0a485f5a6e..9224f9b5228 100644 --- a/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/Schema.Mutable.cs +++ b/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/Schema.Mutable.cs @@ -1267,6 +1267,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/Schema1.Mutable.cs b/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/Schema1.Mutable.cs index 5b5d08d80f2..ac9213ad600 100644 --- a/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/Schema1.Mutable.cs +++ b/docs/ExampleRecipes/034-OpenApiCallbackServer/Generated/Models/Schema1.Mutable.cs @@ -1495,6 +1495,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/JsonObject.Mutable.cs b/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/JsonObject.Mutable.cs index 3048c240203..fa209df0b37 100644 --- a/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/JsonObject.Mutable.cs +++ b/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/JsonObject.Mutable.cs @@ -1013,6 +1013,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/Schema.Mutable.cs b/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/Schema.Mutable.cs index 4b79ad83789..3e77d542c13 100644 --- a/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/Schema.Mutable.cs +++ b/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/Schema.Mutable.cs @@ -1267,6 +1267,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/Schema1.Mutable.cs b/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/Schema1.Mutable.cs index f0c3d66cf7c..d6a57899ea5 100644 --- a/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/Schema1.Mutable.cs +++ b/docs/ExampleRecipes/035-OpenApiCallbackClient/Generated/Models/Schema1.Mutable.cs @@ -1495,6 +1495,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/Models/LightMeasuredPayload.Mutable.cs b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/Models/LightMeasuredPayload.Mutable.cs index 5b2f5eefceb..8147c373636 100644 --- a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/Models/LightMeasuredPayload.Mutable.cs +++ b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/Models/LightMeasuredPayload.Mutable.cs @@ -1234,6 +1234,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/Models/TurnOnOffPayload.Mutable.cs b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/Models/TurnOnOffPayload.Mutable.cs index c6534104611..4c0f0cf0b36 100644 --- a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/Models/TurnOnOffPayload.Mutable.cs +++ b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/Models/TurnOnOffPayload.Mutable.cs @@ -1234,6 +1234,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/corvusjson-asyncapi.lock b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/corvusjson-asyncapi.lock index c5c8584c64c..0af149698d0 100644 --- a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/corvusjson-asyncapi.lock +++ b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/corvusjson-asyncapi.lock @@ -1,6 +1,6 @@ { "excludeChannels": [], - "generatedAt": "2026-07-12T10:23:24.1441064\u002B00:00", + "generatedAt": "2026-08-05T05:33:52.9811312\u002B00:00", "generatedFiles": [ "TurnOnProducer.cs", "IReceiveLightMeasurementHandler.cs", @@ -24,7 +24,7 @@ "Models/TurnOnOffPayload.WhetherToTurnOnOrOffTheLight.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B681ffbb74b55fe58f5012c08c907f016a7f0dc58", + "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", "includeChannels": [], "mode": "both", "rootNamespace": "Streetlights.Client", diff --git a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/Models/LightMeasuredPayload.Mutable.cs b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/Models/LightMeasuredPayload.Mutable.cs index 5b2f5eefceb..8147c373636 100644 --- a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/Models/LightMeasuredPayload.Mutable.cs +++ b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/Models/LightMeasuredPayload.Mutable.cs @@ -1234,6 +1234,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/Models/TurnOnOffPayload.Mutable.cs b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/Models/TurnOnOffPayload.Mutable.cs index c6534104611..4c0f0cf0b36 100644 --- a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/Models/TurnOnOffPayload.Mutable.cs +++ b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/Models/TurnOnOffPayload.Mutable.cs @@ -1234,6 +1234,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/corvusjson-asyncapi.lock b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/corvusjson-asyncapi.lock index 99777da4120..b3d2d59e80f 100644 --- a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/corvusjson-asyncapi.lock +++ b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/corvusjson-asyncapi.lock @@ -1,6 +1,6 @@ { "excludeChannels": [], - "generatedAt": "2026-07-12T10:23:25.4984658\u002B00:00", + "generatedAt": "2026-08-05T05:33:55.0813639\u002B00:00", "generatedFiles": [ "TurnOnProducer.cs", "IReceiveLightMeasurementHandler.cs", @@ -24,7 +24,7 @@ "Models/TurnOnOffPayload.WhetherToTurnOnOrOffTheLight.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B681ffbb74b55fe58f5012c08c907f016a7f0dc58", + "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", "includeChannels": [], "mode": "both", "rootNamespace": "Streetlights.Client", diff --git a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/Models/LightMeasuredPayload.Mutable.cs b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/Models/LightMeasuredPayload.Mutable.cs index 5b2f5eefceb..8147c373636 100644 --- a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/Models/LightMeasuredPayload.Mutable.cs +++ b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/Models/LightMeasuredPayload.Mutable.cs @@ -1234,6 +1234,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/Models/TurnOnOffPayload.Mutable.cs b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/Models/TurnOnOffPayload.Mutable.cs index c6534104611..4c0f0cf0b36 100644 --- a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/Models/TurnOnOffPayload.Mutable.cs +++ b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/Models/TurnOnOffPayload.Mutable.cs @@ -1234,6 +1234,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/corvusjson-asyncapi.lock b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/corvusjson-asyncapi.lock index 954c4414b9f..6f720fc9a82 100644 --- a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/corvusjson-asyncapi.lock +++ b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/corvusjson-asyncapi.lock @@ -1,6 +1,6 @@ { "excludeChannels": [], - "generatedAt": "2026-07-12T10:23:26.9149379\u002B00:00", + "generatedAt": "2026-08-05T05:33:56.3391374\u002B00:00", "generatedFiles": [ "TurnOnProducer.cs", "IReceiveLightMeasurementHandler.cs", @@ -24,7 +24,7 @@ "Models/TurnOnOffPayload.WhetherToTurnOnOrOffTheLight.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B681ffbb74b55fe58f5012c08c907f016a7f0dc58", + "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", "includeChannels": [], "mode": "both", "rootNamespace": "Streetlights.Client", diff --git a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/Models/LightMeasuredPayload.Mutable.cs b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/Models/LightMeasuredPayload.Mutable.cs index 5b2f5eefceb..8147c373636 100644 --- a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/Models/LightMeasuredPayload.Mutable.cs +++ b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/Models/LightMeasuredPayload.Mutable.cs @@ -1234,6 +1234,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/Models/TurnOnOffPayload.Mutable.cs b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/Models/TurnOnOffPayload.Mutable.cs index c6534104611..4c0f0cf0b36 100644 --- a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/Models/TurnOnOffPayload.Mutable.cs +++ b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/Models/TurnOnOffPayload.Mutable.cs @@ -1234,6 +1234,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/corvusjson-asyncapi.lock b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/corvusjson-asyncapi.lock index 4f1eab50d86..5efb8373683 100644 --- a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/corvusjson-asyncapi.lock +++ b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/corvusjson-asyncapi.lock @@ -1,6 +1,6 @@ { "excludeChannels": [], - "generatedAt": "2026-07-12T10:23:28.2427377\u002B00:00", + "generatedAt": "2026-08-05T05:33:57.5840693\u002B00:00", "generatedFiles": [ "TurnOnProducer.cs", "IReceiveLightMeasurementHandler.cs", @@ -24,7 +24,7 @@ "Models/TurnOnOffPayload.WhetherToTurnOnOrOffTheLight.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B681ffbb74b55fe58f5012c08c907f016a7f0dc58", + "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", "includeChannels": [], "mode": "both", "rootNamespace": "Streetlights.Client", diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/ApiPetsClient.cs b/docs/ExampleRecipes/042-OpenApi20Client/Generated/ApiPetsClient.cs index 984b1553a49..9d500797bf9 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/ApiPetsClient.cs +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/ApiPetsClient.cs @@ -80,6 +80,38 @@ public ValueTask CreatePetAsync(Petstore.V2.Client.Models.New return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); } + /// + /// Create a pet + /// + /// The request body.. + /// A cancellation token. + public ValueTask CreatePetAsync(Petstore.V2.Client.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.V2.Client.Models.NewPet bodyValue = Petstore.V2.Client.Models.NewPet.CreateBuilder(workspace, in body, 30).RootElement; + CreatePetRequest request = new(); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + return SendWithBodyAsyncCore(workspace, request, bodyValue, responseValidationMode, cancellationToken); + } + /// /// Info for a specific pet /// @@ -127,6 +159,40 @@ public ValueTask UpdatePetWithFormAsync(Petstore.V2.C return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => { FormUrlEncodedSerializer.Serialize(bodyValue, stream); return default; }, "application/x-www-form-urlencoded", responseValidationMode, cancellationToken); } + /// + /// Update a pet using form data + /// + /// The petId parameter. + /// The request body.. + /// A cancellation token. + public ValueTask UpdatePetWithFormAsync(Petstore.V2.Client.Models.JsonString.Source petId, Petstore.V2.Client.Models.UpdatePetWithFormFormBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + JsonWorkspace workspace = JsonWorkspace.CreateUnrented(); + Petstore.V2.Client.Models.UpdatePetWithFormFormBody bodyValue = Petstore.V2.Client.Models.UpdatePetWithFormFormBody.CreateBuilder(workspace, in body, 30).RootElement; + Petstore.V2.Client.Models.JsonString PetIdValue = Petstore.V2.Client.Models.JsonString.CreateBuilder(workspace, petId, 30).RootElement; + UpdatePetWithFormRequest request = new(PetIdValue); + + request.Validate(validationMode); + + if (validationMode == ValidationMode.Detailed) + { + using JsonSchemaResultsCollector bodyCollector = JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed); + if (!bodyValue.EvaluateSchema(bodyCollector)) + { + ThrowHelper.ThrowRequestBodyValidationFailed(SchemaValidationDetail.FormatResults(bodyCollector)); + } + } + else if (validationMode != ValidationMode.None && !bodyValue.EvaluateSchema()) + { + ThrowHelper.ThrowRequestBodyValidationFailed(); + } + + return SendWithBodyWriterAsyncCore(workspace, request, (stream, ct) => { FormUrlEncodedSerializer.Serialize(bodyValue, stream); return default; }, "application/x-www-form-urlencoded", responseValidationMode, cancellationToken); + } + /// public ValueTask DisposeAsync() => default; diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/IApiPetsClient.cs b/docs/ExampleRecipes/042-OpenApi20Client/Generated/IApiPetsClient.cs index 8ed46a296a8..3ff9c17f15d 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/IApiPetsClient.cs +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/IApiPetsClient.cs @@ -40,6 +40,17 @@ public interface IApiPetsClient : IAsyncDisposable /// A cancellation token. ValueTask CreatePetAsync(Petstore.V2.Client.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + /// + /// Create a pet + /// + /// The request body.. + /// A cancellation token. + ValueTask CreatePetAsync(Petstore.V2.Client.Models.NewPet.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; + /// /// Info for a specific pet /// @@ -54,4 +65,16 @@ public interface IApiPetsClient : IAsyncDisposable /// The request body.. /// A cancellation token. ValueTask UpdatePetWithFormAsync(Petstore.V2.Client.Models.JsonString.Source petId, Petstore.V2.Client.Models.UpdatePetWithFormFormBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None); + + /// + /// Update a pet using form data + /// + /// The petId parameter. + /// The request body.. + /// A cancellation token. + ValueTask UpdatePetWithFormAsync(Petstore.V2.Client.Models.JsonString.Source petId, Petstore.V2.Client.Models.UpdatePetWithFormFormBody.Source body, CancellationToken cancellationToken = default, ValidationMode validationMode = ValidationMode.Basic, ValidationMode responseValidationMode = ValidationMode.None) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + ; } diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Error.Mutable.cs b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Error.Mutable.cs index b04f9fdd904..806e3877c0f 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Error.Mutable.cs +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Error.Mutable.cs @@ -1206,6 +1206,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/GetPetsTags.Mutable.cs b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/GetPetsTags.Mutable.cs index 77989c9e7cc..c91435b46eb 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/GetPetsTags.Mutable.cs +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/GetPetsTags.Mutable.cs @@ -1050,6 +1050,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/NewPet.Mutable.cs b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/NewPet.Mutable.cs index 73dc3d3591c..c16ab8a8e7c 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/NewPet.Mutable.cs +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/NewPet.Mutable.cs @@ -1215,6 +1215,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Pet.Mutable.cs b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Pet.Mutable.cs index ddad95f591f..974fc678741 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Pet.Mutable.cs +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Pet.Mutable.cs @@ -1276,6 +1276,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Pets.Mutable.cs b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Pets.Mutable.cs index 4eae171e2a5..8ce252da8ae 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Pets.Mutable.cs +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/Pets.Mutable.cs @@ -1056,6 +1056,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates an empty mutable document builder. /// diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/UpdatePetWithFormFormBody.Mutable.cs b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/UpdatePetWithFormFormBody.Mutable.cs index 124ad589f8d..1895f561e24 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/UpdatePetWithFormFormBody.Mutable.cs +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/Models/UpdatePetWithFormFormBody.Mutable.cs @@ -1215,6 +1215,29 @@ public static JsonDocumentBuilder CreateBuilder( return documentBuilder; } + /// + /// Creates and initializes a mutable document from a context-threaded value. + /// + /// The type of the context carried by the value. + /// The JSON workspace. + /// The context-threaded value with which to initialize the builder. + /// The (optional) estimate of the capacity to reserve for the document. + /// An instance of a mutable document initialized with the given value. + public static JsonDocumentBuilder CreateBuilder( + JsonWorkspace workspace, scoped in Source value, int initialCapacity = 30) + #if NET9_0_OR_GREATER + where TContext : allows ref struct + #endif + { + // Create the document builder without a MetadataDb + JsonDocumentBuilder documentBuilder = workspace.CreateBuilder(-1); + ComplexValueBuilder cvb = ComplexValueBuilder.Create(documentBuilder, initialCapacity); + value.AddAsItem(ref cvb); + Debug.Assert(cvb.MemberCount == 1); + ((IMutableJsonDocument)documentBuilder).SetAndDispose(ref cvb); + return documentBuilder; + } + /// /// Creates and initializes a mutable document from a value. /// diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/corvusjson-openapi.lock b/docs/ExampleRecipes/042-OpenApi20Client/Generated/corvusjson-openapi.lock index a02de42b860..49687544668 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/corvusjson-openapi.lock +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/corvusjson-openapi.lock @@ -1,6 +1,6 @@ { "excludePaths": [], - "generatedAt": "2026-07-26T06:40:14.8011200\u002B00:00", + "generatedAt": "2026-08-05T05:33:58.8274409\u002B00:00", "generatedFiles": [ "ListPetsRequest.cs", "ListPetsResponse.cs", @@ -47,7 +47,7 @@ "Models/UpdatePetWithFormFormBody.StatusEntity.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002Bc29652256eb6b1f4d626a7d5146076a3c1f18cd7", + "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", "includePaths": [], "rootNamespace": "Petstore.V2.Client", "specFileHash": "10cc3488325cfb61d4a68bc721ede20dcd9b93cb9533a03071189478971208ca", From 9631c3331e36eda371c5360d25028e47ff9305d1 Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Wed, 5 Aug 2026 06:56:10 +0100 Subject: [PATCH 08/11] Document the release, and cut it as 5.3.0 (#803) The prose that goes with the preceding commits: the AsyncAPI guide gains the responder and the workspace-carrying request, the OpenAPI guide gains the context-threaded request body, and two new documents cover consuming generated types and the performance techniques the generators now rely on. The agent guidance moves with it. Three new skills (context threading, the bytes-to-bytes discipline, typed model construction) and two updated ones describe the conventions these generators emit against, which is exactly the guidance a contributor needs to extend them without reintroducing the closures and round trips this work removed. 5.3.0 rather than 5.2.14 because two changes are breaking: RequestAsync takes a workspace, and a binary response carries its body through the result factory. This repository has shipped breaking changes in a patch before (5.2.7 renamed a generated member for schemas with a property called "create"), but that one reached almost nobody and only on regeneration. These reach every caller of AsyncAPI request/reply and every handler returning a binary response. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq --- .github/copilot-instructions.md | 28 ++- .../skills/corvus-buffer-and-pooling/SKILL.md | 33 ++++ .../corvus-builder-context-threading/SKILL.md | 175 +++++++++++++++++ .github/skills/corvus-bytes-to-bytes/SKILL.md | 177 +++++++++++++++++ .../SKILL.md | 84 +++++++- .../corvus-typed-model-construction/SKILL.md | 159 +++++++++++++++ GitVersion.yml | 2 +- VERSIONHISTORY.md | 16 ++ docs/AsyncApi.md | 41 ++++ docs/BenchmarkGuide.md | 15 +- docs/ConsumingGeneratedTypes.md | 127 ++++++++++++ docs/JsonDocumentBuilder.md | 2 + docs/PerformanceTechniques.md | 9 + docs/README.md | 1 + docs/SourceGenerator.md | 3 + docs/Validator.md | 10 + docs/code-sample-catalog.yaml | 185 +++++++++++------- docs/website/.lycheeignore | 6 +- docs/website/build.ps1 | 4 + .../23-ConsumingGeneratedTypes.yml | 3 + .../24-PerformanceTechniques.yml | 3 + 21 files changed, 997 insertions(+), 86 deletions(-) create mode 100644 .github/skills/corvus-builder-context-threading/SKILL.md create mode 100644 .github/skills/corvus-bytes-to-bytes/SKILL.md create mode 100644 .github/skills/corvus-typed-model-construction/SKILL.md create mode 100644 docs/ConsumingGeneratedTypes.md create mode 100644 docs/website/doc-descriptors/23-ConsumingGeneratedTypes.yml create mode 100644 docs/website/doc-descriptors/24-PerformanceTechniques.yml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3ce93bc001c..f019a71d856 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,7 +14,7 @@ See `docs/UpstreamReview.md` for the component mapping, review process, and the ## Skills Inventory -20 skills in `.github/skills/` provide deep context on specific areas. Copilot loads them on demand. +22 skills in `.github/skills/` provide deep context on specific areas. Copilot loads them on demand. | Skill | Area | |-------|------| @@ -26,6 +26,8 @@ See `docs/UpstreamReview.md` for the component mapping, review process, and the | `corvus-mutable-documents` | JsonWorkspace, JsonDocumentBuilder, mutation, JSON Patch | | `corvus-buffer-and-pooling` | stackalloc/ArrayPool/ThreadStatic pooling patterns | | `corvus-low-alloc-data-structures` | Ref-struct collections, SIMD, hash sets | +| `corvus-bytes-to-bytes` | Killing record<->document string seams; the genuine-leaf proof; the pre-commit allocation self-audit | +| `corvus-builder-context-threading` | Building generated models from UTF-8 spans with no closure (the `Build` form) | | `corvus-numeric-types` | BigNumber, numeric parsing, format selection | | `corvus-ecma-regex` | ECMAScript → .NET regex translation | | `corvus-query-languages` | JSONata, JMESPath, JsonLogic, JSONPath | @@ -105,6 +107,8 @@ The catalog tracks line numbers of code blocks in documentation, instructions, a See the `corvus-build-and-test` skill for TFM targeting, test project mapping, and common build failure diagnosis. +4. **Allocation & honest-decision self-audit.** The commit is where multi-turn work converges, so this gate lives here, not as a per-edit hope. Scan your own diff and **report** (under a `Decisions & deferrals` heading in your message, never buried in a code comment or a design-doc tier) every: (a) managed `string` / `List` / `Dictionary` introduced on a path where bytes are available; (b) non-`static` builder lambda (a closure) where a `static` + `TContext` form exists; (c) reflection-based dispatch; (d) work deferred, skipped, or abandoned; (e) fix that *moved* a cost (a transcode/allocation) elsewhere rather than removing it — give the before/after `file:line`. The words **"genuine leaf"**, **"marginal"**, **"admin-rare"**, **"low-frequency"**, **"pragmatic"** require the two-ended proof in the `corvus-bytes-to-bytes` skill before they may justify a string — they are red flags for work being avoided, not justifications. Prove every warm-path allocation claim with a BenchmarkDotNet `[MemoryDiagnoser]` baseline-vs-new benchmark. "Admin-rare" is not a licence to allocate. + ### Diagnostic discipline These rules apply whenever investigating or fixing a problem. Do not skip them. @@ -466,7 +470,25 @@ Where `` is the benchmark name (e.g., `AnsibleMeta`, `GeoJson`, `CmakePres ### Regenerating C/ benchmarks -After making code generator changes, regenerate all C/ directories: +After making code generator changes, regenerate **all** C/ directories with the batch script: + +```bash +pwsh benchmarks/scripts/Regenerate-CurrentBenchmarks.ps1 +``` + +It builds the generator, then for every `*BenchmarkModels` project reads the root namespace +(`Corvus.Benchmark.Current`) from the existing `C/` output, applies the `Schema` root-type +convention (overridable via the script's `$Overrides` table) against the project's single `*-schema.json`, +cleans `C/`, regenerates with `--engine V5`, and flags any project whose regeneration is **not** additive-only +for review. It never touches B/. See `docs/BenchmarkGuide.md` for the full description. + +> A non-additive (review-flagged) diff is not automatically wrong: a generator change that alters nested +> type-name truncation (e.g. the path-truncation collision fix in `GenerationDriverV5.cs`) legitimately +> renames deeply-nested files for the larger schemas (GeoJson, Ui5, CmakePresets, …), which git pairs as +> delete+add. Confirm the benchmark solution still builds and treat such a sweep as its own commit, distinct +> from any feature change riding alongside it. + +To regenerate a single project by hand (the script automates exactly this per project): ```bash # Clean the C/ directory first (old files cause compilation errors) @@ -476,7 +498,7 @@ Remove-Item -Recurse -Force benchmarks\Corvus.Text.Json.BenchmarkModels\C\ dotnet run --project src\Corvus.Json.CodeGenerator -f net10.0 -c Release -- --rootNamespace Corvus.Benchmark.Current --outputRootTypeName Schema --outputPath benchmarks\Corvus.Text.Json.BenchmarkModels\C --engine V5 ``` -All 37+ benchmark models follow the same pattern — no special cases. (GeoJson previously required special handling for long file paths, but this was fixed by the path truncation collision fix in `GenerationDriverV5.cs`.) +All 37+ benchmark models follow the same pattern — no special cases. ### Running benchmarks diff --git a/.github/skills/corvus-buffer-and-pooling/SKILL.md b/.github/skills/corvus-buffer-and-pooling/SKILL.md index 2d771ad4e44..ed0d07fc3c9 100644 --- a/.github/skills/corvus-buffer-and-pooling/SKILL.md +++ b/.github/skills/corvus-buffer-and-pooling/SKILL.md @@ -68,6 +68,38 @@ Use `NonRecursive` variants only when you can prove the call site is not recursi 4. **Always use `try/finally`** to guarantee the rented array is returned 5. **For fixed-size buffers always ≤ threshold** (e.g., a 128-byte scratch buffer), plain `stackalloc` without pool fallback is acceptable +### Sizing the buffer: `GetMaxByteCount`, not `GetByteCount` + +When the `length` that sizes a transient UTF-8 scratch buffer comes from a `string`/`char` span, size it with +**`Encoding.UTF8.GetMaxByteCount(chars.Length)`** (a multiply — `chars.Length * 3 + 3`), **not** +`Encoding.UTF8.GetByteCount(chars)` (a full transcoding scan of every code point). `GetMaxByteCount` returns a +safe upper bound, so the buffer is never under-sized; the **exact** filled length is whatever the subsequent +`Encoding.UTF8.GetBytes(chars, buffer)` (or your assemble routine) **returns**, and you slice the buffer by that +return value. The pattern is already "rent ≥ requested, then slice to actual", so an over-estimate of a few bytes +costs nothing and skips the scan. Assembling several parts: sum each part's `GetMaxByteCount` plus exact-width +separators. + +```csharp +// ✅ multiply, not a scan — buffer is an upper bound; `written` is the exact length the rest of the code uses +int max = Encoding.UTF8.GetMaxByteCount(text.Length); +byte[]? rented = max > Threshold ? ArrayPool.Shared.Rent(max) : null; +Span buffer = rented ?? stackalloc byte[Threshold]; +int written = Encoding.UTF8.GetBytes(text, buffer); +Use(buffer[..written]); // never the `max` +``` + +A public "how big a buffer do I need" helper that uses this should be **named for the upper bound it returns** +(`GetMaxEncodedLength`, not `GetEncodedLength`) and documented as a safe size, not an exact count — otherwise a +caller may trust it as exact. Pair it with a writer that reports what it actually wrote +(`EncodeToUtf8(out written)`), so the caller sizes from the bound and then trims to the truth. + +**`GetByteCount` (exact) is still required — do NOT switch these to `GetMaxByteCount`:** an *exact* single-shot +output allocation (`new byte[total]` where `total` is the precise serialized size, e.g. `WorkflowPackage.PackPooled`), +a structural **length field** written into a format (a `ushort` entry-name length), an **offset** you then copy at +(`dest[exactPrefixLen..]`), or `IBufferWriter.AppendSpan(n)` / `GetSpan(n)`-style APIs that **commit exactly `n`** +(over-sizing commits uninitialised trailing bytes). The rule is: *transient scratch sliced by the actual written +length* → `GetMaxByteCount`; *a value that is itself exact output, a committed length, or a copy offset* → `GetByteCount`. + ### char buffer variant ```csharp @@ -204,6 +236,7 @@ The bridge between `ArrayPool` and `IBufferWriter`. Wraps an `ArrayBuffer` | Forgetting to slice rented buffer | Processing garbage bytes beyond `length` | Always `buffer.Slice(0, length)` | | Returning rented array twice | Pool corruption | Use `Interlocked.Exchange(ref arr, null)` | | Creating `string` from UTF-8 on a hot path | Unnecessary GC pressure | Use `ReadOnlySpan` throughout, transcode only at the boundary | +| `GetByteCount(chars)` to size a transient scratch buffer | A full transcoding scan where a multiply would do | `GetMaxByteCount(chars.Length)`, slice by the actual `GetBytes` return — but keep `GetByteCount` for an exact output allocation, a length field, or a copy offset | | Using `NonRecursive` threshold in recursive code | Stack overflow | Only use when call site is provably non-recursive | ## Cross-References diff --git a/.github/skills/corvus-builder-context-threading/SKILL.md b/.github/skills/corvus-builder-context-threading/SKILL.md new file mode 100644 index 00000000000..6b0f5421ee6 --- /dev/null +++ b/.github/skills/corvus-builder-context-threading/SKILL.md @@ -0,0 +1,175 @@ +--- +name: corvus-builder-context-threading +description: > + Build a generated Corvus model (response body, array, nested object) from UTF-8 spans inside a hot + loop with NO closure and NO managed-string round-trip, using the generated Build / + CreateBuilder context-threading form (static lambdas + a ref-struct context that + `allows ref struct`, so it can carry ReadOnlySpan). The context is a RefTuple<…> (the + span-capable ValueTuple companion, .NET 9+) instead of a bespoke context struct. Hand the + context-threaded body to the generic boundary overload — a server result factory's + Ok(Source, ws), or a client operation's ...Async(Source) for a + REQUEST body — for a single closure-free materialization (NOT CreateBuilder().RootElement + + non-generic Ok, which re-materializes). Covers the array Build / AddItem nesting, the implicit + ReadOnlySpan -> JsonString.Source / enum Source operators, RefTuple, and the ref-safety gotchas. + USE FOR: projecting a list/page of bytes-backed values into a generated response without a per-item + closure; threading spans into a builder; reaching the Ok boundary; fixing + CS8347/CS8350/CS8156/CS1729 around generated builders. DO NOT USE FOR: building a model from native + (string/int/JsonElement) values (use corvus-typed-model-construction), the broader bytes-to-bytes + anti-pattern (use corvus-bytes-to-bytes), ref-struct callback signatures (use ref-struct-delegates). +--- + +# Building generated models from spans without a closure + +A non-`static` builder lambda (`new T.Source((ref T.Builder b) => b.Create(value: localString))`) +captures its locals into a **closure** — a heap allocation per call — and **cannot capture a +`ReadOnlySpan`** at all (a `ref struct` can't be captured). The generated `Build` / +`CreateBuilder` form solves both: a **`static`** lambda (no capture, no closure) plus a +**ref-struct context** (`where TContext : allows ref struct`) that carries the spans. This is the form +the JMESPath/Jsonata/OpenApi generators emit; use it whenever you project bytes into a model in a loop. + +> **First check you should field-build at all.** Context-threading is the right tool for projecting a *selected subset* +> of a stored document (a summary that hides fields). If the response type is **congruent** with the stored type, don't +> field-build *or* context-thread — wrap the whole document with `From()` (single response and list alike) and transfer +> ownership; that is strictly less work than any per-field build. Decide with the projection decision order in +> `corvus-ctj-handler-implementation` *before* reaching for this skill. (A field-copy list whose single-document sibling +> already uses whole-doc `From()` is a missed collapse, not a context-threading candidate.) + +## The context — `RefTuple`, the span-capable carrier + +Thread the per-item data through a **`RefTuple<…>`** — the span-capable companion to `ValueTuple` (`Corvus.Text.Json`, +.NET 9+). Because its element type parameters are `allows ref struct`, a `RefTuple` element **can be a +`ReadOnlySpan`** — which `ValueTuple` cannot hold at all. So you no longer hand-roll a bespoke context `ref struct` +per call site; `Deconstruct` recovers named locals (`var (page, access) = state;`). Use it for both the array-level and +the per-item context. (On targets older than .NET 9, `RefTuple` does not exist — use a bespoke `ref struct` there.) + +```csharp +// Static build methods match the generated `Build(in TContext, ref Builder)` delegate; the context is a RefTuple. +private static void BuildGrantee(in RefTuple> item, ref Models.ResolvedGrantee.Builder grantee) +{ + ResolvedPrincipal principal = item.Item1; + grantee.Create( // the Create instance overload (TContext inferred from `in item`) + in item, + complete: true, + identity: Models.ResolvedGrantee.AdministratorIdentityArray.Build(in item, BuildIdentity), // nested array, same context + kind: principal.Kind.ToTokenUtf8(), // ReadOnlySpan -> GranteeKind.Source (implicit) + source: "directory"u8, // ReadOnlySpan -> enum Source (implicit) + value: principal.ValueMemory.Span, // ReadOnlySpan -> JsonString.Source (implicit) + label: principal.HasLabel ? (Models.JsonString.Source)principal.LabelMemory.Span : default); +} +``` + +### RefTuple carrying a span (`TContext` with a ref-type element) + +The case `ValueTuple` can't express — put the `ReadOnlySpan`s **directly** in the context and `Deconstruct` them +into named span locals inside the `static` builder (no bespoke struct, no closure, no managed string): + +```csharp +ReadOnlySpan value = principal.ValueMemory.Span; +ReadOnlySpan label = principal.LabelMemory.Span; +var item = new RefTuple, ReadOnlySpan, GranteeKind>(value, label, principal.Kind); + +array.AddItem(Models.ResolvedGrantee.Build(in item, + static (in RefTuple, ReadOnlySpan, GranteeKind> i, ref Models.ResolvedGrantee.Builder g) => + { + var (v, l, kind) = i; // named span locals via Deconstruct + g.Create(complete: true, kind: kind.ToTokenUtf8(), source: "directory"u8, value: v, label: (Models.JsonString.Source)l); + })); +``` + +The generated string/enum `Source` types implicitly convert from `ReadOnlySpan` (`JsonString.Source`, +`GranteeKind.Source`, an enum `SourceEntity.Source`), so `…Utf8()` spans and `"literal"u8` thread straight +into `Create`. Use `static` named methods (above) or `static` lambdas — never a capturing lambda. + +## Arrays — Build + AddItem + +The array-level context is its own `RefTuple` (the page/source list + whatever the loop needs); each item gets its own +`RefTuple`. The two contexts differ, and that's fine — `AddItem` accepts an item whose context type differs +from the array's. + +```csharp +private static void BuildGrantees(in RefTuple, AccessContext, ControlPlaneAccess> s, ref Models.GranteeList.ResolvedGranteeArray.Builder array) +{ + (IReadOnlyList found, AccessContext context, ControlPlaneAccess access) = s; // Deconstruct + foreach (ResolvedPrincipal p in found) + { + if (!context.Admits(AccessVerb.Read, p.Identity)) { continue; } + var item = new RefTuple>(p, access.DescribeUsageScope(p.Identity)); + array.AddItem(Models.ResolvedGrantee.Build(in item, BuildGrantee)); // AddItem(in Source) — the item's TContext may differ from the array's + } +} +``` + +`Array.Build(in ctx, static …)` returns `Array.Source`; the array builder's +`AddItem(in T.Source)` accepts an item whose context type differs from the array's. + +## Reaching the response boundary — `Ok` (closure-free AND single materialization) + +The generated server result factory has a **generic overload** `…Result.Ok(Body.Source body, ws)` +(and `Created`, etc.) that takes the context-threaded body **directly** and materializes it in **one** pass — +it routes through the model's `CreateBuilder(in Source)`. So build the whole body lazily as a +`Body.Source` (via `Body.Build(in ctx, …fields…)`) and hand it straight over: + +```csharp +var state = new RefTuple, AccessContext, ControlPlaneAccess>(found, context, this.access); +var body = Models.GranteeList.Build( + in state, + grantees: Models.GranteeList.ResolvedGranteeArray.Build(in state, BuildGrantees)); +return SearchGranteesResult.Ok(body, workspace); // Ok — TContext inferred from `body`; one CreateBuilder pass +``` + +**Do NOT** pre-materialize with `CreateBuilder(…).RootElement` and pass the immutable to the *non-generic* +`Ok(immutable, ws)`: that re-materializes the body a **second** time (the immutable is rebuilt into the response +workspace). It is the slower, wrong shape. Hand the lazy `Source` to `Ok` instead — closure-free +*and* a single pass (the measured ~2.0 KB / fastest floor; see `GranteeProjectionBenchmarks`). + +Why it works: `Source` does NOT implicitly convert to the non-generic `Source`, which is why the result type +exposes a `Ok` overload at all — it is the generic counterpart of `Ok(Source, ws)`. Any-schema response bodies +resolve to the universal `JsonElement`, which has the same `CreateBuilder(in Source)` overload, so +`Ok` works for them too. + +## Reaching the request boundary — the client's generic operation overload + +A generated **client** has the mirror of `Ok`: for every operation whose request body is an object or array, +it emits `…Async(Body.Source body, …)` alongside the non-generic `…Async(Body.Source body, …)`. +Build the body lazily and hand it straight over, exactly as on the server: + +```csharp +ClaimRequest.Source> request = ClaimRequest.Build( + in hostedVersions, + ClaimRequest.HostedVersionsEntityArray.Build(in hostedVersions, static (in IReadOnlyCollection vs, ref ClaimRequest.HostedVersionsEntityArray.Builder b) => + { + foreach (string v in vs) + { + b.AddItem(v); + } + }), + leaseSeconds); + +return this.ReadClaimAsync(this.claims.ClaimRunAsync(request, cancellationToken)); // ClaimRunAsync +``` + +**The calling method must not be `async`.** `Source` is a ref struct holding its context, so it cannot live +in an async state machine. Build the source and *start* the send in a plain method that returns the `ValueTask`, and do +the awaiting in a separate async continuation — which is the shape the generated client itself uses, and for the same +reason. + +A scalar request body has no `Source` and only gets the non-generic overload, exactly as on the response side. + +## Gotchas + +| Symptom | Cause | Fix | +|---|---|---| +| **CS1729** `'T.Source' does not contain a constructor that takes 1 argument` (from another assembly, e.g. a benchmark) | the `new T.Source((ref Builder b) => …)` WriteAction ctor is **`internal`** | use the public factory `T.Build((ref T.Builder b) => …)` (same-assembly handler code can use `new(...)`) | +| **CS8347 / CS8350 / CS8156** on `array.AddItem(T.Build(strA, strB))` | the lazy `Build(in field, …)` holds a **ref** to its `in` args; a `string -> Source` arg is a stack temporary, so the lazy item can't be appended | for a genuine string-leaf sub-array (e.g. the `{dimension,value}` grants), use the WriteAction form `new T.Source((ref Builder b) => b.Create(strA, strB))` — a closure there is fine (the strings are the leaf), or thread the value via the context and use a span | +| ternary `label: hasLabel ? span : default` won't infer a common type | `ReadOnlySpan` vs `JsonString.Source default` | cast the span branch: `hasLabel ? (Models.JsonString.Source)span : default` | +| ternary `seconds: has ? (long)x : default` compiles but sends the WRONG VALUE | the branches' natural common type is `long`, so `default` becomes `0L` — NOT an undefined `Source`. The property is emitted as `0` rather than omitted, and a schema `minimum` refuses it at runtime | cast the value branch to the Source type — `has ? (Models.T.Source)(long)x : default` — or assign through an explicitly typed local so the conditional is target-typed. **This is the dangerous sibling of the row above**: a span branch fails to compile, a primitive branch compiles and lies | +| `Build(field: v)` result "may expose variables outside their scope" when **returned** from a helper | `Build` takes fields by `in`; its `Source` cannot escape the method | consume it in place (pass straight to the consumer / `AddItem`), never `return` it — see corvus-typed-model-construction | +| a nested object's array field built with one context but the parent `Create` wants another | `Create(in ctx, …, nested: Nested.Source, …)` takes the nested value at the **parent's** `TContext` — same type | thread the **same** context into the nested `Build` (`Build(in item, …)`); when two call sites carry contexts that differ only in one element (e.g. `RefTuple` vs `RefTuple`) a **single generic** static method over that element works — `BuildIdentity(in RefTuple item, ref Builder) where TIdentity : allows ref struct` — method-group inference fixes `TIdentity` from the `in item` argument at each call site (verified; no per-context duplication needed) | +| `Ok` not emitted for a response whose body is scalar (`type: string`) | only object/array bodies carry a `Source`; the generator gates the generic overload on that | expected — a scalar body uses the non-generic `Ok(Source, ws)`; there is nothing to thread | + +## Cross-References + +- `corvus-typed-model-construction` — the factory overview (`Build` / `CreateBuilder` / `CreateBuilder`), the `JsonElement.Source` ref-safety trap, discriminated unions. Read it first for the basics; this skill is the spans-in-a-loop-no-closure advanced case. +- `corvus-bytes-to-bytes` — when/why to thread spans at all (the record<->document anti-pattern + the genuine-leaf proof). +- `ref-struct-delegates` — the named `ref struct` delegate types the build callbacks use. +- `corvus-mutable-documents` — `JsonWorkspace` / `JsonDocumentBuilder` and the materializing `CreateBuilder` path. \ No newline at end of file diff --git a/.github/skills/corvus-bytes-to-bytes/SKILL.md b/.github/skills/corvus-bytes-to-bytes/SKILL.md new file mode 100644 index 00000000000..1de793035e6 --- /dev/null +++ b/.github/skills/corvus-bytes-to-bytes/SKILL.md @@ -0,0 +1,177 @@ +--- +name: corvus-bytes-to-bytes +description: > + Eliminate hand-rolled POCO record<->document string seams — types/paths that materialize a + managed string (or List/Dictionary) between a bytes SOURCE (a parsed UTF-8 body, a DB + column, a directory/HTTP response) and a bytes SINK (a serialized JSON document, a generated + model, a SecurityTagSet, a DB write). The fix recipe is bytes-native default + opt-in string + extensibility, plus a decision procedure for telling a genuine string leaf from work you are + avoiding. USE FOR: deciding whether a string on a hot/warm path is justified; converting a + bytes->string->bytes u-turn to bytes-to-bytes; reviewing your own diff for hidden string + materializations before committing; building an identity/tag set from a UTF-8 source. + DO NOT USE FOR: the mechanics of building a generated model from spans without a closure + (use corvus-builder-context-threading), raw pooling/stackalloc patterns + (use corvus-buffer-and-pooling), constructing models from native values + (use corvus-typed-model-construction). +--- + +# Bytes-to-bytes: killing record<->document string seams + +A **record<->document string seam** is the most expensive allocation anti-pattern in this codebase +and the one most often re-introduced. It is a value that arrives as **bytes**, is materialized into +a managed **string** (or `List` / `Dictionary` / a per-row POCO), and is then +written back out as **bytes** — a u-turn through the managed heap. Both ends are bytes; the string is +pure overhead, plus a closure if a non-`static` builder lambda is involved. + +The cost is real and measured: a per-row row-security scan was **25.78 KB -> 1.56 KB (0.06x)** once the +filter walked the persisted UTF-8 instead of `SecurityTagSet.ToList()` per row; a directory grantee +projection was **5.88 KB -> 2.01 KB (0.34x)** once value/label flowed as spans instead of `GetString`. + +## The genuine-leaf proof (run this BEFORE you write a string) + +The words **"genuine leaf"**, **"marginal"**, **"admin-rare"**, **"low-frequency"**, **"pragmatic"**, +**"good enough"**, **"consistency win"**, **"too fragile"**, **"edge case"**, **"one X's worth"** are red +flags: they are usually a justification reached for *first*, to license skipping the bytes mechanism. Before any of them excuses a managed string on a +warm/hot path, write a **two-ended trace** and check both ends: + +| | Source end | Destination end | +|---|---|---| +| **String IS the leaf only if** | a string-typed external API: `ClaimsPrincipal` claims, the Novell LDAP client (`LdapAttribute.StringValue`), a `BsonValue.AsString` (the driver pre-materialized it) | a string-typed sink: an `HttpRequestMessage` URI, an LDAP filter, an HTTP `Authorization` header, a `string`-keyed store (`store.GetAsync(string)`), a human-facing audit/error message | + +**If EITHER end is bytes/spans and a documented mechanism exists, it is NOT a leaf — it is work you +are avoiding.** A "constructed" value (e.g. `first + " " + last`, a `prefix + dimension` key) is never +a leaf: assemble it into a pooled/stack UTF-8 buffer, not a string. The mechanism always exists: + +| Need | Mechanism | Skill | +|---|---|---| +| Build a tag set from UTF-8 | `SecurityTagSet.Build` + `IdentityBuilder.Add(span)` | this skill | +| Build a key `prefix + dimension` | `stackalloc`/`ArrayPool` + `Encoding.UTF8.GetBytes(prefix, key)` | corvus-buffer-and-pooling | +| Write UTF-8 into a generated model, no closure | `T.Build` / `CreateBuilder` | corvus-builder-context-threading | +| Compare UTF-8 vs a fixed string | pre-encode the string to `u8`/`byte[]` once, `SequenceEqual` | this skill | +| Re-transcode a holder's bytes | `Utf8JsonWriter` -> `TagSet.CopyFromJsonArray` | corvus-mutable-documents | + +### When the excuse is "too fragile" / "edge case" (a self-imposed invariant) + +Sometimes the string (or the `List` + per-item concat that builds it) isn't defended as a *leaf* but as +too risky to remove — "the sort has a `'-'` vs `'.'` edge case needing a careful comparer", "I'd have to +reproduce the exact output bytes", "it's only one row's worth". That difficulty is almost always +**self-imposed**: it comes from preserving the existing code's *incidental* output shape (an entry/element +order, a container layout, an insertion order), not from the task itself. Before you skip it: (1) name the +exact invariant making it fragile; (2) read the **consumer** — the reader, the sink, the comparer — and +check whether it actually requires that invariant. An order-independent reader (finds entries by +name/key, not position), a sink that re-normalises/re-canonicalises, or a content-addressed-by-hash +artifact does **not**. When no consumer requires it, the invariant is incidental — drop it and the clean +low-alloc form falls out. Example: `WorkflowPackage.PackPooled` sorts sources by **key** in a pooled +scratch array and emits a fixed bucket order (workflow, sources, metadata), writing each entry name as +UTF-8 directly (`"sources/"u8` + key + `".json"u8`, length back-patched) — instead of reproducing the old +full-name sort, which removed the `List` + per-source name string outright (`PackCanonicalPackage` +0.73 -> 0.49 KB, scales per source). Treating incidental output shape as a contract is the +anchoring-on-existing-code failure (deriving the replacement from what the old code happened to do) +applied to behaviour instead of style. + +### Opaque tokens are a carrier seam too ("store-minted, so it stays a string" is a rationalization) + +An opaque pagination/continuation token round-trips between two UTF-8 ends — emitted into a JSON +response, carried back in the next JSON request (a CTJ `JsonString`) — so "it is store-minted, not domain +data, so it stays a string" is a genuine-leaf rationalization: both ends are bytes. Encode it bytes-native +with `System.Buffers.Text.Base64Url` (`GetEncodedLength` + `EncodeToUtf8` straight into the destination; +`GetMaxDecodedLength` + `DecodeFromUtf8` from the request's `pageToken.GetUtf8String().Span`) — no +`EncodeToString`/`DecodeFromChars` char detour, and no per-part `ToString()`/concat/`GetBytes` (assemble +the key into a `stackalloc`/`ArrayPool` UTF-8 buffer with a separator byte). The token must survive into +the response, but **owned ≠ GC**: pool it in a disposable carrier rather than minting a `string` (the only +*necessarily*-GC form). The cross-root request→store `JsonString` is bridged with `JsonString.From(...)` +(free rewrap). The lifetime that governs *emitting* it is the deferred-body rule — see +`corvus-ctj-handler-implementation`. + +## The fix recipe — bytes-native default + opt-in string + +Mirror `IdentityBuilder.Add(ReadOnlySpan key, ReadOnlySpan value)` (the fast path) vs +`Add(ReadOnlySpan key, string value)` (the opt-in for a value a deployment genuinely computed +through a string API). The bytes path is *the* path; the string path is the explicit, documented +exception — never the default. + +```csharp +// The bytes-native seam every adapter/handler builds an identity through. The value span is the +// unescaped UTF-8 the source already holds (reader.GetUtf8String().Span); no managed string per tag. +SecurityTagSet identity = SecurityTagSet.Build( + in state, + static (ref IdentityBuilder builder, in TState s) => + { + builder.Add("sys:tenant"u8, s.TenantSpan); // span path + builder.Add("sys:sub"u8, s.SubSpan); + }); +``` + +A deferred holder (`SecurityTagSet`, `TagSet`) is the bytes form. Read it as `((JsonElement)x).GetUtf8String()` +(a `ref struct`; cannot cross an `await`) or `.TakeOwnership(out byte[]? rented)` for owned +`ReadOnlyMemory` that can. Transcode to a `string` ONLY at the genuine leaf (e.g. +`Encoding.UTF8.GetString(value.Span)` for a `string`-keyed store key). + +### Dual constructor for an extension-point contract + +When a type is a deployment-authored extension point (e.g. `ResolvedPrincipal`, returned by an +`IDirectoryIdentityMapper`), give it BOTH constructors — span for the built-in fast path, string for +mapper ergonomics — and decode on demand for string consumers: + +```csharp +public readonly struct ResolvedPrincipal +{ + private readonly ReadOnlyMemory value; // owned UTF-8 + + // Span ctor — the built-in adapters' bytes-to-bytes fast path (copies the transient span to owned). + public ResolvedPrincipal(GranteeKind kind, ReadOnlySpan value, ReadOnlySpan label, bool hasLabel, SecurityTagSet identity) { /* value.ToArray() */ } + // String ctor — the ergonomic path a deployment mapper (or LDAP, a genuine string leaf) uses. + public ResolvedPrincipal(GranteeKind kind, string value, string? label, SecurityTagSet identity) { /* Encoding.UTF8.GetBytes(value) */ } + + public ReadOnlyMemory ValueMemory => this.value; // server: bytes-to-bytes into the response + public string Value => Encoding.UTF8.GetString(this.value.Span); // CLI/tests: decode on demand +} +``` + +### Constructed value -> pooled buffer (never a string) + +```csharp +// A "first last" display name has no single source span — assemble it into a REUSED pooled buffer +// (rented once outside the row loop, grown on demand), not Encoding.UTF8.GetString(...) + string concat. +int needed = first.Length + 1 + last.Length; +if (labelBuffer is null || labelBuffer.Length < needed) +{ + if (labelBuffer is not null) { ArrayPool.Shared.Return(labelBuffer); } + labelBuffer = ArrayPool.Shared.Rent(needed); +} +first.CopyTo(labelBuffer); +labelBuffer[first.Length] = (byte)' '; +last.CopyTo(labelBuffer.AsSpan(first.Length + 1)); +ReadOnlySpan labelSpan = labelBuffer.AsSpan(0, needed); // the consumer copies it before the next row reuses the buffer +``` + +## Walking a holder's UTF-8 instead of materializing it + +When a path must *evaluate* over a tag set (not just copy it), parse the persisted UTF-8 once into a +pooled scratch + slice table (`SecurityTagSpanSort.Parse`), compare on spans, and pre-encode the fixed +side once. The string evaluator becomes a thin adapter that delegates via `FromTags`, so the existing +tests lock the bytes evaluator's semantics. See `SecurityRule.EvaluateAll(in SecurityTagSet, in Utf8ClaimSet)` +and `SecurityRuleEvaluation.cs`. Ordinal UTF-8 `SequenceEqual` IS ordinal string equality for the same +code points. + +## Pre-commit self-audit (mandatory — see copilot-instructions.md pre-commit gate) + +Before committing, scan your own diff and **report** each: + +- [ ] New managed `string` / `List` / `Dictionary` on a path where bytes are available? -> apply the proof; fix or flag. +- [ ] New non-`static` builder lambda (a closure) where a `static` + `TContext` form exists? -> corvus-builder-context-threading. +- [ ] Reflection-based dispatch where a virtual/span seam exists? -> remove it. +- [ ] Any work deferred / skipped / relocated? -> surface it under `Decisions & deferrals` + a task, never a buried comment. +- [ ] Did a fix MOVE a cost (a transcode/alloc) elsewhere rather than remove it? -> state the before/after `file:line`. + +Prove every warm path with a BenchmarkDotNet `[MemoryDiagnoser]` benchmark (baseline old vs new) — see +`SecurityFilterScanBenchmarks` / `GranteeProjectionBenchmarks` for the shape. "Admin-rare" is not a +licence to allocate. + +## Cross-References + +- `corvus-builder-context-threading` — write UTF-8 spans into a generated model with no closure (the `Build` form). +- `corvus-buffer-and-pooling` — the `stackalloc`/`ArrayPool`/thread-local pooling the span paths rent from. +- `corvus-typed-model-construction` — constructing generated models from native values (the non-span common case). +- `ref-struct-delegates` — why the build callbacks are named `ref struct` delegates, not `Func<>`/`Action<>`. +- `corvus-parsed-documents-and-memory` — `GetUtf8String()` / `TakeOwnership` / the deferred-holder memory model. diff --git a/.github/skills/corvus-ctj-handler-implementation/SKILL.md b/.github/skills/corvus-ctj-handler-implementation/SKILL.md index 73e7733a25e..8abfe2033a8 100644 --- a/.github/skills/corvus-ctj-handler-implementation/SKILL.md +++ b/.github/skills/corvus-ctj-handler-implementation/SKILL.md @@ -106,6 +106,66 @@ return OkResult(body: (TodoItem)mutableItem, workspace); **Note:** The CTJ002 analyzer may incorrectly flag this cast as unnecessary (see issue #775). Suppress or ignore — the cast is required. +### Emitting store-produced data the body holds past the handler (the deferred-body rule) + +A response body's `Source` is **not** consumed when the handler returns — it is re-read later, during response validation/serialization (`ValidateBody`). So anything the body references must stay valid until *then*, not just until the handler exits: + +- **A pooled document the body projects** must be handed to the workspace — single value `workspace.TakeOwnership(doc)`; a page/list `PooledDocumentList.TransferOwnershipTo(workspace)` — so it outlives the handler. `using`-disposing it at handler return is a use-after-free (`ObjectDisposedException` at serialization). Inspect-and-discard checks that never reach the body are the only safe `using`. +- **A pooled/transient scalar written via a builder** (e.g. an opaque continuation token emitted `(JsonString.Source)tokenUtf8.Span`): a builder lambda **cannot capture a `Span`**, and a `stackalloc`/just-disposed buffer would dangle. Carry it as a **capturable `ReadOnlyMemory` owned by a disposable carrier** (the page) that the handler `using`-scopes: the synchronous `Ok(...)`/`Build(...)` copies the bytes into the response document while the carrier is alive; the carrier's `Dispose` returns the pooled buffer afterwards. Centralise the rent+encode in a page `Create(...)` factory so every backend call site stays a leak-free one-liner. + +A primitive value written via `b.Create(prop: value)` is copied into the response document during the build — safe for a transient *only if* the build is synchronous and the value is alive throughout it, which the disposable-carrier pattern guarantees. + +**Scalar-copied vs `From`-wrapped (the precise rule).** `ResultType.Ok(body, workspace)` runs the body `Source` closure **synchronously** inside `CreateBuilder(workspace, body).RootElement`, so a `using`-scoped carrier is still alive when the closure runs. Within that closure two things behave differently at serialization time: +- A **scalar** written via `b.Create(token: (JsonString.Source)page.NextPageToken.Span)` is **copied** into the response document during `CreateBuilder` → the carrier's pooled buffer can be disposed right after `Ok` returns (read `.Span` *inside* the closure, never as a captured `Span` local). This is why the page can be `using`-scoped. +- A **`From(externalDoc)`-wrapped sub-document** (e.g. `CatalogVersionSummary.From(version)` over a pooled store doc) is **referenced**, not copied → it is re-read at post-handler serialization, so its backing must be handed over with `PooledDocumentList.TransferOwnershipTo(workspace)`. A page can do **both**: `TransferOwnershipTo` its wrapped documents *and* `using`-dispose itself to return the pooled token buffer (the token was already copied). + +**A carrier that owns a pooled buffer must be a `sealed class : IDisposable`, never a `readonly record struct`.** A record struct is copy-by-value, so two copies share one rented `byte[]` and `Dispose` double-returns it (pool corruption). A record struct may own a `PooledDocumentList` (a class — idempotent dispose) and get away with it, but the moment it also owns a rented buffer, convert it to a class (mirror `SourceCredentialPage`/`ObservedIdentityPage`; `WorkflowRunPage`/`CatalogPage` were converted from record structs for exactly this). + +## Response projection: decision order (run this BEFORE writing a projection) + +A response body is built from a stored document. Pick the mechanism in **this order** — most projections are over-built because the wrong rung was chosen, and that over-building is pure allocation (managed strings, per-item closures, rebuilt arrays): + +1. **Is the response type *congruent* with the stored type?** (Same fields, same required set, nothing the response must hide.) If yes → **whole-document `From()`**, for the single-document response *and* the list: + - Single: `return GetXResult.Ok(Models.XView.From(doc.RootElement), workspace)` + `workspace.TakeOwnership(doc)`. + - List: per item `array.AddItem(Models.XView.From(item))` + `page.TransferOwnershipTo(workspace)`. + - **The single-document and list responses MUST use the same mechanism.** A field-copy *list* whose single-document sibling is a whole-doc `From()` is a missed collapse — check the single-doc site first; if it wraps with `From()`, the list collapses too (per item + ownership transfer). (This was the `ToRuleSource`/`ToViewSource` bug: the list field-copied while create/get/update already wrapped.) +2. **Must the response hide stored fields?** (e.g. a summary that drops an internal `scopes`/`expiresAt`/`usageTags`.) *Only then* field-select — and carry each selected leaf **bytes-native**: `Models.JsonString.From(stored.RawAccessor)` / `Models.JsonDateTime.From(stored.RawAccessor)`, never `(string)stored.X` or the nullable `XxxValue`/`XxxOrNull` accessors (see below). +3. **Build the list/object closure-free.** Thread the context (`Build`), don't capture in a lambda — see `corvus-builder-context-threading`. The list `Build` is ref-scoped to its `in` argument, so build it **inline in the handler**, not in a returned helper. + +### The `XxxOrNull` / `XxxValue` anti-pattern (the realising ternary) + +Never feed a generated builder from the nullable convenience accessors: + +```csharp +// ❌ XxxValue/XxxOrNull DISCARD Undefined-ness → the ternary's `default` writes a REAL value +// (DateTimeOffset default = the EPOCH; string = null/empty), not an omitted field. Latent bug. +lastUpdatedAt: stored.UpdatedAtValue is { } v ? v : default, +description: stored.DescriptionOrNull is { } d ? d : default, + +// ✅ bare From() of the RAW accessor PROPAGATES Undefined → an absent field is OMITTED, no ternary +lastUpdatedAt: Models.JsonDateTime.From(stored.LastUpdatedAt), +description: Models.JsonString.From(stored.Description), +``` + +The `if (x.IsNotUndefined()) { local = From(x); }` guard is the same smell — redundant, since `From()` already propagates Undefined. + +### Up-front sweep — grep when you START projection work (find ALL instances at once, not serially) + +```bash +# nullable-accessor-into-builder smell (then READ each hit to classify — see "not the smell" below) +grep -rnE "OrNull is \{ ?\}|Value is \{ ?\}" src/ --include=*.cs | grep -v /Generated/ +# closure-based projection (prefer Build context-threading). Matches the builder-lambda +# signature `((ref T.Builder x)` so it catches BOTH `new T.Source((ref …)` and the `=> new((ref …)` +# shorthand (a `new Models\.…Source` pattern misses the shorthand); does not match static BuildX(in ctx, ref …). +grep -rnE "\(\(ref [A-Za-z].*\.Builder " src/ --include=*.cs | grep -v /Generated/ +``` + +**Not the smell** (leave alone): nullable accessors used as query-filter predicates, expiry comparisons (`ExpiresAtValue is { } e && e <= now`), hand-written `Utf8JsonWriter` envelope projections to a *different* shape (unix-millis dates, table columns), and `string?`→`Source` CLI-settings bridges (a plain `string?`, no CTJ Undefined concept). + +### Distrust a "can't use `From` here" comment + +A comment that rules out a whole-doc `From()` ("the batch is disposed before serialization, so a `From()` wrap would dangle") may predate the ownership-transfer pattern. Re-derive against the current rule: `TransferOwnershipTo(workspace)` keeps the batch alive, so the list **can** wrap. Trusting that one stale comment hid the `ToViewSource` collapse for an entire campaign — see `corvus-builder-context-threading` and the memories on verifying before declaring impossible. + ## Zero-Copy Cross-Namespace Values: From\() Every generated type has a static `From()` method that reinterprets backing memory as a different type — zero allocation: @@ -120,6 +180,16 @@ Directory.JsonUuid.From(accepted.Ticket) Use `From()` whenever passing values between types from different generated namespaces. Within the same namespace, implicit conversion works directly. +`From()` **propagates undefined**: `From(source)` of an undefined `source` is an undefined target (it reinterprets the same backing memory, which is still undefined). So do **not** guard it with an `IsNotUndefined()` ternary when the consumer already treats undefined as absent: + +```csharp +// ❌ redundant — From() of an undefined PageToken is already an undefined JsonString +JsonString pageToken = parameters.PageToken.IsNotUndefined() ? JsonString.From(parameters.PageToken) : default; + +// ✅ undefined flows straight through; the store's `if (pageToken.IsNotUndefined())` sees it either way +JsonString pageToken = JsonString.From(parameters.PageToken); +``` + ## Array Enumeration Generated array types do NOT implement `IEnumerable`. You must call `EnumerateArray()`: @@ -162,19 +232,18 @@ private static bool TryFindItem(TodoList list, JsonString todoId, out TodoItem f For optional properties that may be undefined: ```csharp -// Check before accessing +// Mutating a builder: guard the Set (you don't want to overwrite with undefined) if (!item.DueDate.IsUndefined()) { mutableItem.SetDueDate(update.DueDate); } -// Ternary for Source construction -email: user.HasValue && !user.Value.Email.IsUndefined() - ? Directory.JsonEmail.From(user.Value.Email) - : default +// Projecting INTO a builder: do NOT ternary-guard From() — it propagates Undefined, +// so an absent field is omitted with no ternary (see "The XxxOrNull/XxxValue anti-pattern"). +email: Directory.JsonEmail.From(user.Email) ``` -`default` for a `Source` produces an undefined value (omitted from output). +`default` for a `Source` produces an undefined value (omitted from output) — but you rarely need to write `default` explicitly, because a bare `From()` of an undefined source already yields it. Reach for a ternary only when the *true* branch is a non-CTJ type (a C# `string?`/`DateTimeOffset` with no source element to wrap — e.g. a CLI settings value); then cast the true branch to the `Source` type so `default` stays `default(Source)`. ## AddItem on Array Builders @@ -216,6 +285,9 @@ await blob.UploadAsync(ms, options, ct); | `From()` within same namespace | Unnecessary — implicit conversion works | Remove `From()`, use value directly | | Returning `T?` from lookup helpers | Forces boxing/nullable overhead on struct types | Use `bool TryX(out T result)` pattern | | Using `System.Text.Json.Utf8JsonWriter` | Wrong writer type; won't serialize CTJ types | Use `Corvus.Text.Json.Utf8JsonWriter` | +| `XxxOrNull`/`XxxValue is { } v ? v : default` into a builder | Nullable accessor discards Undefined → `default` writes the epoch/empty, not an omitted field | Bare `Models.JsonX.From(stored.RawAccessor)` (propagates Undefined) | +| Field-copying a list whose single-doc sibling uses whole-doc `From()` | Over-allocation (managed strings + closures) for a congruent type | Collapse the list to per-item `From()` + `TransferOwnershipTo` | +| Capturing-lambda `new X.Source((ref b) => …)` per list item | A heap closure per item/array/list | `Build` context-threading (inline in the handler) | ## Cross-References diff --git a/.github/skills/corvus-typed-model-construction/SKILL.md b/.github/skills/corvus-typed-model-construction/SKILL.md new file mode 100644 index 00000000000..821f6ac52ee --- /dev/null +++ b/.github/skills/corvus-typed-model-construction/SKILL.md @@ -0,0 +1,159 @@ +--- +name: corvus-typed-model-construction +description: > + Construct instances of generated Corvus strongly-typed models (DTOs, request/response + bodies, discriminated-union variants) allocation-free, without composing or parsing JSON. + Covers the Create() / Build() / CreateBuilder() / CreateBuilder() factories, + threading state through a context tuple with STATIC lambdas to avoid closures, the + JsonElement.Source ref-safety trap and its fix, avoiding interim materializations, and + building discriminated-union bodies. USE FOR: building a generated model from native + values to pass to a generated client/handler, embedding a JsonElement/array value into a + generated object, building union variants, fixing CS8168/CS8347/CS8350 around builders. + DO NOT USE FOR: mutating an arbitrary JsonElement document (use corvus-mutable-documents), + read-only parsing (use corvus-parsed-documents-and-memory), ref-struct callback signatures + (use ref-struct-delegates). +--- + +# Typed Model Construction (allocation-free) + +Generated Corvus types (`[JsonSchemaTypeGenerator]` / `openapi-client` / `openapi-server` +models) are built through value-typed factories that write **once** into a pooled arena. The +goal is to go from native values (`int`, `string`, a `JsonElement`) straight into the model +with **no interim string/JSON, no `ParseValue`, and no closure allocation**. + +## Never compose-then-parse + +```csharp +// ❌ interim string + parse + re-validate. Do not build request/response bodies this way. +var body = $$"""{"mode":"Rewind","targetCursor":{{n}}}"""; +Models.ResumeRequest req = JsonElement.ParseValue(body); +``` + +Use the generated factories instead. + +## The factories — pick by situation + +| Factory | Returns | Use when | +|---|---|---| +| `T.Build(field: v, …)` | `T.Source` (lazy) | **The default.** Native values — including a `JsonElement`/array — passed straight in. Hand the `Source` to a consumer that materializes it once (a generated client/handler method). No workspace, no closure. | +| `T.Build(static (ref T.Builder b) => b.Create(…))` | `T.Source` (lazy) | No fields to set (e.g. a `const`-only union variant), or you need imperative logic. Lambda MUST be `static`. | +| `T.CreateBuilder(ws, field: v, …)` then `.RootElement` | `T` (immutable, in `ws`) | Only when you actually need a *materialized* value (e.g. to read it back, or store it). Not needed just to pass to a consumer. | +| `T.CreateBuilder(ws, ctx, static (in TContext ctx, ref T.Builder b) => …)` then `.RootElement` | `T` | Materializing while threading runtime values into a builder loop/conditional. The form the JMESPath/Jsonata/OpenApi generators emit. | +| `T.CreateBuilder(ws, in T.Source body)` then `.RootElement` | `T` | Materializing a body you already assembled closure-free as a `Source`. Usually you don't call this directly — the generated `…Result.Ok(T.Source body, ws)` does, in a **single** pass. See `corvus-builder-context-threading`. | + +**Default to `T.Build(field: v, …)`** — it is lazy and the consumer materializes it directly +into its own buffer (one pass, no interim document). You almost never need `CreateBuilder` + +`.RootElement`; reach for it only when you genuinely need a materialized value in hand. + +**Reaching a server result factory closure-free:** build the body as a context-threaded `T.Source` +(`T.Build(in ctx, …)`, the context a `RefTuple`) and hand it to the generic `…Result.Ok(body, ws)` +overload — one closure-free materialization. Do **not** `CreateBuilder(…).RootElement` then pass the immutable +to the non-generic `Ok` (that re-materializes). Full detail: `corvus-builder-context-threading`. + +```csharp +// ✅ lazy, no workspace, no materialization — pass the builder straight to the client +await client.ResumeRunAsync(runId, RewindResume.Build(targetCursor), ct); // int +await client.CancelRunAsync(runId, CancelRequest.Build(reason), ct); // string +using var doc = ParsedJsonDocument.Parse(File.ReadAllBytes(path)); +await client.ResumeRunAsync(runId, SkipResume.Build(skipOutputs: doc.RootElement), ct); // JsonElement, passed directly +``` + +**Don't hand-roll the `Source` constructor when `Build` covers it.** A plain field set is +`T.Build(field: v, …)`. Reach for the raw `new T.Source((ref T.Builder b) => b.Create(…))` +constructor only for genuine imperative logic — not to set a couple of fields. (Older code in the +tree uses the constructor form for simple bodies; prefer `Build`.) + +```csharp +// ❌ hand-rolled constructor for a plain field set +var body = new Models.MemberWrite.Source((ref Models.MemberWrite.Builder b) => b.Create(value: v, dimension: d)); +// ✅ the Build factory +var body = Models.MemberWrite.Build(value: v, dimension: d); +``` + +**`Build` takes its fields by `in`, so consume its result in place — don't return it from a +helper.** Because `Build(in field, …)` holds a ref to each argument, its `Source` cannot escape +the method that built it: pass it **directly** to the consumer in the same expression (the common +case). Wrapping `Build` in a method that *returns* the `Source` fails with CS8347 / CS8156 ("may +expose variables … outside their declaration scope" / "cannot be … returned by reference"). If you +want a named local, build and consume it in the same scope. + +```csharp +// ✅ consumed in place +await client.AddAdministratorAsync(baseId, Models.MemberWrite.Build(value: v, dimension: d), ct); + +// ❌ returning Build's result escapes the in-ref (CS8347/CS8156) +static Models.MemberWrite.Source Member(string d, string v) => Models.MemberWrite.Build(value: v, dimension: d); +``` + +## The ref-safety trap (the one real gotcha) + +`JsonElement.Source` is a ref struct created from `in JsonElement` — it holds a **ref** to the +element. Passing a `JsonElement` *directly as an argument* to a `Build(in …)`/`CreateBuilder(in …)` +factory is fine (the factory call's scope keeps the source document alive). What fails is +**capturing** a `JsonElement` in a (non-static) builder *lambda* — the captured ref escapes into +the closure and the compiler rejects it: + +```csharp +// ❌ CS8168 / CS8347 / CS8350 — outputs is captured; its ref escapes the closure +JsonElement outputs = doc.RootElement; +var src = new SkipResume.Source((ref SkipResume.Builder b) => b.Create(skipOutputs: outputs)); + +// ✅ pass it to the native-field factory instead — no lambda, no capture +var src = SkipResume.Build(skipOutputs: doc.RootElement); +``` + +If you genuinely need imperative building *and* a threaded `JsonElement`, use the `TContext` +form so the value flows through `scoped in` rather than a closure: + +```csharp +var value = T.CreateBuilder( + workspace, + (src: someElement, ws: workspace), + static (in (JsonElement src, JsonWorkspace ws) ctx, ref T.Builder b) => { /* use ctx.src */ }).RootElement; +``` + +Always make builder lambdas `static` and thread state via the context tuple — a non-static +lambda allocates a closure and reintroduces the capture/escape problem. + +## Discriminated unions (oneOf) + +Build the **variant**, not the union; the variant `Source` implicitly converts to the union's +(non-generic) `Source`, so you pass the builder straight to the consumer — no materialization: + +```csharp +// RewindResume.Source / StatePatchResume.Source --> ResumeRequest.Source (implicit) --> client +await client.ResumeRunAsync(runId, RewindResume.Build(targetCursor), ct); +await client.ResumeRunAsync(runId, StatePatchResume.Build(patchArray), ct); +``` + +`const`-discriminated variants set their own discriminator inside `Build`/`Create`, so you never +set `mode` (etc.) yourself. + +Caveat (rare): a variant's **`Source` does NOT implicitly convert** to the union's +non-generic `Source`. So if you must use the `TContext` threading form for a union body, +materialize that variant via `CreateBuilder(ws, …).RootElement` and pass the immutable +(which converts via the `Source(Variant instance)` operator). The native-field `Build(field: v)` +factory avoids this entirely, so prefer it. + +## Lifetime + +The immutable returned by `.RootElement` is a view over the `workspace` (and `.Source` lazies +reference whatever they were built from). Keep the `JsonWorkspace` / source `ParsedJsonDocument` +alive until the consumer has finished writing the value (e.g. across the `await` on a client +call). All of `JsonWorkspace`, `JsonDocumentBuilder`, `ParsedJsonDocument` are `using`-disposable. + +## Conversions cheat-sheet + +- `string` → `JsonString.Source`, `int` → `JsonInt32.Source`, `int`/`long` → `JsonInteger`/`Schema.Source`, `DateTimeOffset`/ISO `string` → `JsonDateTime.Source` — all implicit. +- `JsonElement` → `JsonElement.Source` (implicit, **by-ref** — the trap above). +- A generated value `T` → its containing union's `Source` (implicit). Chained two-hop user + conversions are NOT allowed at an argument — introduce an intermediate local for the first hop. + +## Cross-References + +- `corvus-builder-context-threading` — building from UTF-8 **spans** in a loop with no closure (the `Build` / `CreateBuilder` form and its ref-safety gotchas); reach for it when the values are spans, not native `string`/`int`. +- `corvus-bytes-to-bytes` — when to thread spans at all (the record<->document string-seam anti-pattern + the genuine-leaf proof). +- `corvus-mutable-documents` — `JsonWorkspace` / `JsonDocumentBuilder` and mutating arbitrary `JsonElement` documents. +- `ref-struct-delegates` — why builder callbacks use named `Build` delegates (ref struct params) not `Func<>`/`Action<>`. +- `corvus-buffer-and-pooling` — the pooling that backs the workspace arena. +- `corvus-codegen` — how these factories are generated. diff --git a/GitVersion.yml b/GitVersion.yml index e43f4b3c21a..6a9bd4b5a93 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -20,4 +20,4 @@ branches: - feature - support - hotfix -next-version: "5.2" +next-version: "5.3" diff --git a/VERSIONHISTORY.md b/VERSIONHISTORY.md index 98633a532a9..8ccdfd9c6a7 100644 --- a/VERSIONHISTORY.md +++ b/VERSIONHISTORY.md @@ -1,5 +1,21 @@ # Version History +## V5.3.0 + +V5.3.0 brings the OpenAPI and AsyncAPI generation work from the workflow-engine campaign back to the mainline. Generated clients gain a closure-free request-body overload, generated servers describe themselves from the specification, optional request bodies are finally optional, and the AsyncAPI transport surface gains a request/reply responder. Two changes are breaking, which is why this is a minor rather than a patch release. + +### Breaking changes + +- **`IMessageTransport.RequestAsync` takes a `JsonWorkspace`** — The request/reply call now receives the workspace that owns the reply's lifetime, as a required parameter ahead of the optional `headers` and `cancellationToken`. Previously the reply was materialised against an ambient lifetime the caller could not control, which is the wrong shape for a caller that wants the reply to live exactly as long as the document it is being folded into. Every call site needs the workspace threading through it, and any custom `IMessageTransport` needs the new signature on its implementation of the abstract overload. The convenience overload that takes channel strings forwards to it unchanged in every other respect. +- **A generated binary response carries its body through the result factory** — An operation whose response is binary generated a parameterless `Ok()`, which could not express the body at all. The shipped example recipe said as much in a comment, returning `Ok()` and noting that the streaming was somebody else's problem. It now generates `Ok(ReadOnlyMemory body, string? contentType)` and `Ok(Func writeBody, string? contentType)`, so the handler supplies the bytes or a writer and chooses the content type rather than accepting whichever one the specification listed first. Handlers returning a binary response fail to compile until they pass a body, which is the point. Regenerate to pick it up. + +### New features + +- **Generated clients accept a context-threaded request body** — A server result factory has long offered `Ok(Source, workspace)`, so a caller can assemble a response body lazily with its context threaded through and materialise it in one pass with no per-item closure. A client had no counterpart, so anyone with a collection to put in a *request* body had to close over it. The machinery was already present: the generators take the set of body pointers whose type is an object or array, and emit the generic overload only for those. The server command computed that set and the client command never did, so the client path silently opted out under what its own doc comment called "the conservative default". Generated clients now emit `OperationAsync(Model.Source body, ...)` alongside the plain overload. OpenAPI 2.0 was worse and is worth naming separately: the parameter did not exist there at all, so a 2.0 *server* was also missing the closure-free response factories every 3.x server has had. +- **AsyncAPI gains a request/reply responder** — `IMessageTransport.SubscribeReplyAsync` subscribes to a channel, hands each request to a handler, and publishes the handler's reply on the correlated reply channel. It ships with a default implementation that throws `NotSupportedException`, so a transport that does not support responders is unaffected and existing custom transports continue to compile. +- **Dynamic channel addresses take spans and memory, not only strings** — The generated methods for a channel whose address is parameterised now offer `string`, `ReadOnlySpan`, and `ReadOnlyMemory` overloads, so an address composed from UTF-8 bytes no longer has to become a string on the way to the transport. +- **Generated code carries the documentation the specification declares** — Operation, parameter, and model descriptions from the source document are emitted as XML doc comments on the generated members, XML-escaped so a description containing markup does not break the build. +- **An optional request body is optional in generated clients and servers** — A request body not marked `required` generated a mandatory parameter, so a caller had to supply something for a body the specification says may be absent. Clients now omit the body when it is not supplied, and servers treat it as absent rather than empty. ## V5.2.13 V5.2.13 fixes two defects in the schema validation error messages and makes the source generator work for structs declared in the global namespace. diff --git a/docs/AsyncApi.md b/docs/AsyncApi.md index 362efe1b91e..e400cda6edd 100644 --- a/docs/AsyncApi.md +++ b/docs/AsyncApi.md @@ -1099,6 +1099,47 @@ AsyncAPI 3.0 uses the standard operation `reply` object. AsyncAPI 2.6 has `corre The extension is intentionally explicit. The generator does not infer request/reply pairs from matching `correlationId` values because that is ambiguous in real-world 2.6 documents. +### Responder (request/reply receive) + +The methods above are the *requester* half of request/reply (send a request, await the reply). The *responder* half — receive a request and send back a correlated reply — is modelled by a **receive** operation that declares a `reply`. For these operations the generator produces a reply-returning handler and a consumer that publishes the reply for you. + +The handler returns the reply payload instead of `void`: + +```csharp +public sealed class CalculateHandler : ICalculateHandler +{ + // A receive operation with a reply: return the reply payload; the consumer publishes it. + public ValueTask HandleCalculateRequestAsync( + CalculateRequest payload, + CancellationToken cancellationToken = default) + { + int sum = payload.A + payload.B; + return ValueTask.FromResult(new CalculateResponse.Source((ref CalculateResponse.Builder b) => + { + b.Create(result: sum); + })); + } +} +``` + +The generated consumer subscribes through the transport's responder primitive: + +```csharp +ValueTask SubscribeReplyAsync( + ReadOnlyMemory channelUtf8, + Func> handler, + CancellationToken cancellationToken = default) + where TRequest : struct, IJsonElement + where TReply : struct, IJsonElement; +``` + +The transport owns correlation: for each delivered request it reads the request's reply-to address and correlation id (native broker fields — the same `CorrelationId`/`ReplyTo` the requester sets), invokes the handler, and publishes the returned reply to the reply-to address correlated to the request. The handler never sees the correlation plumbing. + +**Reply ownership.** `RequestAsync` takes a `JsonWorkspace` and threads it through to the parse of the reply: the returned payload and headers are views over documents that workspace owns, so they stay valid until the workspace is disposed. Dispose the workspace once the reply is no longer needed. A generated requester threads the run's workspace, so the reply joins the run and is released with it, rather than being abandoned to the garbage collector. This mirrors `IApiResponse` on the OpenAPI side, which owns its parsed response body and is itself disposable. + +**Implementation status.** `SubscribeReplyAsync` is a default interface member that throws `NotSupportedException`, so a transport opts in by overriding it. The in-memory testing transport implements a full in-process round-trip: a `RequestAsync` call delivers the request to a registered responder, whose reply completes the requester's pending call (with no responder registered, `RequestAsync` parks the request for the test helper `CompleteRequest`, as before). The broker transports (NATS, Kafka, AMQP, MQTT, WebSocket, Azure Service Bus) inherit the default until responder support is implemented for each. + + ## Bindings AsyncAPI bindings provide protocol-specific configuration. The generator captures bindings at three levels and makes them available to the transport via `MessageContext`: diff --git a/docs/BenchmarkGuide.md b/docs/BenchmarkGuide.md index 1907b542eca..2ed4328a0fe 100644 --- a/docs/BenchmarkGuide.md +++ b/docs/BenchmarkGuide.md @@ -122,7 +122,20 @@ A regression is flagged (⚠️) when Mean increases by more than the threshold ## Regenerating C/ models -After making code generator changes, regenerate all C/ directories: +After making code generator changes, regenerate **all** C/ directories with the batch script +(`benchmarks/scripts/Regenerate-CurrentBenchmarks.ps1`): + +```powershell +pwsh benchmarks/scripts/Regenerate-CurrentBenchmarks.ps1 +``` + +It builds the generator, then for every `*BenchmarkModels` project reads the root namespace +(`Corvus.Benchmark.Current`) from the existing `C/` output, uses the root type `Schema` +(overridable in the script's `$Overrides` table) and the project's single `*-schema.json`, cleans `C/`, +regenerates with `--engine V5`, and flags any project whose regeneration is **not** additive-only for review. +It never touches `B/`. + +To regenerate a single project by hand (the script automates exactly this per project): ```powershell # 1. Clean the C/ directory (old files cause compilation errors) diff --git a/docs/ConsumingGeneratedTypes.md b/docs/ConsumingGeneratedTypes.md new file mode 100644 index 00000000000..046c88ec2e4 --- /dev/null +++ b/docs/ConsumingGeneratedTypes.md @@ -0,0 +1,127 @@ +# Consuming Generated Types + +## Overview + +Corvus generates strongly-typed .NET models from JSON Schema, OpenAPI, and AsyncAPI (through the +[source generator](./SourceGenerator.md), the [CLI](./CodeGenerator.md), and the runtime +[Validator](./Validator.md)). This guide is about reading and building those models in your own code, allocation +free, without composing or parsing JSON strings by hand. The conventions below differ from `System.Text.Json` in +a few ways that are worth knowing up front. + +## The types are Corvus's, not System.Text.Json's + +A generated model and every value you read from it are `Corvus.Text.Json` types. `JsonElement`, `JsonValueKind`, +and the typed accessors are Corvus's, operating on UTF-8 bytes. Reach for the Corvus members below rather than a +`System.Text.Json` API of the same name. + +## Optional properties are Undefined, not null + +An absent optional property returns an **Undefined** value, not `null`. A generated value is a non-nullable +struct, so testing it against `null` never fires. Check with `IsNotUndefined()`, then convert to the native type. + +```csharp +string? baseWorkflowId = body.BaseWorkflowId.IsNotUndefined() ? (string)body.BaseWorkflowId : null; +long? seconds = body.RequestedDurationSeconds.IsNotUndefined() ? (long)body.RequestedDurationSeconds : null; +``` + +## Reading values + +Convert a scalar to its native type with a cast: `(string)value`, `(long)value`, `(int)value`, `(bool)value`. +For a date or date-time, use the typed accessor rather than a cast plus `DateTimeOffset.Parse`, which is not +JSON-Schema compliant. + +```csharp +DateTimeOffset when = element.GetDateTimeOffset(); +``` + +To read a string value's bytes without allocating a managed `string`, use `element.GetUtf8String()` (see the UTF-8 +section of [Performance Techniques](./PerformanceTechniques.md)). + +### Discriminated unions: `Match` + +A `oneOf` schema generates a union. Read it with the generated `Match(...)` (or the `IsX` / `AsX` +accessors) rather than inspecting `ValueKind` by hand, so the compiler checks that you handled every case. + +## An absent optional object property throws on nested access + +An absent optional **object** property returns a default value whose parent is null, so navigating into it throws +a `NullReferenceException`. Gate nested access on the value's kind, which covers both the absent case and an +explicit JSON `null`. + +```csharp +if (parent.Child.ValueKind == JsonValueKind.Object) +{ + // safe to read parent.Child.GrandChild here +} +``` + +## Schema defaults materialise in the getter + +If a property declares a non-null `default` in its schema, the generated getter returns that default when the +property is absent. So express a server-assigned or optional-with-a-default value in the schema rather than +hand-writing a null guard at every call site; the generated type carries the default for you. + +## Building a value: convert and construct, never compose-then-parse + +Do not build a body as a JSON string and `ParseValue` it (an interim string, a parse, and a re-validation). +Convert a native value with the static `From` factory, and build a model with its generated factories. + +```csharp +JsonString token = JsonString.From(rawPageToken); // native value to a generated type +await client.ResumeRunAsync(runId, RewindResume.Build(targetCursor), ct); // build a body, hand it straight over +``` + +Pick the factory by situation. + +| Factory | Returns | Use when | +|---------|---------|----------| +| `T.Build(field: v, …)` | `T.Source` (lazy) | The default. Native values, including a `JsonElement` or array, passed straight in. Hand the `Source` to a consumer (a generated client or handler) that materialises it once. No workspace, no closure. | +| `T.Build(static (ref T.Builder b) => …)` | `T.Source` (lazy) | No fields to set, or you need imperative logic. The lambda must be `static`. | +| `T.CreateBuilder(ws, field: v, …).RootElement` | `T` (materialised) | Only when you need a materialised value in hand (to read it back or store it), not merely to pass it on. | +| `T.CreateBuilder(ws, ctx, static (in TContext ctx, ref T.Builder b) => …).RootElement` | `T` | Materialising while threading runtime values into a builder loop or conditional, closure free. | + +For a discriminated union, build the **variant**, not the union; the variant's `Source` converts implicitly to the +union's, so you pass the builder straight to the consumer with no materialisation. A `const`-discriminated variant +sets its own discriminator inside `Build`, so you never set it yourself. + +### The `JsonElement.Source` ref-safety trap + +`JsonElement.Source` is a `ref struct` created from an `in JsonElement`; it holds a ref to the element. Passing a +`JsonElement` **directly as an argument** to a `Build(in …)` or `CreateBuilder(in …)` factory is fine (the call's +scope keeps the source document alive). What fails is **capturing** a `JsonElement` in a non-static builder lambda, +where the captured ref escapes into the closure and the compiler rejects it (CS8168 / CS8347 / CS8350). + +```csharp +// wrong: outputs is captured; its ref escapes the closure +JsonElement outputs = doc.RootElement; +var src = new SkipResume.Source((ref SkipResume.Builder b) => b.Create(skipOutputs: outputs)); + +// right: pass it to the native-field factory, no lambda, no capture +var src = SkipResume.Build(skipOutputs: doc.RootElement); +``` + +If you genuinely need imperative building and a threaded `JsonElement`, use the `CreateBuilder` form so +the value flows through `scoped in` rather than a closure. Always make builder lambdas `static` and thread state +through the context tuple; a non-static lambda allocates a closure and reintroduces the capture problem. + +## Cross-assembly type identity + +Each generation root emits its own copy of the primitive types, so there is no single shared `JsonString`. When a +generated type crosses an assembly boundary (a public seam typed on it), reference it by its fully-qualified name; +the same short name from another assembly is a distinct type. See the +[source generator troubleshooting](./SourceGenerator.md#troubleshooting). + +## Lifetime + +A materialised value from `.RootElement` is a view over its `JsonWorkspace`, and a lazy `.Source` references +whatever it was built from. Keep the `JsonWorkspace` or source `ParsedJsonDocument` alive until the consumer has +finished writing the value, for example across the `await` on a client call. `JsonWorkspace`, +`JsonDocumentBuilder`, and `ParsedJsonDocument` are all `using`-disposable. + +## See also + +- [Source Generator Code Generation](./SourceGenerator.md) and [CLI Code Generation](./CodeGenerator.md) for + generating the types. +- [Parsing & Reading JSON](./ParsedJsonDocument.md) for the read-only document model. +- [Building & Mutating JSON](./JsonDocumentBuilder.md) for building and modifying arbitrary documents. +- [Performance Techniques](./PerformanceTechniques.md) for the UTF-8 and allocation model underneath. diff --git a/docs/JsonDocumentBuilder.md b/docs/JsonDocumentBuilder.md index 1ff1845a10f..9a2ca0bce09 100644 --- a/docs/JsonDocumentBuilder.md +++ b/docs/JsonDocumentBuilder.md @@ -390,6 +390,8 @@ In many applications, you receive JSON from an API, file, or database, modify it If you know you'll be modifying the JSON, parse directly into a mutable builder. This is the fastest approach — a single pass over the input, no intermediate document, and no per-value copies. The raw UTF-8 bytes become the builder's backing store, and mutations append on top. +For a read-modify-write over a persisted document, prefer this parse-and-patch approach to rebuilding the document by re-passing every field to a `Create()` factory. Parse-and-patch is faster (unchanged fields carry through as raw bytes rather than being realised as managed values), and it is safer: a rebuild silently drops any field you forget to re-pass, whereas patching touches only the fields you name and carries the rest through unchanged. + ```csharp using JsonWorkspace workspace = JsonWorkspace.Create(); diff --git a/docs/PerformanceTechniques.md b/docs/PerformanceTechniques.md index 1cbc22167b5..6657b465263 100644 --- a/docs/PerformanceTechniques.md +++ b/docs/PerformanceTechniques.md @@ -75,6 +75,15 @@ On `netstandard2.0` these use `unsafe fixed` pointer overloads; on modern .NET t > Source: `src/Corvus.Text.Json/Corvus/Text/Json/Reader/JsonReaderHelper.Unescaping.cs` +### Reading a value's bytes without a string + +Transcoding is for when you genuinely need a `string`. To read or compare a string value while staying on the UTF-8 path, use the consumer primitives that hand back the bytes rather than a managed string. + +- `element.GetUtf8String()` returns an `UnescapedUtf8JsonString`, a `ref struct` over the value's unescaped UTF-8 bytes. Read it or `SequenceEqual`-compare it without allocating a `string`. +- `JsonMarshal.GetRawUtf8Value(element)` returns a `RawUtf8JsonString`, a non-owning view of the raw (as-stored) UTF-8 bytes of any value. + +Both reference the source document's bytes, so they are valid only while that document is alive. Copy out what you need before the document is disposed. + --- ## 3. Three-Tier Pooling Hierarchy diff --git a/docs/README.md b/docs/README.md index b19b482f400..6ca15328dff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ The markdown files in this directory are the source content for the website. The | [JsonDocumentBuilder.md](./JsonDocumentBuilder.md) | Docs — Building & Mutating JSON | | [SourceGenerator.md](./SourceGenerator.md) | Docs — Source Generator | | [CodeGenerator.md](./CodeGenerator.md) | Docs — CLI Code Generation | +| [ConsumingGeneratedTypes.md](./ConsumingGeneratedTypes.md) | Docs — Consuming Generated Types | | [Validator.md](./Validator.md) | Docs — Dynamic Schema Validation | | [SchemaEvaluator.md](./SchemaEvaluator.md) | Docs — Standalone Schema Evaluator | | [JsonPatch.md](./JsonPatch.md) | Docs — JSON Patch, Merge Patch & Diff | diff --git a/docs/SourceGenerator.md b/docs/SourceGenerator.md index 74a7275a066..4fed185f02b 100644 --- a/docs/SourceGenerator.md +++ b/docs/SourceGenerator.md @@ -242,3 +242,6 @@ Both the source generator and the [`corvusjson` CLI tool](/docs/code-generator.h | Type name collisions | Multiple schemas define types with the same name | Use `[JsonSchemaTypeGenerator("schema.json#/$defs/Specific")]` to target a specific definition | | Unexpected property names | Naming heuristic choosing a poor name | Disable the heuristic with `CorvusTextJsonDisabledNamingHeuristics` | | Stale generated code | Incremental cache not invalidated | Clean and rebuild (`dotnet clean && dotnet build`) | +| Cross-assembly type mismatch on a generated type (`CS0535`, or a conversion that should exist but does not) | Each generation root emits its own copy of the primitive types; there is no shared base `JsonString`, so the "same" concrete type from another assembly is a distinct type | Fully-qualify the generated type at the cross-assembly seam rather than assuming a common base type | +| `CS0029` on a model type named `Source` | The generator emits a nested builder `ref struct` named `Source` on each type, which collides with a model of the same name | Do not name a `[JsonSchemaTypeGenerator]` model `Source` | +| `CS0246` for the shared primitive types | A generation-root model was declared in a leaf namespace, so the shared primitives were emitted into that leaf instead of the assembly root | Declare a generation-root model in the assembly's root namespace, even when its file sits in a subfolder | diff --git a/docs/Validator.md b/docs/Validator.md index c800b541b3a..be2a7b14288 100644 --- a/docs/Validator.md +++ b/docs/Validator.md @@ -226,6 +226,16 @@ Under the hood, the Validator uses the same code generation engine as the source This means the Validator produces the exact same validation logic as build-time source generation — the only difference is that compilation happens at runtime. +### Hosting requirement: preserve the compilation context + +Because compilation happens at runtime, the Roslyn compiler reads the host application's reference assemblies and preprocessor symbols from its `.deps.json`. Any application or test project that hosts the Validator must set `PreserveCompilationContext` in its project file. Without it, the first validation throws, reporting that the generated validator does not implement its expected interface, because the runtime compilation ran against an incomplete reference set. + +```xml + + true + +``` + ## Supported JSON Schema Drafts | Draft | `$schema` URI | diff --git a/docs/code-sample-catalog.yaml b/docs/code-sample-catalog.yaml index a52cfee36c9..8ae828168d7 100644 --- a/docs/code-sample-catalog.yaml +++ b/docs/code-sample-catalog.yaml @@ -664,19 +664,21 @@ main-docs: - {index: 47, language: csharp, lines: [1016, 1038], category: compilable, verified: false} - {index: 48, language: csharp, lines: [1061, 1072], category: compilable, verified: false} - {index: 49, language: json, lines: [1078, 1098]} - - {index: 50, language: json, lines: [1112, 1126]} - - {index: 51, language: csharp, lines: [1130, 1139], category: compilable, verified: false} - - {index: 52, language: csharp, lines: [1149, 1157], category: compilable, verified: false} - - {index: 53, language: csharp, lines: [1170, 1187], category: compilable, verified: false} - - {index: 54, language: bash, lines: [1195, 1197]} - - {index: 55, language: csharp, lines: [1199, 1210], category: compilable, verified: false} - - {index: 56, language: bash, lines: [1220, 1222]} - - {index: 57, language: bash, lines: [1240, 1258]} - - {index: 58, language: bash, lines: [1262, 1264]} - - {index: 59, language: , lines: [1268, 1278]} - - {index: 60, language: bash, lines: [1291, 1297]} - - {index: 61, language: csharp, lines: [1333, 1338], category: compilable, verified: false} - - {index: 62, language: , lines: [1352, 1357]} + - {index: 50, language: csharp, lines: [1108, 1123], category: compilable, verified: false} + - {index: 51, language: csharp, lines: [1127, 1134], category: compilable, verified: false} + - {index: 52, language: json, lines: [1153, 1167]} + - {index: 53, language: csharp, lines: [1171, 1180], category: compilable, verified: false} + - {index: 54, language: csharp, lines: [1190, 1198], category: compilable, verified: false} + - {index: 55, language: csharp, lines: [1211, 1228], category: compilable, verified: false} + - {index: 56, language: bash, lines: [1236, 1238]} + - {index: 57, language: csharp, lines: [1240, 1251], category: compilable, verified: false} + - {index: 58, language: bash, lines: [1261, 1263]} + - {index: 59, language: bash, lines: [1281, 1299]} + - {index: 60, language: bash, lines: [1303, 1305]} + - {index: 61, language: , lines: [1309, 1319]} + - {index: 62, language: bash, lines: [1332, 1338]} + - {index: 63, language: csharp, lines: [1374, 1379], category: compilable, verified: false} + - {index: 64, language: , lines: [1393, 1398]} - path: "docs/AsyncApiMessageResumption.md" blocks: - {index: 0, language: , lines: [26, 32]} @@ -706,8 +708,9 @@ main-docs: - {index: 1, language: powershell, lines: [57, 71]} - {index: 2, language: yaml, lines: [88, 95]} - {index: 3, language: powershell, lines: [114, 119]} - - {index: 4, language: powershell, lines: [127, 138]} - - {index: 5, language: powershell, lines: [142, 151]} + - {index: 4, language: powershell, lines: [128, 130]} + - {index: 5, language: powershell, lines: [140, 151]} + - {index: 6, language: powershell, lines: [155, 164]} - path: "docs/BowtiePrerequisites.md" blocks: - {index: 0, language: powershell, lines: [31, 33]} @@ -795,6 +798,13 @@ main-docs: - {index: 5, language: powershell, lines: [130, 132]} - {index: 6, language: powershell, lines: [148, 153]} - {index: 7, language: powershell, lines: [200, 206]} + - path: "docs/ConsumingGeneratedTypes.md" + blocks: + - {index: 0, language: csharp, lines: [22, 25], category: compilable, verified: false} + - {index: 1, language: csharp, lines: [33, 35], category: compilable, verified: false} + - {index: 2, language: csharp, lines: [51, 56], category: compilable, verified: false} + - {index: 3, language: csharp, lines: [69, 72], category: compilable, verified: false} + - {index: 4, language: csharp, lines: [94, 101], category: compilable, verified: false} - path: "docs/CrossPlatformDynamicCompilation.md" blocks: - {index: 0, language: , lines: [56, 59]} @@ -954,40 +964,40 @@ main-docs: - {index: 11, language: csharp, lines: [305, 317], category: compilable, verified: false} - {index: 12, language: csharp, lines: [322, 332], category: compilable, verified: false} - {index: 13, language: csharp, lines: [352, 359], category: compilable, verified: false} - - {index: 14, language: csharp, lines: [393, 408], category: compilable, verified: false} - - {index: 15, language: csharp, lines: [412, 428], category: compilable, verified: false} - - {index: 16, language: csharp, lines: [434, 454], category: compilable, verified: false} - - {index: 17, language: csharp, lines: [460, 481], category: compilable, verified: false} - - {index: 18, language: csharp, lines: [491, 538], category: compilable, verified: false} - - {index: 19, language: csharp, lines: [554, 575], category: compilable, verified: false} - - {index: 20, language: csharp, lines: [579, 617], category: compilable, verified: false} - - {index: 21, language: csharp, lines: [623, 632], category: compilable, verified: false} - - {index: 22, language: csharp, lines: [636, 644], category: compilable, verified: false} - - {index: 23, language: csharp, lines: [662, 685], category: compilable, verified: false} - - {index: 24, language: csharp, lines: [701, 735], category: fragment, verified: false} - - {index: 25, language: csharp, lines: [743, 781], category: compilable, verified: false} - - {index: 26, language: csharp, lines: [808, 826], category: compilable, verified: false} - - {index: 27, language: csharp, lines: [834, 855], category: compilable, verified: false} - - {index: 28, language: csharp, lines: [879, 908], category: compilable, verified: false} - - {index: 29, language: csharp, lines: [919, 951], category: compilable, verified: false} - - {index: 30, language: csharp, lines: [972, 989], category: compilable, verified: false} - - {index: 31, language: csharp, lines: [997, 1039], category: compilable, verified: false} - - {index: 32, language: csharp, lines: [1049, 1142], category: compilable, verified: false} - - {index: 33, language: csharp, lines: [1163, 1194], category: compilable, verified: false} - - {index: 34, language: csharp, lines: [1209, 1248], category: compilable, verified: false} - - {index: 35, language: csharp, lines: [1252, 1278], category: compilable, verified: false} - - {index: 36, language: csharp, lines: [1292, 1315], category: compilable, verified: false} - - {index: 37, language: csharp, lines: [1319, 1330], category: compilable, verified: false} - - {index: 38, language: csharp, lines: [1336, 1357], category: compilable, verified: false} - - {index: 39, language: csharp, lines: [1370, 1376], category: compilable, verified: false} - - {index: 40, language: csharp, lines: [1379, 1387], category: compilable, verified: false} - - {index: 41, language: csharp, lines: [1390, 1397], category: compilable, verified: false} - - {index: 42, language: csharp, lines: [1416, 1431], category: compilable, verified: false} - - {index: 43, language: csharp, lines: [1469, 1475], category: compilable, verified: false} - - {index: 44, language: csharp, lines: [1478, 1485], category: compilable, verified: false} - - {index: 45, language: csharp, lines: [1509, 1513], category: compilable, verified: false} - - {index: 46, language: csharp, lines: [1516, 1520], category: compilable, verified: false} - - {index: 47, language: csharp, lines: [1523, 1527], category: compilable, verified: false} + - {index: 14, language: csharp, lines: [395, 410], category: compilable, verified: false} + - {index: 15, language: csharp, lines: [414, 430], category: compilable, verified: false} + - {index: 16, language: csharp, lines: [436, 456], category: compilable, verified: false} + - {index: 17, language: csharp, lines: [462, 483], category: compilable, verified: false} + - {index: 18, language: csharp, lines: [493, 540], category: compilable, verified: false} + - {index: 19, language: csharp, lines: [556, 577], category: compilable, verified: false} + - {index: 20, language: csharp, lines: [581, 619], category: compilable, verified: false} + - {index: 21, language: csharp, lines: [625, 634], category: compilable, verified: false} + - {index: 22, language: csharp, lines: [638, 646], category: compilable, verified: false} + - {index: 23, language: csharp, lines: [664, 687], category: compilable, verified: false} + - {index: 24, language: csharp, lines: [703, 737], category: fragment, verified: false} + - {index: 25, language: csharp, lines: [745, 783], category: compilable, verified: false} + - {index: 26, language: csharp, lines: [810, 828], category: compilable, verified: false} + - {index: 27, language: csharp, lines: [836, 857], category: compilable, verified: false} + - {index: 28, language: csharp, lines: [881, 910], category: compilable, verified: false} + - {index: 29, language: csharp, lines: [921, 953], category: compilable, verified: false} + - {index: 30, language: csharp, lines: [974, 991], category: compilable, verified: false} + - {index: 31, language: csharp, lines: [999, 1041], category: compilable, verified: false} + - {index: 32, language: csharp, lines: [1051, 1144], category: compilable, verified: false} + - {index: 33, language: csharp, lines: [1165, 1196], category: compilable, verified: false} + - {index: 34, language: csharp, lines: [1211, 1250], category: compilable, verified: false} + - {index: 35, language: csharp, lines: [1254, 1280], category: compilable, verified: false} + - {index: 36, language: csharp, lines: [1294, 1317], category: compilable, verified: false} + - {index: 37, language: csharp, lines: [1321, 1332], category: compilable, verified: false} + - {index: 38, language: csharp, lines: [1338, 1359], category: compilable, verified: false} + - {index: 39, language: csharp, lines: [1372, 1378], category: compilable, verified: false} + - {index: 40, language: csharp, lines: [1381, 1389], category: compilable, verified: false} + - {index: 41, language: csharp, lines: [1392, 1399], category: compilable, verified: false} + - {index: 42, language: csharp, lines: [1418, 1433], category: compilable, verified: false} + - {index: 43, language: csharp, lines: [1471, 1477], category: compilable, verified: false} + - {index: 44, language: csharp, lines: [1480, 1487], category: compilable, verified: false} + - {index: 45, language: csharp, lines: [1511, 1515], category: compilable, verified: false} + - {index: 46, language: csharp, lines: [1518, 1522], category: compilable, verified: false} + - {index: 47, language: csharp, lines: [1525, 1529], category: compilable, verified: false} - path: "docs/JsonLogic.md" blocks: - {index: 0, language: bash, lines: [29, 32]} @@ -1330,7 +1340,7 @@ main-docs: - {index: 34, language: csharp, lines: [894, 908], category: compilable, verified: false} - path: "docs/PerformanceTechniques.md" blocks: - - {index: 0, language: csharp, lines: [95, 109], category: compilable, verified: false} + - {index: 0, language: csharp, lines: [104, 118], category: compilable, verified: false} - path: "docs/README.md" blocks: - {index: 0, language: powershell, lines: [7, 10]} @@ -1474,6 +1484,7 @@ main-docs: - {index: 10, language: csharp, lines: [181, 194], category: compilable, verified: false} - {index: 11, language: csharp, lines: [202, 208], category: compilable, verified: false} - {index: 12, language: csharp, lines: [212, 214], category: fragment, verified: false} + - {index: 13, language: xml, lines: [233, 237]} - path: "docs/Yaml.md" blocks: - {index: 0, language: bash, lines: [26, 32]} @@ -1676,21 +1687,22 @@ copilot-docs: copilot-instructions: - path: ".github/copilot-instructions.md" blocks: - - {index: 0, language: bash, lines: [44, 56]} - - {index: 1, language: powershell, lines: [99, 102]} - - {index: 2, language: csharp, lines: [161, 171], category: compilable, verified: false} - - {index: 3, language: csharp, lines: [188, 207], category: compilable, verified: false} - - {index: 4, language: powershell, lines: [251, 253]} - - {index: 5, language: powershell, lines: [265, 269]} - - {index: 6, language: powershell, lines: [307, 312]} - - {index: 7, language: powershell, lines: [339, 345]} - - {index: 8, language: csharp, lines: [355, 365], category: compilable, verified: false} - - {index: 9, language: powershell, lines: [434, 443]} - - {index: 10, language: bash, lines: [471, 477]} - - {index: 11, language: bash, lines: [483, 486]} - - {index: 12, language: powershell, lines: [496, 511]} - - {index: 13, language: powershell, lines: [519, 521]} - - {index: 14, language: bash, lines: [554, 556]} + - {index: 0, language: bash, lines: [46, 58]} + - {index: 1, language: powershell, lines: [101, 104]} + - {index: 2, language: csharp, lines: [165, 175], category: compilable, verified: false} + - {index: 3, language: csharp, lines: [192, 211], category: compilable, verified: false} + - {index: 4, language: powershell, lines: [255, 257]} + - {index: 5, language: powershell, lines: [269, 273]} + - {index: 6, language: powershell, lines: [311, 316]} + - {index: 7, language: powershell, lines: [343, 349]} + - {index: 8, language: csharp, lines: [359, 369], category: compilable, verified: false} + - {index: 9, language: powershell, lines: [438, 447]} + - {index: 10, language: bash, lines: [475, 477]} + - {index: 11, language: bash, lines: [493, 499]} + - {index: 12, language: bash, lines: [505, 508]} + - {index: 13, language: powershell, lines: [518, 533]} + - {index: 14, language: powershell, lines: [541, 543]} + - {index: 15, language: bash, lines: [576, 578]} skills: - path: ".github/skills/corvus-analyzers/SKILL.md" @@ -1719,9 +1731,10 @@ skills: blocks: - {index: 0, language: , lines: [20, 24]} - {index: 1, language: csharp, lines: [30, 48], category: compilable, verified: false} - - {index: 2, language: csharp, lines: [73, 91], category: compilable, verified: false} - - {index: 3, language: csharp, lines: [111, 141], category: fragment, verified: false} - - {index: 4, language: csharp, lines: [183, 186], category: fragment, verified: false} + - {index: 2, language: csharp, lines: [82, 89], category: compilable, verified: false} + - {index: 3, language: csharp, lines: [105, 123], category: fragment, verified: false} + - {index: 4, language: csharp, lines: [143, 173], category: fragment, verified: false} + - {index: 5, language: csharp, lines: [215, 218], category: compilable, verified: false} - path: ".github/skills/corvus-build-and-test/SKILL.md" blocks: - {index: 0, language: powershell, lines: [31, 37]} @@ -1734,6 +1747,18 @@ skills: - {index: 7, language: powershell, lines: [207, 213]} - {index: 8, language: python, lines: [244, 262]} - {index: 9, language: powershell, lines: [274, 280]} + - path: ".github/skills/corvus-builder-context-threading/SKILL.md" + blocks: + - {index: 0, language: csharp, lines: [45, 59], category: compilable, verified: false} + - {index: 1, language: csharp, lines: [66, 77], category: compilable, verified: false} + - {index: 2, language: csharp, lines: [89, 100], category: compilable, verified: false} + - {index: 3, language: csharp, lines: [112, 118], category: compilable, verified: false} + - {index: 4, language: csharp, lines: [136, 149], category: compilable, verified: false} + - path: ".github/skills/corvus-bytes-to-bytes/SKILL.md" + blocks: + - {index: 0, language: csharp, lines: [93, 103], category: compilable, verified: false} + - {index: 1, language: csharp, lines: [116, 129], category: compilable, verified: false} + - {index: 2, language: csharp, lines: [133, 146], category: compilable, verified: false} - path: ".github/skills/corvus-codegen/SKILL.md" blocks: - {index: 0, language: csharp, lines: [22, 25], category: compilable, verified: false} @@ -1749,12 +1774,15 @@ skills: - {index: 3, language: csharp, lines: [78, 85], category: compilable, verified: false} - {index: 4, language: csharp, lines: [89, 93], category: compilable, verified: false} - {index: 5, language: csharp, lines: [99, 105], category: compilable, verified: false} - - {index: 6, language: csharp, lines: [113, 119], category: compilable, verified: false} - - {index: 7, language: csharp, lines: [127, 136], category: compilable, verified: false} - - {index: 8, language: csharp, lines: [142, 158], category: compilable, verified: false} - - {index: 9, language: csharp, lines: [164, 175], category: compilable, verified: false} - - {index: 10, language: csharp, lines: [183, 191], category: compilable, verified: false} - - {index: 11, language: csharp, lines: [197, 205], category: compilable, verified: false} + - {index: 6, language: csharp, lines: [139, 148], category: compilable, verified: false} + - {index: 7, language: bash, lines: [154, 161]} + - {index: 8, language: csharp, lines: [173, 179], category: compilable, verified: false} + - {index: 9, language: csharp, lines: [185, 191], category: compilable, verified: false} + - {index: 10, language: csharp, lines: [197, 206], category: compilable, verified: false} + - {index: 11, language: csharp, lines: [212, 228], category: compilable, verified: false} + - {index: 12, language: csharp, lines: [234, 244], category: compilable, verified: false} + - {index: 13, language: csharp, lines: [252, 260], category: compilable, verified: false} + - {index: 14, language: csharp, lines: [266, 274], category: compilable, verified: false} - path: ".github/skills/corvus-docs-website/SKILL.md" blocks: - {index: 0, language: powershell, lines: [24, 27]} @@ -1819,6 +1847,15 @@ skills: blocks: - {index: 0, language: powershell, lines: [18, 20]} - {index: 1, language: powershell, lines: [44, 53]} + - path: ".github/skills/corvus-typed-model-construction/SKILL.md" + blocks: + - {index: 0, language: csharp, lines: [26, 30], category: compilable, verified: false} + - {index: 1, language: csharp, lines: [53, 59], category: compilable, verified: false} + - {index: 2, language: csharp, lines: [66, 71], category: compilable, verified: false} + - {index: 3, language: csharp, lines: [80, 86], category: compilable, verified: false} + - {index: 4, language: csharp, lines: [96, 103], category: compilable, verified: false} + - {index: 5, language: csharp, lines: [108, 113], category: compilable, verified: false} + - {index: 6, language: csharp, lines: [123, 127], category: compilable, verified: false} - path: ".github/skills/corvus-v4-migration/SKILL.md" blocks: - {index: 0, language: xml, lines: [29, 31]} diff --git a/docs/website/.lycheeignore b/docs/website/.lycheeignore index 53fdeb5930e..a819213417b 100644 --- a/docs/website/.lycheeignore +++ b/docs/website/.lycheeignore @@ -33,4 +33,8 @@ https://dotnet\.microsoft\.com https://endjin\.com # learn.microsoft.com intermittently returns 429 (rate limiting) in CI -https://learn\.microsoft\.com \ No newline at end of file +https://learn\.microsoft\.com + +# www.asyncapi.com resets the connection ("Connection reset by peer") for the +# automated checker in CI, though the tutorial links resolve fine in a browser. +https://www\.asyncapi\.com \ No newline at end of file diff --git a/docs/website/build.ps1 b/docs/website/build.ps1 index 862603616db..4d9b8e84251 100644 --- a/docs/website/build.ps1 +++ b/docs/website/build.ps1 @@ -775,6 +775,10 @@ foreach ($descriptorFile in $descriptorFiles) { # Point them at the GitHub source $docBody = $docBody -replace '\(copilot/([^)]+\.md)\)', "($canonicalRepoUrl/blob/$canonicalBlobRef/docs/copilot/`$1)" + # Rewrite links to the deep Arazzo design docs (docs/arazzo/**) — the ADRs, guides, + # and reference — which are not published as website pages. Point them at the GitHub source. + $docBody = $docBody -replace '\(arazzo/([^)]+\.md)\)', "($canonicalRepoUrl/blob/$canonicalBlobRef/docs/arazzo/`$1)" + # Rewrite links to ExampleRecipes from doc pages: # ../ExampleRecipes/029-OpenApiClient/ -> /examples/open-api-client.html # ../ExampleRecipes/029-OpenApiClient -> /examples/open-api-client.html diff --git a/docs/website/doc-descriptors/23-ConsumingGeneratedTypes.yml b/docs/website/doc-descriptors/23-ConsumingGeneratedTypes.yml new file mode 100644 index 00000000000..58eed561c22 --- /dev/null +++ b/docs/website/doc-descriptors/23-ConsumingGeneratedTypes.yml @@ -0,0 +1,3 @@ +source: ConsumingGeneratedTypes.md +navTitle: "Consuming Generated Types" +description: "Read and build Corvus's generated strongly-typed models in your own code, allocation-free and without composing or parsing JSON strings. Covers Undefined-not-null optional properties, typed accessors and unions, the From and Build factories, the JsonElement.Source ref-safety trap, and cross-assembly type identity." diff --git a/docs/website/doc-descriptors/24-PerformanceTechniques.yml b/docs/website/doc-descriptors/24-PerformanceTechniques.yml new file mode 100644 index 00000000000..bcc67b2ebe6 --- /dev/null +++ b/docs/website/doc-descriptors/24-PerformanceTechniques.yml @@ -0,0 +1,3 @@ +source: PerformanceTechniques.md +navTitle: "Performance Techniques" +description: "The UTF-8, byte-native, and allocation model that underpins Corvus.Text.Json's high-performance JSON handling." From 3321932a9cb85c63164211edab04573191be982d Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Wed, 5 Aug 2026 09:13:13 +0100 Subject: [PATCH 09/11] Regenerate the recipes, and credit the contributions (#803) The AsyncAPI consumer changes reach the committed recipe output, and the streetlights recipes show why the parameterised-channel contribution matters: they subscribed to smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured literally, placeholder and all, so the generated consumer listened on a channel no publisher ever wrote to. They now take the streetlight id and compose the address from it. VERSIONHISTORY credits both contributions to Levy Barbosa, and records that the allocation-free composition was added when #914 was merged rather than being part of what was contributed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq --- VERSIONHISTORY.md | 4 +- .../Generated/corvusjson-openapi.lock | 4 +- .../Generated/corvusjson-openapi.lock | 4 +- .../Generated/Client/corvusjson-openapi.lock | 4 +- .../ReceiveLightMeasurementConsumer.cs | 62 ++++++++++++++++--- .../Generated/corvusjson-asyncapi.lock | 4 +- .../ReceiveLightMeasurementConsumer.cs | 62 ++++++++++++++++--- .../Generated/corvusjson-asyncapi.lock | 4 +- .../ReceiveLightMeasurementConsumer.cs | 62 ++++++++++++++++--- .../Generated/corvusjson-asyncapi.lock | 4 +- .../ReceiveLightMeasurementConsumer.cs | 62 ++++++++++++++++--- .../Generated/corvusjson-asyncapi.lock | 4 +- .../Generated/corvusjson-openapi.lock | 4 +- 13 files changed, 231 insertions(+), 53 deletions(-) diff --git a/VERSIONHISTORY.md b/VERSIONHISTORY.md index 8ccdfd9c6a7..ab3368d7c42 100644 --- a/VERSIONHISTORY.md +++ b/VERSIONHISTORY.md @@ -2,7 +2,7 @@ ## V5.3.0 -V5.3.0 brings the OpenAPI and AsyncAPI generation work from the workflow-engine campaign back to the mainline. Generated clients gain a closure-free request-body overload, generated servers describe themselves from the specification, optional request bodies are finally optional, and the AsyncAPI transport surface gains a request/reply responder. Two changes are breaking, which is why this is a minor rather than a patch release. +V5.3.0 brings the OpenAPI and AsyncAPI generation work from the workflow-engine campaign back to the mainline. Generated clients gain a closure-free request-body overload, generated servers describe themselves from the specification, optional request bodies are finally optional, and the AsyncAPI transport surface gains a request/reply responder. Two changes are breaking, which is why this is a minor rather than a patch release. It also carries two community contributions from Levy Barbosa, covering AsyncAPI channel parameters and channel/operation bindings. ### Breaking changes @@ -11,6 +11,8 @@ V5.3.0 brings the OpenAPI and AsyncAPI generation work from the workflow-engine ### New features +- **Generated AsyncAPI consumers subscribe to a parameterised channel address** — A channel whose address declares parameters (`orders.{orderId}.created`) generated a consumer that subscribed to the address *literally*, placeholder and all, so it listened on a channel no publisher ever used. The consumer now takes each declared parameter as an argument to `StartAsync` and composes the address from them. The composition allocates only the arrays the subscription retains: the template is split at generation time so its literal parts are `u8` literals, only the parameter values are transcoded, and the address is filled once; the dead-letter address is built from those bytes rather than by concatenating a second string. A `ReadOnlySpan` overload sits beneath the `string` one, so a caller holding a span never creates a string just to have it measured and copied. Contributed by [Levy Barbosa (@Levyks)](https://github.com/Levyks) in [#914](https://github.com/corvus-dotnet/Corvus.JsonSchema/pull/914), with thanks; the allocation-free composition was added on merge. +- **Generated AsyncAPI consumers carry channel and operation bindings to the transport** — A consumer whose channel or operation declares bindings is now subscribed with them, as a `MessageContext`, so protocol-specific metadata reaches the transport instead of stopping at the generator. It applies to responders too, via a new `MessageContext` overload of `SubscribeReplyAsync` whose default implementation drops the context and forwards, so no existing transport changes. Contributed by [Levy Barbosa (@Levyks)](https://github.com/Levyks) in [#913](https://github.com/corvus-dotnet/Corvus.JsonSchema/pull/913), with thanks. - **Generated clients accept a context-threaded request body** — A server result factory has long offered `Ok(Source, workspace)`, so a caller can assemble a response body lazily with its context threaded through and materialise it in one pass with no per-item closure. A client had no counterpart, so anyone with a collection to put in a *request* body had to close over it. The machinery was already present: the generators take the set of body pointers whose type is an object or array, and emit the generic overload only for those. The server command computed that set and the client command never did, so the client path silently opted out under what its own doc comment called "the conservative default". Generated clients now emit `OperationAsync(Model.Source body, ...)` alongside the plain overload. OpenAPI 2.0 was worse and is worth naming separately: the parameter did not exist there at all, so a 2.0 *server* was also missing the closure-free response factories every 3.x server has had. - **AsyncAPI gains a request/reply responder** — `IMessageTransport.SubscribeReplyAsync` subscribes to a channel, hands each request to a handler, and publishes the handler's reply on the correlated reply channel. It ships with a default implementation that throws `NotSupportedException`, so a transport that does not support responders is unaffected and existing custom transports continue to compile. - **Dynamic channel addresses take spans and memory, not only strings** — The generated methods for a channel whose address is parameterised now offer `string`, `ReadOnlySpan`, and `ReadOnlyMemory` overloads, so an address composed from UTF-8 bytes no longer has to become a string on the way to the transport. diff --git a/docs/ExampleRecipes/029-OpenApiClient/Generated/corvusjson-openapi.lock b/docs/ExampleRecipes/029-OpenApiClient/Generated/corvusjson-openapi.lock index 60789a79925..c326b6272fa 100644 --- a/docs/ExampleRecipes/029-OpenApiClient/Generated/corvusjson-openapi.lock +++ b/docs/ExampleRecipes/029-OpenApiClient/Generated/corvusjson-openapi.lock @@ -1,6 +1,6 @@ { "excludePaths": [], - "generatedAt": "2026-08-05T05:33:42.2654770\u002B00:00", + "generatedAt": "2026-08-05T08:11:35.5321285\u002B00:00", "generatedFiles": [ "ListPetsRequest.cs", "ListPetsResponse.cs", @@ -36,7 +36,7 @@ "Models/GetPetsLimit.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", + "generatorVersion": "1.0.0\u002B5b9fd234fc6c4d43829a968fde123e5fa70dd619", "includePaths": [], "rootNamespace": "Petstore.Client", "specFileHash": "199092ff57f7e5d812b7164055f1065eb27cdc0a7be4303bea5e4edc070217d8", diff --git a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/corvusjson-openapi.lock b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/corvusjson-openapi.lock index 89bd857746a..c402ba9c3b7 100644 --- a/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/corvusjson-openapi.lock +++ b/docs/ExampleRecipes/031-OpenApiAdvancedClient/Generated/corvusjson-openapi.lock @@ -1,6 +1,6 @@ { "excludePaths": [], - "generatedAt": "2026-08-05T05:33:45.0009931\u002B00:00", + "generatedAt": "2026-08-05T08:11:41.7431400\u002B00:00", "generatedFiles": [ "ListPetsRequest.cs", "ListPetsResponse.cs", @@ -135,7 +135,7 @@ "Models/PostPetsByPetIdPhotosBody.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", + "generatorVersion": "1.0.0\u002B5b9fd234fc6c4d43829a968fde123e5fa70dd619", "includePaths": [], "rootNamespace": "Petstore.Extended", "specFileHash": "27f81b7eb15fe66d4e3b2ffb3572a80fe7e0c6b564966f8b785c7496c1631ff8", diff --git a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/corvusjson-openapi.lock b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/corvusjson-openapi.lock index fa8864c26e4..69f3e2342d8 100644 --- a/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/corvusjson-openapi.lock +++ b/docs/ExampleRecipes/033-OpenApiEndToEnd/Generated/Client/corvusjson-openapi.lock @@ -1,6 +1,6 @@ { "excludePaths": [], - "generatedAt": "2026-08-05T05:33:47.8483521\u002B00:00", + "generatedAt": "2026-08-05T08:11:48.4023017\u002B00:00", "generatedFiles": [ "ListPetsRequest.cs", "ListPetsResponse.cs", @@ -135,7 +135,7 @@ "Models/PostPetsByPetIdPhotosBody.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", + "generatorVersion": "1.0.0\u002B5b9fd234fc6c4d43829a968fde123e5fa70dd619", "includePaths": [], "rootNamespace": "Petstore.EndToEnd.Client", "specFileHash": "27f81b7eb15fe66d4e3b2ffb3572a80fe7e0c6b564966f8b785c7496c1631ff8", diff --git a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/ReceiveLightMeasurementConsumer.cs b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/ReceiveLightMeasurementConsumer.cs index cfd310515fa..ff00dc5ed48 100644 --- a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/ReceiveLightMeasurementConsumer.cs +++ b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/ReceiveLightMeasurementConsumer.cs @@ -20,10 +20,9 @@ public sealed class ReceiveLightMeasurementConsumer : IAsyncDisposable private readonly ValidationMode validationMode; private readonly IMessageErrorPolicy errorPolicy; private readonly IMessageAuthenticationProvider? authProvider; - private const string ChannelAddress = "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; - private static readonly byte[] ChannelAddressUtf8 = "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"u8.ToArray(); - private const string DeadLetterChannel = "dead-letter.smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; - private static readonly byte[] DeadLetterChannelUtf8 = "dead-letter.smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"u8.ToArray(); + private ReadOnlyMemory subscribedChannelUtf8; + private byte[]? subscribedDeadLetterChannelUtf8; + private static readonly byte[] DeadLetterPrefixUtf8 = "dead-letter."u8.ToArray(); private static readonly MessageAuthenticationContext SaslScramAuthContext = new(SecuritySchemeType.Plain, "saslScram"); @@ -47,15 +46,55 @@ public ReceiveLightMeasurementConsumer(IMessageTransport transport, IReceiveLigh /// /// Starts consuming messages from the channel. /// + /// The ID of the streetlight. /// A cancellation token. - public async ValueTask StartAsync(CancellationToken cancellationToken = default) + public ValueTask StartAsync(string streetlightId, CancellationToken cancellationToken = default) { + return this.StartAsync(streetlightId.AsSpan(), cancellationToken); + } + + /// + /// Starts consuming messages from the channel composed from the supplied parameters. + /// + /// The ID of the streetlight. + /// A cancellation token. + /// A task that completes when the subscription is established. + public ValueTask StartAsync(ReadOnlySpan streetlightId, CancellationToken cancellationToken = default) + { + int channelLength = 39 + 18 + Encoding.UTF8.GetByteCount(streetlightId); + byte[] channelUtf8 = new byte[channelLength]; + int written = 0; + "smartylighting.streetlights.1.0.action."u8.CopyTo(channelUtf8.AsSpan(written)); + written += 39; + written += Encoding.UTF8.GetBytes(streetlightId, channelUtf8.AsSpan(written)); + ".lighting.measured"u8.CopyTo(channelUtf8.AsSpan(written)); + written += 18; + + this.subscribedChannelUtf8 = channelUtf8; + byte[] deadLetterUtf8 = new byte[DeadLetterPrefixUtf8.Length + channelLength]; + DeadLetterPrefixUtf8.CopyTo(deadLetterUtf8.AsSpan()); + channelUtf8.CopyTo(deadLetterUtf8.AsSpan(DeadLetterPrefixUtf8.Length)); + this.subscribedDeadLetterChannelUtf8 = deadLetterUtf8; + + return this.StartAsyncCore(channelUtf8, cancellationToken); + } + + /// + /// Starts consuming messages from the supplied (already UTF-8 encoded) channel. + /// + /// The channel address to subscribe to as UTF-8 bytes. + /// A cancellation token. + /// A task that completes when the subscription is established. + private async ValueTask StartAsyncCore(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken) + { + this.subscribedChannelUtf8 = channelUtf8; + if (this.authProvider is not null) { await this.authProvider.AuthenticateAsync(SaslScramAuthContext, cancellationToken).ConfigureAwait(false); } - await this.transport.SubscribeAsync(ChannelAddressUtf8, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false); + await this.transport.SubscribeAsync(this.subscribedChannelUtf8, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false); } /// @@ -64,7 +103,12 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) /// A cancellation token. public ValueTask StopAsync(CancellationToken cancellationToken = default) { - return this.transport.UnsubscribeAsync(ChannelAddressUtf8, cancellationToken); + if (this.subscribedChannelUtf8.IsEmpty) + { + ThrowHelper.ThrowConsumerNotStarted(); + } + + return this.transport.UnsubscribeAsync(this.subscribedChannelUtf8, cancellationToken); } private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasuredPayload payload, Corvus.Text.Json.JsonElement headers, CancellationToken cancellationToken) @@ -80,7 +124,7 @@ private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasu } catch (Exception ex) { - MessageErrorContext errorContext = new(ChannelAddressUtf8, MessageErrorKind.Handler, JsonElement.From(payload), headers); + MessageErrorContext errorContext = new(this.subscribedChannelUtf8, MessageErrorKind.Handler, JsonElement.From(payload), headers); MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, errorContext, cancellationToken).ConfigureAwait(false); switch (action) @@ -91,7 +135,7 @@ private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasu await this.StopAsync(cancellationToken).ConfigureAwait(false); return; case MessageErrorAction.DeadLetter: - await this.transport.DeadLetterAsync(DeadLetterChannelUtf8, ChannelAddressUtf8, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false); + await this.transport.DeadLetterAsync(this.subscribedDeadLetterChannelUtf8!, this.subscribedChannelUtf8, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false); return; default: return; diff --git a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/corvusjson-asyncapi.lock b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/corvusjson-asyncapi.lock index 0af149698d0..950f0f92168 100644 --- a/docs/ExampleRecipes/036-AsyncApiProducer/Generated/corvusjson-asyncapi.lock +++ b/docs/ExampleRecipes/036-AsyncApiProducer/Generated/corvusjson-asyncapi.lock @@ -1,6 +1,6 @@ { "excludeChannels": [], - "generatedAt": "2026-08-05T05:33:52.9811312\u002B00:00", + "generatedAt": "2026-08-05T08:12:00.7365448\u002B00:00", "generatedFiles": [ "TurnOnProducer.cs", "IReceiveLightMeasurementHandler.cs", @@ -24,7 +24,7 @@ "Models/TurnOnOffPayload.WhetherToTurnOnOrOffTheLight.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", + "generatorVersion": "1.0.0\u002B5b9fd234fc6c4d43829a968fde123e5fa70dd619", "includeChannels": [], "mode": "both", "rootNamespace": "Streetlights.Client", diff --git a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/ReceiveLightMeasurementConsumer.cs b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/ReceiveLightMeasurementConsumer.cs index cfd310515fa..ff00dc5ed48 100644 --- a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/ReceiveLightMeasurementConsumer.cs +++ b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/ReceiveLightMeasurementConsumer.cs @@ -20,10 +20,9 @@ public sealed class ReceiveLightMeasurementConsumer : IAsyncDisposable private readonly ValidationMode validationMode; private readonly IMessageErrorPolicy errorPolicy; private readonly IMessageAuthenticationProvider? authProvider; - private const string ChannelAddress = "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; - private static readonly byte[] ChannelAddressUtf8 = "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"u8.ToArray(); - private const string DeadLetterChannel = "dead-letter.smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; - private static readonly byte[] DeadLetterChannelUtf8 = "dead-letter.smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"u8.ToArray(); + private ReadOnlyMemory subscribedChannelUtf8; + private byte[]? subscribedDeadLetterChannelUtf8; + private static readonly byte[] DeadLetterPrefixUtf8 = "dead-letter."u8.ToArray(); private static readonly MessageAuthenticationContext SaslScramAuthContext = new(SecuritySchemeType.Plain, "saslScram"); @@ -47,15 +46,55 @@ public ReceiveLightMeasurementConsumer(IMessageTransport transport, IReceiveLigh /// /// Starts consuming messages from the channel. /// + /// The ID of the streetlight. /// A cancellation token. - public async ValueTask StartAsync(CancellationToken cancellationToken = default) + public ValueTask StartAsync(string streetlightId, CancellationToken cancellationToken = default) { + return this.StartAsync(streetlightId.AsSpan(), cancellationToken); + } + + /// + /// Starts consuming messages from the channel composed from the supplied parameters. + /// + /// The ID of the streetlight. + /// A cancellation token. + /// A task that completes when the subscription is established. + public ValueTask StartAsync(ReadOnlySpan streetlightId, CancellationToken cancellationToken = default) + { + int channelLength = 39 + 18 + Encoding.UTF8.GetByteCount(streetlightId); + byte[] channelUtf8 = new byte[channelLength]; + int written = 0; + "smartylighting.streetlights.1.0.action."u8.CopyTo(channelUtf8.AsSpan(written)); + written += 39; + written += Encoding.UTF8.GetBytes(streetlightId, channelUtf8.AsSpan(written)); + ".lighting.measured"u8.CopyTo(channelUtf8.AsSpan(written)); + written += 18; + + this.subscribedChannelUtf8 = channelUtf8; + byte[] deadLetterUtf8 = new byte[DeadLetterPrefixUtf8.Length + channelLength]; + DeadLetterPrefixUtf8.CopyTo(deadLetterUtf8.AsSpan()); + channelUtf8.CopyTo(deadLetterUtf8.AsSpan(DeadLetterPrefixUtf8.Length)); + this.subscribedDeadLetterChannelUtf8 = deadLetterUtf8; + + return this.StartAsyncCore(channelUtf8, cancellationToken); + } + + /// + /// Starts consuming messages from the supplied (already UTF-8 encoded) channel. + /// + /// The channel address to subscribe to as UTF-8 bytes. + /// A cancellation token. + /// A task that completes when the subscription is established. + private async ValueTask StartAsyncCore(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken) + { + this.subscribedChannelUtf8 = channelUtf8; + if (this.authProvider is not null) { await this.authProvider.AuthenticateAsync(SaslScramAuthContext, cancellationToken).ConfigureAwait(false); } - await this.transport.SubscribeAsync(ChannelAddressUtf8, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false); + await this.transport.SubscribeAsync(this.subscribedChannelUtf8, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false); } /// @@ -64,7 +103,12 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) /// A cancellation token. public ValueTask StopAsync(CancellationToken cancellationToken = default) { - return this.transport.UnsubscribeAsync(ChannelAddressUtf8, cancellationToken); + if (this.subscribedChannelUtf8.IsEmpty) + { + ThrowHelper.ThrowConsumerNotStarted(); + } + + return this.transport.UnsubscribeAsync(this.subscribedChannelUtf8, cancellationToken); } private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasuredPayload payload, Corvus.Text.Json.JsonElement headers, CancellationToken cancellationToken) @@ -80,7 +124,7 @@ private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasu } catch (Exception ex) { - MessageErrorContext errorContext = new(ChannelAddressUtf8, MessageErrorKind.Handler, JsonElement.From(payload), headers); + MessageErrorContext errorContext = new(this.subscribedChannelUtf8, MessageErrorKind.Handler, JsonElement.From(payload), headers); MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, errorContext, cancellationToken).ConfigureAwait(false); switch (action) @@ -91,7 +135,7 @@ private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasu await this.StopAsync(cancellationToken).ConfigureAwait(false); return; case MessageErrorAction.DeadLetter: - await this.transport.DeadLetterAsync(DeadLetterChannelUtf8, ChannelAddressUtf8, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false); + await this.transport.DeadLetterAsync(this.subscribedDeadLetterChannelUtf8!, this.subscribedChannelUtf8, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false); return; default: return; diff --git a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/corvusjson-asyncapi.lock b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/corvusjson-asyncapi.lock index b3d2d59e80f..fc7b56868d5 100644 --- a/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/corvusjson-asyncapi.lock +++ b/docs/ExampleRecipes/037-AsyncApiConsumer/Generated/corvusjson-asyncapi.lock @@ -1,6 +1,6 @@ { "excludeChannels": [], - "generatedAt": "2026-08-05T05:33:55.0813639\u002B00:00", + "generatedAt": "2026-08-05T08:12:03.9431590\u002B00:00", "generatedFiles": [ "TurnOnProducer.cs", "IReceiveLightMeasurementHandler.cs", @@ -24,7 +24,7 @@ "Models/TurnOnOffPayload.WhetherToTurnOnOrOffTheLight.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", + "generatorVersion": "1.0.0\u002B5b9fd234fc6c4d43829a968fde123e5fa70dd619", "includeChannels": [], "mode": "both", "rootNamespace": "Streetlights.Client", diff --git a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/ReceiveLightMeasurementConsumer.cs b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/ReceiveLightMeasurementConsumer.cs index cfd310515fa..ff00dc5ed48 100644 --- a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/ReceiveLightMeasurementConsumer.cs +++ b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/ReceiveLightMeasurementConsumer.cs @@ -20,10 +20,9 @@ public sealed class ReceiveLightMeasurementConsumer : IAsyncDisposable private readonly ValidationMode validationMode; private readonly IMessageErrorPolicy errorPolicy; private readonly IMessageAuthenticationProvider? authProvider; - private const string ChannelAddress = "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; - private static readonly byte[] ChannelAddressUtf8 = "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"u8.ToArray(); - private const string DeadLetterChannel = "dead-letter.smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; - private static readonly byte[] DeadLetterChannelUtf8 = "dead-letter.smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"u8.ToArray(); + private ReadOnlyMemory subscribedChannelUtf8; + private byte[]? subscribedDeadLetterChannelUtf8; + private static readonly byte[] DeadLetterPrefixUtf8 = "dead-letter."u8.ToArray(); private static readonly MessageAuthenticationContext SaslScramAuthContext = new(SecuritySchemeType.Plain, "saslScram"); @@ -47,15 +46,55 @@ public ReceiveLightMeasurementConsumer(IMessageTransport transport, IReceiveLigh /// /// Starts consuming messages from the channel. /// + /// The ID of the streetlight. /// A cancellation token. - public async ValueTask StartAsync(CancellationToken cancellationToken = default) + public ValueTask StartAsync(string streetlightId, CancellationToken cancellationToken = default) { + return this.StartAsync(streetlightId.AsSpan(), cancellationToken); + } + + /// + /// Starts consuming messages from the channel composed from the supplied parameters. + /// + /// The ID of the streetlight. + /// A cancellation token. + /// A task that completes when the subscription is established. + public ValueTask StartAsync(ReadOnlySpan streetlightId, CancellationToken cancellationToken = default) + { + int channelLength = 39 + 18 + Encoding.UTF8.GetByteCount(streetlightId); + byte[] channelUtf8 = new byte[channelLength]; + int written = 0; + "smartylighting.streetlights.1.0.action."u8.CopyTo(channelUtf8.AsSpan(written)); + written += 39; + written += Encoding.UTF8.GetBytes(streetlightId, channelUtf8.AsSpan(written)); + ".lighting.measured"u8.CopyTo(channelUtf8.AsSpan(written)); + written += 18; + + this.subscribedChannelUtf8 = channelUtf8; + byte[] deadLetterUtf8 = new byte[DeadLetterPrefixUtf8.Length + channelLength]; + DeadLetterPrefixUtf8.CopyTo(deadLetterUtf8.AsSpan()); + channelUtf8.CopyTo(deadLetterUtf8.AsSpan(DeadLetterPrefixUtf8.Length)); + this.subscribedDeadLetterChannelUtf8 = deadLetterUtf8; + + return this.StartAsyncCore(channelUtf8, cancellationToken); + } + + /// + /// Starts consuming messages from the supplied (already UTF-8 encoded) channel. + /// + /// The channel address to subscribe to as UTF-8 bytes. + /// A cancellation token. + /// A task that completes when the subscription is established. + private async ValueTask StartAsyncCore(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken) + { + this.subscribedChannelUtf8 = channelUtf8; + if (this.authProvider is not null) { await this.authProvider.AuthenticateAsync(SaslScramAuthContext, cancellationToken).ConfigureAwait(false); } - await this.transport.SubscribeAsync(ChannelAddressUtf8, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false); + await this.transport.SubscribeAsync(this.subscribedChannelUtf8, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false); } /// @@ -64,7 +103,12 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) /// A cancellation token. public ValueTask StopAsync(CancellationToken cancellationToken = default) { - return this.transport.UnsubscribeAsync(ChannelAddressUtf8, cancellationToken); + if (this.subscribedChannelUtf8.IsEmpty) + { + ThrowHelper.ThrowConsumerNotStarted(); + } + + return this.transport.UnsubscribeAsync(this.subscribedChannelUtf8, cancellationToken); } private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasuredPayload payload, Corvus.Text.Json.JsonElement headers, CancellationToken cancellationToken) @@ -80,7 +124,7 @@ private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasu } catch (Exception ex) { - MessageErrorContext errorContext = new(ChannelAddressUtf8, MessageErrorKind.Handler, JsonElement.From(payload), headers); + MessageErrorContext errorContext = new(this.subscribedChannelUtf8, MessageErrorKind.Handler, JsonElement.From(payload), headers); MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, errorContext, cancellationToken).ConfigureAwait(false); switch (action) @@ -91,7 +135,7 @@ private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasu await this.StopAsync(cancellationToken).ConfigureAwait(false); return; case MessageErrorAction.DeadLetter: - await this.transport.DeadLetterAsync(DeadLetterChannelUtf8, ChannelAddressUtf8, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false); + await this.transport.DeadLetterAsync(this.subscribedDeadLetterChannelUtf8!, this.subscribedChannelUtf8, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false); return; default: return; diff --git a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/corvusjson-asyncapi.lock b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/corvusjson-asyncapi.lock index 6f720fc9a82..fba58232164 100644 --- a/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/corvusjson-asyncapi.lock +++ b/docs/ExampleRecipes/038-AsyncApiEndToEnd/Generated/corvusjson-asyncapi.lock @@ -1,6 +1,6 @@ { "excludeChannels": [], - "generatedAt": "2026-08-05T05:33:56.3391374\u002B00:00", + "generatedAt": "2026-08-05T08:12:07.9100332\u002B00:00", "generatedFiles": [ "TurnOnProducer.cs", "IReceiveLightMeasurementHandler.cs", @@ -24,7 +24,7 @@ "Models/TurnOnOffPayload.WhetherToTurnOnOrOffTheLight.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", + "generatorVersion": "1.0.0\u002B5b9fd234fc6c4d43829a968fde123e5fa70dd619", "includeChannels": [], "mode": "both", "rootNamespace": "Streetlights.Client", diff --git a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/ReceiveLightMeasurementConsumer.cs b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/ReceiveLightMeasurementConsumer.cs index cfd310515fa..ff00dc5ed48 100644 --- a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/ReceiveLightMeasurementConsumer.cs +++ b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/ReceiveLightMeasurementConsumer.cs @@ -20,10 +20,9 @@ public sealed class ReceiveLightMeasurementConsumer : IAsyncDisposable private readonly ValidationMode validationMode; private readonly IMessageErrorPolicy errorPolicy; private readonly IMessageAuthenticationProvider? authProvider; - private const string ChannelAddress = "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; - private static readonly byte[] ChannelAddressUtf8 = "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"u8.ToArray(); - private const string DeadLetterChannel = "dead-letter.smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; - private static readonly byte[] DeadLetterChannelUtf8 = "dead-letter.smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"u8.ToArray(); + private ReadOnlyMemory subscribedChannelUtf8; + private byte[]? subscribedDeadLetterChannelUtf8; + private static readonly byte[] DeadLetterPrefixUtf8 = "dead-letter."u8.ToArray(); private static readonly MessageAuthenticationContext SaslScramAuthContext = new(SecuritySchemeType.Plain, "saslScram"); @@ -47,15 +46,55 @@ public ReceiveLightMeasurementConsumer(IMessageTransport transport, IReceiveLigh /// /// Starts consuming messages from the channel. /// + /// The ID of the streetlight. /// A cancellation token. - public async ValueTask StartAsync(CancellationToken cancellationToken = default) + public ValueTask StartAsync(string streetlightId, CancellationToken cancellationToken = default) { + return this.StartAsync(streetlightId.AsSpan(), cancellationToken); + } + + /// + /// Starts consuming messages from the channel composed from the supplied parameters. + /// + /// The ID of the streetlight. + /// A cancellation token. + /// A task that completes when the subscription is established. + public ValueTask StartAsync(ReadOnlySpan streetlightId, CancellationToken cancellationToken = default) + { + int channelLength = 39 + 18 + Encoding.UTF8.GetByteCount(streetlightId); + byte[] channelUtf8 = new byte[channelLength]; + int written = 0; + "smartylighting.streetlights.1.0.action."u8.CopyTo(channelUtf8.AsSpan(written)); + written += 39; + written += Encoding.UTF8.GetBytes(streetlightId, channelUtf8.AsSpan(written)); + ".lighting.measured"u8.CopyTo(channelUtf8.AsSpan(written)); + written += 18; + + this.subscribedChannelUtf8 = channelUtf8; + byte[] deadLetterUtf8 = new byte[DeadLetterPrefixUtf8.Length + channelLength]; + DeadLetterPrefixUtf8.CopyTo(deadLetterUtf8.AsSpan()); + channelUtf8.CopyTo(deadLetterUtf8.AsSpan(DeadLetterPrefixUtf8.Length)); + this.subscribedDeadLetterChannelUtf8 = deadLetterUtf8; + + return this.StartAsyncCore(channelUtf8, cancellationToken); + } + + /// + /// Starts consuming messages from the supplied (already UTF-8 encoded) channel. + /// + /// The channel address to subscribe to as UTF-8 bytes. + /// A cancellation token. + /// A task that completes when the subscription is established. + private async ValueTask StartAsyncCore(ReadOnlyMemory channelUtf8, CancellationToken cancellationToken) + { + this.subscribedChannelUtf8 = channelUtf8; + if (this.authProvider is not null) { await this.authProvider.AuthenticateAsync(SaslScramAuthContext, cancellationToken).ConfigureAwait(false); } - await this.transport.SubscribeAsync(ChannelAddressUtf8, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false); + await this.transport.SubscribeAsync(this.subscribedChannelUtf8, this.HandleMessageAsync, cancellationToken).ConfigureAwait(false); } /// @@ -64,7 +103,12 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) /// A cancellation token. public ValueTask StopAsync(CancellationToken cancellationToken = default) { - return this.transport.UnsubscribeAsync(ChannelAddressUtf8, cancellationToken); + if (this.subscribedChannelUtf8.IsEmpty) + { + ThrowHelper.ThrowConsumerNotStarted(); + } + + return this.transport.UnsubscribeAsync(this.subscribedChannelUtf8, cancellationToken); } private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasuredPayload payload, Corvus.Text.Json.JsonElement headers, CancellationToken cancellationToken) @@ -80,7 +124,7 @@ private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasu } catch (Exception ex) { - MessageErrorContext errorContext = new(ChannelAddressUtf8, MessageErrorKind.Handler, JsonElement.From(payload), headers); + MessageErrorContext errorContext = new(this.subscribedChannelUtf8, MessageErrorKind.Handler, JsonElement.From(payload), headers); MessageErrorAction action = await this.errorPolicy.HandleErrorAsync(ex, errorContext, cancellationToken).ConfigureAwait(false); switch (action) @@ -91,7 +135,7 @@ private async ValueTask HandleMessageAsync(Streetlights.Client.Models.LightMeasu await this.StopAsync(cancellationToken).ConfigureAwait(false); return; case MessageErrorAction.DeadLetter: - await this.transport.DeadLetterAsync(DeadLetterChannelUtf8, ChannelAddressUtf8, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false); + await this.transport.DeadLetterAsync(this.subscribedDeadLetterChannelUtf8!, this.subscribedChannelUtf8, JsonElement.From(payload), headers, ex, cancellationToken).ConfigureAwait(false); return; default: return; diff --git a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/corvusjson-asyncapi.lock b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/corvusjson-asyncapi.lock index 5efb8373683..6717a32624e 100644 --- a/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/corvusjson-asyncapi.lock +++ b/docs/ExampleRecipes/039-AsyncApiAuthentication/Generated/corvusjson-asyncapi.lock @@ -1,6 +1,6 @@ { "excludeChannels": [], - "generatedAt": "2026-08-05T05:33:57.5840693\u002B00:00", + "generatedAt": "2026-08-05T08:12:11.3360902\u002B00:00", "generatedFiles": [ "TurnOnProducer.cs", "IReceiveLightMeasurementHandler.cs", @@ -24,7 +24,7 @@ "Models/TurnOnOffPayload.WhetherToTurnOnOrOffTheLight.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", + "generatorVersion": "1.0.0\u002B5b9fd234fc6c4d43829a968fde123e5fa70dd619", "includeChannels": [], "mode": "both", "rootNamespace": "Streetlights.Client", diff --git a/docs/ExampleRecipes/042-OpenApi20Client/Generated/corvusjson-openapi.lock b/docs/ExampleRecipes/042-OpenApi20Client/Generated/corvusjson-openapi.lock index 49687544668..2c6b2ea9e62 100644 --- a/docs/ExampleRecipes/042-OpenApi20Client/Generated/corvusjson-openapi.lock +++ b/docs/ExampleRecipes/042-OpenApi20Client/Generated/corvusjson-openapi.lock @@ -1,6 +1,6 @@ { "excludePaths": [], - "generatedAt": "2026-08-05T05:33:58.8274409\u002B00:00", + "generatedAt": "2026-08-05T08:12:14.4380678\u002B00:00", "generatedFiles": [ "ListPetsRequest.cs", "ListPetsResponse.cs", @@ -47,7 +47,7 @@ "Models/UpdatePetWithFormFormBody.StatusEntity.JsonSchema.cs", "Models/Corvus__GlobalDeclarations.cs" ], - "generatorVersion": "1.0.0\u002B1016560942dce21c2839a2f495db55e0e8d6687a", + "generatorVersion": "1.0.0\u002B5b9fd234fc6c4d43829a968fde123e5fa70dd619", "includePaths": [], "rootNamespace": "Petstore.V2.Client", "specFileHash": "10cc3488325cfb61d4a68bc721ede20dcd9b93cb9533a03071189478971208ca", From 51415bd7637415a2583dc829142d65efdcfd52f1 Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Wed, 5 Aug 2026 09:53:14 +0100 Subject: [PATCH 10/11] Update the AsyncAPI runtime tests for parameterised channels (#803) The parameterised-channel work broke twenty-one call sites in the AsyncAPI runtime tests, and that is the feature rather than a regression. Those tests were asserting the bug: they delivered to smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured with the placeholder still in it, because that is the address the generated consumer really subscribed to. They now pass a streetlight id and deliver to the address it composes. The release notes move that change from New features to Breaking changes. It is both, but the half a consumer has to act on is the compile break: StartAsync() compiled before and does not now. Twenty-one call sites in one test project is a fair preview of what a downstream consumer meets. They also record that this reaches AsyncAPI 2.6, not only 3.0. The 2.6 generator holds a 3.0 generator and delegates emission to it, so both contributions applied to 2.6 the moment they applied to 3.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq --- VERSIONHISTORY.md | 6 +++--- .../AsyncApi26GeneratedEndToEndTests.cs | 4 ++-- .../AuthenticationEndToEndTests.cs | 4 ++-- .../CancellationAndConcurrencyTests.cs | 2 +- .../GeneratedEndToEndTests.cs | 20 +++++++++---------- .../MalformedDataTests.cs | 6 +++--- .../MiddlewareAndResilienceTests.cs | 18 ++++++++--------- 7 files changed, 30 insertions(+), 30 deletions(-) diff --git a/VERSIONHISTORY.md b/VERSIONHISTORY.md index ab3368d7c42..3bbf95987cd 100644 --- a/VERSIONHISTORY.md +++ b/VERSIONHISTORY.md @@ -2,20 +2,20 @@ ## V5.3.0 -V5.3.0 brings the OpenAPI and AsyncAPI generation work from the workflow-engine campaign back to the mainline. Generated clients gain a closure-free request-body overload, generated servers describe themselves from the specification, optional request bodies are finally optional, and the AsyncAPI transport surface gains a request/reply responder. Two changes are breaking, which is why this is a minor rather than a patch release. It also carries two community contributions from Levy Barbosa, covering AsyncAPI channel parameters and channel/operation bindings. +V5.3.0 brings the OpenAPI and AsyncAPI generation work from the workflow-engine campaign back to the mainline. Generated clients gain a closure-free request-body overload, generated servers describe themselves from the specification, optional request bodies are finally optional, and the AsyncAPI transport surface gains a request/reply responder. Three changes are breaking, which is why this is a minor rather than a patch release. It also carries two community contributions from Levy Barbosa, covering AsyncAPI channel parameters and channel/operation bindings. ### Breaking changes - **`IMessageTransport.RequestAsync` takes a `JsonWorkspace`** — The request/reply call now receives the workspace that owns the reply's lifetime, as a required parameter ahead of the optional `headers` and `cancellationToken`. Previously the reply was materialised against an ambient lifetime the caller could not control, which is the wrong shape for a caller that wants the reply to live exactly as long as the document it is being folded into. Every call site needs the workspace threading through it, and any custom `IMessageTransport` needs the new signature on its implementation of the abstract overload. The convenience overload that takes channel strings forwards to it unchanged in every other respect. +- **A generated AsyncAPI consumer for a parameterised channel now requires its parameters** — A channel whose address declares parameters (`orders.{orderId}.created`) generated a consumer that subscribed to the address *literally*, placeholder and all, so it listened on a channel no publisher ever wrote to. `StartAsync()` therefore took no arguments and looked like it worked. It now takes each declared parameter, so every call site for such a consumer fails to compile until it supplies them, and the subscription moves to the address those parameters compose. Both are intended: the call that compiled before was subscribing to nothing real. Composition allocates only the arrays the subscription retains, because the template is split at generation time so its literal parts are `u8` literals, only the parameter values are transcoded, and the address is filled once; the dead-letter address is built from those bytes rather than by concatenating a second string. A `ReadOnlySpan` overload sits beneath the `string` one, so a caller holding a span never creates a string just to have it measured and copied. This reaches AsyncAPI 2.6 as well as 3.0, because the 2.6 generator delegates its emission to the 3.0 one. Contributed by [Levy Barbosa (@Levyks)](https://github.com/Levyks) in [#914](https://github.com/corvus-dotnet/Corvus.JsonSchema/pull/914), with thanks; the allocation-free composition was added on merge. - **A generated binary response carries its body through the result factory** — An operation whose response is binary generated a parameterless `Ok()`, which could not express the body at all. The shipped example recipe said as much in a comment, returning `Ok()` and noting that the streaming was somebody else's problem. It now generates `Ok(ReadOnlyMemory body, string? contentType)` and `Ok(Func writeBody, string? contentType)`, so the handler supplies the bytes or a writer and chooses the content type rather than accepting whichever one the specification listed first. Handlers returning a binary response fail to compile until they pass a body, which is the point. Regenerate to pick it up. ### New features -- **Generated AsyncAPI consumers subscribe to a parameterised channel address** — A channel whose address declares parameters (`orders.{orderId}.created`) generated a consumer that subscribed to the address *literally*, placeholder and all, so it listened on a channel no publisher ever used. The consumer now takes each declared parameter as an argument to `StartAsync` and composes the address from them. The composition allocates only the arrays the subscription retains: the template is split at generation time so its literal parts are `u8` literals, only the parameter values are transcoded, and the address is filled once; the dead-letter address is built from those bytes rather than by concatenating a second string. A `ReadOnlySpan` overload sits beneath the `string` one, so a caller holding a span never creates a string just to have it measured and copied. Contributed by [Levy Barbosa (@Levyks)](https://github.com/Levyks) in [#914](https://github.com/corvus-dotnet/Corvus.JsonSchema/pull/914), with thanks; the allocation-free composition was added on merge. - **Generated AsyncAPI consumers carry channel and operation bindings to the transport** — A consumer whose channel or operation declares bindings is now subscribed with them, as a `MessageContext`, so protocol-specific metadata reaches the transport instead of stopping at the generator. It applies to responders too, via a new `MessageContext` overload of `SubscribeReplyAsync` whose default implementation drops the context and forwards, so no existing transport changes. Contributed by [Levy Barbosa (@Levyks)](https://github.com/Levyks) in [#913](https://github.com/corvus-dotnet/Corvus.JsonSchema/pull/913), with thanks. - **Generated clients accept a context-threaded request body** — A server result factory has long offered `Ok(Source, workspace)`, so a caller can assemble a response body lazily with its context threaded through and materialise it in one pass with no per-item closure. A client had no counterpart, so anyone with a collection to put in a *request* body had to close over it. The machinery was already present: the generators take the set of body pointers whose type is an object or array, and emit the generic overload only for those. The server command computed that set and the client command never did, so the client path silently opted out under what its own doc comment called "the conservative default". Generated clients now emit `OperationAsync(Model.Source body, ...)` alongside the plain overload. OpenAPI 2.0 was worse and is worth naming separately: the parameter did not exist there at all, so a 2.0 *server* was also missing the closure-free response factories every 3.x server has had. - **AsyncAPI gains a request/reply responder** — `IMessageTransport.SubscribeReplyAsync` subscribes to a channel, hands each request to a handler, and publishes the handler's reply on the correlated reply channel. It ships with a default implementation that throws `NotSupportedException`, so a transport that does not support responders is unaffected and existing custom transports continue to compile. -- **Dynamic channel addresses take spans and memory, not only strings** — The generated methods for a channel whose address is parameterised now offer `string`, `ReadOnlySpan`, and `ReadOnlyMemory` overloads, so an address composed from UTF-8 bytes no longer has to become a string on the way to the transport. +- **Dynamic channel addresses take spans and memory, not only strings** — The generated methods for a channel whose whole address is supplied by the caller now offer `string`, `ReadOnlySpan`, and `ReadOnlyMemory` overloads, so an address composed from UTF-8 bytes no longer has to become a string on the way to the transport. - **Generated code carries the documentation the specification declares** — Operation, parameter, and model descriptions from the source document are emitted as XML doc comments on the generated members, XML-escaped so a description containing markup does not break the build. - **An optional request body is optional in generated clients and servers** — A request body not marked `required` generated a mandatory parameter, so a caller had to supply something for a body the specification says may be absent. Clients now omit the body when it is not supplied, and servers treat it as absent rather than empty. ## V5.2.13 diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/AsyncApi26GeneratedEndToEndTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/AsyncApi26GeneratedEndToEndTests.cs index 1267322ce25..4d55a823878 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/AsyncApi26GeneratedEndToEndTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/AsyncApi26GeneratedEndToEndTests.cs @@ -16,7 +16,7 @@ namespace Corvus.Text.Json.AsyncApi.Runtime.Tests; public class AsyncApi26GeneratedEndToEndTests { private const string LightMeasurementChannel = - "smartylighting/streetlights/1/0/action/{streetlightId}/lighting/measured"; + "smartylighting/streetlights/1/0/action/1/lighting/measured"; [TestMethod] public async Task Producer_PublishTurnOnOff_UsesSubscribeOperationChannel() @@ -46,7 +46,7 @@ public async Task Consumer_StartAsync_UsesPublishOperationChannel() MockLightMeasurementHandler handler = new(); await using Streetlights26.ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None); - await consumer.StartAsync(); + await consumer.StartAsync("1"); await transport.DeliverAsync( LightMeasurementChannel, diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/AuthenticationEndToEndTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/AuthenticationEndToEndTests.cs index 62412599701..db7f590e849 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/AuthenticationEndToEndTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/AuthenticationEndToEndTests.cs @@ -19,7 +19,7 @@ namespace Corvus.Text.Json.AsyncApi.Runtime.Tests; public class AuthenticationEndToEndTests { private const string LightMeasurementChannel = - "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; + "smartylighting.streetlights.1.0.action.1.lighting.measured"; [TestMethod] public async Task Producer_WithAuthProvider_AuthenticatesBeforePublish() @@ -50,7 +50,7 @@ public async Task Consumer_WithAuthProvider_AuthenticatesBeforeSubscribe() await using ReceiveLightMeasurementConsumer consumer = new( transport, handler, ValidationMode.None, errorPolicy: null, authProvider: authProvider); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // Auth was called during StartAsync (before subscribe) Assert.AreEqual(1, authProvider.AuthenticateCallCount); diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/CancellationAndConcurrencyTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/CancellationAndConcurrencyTests.cs index 106eacaf6a2..5b3c0919c49 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/CancellationAndConcurrencyTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/CancellationAndConcurrencyTests.cs @@ -19,7 +19,7 @@ namespace Corvus.Text.Json.AsyncApi.Runtime.Tests; public class CancellationAndConcurrencyTests { private const string LightMeasurementChannel = - "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; + "smartylighting.streetlights.1.0.action.1.lighting.measured"; [TestMethod] public async Task RequestAsync_CancellationToken_ThrowsOperationCanceledException() diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/GeneratedEndToEndTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/GeneratedEndToEndTests.cs index 0c1baed6892..752a710bcd3 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/GeneratedEndToEndTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/GeneratedEndToEndTests.cs @@ -31,7 +31,7 @@ namespace Corvus.Text.Json.AsyncApi.Runtime.Tests; public class GeneratedEndToEndTests { private const string LightMeasurementChannel = - "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; + "smartylighting.streetlights.1.0.action.1.lighting.measured"; [TestMethod] public async Task Producer_PublishTurnOnOff_SerializesPayloadToChannel() @@ -122,7 +122,7 @@ public async Task Consumer_StartAsync_SubscribesToChannel() MockLightMeasurementHandler handler = new(); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // Deliver a message to verify the subscription is active await transport.DeliverAsync( @@ -139,7 +139,7 @@ public async Task Consumer_HandlerReceivesDeserializedPayload() MockLightMeasurementHandler handler = new(); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None); - await consumer.StartAsync(); + await consumer.StartAsync("1"); await transport.DeliverAsync( LightMeasurementChannel, @@ -160,7 +160,7 @@ public async Task Consumer_BasicValidation_InvalidPayload_WithAbortPolicy_Stops( DefaultMessageErrorPolicy abortPolicy = new(MessageErrorAction.Abort, MessageErrorAction.Abort, MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.Basic, abortPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // lumens has minimum:0, so -1 is invalid; policy says abort immediately (0 retries) await transport.DeliverAsync( @@ -183,7 +183,7 @@ public async Task Consumer_ValidationNone_InvalidPayload_StillDelivers() MockLightMeasurementHandler handler = new(); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // Invalid payload (lumens < 0) but validation is disabled await transport.DeliverAsync( @@ -200,7 +200,7 @@ public async Task Consumer_StopAsync_UnsubscribesFromChannel() MockLightMeasurementHandler handler = new(); ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None); - await consumer.StartAsync(); + await consumer.StartAsync("1"); await consumer.StopAsync(); // After stopping, delivering should throw because there's no subscription @@ -217,7 +217,7 @@ public async Task Consumer_DisposeAsync_UnsubscribesFromChannel() MockLightMeasurementHandler handler = new(); ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None); - await consumer.StartAsync(); + await consumer.StartAsync("1"); await consumer.DisposeAsync(); await Assert.ThrowsExactlyAsync( @@ -246,7 +246,7 @@ public async Task Consumer_DefaultPolicy_SkipsAfterRetries() // Default policy: 3 retries then skip await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.Basic); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // Invalid payload triggers validation failure; default policy retries 3 times, then skips await transport.DeliverAsync( @@ -272,7 +272,7 @@ public async Task Consumer_DeadLetterPolicy_SendsToDeadLetterChannel() DefaultMessageErrorPolicy deadLetterPolicy = new(MessageErrorAction.DeadLetter, MessageErrorAction.DeadLetter, MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.Basic, deadLetterPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); await transport.DeliverAsync( LightMeasurementChannel, @@ -299,7 +299,7 @@ public async Task Consumer_SkipPolicy_HandlerFailure_SkipsMessage() DefaultMessageErrorPolicy skipPolicy = new(MessageErrorAction.Skip, MessageErrorAction.Skip, MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, throwingHandler, ValidationMode.None, skipPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); await transport.DeliverAsync( LightMeasurementChannel, diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MalformedDataTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MalformedDataTests.cs index 04ad58bf39d..ed3090571a5 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MalformedDataTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MalformedDataTests.cs @@ -19,7 +19,7 @@ namespace Corvus.Text.Json.AsyncApi.Runtime.Tests; public class MalformedDataTests { private const string LightMeasurementChannel = - "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; + "smartylighting.streetlights.1.0.action.1.lighting.measured"; [TestMethod] public async Task DeliverRaw_MalformedJson_PolicyDeadLetter_SendsToDeadLetter() @@ -143,7 +143,7 @@ public async Task Consumer_SchemaValidation_WrongType_DeadLetters() MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.Basic, dlPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // Valid JSON but lumens is a string — type mismatch fails validation await transport.DeliverAsync( @@ -166,7 +166,7 @@ public async Task Consumer_Detailed_Validation_IncludesSpecificPropertyPath() MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.Detailed, dlPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // lumens value exceeds no constraint (minimum:0) but type is wrong await transport.DeliverAsync( diff --git a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MiddlewareAndResilienceTests.cs b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MiddlewareAndResilienceTests.cs index 176fc4b2471..b0dd95ac6f0 100644 --- a/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MiddlewareAndResilienceTests.cs +++ b/tests/Corvus.Text.Json.AsyncApi.Runtime.Tests/MiddlewareAndResilienceTests.cs @@ -19,7 +19,7 @@ namespace Corvus.Text.Json.AsyncApi.Runtime.Tests; public class MiddlewareAndResilienceTests { private const string LightMeasurementChannel = - "smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured"; + "smartylighting.streetlights.1.0.action.1.lighting.measured"; [TestMethod] public async Task Consumer_WithMiddleware_MiddlewareWrapsHandler() @@ -31,7 +31,7 @@ public async Task Consumer_WithMiddleware_MiddlewareWrapsHandler() CountingHandler handler = new(); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None); - await consumer.StartAsync(); + await consumer.StartAsync("1"); await transport.DeliverAsync( LightMeasurementChannel, @@ -60,7 +60,7 @@ public async Task Consumer_HandlerThrows_PolicySkip_ContinuesProcessing() MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None, skipPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // First message: handler throws, policy says skip await transport.DeliverAsync( @@ -89,7 +89,7 @@ public async Task Consumer_HandlerThrows_PolicyDeadLetter_SendsToDeadLetter() MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None, dlPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); await transport.DeliverAsync( LightMeasurementChannel, @@ -114,7 +114,7 @@ public async Task Consumer_HandlerThrows_PolicyAbort_UnsubscribesFromChannel() MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None, abortPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); await transport.DeliverAsync( LightMeasurementChannel, @@ -139,7 +139,7 @@ public async Task Consumer_ValidationFailure_PolicyDeadLetter_SendsInvalidPayloa MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.Basic, dlPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // Invalid: lumens is negative (minimum: 0) await transport.DeliverAsync( @@ -170,7 +170,7 @@ public async Task Consumer_MultipleFailures_PolicySkip_ContinuesEachTime() MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.None, skipPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // Deliver 3 messages that all fail for (int i = 0; i < 3; i++) @@ -199,7 +199,7 @@ public async Task Consumer_ValidationMode_Detailed_IncludesSchemaPath() MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.Detailed, dlPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // Invalid: lumens is a string instead of integer await transport.DeliverAsync( @@ -226,7 +226,7 @@ public async Task Consumer_HandlerSucceeds_NoDeadLetter_NoAbort() MessageErrorAction.Abort); await using ReceiveLightMeasurementConsumer consumer = new(transport, handler, ValidationMode.Basic, strictPolicy); - await consumer.StartAsync(); + await consumer.StartAsync("1"); // Valid message — handler succeeds, no policy invoked await transport.DeliverAsync( From 38b87ac88834ad03444586a31a6a321cf19339a5 Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Wed, 5 Aug 2026 10:54:03 +0100 Subject: [PATCH 11/11] Document the capabilities this release adds (#803) Three gaps, found by asking what a reader would have to reverse-engineer from the release notes. docs/OpenApi.md had not been touched at all, so the headline client feature and a breaking change were both undocumented. It now covers building a request body without closures (the client counterpart of the server's Ok), that an optional body is optional, and that a binary response carries its body through the result factory. docs/AsyncApi.md documented channel parameters for producers only, which was correct until now: consumers ignored them and subscribed to the template literally. The section now covers the consumer side, including the span overload, and says plainly what the old behaviour was, since anyone upgrading meets the compile break first and deserves to know it was hiding a bug. Corvus.Text.Json.OpenApi.Polly shipped with no documentation whatsoever. It now has the section its AsyncAPI counterpart has had, with the caveat that a retry pipeline cannot tell an idempotent operation from one that is not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq --- docs/AsyncApi.md | 21 +++++ docs/OpenApi.md | 69 +++++++++++++++ docs/code-sample-catalog.yaml | 154 ++++++++++++++++++---------------- 3 files changed, 170 insertions(+), 74 deletions(-) diff --git a/docs/AsyncApi.md b/docs/AsyncApi.md index e400cda6edd..616a9505f4a 100644 --- a/docs/AsyncApi.md +++ b/docs/AsyncApi.md @@ -890,6 +890,27 @@ await producer.PublishTurnOnOffAsync( The generated code constructs the channel address from the template using zero-allocation UTF-8 byte manipulation with pooled buffers — no string concatenation or allocation on the hot path. +### Consumers + +A consumer for a parameterised channel takes the same parameters on `StartAsync`, and subscribes to the address they compose: + +```csharp +await using ReceiveLightMeasurementConsumer consumer = new(transport, handler); + +// Subscribes to "smartylighting.streetlights.1.0.action.lamp-42.lighting.measured" +await consumer.StartAsync(streetlightId: "lamp-42"); +``` + +A `ReadOnlySpan` overload sits beneath the `string` one, so a caller that already holds a span does not create a string just to have it measured and copied: + +```csharp +await consumer.StartAsync(streetlightId: idSpan); +``` + +The address is composed the same way the producer composes its own: the template is split when the code is generated, so its literal parts are `u8` literals and only the parameter values are transcoded, filled once into the array the subscription retains. The dead-letter address is built from those same bytes rather than by concatenating a second string. + +> **Before 5.3.0** a consumer for a parameterised channel had no parameters on `StartAsync` and subscribed to the template *literally*, placeholder and all — so it listened on a channel no publisher ever wrote to. Supplying the parameters is therefore a breaking change with a fix inside it: the call that compiled before was subscribing to nothing real. + ## Message Headers When an AsyncAPI message defines a `headers` schema (directly or via message traits), the generated code produces typed header structures. Headers provide metadata about the message — correlation IDs, trace context, content versioning — separate from the payload. diff --git a/docs/OpenApi.md b/docs/OpenApi.md index bd2a192b662..cc1b9813bcd 100644 --- a/docs/OpenApi.md +++ b/docs/OpenApi.md @@ -263,6 +263,31 @@ JsonString nextPage = response.XNextHeader; ## Request Bodies +### Building a Body Without Closures + +Where a body is an object or an array, the client also emits a generic overload taking that type's `Source`. It is the client counterpart of the server's `Ok(...)` factory: the body is assembled lazily with the caller's context threaded through, and materialised in one pass, so putting a collection in a request body costs no per-item closure. + +```csharp +// Without: the lambda captures `pets`, allocating a closure per call. +await client.CreatePetsAsync(body: Pets.Build(b => { foreach (Pet p in pets) b.AddItem(p); })); + +// With: `pets` is threaded through, and the lambda stays static. +await client.CreatePetsAsync( + body: Pets.Build(in pets, static (in IReadOnlyList source, ref Pets.Builder b) => + { + foreach (Pet p in source) + { + b.AddItem(p); + } + })); +``` + +The overload is emitted only where it can be: a body whose type is a scalar has no `Source`, so nothing changes there. + +### Optional Bodies + +A request body that is not marked `required` is optional in the generated signature. A client omits it when it is not supplied, rather than sending an empty one, and a server sees it as absent rather than as an empty value. + ### JSON Bodies (Client) Object bodies use the generated `Builder` pattern. Required properties are mandatory parameters; optional ones have defaults: @@ -399,6 +424,22 @@ await foreach (ParsedJsonDocument doc in activityResponse.Enumera ## Binary Transfers +### Binary Responses (Server) + +A binary response carries its body through the result factory, either as bytes or as a writer, and chooses its content type: + +```csharp +// Bytes you already hold. +return DownloadPhotoResult.Ok(photo.Data, photo.ContentType); + +// Or stream it, for something you do not want to buffer. +return DownloadPhotoResult.Ok( + (stream, ct) => blob.DownloadToAsync(stream, ct), + "image/png"); +``` + +> **Before 5.3.0** this factory took no arguments, so a handler could not supply the body at all and the content type came from whichever one the specification happened to list first. Handlers returning a binary response must now pass a body, which is a compile break with the missing capability inside it. + ### File Upload (Multipart) ```csharp @@ -1033,6 +1074,34 @@ var petsClient = new ApiPetsClient(new HttpClientTransport(httpClient)); await using var response = await petsClient.ListPetsAsync(); ``` +## Resilience (Polly Integration) + +The `Corvus.Text.Json.OpenApi.Polly` package wraps any `IApiTransport` in a [Polly](https://github.com/App-vNext/Polly) resilience pipeline, so retry and circuit-breaking are configured once for the client rather than at every call site. It is the OpenAPI counterpart of `Corvus.Text.Json.AsyncApi.Polly`. + +```bash +dotnet add package Corvus.Text.Json.OpenApi.Polly +``` + +```csharp +using Corvus.Text.Json.OpenApi.Polly; +using Polly; +using Polly.Retry; + +ResiliencePipeline pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddTimeout(TimeSpan.FromSeconds(10)) + .Build(); + +await using HttpClientTransport inner = new(httpClient); +await using ResilientApiTransport transport = new(inner, pipeline); + +ApiPetsClient client = new(transport); +``` + +Every operation passes through the pipeline and is otherwise unchanged, so the generated client neither knows nor needs to know that it is there. Disposing the resilient transport disposes the one it wraps. + +Choose the strategies with the API in mind: retrying a non-idempotent operation can duplicate work, and the pipeline cannot tell which is which. + ## Webhooks and Callbacks OpenAPI specifications can define **webhooks** (top-level, spec-wide notifications) and **callbacks** (per-operation, triggered by runtime expressions). The Corvus code generator supports both with dedicated commands that produce the same output structure as regular client/server generation. diff --git a/docs/code-sample-catalog.yaml b/docs/code-sample-catalog.yaml index 8ae828168d7..7b4052de71a 100644 --- a/docs/code-sample-catalog.yaml +++ b/docs/code-sample-catalog.yaml @@ -656,29 +656,31 @@ main-docs: - {index: 39, language: csharp, lines: [832, 849], category: compilable, verified: false} - {index: 40, language: csharp, lines: [853, 875], category: compilable, verified: false} - {index: 41, language: csharp, lines: [883, 889], category: compilable, verified: false} - - {index: 42, language: json, lines: [901, 927]} - - {index: 43, language: csharp, lines: [933, 941], category: compilable, verified: false} - - {index: 44, language: csharp, lines: [949, 977], category: compilable, verified: false} - - {index: 45, language: csharp, lines: [983, 991], category: compilable, verified: false} - - {index: 46, language: csharp, lines: [999, 1012], category: compilable, verified: false} - - {index: 47, language: csharp, lines: [1016, 1038], category: compilable, verified: false} - - {index: 48, language: csharp, lines: [1061, 1072], category: compilable, verified: false} - - {index: 49, language: json, lines: [1078, 1098]} - - {index: 50, language: csharp, lines: [1108, 1123], category: compilable, verified: false} - - {index: 51, language: csharp, lines: [1127, 1134], category: compilable, verified: false} - - {index: 52, language: json, lines: [1153, 1167]} - - {index: 53, language: csharp, lines: [1171, 1180], category: compilable, verified: false} - - {index: 54, language: csharp, lines: [1190, 1198], category: compilable, verified: false} - - {index: 55, language: csharp, lines: [1211, 1228], category: compilable, verified: false} - - {index: 56, language: bash, lines: [1236, 1238]} - - {index: 57, language: csharp, lines: [1240, 1251], category: compilable, verified: false} - - {index: 58, language: bash, lines: [1261, 1263]} - - {index: 59, language: bash, lines: [1281, 1299]} - - {index: 60, language: bash, lines: [1303, 1305]} - - {index: 61, language: , lines: [1309, 1319]} - - {index: 62, language: bash, lines: [1332, 1338]} - - {index: 63, language: csharp, lines: [1374, 1379], category: compilable, verified: false} - - {index: 64, language: , lines: [1393, 1398]} + - {index: 42, language: csharp, lines: [897, 902], category: compilable, verified: false} + - {index: 43, language: csharp, lines: [906, 908], category: compilable, verified: false} + - {index: 44, language: json, lines: [922, 948]} + - {index: 45, language: csharp, lines: [954, 962], category: compilable, verified: false} + - {index: 46, language: csharp, lines: [970, 998], category: compilable, verified: false} + - {index: 47, language: csharp, lines: [1004, 1012], category: compilable, verified: false} + - {index: 48, language: csharp, lines: [1020, 1033], category: compilable, verified: false} + - {index: 49, language: csharp, lines: [1037, 1059], category: compilable, verified: false} + - {index: 50, language: csharp, lines: [1082, 1093], category: compilable, verified: false} + - {index: 51, language: json, lines: [1099, 1119]} + - {index: 52, language: csharp, lines: [1129, 1144], category: compilable, verified: false} + - {index: 53, language: csharp, lines: [1148, 1155], category: compilable, verified: false} + - {index: 54, language: json, lines: [1174, 1188]} + - {index: 55, language: csharp, lines: [1192, 1201], category: compilable, verified: false} + - {index: 56, language: csharp, lines: [1211, 1219], category: compilable, verified: false} + - {index: 57, language: csharp, lines: [1232, 1249], category: compilable, verified: false} + - {index: 58, language: bash, lines: [1257, 1259]} + - {index: 59, language: csharp, lines: [1261, 1272], category: compilable, verified: false} + - {index: 60, language: bash, lines: [1282, 1284]} + - {index: 61, language: bash, lines: [1302, 1320]} + - {index: 62, language: bash, lines: [1324, 1326]} + - {index: 63, language: , lines: [1330, 1340]} + - {index: 64, language: bash, lines: [1353, 1359]} + - {index: 65, language: csharp, lines: [1395, 1400], category: compilable, verified: false} + - {index: 66, language: , lines: [1414, 1419]} - path: "docs/AsyncApiMessageResumption.md" blocks: - {index: 0, language: , lines: [26, 32]} @@ -1250,57 +1252,61 @@ main-docs: - {index: 12, language: csharp, lines: [220, 237], category: compilable, verified: false} - {index: 13, language: csharp, lines: [245, 250], category: compilable, verified: false} - {index: 14, language: csharp, lines: [258, 262], category: compilable, verified: false} - - {index: 15, language: csharp, lines: [270, 282], category: compilable, verified: false} - - {index: 16, language: csharp, lines: [288, 301], category: compilable, verified: false} - - {index: 17, language: csharp, lines: [307, 316], category: compilable, verified: false} - - {index: 18, language: csharp, lines: [320, 332], category: compilable, verified: false} - - {index: 19, language: csharp, lines: [340, 355], category: compilable, verified: false} - - {index: 20, language: csharp, lines: [361, 369], category: compilable, verified: false} - - {index: 21, language: csharp, lines: [375, 386], category: compilable, verified: false} - - {index: 22, language: csharp, lines: [390, 398], category: compilable, verified: false} - - {index: 23, language: csharp, lines: [404, 412], category: compilable, verified: false} - - {index: 24, language: csharp, lines: [416, 421], category: compilable, verified: false} - - {index: 25, language: csharp, lines: [427, 444], category: fragment, verified: false} - - {index: 26, language: csharp, lines: [452, 465], category: fragment, verified: false} - - {index: 27, language: csharp, lines: [491, 503], category: fragment, verified: false} - - {index: 28, language: csharp, lines: [558, 581], category: fragment, verified: false} - - {index: 29, language: csharp, lines: [587, 607], category: fragment, verified: false} - - {index: 30, language: csharp, lines: [625, 635], category: fragment, verified: false} - - {index: 31, language: csharp, lines: [639, 661], category: fragment, verified: false} - - {index: 32, language: csharp, lines: [665, 695], category: fragment, verified: false} - - {index: 33, language: csharp, lines: [699, 709], category: fragment, verified: false} - - {index: 34, language: csharp, lines: [715, 740], category: fragment, verified: false} - - {index: 35, language: csharp, lines: [748, 768], category: fragment, verified: false} - - {index: 36, language: csharp, lines: [774, 779], category: fragment, verified: false} - - {index: 37, language: csharp, lines: [785, 803], category: fragment, verified: false} - - {index: 38, language: csharp, lines: [807, 814], category: fragment, verified: false} - - {index: 39, language: csharp, lines: [820, 826], category: fragment, verified: false} - - {index: 40, language: csharp, lines: [832, 840], category: fragment, verified: false} - - {index: 41, language: csharp, lines: [852, 858], category: fragment, verified: false} - - {index: 42, language: csharp, lines: [862, 871], category: compilable, verified: false} - - {index: 43, language: csharp, lines: [883, 912], category: compilable, verified: false} - - {index: 44, language: csharp, lines: [916, 942], category: compilable, verified: false} - - {index: 45, language: csharp, lines: [946, 959], category: compilable, verified: false} - - {index: 46, language: csharp, lines: [976, 991], category: compilable, verified: false} - - {index: 47, language: csharp, lines: [999, 1015], category: compilable, verified: false} - - {index: 48, language: csharp, lines: [1023, 1034], category: compilable, verified: false} - - {index: 49, language: , lines: [1044, 1052]} - - {index: 50, language: bash, lines: [1065, 1069]} - - {index: 51, language: csharp, lines: [1078, 1089], category: compilable, verified: false} - - {index: 52, language: bash, lines: [1095, 1099]} - - {index: 53, language: csharp, lines: [1107, 1118], category: compilable, verified: false} - - {index: 54, language: csharp, lines: [1134, 1144], category: compilable, verified: false} - - {index: 55, language: csharp, lines: [1154, 1168], category: compilable, verified: false} - - {index: 56, language: json, lines: [1174, 1218]} - - {index: 57, language: bash, lines: [1224, 1226]} - - {index: 58, language: bash, lines: [1242, 1244]} - - {index: 59, language: bash, lines: [1250, 1252]} - - {index: 60, language: bash, lines: [1260, 1262]} - - {index: 61, language: bash, lines: [1270, 1272]} - - {index: 62, language: bash, lines: [1300, 1317]} - - {index: 63, language: bash, lines: [1321, 1331]} - - {index: 64, language: bash, lines: [1339, 1351]} - - {index: 65, language: bash, lines: [1357, 1372]} + - {index: 15, language: csharp, lines: [270, 283], category: compilable, verified: false} + - {index: 16, language: csharp, lines: [295, 307], category: compilable, verified: false} + - {index: 17, language: csharp, lines: [313, 326], category: compilable, verified: false} + - {index: 18, language: csharp, lines: [332, 341], category: compilable, verified: false} + - {index: 19, language: csharp, lines: [345, 357], category: compilable, verified: false} + - {index: 20, language: csharp, lines: [365, 380], category: compilable, verified: false} + - {index: 21, language: csharp, lines: [386, 394], category: compilable, verified: false} + - {index: 22, language: csharp, lines: [400, 411], category: compilable, verified: false} + - {index: 23, language: csharp, lines: [415, 423], category: compilable, verified: false} + - {index: 24, language: csharp, lines: [431, 439], category: compilable, verified: false} + - {index: 25, language: csharp, lines: [445, 453], category: fragment, verified: false} + - {index: 26, language: csharp, lines: [457, 462], category: fragment, verified: false} + - {index: 27, language: csharp, lines: [468, 485], category: fragment, verified: false} + - {index: 28, language: csharp, lines: [493, 506], category: fragment, verified: false} + - {index: 29, language: csharp, lines: [532, 544], category: fragment, verified: false} + - {index: 30, language: csharp, lines: [599, 622], category: fragment, verified: false} + - {index: 31, language: csharp, lines: [628, 648], category: fragment, verified: false} + - {index: 32, language: csharp, lines: [666, 676], category: fragment, verified: false} + - {index: 33, language: csharp, lines: [680, 702], category: fragment, verified: false} + - {index: 34, language: csharp, lines: [706, 736], category: fragment, verified: false} + - {index: 35, language: csharp, lines: [740, 750], category: fragment, verified: false} + - {index: 36, language: csharp, lines: [756, 781], category: fragment, verified: false} + - {index: 37, language: csharp, lines: [789, 809], category: fragment, verified: false} + - {index: 38, language: csharp, lines: [815, 820], category: fragment, verified: false} + - {index: 39, language: csharp, lines: [826, 844], category: fragment, verified: false} + - {index: 40, language: csharp, lines: [848, 855], category: fragment, verified: false} + - {index: 41, language: csharp, lines: [861, 867], category: fragment, verified: false} + - {index: 42, language: csharp, lines: [873, 881], category: compilable, verified: false} + - {index: 43, language: csharp, lines: [893, 899], category: compilable, verified: false} + - {index: 44, language: csharp, lines: [903, 912], category: compilable, verified: false} + - {index: 45, language: csharp, lines: [924, 953], category: compilable, verified: false} + - {index: 46, language: csharp, lines: [957, 983], category: compilable, verified: false} + - {index: 47, language: csharp, lines: [987, 1000], category: compilable, verified: false} + - {index: 48, language: csharp, lines: [1017, 1032], category: compilable, verified: false} + - {index: 49, language: csharp, lines: [1040, 1056], category: compilable, verified: false} + - {index: 50, language: csharp, lines: [1064, 1075], category: compilable, verified: false} + - {index: 51, language: bash, lines: [1081, 1083]} + - {index: 52, language: csharp, lines: [1085, 1099], category: compilable, verified: false} + - {index: 53, language: , lines: [1113, 1121]} + - {index: 54, language: bash, lines: [1134, 1138]} + - {index: 55, language: csharp, lines: [1147, 1158], category: compilable, verified: false} + - {index: 56, language: bash, lines: [1164, 1168]} + - {index: 57, language: csharp, lines: [1176, 1187], category: compilable, verified: false} + - {index: 58, language: csharp, lines: [1203, 1213], category: compilable, verified: false} + - {index: 59, language: csharp, lines: [1223, 1237], category: compilable, verified: false} + - {index: 60, language: json, lines: [1243, 1287]} + - {index: 61, language: bash, lines: [1293, 1295]} + - {index: 62, language: bash, lines: [1311, 1313]} + - {index: 63, language: bash, lines: [1319, 1321]} + - {index: 64, language: bash, lines: [1329, 1331]} + - {index: 65, language: bash, lines: [1339, 1341]} + - {index: 66, language: bash, lines: [1369, 1386]} + - {index: 67, language: bash, lines: [1390, 1400]} + - {index: 68, language: bash, lines: [1408, 1420]} + - {index: 69, language: bash, lines: [1426, 1441]} - path: "docs/ParsedJsonDocument.md" blocks: - {index: 0, language: csharp, lines: [40, 56], category: compilable, verified: false}