diff --git a/.changeset/core-http-domain-model.md b/.changeset/core-http-domain-model.md new file mode 100644 index 0000000..fc0c3a4 --- /dev/null +++ b/.changeset/core-http-domain-model.md @@ -0,0 +1,5 @@ +--- +"@dexpace/core": minor +--- + +Add the core HTTP domain model (Request, Response, Headers, Status, MediaType, Protocol, QueryParams, RequestOptions, ETag, HttpRange, RequestConditions). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..07d77fc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A Node.js/TypeScript HTTP SDK platform, built as a **port of a language-agnostic product specification**. The +spec in `docs/product-spec/` is normative and numbered; the code exists to satisfy it. Work here is +spec-driven, not feature-driven: before implementing anything, find the requirement IDs it must satisfy. + +Bun workspace. One published package today — `@dexpace/core` (`packages/core`) — with more planned per +`docs/sdk-design-nodejs/02-package-and-workspace-layout.md`. + +## Commands + +All run from the repo root unless noted. + +```bash +bun install --frozen-lockfile + +bun run typecheck # tsc --noEmit against packages/core/tsconfig.json +bun run lint # gts lint . — formatting AND type-aware rules; fatal +bun run fix # gts fix . — autofixes formatting/lint +bun run build # tsc -p packages/core/tsconfig.build.json → dist/ +bun test # coverage is on by default (bunfig.toml), 80% line floor +``` + +Single test file or single test: + +```bash +bun test packages/core/src/http/media-type.test.ts +bun test -t 'rejects blank input' # filter by test name +``` + +API surface (report is committed at `packages/core/etc/core.api.md`): + +```bash +cd packages/core && bun run api:local # regenerate the report after changing exports +cd packages/core && bun run api:ci # verify it matches — this is what CI runs +``` + +Release-shape and invariant gates: + +```bash +bun run lint:publish # publint + attw against the built package +bun run verify:dual-consumption # plain `node` imports the built package and runs it +bun run verify:seam-1 # asserts @dexpace/core has zero runtime dependencies +bun run verify:runtime-floor # tsconfig target vs package engines.node consistency +bun run audit # bun audit --audit-level=high --prod +``` + +**Every one of these is a blocking CI step** (`.github/workflows/ci.yml`). Run the full set before claiming +work is done — `bun test` passing is not sufficient evidence. + +## Documentation hierarchy + +Four distinct trees, easy to confuse: + +| Path | Role | +|---|---| +| `docs/product-spec/` | **Normative.** Numbered requirements (`HTTP-7`, `SEAM-1`, `RETRY-13`, `NFR-5`, …). The source of truth. | +| `docs/sdk-design-nodejs/` | How each spec area maps to idiomatic TypeScript. Non-normative but binding by convention. | +| `docs/knowledge/` | Harvested styleguide + spec knowledge, topic-indexed (`INDEX.md`). Cited as "styleguide 6.7", "ch08". | +| `docs/superpowers/specs/` + `plans/` | Per-phase design doc, task-by-task implementation plan, and a requirement-coverage checklist. | + +`docs/product-spec/appendix-c-consolidated-normative-requirement-index.md` is the fastest way to locate a +requirement ID. + +## Requirement-ID conventions (enforced by review, not tooling) + +- Every source file opens with `// SPDX-License-Identifier: MIT` on **line 1** (NFR-13). +- Every test file's header comment cites the `HTTP-N` / `SEAM-N` IDs it exercises. Phase 9's conformance pass + depends on this traceability existing already. +- When a requirement is deliberately not satisfied, record it — as a deferral in the phase plan naming the + owning phase, or in `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`. Silent + gaps are the failure mode this project is structured to prevent. + +## Domain model construction pattern + +Every model in `packages/core/src/http/` follows one shape. Deviating breaks invariants that tooling does not +catch: + +- **`#private` fields only.** Not TS `private`. Styleguide 6.7 carves this out for libraries whose internals + must stay unreachable reflectively. +- **TS `private` constructor**, so no public field-wise constructor appears in the emitted `.d.ts` — a + consumer cannot construct around `build()`'s validation (HTTP-2). +- **The `createX` friend-class hook.** TypeScript has no friend classes, so a builder (a *different* class) + reaches its model's private constructor through a module-scoped `let createX` assigned exactly once inside + the class's `static {}` block. That `let` is init-once wiring, not mutable state. Every builder-based model + repeats it: `createHeaders`, `createQueryParams`, `createRequest`, `createResponse`, `createRequestOptions`, + `createRequestConditions`. +- **`Object.freeze(this)` once, at the end of the constructor.** Freeze is shallow and is never relied on to + cascade — nested arrays and `Map`s are frozen independently at build time. +- **`newBuilder()` returns a pre-filled builder that deep-copies every collection**, never aliases the source + (HTTP-3). Value types with no builder (`Status`, `Protocol`, `MediaType`, `ETag`, `HttpRange`) use static + factories instead. +- **Required fields go through `requireField()`** from `builder.ts` — never a bespoke `if (!x) throw`. It + single-sources HTTP-4's `` `${name} is required` `` message. +- **Typed errors only.** Everything descends from `DomainModelError`; no bare `throw new Error(...)`. Each + subclass sets `this.name = new.target.name`, and wrap-and-rethrow always passes `{cause}`. +- **Getters return frozen or freshly-copied values.** `Request.url` clones on every access because the native + `URL` is mutable — the one place a frozen class still leaks mutability (HTTP-5). + +Validation uses explicit predicate functions, not zod. Zod targets untrusted boundary parsing; these modules +validate already-typed values against character-class and grammar rules (styleguide 6.8 permits this). + +## Constraints that will bite + +- **Zero runtime dependencies in `@dexpace/core`** (SEAM-1), gate-enforced. Reaching for a small date or URL + utility is exactly the reflex `verify:seam-1` exists to catch. Dev dependencies are fine. +- **ESM-only, NodeNext.** Relative imports carry the `.js` extension even in `.ts` source. + `verbatimModuleSyntax` is on, so type-only imports need `import type`. +- **`erasableSyntaxOnly`** — no enums, no namespaces, no constructor parameter properties. +- **Lint is type-aware and strict** (`strictTypeChecked` + `stylisticTypeChecked` over gts): 70-line function + cap, `max-depth` 3, `max-params` 3, explicit return types on exported functions and methods. + `max-params` counts constructor parameters, which is why several private model constructors carry a + documented `eslint-disable-next-line max-params`. +- **Every `eslint-disable` must carry a `-- reason`** (`eslint-comments/require-description`, wired for + NFR-7). Suppressing a rule without a stated reason and re-enable condition fails lint. +- **Prettier config is deliberately absent at the root.** `eslint.config.js` sources `gts/.prettierrc.json` + explicitly; see the comment there before adding any Prettier file. +- **Formatting is a lint error**, not a warning. Run `bun run fix` before `bun run lint` if the diff is large. + +## Public API surface + +`packages/core/src/http/index.ts` is the single front door; `packages/core/src/index.ts` re-exports it. Internal +helpers (`requireField`, `toError`, the `ascii-validation` predicates, the `method.ts` classifiers) are +deliberately **not** re-exported — in-package consumers import the module directly. + +Anything the barrel exports needs a TSDoc block with `@public`, plus `@throws` naming each catchable error +class on operations that throw. `api-extractor` will otherwise flag it, and the committed report records it as +`(undocumented)`. After changing exports: rebuild, run `api:local`, and commit the regenerated report. + +Consumer-facing changes need a changeset (`bunx changeset`). + +## Phase workflow + +Work proceeds phase by phase against `docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`. Each +phase has a design spec, an implementation plan with numbered tasks (TDD: write the failing test, confirm it +fails, implement, confirm it passes, commit), and a checklist mapping every requirement ID to the task that +satisfies it. When asked to implement or validate a phase, read all three before touching code. diff --git a/bun.lock b/bun.lock index 4a28264..5ea441b 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,9 @@ "packages/core": { "name": "@dexpace/core", "version": "0.0.0", + "devDependencies": { + "expect-type": "^1.4.0", + }, }, }, "packages": { @@ -312,6 +315,8 @@ "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], diff --git a/docs/open-items.md b/docs/open-items.md new file mode 100644 index 0000000..cc9fcc7 --- /dev/null +++ b/docs/open-items.md @@ -0,0 +1,191 @@ +# Open Items + +Running register of everything known to be unmet, unverified, misreported, or deliberately deferred across the +implemented portion of this project. Reviewed state: **scaffold milestone** (committed, `0ebdc79`) and +**Phase 1 — Core HTTP Domain Model** (branch `2-phase-1-core-http-domain-model`, uncommitted at time of +review). Last reviewed **2026-07-30**. + +A requirement absent from this file is either satisfied or belongs to a phase that has not started. The point +of the file is that nothing is unmet *silently* — every gap below is either scheduled against a named phase or +awaiting a decision. + +**Status vocabulary** + +| Status | Meaning | +|---|---| +| **DECIDE** | Blocked on a human decision. Two or more defensible answers; picking one is the work. | +| **ACT** | Decision already made or obvious; the work is simply not done. | +| **SCHEDULED** | Deliberately deferred to a named phase. No action now; listed so it cannot be lost. | +| **WATCH** | Not a defect today. Becomes one when a stated trigger fires. | + +--- + +## A. Requirements unmet or misreported + +### A1 — HTTP-24: `charset` does not return null for an unknown encoding — **DECIDE** + +`product-spec/04` §4.4 conformance text: "`charset=utf-8` → UTF-8; `charset=bogus` → **null**; no charset → +null." Actual behavior: + +```ts +MediaType.parse('text/plain;charset=bogus').charset // → 'bogus', not undefined +``` + +`packages/core/src/http/media-type.ts` returns the parameter verbatim. There is no registry of recognized +encodings to resolve against, so "unknown" is not a state the current design can detect — the reference +contract presumably assumed a `Charset` type whose lookup can fail. + +The Phase 1 checklist marks HTTP-24 ✅ with no note, so the project currently *claims* conformance it does not +have. That is the actual defect; the behavior itself may well be the right call. + +Two ways out, both acceptable, but one must be chosen: +1. Resolve against a known-encoding set (e.g. `TextDecoder` probing or an explicit allow-list) and return + `undefined` for anything unrecognized. +2. Record a deliberate deviation in + `docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md`, on the grounds that the + TypeScript port models charset as an opaque string and has no failing lookup to model "unknown" with. + +Either way: correct the checklist row, and add a test pinning the chosen behavior. The getter's TSDoc already +documents the current behavior honestly. + +### A2 — HTTP-22: the checklist describes an implementation that does not exist — **ACT** + +Phase 1 checklist, HTTP-22 row: `✅ | Task 7, HeaderName.of()'s static cache`. + +No such cache exists. The plan deliberately dropped interning (Task 7's `HeaderName` comment: "No interning: +HTTP-22 makes it a MAY, and an intern map keyed by caller-supplied names is exactly the unbounded, +process-lived, caller-influenced map XCUT-14's drain-to-cap rule forbids"), and +`packages/core/src/http/headers.ts` has no static map on `HeaderName`. + +The decision is right and the requirement is a MAY, so nothing about the code needs to change. The checklist +row is simply false and should read ⏳/N/A with the XCUT-14 reasoning, not ✅. + +### A3 — HTTP-11: `Response` exposes no range classification of its own — **DECIDE** + +`product-spec/04` §4.3: "Status MUST classify by range … **and a response MUST expose these derived from its +status**." `Response` carries only `status`; callers reach classification one hop away via +`response.status.isSuccess`. + +Defensible as satisfied — the classification *is* reachable and single-sourced on `Status`, and mirroring six +getters onto `Response` is pure surface duplication. But no one recorded that reading, so it is currently an +accident rather than a decision. Either add the delegating getters or write the interpretation into the +checklist row. + +### A4 — SEAM-1 is enforced narrowly relative to its conformance text — **ACT** + +`scripts/verify-seam-1.mjs` asserts `packages/core/package.json`'s `dependencies` is `{}`. The spec's +conformance clause is broader: "a dependency audit of the core module finds only the standard library plus the +compile-scope logging facade; **no transport/codec/stream symbol is referenced from core**." + +Blind spots today: `peerDependencies`, `optionalDependencies`, and `bundleDependencies` are unchecked, and +nothing inspects what the source actually imports. Low risk while core imports nothing but `URL`, but the gate +reads as stronger than it is. Cheap hardening: assert the other three dependency keys are absent-or-empty, and +add an import scan over `packages/core/src` allowing only relative specifiers and `node:`-prefixed builtins. + +--- + +## B. Gates and tooling + +### B1 — NFR-10 / NFR-17: CI never runs on the declared minimum runtime — **ACT** (trigger has now fired) + +The scaffold checklist deferred this explicitly: *"recommend adding an `actions/setup-node@v4` step pinned to +`18.17` running `scripts/verify-dual-consumption.mjs` once real Node-API usage lands (Phase 1 onward), rather +than adding it now for a function that touches no runtime API."* + +**That trigger has fired.** Phase 1 uses the native `URL` class, `Object.freeze`, class `static {}` blocks, and +`#private` fields — all real runtime surface. `verify:runtime-floor` checks that `engines.node` and the +compiled language level *agree*, but nothing ever executes the artifact on Node 18.17; CI runs whatever the +GitHub Actions runner defaults to. The half of NFR-10 that catches "we shipped syntax the declared floor cannot +run" is still missing. + +### B2 — NFR-13: SPDX headers missing on scaffold-era files — **ACT** + +Phase 1 established the convention ("every new source file opens with `// SPDX-License-Identifier: MIT` on line +1") and every file under `packages/core/src/http/` complies. Three files predating it do not: + +- `scripts/verify-runtime-floor.mjs` +- `scripts/verify-seam-1.mjs` +- `eslint.config.js` + +`scripts/verify-dual-consumption.mjs` gained one during Phase 1, which is what makes the omission of its two +siblings look accidental rather than scoped. NFR-13 is a review convention, not a mechanical gate, so this is a +one-line-per-file cleanup. + +### B3 — NFR-12: reproducible builds asserted, never proven — **WATCH** + +`bun install --frozen-lockfile` plus plain `tsc` are deterministic by construction, but nothing demonstrates +it. Becomes real at first publish (~Phase 10): build twice, diff artifact digests. + +### B4 — NFR-14: `expect-type` breaks the single-source-of-versions convention — **WATCH** + +Every other devDependency is centralized at the workspace root; Phase 1 added `expect-type` to +`packages/core/package.json`'s own `devDependencies`. Harmless with one package — it is exactly the restatement +NFR-14 warns about once a second package exists (Phase 8). Either hoist it to the root now or fold it into the +NFR-14 decision at Phase 8. + +--- + +## C. Documentation defects + +### C1 — Phase 1's scope statement contradicts its own plan — **ACT** + +`docs/superpowers/specs/2026-07-23-phase1-core-http-domain-model-design.md` says the scope is "Full +`product-spec/04-core-http-domain-model.md` (HTTP-3 through HTTP-53, both MUST and SHOULD level) in one phase." + +The plan's own Self-Review then amends that: *"The Phase 1 spec's scope statement should be read — and amended +— as HTTP-3..35, 46..50, 53"*, with the body-lifecycle cluster deferred to Phase 3b. The amendment was never +applied to the design doc, so read literally the two documents disagree about what Phase 1 owed. Correct the +design doc's scope line to match the plan. + +### C2 — The structural-typing bypass deviation is not yet recorded — **SCHEDULED** (Phase 10) + +The Phase 1 design doc acknowledges that `#private` fields close the *accidental* structural-typing bypass but +not deliberate reflection abuse (`Object.create(Request.prototype)`), and states this is "to be listed in +`sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` when that phase is reached." Listed +here so the promise survives until then. + +--- + +## D. Scheduled deferrals + +No action now. Each is already owned by a named phase; this table exists so none can quietly lapse. + +| Item | Requirement | Owner phase | Note | +|---|---|---|---| +| Body lifecycle: write/replayability, single-use, close, charset | HTTP-36 – HTTP-43 | 3b | `Request`/`Response` `body` is typed `unknown` as an explicit placeholder | +| Lazy `TypedResponse` with parse-once memoization | HTTP-44, HTTP-45 | 3b | | +| `MultipartBody` — the one builder-based model HTTP-3 lists that Phase 1 did not build | HTTP-51 | 3b | Depends on body-lifecycle contracts | +| 1 MiB error-body buffering cap | HTTP-52 | 3b | | +| `Request.equals` compares body by reference, not by value | HTTP-46 (body clause) | 3b | Blocked on a real `Body` model supplying value equality | +| `RequestConditions.applyTo` cannot emit an obs-text ETag | HTTP-18 vs HTTP-48/50 | 10 | Spec text in scope does not resolve the tension; strict outbound path kept rather than guessed. Documented in `applyTo`'s TSDoc | +| Seam contracts (byte-stream, transport, codec, projection) | SEAM-2 – SEAM-30 | 2–8 | | +| Adapter packages, peer-dependency dedup | NFR-2 | 8 | | +| Shrink-survival regression guard | NFR-9 | 9 | | +| Concurrency-model agnosticism check | NFR-11 | 4 | No async code exists yet | +| Self-identifying version metadata (real `User-Agent`) | NFR-15 | 7/8 | | +| Publish + provenance CI job | NFR-16 | release | `prepublishOnly` wired; nothing published yet | +| NFR-8 re-confirmed as a documented non-applicability | NFR-8 | 10 | No reflection-driven discovery surface exists by design | + +--- + +## E. Process + +### E1 — Phase 1 has no commits — **DECIDE** + +`git log main..HEAD` shows only the scaffold commit. The Phase 1 plan specifies a commit after each of its 15 +tasks (`feat(core): add Status value type (HTTP-10/11/12)`, and so on); all ~40 files currently sit in the +index and working tree as one undifferentiated change. + +Not a correctness problem — every gate passes. But the per-task history the plan describes cannot be +reconstructed after the fact, and a single 3,300-line commit is materially harder to review or bisect. Decide +whether to reconstruct the task-by-task sequence before merging or to accept one squashed commit and note the +departure. + +--- + +## Maintaining this file + +Add an entry the moment a gap is found, not when it is fixed — the failure mode this file prevents is a +checklist row marked ✅ against code that does not implement it (A1, A2 are both instances). Remove an entry +only when the underlying requirement is genuinely satisfied *and* its checklist row agrees. When a phase +closes, re-scan its checklist against the code rather than trusting the marks. diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 7f4a83c..407522c 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -4,9 +4,254 @@ ```ts -// @public (undocumented) -export function ping(): 'pong'; +// @public +export interface Builder { + build(): T; +} -// (No @packageDocumentation comment for this package) +// @public +export class DomainModelError extends Error { + constructor(message: string, options?: ErrorOptions); +} + +// @public +export class ETag { + static readonly ANY: ETag; + get isAny(): boolean; + get isWeak(): boolean; + get opaque(): string | undefined; + static parse(raw: string): ETag | undefined; + get raw(): string; +} + +// @public +export class EtagParseError extends DomainModelError { +} + +// @public +export class HeaderName { + equals(other: HeaderName): boolean; + get lowerCased(): string; + static of(raw: string): HeaderName; + get raw(): string; +} + +// @public +class Headers_2 { + entries(): readonly (readonly [string, string])[]; + equals(other: Headers_2): boolean; + get(name: string | HeaderName): string | undefined; + getAll(name: string | HeaderName): readonly string[]; + has(name: string | HeaderName): boolean; + names(): readonly string[]; + static newBuilder(): HeadersBuilder; + newBuilder(): HeadersBuilder; +} +export { Headers_2 as Headers } + +// @public +export class HeadersBuilder implements Builder { + add(name: string | HeaderName, value: string): this; + addInbound(name: string | HeaderName, value: string): this; + build(): Headers_2; + set(name: string | HeaderName, value: string | null): this; + setInbound(name: string | HeaderName, value: string | null): this; +} + +// @public +export class HeaderValidationError extends DomainModelError { + constructor(kind: 'name' | 'value', offendingName: string, _offendingValue: string | undefined); + readonly escapedName: string; + readonly kind: 'name' | 'value'; +} + +// @public +export class HttpRange { + static bounded(start: number, length: number): HttpRange; + get kind(): RangeKind; + get length(): number | undefined; + static open(start: number): HttpRange; + static parse(raw: string): HttpRange; + get raw(): string; + get start(): number | undefined; + static suffix(suffixLength: number): HttpRange; + get suffixLength(): number | undefined; +} + +// @public +export class HttpRangeValidationError extends DomainModelError { +} + +// @public +export class MediaType { + get charset(): string | undefined; + equals(other: MediaType): boolean; + matches(pattern: MediaType): boolean; + static of(type: string, subtype: string, parameters?: ReadonlyMap): MediaType; + parameter(key: string): string | undefined; + static parse(raw: string): MediaType; + render(): string; + get subtype(): string; + get type(): string; +} + +// @public +export class MediaTypeParseError extends DomainModelError { +} + +// @public +export type Method = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'; + +// @public +export class Protocol { + equals(other: Protocol): boolean; + static readonly HTTP_1_1: Protocol; + static readonly HTTP_2: Protocol; + static parse(raw: string): Protocol; + get token(): string; +} + +// @public +export class ProtocolParseError extends DomainModelError { +} + +// @public +export class QueryParams { + encode(): string; + equals(other: QueryParams): boolean; + get(name: string): string | undefined; + getAll(name: string): readonly string[]; + has(name: string): boolean; + static newBuilder(): QueryParamsBuilder; + newBuilder(): QueryParamsBuilder; + static parse(raw: string | null | undefined): QueryParams; +} + +// @public +export class QueryParamsBuilder implements Builder { + add(name: string, value: string | null): this; + build(): QueryParams; +} + +// @public +export type RangeKind = 'bounded' | 'suffix' | 'open'; + +// @public +class Request_2 { + get body(): unknown; + equals(other: Request_2): boolean; + get headers(): Headers_2; + get method(): Method; + static newBuilder(): RequestBuilder; + newBuilder(): RequestBuilder; + get url(): URL; +} +export { Request_2 as Request } + +// @public +export class RequestBodyNotAllowedError extends DomainModelError { + constructor(method: string); +} + +// @public +export class RequestBuilder implements Builder { + body(body: unknown): this; + build(): Request_2; + headers(headers: Headers_2): this; + method(method: Method): this; + url(url: string | URL): this; +} + +// @public +export class RequestConditions { + applyTo(headers: Headers_2): Headers_2; + static newBuilder(): RequestConditionsBuilder; + newBuilder(): RequestConditionsBuilder; +} + +// @public +export class RequestConditionsBuilder implements Builder { + build(): RequestConditions; + ifMatch(etag: ETag): this; + ifModifiedSince(date: Date): this; + ifNoneMatch(etag: ETag): this; + ifUnmodifiedSince(date: Date): this; +} + +// @public +export class RequestConditionsValidationError extends DomainModelError { +} + +// @public +export class RequestOptions { + static readonly EMPTY: RequestOptions; + get maxRetries(): number | undefined; + static newBuilder(): RequestOptionsBuilder; + newBuilder(): RequestOptionsBuilder; + tag(key: string): string | undefined; + get timeoutMs(): number | undefined; +} + +// @public +export class RequestOptionsBuilder implements Builder { + build(): RequestOptions; + maxRetries(value: number | undefined): this; + tags(entries: ReadonlyMap): this; + timeoutMs(value: number | undefined): this; +} + +// @public +export class RequestOptionsValidationError extends DomainModelError { +} + +// @public +export class RequiredFieldError extends DomainModelError { + constructor(fieldName: string); + readonly fieldName: string; +} + +// @public +class Response_2 { + get body(): unknown; + get headers(): Headers_2; + static newBuilder(): ResponseBuilder; + newBuilder(): ResponseBuilder; + get protocol(): Protocol; + get reasonPhrase(): string | undefined; + get request(): Request_2; + get status(): Status; +} +export { Response_2 as Response } + +// @public +export class ResponseBuilder implements Builder { + body(body: unknown): this; + build(): Response_2; + headers(headers: Headers_2): this; + protocol(protocol: Protocol): this; + reasonPhrase(reasonPhrase: string | undefined): this; + request(request: Request_2): this; + status(status: Status): this; +} + +// @public +export class Status { + get code(): number; + equals(other: Status): boolean; + get isClientError(): boolean; + get isError(): boolean; + get isInformational(): boolean; + get isRecognized(): boolean; + get isRedirect(): boolean; + get isServerError(): boolean; + get isSuccess(): boolean; + get name(): string | undefined; + static of(code: number): Status; + static recognized(code: number): Status | undefined; +} + +// @public +export class UrlConstructionError extends DomainModelError { +} ``` diff --git a/packages/core/package.json b/packages/core/package.json index 7c75c27..835e89a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,5 +26,8 @@ "api:local": "api-extractor run --local", "api:ci": "api-extractor run", "prepublishOnly": "bun run build && bun run api:ci && publint . && attw --pack . --ignore-rules cjs-resolves-to-esm" + }, + "devDependencies": { + "expect-type": "^1.4.0" } } diff --git a/packages/core/src/http/ascii-validation.test.ts b/packages/core/src/http/ascii-validation.test.ts new file mode 100644 index 0000000..33511aa --- /dev/null +++ b/packages/core/src/http/ascii-validation.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/ascii-validation.test.ts +// Exercises: HTTP-18 (outbound value grammar: HTAB + printable ASCII 0x20-0x7E only) +import {describe, expect, test} from 'bun:test'; +import { + hasForbiddenOutboundByte, + hasForbiddenNameByte, + hasForbiddenInboundValueByte, +} from './ascii-validation.js'; + +describe('hasForbiddenOutboundByte', () => { + test('accepts HTAB and printable ASCII', () => { + expect(hasForbiddenOutboundByte('a\tb')).toBe(false); + expect(hasForbiddenOutboundByte('printable ASCII 0x20-0x7E')).toBe(false); + }); + + test('rejects CR/LF and other control characters', () => { + expect(hasForbiddenOutboundByte('a\r\nb')).toBe(true); + expect(hasForbiddenOutboundByte('a\0b')).toBe(true); + }); + + test('rejects non-ASCII bytes', () => { + expect(hasForbiddenOutboundByte('vålue')).toBe(true); + }); +}); + +describe('hasForbiddenNameByte', () => { + test('rejects HTAB, unlike the value predicate', () => { + expect(hasForbiddenNameByte('a\tb')).toBe(true); + }); + + test('rejects CR/LF, NUL, DEL, and non-ASCII', () => { + expect(hasForbiddenNameByte('a\r\nb')).toBe(true); + expect(hasForbiddenNameByte('a\0b')).toBe(true); + expect(hasForbiddenNameByte('héader')).toBe(true); + }); + + test('accepts ordinary printable ASCII', () => { + expect(hasForbiddenNameByte('X-Trace')).toBe(false); + }); +}); + +describe('hasForbiddenInboundValueByte', () => { + test('permits obs-text (bytes >= 0x80)', () => { + expect(hasForbiddenInboundValueByte('café')).toBe(false); + }); + + test('still rejects control characters', () => { + expect(hasForbiddenInboundValueByte('a\r\nb')).toBe(true); + expect(hasForbiddenInboundValueByte('a\0b')).toBe(true); + }); + + test('permits HTAB', () => { + expect(hasForbiddenInboundValueByte('a\tb')).toBe(false); + }); +}); diff --git a/packages/core/src/http/ascii-validation.ts b/packages/core/src/http/ascii-validation.ts new file mode 100644 index 0000000..ff650c4 --- /dev/null +++ b/packages/core/src/http/ascii-validation.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/ascii-validation.ts +/** + * Reports whether `value` contains a byte forbidden in an outbound header value: anything outside + * HTAB plus printable ASCII 0x20–0x7E (HTTP-18). + * + * Media-type construction reuses this exact predicate rather than reimplementing the character + * class, so a media type is always header-safe and the two rules cannot drift (HTTP-26). + * + * @param value - the text to inspect. + * @returns `true` when at least one byte is forbidden. + */ +export function hasForbiddenOutboundByte(value: string): boolean { + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0; + const allowed = code === 0x09 || (code >= 0x20 && code <= 0x7e); + if (!allowed) return true; + } + return false; +} + +/** + * Reports whether `value` contains a byte forbidden in a header name: any C0 control, DEL, or + * non-ASCII byte. Stricter than the value rule — HTAB is not excepted here (HTTP-17). + * + * @param value - the name to inspect, already trimmed. + * @returns `true` when at least one byte is forbidden. + */ +export function hasForbiddenNameByte(value: string): boolean { + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0; + if (code <= 0x1f || code === 0x7f || code > 0x7e) return true; + } + return false; +} + +/** + * Reports whether `value` contains a byte forbidden in an *inbound* header value: control + * characters (C0 except HTAB, plus DEL) only. + * + * Deliberately laxer than {@link hasForbiddenOutboundByte} — RFC 7230 permits obs-text (≥ 0x80) in + * a response field value, and applying the outbound grammar inbound would silently drop legitimate + * headers such as a Latin-1 `Content-Disposition` filename (HTTP-19). + * + * @param value - the text to inspect. + * @returns `true` when at least one byte is forbidden. + */ +export function hasForbiddenInboundValueByte(value: string): boolean { + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0; + const isControl = code <= 0x1f && code !== 0x09; + const isDel = code === 0x7f; + if (isControl || isDel) return true; + } + return false; +} diff --git a/packages/core/src/http/builder.test.ts b/packages/core/src/http/builder.test.ts new file mode 100644 index 0000000..830c476 --- /dev/null +++ b/packages/core/src/http/builder.test.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/builder.test.ts +// Exercises: SEAM-29 (shared Builder contract), HTTP-4 (requireField single-sourcing) +import {describe, expect, test} from 'bun:test'; +import {expectTypeOf} from 'expect-type'; +import type {Builder} from './builder.js'; +import {requireField} from './builder.js'; +import {RequiredFieldError} from './errors.js'; + +describe('requireField', () => { + test('returns the value when present', () => { + expect(requireField('https://example.com', 'url')).toBe( + 'https://example.com', + ); + }); + + test('throws RequiredFieldError naming the field when null', () => { + expect(() => requireField(null, 'url')).toThrow(RequiredFieldError); + expect(() => requireField(null, 'url')).toThrow('url is required'); + }); + + test('throws RequiredFieldError naming the field when undefined', () => { + expect(() => { + requireField(undefined, 'status'); + }).toThrow('status is required'); + }); +}); + +// Type-level contract for the exported generic (styleguide 11.7). The assertions are erased at runtime; +// the real check is `tsc --noEmit` in the lint/typecheck gate. +describe('Builder type contract', () => { + test('any class with build(): T satisfies Builder; the wrong target type is rejected', () => { + class NumberBuilder { + build(): number { + return 1; + } + } + expectTypeOf().toExtend>(); + expectTypeOf().not.toExtend>(); + }); +}); diff --git a/packages/core/src/http/builder.ts b/packages/core/src/http/builder.ts new file mode 100644 index 0000000..5411659 --- /dev/null +++ b/packages/core/src/http/builder.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/builder.ts +import {RequiredFieldError} from './errors.js'; + +/** + * The shared construction contract every builder-based domain model implements (SEAM-29). + * + * Structural, not nominal — a class satisfies it by declaring `build(): T`, with no explicit + * `implements` required — so generic composition helpers can accept any model's builder uniformly. + * + * @typeParam T - the immutable model the builder produces. + * @public + */ +export interface Builder { + /** + * Validates the accumulated state and constructs the immutable model. + * + * @returns a fully constructed, frozen instance of `T`. + */ + build(): T; +} + +/** + * Returns `value` when present, throwing a field-named error when it is `null` or `undefined`. + * + * The single source of HTTP-4's required-field errors — every `build()` in this package routes its + * required-field checks through here so the message can never drift between models. + * + * @param value - the possibly-absent field value. + * @param fieldName - the field's name, as it should appear in the error message. + * @returns `value`, narrowed to `T`. + * @throws {@link RequiredFieldError} when `value` is `null` or `undefined`. + */ +export function requireField( + value: T | null | undefined, + fieldName: string, +): T { + if (value === null || value === undefined) { + throw new RequiredFieldError(fieldName); + } + return value; +} diff --git a/packages/core/src/http/errors.test.ts b/packages/core/src/http/errors.test.ts new file mode 100644 index 0000000..1481fbd --- /dev/null +++ b/packages/core/src/http/errors.test.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/errors.test.ts +// Exercises: HTTP-4 (field-named errors), HTTP-20 (no value echo, escaped name) +import {describe, expect, test} from 'bun:test'; +import { + RequiredFieldError, + HeaderValidationError, + toError, + RequestBodyNotAllowedError, +} from './errors.js'; + +describe('RequiredFieldError', () => { + test('message and structured field name the missing field', () => { + const error = new RequiredFieldError('url'); + expect(error.message).toBe('url is required'); + expect(error.name).toBe('RequiredFieldError'); + expect(error.fieldName).toBe('url'); // structured field, not just prose (styleguide 8.9) + }); +}); + +describe('HeaderValidationError', () => { + test('never echoes the offending value', () => { + const error = new HeaderValidationError( + 'name', + 'X-Trace', + 'secret-token-value', + ); + expect(error.message).not.toContain('secret-token-value'); + }); + + test('escapes control characters in an echoed name', () => { + const error = new HeaderValidationError('name', 'a\rb', undefined); + expect(error.message).not.toContain('\r'); + expect(error.message).toContain('\\r'); + expect(error.kind).toBe('name'); + expect(error.escapedName).toBe('a\\rb'); // the raw value is never stored, only the escaped name + }); +}); + +describe('toError', () => { + test('returns an Error unchanged and wraps a non-Error without ever throwing', () => { + const original = new Error('boom'); + expect(toError(original)).toBe(original); + expect(toError('plain string')).toBeInstanceOf(Error); + expect(toError(Object.create(null))).toBeInstanceOf(Error); + }); +}); + +describe('RequestBodyNotAllowedError', () => { + test('names the offending method', () => { + const error = new RequestBodyNotAllowedError('GET'); + expect(error.message).toContain('GET'); + }); +}); diff --git a/packages/core/src/http/errors.ts b/packages/core/src/http/errors.ts new file mode 100644 index 0000000..33f3ea6 --- /dev/null +++ b/packages/core/src/http/errors.ts @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/errors.ts +/** + * The root of every error the HTTP domain model throws. + * + * Catch this to handle any construction, validation, or parse failure from the model uniformly; + * catch a leaf subclass to distinguish a specific failure. Every subclass sets `name` to its own + * class name, and wrap-and-rethrow always passes `{cause}`. + * + * @public + */ +export class DomainModelError extends Error { + /** + * @param message - the human-readable failure description. + * @param options - standard error options; pass `{cause}` when wrapping a caught error. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = new.target.name; + } +} + +// Narrows a caught `unknown` into an Error (styleguide 8.4). Defined once, imported everywhere a caught +// value becomes a `cause`. Must never itself throw from inside a catch — String() can throw on a +// null-prototype object or a hostile toString, hence the inner try. +/** + * Narrows a caught `unknown` into an `Error`, for use as a `cause` when rethrowing. + * + * Never throws itself — `String()` can throw on a null-prototype object or a hostile `toString`, + * so the conversion is guarded and falls back to a fixed message. + * + * @param value - the caught value. + * @returns `value` unchanged when it is already an `Error`, otherwise a new `Error` describing it. + */ +export function toError(value: unknown): Error { + if (value instanceof Error) return value; + try { + return new Error(String(value)); + } catch { + return new Error('unstringifiable thrown value'); + } +} + +/** + * Thrown by `build()` when a required field was never set (HTTP-4). + * + * The missing field is available both in the message (`" is required"`) and, structurally, + * on {@link RequiredFieldError.fieldName} — prefer the field over parsing the prose. + * + * @public + */ +export class RequiredFieldError extends DomainModelError { + /** The name of the field that was missing. */ + readonly fieldName: string; + + /** + * @param fieldName - the name of the missing field, as it should appear in the message. + */ + constructor(fieldName: string) { + super(`${fieldName} is required`); + this.fieldName = fieldName; + } +} + +/* eslint-disable no-control-regex -- control character escaping required for HTTP-20 */ +function escapeControlChars(input: string): string { + return input + .replace( + /[\x00-\x1f\x7f]/g, + ch => `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`, + ) + .replace(/\\x0d/g, '\\r') + .replace(/\\x0a/g, '\\n'); +} +/* eslint-enable no-control-regex -- re-enable no-control-regex */ + +/* eslint-disable @typescript-eslint/no-unused-vars -- parameter deliberately accepted but un-echoed for HTTP-20 */ +/** + * Thrown when a header name or value fails validation (HTTP-17/18/19). + * + * The offending value is never echoed and never stored, and an echoed name has its control + * characters escaped — the redaction happens in this constructor rather than at each call site, so + * no throw site can leak a secret or inject control characters into a log (HTTP-20). + * + * @public + */ +export class HeaderValidationError extends DomainModelError { + /** Whether the header's name or its value failed validation. */ + readonly kind: 'name' | 'value'; + /** + * The offending header name with its control characters escaped. Escaped before storage; the raw + * offending *value* is deliberately never kept on the error at all (HTTP-20). + */ + readonly escapedName: string; + + /** + * @param kind - whether the name or the value was rejected. + * @param offendingName - the header name; stored only in escaped form. + * @param _offendingValue - the rejected value. Accepted so call sites read naturally, but + * deliberately never interpolated into the message nor retained on the error (HTTP-20). + */ + constructor( + kind: 'name' | 'value', + offendingName: string, + _offendingValue: string | undefined, + ) { + const escapedName = escapeControlChars(offendingName); + super(`invalid header ${kind}: ${escapedName}`); + this.kind = kind; + this.escapedName = escapedName; + } +} +/* eslint-enable @typescript-eslint/no-unused-vars -- re-enable no-unused-vars */ + +/** + * Thrown when a media type is blank, structurally malformed, or contains a byte the grammar forbids + * (HTTP-25/26/53). + * + * @public + */ +export class MediaTypeParseError extends DomainModelError {} + +/** + * Thrown when a protocol identifier is not one of the recognized HTTP versions or their aliases + * (HTTP-33). + * + * @public + */ +export class ProtocolParseError extends DomainModelError {} + +/** + * Thrown when a request URL is malformed or not absolute; the message carries the offending input + * and the underlying parse failure is chained as `cause` (HTTP-47). + * + * @public + */ +export class UrlConstructionError extends DomainModelError {} + +/** + * Thrown when a per-call operational override is out of range — a non-null timeout that is zero or + * negative, or a negative max-retries (HTTP-35). + * + * @public + */ +export class RequestOptionsValidationError extends DomainModelError {} + +/** + * Thrown when an ETag is unterminated, has an empty strong opaque tag, or contains a character + * outside the permitted etagc set (HTTP-48). + * + * @public + */ +export class EtagParseError extends DomainModelError {} + +/** + * Thrown when a byte range is invalid — a negative offset, a non-positive length, an overflowing + * bound, a non-`bytes` unit, or a multi-range list (HTTP-49). + * + * @public + */ +export class HttpRangeValidationError extends DomainModelError {} + +/** + * Thrown when conditional-request state is contradictory — mixing the any-tag (`*`) with a concrete + * entity-tag in the same header (HTTP-50). + * + * @public + */ +export class RequestConditionsValidationError extends DomainModelError {} + +/** + * Thrown when a request carries a body on a method whose classification forbids one — GET, HEAD, + * TRACE, CONNECT. Raised at construction rather than deferred to a transport (HTTP-7). + * + * @public + */ +export class RequestBodyNotAllowedError extends DomainModelError { + /** + * @param method - the method that forbids a body, named in the message. + */ + constructor(method: string) { + super(`method ${method} does not allow a request body`); + } +} diff --git a/packages/core/src/http/etag.test.ts b/packages/core/src/http/etag.test.ts new file mode 100644 index 0000000..b8b83a1 --- /dev/null +++ b/packages/core/src/http/etag.test.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/etag.test.ts +// Exercises: HTTP-48 (strong/weak/any forms, etagc validation, round-trip, absent-for-blank) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {ETag} from './etag.js'; +import {EtagParseError} from './errors.js'; + +describe('ETag.parse', () => { + test('parses a strong ETag', () => { + const etag = ETag.parse('"abc123"'); + expect(etag?.isWeak).toBe(false); + expect(etag?.opaque).toBe('abc123'); + }); + + test('parses a weak ETag', () => { + const etag = ETag.parse('W/"abc123"'); + expect(etag?.isWeak).toBe(true); + expect(etag?.opaque).toBe('abc123'); + }); + + test('parses the any singleton', () => { + const etag = ETag.parse('*'); + expect(etag?.isAny).toBe(true); + }); + + test('rejects a literal quote, control chars, or DEL inside the opaque tag', () => { + expect(() => ETag.parse('"a"b"')).toThrow(EtagParseError); + expect(() => ETag.parse('"a\r\nb"')).toThrow(EtagParseError); + }); + + test('permits obs-text inside the opaque tag', () => { + expect(() => ETag.parse('"café"')).not.toThrow(); + }); + + test('rejects an empty strong opaque tag', () => { + expect(() => ETag.parse('""')).toThrow(EtagParseError); + }); + + test('permits an empty weak opaque tag', () => { + expect(() => ETag.parse('W/""')).not.toThrow(); + }); + + test('round-trips its raw form', () => { + expect(ETag.parse('"abc123"')?.raw).toBe('"abc123"'); + }); + + test('rejects an unterminated form', () => { + expect(() => ETag.parse('"abc123')).toThrow(EtagParseError); + }); + + test('returns absent, not an error, for blank input', () => { + expect(ETag.parse('')).toBeUndefined(); + expect(ETag.parse(' ')).toBeUndefined(); + }); +}); + +describe('raw-form round-trip property (HTTP-48, styleguide 11.5)', () => { + test('parse reproduces the raw form, weakness, and opaque for generated valid ETags', () => { + const opaqueArb = fc.stringMatching(/^[\x23-\x7e]{0,12}$/); // etagc subset; 0x22 (") sits below the range + fc.assert( + fc.property(opaqueArb, fc.boolean(), (opaque, isWeak) => { + fc.pre(isWeak || opaque !== ''); // an empty strong opaque is invalid by HTTP-48 + const raw = isWeak ? `W/"${opaque}"` : `"${opaque}"`; + const parsed = ETag.parse(raw); + expect(parsed?.raw).toBe(raw); + expect(parsed?.opaque).toBe(opaque); + expect(parsed?.isWeak).toBe(isWeak); + }), + ); + }); +}); diff --git a/packages/core/src/http/etag.ts b/packages/core/src/http/etag.ts new file mode 100644 index 0000000..30ed21f --- /dev/null +++ b/packages/core/src/http/etag.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/etag.ts +import {EtagParseError} from './errors.js'; + +function hasForbiddenEtagcByte(value: string): boolean { + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0; + const allowed = + code === 0x21 || (code >= 0x23 && code <= 0x7e) || code >= 0x80; + if (!allowed) return true; + } + return false; +} + +/** + * An HTTP entity-tag in one of three forms: strong (`"opaque"`), weak (`W/"opaque"`), or the any + * singleton ({@link ETag.ANY}, `*`) (HTTP-48). + * + * The opaque tag permits obs-text but rejects a literal quote, control characters, and DEL. A + * strong tag's opaque part must be non-empty; a weak tag's may be empty. Every instance round-trips + * through {@link ETag.raw}. + * + * @public + */ +export class ETag { + readonly #raw: string; + readonly #opaque: string | undefined; + readonly #weak: boolean; + readonly #any: boolean; + + // eslint-disable-next-line max-params -- private, factory-internal; the four ETag facets are a fixed shape (HTTP-48) + private constructor( + raw: string, + opaque: string | undefined, + weak: boolean, + any: boolean, + ) { + this.#raw = raw; + this.#opaque = opaque; + this.#weak = weak; + this.#any = any; + Object.freeze(this); + } + + /** The any singleton, `*`, which matches any entity-tag. */ + static readonly ANY = new ETag('*', undefined, false, true); + + /** + * Parses an entity-tag. + * + * @param raw - the tag text; surrounding whitespace is trimmed. + * @returns the parsed tag, or `undefined` for blank input — an absent header is not an error + * (HTTP-48). + * @throws {@link EtagParseError} when the tag is unterminated or otherwise malformed, when a + * strong tag's opaque part is empty, or when the opaque part contains a quote, a control + * character, or DEL. + */ + static parse(raw: string): ETag | undefined { + const trimmed = raw.trim(); + if (trimmed === '') return undefined; + if (trimmed === '*') return ETag.ANY; + + const weak = trimmed.startsWith('W/'); + const quotedPart = weak ? trimmed.slice(2) : trimmed; + if ( + quotedPart.length < 2 || + !quotedPart.startsWith('"') || + !quotedPart.endsWith('"') + ) { + throw new EtagParseError(`unterminated or malformed ETag: ${raw}`); + } + + const opaque = quotedPart.slice(1, -1); + if (!weak && opaque === '') + throw new EtagParseError('a strong ETag opaque tag must not be empty'); + if (hasForbiddenEtagcByte(opaque)) { + throw new EtagParseError( + 'ETag opaque tag contains a forbidden character', + ); + } + return new ETag(trimmed, opaque, weak, false); + } + + /** Whether this is a weak tag (`W/"..."`), permitting semantically-equivalent representations. */ + get isWeak(): boolean { + return this.#weak; + } + + /** Whether this is the any singleton, `*`. */ + get isAny(): boolean { + return this.#any; + } + + /** The opaque tag with its quotes and weakness prefix stripped; `undefined` for the any tag. */ + get opaque(): string | undefined { + return this.#opaque; + } + + /** The tag exactly as it appears on the wire, including quotes and any `W/` prefix. */ + get raw(): string { + return this.#raw; + } +} diff --git a/packages/core/src/http/headers.test.ts b/packages/core/src/http/headers.test.ts new file mode 100644 index 0000000..08cde02 --- /dev/null +++ b/packages/core/src/http/headers.test.ts @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/headers.test.ts +// Exercises: HTTP-13 (case-insensitive storage), HTTP-14 (multi-value add/set), HTTP-15 (null removes), +// HTTP-16 (insertion order), HTTP-3 (newBuilder derivation doesn't alias), HTTP-5 (no live-builder leak), +// HTTP-17 (outbound name validation + trim), HTTP-18 (outbound value validation), HTTP-19 (inbound leniency), +// HTTP-20 (no value echo, escaped name), HTTP-21 (typed HeaderName interop) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {Headers, HeaderName} from './headers.js'; + +describe('case-insensitive storage', () => { + test('a name added under one casing resolves under any other', () => { + const headers = Headers.newBuilder() + .add('Content-Type', 'text/plain') + .build(); + expect(headers.get('content-type')).toBe('text/plain'); + expect(headers.get('CONTENT-TYPE')).toBe('text/plain'); + expect(headers.has('cOnTeNt-TyPe')).toBe(true); + }); + + test('folds using an ASCII-only rule, not a locale-sensitive one', () => { + const headers = Headers.newBuilder().add('X-Trace-I', 'v').build(); + expect(headers.has('x-trace-i')).toBe(true); + }); +}); + +describe('multi-value semantics', () => { + test('add appends, set replaces the whole list', () => { + const headers = Headers.newBuilder() + .add('X-Tag', 'a') + .add('X-Tag', 'b') + .build(); + expect(headers.getAll('X-Tag')).toEqual(['a', 'b']); + + const replaced = headers.newBuilder().set('X-Tag', 'c').build(); + expect(replaced.getAll('X-Tag')).toEqual(['c']); + }); +}); + +describe('null removes', () => { + test('setting a header value to null removes it entirely', () => { + const headers = Headers.newBuilder() + .add('X-Tag', 'a') + .set('X-Tag', null) + .build(); + expect(headers.has('X-Tag')).toBe(false); + }); +}); + +describe('insertion order', () => { + test('distinct names iterate in insertion order', () => { + const headers = Headers.newBuilder() + .add('X-First', '1') + .add('X-Second', '2') + .add('X-Third', '3') + .build(); + expect(headers.names()).toEqual(['X-First', 'X-Second', 'X-Third']); + }); +}); + +describe('newBuilder derivation', () => { + test('mutating a derived builder does not affect the original', () => { + const original = Headers.newBuilder().add('X-Tag', 'a').build(); + + original.newBuilder().add('X-Tag', 'b').build(); + + expect(original.getAll('X-Tag')).toEqual(['a']); + }); + + test('a previously-returned snapshot is unchanged after the source builder mutates further', () => { + const builder = Headers.newBuilder().add('X-Tag', 'a'); + const first = builder.build(); + builder.add('X-Tag', 'b'); + const second = builder.build(); + + expect(first.getAll('X-Tag')).toEqual(['a']); + expect(second.getAll('X-Tag')).toEqual(['a', 'b']); + }); +}); + +describe('outbound name validation (HTTP-17)', () => { + test('rejects a blank name', () => { + expect(() => Headers.newBuilder().add('', 'v')).toThrow(); + }); + + test('rejects a name with CR/LF or NUL', () => { + expect(() => Headers.newBuilder().add('a\r\nb', 'v')).toThrow(); + expect(() => Headers.newBuilder().add('a\0b', 'v')).toThrow(); + }); + + test('rejects a non-ASCII name', () => { + expect(() => Headers.newBuilder().add('héader', 'v')).toThrow(); + }); + + test('trims surrounding whitespace and stores the trimmed form', () => { + const headers = Headers.newBuilder().add(' X-Trace ', 'v').build(); + expect(headers.names()).toEqual(['X-Trace']); + }); +}); + +describe('outbound value validation (HTTP-18)', () => { + test('rejects CR/LF and NUL in a value', () => { + expect(() => Headers.newBuilder().add('X-Tag', 'a\r\nb')).toThrow(); + expect(() => Headers.newBuilder().add('X-Tag', 'a\0b')).toThrow(); + }); + + test('rejects a non-ASCII value', () => { + expect(() => Headers.newBuilder().add('X-Tag', 'vålue')).toThrow(); + }); + + test('accepts HTAB in a value', () => { + expect(() => Headers.newBuilder().add('X-Tag', 'a\tb')).not.toThrow(); + }); +}); + +describe('inbound leniency (HTTP-19)', () => { + test('permits a non-ASCII (obs-text) inbound value that outbound would reject', () => { + const headers = Headers.newBuilder() + .addInbound('Content-Disposition', 'café') + .build(); + expect(headers.get('content-disposition')).toBe('café'); + }); + + test('still rejects a control character in an inbound value', () => { + expect(() => Headers.newBuilder().addInbound('X-Tag', 'a\r\nb')).toThrow(); + }); + + test('inbound names remain strictly validated', () => { + expect(() => Headers.newBuilder().addInbound('héader', 'v')).toThrow(); + }); +}); + +describe('error messages never leak (HTTP-20)', () => { + test('a rejected value never appears in the thrown message', () => { + try { + Headers.newBuilder().add('X-Tag', 'secret-value-abc\r\n'); + throw new Error('expected add() to throw'); + } catch (e) { + expect((e as Error).message).not.toContain('secret-value-abc'); + } + }); + + test('a rejected name with an embedded CR appears escaped, not raw', () => { + try { + Headers.newBuilder().add('a\rb', 'v'); + throw new Error('expected add() to throw'); + } catch (e) { + expect((e as Error).message).not.toContain('\r'); + expect((e as Error).message).toContain('\\r'); + } + }); +}); + +describe('HeaderName (HTTP-21)', () => { + test('compares by case-folded form while preserving original casing', () => { + const a = HeaderName.of('Content-Type'); + const b = HeaderName.of('content-type'); + expect(a.equals(b)).toBe(true); + expect(a.raw).toBe('Content-Type'); + }); + + test('is interchangeable with the string-keyed API in both directions', () => { + const typedAdded = Headers.newBuilder() + .add(HeaderName.of('X-Trace'), 'v') + .build(); + expect(typedAdded.get('x-trace')).toBe('v'); + + const stringAdded = Headers.newBuilder().add('X-Trace', 'v').build(); + expect(stringAdded.get(HeaderName.of('x-TRACE'))).toBe('v'); + expect(stringAdded.has(HeaderName.of('X-Trace'))).toBe(true); + }); + + test('enforces the same name validation as HTTP-17', () => { + expect(() => HeaderName.of('a\r\nb')).toThrow(); + }); +}); + +describe('case-fold property (HTTP-13)', () => { + test('a name added under any casing resolves under every other casing', () => { + const nameArb = fc.stringMatching(/^[A-Za-z][A-Za-z0-9-]{0,19}$/); + const valueArb = fc.stringMatching(/^[\x20-\x7e]{0,20}$/); + fc.assert( + fc.property(nameArb, valueArb, (name, value) => { + const headers = Headers.newBuilder().add(name, value).build(); + expect(headers.get(name.toLowerCase())).toBe(value); + expect(headers.get(name.toUpperCase())).toBe(value); + expect(headers.has(name)).toBe(true); + }), + ); + }); +}); + +describe('entries() (HTTP-14/16, HTTP-5)', () => { + test('flattens every name/value pair in insertion order, preserving original casing', () => { + const headers = Headers.newBuilder() + .add('X-Tag', 'a') + .add('X-Tag', 'b') + .add('Content-Type', 'text/plain') + .build(); + expect(headers.entries()).toEqual([ + ['X-Tag', 'a'], + ['X-Tag', 'b'], + ['Content-Type', 'text/plain'], + ]); + }); + + test('is empty for empty headers, and a returned snapshot never reaches the model', () => { + expect(Headers.newBuilder().build().entries()).toEqual([]); + + const headers = Headers.newBuilder().add('X-Tag', 'a').build(); + const snapshot = headers.entries() as [string, string][]; + snapshot.push(['X-Injected', 'v']); + expect(headers.entries()).toHaveLength(1); + }); +}); + +describe('setInbound (HTTP-19)', () => { + test('replaces the whole value list under the lenient inbound rule', () => { + const headers = Headers.newBuilder() + .addInbound('X-Tag', 'a') + .addInbound('X-Tag', 'b') + .setInbound('X-Tag', 'café') + .build(); + expect(headers.getAll('X-Tag')).toEqual(['café']); + }); + + test('a null value removes the header, exactly like the outbound set', () => { + const headers = Headers.newBuilder() + .addInbound('X-Tag', 'a') + .setInbound('X-Tag', null) + .build(); + expect(headers.has('X-Tag')).toBe(false); + }); + + test('still rejects a control character in the value and a non-ASCII name', () => { + expect(() => Headers.newBuilder().setInbound('X-Tag', 'a\r\nb')).toThrow(); + expect(() => Headers.newBuilder().setInbound('héader', 'v')).toThrow(); + }); +}); + +describe('HeaderName.lowerCased (HTTP-21)', () => { + test('exposes the case-folded form the model keys on, alongside the raw casing', () => { + const name = HeaderName.of(' Content-Type '); + expect(name.lowerCased).toBe('content-type'); + expect(name.raw).toBe('Content-Type'); + }); +}); diff --git a/packages/core/src/http/headers.ts b/packages/core/src/http/headers.ts new file mode 100644 index 0000000..31ff0f1 --- /dev/null +++ b/packages/core/src/http/headers.ts @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/headers.ts +import type {Builder} from './builder.js'; +import {HeaderValidationError} from './errors.js'; +import { + hasForbiddenNameByte, + hasForbiddenOutboundByte, + hasForbiddenInboundValueByte, +} from './ascii-validation.js'; + +function validateName(name: string): string { + const trimmed = name.trim(); + if (trimmed === '' || hasForbiddenNameByte(trimmed)) { + throw new HeaderValidationError('name', name, undefined); + } + return trimmed; +} + +function validateOutboundValue(name: string, value: string): void { + if (hasForbiddenOutboundByte(value)) { + throw new HeaderValidationError('value', name, value); + } +} + +function validateInboundValue(name: string, value: string): void { + if (hasForbiddenInboundValueByte(value)) { + throw new HeaderValidationError('value', name, value); + } +} + +function toRawName(name: string | HeaderName): string { + return typeof name === 'string' ? name : name.raw; +} + +let createHeaders: ( + valuesByLowerName: ReadonlyMap, + originalCasingByLowerName: ReadonlyMap, + insertionOrder: readonly string[], +) => Headers; + +/** + * An immutable, case-insensitive, multi-value header collection. + * + * Names are folded with an ASCII-invariant rule for storage, lookup, containment, and equality, + * while the original casing is preserved for wire emission (HTTP-13). A name may carry several + * values, and both per-name value order and distinct-name insertion order are preserved + * (HTTP-14/16). Every returned collection is read-only and isolated from the builder that produced + * it, so no accessor can be used to reach back into the model (HTTP-5). + * + * Construct through the static `Headers.newBuilder()`; derive an existing instance through its own + * `newBuilder()` method. + * + * @example + * ```ts + * const headers = Headers.newBuilder() + * .add('Content-Type', 'text/plain') + * .add('X-Tag', 'a') + * .add('X-Tag', 'b') + * .build(); + * + * headers.get('content-type'); // 'text/plain' + * headers.getAll('X-Tag'); // ['a', 'b'] + * ``` + * + * @public + */ +export class Headers { + readonly #valuesByLowerName: ReadonlyMap; + readonly #originalCasingByLowerName: ReadonlyMap; + readonly #insertionOrder: readonly string[]; + + private constructor( + valuesByLowerName: ReadonlyMap, + originalCasingByLowerName: ReadonlyMap, + insertionOrder: readonly string[], + ) { + this.#valuesByLowerName = valuesByLowerName; + this.#originalCasingByLowerName = originalCasingByLowerName; + this.#insertionOrder = insertionOrder; + Object.freeze(this); + } + + static { + createHeaders = (values, casing, order) => + new Headers(values, casing, order); + } + + /** + * Starts an empty builder. + * + * @returns a fresh {@link HeadersBuilder} with no headers set. + */ + static newBuilder(): HeadersBuilder { + return new HeadersBuilder(); + } + + /** + * Derives a builder pre-populated from this instance (HTTP-3). + * + * Every value list is copied, never aliased, so mutating the returned builder leaves this + * instance unchanged. Values are re-appended through the lenient inbound path: they already + * passed validation when this instance was built, and a `Headers` carrying obs-text must stay + * derivable (HTTP-19). + * + * @returns a {@link HeadersBuilder} holding a copy of this instance's headers. + */ + newBuilder(): HeadersBuilder { + const builder = new HeadersBuilder(); + for (const lowerName of this.#insertionOrder) { + const originalName = + this.#originalCasingByLowerName.get(lowerName) ?? lowerName; + for (const value of this.#valuesByLowerName.get(lowerName) ?? []) { + builder.addInbound(originalName, value); + } + } + return builder; + } + + /** + * Returns the first value stored under `name`, matched case-insensitively. + * + * @param name - the header name, as a string or a {@link HeaderName} (HTTP-21). + * @returns the first value, or `undefined` when the name is absent. + */ + get(name: string | HeaderName): string | undefined { + return this.#valuesByLowerName.get(toRawName(name).toLowerCase())?.[0]; + } + + /** + * Returns every value stored under `name`, in insertion order. + * + * @param name - the header name, as a string or a {@link HeaderName}. + * @returns a read-only, frozen list of values — empty when the name is absent. + */ + getAll(name: string | HeaderName): readonly string[] { + return this.#valuesByLowerName.get(toRawName(name).toLowerCase()) ?? []; + } + + /** + * Reports whether `name` is present, matched case-insensitively. + * + * @param name - the header name, as a string or a {@link HeaderName}. + * @returns `true` when at least one value is stored under the name. + */ + has(name: string | HeaderName): boolean { + return this.#valuesByLowerName.has(toRawName(name).toLowerCase()); + } + + /** + * Lists the distinct header names in insertion order, each in its original casing (HTTP-16). + * + * @returns a fresh read-only list; mutating it cannot reach the model. + */ + names(): readonly string[] { + return this.#insertionOrder.map( + lowerName => this.#originalCasingByLowerName.get(lowerName) ?? lowerName, + ); + } + + /** + * Flattens every name/value pair for wire emission — one entry per value, so a multi-value name + * appears once per value, in insertion order and original casing. + * + * @returns a fresh read-only list of `[name, value]` pairs; mutating it cannot reach the model. + */ + entries(): readonly (readonly [string, string])[] { + const result: (readonly [string, string])[] = []; + for (const lowerName of this.#insertionOrder) { + const originalName = + this.#originalCasingByLowerName.get(lowerName) ?? lowerName; + for (const value of this.#valuesByLowerName.get(lowerName) ?? []) { + result.push([originalName, value]); + } + } + return result; + } + + /** + * Compares by value, case-insensitively on names and case-sensitively on values (HTTP-13). + * + * @param other - the headers to compare against. + * @returns `true` when both hold the same names with the same value lists. + */ + equals(other: Headers): boolean { + if (this.#insertionOrder.length !== other.#insertionOrder.length) + return false; + for (const lowerName of this.#insertionOrder) { + const mine = this.#valuesByLowerName.get(lowerName) ?? []; + const theirs = other.#valuesByLowerName.get(lowerName) ?? []; + if (mine.length !== theirs.length || mine.some((v, i) => v !== theirs[i])) + return false; + } + return true; + } +} + +/** + * Accumulates headers and produces an immutable {@link Headers}. + * + * Two validation paths exist deliberately. The outbound methods ({@link HeadersBuilder.add}, + * {@link HeadersBuilder.set}) apply the strict caller-set grammar — HTAB plus printable ASCII in + * values, no control or non-ASCII bytes in names (HTTP-17/18). The inbound methods + * ({@link HeadersBuilder.addInbound}, {@link HeadersBuilder.setInbound}) relax values to permit + * obs-text while still rejecting control characters, for headers received from a server (HTTP-19). + * Names are validated strictly on both paths, and are trimmed before validation. + * + * @public + */ +export class HeadersBuilder implements Builder { + readonly #valuesByLowerName = new Map(); + readonly #originalCasingByLowerName = new Map(); + readonly #insertionOrder: string[] = []; + + /** + * Appends a value under `name`, keeping any values already stored there (HTTP-14). + * + * @param name - the header name; surrounding whitespace is trimmed before validation. + * @param value - the value, held to the strict outbound grammar. + * @returns this builder, for chaining. + * @throws {@link HeaderValidationError} when the name is blank or carries a control, DEL, or + * non-ASCII byte, or when the value carries anything outside HTAB and printable ASCII. + */ + add(name: string | HeaderName, value: string): this { + const trimmedName = validateName(toRawName(name)); + validateOutboundValue(trimmedName, value); + return this.#append(trimmedName, value); + } + + /** + * Replaces the whole value list under `name`, or removes the header when `value` is `null` + * (HTTP-14/15). + * + * @param name - the header name; surrounding whitespace is trimmed before validation. + * @param value - the single replacement value, or `null` to remove the header entirely. + * @returns this builder, for chaining. + * @throws {@link HeaderValidationError} when the name is invalid, or when a non-null value + * carries anything outside HTAB and printable ASCII. + */ + set(name: string | HeaderName, value: string | null): this { + const trimmedName = validateName(toRawName(name)); + if (value !== null) validateOutboundValue(trimmedName, value); + return this.#replace(trimmedName, value); + } + + /** + * Appends a value received from a server, permitting obs-text in the value (HTTP-19). + * + * @param name - the header name; validated as strictly as on the outbound path. + * @param value - the received value; control characters and DEL are still rejected. + * @returns this builder, for chaining. + * @throws {@link HeaderValidationError} when the name is invalid or the value carries a control + * character or DEL. + */ + addInbound(name: string | HeaderName, value: string): this { + const trimmedName = validateName(toRawName(name)); + validateInboundValue(trimmedName, value); + return this.#append(trimmedName, value); + } + + /** + * Replaces the value list for a header received from a server, or removes it when `value` is + * `null`, permitting obs-text in the value (HTTP-19). + * + * @param name - the header name; validated as strictly as on the outbound path. + * @param value - the single replacement value, or `null` to remove the header entirely. + * @returns this builder, for chaining. + * @throws {@link HeaderValidationError} when the name is invalid or a non-null value carries a + * control character or DEL. + */ + setInbound(name: string | HeaderName, value: string | null): this { + const trimmedName = validateName(toRawName(name)); + if (value !== null) validateInboundValue(trimmedName, value); + return this.#replace(trimmedName, value); + } + + #append(name: string, value: string): this { + const lowerName = name.toLowerCase(); + if (!this.#valuesByLowerName.has(lowerName)) { + this.#insertionOrder.push(lowerName); + this.#originalCasingByLowerName.set(lowerName, name); + this.#valuesByLowerName.set(lowerName, []); + } + this.#valuesByLowerName.get(lowerName)?.push(value); + return this; + } + + #replace(name: string, value: string | null): this { + const lowerName = name.toLowerCase(); + if (value === null) { + this.#valuesByLowerName.delete(lowerName); + this.#originalCasingByLowerName.delete(lowerName); + const index = this.#insertionOrder.indexOf(lowerName); + if (index !== -1) this.#insertionOrder.splice(index, 1); + return this; + } + if (!this.#valuesByLowerName.has(lowerName)) + this.#insertionOrder.push(lowerName); + this.#originalCasingByLowerName.set(lowerName, name); + this.#valuesByLowerName.set(lowerName, [value]); + return this; + } + + /** + * Deep-copies and freezes the accumulated state into an immutable {@link Headers}. + * + * Every value list is copied at this point, so a snapshot returned earlier never observes later + * mutations of this builder (HTTP-5). + * + * @returns the frozen headers. + */ + build(): Headers { + const frozenValues = new Map(); + for (const [lowerName, values] of this.#valuesByLowerName) { + frozenValues.set(lowerName, Object.freeze([...values])); + } + return createHeaders( + Object.freeze(frozenValues), + Object.freeze(new Map(this.#originalCasingByLowerName)), + Object.freeze([...this.#insertionOrder]), + ); + } +} + +/** + * A validated header name that compares by its case-folded form while preserving the original + * casing for wire emission (HTTP-21). + * + * Interchangeable with plain strings: every name-accepting method on {@link Headers} and + * {@link HeadersBuilder} takes either form, and a header added under one form is visible under the + * other. Enforces the same name grammar as the outbound string path (HTTP-17). + * + * Instances are deliberately not interned. HTTP-22 makes interning a MAY, and a process-lived map + * keyed by caller-supplied names would be unbounded caller-influenced state; the observable + * contract is value equality by case-folded name, which needs no shared instances. + * + * @public + */ +export class HeaderName { + readonly #raw: string; + readonly #lower: string; + + private constructor(raw: string, lower: string) { + this.#raw = raw; + this.#lower = lower; + Object.freeze(this); + } + + /** + * Validates and constructs a header name. + * + * @param raw - the name; surrounding whitespace is trimmed before validation and the trimmed + * form is what gets stored. + * @returns the frozen header name. + * @throws {@link HeaderValidationError} when the name is blank or carries a control, DEL, or + * non-ASCII byte (HTTP-17). + */ + static of(raw: string): HeaderName { + const trimmed = validateName(raw); + return new HeaderName(trimmed, trimmed.toLowerCase()); + } + + /** The name in its original (trimmed) casing, as it should appear on the wire. */ + get raw(): string { + return this.#raw; + } + + /** The case-folded form the header model keys on. */ + get lowerCased(): string { + return this.#lower; + } + + /** + * Compares by case-folded form; original casing never participates. + * + * @param other - the name to compare against. + * @returns `true` when both fold to the same name. + */ + equals(other: HeaderName): boolean { + return this.#lower === other.#lower; + } +} diff --git a/packages/core/src/http/http-range.test.ts b/packages/core/src/http/http-range.test.ts new file mode 100644 index 0000000..3787630 --- /dev/null +++ b/packages/core/src/http/http-range.test.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/http-range.test.ts +// Exercises: HTTP-49 (bounded/suffix/open factories, bytes-only, single-range, verbatim storage) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {HttpRange} from './http-range.js'; +import {HttpRangeValidationError} from './errors.js'; + +describe('bounded()', () => { + test('rejects a negative offset', () => { + expect(() => HttpRange.bounded(-1, 10)).toThrow(HttpRangeValidationError); + }); + + test('rejects a non-positive length', () => { + expect(() => HttpRange.bounded(0, 0)).toThrow(HttpRangeValidationError); + expect(() => HttpRange.bounded(0, -5)).toThrow(HttpRangeValidationError); + }); + + test('constructs a valid bounded range', () => { + const range = HttpRange.bounded(0, 500); + expect(range.kind).toBe('bounded'); + expect(range.start).toBe(0); + expect(range.length).toBe(500); + }); +}); + +describe('suffix()', () => { + test('rejects a non-positive suffix length', () => { + expect(() => HttpRange.suffix(0)).toThrow(HttpRangeValidationError); + }); + + test('constructs a valid suffix range', () => { + const range = HttpRange.suffix(500); + expect(range.kind).toBe('suffix'); + expect(range.suffixLength).toBe(500); + }); +}); + +describe('open()', () => { + test('rejects a negative start', () => { + expect(() => HttpRange.open(-1)).toThrow(HttpRangeValidationError); + }); + + test('constructs a valid open-ended range', () => { + const range = HttpRange.open(9500); + expect(range.kind).toBe('open'); + expect(range.start).toBe(9500); + }); +}); + +describe('parse()', () => { + test('parses a bounded range and stores the raw text verbatim', () => { + const range = HttpRange.parse('bytes=0-499'); + expect(range.kind).toBe('bounded'); + expect(range.start).toBe(0); + expect(range.length).toBe(500); + expect(range.raw).toBe('bytes=0-499'); + }); + + test('parses a suffix range', () => { + const range = HttpRange.parse('bytes=-500'); + expect(range.kind).toBe('suffix'); + expect(range.suffixLength).toBe(500); + }); + + test('parses an open-ended range', () => { + const range = HttpRange.parse('bytes=9500-'); + expect(range.kind).toBe('open'); + expect(range.start).toBe(9500); + }); + + test('supports only the bytes unit', () => { + expect(() => HttpRange.parse('items=0-4')).toThrow( + HttpRangeValidationError, + ); + }); + + test('rejects a multi-range comma', () => { + expect(() => HttpRange.parse('bytes=0-499,600-999')).toThrow( + HttpRangeValidationError, + ); + }); + + test('rejects fractional, hex, and overflowing values in factories and parse alike', () => { + expect(() => HttpRange.bounded(1.5, 2)).toThrow(HttpRangeValidationError); + expect(() => HttpRange.parse('bytes=0x10-0x20')).toThrow( + HttpRangeValidationError, + ); + expect(() => HttpRange.parse('bytes=0-9007199254740993')).toThrow( + HttpRangeValidationError, + ); + }); +}); + +describe('factory/parse round-trip property (HTTP-49, styleguide 11.5)', () => { + test('a bounded factory raw form re-parses to the same range', () => { + fc.assert( + fc.property( + fc.nat({max: 1_000_000}), + fc.integer({min: 1, max: 1_000_000}), + (start, length) => { + const range = HttpRange.bounded(start, length); + const reparsed = HttpRange.parse(range.raw); + expect(reparsed.kind).toBe('bounded'); + expect(reparsed.start).toBe(start); + expect(reparsed.length).toBe(length); + }, + ), + ); + }); +}); diff --git a/packages/core/src/http/http-range.ts b/packages/core/src/http/http-range.ts new file mode 100644 index 0000000..ad87d82 --- /dev/null +++ b/packages/core/src/http/http-range.ts @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/http-range.ts +import {HttpRangeValidationError} from './errors.js'; + +/** + * Which of the three byte-range shapes an {@link HttpRange} holds: a bounded window + * (`bytes=0-499`), a trailing suffix (`bytes=-500`), or an open-ended tail (`bytes=9500-`). + * + * @public + */ +export type RangeKind = 'bounded' | 'suffix' | 'open'; + +function validateNonNegative(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new HttpRangeValidationError( + `${label} must be a non-negative safe integer, got ${String(value)}`, + ); + } +} + +function validatePositive(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new HttpRangeValidationError( + `${label} must be a positive safe integer, got ${String(value)}`, + ); + } +} + +function parseByteCount(part: string, label: string): number { + if (!/^\d+$/.test(part)) { + throw new HttpRangeValidationError( + `${label} must be a plain decimal integer`, + ); + } + const value = Number(part); + if (!Number.isSafeInteger(value)) { + throw new HttpRangeValidationError( + `${label} overflows the safe-integer range`, + ); + } + return value; +} + +/** + * A single HTTP byte range, in one of the three {@link RangeKind} shapes (HTTP-49). + * + * Only the `bytes` unit is supported, and only one range per value — a multi-range comma list is + * rejected. All bounds must be plain decimal safe integers, so a fractional, hex, or overflowing + * value fails at construction rather than silently truncating. + * + * {@link HttpRange.raw} holds the parsed input verbatim when the instance came from + * {@link HttpRange.parse}, and a synthesized canonical form when it came from one of the + * factories — those have no original wire text to preserve. + * + * @public + */ +export class HttpRange { + readonly #kind: RangeKind; + readonly #start: number | undefined; + readonly #length: number | undefined; + readonly #suffixLength: number | undefined; + readonly #raw: string; + + // eslint-disable-next-line max-params -- private, factory-internal; range facets are a fixed shape (HTTP-49) + private constructor( + kind: RangeKind, + start: number | undefined, + length: number | undefined, + suffixLength: number | undefined, + raw: string, + ) { + this.#kind = kind; + this.#start = start; + this.#length = length; + this.#suffixLength = suffixLength; + this.#raw = raw; + Object.freeze(this); + } + + /** + * Builds a bounded range covering `length` bytes from `start`. + * + * @param start - the first byte offset; must be a non-negative safe integer. + * @param length - how many bytes to request; must be a positive safe integer. + * @returns the frozen range, rendering as `bytes=start-end`. + * @throws {@link HttpRangeValidationError} when the offset is negative, the length is + * non-positive, either is not a safe integer, or the computed end overflows. + */ + static bounded(start: number, length: number): HttpRange { + validateNonNegative(start, 'range start'); + validatePositive(length, 'range length'); + const end = start + length - 1; + if (!Number.isSafeInteger(end)) + throw new HttpRangeValidationError( + `range overflows: ${String(start)}-${String(end)}`, + ); + return new HttpRange( + 'bounded', + start, + length, + undefined, + `bytes=${String(start)}-${String(end)}`, + ); + } + + /** + * Builds a suffix range requesting the final `suffixLength` bytes. + * + * @param suffixLength - how many trailing bytes to request; must be a positive safe integer. + * @returns the frozen range, rendering as `bytes=-suffixLength`. + * @throws {@link HttpRangeValidationError} when the length is non-positive or not a safe integer. + */ + static suffix(suffixLength: number): HttpRange { + validatePositive(suffixLength, 'suffix length'); + return new HttpRange( + 'suffix', + undefined, + undefined, + suffixLength, + `bytes=-${String(suffixLength)}`, + ); + } + + /** + * Builds an open-ended range from `start` to the end of the representation. + * + * @param start - the first byte offset; must be a non-negative safe integer. + * @returns the frozen range, rendering as `bytes=start-`. + * @throws {@link HttpRangeValidationError} when the offset is negative or not a safe integer. + */ + static open(start: number): HttpRange { + validateNonNegative(start, 'range start'); + return new HttpRange( + 'open', + start, + undefined, + undefined, + `bytes=${String(start)}-`, + ); + } + + /** + * Parses a `Range` header value, holding parse to the same strictness as the factories. + * + * @param raw - the range text, e.g. `bytes=0-499`; surrounding whitespace is trimmed and the + * trimmed text is retained as {@link HttpRange.raw}. + * @returns the frozen range. + * @throws {@link HttpRangeValidationError} when the unit is not `bytes`, the value is a + * multi-range list, the spec is malformed, a bound is not a plain decimal integer, a bound + * overflows the safe-integer range, or the implied length is non-positive. + */ + static parse(raw: string): HttpRange { + const trimmed = raw.trim(); + if (!trimmed.startsWith('bytes=')) { + throw new HttpRangeValidationError( + `only the bytes unit is supported: ${raw}`, + ); + } + + const spec = trimmed.slice('bytes='.length); + if (spec.includes(',')) + throw new HttpRangeValidationError( + `multi-range is not supported: ${raw}`, + ); + + const dashIndex = spec.indexOf('-'); + if (dashIndex === -1) + throw new HttpRangeValidationError(`malformed range: ${raw}`); + + const startPart = spec.slice(0, dashIndex); + const endPart = spec.slice(dashIndex + 1); + + if (startPart === '') { + const suffixLength = parseByteCount(endPart, 'suffix length'); + validatePositive(suffixLength, 'suffix length'); + return new HttpRange( + 'suffix', + undefined, + undefined, + suffixLength, + trimmed, + ); + } + + const start = parseByteCount(startPart, 'range start'); + if (endPart === '') + return new HttpRange('open', start, undefined, undefined, trimmed); + + const end = parseByteCount(endPart, 'range end'); + const length = end - start + 1; + validatePositive(length, 'range length'); + return new HttpRange('bounded', start, length, undefined, trimmed); + } + + /** Which range shape this instance holds. */ + get kind(): RangeKind { + return this.#kind; + } + + /** The first byte offset for a bounded or open range; `undefined` for a suffix range. */ + get start(): number | undefined { + return this.#start; + } + + /** The byte count for a bounded range; `undefined` for a suffix or open range. */ + get length(): number | undefined { + return this.#length; + } + + /** The trailing byte count for a suffix range; `undefined` for the other shapes. */ + get suffixLength(): number | undefined { + return this.#suffixLength; + } + + /** The wire form: the parsed input verbatim, or a canonical rendering for factory-built ranges. */ + get raw(): string { + return this.#raw; + } +} diff --git a/packages/core/src/http/index.ts b/packages/core/src/http/index.ts new file mode 100644 index 0000000..05aaaa0 --- /dev/null +++ b/packages/core/src/http/index.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/index.ts +// requireField, toError, the method predicates (isIdempotent/isBodyForbidden/methodWireToken), and the +// ascii-validation predicates are deliberately NOT re-exported: HTTP-9 keeps the idempotency classification +// an internal constant rather than a public accessor, and the rest are in-package plumbing with no external +// caller yet (api-design ch10: helpers stay unexported until an outside caller genuinely needs them). +export type {Builder} from './builder.js'; +export { + DomainModelError, + RequiredFieldError, + HeaderValidationError, + MediaTypeParseError, + ProtocolParseError, + UrlConstructionError, + RequestOptionsValidationError, + EtagParseError, + HttpRangeValidationError, + RequestConditionsValidationError, + RequestBodyNotAllowedError, +} from './errors.js'; +export type {Method} from './method.js'; +export {Status} from './status.js'; +export {Protocol} from './protocol.js'; +export {MediaType} from './media-type.js'; +export {Headers, HeadersBuilder, HeaderName} from './headers.js'; +export {QueryParams, QueryParamsBuilder} from './query-params.js'; +export {Request, RequestBuilder} from './request.js'; +export {Response, ResponseBuilder} from './response.js'; +export {RequestOptions, RequestOptionsBuilder} from './request-options.js'; +export {ETag} from './etag.js'; +export {HttpRange, type RangeKind} from './http-range.js'; +export { + RequestConditions, + RequestConditionsBuilder, +} from './request-conditions.js'; diff --git a/packages/core/src/http/media-type.test.ts b/packages/core/src/http/media-type.test.ts new file mode 100644 index 0000000..d4c8246 --- /dev/null +++ b/packages/core/src/http/media-type.test.ts @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/media-type.test.ts +// Exercises: HTTP-23 (case rules), HTTP-24 (charset never throws), HTTP-25/HTTP-53 (parse/render round-trip), +// HTTP-26 (forbidden bytes), HTTP-27 (wildcard matching) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {MediaType} from './media-type.js'; +import {MediaTypeParseError} from './errors.js'; + +describe('MediaType.parse', () => { + test('lower-cases type, subtype, and parameter keys; preserves parameter value case', () => { + const mediaType = MediaType.parse('Application/JSON;Charset=UTF-8'); + expect(mediaType.type).toBe('application'); + expect(mediaType.subtype).toBe('json'); + expect(mediaType.parameter('charset')).toBe('UTF-8'); + }); + + test('rejects blank input', () => { + expect(() => MediaType.parse('')).toThrow(MediaTypeParseError); + expect(() => MediaType.parse(' ')).toThrow(MediaTypeParseError); + }); + + test('rejects an empty type or subtype', () => { + expect(() => MediaType.parse('/json')).toThrow(MediaTypeParseError); + expect(() => MediaType.parse('application/')).toThrow(MediaTypeParseError); + }); + + test('rejects a parameter with no "=" or an empty key/value', () => { + expect(() => MediaType.parse('text/plain;charset')).toThrow( + MediaTypeParseError, + ); + expect(() => MediaType.parse('text/plain;=utf-8')).toThrow( + MediaTypeParseError, + ); + }); + + test('respects quoted-strings when splitting parameters', () => { + const mediaType = MediaType.parse('text/plain;boundary="a;b=c"'); + expect(mediaType.parameter('boundary')).toBe('a;b=c'); + }); +}); + +describe('charset', () => { + test('resolves case-insensitively', () => { + expect(MediaType.parse('text/plain;CHARSET=utf-8').charset).toBe('utf-8'); + }); + + test('is undefined, never throws, when absent or unknown', () => { + expect(MediaType.parse('text/plain').charset).toBeUndefined(); + }); +}); + +describe('construction rejects forbidden bytes (HTTP-26)', () => { + test('rejects a control character or non-ASCII byte in type/subtype/params', () => { + expect(() => MediaType.of('text', 'plain\r\n')).toThrow( + MediaTypeParseError, + ); + expect(() => + MediaType.of('text', 'plain', new Map([['name', 'vålue']])), + ).toThrow(MediaTypeParseError); + }); + + test('rejects a non-token or empty type, subtype, or parameter key via of()', () => { + expect(() => MediaType.of('', 'json')).toThrow(MediaTypeParseError); + expect(() => MediaType.of('te;xt', 'plain')).toThrow(MediaTypeParseError); + expect(() => + MediaType.of('text', 'plain', new Map([['ke=y', 'v']])), + ).toThrow(MediaTypeParseError); + }); +}); + +describe('wildcard matching (HTTP-27)', () => { + test('a bare */* matches anything', () => { + expect( + MediaType.parse('application/json').matches(MediaType.parse('*/*')), + ).toBe(true); + }); + + test('a wildcard subtype matches any concrete subtype, but not the reverse', () => { + expect( + MediaType.parse('application/json').matches( + MediaType.parse('application/*'), + ), + ).toBe(true); + expect( + MediaType.parse('application/json').matches(MediaType.parse('text/*')), + ).toBe(false); + }); + + test('rejects a wildcard type with a concrete subtype', () => { + expect(() => MediaType.parse('*/json')).toThrow(MediaTypeParseError); + }); +}); + +describe('parse(render(x)) === x round-trip (HTTP-25)', () => { + test('holds for generated type/subtype/parameter combinations', () => { + const tokenArb = fc.stringMatching(/^[a-z][a-z0-9]{0,9}$/); + const valueArb = fc + .string({minLength: 0, maxLength: 12}) + .filter(s => /^[\x20-\x7e]*$/.test(s)); + fc.assert( + fc.property( + tokenArb, + tokenArb, + fc.dictionary(tokenArb, valueArb, {maxKeys: 4}), + (type, subtype, params) => { + const original = MediaType.of( + type, + subtype, + new Map(Object.entries(params)), + ); + const restored = MediaType.parse(original.render()); + expect(restored.equals(original)).toBe(true); + }, + ), + ); + }); +}); diff --git a/packages/core/src/http/media-type.ts b/packages/core/src/http/media-type.ts new file mode 100644 index 0000000..b2afabf --- /dev/null +++ b/packages/core/src/http/media-type.ts @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/media-type.ts +import {MediaTypeParseError} from './errors.js'; +import {hasForbiddenOutboundByte} from './ascii-validation.js'; + +const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +function splitRespectingQuotes(input: string, separator: string): string[] { + const parts: string[] = []; + let current = ''; + let inQuotes = false; + let escaped = false; + for (const ch of input) { + if (escaped) { + current += ch; + escaped = false; + } else if (ch === '\\' && inQuotes) { + current += ch; + escaped = true; + } else if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + } else if (ch === separator && !inQuotes) { + parts.push(current); + current = ''; + } else { + current += ch; + } + } + parts.push(current); + return parts; +} + +function validateNoForbiddenBytes(value: string): void { + if (hasForbiddenOutboundByte(value)) { + throw new MediaTypeParseError( + `media type contains a forbidden character (${String(value.length)} chars)`, + ); + } +} + +function validateToken(value: string, label: string): void { + if (!TOKEN_RE.test(value)) { + throw new MediaTypeParseError( + `media type ${label} must be a non-empty RFC token (${String(value.length)} chars)`, + ); + } +} + +function renderParameterValue(value: string): string { + if (TOKEN_RE.test(value)) return value; + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +/** + * An RFC 7231 media type — a type, a subtype, and zero or more parameters. + * + * Type, subtype, and parameter keys are lower-cased at construction; parameter values keep their + * case, so equality is case-insensitive on the former and case-sensitive on the latter (HTTP-23). + * Every constructible instance renders to text that re-parses to an equal value (HTTP-25), and no + * instance can carry a byte that would be unsafe in a header (HTTP-26). + * + * @example + * ```ts + * const json = MediaType.parse('Application/JSON;Charset=UTF-8'); + * json.type; // 'application' + * json.charset; // 'UTF-8' + * json.matches(MediaType.parse('application/*')); // true + * ``` + * + * @public + */ +export class MediaType { + readonly #type: string; + readonly #subtype: string; + readonly #parameters: ReadonlyMap; + + private constructor( + type: string, + subtype: string, + parameters: ReadonlyMap, + ) { + this.#type = type; + this.#subtype = subtype; + this.#parameters = parameters; + Object.freeze(this); + } + + /** + * Constructs a media type from already-separated parts. + * + * @param type - the type; must be a non-empty RFC token. + * @param subtype - the subtype; must be a non-empty RFC token. + * @param parameters - optional parameters; keys must be RFC tokens, values must be free of + * control and non-ASCII bytes. Copied, never aliased. + * @returns the frozen media type, with type, subtype, and parameter keys lower-cased. + * @throws {@link MediaTypeParseError} when a part is not a valid token, when a parameter value + * contains a forbidden byte, or when a wildcard type is paired with a concrete subtype. + */ + static of( + type: string, + subtype: string, + parameters: ReadonlyMap = new Map(), + ): MediaType { + // Type, subtype, and parameter keys must be RFC tokens (which also implies non-empty and free of + // forbidden bytes) — anything else renders to text that parse() rejects or reparses differently, + // breaking HTTP-25's parse(render(x)) === x guarantee for constructible values (HTTP-53's grammar). + validateToken(type, 'type'); + validateToken(subtype, 'subtype'); + if (type === '*' && subtype !== '*') { + throw new MediaTypeParseError( + 'a wildcard type is only permitted with a wildcard subtype (*/*) per HTTP-27', + ); + } + const normalized = new Map(); + for (const [key, value] of parameters) { + validateToken(key, 'parameter key'); + validateNoForbiddenBytes(value); + normalized.set(key.toLowerCase(), value); + } + return new MediaType( + type.toLowerCase(), + subtype.toLowerCase(), + Object.freeze(normalized), + ); + } + + /** + * Parses a media type, respecting quoted-strings — a `;` or `=` inside quotes is not a separator + * — splitting each parameter on its first `=` only, stripping quotes, and unescaping + * quoted-pairs (HTTP-25). + * + * @param raw - the media-type text, e.g. `text/plain;charset=utf-8`. + * @returns the parsed, frozen media type. + * @throws {@link MediaTypeParseError} when the input is blank, lacks a non-empty type and subtype + * around a single `/`, carries a parameter without a non-empty key and value, or contains a byte + * the grammar forbids. + */ + static parse(raw: string): MediaType { + if (raw.trim() === '') + throw new MediaTypeParseError('media type cannot be blank'); + + const segments = splitRespectingQuotes(raw, ';'); + const typeSubtype = segments[0]?.trim() ?? ''; + const slashIndex = typeSubtype.indexOf('/'); + if (slashIndex <= 0 || slashIndex === typeSubtype.length - 1) { + throw new MediaTypeParseError( + `media type requires non-empty type and subtype: ${raw}`, + ); + } + + const type = typeSubtype.slice(0, slashIndex); + const subtype = typeSubtype.slice(slashIndex + 1); + const parameters = MediaType.#parseParameters(segments.slice(1), raw); + return MediaType.of(type, subtype, parameters); + } + + static #parseParameters( + segments: readonly string[], + raw: string, + ): Map { + const parameters = new Map(); + for (const segment of segments) { + const trimmed = segment.trim(); + if (trimmed === '') continue; + + const eqIndex = trimmed.indexOf('='); + if (eqIndex <= 0 || eqIndex === trimmed.length - 1) { + throw new MediaTypeParseError( + `malformed parameter in media type: ${raw}`, + ); + } + + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1).replace(/\\(.)/g, '$1'); + } + parameters.set(key, value); + } + return parameters; + } + + /** The lower-cased type, e.g. `application`. */ + get type(): string { + return this.#type; + } + + /** The lower-cased subtype, e.g. `json`. */ + get subtype(): string { + return this.#subtype; + } + + /** + * Looks up a parameter by key, case-insensitively. + * + * @param key - the parameter key. + * @returns the parameter's value with its original case, or `undefined` when absent. + */ + parameter(key: string): string | undefined { + return this.#parameters.get(key.toLowerCase()); + } + + /** + * The `charset` parameter, resolved case-insensitively — `undefined` when absent, never throwing, + * so callers fall back to their own default (HTTP-24). + * + * The value is returned verbatim and is not checked against a registry of known encodings; an + * unrecognized name surfaces as-is rather than as `undefined`. + */ + get charset(): string | undefined { + return this.parameter('charset'); + } + + /** + * Renders the canonical wire form, emitting each parameter value bare when it is a valid token + * and quoted-and-escaped otherwise, so `parse(render(x))` equals `x` (HTTP-25). + * + * @returns the rendered media type. + */ + render(): string { + let result = `${this.#type}/${this.#subtype}`; + for (const [key, value] of this.#parameters) { + result += `; ${key}=${renderParameterValue(value)}`; + } + return result; + } + + /** + * Tests this media type against a possibly-wildcarded pattern, ignoring parameters (HTTP-27). + * + * A wildcard in either position matches any value; a wildcard type is only constructible with a + * wildcard subtype, so the pattern is either `*/*`, `type/*`, or fully concrete. + * + * @param pattern - the pattern to match against. + * @returns `true` when this media type satisfies the pattern. + */ + matches(pattern: MediaType): boolean { + const typeMatches = pattern.#type === '*' || pattern.#type === this.#type; + const subtypeMatches = + pattern.#subtype === '*' || pattern.#subtype === this.#subtype; + return typeMatches && subtypeMatches; + } + + /** + * Compares by value: case-insensitively on type, subtype, and parameter keys (all already + * folded), case-sensitively on parameter values (HTTP-23). + * + * @param other - the media type to compare against. + * @returns `true` when both describe the same media type with the same parameters. + */ + equals(other: MediaType): boolean { + if (this.#type !== other.#type || this.#subtype !== other.#subtype) + return false; + if (this.#parameters.size !== other.#parameters.size) return false; + for (const [key, value] of this.#parameters) { + if (other.#parameters.get(key) !== value) return false; + } + return true; + } +} diff --git a/packages/core/src/http/method.test.ts b/packages/core/src/http/method.test.ts new file mode 100644 index 0000000..b25206e --- /dev/null +++ b/packages/core/src/http/method.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/method.test.ts +// Exercises: HTTP-9 (idempotency classification, uppercase wire token) +import {describe, expect, test} from 'bun:test'; +import { + isIdempotent, + isBodyForbidden, + methodWireToken, + type Method, +} from './method.js'; + +describe('isIdempotent', () => { + test('GET, HEAD, OPTIONS, PUT, DELETE are idempotent', () => { + const idempotent: Method[] = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']; + for (const method of idempotent) expect(isIdempotent(method)).toBe(true); + }); + + test('POST, PATCH, CONNECT, TRACE are not idempotent', () => { + const notIdempotent: Method[] = ['POST', 'PATCH', 'CONNECT', 'TRACE']; + for (const method of notIdempotent) + expect(isIdempotent(method)).toBe(false); + }); +}); + +describe('isBodyForbidden', () => { + test('GET, HEAD, TRACE, CONNECT forbid a body', () => { + const forbidden: Method[] = ['GET', 'HEAD', 'TRACE', 'CONNECT']; + for (const method of forbidden) expect(isBodyForbidden(method)).toBe(true); + }); + + test('POST, PUT, DELETE, PATCH, OPTIONS allow a body', () => { + const allowed: Method[] = ['POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']; + for (const method of allowed) expect(isBodyForbidden(method)).toBe(false); + }); +}); + +describe('methodWireToken', () => { + test('equals the uppercase method name for every method', () => { + const all: Method[] = [ + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'DELETE', + 'CONNECT', + 'OPTIONS', + 'TRACE', + 'PATCH', + ]; + for (const method of all) + expect(methodWireToken(method)).toBe(method.toUpperCase()); + }); +}); diff --git a/packages/core/src/http/method.ts b/packages/core/src/http/method.ts new file mode 100644 index 0000000..490c330 --- /dev/null +++ b/packages/core/src/http/method.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/method.ts +/** + * An HTTP request method. Each member's canonical wire token equals its uppercase name (HTTP-9). + * + * @public + */ +export type Method = + | 'GET' + | 'HEAD' + | 'POST' + | 'PUT' + | 'DELETE' + | 'CONNECT' + | 'OPTIONS' + | 'TRACE' + | 'PATCH'; + +const IDEMPOTENT_METHODS: ReadonlySet = new Set([ + 'GET', + 'HEAD', + 'OPTIONS', + 'PUT', + 'DELETE', +]); +const BODY_FORBIDDEN_METHODS: ReadonlySet = new Set([ + 'GET', + 'HEAD', + 'TRACE', + 'CONNECT', +]); + +/** + * Reports whether `method` is idempotent — the set `{GET, HEAD, OPTIONS, PUT, DELETE}`. + * + * This is the single source both the configurable retry allow-list and the inherent replay-safety + * gate derive from (HTTP-9); it is deliberately not re-exported from the package barrel. + * + * @param method - the method to classify. + * @returns `true` when the method is idempotent. + */ +export function isIdempotent(method: Method): boolean { + return IDEMPOTENT_METHODS.has(method); +} + +/** + * Reports whether `method`'s classification forbids a request body — GET, HEAD, TRACE, CONNECT. + * + * @param method - the method to classify. + * @returns `true` when a body must be rejected at construction (HTTP-7). + */ +export function isBodyForbidden(method: Method): boolean { + return BODY_FORBIDDEN_METHODS.has(method); +} + +/** + * Returns the canonical wire token for `method`, which equals its uppercase name (HTTP-9). + * + * @param method - the method to render. + * @returns the uppercase wire token. + */ +export function methodWireToken(method: Method): string { + return method; +} diff --git a/packages/core/src/http/protocol.test.ts b/packages/core/src/http/protocol.test.ts new file mode 100644 index 0000000..4613d5a --- /dev/null +++ b/packages/core/src/http/protocol.test.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/protocol.test.ts +// Exercises: HTTP-33 (canonical lowercase wire form, case-insensitive alias parsing) +import {describe, expect, test} from 'bun:test'; +import {Protocol} from './protocol.js'; +import {ProtocolParseError} from './errors.js'; + +describe('Protocol.parse', () => { + test('parses the canonical lowercase forms', () => { + expect(Protocol.parse('http/1.1').token).toBe('http/1.1'); + expect(Protocol.parse('http/2').token).toBe('http/2'); + }); + + test('accepts the HTTP/2 and HTTP/2.0 aliases case-insensitively', () => { + expect(Protocol.parse('HTTP/2').token).toBe('http/2'); + expect(Protocol.parse('HTTP/2.0').token).toBe('http/2'); + expect(Protocol.parse('Http/1.1').token).toBe('http/1.1'); + }); + + test('throws ProtocolParseError on an unrecognized identifier', () => { + expect(() => Protocol.parse('ftp/1.0')).toThrow(ProtocolParseError); + }); +}); + +describe('equals', () => { + test('two protocols with the same token are equal', () => { + expect(Protocol.parse('HTTP/2').equals(Protocol.HTTP_2)).toBe(true); + }); +}); diff --git a/packages/core/src/http/protocol.ts b/packages/core/src/http/protocol.ts new file mode 100644 index 0000000..7af0093 --- /dev/null +++ b/packages/core/src/http/protocol.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/protocol.ts +import {ProtocolParseError} from './errors.js'; + +/** + * A negotiated HTTP protocol version, held in its canonical lower-case wire form (HTTP-33). + * + * Frozen and value-comparable; obtain instances from the {@link Protocol.HTTP_1_1} / + * {@link Protocol.HTTP_2} constants or from {@link Protocol.parse}. + * + * @public + */ +export class Protocol { + readonly #token: string; + + private constructor(token: string) { + this.#token = token; + Object.freeze(this); + } + + /** HTTP/1.1, canonical token `http/1.1`. */ + static readonly HTTP_1_1 = new Protocol('http/1.1'); + + /** HTTP/2, canonical token `http/2`. */ + static readonly HTTP_2 = new Protocol('http/2'); + + /** + * Parses a protocol identifier case-insensitively and locale-invariantly, accepting the canonical + * forms plus the `HTTP/2` and `HTTP/2.0` aliases (HTTP-33). + * + * @param raw - the identifier to parse; surrounding whitespace is ignored. + * @returns the corresponding canonical constant. + * @throws {@link ProtocolParseError} when the identifier is not a recognized HTTP version. + */ + static parse(raw: string): Protocol { + const normalized = raw.trim().toLowerCase(); + if (normalized === 'http/1.1') return Protocol.HTTP_1_1; + if (normalized === 'http/2' || normalized === 'http/2.0') + return Protocol.HTTP_2; + throw new ProtocolParseError(`unrecognized protocol: ${raw}`); + } + + /** The canonical lower-case wire token, e.g. `http/1.1`. */ + get token(): string { + return this.#token; + } + + /** + * Compares by canonical token, so any accepted alias equals the constant it parses to. + * + * @param other - the protocol to compare against. + * @returns `true` when both canonical tokens are equal. + */ + equals(other: Protocol): boolean { + return this.#token === other.#token; + } +} diff --git a/packages/core/src/http/query-params.test.ts b/packages/core/src/http/query-params.test.ts new file mode 100644 index 0000000..7a71853 --- /dev/null +++ b/packages/core/src/http/query-params.test.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/query-params.test.ts +// Exercises: HTTP-28 (case-sensitive, multi-value, value-less param), HTTP-29/32 (RFC 3986 encoding), +// HTTP-30 (order-sensitive equality, empty-list dropped), HTTP-31 (lenient parse) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {QueryParams} from './query-params.js'; + +describe('case-sensitive names and multi-value (HTTP-28)', () => { + test('page and Page are distinct names', () => { + const params = QueryParams.newBuilder() + .add('page', '1') + .add('Page', '2') + .build(); + expect(params.get('page')).toBe('1'); + expect(params.get('Page')).toBe('2'); + }); + + test('a value-less parameter models as a single empty-string value', () => { + const params = QueryParams.newBuilder().add('flag', null).build(); + expect(params.get('flag')).toBe(''); + expect(params.has('flag')).toBe(true); + }); + + test('an absent name returns undefined from get, false from has', () => { + const params = QueryParams.newBuilder().build(); + expect(params.get('missing')).toBeUndefined(); + expect(params.has('missing')).toBe(false); + }); +}); + +describe('RFC 3986 encoding (HTTP-29/32)', () => { + test('space encodes as %20, never +; literal + encodes as %2B', () => { + const params = QueryParams.newBuilder() + .add('q', 'a b') + .add('plus', 'c+d') + .build(); + expect(params.encode()).toBe('q=a%20b&plus=c%2Bd'); + }); + + test('reserved characters / and * are percent-encoded', () => { + const params = QueryParams.newBuilder() + .add('path', 'a/b') + .add('star', 'a*b') + .build(); + expect(params.encode()).toBe('path=a%2Fb&star=a%2Ab'); + }); + + test('is empty when there are no params', () => { + expect(QueryParams.newBuilder().build().encode()).toBe(''); + }); +}); + +describe('order-sensitive equality (HTTP-30)', () => { + test('two instances are equal iff they encode identically', () => { + const a = QueryParams.newBuilder().add('x', '1').add('y', '2').build(); + const b = QueryParams.newBuilder().add('x', '1').add('y', '2').build(); + const reordered = QueryParams.newBuilder() + .add('y', '2') + .add('x', '1') + .build(); + expect(a.equals(b)).toBe(true); + expect(a.equals(reordered)).toBe(false); + }); +}); + +describe('lenient parsing (HTTP-31)', () => { + test('null/blank query parses to empty', () => { + expect(QueryParams.parse(null).encode()).toBe(''); + expect(QueryParams.parse('').encode()).toBe(''); + expect(QueryParams.parse(' ').encode()).toBe(''); + }); + + test('tolerates a leading ?', () => { + expect(QueryParams.parse('?a=1').get('a')).toBe('1'); + }); + + test('a segment with no = or a trailing = yields an empty-string value', () => { + expect(QueryParams.parse('flag').get('flag')).toBe(''); + expect(QueryParams.parse('flag=').get('flag')).toBe(''); + }); + + test('a stray & is skipped rather than producing a phantom entry', () => { + const params = QueryParams.parse('a=1&&b=2'); + expect(params.getAll('')).toEqual([]); + expect(params.get('a')).toBe('1'); + expect(params.get('b')).toBe('2'); + }); + + test('malformed percent-encoding falls back to raw text instead of throwing', () => { + expect(() => QueryParams.parse('a=%zz')).not.toThrow(); + expect(QueryParams.parse('a=%zz').get('a')).toBe('%zz'); + }); +}); + +describe('parse(x.encode()) round-trip (HTTP-29/31 as inverses)', () => { + test('holds for arbitrary generated name/value pairs', () => { + fc.assert( + fc.property( + fc.string({minLength: 1, maxLength: 15}), + fc.string({maxLength: 15}), + (name, value) => { + const original = QueryParams.newBuilder().add(name, value).build(); + const restored = QueryParams.parse(original.encode()); + expect(restored.equals(original)).toBe(true); + }, + ), + ); + }); +}); + +describe('newBuilder derivation (HTTP-3)', () => { + test('a derived builder is pre-filled and does not alias the original', () => { + const original = QueryParams.newBuilder() + .add('x', '1') + .add('x', '2') + .add('y', '3') + .build(); + + const derived = original.newBuilder().add('x', '4').build(); + + expect(derived.getAll('x')).toEqual(['1', '2', '4']); + expect(derived.get('y')).toBe('3'); + expect(original.getAll('x')).toEqual(['1', '2']); + }); +}); diff --git a/packages/core/src/http/query-params.ts b/packages/core/src/http/query-params.ts new file mode 100644 index 0000000..7c6873e --- /dev/null +++ b/packages/core/src/http/query-params.ts @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/query-params.ts +import type {Builder} from './builder.js'; + +function percentEncodeComponent(value: string): string { + return encodeURIComponent(value).replace( + /[!*'()]/g, + c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +function safeDecodeComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +let createQueryParams: ( + valuesByName: ReadonlyMap, + insertionOrder: readonly string[], +) => QueryParams; + +/** + * An immutable, order-preserving, multi-value collection of URL query parameters. + * + * Unlike {@link Headers}, names are case-**sensitive** — `page` and `Page` are distinct. A + * value-less parameter (`?flag`) is modelled as a single empty-string value, distinct from an + * absent name (HTTP-28). + * + * Encoding and parsing are deliberately asymmetric and kept as separate operations. + * {@link QueryParams.encode} is strict RFC 3986 percent-encoding — not + * `application/x-www-form-urlencoded`, so a space is `%20` and never `+` (HTTP-29/32). + * {@link QueryParams.parse} is lenient and never throws (HTTP-31). + * + * @example + * ```ts + * QueryParams.newBuilder().add('q', 'a b').add('plus', 'c+d').build().encode(); + * // 'q=a%20b&plus=c%2Bd' + * ``` + * + * @public + */ +export class QueryParams { + readonly #valuesByName: ReadonlyMap; + readonly #insertionOrder: readonly string[]; + + private constructor( + valuesByName: ReadonlyMap, + insertionOrder: readonly string[], + ) { + this.#valuesByName = valuesByName; + this.#insertionOrder = insertionOrder; + Object.freeze(this); + } + + static { + createQueryParams = (values, order) => new QueryParams(values, order); + } + + /** + * Starts an empty builder. + * + * @returns a fresh {@link QueryParamsBuilder} with no parameters set. + */ + static newBuilder(): QueryParamsBuilder { + return new QueryParamsBuilder(); + } + + /** + * Derives a builder pre-populated from this instance, copying every value list rather than + * aliasing it (HTTP-3). + * + * @returns a {@link QueryParamsBuilder} holding a copy of this instance's parameters. + */ + newBuilder(): QueryParamsBuilder { + const builder = new QueryParamsBuilder(); + for (const name of this.#insertionOrder) { + for (const value of this.#valuesByName.get(name) ?? []) + builder.add(name, value); + } + return builder; + } + + /** + * Parses a query string leniently, inverting {@link QueryParams.encode} and never throwing + * (HTTP-31). + * + * A `null`, `undefined`, or blank input yields empty parameters; a leading `?` is tolerated; a + * segment with no `=` or a trailing `=` yields an empty-string value; a stray `&` is skipped; and + * malformed percent-encoding falls back to the raw text rather than failing. + * + * @param raw - the query string, with or without its leading `?`. + * @returns the parsed, frozen parameters. + */ + static parse(raw: string | null | undefined): QueryParams { + const builder = new QueryParamsBuilder(); + if (raw === null || raw === undefined || raw.trim() === '') + return builder.build(); + + const withoutLeadingMark = raw.startsWith('?') ? raw.slice(1) : raw; + for (const segment of withoutLeadingMark.split('&')) { + if (segment === '') continue; + const eqIndex = segment.indexOf('='); + const rawName = eqIndex === -1 ? segment : segment.slice(0, eqIndex); + const rawValue = eqIndex === -1 ? '' : segment.slice(eqIndex + 1); + builder.add(safeDecodeComponent(rawName), safeDecodeComponent(rawValue)); + } + return builder.build(); + } + + /** + * Returns the first value stored under `name`, matched case-sensitively. + * + * @param name - the parameter name. + * @returns the first value — `''` for a value-less parameter — or `undefined` when absent. + */ + get(name: string): string | undefined { + return this.#valuesByName.get(name)?.[0]; + } + + /** + * Returns every value stored under `name`, in insertion order. + * + * @param name - the parameter name. + * @returns a read-only, frozen list of values — empty when the name is absent. + */ + getAll(name: string): readonly string[] { + return this.#valuesByName.get(name) ?? []; + } + + /** + * Reports whether `name` is present, matched case-sensitively. + * + * @param name - the parameter name. + * @returns `true` when at least one value is stored under the name. + */ + has(name: string): boolean { + return this.#valuesByName.has(name); + } + + /** + * Renders the query string with RFC 3986 percent-encoding (HTTP-29/32). + * + * Everything outside the unreserved set `A–Z a–z 0–9 - . _ ~` is encoded — space as `%20` never + * `+`, a literal `+` as `%2B`, `/` as `%2F`, `*` as `%2A`. Insertion order is preserved, a + * repeated name is emitted once per value, and the leading `?` is omitted. + * + * @returns the encoded query string, empty when there are no parameters. + */ + encode(): string { + const parts: string[] = []; + for (const name of this.#insertionOrder) { + const encodedName = percentEncodeComponent(name); + for (const value of this.#valuesByName.get(name) ?? []) { + parts.push(`${encodedName}=${percentEncodeComponent(value)}`); + } + } + return parts.join('&'); + } + + /** + * Compares order-sensitively: two instances are equal iff they encode identically (HTTP-30). + * + * @param other - the parameters to compare against. + * @returns `true` when both encode to the same string. + */ + equals(other: QueryParams): boolean { + return this.encode() === other.encode(); + } +} + +/** + * Accumulates query parameters and produces an immutable {@link QueryParams}. + * + * @public + */ +export class QueryParamsBuilder implements Builder { + readonly #valuesByName = new Map(); + readonly #insertionOrder: string[] = []; + + /** + * Appends a value under `name`, keeping any values already stored there. + * + * @param name - the parameter name, kept case-sensitively. + * @param value - the value; `null` records a value-less parameter as a single empty string + * (HTTP-28). + * @returns this builder, for chaining. + */ + add(name: string, value: string | null): this { + const actualValue = value ?? ''; + if (!this.#valuesByName.has(name)) { + this.#insertionOrder.push(name); + this.#valuesByName.set(name, []); + } + this.#valuesByName.get(name)?.push(actualValue); + return this; + } + + /** + * Deep-copies and freezes the accumulated state into an immutable {@link QueryParams}. + * + * A name whose value list ended up empty is dropped here, so it cannot leave a phantom + * containment entry that {@link QueryParams.encode} would never emit (HTTP-30). + * + * @returns the frozen parameters. + */ + build(): QueryParams { + const valuesByName = new Map(); + const insertionOrder: string[] = []; + for (const name of this.#insertionOrder) { + const values = this.#valuesByName.get(name) ?? []; + if (values.length === 0) continue; // HTTP-30: an empty value list is dropped at build time + valuesByName.set(name, Object.freeze([...values])); + insertionOrder.push(name); + } + return createQueryParams( + Object.freeze(valuesByName), + Object.freeze(insertionOrder), + ); + } +} diff --git a/packages/core/src/http/request-conditions.test.ts b/packages/core/src/http/request-conditions.test.ts new file mode 100644 index 0000000..4382f64 --- /dev/null +++ b/packages/core/src/http/request-conditions.test.ts @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/request-conditions.test.ts +// Exercises: HTTP-50 (comma-joined If-Match/If-None-Match, RFC 1123 dates, idempotent apply, any-tag exclusivity) +import {describe, expect, test} from 'bun:test'; +import { + RequestConditions, + RequestConditionsBuilder, +} from './request-conditions.js'; +import {ETag} from './etag.js'; +import {Headers} from './headers.js'; +import {RequestConditionsValidationError} from './errors.js'; + +function etag(raw: string): ETag { + const parsed = ETag.parse(raw); + if (parsed === undefined) + throw new Error(`test fixture is not a valid ETag: ${raw}`); + return parsed; +} + +describe('If-Match / If-None-Match emission', () => { + test('emits multiple ETags as one comma-separated header', () => { + const conditions = RequestConditions.newBuilder() + .ifMatch(etag('"a"')) + .ifMatch(etag('"b"')) + .build(); + const headers = conditions.applyTo(Headers.newBuilder().build()); + expect(headers.get('If-Match')).toBe('"a", "b"'); + }); +}); + +describe('date emission', () => { + test('emits If-Modified-Since as an RFC 1123 date', () => { + const conditions = RequestConditions.newBuilder() + .ifModifiedSince(new Date('2015-10-21T07:28:00Z')) + .build(); + const headers = conditions.applyTo(Headers.newBuilder().build()); + expect(headers.get('If-Modified-Since')).toBe( + 'Wed, 21 Oct 2015 07:28:00 GMT', + ); + }); +}); + +describe('idempotent apply', () => { + test('applying the same conditions twice does not duplicate the header', () => { + const conditions = RequestConditions.newBuilder() + .ifMatch(etag('"a"')) + .build(); + const once = conditions.applyTo(Headers.newBuilder().build()); + const twice = conditions.applyTo(once); + expect(twice.getAll('If-Match')).toEqual(['"a"']); + }); +}); + +describe('any-tag mutual exclusivity', () => { + test('collapses repeated * to one', () => { + const conditions = RequestConditions.newBuilder() + .ifMatch(ETag.ANY) + .ifMatch(ETag.ANY) + .build(); + const headers = conditions.applyTo(Headers.newBuilder().build()); + expect(headers.get('If-Match')).toBe('*'); + }); + + test('rejects mixing * with a concrete ETag', () => { + const builder = new RequestConditionsBuilder().ifMatch(ETag.ANY); + expect(() => builder.ifMatch(etag('"a"'))).toThrow( + RequestConditionsValidationError, + ); + }); + + test('rejects adding * after a concrete ETag', () => { + const builder = new RequestConditionsBuilder().ifMatch(etag('"a"')); + expect(() => builder.ifMatch(ETag.ANY)).toThrow( + RequestConditionsValidationError, + ); + }); +}); + +describe('newBuilder derivation (HTTP-3) and Date isolation (HTTP-1)', () => { + test('deriving and rebuilding preserves conditions without affecting the original', () => { + const original = RequestConditions.newBuilder() + .ifMatch(etag('"a"')) + .build(); + const derived = original.newBuilder().ifNoneMatch(etag('"b"')).build(); + + const originalHeaders = original.applyTo(Headers.newBuilder().build()); + const derivedHeaders = derived.applyTo(Headers.newBuilder().build()); + + expect(originalHeaders.get('If-None-Match')).toBeUndefined(); + expect(derivedHeaders.get('If-Match')).toBe('"a"'); + expect(derivedHeaders.get('If-None-Match')).toBe('"b"'); + }); + + test('mutating the caller-supplied Date after build does not change what applyTo emits', () => { + const date = new Date('2015-10-21T07:28:00Z'); + const conditions = RequestConditions.newBuilder() + .ifModifiedSince(date) + .build(); + + date.setFullYear(1999); + + const headers = conditions.applyTo(Headers.newBuilder().build()); + expect(headers.get('If-Modified-Since')).toBe( + 'Wed, 21 Oct 2015 07:28:00 GMT', + ); + }); +}); + +describe('ifUnmodifiedSince (HTTP-50)', () => { + test('emits an RFC 1123 date and survives mutation of the caller-supplied Date', () => { + const date = new Date('2015-10-21T07:28:00Z'); + const conditions = RequestConditions.newBuilder() + .ifUnmodifiedSince(date) + .build(); + + date.setFullYear(1999); + + const headers = conditions.applyTo(Headers.newBuilder().build()); + expect(headers.get('If-Unmodified-Since')).toBe( + 'Wed, 21 Oct 2015 07:28:00 GMT', + ); + }); + + test('is carried through newBuilder derivation and applies idempotently', () => { + const original = RequestConditions.newBuilder() + .ifUnmodifiedSince(new Date('2015-10-21T07:28:00Z')) + .build(); + const derived = original.newBuilder().build(); + + const once = derived.applyTo(Headers.newBuilder().build()); + const twice = derived.applyTo(once); + expect(twice.getAll('If-Unmodified-Since')).toEqual([ + 'Wed, 21 Oct 2015 07:28:00 GMT', + ]); + }); +}); diff --git a/packages/core/src/http/request-conditions.ts b/packages/core/src/http/request-conditions.ts new file mode 100644 index 0000000..2af7ec9 --- /dev/null +++ b/packages/core/src/http/request-conditions.ts @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/request-conditions.ts +import type {Builder} from './builder.js'; +import {RequestConditionsValidationError} from './errors.js'; +import {ETag} from './etag.js'; +import {Headers} from './headers.js'; + +function addEtag( + list: readonly ETag[], + etag: ETag, + headerName: string, +): readonly ETag[] { + if (etag.isAny) { + if (list.some(e => !e.isAny)) { + throw new RequestConditionsValidationError( + `${headerName}: '*' cannot combine with a concrete ETag`, + ); + } + return [ETag.ANY]; + } + if (list.some(e => e.isAny)) { + throw new RequestConditionsValidationError( + `${headerName}: cannot add a concrete ETag alongside '*'`, + ); + } + return [...list, etag]; +} + +function toRfc1123(date: Date): string { + return date.toUTCString(); +} + +// eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-50 +let createRequestConditions: ( + ifMatch: readonly ETag[], + ifNoneMatch: readonly ETag[], + ifModifiedSince: Date | undefined, + ifUnmodifiedSince: Date | undefined, +) => RequestConditions; + +/** + * An immutable set of conditional-request preconditions, applied to headers as `If-Match`, + * `If-None-Match`, `If-Modified-Since`, and `If-Unmodified-Since` (HTTP-50). + * + * Multiple entity-tags emit as one comma-separated header; dates emit in RFC 1123 form. + * {@link RequestConditions.applyTo} uses `set`, never `add`, so applying the same conditions twice + * cannot duplicate a header. The any-tag (`*`) is mutually exclusive with concrete entity-tags, and + * repeated `*` collapses to one — enforced when the tag is added, not at emission. + * + * @example + * ```ts + * const conditions = RequestConditions.newBuilder().ifMatch(etag).build(); + * const headers = conditions.applyTo(existingHeaders); + * ``` + * + * @public + */ +export class RequestConditions { + readonly #ifMatch: readonly ETag[]; + readonly #ifNoneMatch: readonly ETag[]; + readonly #ifModifiedSince: Date | undefined; + readonly #ifUnmodifiedSince: Date | undefined; + + // eslint-disable-next-line max-params -- private, builder-internal; the four conditional facets are fixed (HTTP-50) + private constructor( + ifMatch: readonly ETag[], + ifNoneMatch: readonly ETag[], + ifModifiedSince: Date | undefined, + ifUnmodifiedSince: Date | undefined, + ) { + this.#ifMatch = ifMatch; + this.#ifNoneMatch = ifNoneMatch; + this.#ifModifiedSince = ifModifiedSince; + this.#ifUnmodifiedSince = ifUnmodifiedSince; + Object.freeze(this); + } + + static { + // eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-50 + createRequestConditions = (ifMatch, ifNoneMatch, modified, unmodified) => + new RequestConditions(ifMatch, ifNoneMatch, modified, unmodified); + } + + /** + * Starts an empty builder. + * + * @returns a fresh {@link RequestConditionsBuilder} with no preconditions set. + */ + static newBuilder(): RequestConditionsBuilder { + return new RequestConditionsBuilder(); + } + + /** + * Derives a builder pre-populated from this instance (HTTP-3). + * + * `ETag` instances are frozen values safe to share; the builder's own setters re-copy the dates, + * so neither instance aliases the other. + * + * @returns a {@link RequestConditionsBuilder} holding this instance's preconditions. + */ + newBuilder(): RequestConditionsBuilder { + const builder = new RequestConditionsBuilder(); + for (const matchTag of this.#ifMatch) builder.ifMatch(matchTag); + for (const noneMatchTag of this.#ifNoneMatch) + builder.ifNoneMatch(noneMatchTag); + if (this.#ifModifiedSince !== undefined) + builder.ifModifiedSince(this.#ifModifiedSince); + if (this.#ifUnmodifiedSince !== undefined) + builder.ifUnmodifiedSince(this.#ifUnmodifiedSince); + return builder; + } + + /** + * Returns a copy of `headers` with these preconditions written onto it. + * + * Idempotent: each header is `set`, never appended, so applying the same conditions repeatedly + * yields the same result (HTTP-50). A precondition that was never set leaves its header + * untouched. + * + * Emission goes through the strict outbound header path, which rejects obs-text. An ETag whose + * opaque tag carries obs-text is legal per HTTP-48 but cannot be emitted here; reconciling that + * against HTTP-18 is left to a later phase rather than guessed at now. + * + * @param headers - the headers to derive from; not modified. + * @returns a new {@link Headers} carrying the preconditions. + * @throws {@link HeaderValidationError} when an entity-tag contains a byte the outbound header + * value grammar forbids. + */ + applyTo(headers: Headers): Headers { + let builder = headers.newBuilder(); + if (this.#ifMatch.length > 0) { + builder = builder.set( + 'If-Match', + this.#ifMatch.map(e => e.raw).join(', '), + ); + } + if (this.#ifNoneMatch.length > 0) { + builder = builder.set( + 'If-None-Match', + this.#ifNoneMatch.map(e => e.raw).join(', '), + ); + } + if (this.#ifModifiedSince !== undefined) { + builder = builder.set( + 'If-Modified-Since', + toRfc1123(this.#ifModifiedSince), + ); + } + if (this.#ifUnmodifiedSince !== undefined) { + builder = builder.set( + 'If-Unmodified-Since', + toRfc1123(this.#ifUnmodifiedSince), + ); + } + return builder.build(); + } +} + +/** + * Accumulates conditional-request preconditions and produces an immutable + * {@link RequestConditions}. + * + * @public + */ +export class RequestConditionsBuilder implements Builder { + #ifMatch: readonly ETag[] = []; + #ifNoneMatch: readonly ETag[] = []; + #ifModifiedSince: Date | undefined; + #ifUnmodifiedSince: Date | undefined; + + /** + * Adds an entity-tag to `If-Match`. + * + * @param etag - the tag; {@link ETag.ANY} collapses any repeat to a single `*`. + * @returns this builder, for chaining. + * @throws {@link RequestConditionsValidationError} when this would mix `*` with a concrete + * entity-tag in either direction (HTTP-50). + */ + ifMatch(etag: ETag): this { + this.#ifMatch = addEtag(this.#ifMatch, etag, 'If-Match'); + return this; + } + + /** + * Adds an entity-tag to `If-None-Match`. + * + * @param etag - the tag; {@link ETag.ANY} collapses any repeat to a single `*`. + * @returns this builder, for chaining. + * @throws {@link RequestConditionsValidationError} when this would mix `*` with a concrete + * entity-tag in either direction (HTTP-50). + */ + ifNoneMatch(etag: ETag): this { + this.#ifNoneMatch = addEtag(this.#ifNoneMatch, etag, 'If-None-Match'); + return this; + } + + /** + * Sets `If-Modified-Since`. + * + * @param date - the instant; copied, not aliased, so a caller mutating its own `Date` after + * `build()` cannot change what {@link RequestConditions.applyTo} emits. + * @returns this builder, for chaining. + */ + ifModifiedSince(date: Date): this { + this.#ifModifiedSince = new Date(date.getTime()); + return this; + } + + /** + * Sets `If-Unmodified-Since`. + * + * @param date - the instant; copied, not aliased, exactly as in + * {@link RequestConditionsBuilder.ifModifiedSince}. + * @returns this builder, for chaining. + */ + ifUnmodifiedSince(date: Date): this { + this.#ifUnmodifiedSince = new Date(date.getTime()); + return this; + } + + /** + * Freezes the accumulated preconditions into an immutable {@link RequestConditions}. + * + * All exclusivity validation already happened at the setters, so this cannot fail. + * + * @returns the frozen conditions. + */ + build(): RequestConditions { + return createRequestConditions( + this.#ifMatch, + this.#ifNoneMatch, + this.#ifModifiedSince, + this.#ifUnmodifiedSince, + ); + } +} diff --git a/packages/core/src/http/request-options.test.ts b/packages/core/src/http/request-options.test.ts new file mode 100644 index 0000000..aef49e0 --- /dev/null +++ b/packages/core/src/http/request-options.test.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/request-options.test.ts +// Exercises: HTTP-34 (EMPTY sentinel, defensive tag copy), HTTP-35 (timeout/maxRetries validation) +import {describe, expect, test} from 'bun:test'; +import {RequestOptions} from './request-options.js'; +import {RequestOptionsValidationError} from './errors.js'; + +describe('RequestOptions.EMPTY', () => { + test('has null timeout, null max-retries, empty tags', () => { + expect(RequestOptions.EMPTY.timeoutMs).toBeUndefined(); + expect(RequestOptions.EMPTY.maxRetries).toBeUndefined(); + expect(RequestOptions.EMPTY.tag('anything')).toBeUndefined(); + }); +}); + +describe('timeout validation (HTTP-35)', () => { + test('rejects zero or negative timeout', () => { + expect(() => RequestOptions.newBuilder().timeoutMs(0)).toThrow( + RequestOptionsValidationError, + ); + expect(() => RequestOptions.newBuilder().timeoutMs(-1)).toThrow( + RequestOptionsValidationError, + ); + }); + + test('accepts a null (undefined) timeout — no override', () => { + expect(() => + RequestOptions.newBuilder().timeoutMs(undefined).build(), + ).not.toThrow(); + }); + + test('accepts a positive timeout', () => { + expect(RequestOptions.newBuilder().timeoutMs(5000).build().timeoutMs).toBe( + 5000, + ); + }); +}); + +describe('maxRetries validation (HTTP-35)', () => { + test('rejects a negative maxRetries', () => { + expect(() => RequestOptions.newBuilder().maxRetries(-1)).toThrow( + RequestOptionsValidationError, + ); + }); + + test('accepts 0, meaning "disable retries for this call"', () => { + expect(RequestOptions.newBuilder().maxRetries(0).build().maxRetries).toBe( + 0, + ); + }); +}); + +describe('tags are defensively copied at build (HTTP-34)', () => { + test('a built options is unaffected by later mutation of the source map', () => { + const source = new Map([['env', 'prod']]); + const options = RequestOptions.newBuilder().tags(source).build(); + source.set('env', 'mutated'); + expect(options.tag('env')).toBe('prod'); + }); +}); + +describe('newBuilder derivation (HTTP-3)', () => { + test('a derived builder is pre-filled with timeout, retries, and tags', () => { + const original = RequestOptions.newBuilder() + .timeoutMs(5000) + .maxRetries(0) + .tags(new Map([['env', 'prod']])) + .build(); + + const derived = original.newBuilder().maxRetries(3).build(); + + expect(derived.timeoutMs).toBe(5000); + expect(derived.maxRetries).toBe(3); + expect(derived.tag('env')).toBe('prod'); + expect(original.maxRetries).toBe(0); + }); +}); diff --git a/packages/core/src/http/request-options.ts b/packages/core/src/http/request-options.ts new file mode 100644 index 0000000..a961ede --- /dev/null +++ b/packages/core/src/http/request-options.ts @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/request-options.ts +import type {Builder} from './builder.js'; +import {RequestOptionsValidationError} from './errors.js'; + +let createRequestOptions: ( + timeoutMs: number | undefined, + maxRetries: number | undefined, + tags: ReadonlyMap, +) => RequestOptions; + +/** + * Immutable per-call operational overrides that are deliberately *not* part of the wire form: a + * timeout, a max-retries count, and opaque string-keyed tags (HTTP-34). + * + * Every field defaults to a "use the configured default" sentinel of `undefined`, and + * {@link RequestOptions.EMPTY} is the canonical override-nothing instance. + * + * `undefined` and `0` are different states for max-retries: `undefined` means "use the default", + * while `0` means "disable retries for this call" (HTTP-35). + * + * @public + */ +export class RequestOptions { + readonly #timeoutMs: number | undefined; + readonly #maxRetries: number | undefined; + readonly #tags: ReadonlyMap; + + private constructor( + timeoutMs: number | undefined, + maxRetries: number | undefined, + tags: ReadonlyMap, + ) { + this.#timeoutMs = timeoutMs; + this.#maxRetries = maxRetries; + this.#tags = tags; + Object.freeze(this); + } + + static { + createRequestOptions = (timeoutMs, maxRetries, tags) => + new RequestOptions(timeoutMs, maxRetries, tags); + } + + /** The canonical "override nothing" instance: no timeout, no retry override, no tags. */ + static readonly EMPTY = new RequestOptions( + undefined, + undefined, + Object.freeze(new Map()), + ); + + /** + * Starts an empty builder. + * + * @returns a fresh {@link RequestOptionsBuilder} overriding nothing. + */ + static newBuilder(): RequestOptionsBuilder { + return new RequestOptionsBuilder(); + } + + /** + * Derives a builder pre-populated from this instance, copying the tag map rather than aliasing it + * (HTTP-3). + * + * @returns a {@link RequestOptionsBuilder} holding a copy of these options. + */ + newBuilder(): RequestOptionsBuilder { + return new RequestOptionsBuilder() + .timeoutMs(this.#timeoutMs) + .maxRetries(this.#maxRetries) + .tags(this.#tags); + } + + /** The per-call timeout in milliseconds, or `undefined` to use the configured default. */ + get timeoutMs(): number | undefined { + return this.#timeoutMs; + } + + /** + * The per-call retry ceiling, or `undefined` to use the configured default. A value of `0` means + * retries are disabled for this call — distinct from `undefined`. + */ + get maxRetries(): number | undefined { + return this.#maxRetries; + } + + /** + * Looks up an opaque tag by key. + * + * @param key - the tag key, matched case-sensitively. + * @returns the tag value, or `undefined` when unset. + */ + tag(key: string): string | undefined { + return this.#tags.get(key); + } +} + +/** + * Accumulates per-call overrides and produces an immutable {@link RequestOptions}. + * + * Range validation happens at each setter, not at `build()`, so a bad value fails at the call site + * that supplied it. + * + * @public + */ +export class RequestOptionsBuilder implements Builder { + #timeoutMs: number | undefined; + #maxRetries: number | undefined; + readonly #tags = new Map(); + + /** + * Sets the per-call timeout. + * + * @param value - the timeout in milliseconds, or `undefined` for no override. Zero is rejected + * rather than reinterpreted: it means "no timeout" in one transport and is an error in another + * (HTTP-35). + * @returns this builder, for chaining. + * @throws {@link RequestOptionsValidationError} when a defined value is zero or negative. + */ + timeoutMs(value: number | undefined): this { + if (value !== undefined && value <= 0) { + throw new RequestOptionsValidationError( + `timeout must be positive, got ${String(value)}`, + ); + } + this.#timeoutMs = value; + return this; + } + + /** + * Sets the per-call retry ceiling. + * + * @param value - the maximum retries, or `undefined` for no override. `0` is accepted and means + * "disable retries for this call"; a negative count is rejected rather than silently + * reinterpreted (HTTP-35). + * @returns this builder, for chaining. + * @throws {@link RequestOptionsValidationError} when a defined value is negative. + */ + maxRetries(value: number | undefined): this { + if (value !== undefined && value < 0) { + throw new RequestOptionsValidationError( + `maxRetries must not be negative, got ${String(value)}`, + ); + } + this.#maxRetries = value; + return this; + } + + /** + * Merges opaque tags into the builder's own map, overwriting on key collision. + * + * @param entries - the tags to merge; read, never retained. + * @returns this builder, for chaining. + */ + tags(entries: ReadonlyMap): this { + for (const [key, value] of entries) this.#tags.set(key, value); + return this; + } + + /** + * Copies and freezes the accumulated state into an immutable {@link RequestOptions}. + * + * Tags are defensively copied here, so later mutation of any source map a caller passed to + * {@link RequestOptionsBuilder.tags} cannot change the built instance (HTTP-34). + * + * @returns the frozen options. + */ + build(): RequestOptions { + return createRequestOptions( + this.#timeoutMs, + this.#maxRetries, + Object.freeze(new Map(this.#tags)), + ); + } +} diff --git a/packages/core/src/http/request.test.ts b/packages/core/src/http/request.test.ts new file mode 100644 index 0000000..568ff7c --- /dev/null +++ b/packages/core/src/http/request.test.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/request.test.ts +// Exercises: HTTP-6 (required fields), HTTP-7 (body/method legality), HTTP-8 (GET default / missing method), +// HTTP-9 (method), HTTP-46 (textual URL equality, no DNS), HTTP-47 (malformed URL), HTTP-3/5 (derivation, +// immutability) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {Request} from './request.js'; +import {Headers} from './headers.js'; +import { + RequiredFieldError, + UrlConstructionError, + RequestBodyNotAllowedError, +} from './errors.js'; + +describe('required fields (HTTP-6, HTTP-4)', () => { + test('build() throws naming url when no URL is set', () => { + expect(() => Request.newBuilder().method('GET').build()).toThrow( + RequiredFieldError, + ); + expect(() => Request.newBuilder().method('GET').build()).toThrow( + 'url is required', + ); + }); +}); + +describe('method/body legality (HTTP-7)', () => { + test('rejects a body on GET, HEAD, TRACE, CONNECT', () => { + for (const method of ['GET', 'HEAD', 'TRACE', 'CONNECT'] as const) { + expect(() => + Request.newBuilder() + .method(method) + .url('https://example.com') + .body('x') + .build(), + ).toThrow(RequestBodyNotAllowedError); + } + }); + + test('accepts a body on POST/PUT/DELETE/PATCH/OPTIONS', () => { + expect(() => + Request.newBuilder() + .method('POST') + .url('https://example.com') + .body('x') + .build(), + ).not.toThrow(); + }); + + test('clearing the body succeeds even on a body-forbidden method', () => { + const request = Request.newBuilder() + .method('GET') + .url('https://example.com') + .body('x') + .body(undefined) + .build(); + expect(request.body).toBeUndefined(); + }); + + test('a null body clears like undefined — HTTP-7 rejects only a non-null body', () => { + const request = Request.newBuilder() + .method('GET') + .url('https://example.com') + .body('x') + .body(null) + .build(); + expect(request.body).toBeUndefined(); + }); +}); + +describe('method defaulting (HTTP-8)', () => { + test('defaults to GET when neither method nor body is set', () => { + const request = Request.newBuilder().url('https://example.com').build(); + expect(request.method).toBe('GET'); + }); + + test('fails naming the missing method when a body is set with no method', () => { + expect(() => + Request.newBuilder().url('https://example.com').body('x').build(), + ).toThrow('method is required'); + }); +}); + +describe('URL equality (HTTP-46)', () => { + test('two requests to the same textual URL are equal', () => { + const a = Request.newBuilder().url('https://example.com/a').build(); + const b = Request.newBuilder().url('https://example.com/a').build(); + expect(a.equals(b)).toBe(true); + }); + + test('textually different URLs are not equal, with no network access', () => { + const a = Request.newBuilder().url('https://example.com/a').build(); + const b = Request.newBuilder().url('https://example.com/b').build(); + expect(a.equals(b)).toBe(false); + }); + + test('equality tracks textual href equality for generated URLs', () => { + const pathArb = fc.stringMatching(/^[a-z0-9]{0,10}$/); + fc.assert( + fc.property(pathArb, pathArb, (left, right) => { + const a = Request.newBuilder() + .url(`https://example.com/${left}`) + .build(); + const b = Request.newBuilder() + .url(`https://example.com/${right}`) + .build(); + expect(a.equals(b)).toBe(left === right); + }), + ); + }); +}); + +describe('malformed URL (HTTP-47)', () => { + test('throws UrlConstructionError naming the offending input', () => { + expect(() => Request.newBuilder().url('::bad').build()).toThrow( + UrlConstructionError, + ); + expect(() => Request.newBuilder().url('relative/path').build()).toThrow( + UrlConstructionError, + ); + }); +}); + +describe('newBuilder derivation and immutability (HTTP-3/5)', () => { + test('the returned URL cannot be used to mutate the request', () => { + const request = Request.newBuilder().url('https://example.com/a').build(); + request.url.pathname = '/hacked'; + expect(request.url.pathname).toBe('/a'); + }); + + test('deriving a builder and rebuilding does not affect the original', () => { + const original = Request.newBuilder().url('https://example.com/a').build(); + original.newBuilder().url('https://example.com/b').build(); + expect(original.url.href).toBe('https://example.com/a'); + }); +}); + +describe('headers (HTTP-6)', () => { + test('defaults to empty headers and carries what the builder was given', () => { + const empty = Request.newBuilder().url('https://example.com').build(); + expect(empty.headers.names()).toEqual([]); + + const headers = Headers.newBuilder().add('X-Trace', 'v').build(); + const request = Request.newBuilder() + .url('https://example.com') + .headers(headers) + .build(); + expect(request.headers.get('x-trace')).toBe('v'); + }); + + test('headers participate in equality (HTTP-46)', () => { + const base = Request.newBuilder().url('https://example.com'); + const withHeader = base + .headers(Headers.newBuilder().add('X-Trace', 'v').build()) + .build(); + const withoutHeader = Request.newBuilder() + .url('https://example.com') + .build(); + expect(withHeader.equals(withoutHeader)).toBe(false); + }); +}); diff --git a/packages/core/src/http/request.ts b/packages/core/src/http/request.ts new file mode 100644 index 0000000..2610f29 --- /dev/null +++ b/packages/core/src/http/request.ts @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/request.ts +import type {Builder} from './builder.js'; +import {requireField} from './builder.js'; +import {UrlConstructionError, RequestBodyNotAllowedError} from './errors.js'; +import {type Method, isBodyForbidden} from './method.js'; +import {Headers} from './headers.js'; + +function parseUrl(raw: string): URL { + try { + return new URL(raw); + } catch (e: unknown) { + throw new UrlConstructionError(`malformed or non-absolute URL: ${raw}`, { + cause: e, + }); + } +} + +// eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-6 +let createRequest: ( + method: Method, + url: URL, + headers: Headers, + body: unknown, +) => Request; + +/** + * An immutable HTTP request: method, target URL, headers, and an optional body (HTTP-6). + * + * Operational knobs such as timeout and retries are deliberately *not* here — they are per-call + * overrides that never reach the wire, and live in {@link RequestOptions} instead. + * + * Method/body legality is enforced at construction rather than deferred to a transport: a body on + * GET, HEAD, TRACE, or CONNECT fails in `build()` (HTTP-7). Equality compares the URL by textual + * external form only, so it never resolves DNS or performs any other blocking work (HTTP-46). + * + * @example + * ```ts + * const request = Request.newBuilder() + * .method('POST') + * .url('https://example.com/items') + * .body('payload') + * .build(); + * ``` + * + * @public + */ +export class Request { + readonly #method: Method; + readonly #url: URL; + readonly #headers: Headers; + readonly #body: unknown; + + // eslint-disable-next-line max-params -- private, builder-internal; field count fixed by the wire model (HTTP-6) + private constructor( + method: Method, + url: URL, + headers: Headers, + body: unknown, + ) { + this.#method = method; + this.#url = url; + this.#headers = headers; + this.#body = body; + Object.freeze(this); + } + + static { + // eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-6 + createRequest = (method, url, headers, body) => + new Request(method, url, headers, body); + } + + /** + * Starts an empty builder. + * + * @returns a fresh {@link RequestBuilder}. + */ + static newBuilder(): RequestBuilder { + return new RequestBuilder(); + } + + /** + * Derives a builder pre-populated from this instance; the URL is copied, not aliased (HTTP-3). + * + * @returns a {@link RequestBuilder} holding a copy of this request's state. + */ + newBuilder(): RequestBuilder { + return new RequestBuilder() + .method(this.#method) + .url(this.#url) + .headers(this.#headers) + .body(this.#body); + } + + /** The request method. */ + get method(): Method { + return this.#method; + } + + /** + * The target URL. + * + * Returns a fresh `URL` on every access: the native `URL` is mutable, so handing out the stored + * instance would let a caller mutate this otherwise-immutable request through it (HTTP-5). + */ + get url(): URL { + return new URL(this.#url.href); + } + + /** The request headers — never null, possibly empty. Already frozen, so returned by reference. */ + get headers(): Headers { + return this.#headers; + } + + /** + * The request body, or `undefined` when absent. + * + * Typed `unknown` on purpose: this phase only needs presence or absence to enforce HTTP-7/8. The + * body lifecycle — streaming, replayability, charset — is owned by a later phase. + */ + get body(): unknown { + return this.#body; + } + + /** + * Compares method, URL, headers, and body. + * + * The URL is compared by textual external form only, never by resolving the host — native URL + * equality on some platforms resolves DNS, which blocks and is wrong for virtual hosts sharing an + * IP (HTTP-46). The body is compared by reference for now; value equality arrives with the real + * body model in a later phase. + * + * @param other - the request to compare against. + * @returns `true` when every compared facet is equal. + */ + equals(other: Request): boolean { + return ( + this.#method === other.#method && + this.#url.href === other.#url.href && + this.#headers.equals(other.#headers) && + this.#body === other.#body + ); + } +} + +/** + * Accumulates request state and produces an immutable {@link Request}. + * + * @public + */ +export class RequestBuilder implements Builder { + #method: Method | undefined; + #url: URL | undefined; + #headers: Headers = Headers.newBuilder().build(); + #body: unknown; + + /** + * Sets the request method. + * + * @param method - the method to use. + * @returns this builder, for chaining. + */ + method(method: Method): this { + this.#method = method; + return this; + } + + /** + * Sets the target URL, parsing and copying it immediately so later mutation of a caller's `URL` + * cannot reach the built request. + * + * @param url - an absolute URL, as a string or a `URL`. + * @returns this builder, for chaining. + * @throws {@link UrlConstructionError} when a string input is malformed or not absolute; the + * message carries the offending input and chains the underlying failure as `cause` (HTTP-47). + */ + url(url: string | URL): this { + this.#url = url instanceof URL ? new URL(url.href) : parseUrl(url); + return this; + } + + /** + * Sets the request headers, replacing whatever was set before. + * + * @param headers - the headers to send; already immutable, so held by reference. + * @returns this builder, for chaining. + */ + headers(headers: Headers): this { + this.#headers = headers; + return this; + } + + /** + * Sets or clears the request body. + * + * @param body - the body, or `null`/`undefined` to clear it. `null` normalizes to `undefined`: + * HTTP-7 rejects only a *non-null* body, so passing `null` clears exactly like `undefined`. + * @returns this builder, for chaining. + */ + body(body: unknown): this { + this.#body = body ?? undefined; + return this; + } + + /** + * Validates required fields and method/body legality, then constructs the request. + * + * With no method set, defaults to GET only when no body is present; a body with no method fails + * naming the missing method rather than defaulting to GET and then tripping the no-body rule + * (HTTP-8). + * + * @returns the frozen request. + * @throws {@link RequiredFieldError} when no URL was set, or when a body was set with no method. + * @throws {@link RequestBodyNotAllowedError} when a body is present on GET, HEAD, TRACE, or + * CONNECT (HTTP-7). + */ + build(): Request { + const url = requireField(this.#url, 'url'); + + if (this.#method === undefined) { + if (this.#body !== undefined) requireField(undefined, 'method'); + return createRequest('GET', url, this.#headers, this.#body); + } + + if (this.#body !== undefined && isBodyForbidden(this.#method)) { + throw new RequestBodyNotAllowedError(this.#method); + } + + return createRequest(this.#method, url, this.#headers, this.#body); + } +} diff --git a/packages/core/src/http/response.test.ts b/packages/core/src/http/response.test.ts new file mode 100644 index 0000000..d3d8b46 --- /dev/null +++ b/packages/core/src/http/response.test.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/response.test.ts +// Exercises: HTTP-6 (response's required fields: request, protocol, status) +import {describe, expect, test} from 'bun:test'; +import {Response} from './response.js'; +import {Request} from './request.js'; +import {Protocol} from './protocol.js'; +import {Status} from './status.js'; +import {Headers} from './headers.js'; + +function baseRequest(): Request { + return Request.newBuilder().url('https://example.com').build(); +} + +describe('required fields', () => { + test('throws naming request when missing', () => { + expect(() => + Response.newBuilder() + .protocol(Protocol.HTTP_1_1) + .status(Status.of(200)) + .build(), + ).toThrow('request is required'); + }); + + test('throws naming protocol when missing', () => { + expect(() => + Response.newBuilder() + .request(baseRequest()) + .status(Status.of(200)) + .build(), + ).toThrow('protocol is required'); + }); + + test('throws naming status when missing', () => { + expect(() => + Response.newBuilder() + .request(baseRequest()) + .protocol(Protocol.HTTP_1_1) + .build(), + ).toThrow('status is required'); + }); +}); + +describe('construction', () => { + test('carries the originating request, protocol, status, headers, and an optional reason phrase/body', () => { + const request = baseRequest(); + const response = Response.newBuilder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(200)) + .reasonPhrase('OK') + .body('payload') + .build(); + + expect(response.request.equals(request)).toBe(true); + expect(response.protocol.equals(Protocol.HTTP_1_1)).toBe(true); + expect(response.status.equals(Status.of(200))).toBe(true); + expect(response.reasonPhrase).toBe('OK'); + expect(response.body).toBe('payload'); + }); + + test('reason phrase and body are optional', () => { + const response = Response.newBuilder() + .request(baseRequest()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(204)) + .build(); + expect(response.reasonPhrase).toBeUndefined(); + expect(response.body).toBeUndefined(); + }); +}); + +describe('newBuilder derivation', () => { + test('deriving a builder and rebuilding does not affect the original', () => { + const original = Response.newBuilder() + .request(baseRequest()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(200)) + .build(); + original.newBuilder().status(Status.of(500)).build(); + expect(original.status.code).toBe(200); + }); +}); + +describe('headers (HTTP-6)', () => { + test('defaults to empty headers and carries what the builder was given', () => { + const bare = Response.newBuilder() + .request(baseRequest()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(204)) + .build(); + expect(bare.headers.names()).toEqual([]); + + const response = bare + .newBuilder() + .headers(Headers.newBuilder().add('Content-Type', 'text/plain').build()) + .build(); + expect(response.headers.get('content-type')).toBe('text/plain'); + expect(bare.headers.has('content-type')).toBe(false); + }); +}); diff --git a/packages/core/src/http/response.ts b/packages/core/src/http/response.ts new file mode 100644 index 0000000..8c9c68e --- /dev/null +++ b/packages/core/src/http/response.ts @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/response.ts +import type {Builder} from './builder.js'; +import {requireField} from './builder.js'; +import type {Request} from './request.js'; +import type {Protocol} from './protocol.js'; +import type {Status} from './status.js'; +import {Headers} from './headers.js'; + +// eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-6 +let createResponse: ( + request: Request, + protocol: Protocol, + status: Status, + reasonPhrase: string | undefined, + headers: Headers, + body: unknown, +) => Response; + +/** + * An immutable HTTP response: the originating request, the negotiated protocol, the status, an + * optional reason phrase, headers, and an optional body (HTTP-6). + * + * Status-range classification is reached through {@link Response.status} — `response.status.isSuccess`, + * `response.status.isError`, and the rest (HTTP-11). + * + * @public + */ +export class Response { + readonly #request: Request; + readonly #protocol: Protocol; + readonly #status: Status; + readonly #reasonPhrase: string | undefined; + readonly #headers: Headers; + readonly #body: unknown; + + // eslint-disable-next-line max-params -- private, builder-internal; field count fixed by the wire model (HTTP-6) + private constructor( + request: Request, + protocol: Protocol, + status: Status, + reasonPhrase: string | undefined, + headers: Headers, + body: unknown, + ) { + this.#request = request; + this.#protocol = protocol; + this.#status = status; + this.#reasonPhrase = reasonPhrase; + this.#headers = headers; + this.#body = body; + Object.freeze(this); + } + + static { + // eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-6 + createResponse = (request, protocol, status, reasonPhrase, headers, body) => + new Response(request, protocol, status, reasonPhrase, headers, body); + } + + /** + * Starts an empty builder. + * + * @returns a fresh {@link ResponseBuilder}. + */ + static newBuilder(): ResponseBuilder { + return new ResponseBuilder(); + } + + /** + * Derives a builder pre-populated from this instance (HTTP-3). + * + * Every field it carries is itself immutable — `Request` freezes and defensively clones its URL, + * and `Headers`, `Status`, and `Protocol` are frozen values — so sharing them cannot leak + * mutability back into either instance. + * + * @returns a {@link ResponseBuilder} holding this response's state. + */ + newBuilder(): ResponseBuilder { + return new ResponseBuilder() + .request(this.#request) + .protocol(this.#protocol) + .status(this.#status) + .reasonPhrase(this.#reasonPhrase) + .headers(this.#headers) + .body(this.#body); + } + + /** The request this response was produced for. */ + get request(): Request { + return this.#request; + } + + /** The negotiated protocol version. */ + get protocol(): Protocol { + return this.#protocol; + } + + /** The response status, which also carries the range classification (HTTP-11). */ + get status(): Status { + return this.#status; + } + + /** The reason phrase as sent, or `undefined` when the transport supplied none. */ + get reasonPhrase(): string | undefined { + return this.#reasonPhrase; + } + + /** The response headers — never null, possibly empty. */ + get headers(): Headers { + return this.#headers; + } + + /** + * The response body, or `undefined` when absent. Typed `unknown` until the body lifecycle lands + * in a later phase. + */ + get body(): unknown { + return this.#body; + } +} + +/** + * Accumulates response state and produces an immutable {@link Response}. + * + * @public + */ +export class ResponseBuilder implements Builder { + #request: Request | undefined; + #protocol: Protocol | undefined; + #status: Status | undefined; + #reasonPhrase: string | undefined; + #headers: Headers = Headers.newBuilder().build(); + #body: unknown; + + /** + * Sets the originating request. Required. + * + * @param request - the request this response answers. + * @returns this builder, for chaining. + */ + request(request: Request): this { + this.#request = request; + return this; + } + + /** + * Sets the negotiated protocol. Required. + * + * @param protocol - the protocol the exchange used. + * @returns this builder, for chaining. + */ + protocol(protocol: Protocol): this { + this.#protocol = protocol; + return this; + } + + /** + * Sets the response status. Required. + * + * @param status - the status received. + * @returns this builder, for chaining. + */ + status(status: Status): this { + this.#status = status; + return this; + } + + /** + * Sets the reason phrase. + * + * @param reasonPhrase - the phrase as sent, or `undefined` when there was none. + * @returns this builder, for chaining. + */ + reasonPhrase(reasonPhrase: string | undefined): this { + this.#reasonPhrase = reasonPhrase; + return this; + } + + /** + * Sets the response headers, replacing whatever was set before. + * + * @param headers - the headers received; already immutable, so held by reference. + * @returns this builder, for chaining. + */ + headers(headers: Headers): this { + this.#headers = headers; + return this; + } + + /** + * Sets the response body. + * + * @param body - the body, or `undefined` when absent. + * @returns this builder, for chaining. + */ + body(body: unknown): this { + this.#body = body; + return this; + } + + /** + * Validates the required fields and constructs the response. + * + * @returns the frozen response. + * @throws {@link RequiredFieldError} when the request, protocol, or status was never set, + * naming whichever is missing (HTTP-4). + */ + build(): Response { + const request = requireField(this.#request, 'request'); + const protocol = requireField(this.#protocol, 'protocol'); + const status = requireField(this.#status, 'status'); + return createResponse( + request, + protocol, + status, + this.#reasonPhrase, + this.#headers, + this.#body, + ); + } +} diff --git a/packages/core/src/http/status.test.ts b/packages/core/src/http/status.test.ts new file mode 100644 index 0000000..05b2097 --- /dev/null +++ b/packages/core/src/http/status.test.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/status.test.ts +// Exercises: HTTP-10 (total function, never throws), HTTP-11 (range classification), HTTP-12 (code-only equality) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {Status} from './status.js'; + +describe('Status.of', () => { + test('maps a known code to a named canonical instance', () => { + const status = Status.of(200); + expect(status.code).toBe(200); + expect(status.name).toBe('OK'); + expect(status.isRecognized).toBe(true); + }); + + test('maps an unrecognized code to a raw, unnamed instance without throwing', () => { + const status = Status.of(599); + expect(status.code).toBe(599); + expect(status.name).toBeUndefined(); + expect(status.isRecognized).toBe(false); + }); + + test('recognized() returns the canonical instance for a known code and absent for an unknown one', () => { + expect(Status.recognized(200)).toBe(Status.of(200)); + expect(Status.recognized(599)).toBeUndefined(); + }); + + test('never throws for any integer code, per the total-function property', () => { + fc.assert( + fc.property(fc.integer({min: 100, max: 999}), code => { + expect(() => Status.of(code)).not.toThrow(); + }), + ); + }); +}); + +describe('range classification', () => { + test.each([ + [100, 'isInformational'], + [200, 'isSuccess'], + [301, 'isRedirect'], + [404, 'isClientError'], + [500, 'isServerError'], + ] as const)('code %i sets %s', (code, flag) => { + expect(Status.of(code)[flag]).toBe(true); + }); + + test('400-599 are isError', () => { + expect(Status.of(404).isError).toBe(true); + expect(Status.of(500).isError).toBe(true); + expect(Status.of(200).isError).toBe(false); + }); +}); + +describe('equals', () => { + test('two Status values are equal iff their codes are equal, name does not participate', () => { + expect(Status.of(200).equals(Status.of(200))).toBe(true); + expect(Status.of(599).equals(Status.of(599))).toBe(true); // both unnamed, same code + expect(Status.of(200).equals(Status.of(201))).toBe(false); + }); +}); diff --git a/packages/core/src/http/status.ts b/packages/core/src/http/status.ts new file mode 100644 index 0000000..dbdfc98 --- /dev/null +++ b/packages/core/src/http/status.ts @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/status.ts +/** + * An HTTP response status code, with its canonical reason name when the code is recognized. + * + * A total function of the integer code: {@link Status.of} never throws, so a transport can surface a + * vendor code (nginx 499, Cloudflare 520–526) faithfully instead of failing on it (HTTP-10). + * Instances are frozen and compare by numeric code alone (HTTP-12). + * + * @example + * ```ts + * Status.of(200).name; // 'OK' + * Status.of(599).name; // undefined + * Status.of(599).isRecognized // false + * ``` + * + * @public + */ +export class Status { + static readonly #known = new Map(); + + readonly #code: number; + readonly #name: string | undefined; + + private constructor(code: number, name: string | undefined) { + this.#code = code; + this.#name = name; + Object.freeze(this); + } + + static #register(code: number, name: string): void { + Status.#known.set(code, new Status(code, name)); + } + + static { + Status.#register(200, 'OK'); + Status.#register(201, 'Created'); + Status.#register(204, 'No Content'); + Status.#register(301, 'Moved Permanently'); + Status.#register(302, 'Found'); + Status.#register(304, 'Not Modified'); + Status.#register(400, 'Bad Request'); + Status.#register(401, 'Unauthorized'); + Status.#register(403, 'Forbidden'); + Status.#register(404, 'Not Found'); + Status.#register(409, 'Conflict'); + Status.#register(429, 'Too Many Requests'); + Status.#register(500, 'Internal Server Error'); + Status.#register(502, 'Bad Gateway'); + Status.#register(503, 'Service Unavailable'); + } + + /** + * Maps any integer code to a `Status`, never throwing (HTTP-10). + * + * @param code - the numeric status code. + * @returns the canonical named instance for a recognized code, otherwise a raw, unnamed instance + * carrying that code. + */ + static of(code: number): Status { + return Status.#known.get(code) ?? new Status(code, undefined); + } + + // HTTP-10's second clause verbatim: a lookup that returns absent for an unknown code, distinct from + // the total-function `of`. + /** + * Looks up only the recognized codes, letting a caller distinguish them from vendor codes — + * HTTP-10's second clause, deliberately distinct from the total {@link Status.of}. + * + * @param code - the numeric status code. + * @returns the canonical instance, or `undefined` when the code is not recognized. + */ + static recognized(code: number): Status | undefined { + return Status.#known.get(code); + } + + /** The numeric status code. */ + get code(): number { + return this.#code; + } + + /** The canonical reason name (`'OK'`, `'Not Found'`), or `undefined` for an unrecognized code. */ + get name(): string | undefined { + return this.#name; + } + + /** Whether this code is one the model recognizes and can name. */ + get isRecognized(): boolean { + return this.#name !== undefined; + } + + /** Whether the code is informational (100–199). */ + get isInformational(): boolean { + return this.#code >= 100 && this.#code <= 199; + } + + /** Whether the code is a success (200–299). */ + get isSuccess(): boolean { + return this.#code >= 200 && this.#code <= 299; + } + + /** Whether the code is a redirect (300–399). */ + get isRedirect(): boolean { + return this.#code >= 300 && this.#code <= 399; + } + + /** Whether the code is a client error (400–499). */ + get isClientError(): boolean { + return this.#code >= 400 && this.#code <= 499; + } + + /** Whether the code is a server error (500–599). */ + get isServerError(): boolean { + return this.#code >= 500 && this.#code <= 599; + } + + /** Whether the code is any error, client or server (400–599). */ + get isError(): boolean { + return this.#code >= 400 && this.#code <= 599; + } + + /** + * Compares by numeric code only — the reason name never participates (HTTP-12). + * + * @param other - the status to compare against. + * @returns `true` when both codes are equal. + */ + equals(other: Status): boolean { + return this.#code === other.#code; + } +} diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts deleted file mode 100644 index ab286ea..0000000 --- a/packages/core/src/index.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {expect, test} from 'bun:test'; -import {ping} from './index.js'; - -test('ping returns pong', () => { - expect(ping()).toBe('pong'); -}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7c6cd85..df1ff17 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,14 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/index.ts /** - * @public + * The immutable, transport-agnostic HTTP domain model at the heart of `@dexpace/core`. + * + * Every type here is frozen at construction and built through a builder or a static factory, so + * case-insensitivity, multi-value semantics, ordering, header-injection defenses, method/body + * legality, and total status handling are fixed once and behave identically under every transport. + * + * The package has zero runtime dependencies. + * + * @packageDocumentation */ -export function ping(): 'pong' { - return 'pong'; -} +export * from './http/index.js'; diff --git a/scripts/verify-dual-consumption.mjs b/scripts/verify-dual-consumption.mjs index 1fefc06..ebf6195 100644 --- a/scripts/verify-dual-consumption.mjs +++ b/scripts/verify-dual-consumption.mjs @@ -1,8 +1,10 @@ +// SPDX-License-Identifier: MIT // scripts/verify-dual-consumption.mjs import assert from 'node:assert/strict'; -import {ping} from '@dexpace/core'; +import {Status} from '@dexpace/core'; -assert.equal(ping(), 'pong'); +assert.equal(Status.of(200).code, 200); +assert.equal(Status.of(200).name, 'OK'); console.log( 'dual-consumption check passed: plain Node import resolved and executed @dexpace/core', );