From f035438b42cb18f5d55ee18e5efc7bc6de5e0763 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 4 Sep 2026 14:56:38 -0400 Subject: [PATCH 1/5] feat(http): add RespondBytes for raw binary response payloads Closes the gap between RespondText and RespondJson - a consumer needing to serve raw bytes (e.g. a fetched certificate's DER bytes) previously had to round-trip through a Latin1-encoded string, an awkward workaround surfaced by dogfood evidence in alexa-vox-craft. Follows RespondJson's serialize-once-to-bytes model; recorded as ADR-0051 Amendment 2. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- ...pono-http-handler-based-testing-package.md | 25 +++++++++++++++++ ...tionBuilder.RespondBytes(byte[],string).md | 28 +++++++++++++++++++ ...no.Http.HttpResponseRegistrationBuilder.md | 1 + .../HttpResponseRegistrationBuilder.cs | 18 ++++++++++++ .../TestHttpHandlerTests.cs | 28 +++++++++++++++++++ 5 files changed, 100 insertions(+) create mode 100644 docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md diff --git a/docs/adr/0051-compono-http-handler-based-testing-package.md b/docs/adr/0051-compono-http-handler-based-testing-package.md index 7c381ea..47a2766 100644 --- a/docs/adr/0051-compono-http-handler-based-testing-package.md +++ b/docs/adr/0051-compono-http-handler-based-testing-package.md @@ -610,3 +610,28 @@ opacity is a deliberate ADR-0048 property this amendment doesn't weaken just to let `Compono.Http` introspect it later. If a future need for richer `Match` diagnostics surfaces beyond this one case, that's a separate, its-own-evidence decision. + +## Amendment 2 (2026-09-04): `RespondBytes` for raw binary payloads + +Dogfood evidence from `alexa-vox-craft` (`RequestVerificationTests.GetCertificate_ParsesFetchedCertificateBytes`) +surfaced a gap: `HttpResponseRegistrationBuilder` had `RespondText` (string +content) and `RespondJson` (serialized-to-JSON content), but no way to +respond with an arbitrary byte payload. That test needed to serve a +fetched X.509 certificate's raw DER bytes, and worked around the gap by +round-tripping the bytes through `Encoding.Latin1` text - lossless (Latin1 +is a single-byte, bijective 0-255 char\<-\>byte mapping, unlike UTF-8) but +an awkward, easy-to-get-wrong pattern for what is fundamentally a +binary-content need, not a text one. + +**Decision**: add `RespondBytes(byte[] content, string mediaType = +"application/octet-stream")`, following the same +serialize-once-to-bytes model as `RespondJson` (this ADR's original +Decision Outcome, "Serialize-once-to-bytes model"): `content` is captured +once, and every matched invocation constructs a fresh `ByteArrayContent` +over it with its own `MediaTypeHeaderValue` - never a shared, mutable +header instance across responses (same reasoning as `RespondJsonBytes`'s +existing `MediaTypeHeaderValue` handling). + +**Explicitly not done**: no change to `RespondText` or `RespondJson` - +this is a purely additive third `Respond*` overload, source-compatible +with every existing call site. diff --git a/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md b/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md new file mode 100644 index 0000000..ae1ce00 --- /dev/null +++ b/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md @@ -0,0 +1,28 @@ +#### [Compono\.Http](index.md 'index') +### [Compono\.Http](Compono.Http.md 'Compono\.Http').[HttpResponseRegistrationBuilder](Compono.Http.HttpResponseRegistrationBuilder.md 'Compono\.Http\.HttpResponseRegistrationBuilder') + +## HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\) Method + +Responds with HTTP 200 OK and [content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') verbatim, as +[mediaType](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).mediaType 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.mediaType') \- the raw\-bytes counterpart to [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)') for +binary payloads \(e\.g\. a fetched certificate's DER bytes\) that would otherwise need a lossy +or awkward text encoding to round\-trip through [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)')\. Same +serialize\-once\-to\-bytes model as [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)'): +[content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') is captured once; every matched invocation constructs a fresh +[System\.Net\.Http\.ByteArrayContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.bytearraycontent 'System\.Net\.Http\.ByteArrayContent') over it \(ADR\-0051 Amendment 2\)\. + +```csharp +public Compono.Http.HttpResponseRegistration RespondBytes(byte[] content, string mediaType="application/octet-stream"); +``` +#### Parameters + + + +`content` [System\.Byte](https://learn.microsoft.com/en-us/dotnet/api/system.byte 'System\.Byte')[\[\]](https://learn.microsoft.com/en-us/dotnet/api/system.array 'System\.Array') + + + +`mediaType` [System\.String](https://learn.microsoft.com/en-us/dotnet/api/system.string 'System\.String') + +#### Returns +[HttpResponseRegistration](Compono.Http.HttpResponseRegistration.md 'Compono\.Http\.HttpResponseRegistration') \ No newline at end of file diff --git a/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.md b/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.md index 5331060..3bc9515 100644 --- a/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.md +++ b/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.md @@ -17,6 +17,7 @@ Inheritance [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system | Methods | | | :--- | :--- | | [Respond\(HttpStatusCode\)](Compono.Http.HttpResponseRegistrationBuilder.Respond(System.Net.HttpStatusCode).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.Respond\(System\.Net\.HttpStatusCode\)') | Responds with [statusCode](Compono.Http.HttpResponseRegistrationBuilder.Respond(System.Net.HttpStatusCode).md#Compono.Http.HttpResponseRegistrationBuilder.Respond(System.Net.HttpStatusCode).statusCode 'Compono\.Http\.HttpResponseRegistrationBuilder\.Respond\(System\.Net\.HttpStatusCode\)\.statusCode') and no content\. | +| [RespondBytes\(byte\[\], string\)](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)') | Responds with HTTP 200 OK and [content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') verbatim, as [mediaType](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).mediaType 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.mediaType') \- the raw\-bytes counterpart to [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)') for binary payloads \(e\.g\. a fetched certificate's DER bytes\) that would otherwise need a lossy or awkward text encoding to round\-trip through [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)')\. Same serialize\-once\-to\-bytes model as [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)'): [content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') is captured once; every matched invocation constructs a fresh [System\.Net\.Http\.ByteArrayContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.bytearraycontent 'System\.Net\.Http\.ByteArrayContent') over it \(ADR\-0051 Amendment 2\)\. | | [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)') | Responds with HTTP 200 OK and [value](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions).value 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)\.value') serialized to JSON, using the ordinary runtime\-metadata [System\.Text\.Json\.JsonSerializer](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer 'System\.Text\.Json\.JsonSerializer') path\. [value](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions).value 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)\.value') is serialized once, here, to an immutable byte buffer; every matched invocation constructs a fresh [System\.Net\.Http\.ByteArrayContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.bytearraycontent 'System\.Net\.Http\.ByteArrayContent') over that same buffer with its own explicit `Content-Type` header \(ADR\-0051 "Serialize\-once\-to\-bytes model"\)\. | | [RespondJson<T>\(T, JsonTypeInfo<T>\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.Serialization.Metadata.JsonTypeInfo_T_) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.Serialization\.Metadata\.JsonTypeInfo\\)') | Responds with HTTP 200 OK and [value](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.Serialization.Metadata.JsonTypeInfo_T_).value 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.Serialization\.Metadata\.JsonTypeInfo\\)\.value') serialized to JSON via [jsonTypeInfo](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.Serialization.Metadata.JsonTypeInfo_T_).jsonTypeInfo 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.Serialization\.Metadata\.JsonTypeInfo\\)\.jsonTypeInfo') \(e\.g\. a source\-generated `JsonSerializerContext`'s metadata\) \- the guaranteed\-AOT\-safe path, since it bypasses runtime resolver lookup entirely\. Same serialize\-once\-to\-bytes model as [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)')\. | | [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)') | Responds with HTTP 200 OK and a fresh [System\.Net\.Http\.StringContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.stringcontent 'System\.Net\.Http\.StringContent') per invocation \- the string itself is already immutable, so no serialize\-once optimization is needed here \(contrast [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)'), whose body IS serialized once \- see ADR\-0051 "Serialize\-once\-to\-bytes model"\)\. | diff --git a/src/Compono.Http/HttpResponseRegistrationBuilder.cs b/src/Compono.Http/HttpResponseRegistrationBuilder.cs index d09eeee..89eb279 100644 --- a/src/Compono.Http/HttpResponseRegistrationBuilder.cs +++ b/src/Compono.Http/HttpResponseRegistrationBuilder.cs @@ -79,6 +79,24 @@ public HttpResponseRegistration RespondJson(T value, JsonTypeInfo jsonType return RespondJsonBytes(bytes); } + /// + /// Responds with HTTP 200 OK and verbatim, as + /// - the raw-bytes counterpart to for + /// binary payloads (e.g. a fetched certificate's DER bytes) that would otherwise need a lossy + /// or awkward text encoding to round-trip through . Same + /// serialize-once-to-bytes model as : + /// is captured once; every matched invocation constructs a fresh + /// over it (ADR-0051 Amendment 2). + /// + public HttpResponseRegistration RespondBytes(byte[] content, string mediaType = "application/octet-stream") + { + ArgumentNullException.ThrowIfNull(content); + return Finish(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(content) { Headers = { ContentType = new MediaTypeHeaderValue(mediaType) } }, + }); + } + /// /// Configures this registration to throw instead of returning a /// response - the exact same instance is rethrown on every matched invocation (verified: an diff --git a/test/Compono.Http.Tests/TestHttpHandlerTests.cs b/test/Compono.Http.Tests/TestHttpHandlerTests.cs index b676385..2cd5f3c 100644 --- a/test/Compono.Http.Tests/TestHttpHandlerTests.cs +++ b/test/Compono.Http.Tests/TestHttpHandlerTests.cs @@ -184,6 +184,34 @@ public async Task RespondJson_SetsJsonContentTypeWithUtf8Charset() response.Content.Headers.ContentType!.CharSet.Should().Be("utf-8"); } + [Fact] + public async Task RespondBytes_RoundTripsContentAndDefaultsToOctetStreamContentType() + { + byte[] payload = [0x00, 0x01, 0xFF, 0x7F, 0x80]; + using var handler = new TestHttpHandler(); + handler.OnGet("/cert.pem").RespondBytes(payload); + + using var client = handler.CreateClient(new Uri("https://api.example.com/")); + var response = await client.GetAsync("/cert.pem", TestContext.Current.CancellationToken); + var received = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + + received.Should().Equal(payload); + response.Content.Headers.ContentType.Should().NotBeNull(); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/octet-stream"); + } + + [Fact] + public async Task RespondBytes_UsesSuppliedMediaType() + { + using var handler = new TestHttpHandler(); + handler.OnGet("/cert.pem").RespondBytes([0x01], "application/pkix-cert"); + + using var client = handler.CreateClient(new Uri("https://api.example.com/")); + var response = await client.GetAsync("/cert.pem", TestContext.Current.CancellationToken); + + response.Content.Headers.ContentType!.MediaType.Should().Be("application/pkix-cert"); + } + [Fact] public async Task Throws_RethrowsTheExactSameExceptionInstanceOnEachMatch() { From 4bc5838f5ab288ae8b4c2231fbaa55d8da3dd55d Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 4 Sep 2026 15:06:05 -0400 Subject: [PATCH 2/5] fix(http): snapshot RespondBytes content and document it in the package guide Address Codex review feedback on PR #133: RespondBytes retained the caller's byte[] by reference instead of copying it, so a post-registration mutation or buffer reuse would silently change an already-registered response - breaking the serialize-once snapshot semantics RespondJson already provides and this method's own doc claimed to match. Also add RespondBytes to docs/packages/compono-http.md's Response APIs list, which was updated everywhere except the consumer-facing package guide. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- ...pono-http-handler-based-testing-package.md | 19 ++++++++++++++----- docs/packages/compono-http.md | 5 +++++ ...tionBuilder.RespondBytes(byte[],string).md | 6 ++++-- ...no.Http.HttpResponseRegistrationBuilder.md | 2 +- .../HttpResponseRegistrationBuilder.cs | 9 ++++++--- .../TestHttpHandlerTests.cs | 17 +++++++++++++++++ 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/docs/adr/0051-compono-http-handler-based-testing-package.md b/docs/adr/0051-compono-http-handler-based-testing-package.md index 47a2766..2e9d95d 100644 --- a/docs/adr/0051-compono-http-handler-based-testing-package.md +++ b/docs/adr/0051-compono-http-handler-based-testing-package.md @@ -626,11 +626,20 @@ binary-content need, not a text one. **Decision**: add `RespondBytes(byte[] content, string mediaType = "application/octet-stream")`, following the same serialize-once-to-bytes model as `RespondJson` (this ADR's original -Decision Outcome, "Serialize-once-to-bytes model"): `content` is captured -once, and every matched invocation constructs a fresh `ByteArrayContent` -over it with its own `MediaTypeHeaderValue` - never a shared, mutable -header instance across responses (same reasoning as `RespondJsonBytes`'s -existing `MediaTypeHeaderValue` handling). +Decision Outcome, "Serialize-once-to-bytes model"): `content` is +defensively copied (`(byte[])content.Clone()`) once at registration time +- not retained by reference - and every matched invocation constructs a +fresh `ByteArrayContent` over that private copy, with its own +`MediaTypeHeaderValue` - never a shared, mutable header instance across +responses (same reasoning as `RespondJsonBytes`'s existing +`MediaTypeHeaderValue` handling). The clone matters specifically because, +unlike `RespondJson` (which already produces its own private buffer via +`JsonSerializer.SerializeToUtf8Bytes`), `RespondBytes`'s `content` comes +in as a caller-owned array - without the clone, a caller mutating or +reusing that array after registration would silently change an +already-registered response, which is exactly the snapshot semantics +`RespondJson` already provides and this method's own doc claims to match. +Caught in PR review (Codex, on the implementation PR) before merge. **Explicitly not done**: no change to `RespondText` or `RespondJson` - this is a purely additive third `Respond*` overload, source-compatible diff --git a/docs/packages/compono-http.md b/docs/packages/compono-http.md index 51b4fa3..27d19f3 100644 --- a/docs/packages/compono-http.md +++ b/docs/packages/compono-http.md @@ -87,6 +87,11 @@ registration.Verify().Once(); ergonomic runtime-metadata path (see JSON/AOT below). - `RespondJson(T value, JsonTypeInfo jsonTypeInfo)` — the source-generated, AOT-safe path. + - `RespondBytes(byte[] content, string mediaType = "application/octet-stream")` — + raw binary payloads (e.g. fetched certificate bytes) that `RespondText` + can't carry without a lossy or awkward text encoding. `content` is + defensively copied at registration time, so mutating the caller's array + afterward never changes the registered response. - `Throws(Exception exception)` — rethrows the **same instance** on every matched invocation; there's no exception-factory overload. - Every `Respond*` call builds a **fresh** response/content per matched diff --git a/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md b/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md index ae1ce00..065512f 100644 --- a/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md +++ b/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md @@ -8,8 +8,10 @@ Responds with HTTP 200 OK and [content](Compono.Http.HttpResponseRegistrationBui binary payloads \(e\.g\. a fetched certificate's DER bytes\) that would otherwise need a lossy or awkward text encoding to round\-trip through [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)')\. Same serialize\-once\-to\-bytes model as [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)'): -[content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') is captured once; every matched invocation constructs a fresh -[System\.Net\.Http\.ByteArrayContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.bytearraycontent 'System\.Net\.Http\.ByteArrayContent') over it \(ADR\-0051 Amendment 2\)\. +[content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') is defensively copied once at registration time \- not retained +by reference \- so a caller mutating or reusing its own array afterward can never change an +already\-registered response; every matched invocation constructs a fresh +[System\.Net\.Http\.ByteArrayContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.bytearraycontent 'System\.Net\.Http\.ByteArrayContent') over that private copy \(ADR\-0051 Amendment 2\)\. ```csharp public Compono.Http.HttpResponseRegistration RespondBytes(byte[] content, string mediaType="application/octet-stream"); diff --git a/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.md b/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.md index 3bc9515..23682e8 100644 --- a/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.md +++ b/docs/reference/api/Compono.Http/Compono.Http.HttpResponseRegistrationBuilder.md @@ -17,7 +17,7 @@ Inheritance [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system | Methods | | | :--- | :--- | | [Respond\(HttpStatusCode\)](Compono.Http.HttpResponseRegistrationBuilder.Respond(System.Net.HttpStatusCode).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.Respond\(System\.Net\.HttpStatusCode\)') | Responds with [statusCode](Compono.Http.HttpResponseRegistrationBuilder.Respond(System.Net.HttpStatusCode).md#Compono.Http.HttpResponseRegistrationBuilder.Respond(System.Net.HttpStatusCode).statusCode 'Compono\.Http\.HttpResponseRegistrationBuilder\.Respond\(System\.Net\.HttpStatusCode\)\.statusCode') and no content\. | -| [RespondBytes\(byte\[\], string\)](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)') | Responds with HTTP 200 OK and [content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') verbatim, as [mediaType](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).mediaType 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.mediaType') \- the raw\-bytes counterpart to [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)') for binary payloads \(e\.g\. a fetched certificate's DER bytes\) that would otherwise need a lossy or awkward text encoding to round\-trip through [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)')\. Same serialize\-once\-to\-bytes model as [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)'): [content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') is captured once; every matched invocation constructs a fresh [System\.Net\.Http\.ByteArrayContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.bytearraycontent 'System\.Net\.Http\.ByteArrayContent') over it \(ADR\-0051 Amendment 2\)\. | +| [RespondBytes\(byte\[\], string\)](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)') | Responds with HTTP 200 OK and [content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') verbatim, as [mediaType](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).mediaType 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.mediaType') \- the raw\-bytes counterpart to [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)') for binary payloads \(e\.g\. a fetched certificate's DER bytes\) that would otherwise need a lossy or awkward text encoding to round\-trip through [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)')\. Same serialize\-once\-to\-bytes model as [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)'): [content](Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).md#Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondBytes\(byte\[\], string\)\.content') is defensively copied once at registration time \- not retained by reference \- so a caller mutating or reusing its own array afterward can never change an already\-registered response; every matched invocation constructs a fresh [System\.Net\.Http\.ByteArrayContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.bytearraycontent 'System\.Net\.Http\.ByteArrayContent') over that private copy \(ADR\-0051 Amendment 2\)\. | | [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)') | Responds with HTTP 200 OK and [value](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions).value 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)\.value') serialized to JSON, using the ordinary runtime\-metadata [System\.Text\.Json\.JsonSerializer](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer 'System\.Text\.Json\.JsonSerializer') path\. [value](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions).value 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)\.value') is serialized once, here, to an immutable byte buffer; every matched invocation constructs a fresh [System\.Net\.Http\.ByteArrayContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.bytearraycontent 'System\.Net\.Http\.ByteArrayContent') over that same buffer with its own explicit `Content-Type` header \(ADR\-0051 "Serialize\-once\-to\-bytes model"\)\. | | [RespondJson<T>\(T, JsonTypeInfo<T>\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.Serialization.Metadata.JsonTypeInfo_T_) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.Serialization\.Metadata\.JsonTypeInfo\\)') | Responds with HTTP 200 OK and [value](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.Serialization.Metadata.JsonTypeInfo_T_).value 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.Serialization\.Metadata\.JsonTypeInfo\\)\.value') serialized to JSON via [jsonTypeInfo](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.Serialization.Metadata.JsonTypeInfo_T_).jsonTypeInfo 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.Serialization\.Metadata\.JsonTypeInfo\\)\.jsonTypeInfo') \(e\.g\. a source\-generated `JsonSerializerContext`'s metadata\) \- the guaranteed\-AOT\-safe path, since it bypasses runtime resolver lookup entirely\. Same serialize\-once\-to\-bytes model as [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)')\. | | [RespondText\(string, string, Encoding\)](Compono.Http.HttpResponseRegistrationBuilder.RespondText(string,string,System.Text.Encoding).md 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondText\(string, string, System\.Text\.Encoding\)') | Responds with HTTP 200 OK and a fresh [System\.Net\.Http\.StringContent](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.stringcontent 'System\.Net\.Http\.StringContent') per invocation \- the string itself is already immutable, so no serialize\-once optimization is needed here \(contrast [RespondJson<T>\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\\(T, System\.Text\.Json\.JsonSerializerOptions\)'), whose body IS serialized once \- see ADR\-0051 "Serialize\-once\-to\-bytes model"\)\. | diff --git a/src/Compono.Http/HttpResponseRegistrationBuilder.cs b/src/Compono.Http/HttpResponseRegistrationBuilder.cs index 89eb279..541b8b9 100644 --- a/src/Compono.Http/HttpResponseRegistrationBuilder.cs +++ b/src/Compono.Http/HttpResponseRegistrationBuilder.cs @@ -85,15 +85,18 @@ public HttpResponseRegistration RespondJson(T value, JsonTypeInfo jsonType /// binary payloads (e.g. a fetched certificate's DER bytes) that would otherwise need a lossy /// or awkward text encoding to round-trip through . Same /// serialize-once-to-bytes model as : - /// is captured once; every matched invocation constructs a fresh - /// over it (ADR-0051 Amendment 2). + /// is defensively copied once at registration time - not retained + /// by reference - so a caller mutating or reusing its own array afterward can never change an + /// already-registered response; every matched invocation constructs a fresh + /// over that private copy (ADR-0051 Amendment 2). /// public HttpResponseRegistration RespondBytes(byte[] content, string mediaType = "application/octet-stream") { ArgumentNullException.ThrowIfNull(content); + var snapshot = (byte[])content.Clone(); return Finish(_ => new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent(content) { Headers = { ContentType = new MediaTypeHeaderValue(mediaType) } }, + Content = new ByteArrayContent(snapshot) { Headers = { ContentType = new MediaTypeHeaderValue(mediaType) } }, }); } diff --git a/test/Compono.Http.Tests/TestHttpHandlerTests.cs b/test/Compono.Http.Tests/TestHttpHandlerTests.cs index 2cd5f3c..5fb578e 100644 --- a/test/Compono.Http.Tests/TestHttpHandlerTests.cs +++ b/test/Compono.Http.Tests/TestHttpHandlerTests.cs @@ -200,6 +200,23 @@ public async Task RespondBytes_RoundTripsContentAndDefaultsToOctetStreamContentT response.Content.Headers.ContentType!.MediaType.Should().Be("application/octet-stream"); } + [Fact] + public async Task RespondBytes_MutatingCallersArrayAfterRegistration_DoesNotAffectResponse() + { + byte[] original = [0x01, 0x02, 0x03]; + byte[] expected = [.. original]; + using var handler = new TestHttpHandler(); + handler.OnGet("/cert.pem").RespondBytes(original); + + original[0] = 0xFF; + + using var client = handler.CreateClient(new Uri("https://api.example.com/")); + var response = await client.GetAsync("/cert.pem", TestContext.Current.CancellationToken); + var received = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + + received.Should().Equal(expected); + } + [Fact] public async Task RespondBytes_UsesSuppliedMediaType() { From c0b48bf52c34f10b9d32fd8520818ce308dd6846 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 4 Sep 2026 15:08:50 -0400 Subject: [PATCH 3/5] docs(skill): capture targeted-review-request step in respond-to-pr-feedback The task file's Phase 3 covered replying to and resolving feedback but never mentioned requesting a fresh bot review scoped to the fix commit once real code changes are pushed - an established pattern this session used, now made explicit so it isn't re-derived ad hoc next time. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- .../engineering-workflow/tasks/respond-to-pr-feedback.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.agents/skills/engineering-workflow/tasks/respond-to-pr-feedback.md b/.agents/skills/engineering-workflow/tasks/respond-to-pr-feedback.md index 85bc323..9838137 100644 --- a/.agents/skills/engineering-workflow/tasks/respond-to-pr-feedback.md +++ b/.agents/skills/engineering-workflow/tasks/respond-to-pr-feedback.md @@ -224,6 +224,14 @@ pass over every comment from phase 1. top-level issue comments and review bodies have no "resolved" state on GitHub at all; the permalink-marked reply in step 8 is the entire deliverable for those (see step 2). +10. **If this pass had real code changes** (not doc-only fixes) **and a bot + reviewer is configured on the repo** (e.g. `chatgpt-codex-connector`), + request a fresh review scoped to what actually changed — `gh pr comment + --body "@codex review commit "` — rather than a bare "@codex review". Skip + this step for a round that only touched docs/comments with no + behavioral diff; re-running a bot review round costs real turnaround + and isn't warranted when there's nothing new for it to check. ## Output From 4aadaff3f0fb580d7204abec9baf45ae0e8d4359 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 4 Sep 2026 15:11:08 -0400 Subject: [PATCH 4/5] docs(skill): add RespondBytes to the compono skill's Compono.Http reference The published compono skill's http.md enumerated every Respond* finalizer for consumers writing Compono.Http tests, but was written before RespondBytes existed (PR #133). Add it alongside the others so skill guidance doesn't quietly fall behind the package's actual API surface. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- skills/compono/references/http.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/skills/compono/references/http.md b/skills/compono/references/http.md index 74d8775..f83e6b2 100644 --- a/skills/compono/references/http.md +++ b/skills/compono/references/http.md @@ -60,7 +60,11 @@ a v1-only limitation that might later be lifted. body matcher DSL. - Every match finalizes with `.Respond(HttpStatusCode)`, `.RespondText(content, mediaType, encoding)`, - `.RespondJson(value, options?)`, `.RespondJson(value, jsonTypeInfo)`, or + `.RespondJson(value, options?)`, `.RespondJson(value, jsonTypeInfo)`, + `.RespondBytes(content, mediaType)` (raw binary payloads — e.g. fetched + certificate bytes — that `RespondText` can't carry without a lossy text + encoding; `content` is copied at registration time, so mutating the + caller's array afterward never changes the registered response), or `.Throws(exception)` — each returns the `HttpResponseRegistration` handle, capture it for verification: ```csharp From a1f0f53cd88db04f8ac6d26e0b9d4a8e0aaddf7f Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 4 Sep 2026 15:12:43 -0400 Subject: [PATCH 5/5] docs(plan): record RespondBytes in PLAN-0051's Done checklist Address Codex review feedback on PR #133: PLAN-0051's Response APIs (Task 4) and Behavioral tests (Task 9) checklists still only enumerated the pre-Amendment-2 Respond* set, so the plan no longer accurately reflected Compono.Http's completed public behavior and test coverage. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- ...o-http-handler-based-testing-package-impl-plan.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/plans/0051-compono-http-handler-based-testing-package-impl-plan.md b/docs/plans/0051-compono-http-handler-based-testing-package-impl-plan.md index 5aef7d1..ee0b602 100644 --- a/docs/plans/0051-compono-http-handler-based-testing-package-impl-plan.md +++ b/docs/plans/0051-compono-http-handler-based-testing-package-impl-plan.md @@ -339,6 +339,13 @@ these, even if implementation makes one look easy to add along the way: behavior, ADR-0051 — no cloning, no factory parameter). - [x] Every `Respond*`/`Throws` call finalizes and returns the `HttpResponseRegistration` handle (not `void`). +- [x] `RespondBytes(byte[] content, string mediaType = "application/octet-stream")` + (ADR-0051 Amendment 2, added after this plan's original `Done` + status) — `content` is defensively copied (`(byte[])content.Clone()`) + once at registration time, not retained by reference; each + invocation constructs a fresh `ByteArrayContent` over that private + copy with its own `MediaTypeHeaderValue`, matching `RespondJson`'s + serialize-once-to-bytes model. ### 5. JSON/AOT correctness @@ -503,6 +510,11 @@ these, even if implementation makes one look easy to add along the way: consumer). - [x] `RespondJson` sets `Content-Type: application/json; charset=utf-8` correctly on every fresh `ByteArrayContent`. +- [x] `RespondBytes` round-trips content and defaults to + `application/octet-stream`, honors a supplied `mediaType`, and a + mutation to the caller's array after registration doesn't affect + an already-registered response (ADR-0051 Amendment 2 — added after + this plan's original `Done` status). - [x] `Throws(exception)` rethrows the exact same instance (`ReferenceEquals`) on repeated matches. - [x] `registration.Verify().Never()/.Once()/.Exactly(n)` — including the