Skip to content

Commit 8a83ceb

Browse files
docs(spec): IHttpRequest.body TSDoc states what a handler reads and that the read may refuse (#24068)
Fixes #24043 Clause-②: no ## What `IHttpRequest.body`'s TSDoc in `packages/spec/src/contracts/http-server.ts` now says what a handler reads and what a read can do. It used to say only "Request body (parsed — JSON or form data when applicable)". - **What it is:** the parsed JSON document for `application/json`, or the parsed fields of a `multipart/form-data` / `application/x-www-form-urlencoded` form. `{}` when the request carries no body. - **It may refuse:** an implementation that cannot parse the body, or does not read its media type, throws from the read instead of handing over an invented value. The standard transport (`@objectstack/plugin-hono-server`) does this and answers 400 `INVALID_REQUEST` itself, replacing whatever the handler wrote. So a handler must not catch the refusal and carry on. - **Raw bytes:** a handler that needs them reads `rawBody()` and never reads `body`. TSDoc only. The type (`body?: any`), every other member and every runtime path are unchanged. `rawBody`'s TSDoc is unchanged too, because it does not contradict the new text (see Acceptance notes). There is one prose-only `patch` changeset for `@objectstack/spec`, because the TSDoc ships in the `.d.ts`. ## Measured: each clause against the standard transport (`origin/main` `e246f06119`) | Clause | Code | Pin in `request-body-refusal.test.ts` | |:---|:---|:---| | JSON is parsed; empty or whitespace-only JSON is `{}` | `request-body.ts` `readRequestBody`, the `application/json` arm (case-insensitive) | "a valid JSON body reaches the handler parsed" · "an empty or whitespace-only JSON body is `{}`" · "a declared JSON media type is matched case-insensitively" | | A form is parsed | the `multipart/form-data` / `application/x-www-form-urlencoded` arm (`parseBody()`) | "a urlencoded form body reaches the handler parsed" | | `{}` when there is no body | the last arm: `declaresBody` (RFC 9112 framing headers) is false, so the answer is `{ body: {} }` | "a request with no body is `{}`, whatever it declares" · "CONTROL over the wire: a POST with no body is `{}`" | | Refuses a body that does not parse | the JSON arm's `JSON.parse` catch, and the form arm's catch, both answer `RequestBodyRefusal` (400 `INVALID_REQUEST`) | steps 2, 3, 5 and 6 · "a form body that does not parse is refused" | | Refuses a non-empty body in a media type it does not read | the last arm: `declaresBody` is true, so the answer is a refusal (`text/plain`, `application/octet-stream`, no Content-Type, …) | "step 4 — a valid query body sent as text/plain" · "a body with no Content-Type at all is refused" · the step 4 test over the wire | | The refusal is thrown from the read, and the transport answers it whatever the handler wrote | `adapter.ts` `runHandler`: the `get body()` accessor throws and records `refusedBodyRead`; after the handler settles, `if (refusedBodyRead)` returns the declared envelope in place of the handler's response | the query door catches the throw and writes its own 500, and the pin still sees 400 `INVALID_REQUEST` ("step 2" … "step 4") | | `rawBody()` is the byte door | the `rawBody` member reads `c.req.arrayBuffer()`; JSON is read with `text()`, which fills the same Hono body cache | the module's CONTROL list: the byte doors still answer as before | The handler consequence from the card holds in this form: the handler's response is discarded, not written after the 400. What survives the catch is any side effect the handler performs. So the TSDoc tells a handler not to catch the refusal and carry on, and invents no API to go with it. ## What ships `pnpm --filter @objectstack/spec build` (exit 0). `grep -rl 'must not catch the refusal and carry on' dist` matches `dist/contracts/index.d.ts` and `dist/contracts/index.d.mts`. Positive control: `Raw body accessor for binary uploads` (the untouched `rawBody` text) matches the same two files. The old sentence `parsed — JSON or form data when applicable` matches nothing in `dist`. `dist` is in `files[]`. ## Gates (head `2a55ea38`; same tree as `8026677d`, where they ran; only the commit message was reworded) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 78 commands. All 78 were run, each exit code captured before any pipe, plus the 5 roster families keeping a roster under one of this diff's directories (`check-changeset-fixed`, `check:meta-url-spelling`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`): **79 exit 0, 4 exit 3**. - Exit 3 means PREREQUISITE NOT MET, so these four are NOT MEASURED locally: `check-plugin-teardown-shape --self-test` (shallow clone cannot reach its pinned control commit), `@objectstack/lint check:doc-formula-expressions` (`@objectstack/formula` not built), `check:dual-build-cjs-loads` and `check:lean-entry-closure` (workspace `dist` not built). None of them reads `http-server.ts` or a changeset. CI runs them. - `--ran` reconciliation: "78 derived famil(ies) accounted for — 74 run, 4 NOT-MEASURED (4 DERIVED from a recorded exit 3)". - `pnpm --filter @objectstack/spec typecheck` exit 0. `pnpm --filter @objectstack/spec check:generated` exit 0. ESLint (`--no-inline-config`) over both changed files: exit 0 (the `.md` file is ignored by the config). ## Acceptance notes - **Other producers of `IHttpRequest`, measured on `main`** (none is changed here; the text says what an implementation MAY do and does not claim every producer throws): - `packages/qa/http-conformance/src/adapter.ts` reads JSON, urlencoded and text bodies eagerly. A malformed JSON body becomes `{}` (`catch { body = {}; }`), a `text/*` body becomes the string, and every other media type is `{}` with the bytes left for `rawBody()`. It does not refuse. - `packages/rest/src/http-request-test-builder.ts` leaves `body` and `rawBody` absent unless the caller passes them, so `body` is `undefined` rather than `{}` for a fixture that states none. The member stays optional (`body?`), and no type change was in scope. - **`rawBody` TSDoc ("consume the underlying request stream lazily so handlers that only need `body` do not pay the parsing cost").** This still matches the standard adapter: `rawBody()` reads `c.req.arrayBuffer()` only when called, and the media types `body` does not read stay unread until then. JSON and form bodies are read before the handler, which was also true before PR #24005. It does not contradict the new `body` text, so it is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KNNm3SRPyw288E5DT4qVGx --- _Generated by [Claude Code](https://claude.ai/code/session_01KNNm3SRPyw288E5DT4qVGx)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8133cc7 commit 8a83ceb

2 files changed

Lines changed: 29 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `IHttpRequest.body`'s TSDoc says what a handler reads, and that reading it may refuse
6+
7+
Clause-②: no
8+
9+
- **What it is.** The parsed JSON document for `application/json`, or the parsed fields of a `multipart/form-data` or `application/x-www-form-urlencoded` form; `{}` when the request carries no body.
10+
- **It may refuse.** An implementation that cannot parse the body, or does not read its media type, throws from the read instead of handing over an invented value. The standard transport (`@objectstack/plugin-hono-server`) does, and answers 400 `INVALID_REQUEST` itself, replacing whatever the handler wrote. So a handler must not catch the refusal and carry on.
11+
- **Raw bytes.** A handler that needs them reads `rawBody()` and never reads `body`.
12+
- **Unchanged:** the type (`body?: any`), every other member, and every runtime behaviour. This is prose in the shipped `.d.ts` only.

‎packages/spec/src/contracts/http-server.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,23 @@ export interface IHttpRequest {
2020
params: Record<string, string>;
2121
/** Request query parameters */
2222
query: Record<string, string | string[]>;
23-
/** Request body (parsed — JSON or form data when applicable) */
23+
/**
24+
* Request body, parsed by its declared content type: the parsed JSON
25+
* document for `application/json`, or the parsed fields of a form
26+
* (`multipart/form-data`, `application/x-www-form-urlencoded`). `{}` when
27+
* the request carries no body.
28+
*
29+
* Reading it may REFUSE: an implementation that cannot parse the body, or
30+
* that does not read the body's media type, throws from the read rather
31+
* than hand over an invented value. The standard transport
32+
* (`@objectstack/plugin-hono-server`) does, and answers the request itself
33+
* with 400 `INVALID_REQUEST`, replacing whatever the handler wrote; so a
34+
* handler must not catch the refusal and carry on, because anything it
35+
* does after the catch acts on a request that has already been refused.
36+
*
37+
* A handler that needs the raw bytes (a file upload, a media type not
38+
* listed above) reads {@link rawBody} and never reads `body`.
39+
*/
2440
body?: any;
2541
/** Request headers */
2642
headers: Record<string, string | string[]>;

0 commit comments

Comments
 (0)