Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
<number> --body "@codex review commit <sha> — <one line naming which
findings this addresses>"` — 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

Expand Down
34 changes: 34 additions & 0 deletions docs/adr/0051-compono-http-handler-based-testing-package.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,3 +610,37 @@ 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<T>` 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
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
with every existing call site.
5 changes: 5 additions & 0 deletions docs/packages/compono-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ registration.Verify().Once();
ergonomic runtime-metadata path (see JSON/AOT below).
- `RespondJson<T>(T value, JsonTypeInfo<T> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#### [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&lt;T&gt;\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\<T\>\(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\)\.

```csharp
public Compono.Http.HttpResponseRegistration RespondBytes(byte[] content, string mediaType="application/octet-stream");
```
#### Parameters

<a name='Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).content'></a>

`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')

<a name='Compono.Http.HttpResponseRegistrationBuilder.RespondBytes(byte[],string).mediaType'></a>

`mediaType` [System\.String](https://learn.microsoft.com/en-us/dotnet/api/system.string 'System\.String')

#### Returns
[HttpResponseRegistration](Compono.Http.HttpResponseRegistration.md 'Compono\.Http\.HttpResponseRegistration')
Original file line number Diff line number Diff line change
Expand Up @@ -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&lt;T&gt;\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\<T\>\(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&lt;T&gt;\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\<T\>\(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\>\(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\>\(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&lt;T&gt;\(T, JsonTypeInfo&lt;T&gt;\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.Serialization.Metadata.JsonTypeInfo_T_) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\<T\>\(T, System\.Text\.Json\.Serialization\.Metadata\.JsonTypeInfo\<T\>\)') | 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\>\(T, System\.Text\.Json\.Serialization\.Metadata\.JsonTypeInfo\<T\>\)\.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\>\(T, System\.Text\.Json\.Serialization\.Metadata\.JsonTypeInfo\<T\>\)\.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&lt;T&gt;\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\<T\>\(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&lt;T&gt;\(T, JsonSerializerOptions\)](Compono.Http.HttpResponseRegistrationBuilder.RespondJson.md#Compono.Http.HttpResponseRegistrationBuilder.RespondJson_T_(T,System.Text.Json.JsonSerializerOptions) 'Compono\.Http\.HttpResponseRegistrationBuilder\.RespondJson\<T\>\(T, System\.Text\.Json\.JsonSerializerOptions\)'), whose body IS serialized once \- see ADR\-0051 "Serialize\-once\-to\-bytes model"\)\. |
Expand Down
6 changes: 5 additions & 1 deletion skills/compono/references/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/Compono.Http/HttpResponseRegistrationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ public HttpResponseRegistration RespondJson<T>(T value, JsonTypeInfo<T> jsonType
return RespondJsonBytes(bytes);
}

/// <summary>
/// Responds with HTTP 200 OK and <paramref name="content"/> verbatim, as
/// <paramref name="mediaType"/> - the raw-bytes counterpart to <see cref="RespondText"/> 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 <see cref="RespondText"/>. Same
/// serialize-once-to-bytes model as <see cref="RespondJson{T}(T, JsonSerializerOptions?)"/>:
/// <paramref name="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
/// <see cref="ByteArrayContent"/> over that private copy (ADR-0051 Amendment 2).
/// </summary>
public HttpResponseRegistration RespondBytes(byte[] content, string mediaType = "application/octet-stream")
Comment thread
ncipollina marked this conversation as resolved.
Comment thread
ncipollina marked this conversation as resolved.
Comment thread
ncipollina marked this conversation as resolved.
{
ArgumentNullException.ThrowIfNull(content);
var snapshot = (byte[])content.Clone();
return Finish(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(snapshot) { Headers = { ContentType = new MediaTypeHeaderValue(mediaType) } },
});
}

/// <summary>
/// Configures this registration to throw <paramref name="exception"/> instead of returning a
/// response - the exact same instance is rethrown on every matched invocation (verified: an
Expand Down
Loading
Loading