diff --git a/examples/petstore/FINDINGS.md b/examples/petstore/FINDINGS.md new file mode 100644 index 0000000..53db52f --- /dev/null +++ b/examples/petstore/FINDINGS.md @@ -0,0 +1,275 @@ +# Petstore codegen spike — findings + +The deliverable of [issue #64](https://github.com/dexpace/nodejs-sdk/issues/64). This is the answer +the spike was built to produce: for each gap the issue hypothesised, what the example actually had +to hand-write, and whether it belongs in `packages/core/src/codegen/`. + +**Status: spike complete, all five hypotheses confirmed, five more gaps found.** The example is +throwaway. Nothing here is shipped, nothing is a package, and no CI step runs any of it. + +## What was built + +``` +examples/petstore/ +├── spec/petstore.openapi.json byte-identical to python-sdk/examples/petstore/spec/ +├── generate.mjs deterministic renderer, Prettier-formatted output 426 lines +├── generate.d.mts hand-written types for the script +├── tsconfig.json standalone; exists for eslint's projectService (finding 7) +├── src/ +│ ├── models.ts Pet / PetEvent / PetPatch + Schema witnesses 158 +│ ├── operation.ts the Operation / OperationInput split (finding 3) 84 +│ ├── errors.ts PetStoreError / PetNotFoundError + StatusErrorMap 129 +│ ├── support.ts jsonBody, petPatchToWire, PET_PAGE_STRATEGY, mapper 154 +│ ├── service-core.ts the executor — the payload of the spike 290 +│ ├── fake-transport.ts local in-memory Transport (finding 5) 129 +│ └── _generated/ +│ ├── operations.ts the operation table 45 +│ └── client.ts the facade 82 +├── canary.test.ts 15 assertions end to end over the fake transport 365 +└── regen.test.ts re-render, byte-compare 34 +``` + +Everything under `src/` except `_generated/` is what a real service SDK would hand-write. That is +**944 lines, of which roughly 340 are the gap**: the executor's mechanical half, the status map, the +operation split, and the fake transport. The rest — models, schemas, binders — is per-service work +that no core change removes. + +## Verification + +All run by hand; none is a CI step. + +```bash +bun run build # @dexpace/core -> dist, and the rest +node examples/petstore/generate.mjs # rewrite src/_generated/ +bunx tsc -p examples/petstore/tsconfig.json --noEmit +bunx eslint examples/ # clean; and `bun run lint` covers it too +bun test ./examples/petstore # 15 pass, 0 fail +bun run test # 164 files — UNCHANGED from the pre-scaffold run +``` + +The isolation premise held on the number that mattered: `bun run test` collected **164 files before +the scaffold and 164 after**. It did not hold completely — see finding 7. + +--- + +## Confirmed hypotheses + +### 1. No executor tier — confirmed, and it is thin + +Nothing in `@dexpace/core` exports an object with `execute` / `executeRequest` / `paginate` / +`events` plus ownership-aware close. `src/service-core.ts` is that object. Stripped of its comments +it is about 90 lines, and it is thin for one specific reason worth recording: + +**`Runtime implements Transport` (PIPE-26) is what collapses the layer.** The same pipeline drops +into `new Paginator({transport: runtime, ...})`, into a bare `runtime.send()`, and into the response +`sseStreamFrom()` opens, with no adapter anywhere. Python needs a `ServiceCore` and an +`AsyncServiceCore`; Node needs one, and it delegates rather than bridges. + +**Ownership is free, not implemented.** `Runtime.close()` is a documented no-op that never touches +its terminal transport (PIPE-27), so "borrowed" costs no bookkeeping — the executor closes the +transport it built the preset around, and nothing else. Both close semantics are asserted in the +canary. + +**Verdict — belongs in core**, as `packages/core/src/codegen/service-core.ts`. It is the smallest of +the four gaps to lift and the one every service SDK would otherwise copy verbatim. + +### 2. No declarative status-to-error map — confirmed + +`decodeSuccessResponse` routes every 4xx/5xx through `toHttpError`, which produces `HttpStatusError` +and nothing else. `src/errors.ts` is the local `StatusErrorMap`: a `ReadonlyMap` plus a +fallback, validated at construction, applied by `remapStatusError` in the executor's `catch`. + +Two things the spike learned that the issue did not anticipate: + +- **The Node disjointness rule is nearly structural.** Python must enforce that a mapped class + extends `HttpResponseError` and does not extend `OSError`, because both are reachable by + multiple inheritance. Node's tree is `DexpaceError` -> {`HttpStatusError`, `IoError`, + `TransportFailureError`, ...} and single inheritance makes overlap impossible. The check is still + worth having — nothing stops a caller mapping a 404 to an `IoError` subclass — but it is one + `instanceof` on the prototype, not a lattice walk. +- **The re-map is post-hoc, and that is lossy.** By the time `remapStatusError` runs, `toHttpError` + has already drained the body into its bounded buffer and closed the response. A mapped error class + can therefore only ever see what `HttpStatusError` kept: status, media type, and up to 1 MiB of + bytes. If the map lived in core it could construct the typed error **at the drain site**, and a + service error class could be handed a decoded error payload rather than raw bytes. + +**Verdict — belongs in core**, and specifically at the `toHttpError` call site rather than wrapped +around it, so the second point above stops being a limitation. + +### 3. `OperationDescriptor` merges the static and per-call halves — confirmed, and the fix is additive + +Four of `OperationDescriptor`'s six fields (`pathParams`, `query`, `headers`, `body`) change per +call, so it cannot be the module-level constant an operation table needs, and it has no slot for an +operation's declared auth. `src/operation.ts` splits it: + +```ts +Operation = {name, method, pathTemplate, auth?} // frozen once, at module load +OperationInput = {pathParams?, query?, headers?, body?} // per call +assemble(op, input) -> OperationDescriptor // two lines +``` + +**The compatibility question in the issue resolves in favour of "no break".** `Operation & +OperationInput` is exactly `OperationDescriptor` plus `name` and `auth`. Core can introduce both +halves and re-express `OperationDescriptor` as their union without touching a single published +signature; `buildRequest(baseUrl, operation)` keeps its exact shape and every existing caller keeps +compiling. + +**Verdict — belongs in core**, as a purely additive reshape. No deprecation, no major version. + +### 4. The `operation` auth tier has no source — confirmed, and now measured + +`docs/deferred-items.md` records the `operation` tier as **BLOCKED — no source layer exists on this +roadmap**. This spike is that layer, and here is exactly what its absence costs. + +`AuthTiers` is `perCall ?? operation ?? client`, resolved inside `authStep`. `RequestOptions.auth` +fills `perCall`; the step's own settings fill `client`; nothing fills `operation`. So the executor +folds the operation's descriptor into the `perCall` slot: + +```ts +const auth = call.auth ?? operation?.auth; // service-core.ts, requestOptions() +``` + +Three consequences, all real: + +1. **AUTH-4's precedence chain is reimplemented outside core.** The top two-thirds of it live in a + consumer's executor. Every generated SDK would carry the same two-line `??`. +2. **Core cannot tell the two tiers apart.** Once folded, a caller's genuine per-call override and + an operation's declared requirement occupy the same slot. The executor resolves the collision + before core sees it; core has no way to audit, log, or diagnose which tier actually won. +3. **`AuthTiers.operation` stays dead.** It is a documented public field with no writer anywhere in + the workspace. + +**What works correctly and needed no help:** presence-selects-the-tier. The canary asserts all three +outcomes — the operation tier beating a client `API_KEY` default, an operation with no descriptor +falling back to that default, and a present-but-unsatisfiable `OAUTH2` requirement raising +`AuthResolutionError` **with `transport.calls` still empty**. AUTH-4/AUTH-5/AUTH-6 are mechanically +right; only the plumbing is missing. + +**Verdict — the smallest useful fix is a second per-call slot.** Either `RequestOptions` gains +`operationAuth?: AuthDescriptor` (filling `AuthTiers.operation` in `effectiveTiers`), or +`StepContext.options` carries the operation descriptor separately. Either makes the fold above +disappear and `AuthTiers.operation` live. This closes the `deferred-items.md` row. + +### 5. `FakeTransport` and `countingResponse` are unreachable — confirmed + +They live at `packages/core/src/testing/fake-transport.ts` and are absent from the package barrel. +Reaching them means deep-importing `packages/core/src/` while everything else resolves +`@dexpace/core` to `packages/core/dist/` — two copies of core, two `HttpStatusError` classes, every +cross-boundary `instanceof` silently false. The example wrote its own instead: `src/fake-transport.ts`, +129 lines including its own body-draining helper, because `Body` exposes `writeTo(sink)` and no byte +accessor. + +**Verdict — worth deciding, and the answer is probably a separate package.** Exporting the testing +helpers from `@dexpace/core`'s barrel puts test doubles in every production bundle and makes them +API-report surface with a compatibility promise. A `@dexpace/testing` package with core as a peer +dependency gets the sharing without either cost. Doing nothing is also defensible: the fake is 30 +mechanical lines, and every consumer writing their own is not a crisis. + +**A companion positive finding.** Python needs a `_PetPageStrategy` wrapper class that re-decodes +each raw page item, because its `CursorStrategy` is configured by wire field names and yields raw +documents. Node's `cursorStrategy` takes an `extract` callback instead, so the decode happens inside +it and the wrapper has **no twin here**. `support.ts`'s `PET_PAGE_STRATEGY` is one call. + +--- + +## New gaps, found while building + +### 6. There is no encode witness — `Schema` is decode-only + +`Schema` is `{parse(input: unknown): T}`. Nothing in the seam goes the other way. Python's +`Codec` is bidirectional: `_CODEC.encode(model)` produces the wire document, so its `json_body(model)` +is generic over every model. + +Node's `serdeBody(value, serde, mediaType)` encodes **whatever object it is handed**, with no field +mapping. So a model whose field names differ from its wire names — `petId` vs `pet_id`, `weightKg` +vs `weight_kg`, which is every real API — needs a hand-written projection per model: + +```ts +export function petPatchToWire(patch: PetPatch): Readonly> { + return {name: patch.name, tag: patch.tag, weight_kg: patch.weightKg}; +} +``` + +A generator can emit these — it knows both names from `components/schemas`. But there is no seam in +core to hang them on, so today the generated facade has to name a hand-written symbol from the +service's own shim, which is exactly what `client.ts` does. + +**Verdict — not core's job to solve, but core should state the shape.** An `Encoder` mirror of +`Schema`, or a `Codec = {parse; toWire}` pair, would give a generator one thing to emit +instead of a naming convention between two files. + +### 7. `gts lint .` DOES reach `examples/` — the isolation claim was four-fifths right + +The plan's isolation list named `bunfig.toml`, `verify-test-partition.mjs`, the tsconfig projects and +api-extractor. All four hold. It missed **Lint**, which is a blocking CI step and runs `gts lint .` +from the repository root over every file in the tree. + +Two consequences, both handled here rather than by editing shared config: + +- **The example needs its own `tsconfig.json`.** `eslint.config.js` runs the type-aware tier with + `projectService: true`, which resolves each `.ts` file against the nearest enclosing tsconfig. With + none, lint fails with *"was not found by the project service"*. +- **Generated output has to be Prettier-clean**, because formatting is an error, not a warning. + Predicting Prettier's line breaking from a string-concatenating renderer is not viable, so + `generate.mjs` formats its own output through the same `gts/.prettierrc.json` that + `eslint.config.js` feeds the `prettier/prettier` rule, resolved the same way. The cost is that a + Prettier upgrade can change the checked-in bytes — and `regen.test.ts` is what says so. + +This is worth writing down for the next spike that assumes `examples/` is invisible. It is invisible +to four gates and fully visible to the fifth. + +### 8. `Paginator` has no status-mapping hook + +The engine hands every response to the strategy regardless of status. A mid-walk 500 therefore +reaches `extract`, fails schema validation, and surfaces as a `DeserializationError` — never as the +`StatusErrorMap`'s typed error, because the executor's mapping wraps `execute`, not the walk. + +A generated SDK cannot fix this without putting status handling into every strategy, which is the +duplication `StatusErrorMap` exists to prevent. Whatever shape finding 2 takes in core, `Paginator` +needs the same treatment — most cheaply as an optional `onErrorStatus` hook on `PaginatorInit`, or +by having the engine reject on a non-2xx before the strategy is consulted. + +Not exercised by the canary: the spike records the gap rather than asserting the current behaviour, +because asserting it would pin a shape that should change. + +### 9. `max-params: 3` bites a generated facade + +`updatePet(petId, patch, call)` is already at the repository's cap. Two path parameters plus a body +plus a call bag is four, and a real API has plenty of those. A generator targeting this repository's +lint rules must emit a single options object per method — or generated code needs an exemption. +Worth deciding before a generator exists, because it changes every rendered signature. + +### 10. The document's scheme vocabulary needs a mapping table + +`AuthScheme` is a closed union (`'OAUTH2' | 'API_KEY' | 'BASIC' | 'DIGEST' | 'NO_AUTH'`). The frozen +document says `bearer` and `apikey`. `generate.mjs` carries `SCHEME_BY_SPEC_NAME` and fails at +generation time on an unmapped name, which is the right time to fail. A real generator would derive +it from `components/securitySchemes` instead — but the closed union means the mapping is +**mandatory**, not a convenience, and it belongs in whatever codegen contract core publishes. + +### 11. No sync/async parity gate analogue — confirmed, nothing to port + +Node is async-only. One facade, no mode switch in the generator, no AST-normalising parity check. +Python's `tools/parity_check.py` and `test_petstore_parity.py` have no twin and need none. + +--- + +## Recommendation + +If `packages/core/src/codegen/` is built, it should contain, in descending order of value: + +| What | Why | Size | +|---|---|---| +| `Operation` / `OperationInput` / `assemble` | Finding 3. Purely additive; unblocks a real operation table. | ~40 lines | +| The `operation` auth-tier slot | Finding 4. Closes a register row that has been blocked since Phase 5c, and stops AUTH-4 being reimplemented per SDK. | ~15 lines, plus a `RequestOptions` field | +| `StatusErrorMap` + its application at the `toHttpError` site | Finding 2. Removes a hand-written `if (status === ...)` chain from every service SDK, and fixes the post-hoc losses. | ~70 lines | +| `ServiceCore` | Finding 1. The most code, the least judgement — a delegation layer over surfaces that already compose. | ~90 lines | +| A `Paginator` status hook | Finding 8. Without it, finding 2's fix has a hole exactly the width of a paginated endpoint. | ~10 lines | + +Findings 5, 6, 9 and 10 are decisions rather than code: whether testing helpers get a package, +whether the serde seam gains an encode half, whether generated code is exempt from `max-params`, and +where the scheme mapping is published. + +**What this spike deliberately did not do:** design any of those APIs. The issue's sequencing was +right — the executor was built first, against the core as it stands, so the shape of each gap is now +measured rather than guessed. diff --git a/examples/petstore/README.md b/examples/petstore/README.md new file mode 100644 index 0000000..7bebbcf --- /dev/null +++ b/examples/petstore/README.md @@ -0,0 +1,63 @@ +# Petstore codegen canary + +A **throwaway spike**, not a shipped example. It answers one question for +[issue #64](https://github.com/dexpace/nodejs-sdk/issues/64): does the Python SDK's codegen contract +port onto `@dexpace/core` as it stands, and what exactly is missing? + +The answer is [FINDINGS.md](./FINDINGS.md). Read that first — this file is only how to run it. + +Nothing here is a workspace package, nothing is published, and no CI step runs any of it. It lives +outside `packages/` and `tests/` on purpose. One gate does see it — `bun run lint` — which is +[finding 7](./FINDINGS.md#7-gts-lint--does-reach-examples--the-isolation-claim-was-four-fifths-right). + +## What it is + +A frozen OpenAPI document, a deterministic generator, a projection-only facade, a hand-written +executor, and an end-to-end canary over an in-memory transport. The document in `spec/` is +byte-identical to the Python witness's, so the same fixture drives both ports. + +The generator emits **data and delegation, never logic**: an operation table plus a facade whose +every method binds arguments into an `OperationInput` and calls the shared `ServiceCore`. Everything +behavioural — request assembly, pipeline, retry, auth resolution, error mapping, pagination, SSE — +stays in `@dexpace/core` or in the executor the spike was written to measure. + +## Layout + +| Path | What | +|---|---| +| `spec/petstore.openapi.json` | The frozen document. Never edited by anything here. | +| `generate.mjs` | Renders `src/_generated/`. Deterministic, Prettier-formatted. | +| `src/models.ts` | Hand-written models plus a `Schema` per model. | +| `src/operation.ts` | The `Operation` / `OperationInput` split core does not have. | +| `src/errors.ts` | Typed errors plus the local `StatusErrorMap`. | +| `src/support.ts` | The binders the generated facade names. | +| `src/service-core.ts` | The executor — the payload of the spike. | +| `src/fake-transport.ts` | A local in-memory `Transport`. | +| `src/_generated/` | **Generated. Never hand-edit** — `regen.test.ts` fails if you do. | + +## Running it + +From the repository root, after `bun install --frozen-lockfile`: + +```bash +bun run build # required: the example resolves core via dist/ +node examples/petstore/generate.mjs # rewrite src/_generated/ +bun test ./examples/petstore # canary + regen guard +bunx tsc -p examples/petstore/tsconfig.json --noEmit +bunx eslint examples/ +``` + +`bun test ./examples/petstore` needs the `./` prefix — a bare `examples/petstore` is treated as a +test-name filter and matches nothing. + +## Regenerating + +`src/_generated/` is checked in and byte-compared against a fresh render on every test run. To +change what is generated, edit `generate.mjs` (or the frozen document), then: + +```bash +node examples/petstore/generate.mjs && bun test ./examples/petstore +``` + +A Prettier upgrade can also move the bytes, since the generator formats its output through +`gts/.prettierrc.json`. The regen test is what tells you to re-run the script. diff --git a/examples/petstore/canary.test.ts b/examples/petstore/canary.test.ts new file mode 100644 index 0000000..c46aac0 --- /dev/null +++ b/examples/petstore/canary.test.ts @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/canary.test.ts +// End-to-end canary for the generated petstore SDK against the head core, over an in-memory +// transport. Each scenario proves one certified capability reaches a caller THROUGH the generated +// facade rather than through a hand-written call: +// +// PAGE-1/PAGE-10/PAGE-16 listPets walks two cursor pages, splices `?cursor=`, honours maxPages +// SSE-33/SSE-34 watchPets maps frames and stops on the `[DONE]` sentinel +// BODY-30/HTTP-52 a 404 and a 500 arrive as the status map's typed errors +// AUTH-4/AUTH-5/AUTH-6 the operation tier beats the client default, falls back when absent, +// and fails loudly — with no request sent — when present-but-unsatisfiable +// SERDE-15/SERDE-19 a merge-patch body carries Absent / Null / Present intact +// SEAM-14/PIPE-27 an owned transport is closed; a borrowed runtime is not +// +// Run with `bun test ./examples/petstore` after `bun run build`. NOT part of `bun run test`. +import {expect, test} from 'bun:test'; +import { + ApiKeyCredential, + AuthResolutionError, + createAuthDescriptor, + createAuthRequirement, + createBearerToken, + isAbsent, + isNull, + isPresent, + nullValue, + present, + standardResilience, + type ApiKeyCredentialConfig, + type AuthStepSettings, + type BearerCredential, +} from '@dexpace/core'; +import {jsonSerde} from '@dexpace/codec-json'; +import {PetStoreClient} from './src/_generated/client.js'; +import { + PETSTORE_ERRORS, + PetNotFoundError, + PetStoreError, + type StatusErrorMap, +} from './src/errors.js'; +import { + LocalFakeTransport, + readBodyBytes, + type ScriptedReply, +} from './src/fake-transport.js'; +import { + PET_PATCH_SCHEMA, + emptyPetPatch, + type Pet, + type PetEvent, + type PetPatch, +} from './src/models.js'; +import {ServiceCore} from './src/service-core.js'; + +const BASE = 'https://api.example.com'; +const SERDE = jsonSerde(); +const DECODER = new TextDecoder(); + +const API_KEY: ApiKeyCredentialConfig = { + credential: new ApiKeyCredential('k-123'), + headerName: 'X-Api-Key', +}; + +const BEARER: BearerCredential = { + provider: () => Promise.resolve(createBearerToken('t-abc')), +}; + +/** + * A client-tier default of `API_KEY`, with the bearer credential present or absent. + * + * Absent is the unsatisfiable case: `getPet` declares an `OAUTH2` requirement, AUTH-4 selects the + * tier by PRESENCE, and AUTH-5 judges satisfiability on configured credentials — so the call must + * fail rather than quietly fall through to the satisfiable `API_KEY` default below it. + */ +function authSettings(withBearer: boolean): AuthStepSettings { + return { + credentials: withBearer + ? {apiKey: API_KEY, bearer: BEARER} + : {apiKey: API_KEY}, + tiers: {client: createAuthDescriptor([createAuthRequirement('API_KEY')])}, + }; +} + +interface Harness { + readonly client: PetStoreClient; + readonly transport: LocalFakeTransport; +} + +function harness( + script: readonly ScriptedReply[], + options: { + readonly auth?: AuthStepSettings | undefined; + readonly errors?: StatusErrorMap | undefined; + } = {}, +): Harness { + const transport = new LocalFakeTransport(script); + const core = new ServiceCore({ + baseUrl: BASE, + transport, + serde: SERDE, + resilience: options.auth === undefined ? undefined : {auth: options.auth}, + errors: options.errors, + }); + return {client: new PetStoreClient(core), transport}; +} + +function petJson(id: string, name: string): string { + return JSON.stringify({id, name, tag: null}); +} + +const PAGE_ONE = JSON.stringify({ + data: [ + {id: '1', name: 'a', tag: null}, + {id: '2', name: 'b', tag: null}, + ], + next_cursor: 'c2', +}); + +const PAGE_TWO = JSON.stringify({ + data: [{id: '3', name: 'c', tag: null}], + next_cursor: null, +}); + +function sseBody(): string { + const frames = [ + JSON.stringify({kind: 'created', pet_id: '1'}), + JSON.stringify({kind: 'updated', pet_id: '1'}), + '[DONE]', + ]; + return frames.map(frame => `data: ${frame}\n\n`).join(''); +} + +function namedPatch(): PetPatch { + return {...emptyPetPatch(), name: present('Rex')}; +} + +// -------------------------------------------------------------------------------------------- +// paginate +// -------------------------------------------------------------------------------------------- + +test('listPets walks two cursor pages and yields typed pets', async () => { + const {client, transport} = harness([ + {status: 200, body: PAGE_ONE}, + {status: 200, body: PAGE_TWO}, + ]); + const pets: Pet[] = []; + for await (const pet of client.listPets().items()) pets.push(pet); + + expect(pets.map(pet => pet.name)).toEqual(['a', 'b', 'c']); + expect(transport.calls).toHaveLength(2); + // PAGE-16: the cursor is spliced onto the request that produced the page, not re-derived. + expect(transport.calls[1]?.request.url.search).toBe('?cursor=c2'); + await client.close(); +}); + +test('maxPages caps the walk at one exchange', async () => { + const {client, transport} = harness([ + {status: 200, body: PAGE_ONE}, + {status: 200, body: PAGE_TWO}, + ]); + const pets: Pet[] = []; + for await (const pet of client.listPets({maxPages: 1}).items()) + pets.push(pet); + + expect(pets.map(pet => pet.name)).toEqual(['a', 'b']); + expect(transport.calls).toHaveLength(1); + await client.close(); +}); + +// -------------------------------------------------------------------------------------------- +// events +// -------------------------------------------------------------------------------------------- + +test('watchPets yields mapped events and stops on the [DONE] sentinel', async () => { + const {client, transport} = harness([ + { + status: 200, + body: sseBody(), + headers: {'content-type': 'text/event-stream'}, + }, + ]); + const events: PetEvent[] = []; + for await (const event of client.watchPets()) events.push(event); + + expect(events).toEqual([ + {kind: 'created', petId: '1'}, + {kind: 'updated', petId: '1'}, + ]); + expect(transport.calls).toHaveLength(1); + await client.close(); +}); + +test('a failure status never reaches the SSE parser', async () => { + const {client} = harness([{status: 404, body: '{"message":"nope"}'}], { + errors: PETSTORE_ERRORS, + }); + const iterate = async (): Promise => { + for await (const event of client.watchPets()) { + throw new Error(`expected no event, got ${event.kind}`); + } + }; + + const error = await iterate().catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(PetNotFoundError); + await client.close(); +}); + +// -------------------------------------------------------------------------------------------- +// typed errors through the status map +// -------------------------------------------------------------------------------------------- + +test('a 404 arrives as the mapped PetNotFoundError', async () => { + const {client} = harness([{status: 404, body: '{"message":"nope"}'}], { + errors: PETSTORE_ERRORS, + }); + + const error = await client + .updatePet('7', namedPatch()) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(PetNotFoundError); + expect((error as PetNotFoundError).status).toBe(404); + expect((error as PetNotFoundError).preview).toBe('{"message":"nope"}'); + await client.close(); +}); + +test('an unmapped error status falls back to the table default', async () => { + const {client} = harness([{status: 500, body: 'boom'}], { + errors: PETSTORE_ERRORS, + }); + + // `maxRetries: 0` because a 500 is retryable (RETRY-1/CFG-35) and this test is about the + // mapping, not the budget — without it the walk burns the default attempts and their backoff. + const error = await client + .updatePet('7', namedPatch(), {maxRetries: 0}) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(PetStoreError); + expect(error).not.toBeInstanceOf(PetNotFoundError); + await client.close(); +}); + +// -------------------------------------------------------------------------------------------- +// tiered auth +// -------------------------------------------------------------------------------------------- + +test('the operation tier wins over the client default', async () => { + const {client, transport} = harness( + [{status: 200, body: petJson('7', 'Rex')}], + {auth: authSettings(true)}, + ); + + // getPet declares OAUTH2 in the frozen document; the client default is API_KEY. + await client.getPet('7'); + + const headers = transport.calls[0]?.request.headers; + expect(headers?.get('Authorization')).toBe('Bearer t-abc'); + expect(headers?.get('X-Api-Key')).toBeUndefined(); + await client.close(); +}); + +test('an operation with no declared auth falls back to the client default', async () => { + const {client, transport} = harness( + [{status: 200, body: petJson('7', 'Rex')}], + {auth: authSettings(true)}, + ); + + await client.updatePet('7', namedPatch()); + + const headers = transport.calls[0]?.request.headers; + expect(headers?.get('X-Api-Key')).toBe('k-123'); + expect(headers?.get('Authorization')).toBeUndefined(); + await client.close(); +}); + +test('a present but unsatisfiable tier fails loudly, with no request sent', async () => { + const {client, transport} = harness( + [{status: 200, body: petJson('7', 'Rex')}], + {auth: authSettings(false)}, + ); + + const error = await client.getPet('7').catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(AuthResolutionError); + expect(transport.calls).toHaveLength(0); + await client.close(); +}); + +// -------------------------------------------------------------------------------------------- +// merge-patch three-state round trip +// -------------------------------------------------------------------------------------------- + +test('a merge-patch body carries Absent, Null and Present intact', async () => { + const {client, transport} = harness([ + {status: 200, body: petJson('7', 'Rex')}, + ]); + + // name -> Present, tag -> explicit Null, weightKg -> left Absent. + await client.updatePet('7', { + ...emptyPetPatch(), + name: present('Rex'), + tag: nullValue(), + }); + + const sent = transport.calls[0]?.request.body; + if (sent === undefined) throw new Error('expected the facade to send a body'); + expect(sent.mediaType).toBe('application/merge-patch+json'); + + const document: unknown = JSON.parse( + DECODER.decode(await readBodyBytes(sent)), + ); + // The Absent key is gone; the Null one survives as a wire null (SERDE-15). + expect(document).toEqual({name: 'Rex', tag: null}); + + const decoded = PET_PATCH_SCHEMA.parse(document); + expect(isPresent(decoded.name) && decoded.name.value).toBe('Rex'); + expect(isNull(decoded.tag)).toBe(true); + expect(isAbsent(decoded.weightKg)).toBe(true); + await client.close(); +}); + +// -------------------------------------------------------------------------------------------- +// lifecycle +// -------------------------------------------------------------------------------------------- + +test('closing a core that owns its transport closes it exactly once', async () => { + const transport = new LocalFakeTransport([{status: 200, body: '{}'}]); + const client = new PetStoreClient( + new ServiceCore({baseUrl: BASE, transport, serde: SERDE}), + ); + + await client.close(); + + expect(transport.closeCount).toBe(1); +}); + +test('closing a core that borrows a runtime leaves the transport alone', async () => { + const transport = new LocalFakeTransport([{status: 200, body: '{}'}]); + const runtime = standardResilience(transport); + const client = new PetStoreClient( + new ServiceCore({baseUrl: BASE, runtime, serde: SERDE}), + ); + + await client.close(); + + expect(transport.closeCount).toBe(0); +}); + +test('a core needs exactly one of transport or runtime', () => { + const transport = new LocalFakeTransport([{status: 200}]); + + expect(() => new ServiceCore({baseUrl: BASE, serde: SERDE})).toThrow( + TypeError, + ); + expect( + () => + new ServiceCore({ + baseUrl: BASE, + serde: SERDE, + transport, + runtime: standardResilience(transport), + }), + ).toThrow(TypeError); +}); diff --git a/examples/petstore/generate.d.mts b/examples/petstore/generate.d.mts new file mode 100644 index 0000000..e9b6b2a --- /dev/null +++ b/examples/petstore/generate.d.mts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/generate.d.mts +/** + * Types for `generate.mjs`, hand-written because the generator is a plain ESM script. + * + * The alternative — `allowJs` in `tsconfig.json` — types `renderAll()` as + * `Promise>`, and `any` then flows into the regen test where + * `strictTypeChecked`'s `no-unsafe-*` rules reject it. Declaring the surface is both smaller and + * honest about what the script exports. + */ + +/** Parse the frozen OpenAPI document. */ +export declare function loadSpec(specPath?: string): unknown; + +/** Every operation in the document, sorted by `operationId`. */ +export declare function collectOperations(spec: unknown): unknown[]; + +/** Render the operation-table module's text. */ +export declare function renderOperations(ops: unknown[]): string; + +/** Render the facade module's text. */ +export declare function renderClient(ops: unknown[], className: string): string; + +/** Every generated file as `name -> Prettier-formatted content`; writes nothing. */ +export declare function renderAll( + specPath?: string, +): Promise>; + +/** Render and write every generated file; returns how many were written. */ +export declare function writeAll( + specPath?: string, + outDir?: string, +): Promise; diff --git a/examples/petstore/generate.mjs b/examples/petstore/generate.mjs new file mode 100644 index 0000000..25b9a52 --- /dev/null +++ b/examples/petstore/generate.mjs @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/generate.mjs +/** + * Deterministic generator for the petstore codegen canary. + * + * Reads the frozen OpenAPI document (`spec/petstore.openapi.json`) — byte-identical to the one the + * Python witness uses — and renders the checked-in output under `src/_generated/`: + * + * - `operations.ts` — the operation table, pure `Operation` data; + * - `client.ts` — the projection-only facade. ONE facade, not two: Node is async-only, so the + * sync/async mode switch the Python generator carries has nothing to switch on and the parity + * gate that compares the two has no twin here. + * + * The output is deterministic — operations sorted by id, a fixed import order, no timestamps — so + * re-running reproduces the checked-in files byte for byte. `regen.test.ts` asserts exactly that. + * + * node examples/petstore/generate.mjs + * + * `renderAll()` is the pure entry point (name -> content) the regen test compares against the tree + * without touching the filesystem. + * + * **The rendered text is run through Prettier before it is returned.** Predicting Prettier's line + * breaking by hand is a losing game, and `gts lint .` at the repository root DOES lint + * `examples/` — formatting is an error there, not a warning. So the generator formats with the + * exact options `eslint.config.js` feeds the `prettier/prettier` rule, resolved from the same + * `gts/.prettierrc.json`. One consequence worth knowing: a Prettier upgrade can change the + * checked-in bytes, and the regen test is what tells you to re-run this script. + */ +import {readFileSync, writeFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {dirname, join} from 'node:path'; +import process from 'node:process'; +import {fileURLToPath, pathToFileURL} from 'node:url'; + +const require = createRequire(import.meta.url); + +/** The same file `eslint.config.js` sources, resolved the same way. */ +const PRETTIER_RC_PATH = require.resolve('gts/.prettierrc.json'); +const PRETTIER_OPTIONS = require(PRETTIER_RC_PATH); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SPEC_PATH = join(HERE, 'spec', 'petstore.openapi.json'); +const OUT_DIR = join(HERE, 'src', '_generated'); + +/** HTTP methods recognised in a path item, in OpenAPI order. */ +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch']; + +/** Matches a single `{name}` path placeholder. */ +const PLACEHOLDER = /\{([^{}]+)\}/g; + +/** + * The document's scheme vocabulary, mapped onto `AuthScheme` — core's closed union. + * + * The frozen document says `bearer`; core says `OAUTH2`. A real generator needs this table (or a + * `securitySchemes` walk that produces it), because `AuthScheme` is deliberately closed and an + * unmapped name is a generation-time failure rather than a runtime one. + */ +const SCHEME_BY_SPEC_NAME = { + apikey: 'API_KEY', + basic: 'BASIC', + bearer: 'OAUTH2', + digest: 'DIGEST', +}; + +/** Line 1 is NFR-13's SPDX marker, line 2 the repository's file-path comment convention. */ +function header(relativePath) { + return `// SPDX-License-Identifier: MIT\n// ${relativePath}`; +} + +/** Lazily-resolved Prettier, reached through gts so no root dependency is added. */ +let prettierPromise; + +function prettier() { + prettierPromise ??= import( + pathToFileURL(createRequire(PRETTIER_RC_PATH).resolve('prettier')).href + ).then(module => module.default ?? module); + return prettierPromise; +} + +/** `get_pet` -> `getPet`. */ +function camel(snake) { + return snake.replace(/_([a-z0-9])/g, (_match, ch) => ch.toUpperCase()); +} + +/** `get_pet` -> `GET_PET`. */ +function constantCase(snake) { + return snake.toUpperCase(); +} + +/** `PetPatch` -> `PET_PATCH`. */ +function constantCaseOfModel(pascal) { + return pascal.replace(/(? `petPatch`. */ +function lowerCamelOfModel(pascal) { + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +/** Read the frozen document. */ +export function loadSpec(specPath = SPEC_PATH) { + return JSON.parse(readFileSync(specPath, 'utf8')); +} + +function optionalString(value) { + return value === undefined || value === null ? undefined : String(value); +} + +/** Normalise the `auth` extension into `{scheme, scopes}` records, schemes already mapped. */ +function authOf(value) { + if (!Array.isArray(value)) return []; + return value.map(alternative => { + const specName = String(alternative.scheme); + const scheme = SCHEME_BY_SPEC_NAME[specName]; + if (scheme === undefined) { + throw new Error(`unmapped auth scheme "${specName}" in the document`); + } + return {scheme, scopes: (alternative.scopes ?? []).map(String)}; + }); +} + +/** The single request-body media type, or undefined when the operation declares no body. */ +function bodyMediaTypeOf(entry) { + const content = entry.requestBody?.content; + if (content === undefined) return undefined; + const types = Object.keys(content).sort(); + if (types.length !== 1) { + throw new Error( + `expected exactly one request-body media type, got ${String(types.length)}`, + ); + } + return types[0]; +} + +/** Normalise one path-item operation into the record the renderers read. */ +function opFromEntry(path, method, entry) { + const ext = entry['x-dexpace'] ?? {}; + const body = ext.body ?? {}; + return { + operationId: String(entry.operationId), + summary: String(entry.summary ?? '') + .replace(/\s+/g, ' ') + .trim(), + method: method.toUpperCase(), + path, + kind: String(ext.kind), + pathParams: [...path.matchAll(PLACEHOLDER)].map(match => match[1]), + returns: optionalString(ext.returns), + bodyParam: optionalString(body.param), + bodyModel: optionalString(body.model), + bodyMediaType: bodyMediaTypeOf(entry), + itemModel: optionalString(ext.item_model), + strategy: optionalString(ext.strategy), + eventModel: optionalString(ext.event_model), + mapper: optionalString(ext.mapper), + auth: authOf(ext.auth), + }; +} + +/** Every operation in the document, sorted by id so rendering is stable. */ +export function collectOperations(spec) { + const ops = []; + for (const [path, item] of Object.entries(spec.paths ?? {})) { + for (const method of HTTP_METHODS) { + const entry = item[method]; + if (entry !== undefined) ops.push(opFromEntry(path, method, entry)); + } + } + return ops.sort((a, b) => (a.operationId < b.operationId ? -1 : 1)); +} + +const OPERATIONS_DOC = `/** + * Operation table for the petstore canary — GENERATED; do not edit. + * + * Rendered from \`examples/petstore/spec/petstore.openapi.json\` by + * \`examples/petstore/generate.mjs\`. Pure data: one frozen \`Operation\` per \`operationId\`, in + * id-sorted order. Re-render with \`node examples/petstore/generate.mjs\`. + */`; + +function authConstName(op) { + return `${constantCase(op.operationId)}_AUTH`; +} + +function renderRequirement(requirement) { + if (requirement.scopes.length === 0) { + return `createAuthRequirement('${requirement.scheme}')`; + } + const scopes = requirement.scopes.map(scope => `'${scope}'`).join(', '); + return `createAuthRequirement('${requirement.scheme}', [${scopes}])`; +} + +/** Render the operation-table module. */ +export function renderOperations(ops) { + const withAuth = ops.filter(op => op.auth.length > 0); + const lines = [ + header('examples/petstore/src/_generated/operations.ts'), + OPERATIONS_DOC, + '', + ]; + if (withAuth.length > 0) { + lines.push( + "import {createAuthDescriptor, createAuthRequirement} from '@dexpace/core';", + ); + } + lines.push("import type {Operation} from '../operation.js';", ''); + for (const op of withAuth) { + const requirements = op.auth.map(renderRequirement).join(', '); + lines.push( + `const ${authConstName(op)} = createAuthDescriptor([${requirements}]);`, + '', + ); + } + for (const op of ops) { + lines.push(`/** \`${op.method} ${op.path}\` — ${op.summary} */`); + lines.push( + `export const ${constantCase(op.operationId)}: Operation = Object.freeze({`, + `name: '${op.operationId}',`, + `method: '${op.method}',`, + `pathTemplate: '${op.path}',`, + ); + if (op.auth.length > 0) lines.push(`auth: ${authConstName(op)},`); + lines.push('});', ''); + } + return lines.join('\n'); +} + +const CLIENT_DOC = `/** + * The petstore facade — GENERATED; do not edit. + * + * Rendered from \`examples/petstore/spec/petstore.openapi.json\` by + * \`examples/petstore/generate.mjs\`. Projection only: every method binds its arguments into an + * \`OperationInput\` and delegates to the shared \`ServiceCore\`, and carries no logic of its own. + * + * ONE facade, not two — Node is async-only, so the sync/async split the Python witness renders (and + * the AST-parity gate that keeps the two honest) has nothing to correspond to here. + */`; + +/** The runtime schema constant a model's decode witness is named by, in `models.ts`. */ +function schemaConst(model) { + return `${constantCaseOfModel(model)}_SCHEMA`; +} + +/** The hand-written encoder a body model is projected through, in `support.ts`. */ +function encoderName(model) { + return `${lowerCamelOfModel(model)}ToWire`; +} + +function sortedUnique(values) { + return [...new Set(values.filter(value => value !== undefined))].sort(); +} + +/** Render the facade's import block. Order is fixed, so the output is stable. */ +function renderClientImports(ops) { + const lines = []; + if (ops.some(op => op.kind === 'paginate')) { + lines.push("import type {Paginator} from '@dexpace/core';"); + } + const schemas = sortedUnique(ops.map(op => op.returns)).map(schemaConst); + if (schemas.length > 0) { + lines.push(`import {${schemas.join(', ')}} from '../models.js';`); + } + const models = sortedUnique([ + ...ops.map(op => op.returns), + ...ops.map(op => op.bodyModel), + ...ops.map(op => op.itemModel), + ...ops.map(op => op.eventModel), + ]); + if (models.length > 0) { + lines.push(`import type {${models.join(', ')}} from '../models.js';`); + } + if ( + ops.some(op => op.pathParams.length === 0 && op.bodyParam === undefined) + ) { + lines.push("import {NO_INPUT} from '../operation.js';"); + } + lines.push( + "import type {CallOptions, ServiceCore} from '../service-core.js';", + ); + const support = sortedUnique([ + ...ops.map(op => (op.bodyParam === undefined ? undefined : 'jsonBody')), + ...ops.map(op => + op.bodyModel === undefined ? undefined : encoderName(op.bodyModel), + ), + ...ops.map(op => op.strategy), + ...ops.map(op => op.mapper), + ]); + if (support.length > 0) { + lines.push(`import {${support.join(', ')}} from '../support.js';`); + } + lines.push("import * as operations from './operations.js';"); + return lines; +} + +/** The `OperationInput` literal for one operation, or `NO_INPUT` when it has nothing to bind. */ +function renderInput(op) { + const parts = []; + if (op.pathParams.length > 0) { + const pairs = op.pathParams + .map(name => `${name}: ${camel(name)}`) + .join(', '); + parts.push(`pathParams: {${pairs}}`); + } + if (op.bodyParam !== undefined && op.bodyModel !== undefined) { + parts.push( + `body: jsonBody(${encoderName(op.bodyModel)}(${camel(op.bodyParam)}), '${op.bodyMediaType}')`, + ); + } + return parts.length === 0 ? 'NO_INPUT' : `{${parts.join(', ')}}`; +} + +/** The declared parameters of a facade method, path params first, then any body, then the bag. */ +function renderParams(op, bagName, bagType) { + const params = op.pathParams.map(name => `${camel(name)}: string`); + if (op.bodyParam !== undefined && op.bodyModel !== undefined) { + params.push(`${camel(op.bodyParam)}: ${op.bodyModel}`); + } + params.push(`${bagName}: ${bagType} = {}`); + return params.join(', '); +} + +function renderUnaryMethod(op) { + const target = `{schema: ${schemaConst(op.returns)}, typeName: '${op.returns}'}`; + return [ + `/** \`${op.method} ${op.path}\` — ${op.summary} */`, + `${camel(op.operationId)}(${renderParams(op, 'call', 'CallOptions')}): Promise<${op.returns}> {`, + `return this.#core.execute(operations.${constantCase(op.operationId)}, ${renderInput(op)}, {...call, responseType: ${target}});`, + '}', + ].join('\n'); +} + +function renderPaginateMethod(op) { + const bagType = 'CallOptions & {maxPages?: number | undefined}'; + return [ + `/** \`${op.method} ${op.path}\` — ${op.summary} */`, + `${camel(op.operationId)}(${renderParams(op, 'paging', bagType)}): Paginator<${op.itemModel}> {`, + `return this.#core.paginate(operations.${constantCase(op.operationId)}, ${renderInput(op)}, {...paging, strategy: ${op.strategy}});`, + '}', + ].join('\n'); +} + +function renderEventsMethod(op) { + return [ + `/** \`${op.method} ${op.path}\` — ${op.summary} */`, + `${camel(op.operationId)}(${renderParams(op, 'streaming', 'CallOptions')}): AsyncIterable<${op.eventModel}> {`, + `return this.#core.events(operations.${constantCase(op.operationId)}, ${renderInput(op)}, {...streaming, mapper: ${op.mapper}});`, + '}', + ].join('\n'); +} + +function renderMethod(op) { + if (op.kind === 'paginate') return renderPaginateMethod(op); + if (op.kind === 'events') return renderEventsMethod(op); + return renderUnaryMethod(op); +} + +/** Render the facade module. */ +export function renderClient(ops, className) { + const blocks = [ + [ + '/** The petstore client — a projection over `ServiceCore`. */', + `export class ${className} {`, + 'readonly #core: ServiceCore;', + '', + 'constructor(core: ServiceCore) {', + 'this.#core = core;', + '}', + ].join('\n'), + ...ops.map(renderMethod), + [ + '/** Releases whatever the executor owns; a borrowed runtime is left alone. */', + 'close(): Promise {', + 'return this.#core.close();', + '}', + ].join('\n'), + ]; + return [ + header('examples/petstore/src/_generated/client.ts'), + CLIENT_DOC, + '', + ...renderClientImports(ops), + '', + blocks.join('\n\n'), + '}', + '', + ].join('\n'); +} + +/** + * Render every generated file as `name -> content`, Prettier-formatted. + * + * Pure beyond reading the frozen document: it writes nothing. + */ +export async function renderAll(specPath = SPEC_PATH) { + const spec = loadSpec(specPath); + const ops = collectOperations(spec); + const className = String( + spec['x-dexpace-codegen']?.client_class ?? 'ApiClient', + ); + const raw = { + 'operations.ts': renderOperations(ops), + 'client.ts': renderClient(ops, className), + }; + const {format} = await prettier(); + const rendered = new Map(); + for (const [name, content] of Object.entries(raw)) { + rendered.set( + name, + await format(content, {...PRETTIER_OPTIONS, parser: 'typescript'}), + ); + } + return rendered; +} + +/** Render and write every generated file into `outDir`. */ +export async function writeAll(specPath = SPEC_PATH, outDir = OUT_DIR) { + const rendered = await renderAll(specPath); + for (const [name, content] of rendered) { + writeFileSync(join(outDir, name), content, 'utf8'); + } + return rendered.size; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const count = await writeAll(); + process.stdout.write(`generated ${String(count)} files into ${OUT_DIR}\n`); +} diff --git a/examples/petstore/regen.test.ts b/examples/petstore/regen.test.ts new file mode 100644 index 0000000..a64b43b --- /dev/null +++ b/examples/petstore/regen.test.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/regen.test.ts +// The regen-diff guard: re-render the frozen document and byte-compare against the checked-in +// `src/_generated/` tree. A hand-edit to a generated file — or a generator change not reflected in +// the checked-in output — fails here, so the canary can only ever be regenerated, never patched. +// +// Run with `bun test ./examples/petstore`. Deliberately NOT part of `bun run test`: the example is +// outside `packages/` and `tests/`, and the root script names only those two trees. +import {readFileSync, readdirSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {expect, test} from 'bun:test'; +import {renderAll} from './generate.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const GENERATED = join(HERE, 'src', '_generated'); + +test('regenerating reproduces the checked-in output byte for byte', async () => { + const rendered = await renderAll(); + expect(rendered.size).toBeGreaterThan(0); + for (const [name, content] of rendered) { + const checkedIn = readFileSync(join(GENERATED, name), 'utf8'); + expect( + content, + `${name} is out of sync; re-run \`node examples/petstore/generate.mjs\` (never hand-edit a generated file)`, + ).toBe(checkedIn); + } +}); + +test('the generator accounts for every file in src/_generated', async () => { + const rendered = [...(await renderAll()).keys()].sort(); + const onDisk = readdirSync(GENERATED).sort(); + expect(rendered).toEqual(onDisk); +}); diff --git a/examples/petstore/spec/petstore.openapi.json b/examples/petstore/spec/petstore.openapi.json new file mode 100644 index 0000000..b13d8d9 --- /dev/null +++ b/examples/petstore/spec/petstore.openapi.json @@ -0,0 +1,115 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Dexpace Petstore Canary", + "version": "1.0.0", + "description": "A small, frozen OpenAPI document driving the petstore codegen canary. It is a fixture, not a real service: it exercises paginate, SSE events, a merge-patch body with three-valued fields, typed status errors, and a tiered auth requirement end to end through the generated facades." + }, + "x-dexpace-codegen": { + "package": "dexpace_petstore", + "client_class": "PetStoreClient", + "async_client_class": "AsyncPetStoreClient" + }, + "paths": { + "/pets": { + "get": { + "operationId": "list_pets", + "summary": "List pets, paginating by opaque cursor.", + "x-dexpace": { + "kind": "paginate", + "item_model": "Pet", + "strategy": "PET_PAGE_STRATEGY" + }, + "responses": { + "200": {"description": "A page of pets plus the next cursor."} + } + } + }, + "/pets/events": { + "get": { + "operationId": "watch_pets", + "summary": "Stream pet lifecycle events over Server-Sent Events.", + "x-dexpace": { + "kind": "events", + "event_model": "PetEvent", + "mapper": "PET_EVENT_MAPPER" + }, + "responses": { + "200": {"description": "An SSE stream of pet events."} + } + } + }, + "/pets/{pet_id}": { + "get": { + "operationId": "get_pet", + "summary": "Fetch one pet by id.", + "parameters": [ + {"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "x-dexpace": { + "kind": "unary", + "returns": "Pet", + "auth": [{"scheme": "bearer", "scopes": ["pets:read"]}] + }, + "responses": { + "200": {"description": "The requested pet."}, + "404": {"description": "No pet with that id."} + } + }, + "patch": { + "operationId": "update_pet", + "summary": "Apply a merge-patch update to a pet.", + "parameters": [ + {"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "requestBody": { + "required": true, + "content": { + "application/merge-patch+json": { + "schema": {"$ref": "#/components/schemas/PetPatch"} + } + } + }, + "x-dexpace": { + "kind": "unary", + "returns": "Pet", + "body": {"param": "patch", "model": "PetPatch"} + }, + "responses": { + "200": {"description": "The updated pet."}, + "404": {"description": "No pet with that id."} + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "tag": {"type": ["string", "null"]} + } + }, + "PetEvent": { + "type": "object", + "required": ["kind", "pet_id"], + "properties": { + "kind": {"type": "string"}, + "pet_id": {"type": "string"} + } + }, + "PetPatch": { + "type": "object", + "description": "A merge-patch body: an omitted field leaves the target unchanged, an explicit null clears it, and a value sets it.", + "properties": { + "name": {"type": ["string", "null"]}, + "tag": {"type": ["string", "null"]}, + "weight_kg": {"type": ["number", "null"]} + } + } + } + } +} diff --git a/examples/petstore/src/_generated/client.ts b/examples/petstore/src/_generated/client.ts new file mode 100644 index 0000000..0bc2002 --- /dev/null +++ b/examples/petstore/src/_generated/client.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/src/_generated/client.ts +/** + * The petstore facade — GENERATED; do not edit. + * + * Rendered from `examples/petstore/spec/petstore.openapi.json` by + * `examples/petstore/generate.mjs`. Projection only: every method binds its arguments into an + * `OperationInput` and delegates to the shared `ServiceCore`, and carries no logic of its own. + * + * ONE facade, not two — Node is async-only, so the sync/async split the Python witness renders (and + * the AST-parity gate that keeps the two honest) has nothing to correspond to here. + */ + +import type {Paginator} from '@dexpace/core'; +import {PET_SCHEMA} from '../models.js'; +import type {Pet, PetEvent, PetPatch} from '../models.js'; +import {NO_INPUT} from '../operation.js'; +import type {CallOptions, ServiceCore} from '../service-core.js'; +import { + PET_EVENT_MAPPER, + PET_PAGE_STRATEGY, + jsonBody, + petPatchToWire, +} from '../support.js'; +import * as operations from './operations.js'; + +/** The petstore client — a projection over `ServiceCore`. */ +export class PetStoreClient { + readonly #core: ServiceCore; + + constructor(core: ServiceCore) { + this.#core = core; + } + + /** `GET /pets/{pet_id}` — Fetch one pet by id. */ + getPet(petId: string, call: CallOptions = {}): Promise { + return this.#core.execute( + operations.GET_PET, + {pathParams: {pet_id: petId}}, + {...call, responseType: {schema: PET_SCHEMA, typeName: 'Pet'}}, + ); + } + + /** `GET /pets` — List pets, paginating by opaque cursor. */ + listPets( + paging: CallOptions & {maxPages?: number | undefined} = {}, + ): Paginator { + return this.#core.paginate(operations.LIST_PETS, NO_INPUT, { + ...paging, + strategy: PET_PAGE_STRATEGY, + }); + } + + /** `PATCH /pets/{pet_id}` — Apply a merge-patch update to a pet. */ + updatePet( + petId: string, + patch: PetPatch, + call: CallOptions = {}, + ): Promise { + return this.#core.execute( + operations.UPDATE_PET, + { + pathParams: {pet_id: petId}, + body: jsonBody(petPatchToWire(patch), 'application/merge-patch+json'), + }, + {...call, responseType: {schema: PET_SCHEMA, typeName: 'Pet'}}, + ); + } + + /** `GET /pets/events` — Stream pet lifecycle events over Server-Sent Events. */ + watchPets(streaming: CallOptions = {}): AsyncIterable { + return this.#core.events(operations.WATCH_PETS, NO_INPUT, { + ...streaming, + mapper: PET_EVENT_MAPPER, + }); + } + + /** Releases whatever the executor owns; a borrowed runtime is left alone. */ + close(): Promise { + return this.#core.close(); + } +} diff --git a/examples/petstore/src/_generated/operations.ts b/examples/petstore/src/_generated/operations.ts new file mode 100644 index 0000000..434b018 --- /dev/null +++ b/examples/petstore/src/_generated/operations.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/src/_generated/operations.ts +/** + * Operation table for the petstore canary — GENERATED; do not edit. + * + * Rendered from `examples/petstore/spec/petstore.openapi.json` by + * `examples/petstore/generate.mjs`. Pure data: one frozen `Operation` per `operationId`, in + * id-sorted order. Re-render with `node examples/petstore/generate.mjs`. + */ + +import {createAuthDescriptor, createAuthRequirement} from '@dexpace/core'; +import type {Operation} from '../operation.js'; + +const GET_PET_AUTH = createAuthDescriptor([ + createAuthRequirement('OAUTH2', ['pets:read']), +]); + +/** `GET /pets/{pet_id}` — Fetch one pet by id. */ +export const GET_PET: Operation = Object.freeze({ + name: 'get_pet', + method: 'GET', + pathTemplate: '/pets/{pet_id}', + auth: GET_PET_AUTH, +}); + +/** `GET /pets` — List pets, paginating by opaque cursor. */ +export const LIST_PETS: Operation = Object.freeze({ + name: 'list_pets', + method: 'GET', + pathTemplate: '/pets', +}); + +/** `PATCH /pets/{pet_id}` — Apply a merge-patch update to a pet. */ +export const UPDATE_PET: Operation = Object.freeze({ + name: 'update_pet', + method: 'PATCH', + pathTemplate: '/pets/{pet_id}', +}); + +/** `GET /pets/events` — Stream pet lifecycle events over Server-Sent Events. */ +export const WATCH_PETS: Operation = Object.freeze({ + name: 'watch_pets', + method: 'GET', + pathTemplate: '/pets/events', +}); diff --git a/examples/petstore/src/errors.ts b/examples/petstore/src/errors.ts new file mode 100644 index 0000000..a3d7e5d --- /dev/null +++ b/examples/petstore/src/errors.ts @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/src/errors.ts +/** + * The typed error taxonomy for the petstore canary, and the declarative status-to-error map the + * executor consults. + * + * **This whole file is finding 2.** `@dexpace/core` produces exactly one class for a failure + * status: `decodeSuccessResponse` calls `toHttpError`, which returns `HttpStatusError` and nothing + * else. A service SDK that wants `PetNotFoundError` for a 404 therefore has to catch that one + * class and re-map it by status code — which is what {@link remapStatusError} does, and what every + * generated SDK would otherwise reimplement. + * + * The re-map is lossy in one respect worth recording: `toHttpError` has already drained and closed + * the response by the time the mapping runs, so the mapped error is built from the buffered + * `HttpStatusError`, never from the live response. That is fine here — the buffered copy carries + * the status, the media type and a bounded body — but it means a service error class can never see + * anything `HttpStatusError` did not keep. + */ +import { + DexpaceError, + HttpStatusError, + IoError, + TransportFailureError, +} from '@dexpace/core'; + +/** Base class for every typed petstore response error. */ +export class PetStoreError extends DexpaceError { + /** The response status that produced this error. */ + readonly status: number; + /** A bounded, non-consuming preview of the error body; `null` when there was none. */ + readonly preview: string | null; + + constructor(cause: HttpStatusError) { + super(`petstore: HTTP ${String(cause.status)}`, {cause}); + this.status = cause.status; + this.preview = cause.preview(); + } +} + +/** Raised for a 404 — no pet matched the requested id. */ +export class PetNotFoundError extends PetStoreError {} + +/** + * What a status maps to: a class constructible from the `HttpStatusError` core already produced. + * + * Typed against `DexpaceError` rather than `PetStoreError` so {@link createStatusErrorMap}'s + * validation is a real check on a caller-supplied class rather than a restatement of the parameter + * type. + */ +export type StatusErrorConstructor = new ( + cause: HttpStatusError, +) => DexpaceError; + +/** A declarative status-to-error table: the Node shape of Python's `StatusErrorMap`. */ +export interface StatusErrorMap { + /** Exact status matches, most specific. */ + readonly byStatus: ReadonlyMap; + /** Applied to any 4xx/5xx the table does not name. */ + readonly fallback: StatusErrorConstructor; +} + +/** + * Reject a mapped class that sits on the TRANSPORT branch of the error tree. + * + * Python enforces the equivalent rule (`HttpResponseError`, never `OSError`) so an + * `except OSError:` site cannot start catching service errors. The Node tree is + * `DexpaceError` -> {`HttpStatusError`, `IoError`, `TransportFailureError`, ...}, and single + * inheritance means a class cannot be on both branches — but nothing stops a caller mapping a 404 + * to a subclass of `IoError`, which is exactly what the rule forbids and what this rejects. + */ +function assertResponseBranch( + ctor: StatusErrorConstructor, + label: string, +): void { + if ( + ctor.prototype instanceof IoError || + ctor.prototype instanceof TransportFailureError + ) { + throw new TypeError( + `${label} is on the transport branch of the error tree; a mapped status error must not be`, + ); + } + if (!(ctor.prototype instanceof DexpaceError)) { + throw new TypeError(`${label} must extend DexpaceError`); + } +} + +/** + * Build a validated {@link StatusErrorMap}. + * + * Validation runs at construction, not per response: a misconfigured table is a programmer error + * and should surface where the table is written, not on the one production request that happens to + * receive the status it got wrong. + */ +export function createStatusErrorMap(init: { + readonly byStatus?: + Readonly> | undefined; + readonly fallback: StatusErrorConstructor; +}): StatusErrorMap { + assertResponseBranch(init.fallback, 'the fallback error class'); + const byStatus = new Map(); + for (const [key, ctor] of Object.entries(init.byStatus ?? {})) { + assertResponseBranch(ctor, `the error class mapped to status ${key}`); + byStatus.set(Number(key), ctor); + } + return Object.freeze({byStatus, fallback: init.fallback}); +} + +/** + * Re-map an `HttpStatusError` through the table; anything else passes through untouched. + * + * `unknown` in, `unknown` out, so a call site can use it directly in a `catch` without narrowing + * first — and so a transport failure, a `DeserializationError`, or an `AuthResolutionError` reach + * the caller as themselves rather than being laundered into a service error. + */ +export function remapStatusError( + error: unknown, + map: StatusErrorMap | undefined, +): unknown { + if (map === undefined || !(error instanceof HttpStatusError)) return error; + const ctor = map.byStatus.get(error.status) ?? map.fallback; + return new ctor(error); +} + +/** The petstore's own table: a 404 is a `PetNotFoundError`, everything else a `PetStoreError`. */ +export const PETSTORE_ERRORS: StatusErrorMap = createStatusErrorMap({ + byStatus: {404: PetNotFoundError}, + fallback: PetStoreError, +}); diff --git a/examples/petstore/src/fake-transport.ts b/examples/petstore/src/fake-transport.ts new file mode 100644 index 0000000..08af2a0 --- /dev/null +++ b/examples/petstore/src/fake-transport.ts @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/src/fake-transport.ts +/** + * A local in-memory `Transport` for the canary. + * + * **This file is finding 5's evidence.** `@dexpace/core` already ships `FakeTransport` and + * `countingResponse` at `packages/core/src/testing/fake-transport.ts`, and neither is re-exported + * from the package entry point. Reaching them means deep-importing `packages/core/src/`, while the + * rest of the example resolves `@dexpace/core` to `packages/core/dist/` — two copies of core in one + * process, two `HttpStatusError` classes, and every `instanceof` across the boundary silently + * false. So the example writes its own, exactly as a real consumer would have to. + * + * It is not a hardship: the whole thing is a scripted list and one `send`. What it costs is that + * the fake is unshared, so nothing about it is certified by core's own suite. + */ +import {Headers, Protocol, Request, Response, Status} from '@dexpace/core'; +import type {Body, RequestOptions, Transport} from '@dexpace/core'; + +/** One scripted reply. */ +export interface ScriptedReply { + readonly status: number; + /** The response body as text; omitted means a body-less response. */ + readonly body?: string | undefined; + readonly headers?: Readonly> | undefined; +} + +/** One recorded send. */ +export interface RecordedCall { + readonly request: Request; + readonly options: RequestOptions | undefined; + readonly signal: AbortSignal | undefined; +} + +const TEXT_ENCODER = new TextEncoder(); + +function bodyStream(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(TEXT_ENCODER.encode(text)); + controller.close(); + }, + }); +} + +/** + * Drain a request body into bytes. + * + * `Body` exposes `writeTo(sink)` and no byte accessor, so reading what a facade actually sent means + * supplying a sink. Used by the merge-patch assertion, which has to see the encoded document. + */ +export async function readBodyBytes(body: Body): Promise { + const chunks: Uint8Array[] = []; + await body.writeTo( + new WritableStream({ + write(chunk: Uint8Array): void { + chunks.push(chunk); + }, + }), + ); + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +/** + * A scripted transport. Entries are served in order; once exhausted the last entry repeats, and a + * FRESH response — with a fresh body stream — is built per call, so a repeated entry is safe to + * consume more than once. + */ +export class LocalFakeTransport implements Transport { + readonly #script: readonly ScriptedReply[]; + readonly #calls: RecordedCall[] = []; + #closeCount = 0; + + constructor(script: readonly ScriptedReply[]) { + if (script.length === 0) { + throw new TypeError( + 'LocalFakeTransport needs at least one scripted reply', + ); + } + this.#script = [...script]; + } + + /** Every send this double served, in order. */ + get calls(): readonly RecordedCall[] { + return this.#calls; + } + + /** How many times `close()` was called — the owned/borrowed assertion reads this. */ + get closeCount(): number { + return this.#closeCount; + } + + send( + request: Request, + options?: RequestOptions, + signal?: AbortSignal, + ): Promise { + const reply = + this.#script[Math.min(this.#calls.length, this.#script.length - 1)]; + this.#calls.push({request, options, signal}); + if (reply === undefined) { + return Promise.reject(new Error('scripted reply index out of range')); + } + const headers = Headers.newBuilder(); + for (const [name, value] of Object.entries(reply.headers ?? {})) { + headers.setInbound(name, value); + } + return Promise.resolve( + Response.newBuilder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(reply.status)) + .headers(headers.build()) + .body(reply.body === undefined ? null : bodyStream(reply.body)) + .build(), + ); + } + + close(): Promise { + this.#closeCount += 1; + return Promise.resolve(); + } +} diff --git a/examples/petstore/src/models.ts b/examples/petstore/src/models.ts new file mode 100644 index 0000000..e66b3bc --- /dev/null +++ b/examples/petstore/src/models.ts @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/src/models.ts +/** + * Hand-written models for the petstore canary, plus a `Schema` witness for each. + * + * Models are deliberately NOT generated — the generator reads `operationId`, the method, the path + * template and the `x-dexpace` extension, and nothing else (the issue's non-goals say so). What is + * written here is what a real generated SDK ships beside its facades. + * + * Two things are worth noticing while reading, because both are findings rather than style: + * + * 1. **Every model needs a decode witness AND a hand-written encode projection.** `Schema` is a + * one-way seam: `parse(input: unknown): T`. There is no encode witness anywhere in core, so a + * model whose field names differ from its wire names — `petId` vs `pet_id`, `weightKg` vs + * `weight_kg` — has to carry a `toWire` function written by hand. See `support.ts`. + * + * 2. **`PetPatch`'s fields are `Tristate`, and that is the whole point of the merge-patch case.** + * Absent means "leave unchanged", Null means "clear", Present means "set". `@dexpace/codec-json` + * carries both halves: `tristateReplacer` on the encode side (installed by `jsonSerde()` by + * default) and `tristateObject` on the decode side. + */ +import {absent, type Schema, type Tristate} from '@dexpace/core'; +import {tristateObject} from '@dexpace/codec-json'; + +/** A pet as the service returns it. */ +export interface Pet { + readonly id: string; + readonly name: string; + /** `null` when the pet carries no tag; the wire field is nullable, not omissible. */ + readonly tag: string | null; +} + +/** One pet lifecycle event, delivered over SSE. Wire field `pet_id` becomes `petId` here. */ +export interface PetEvent { + readonly kind: string; + readonly petId: string; +} + +/** + * A merge-patch update body. + * + * Every field defaults to Absent through {@link emptyPetPatch}, so + * `{...emptyPetPatch(), name: present('Rex')}` sends only `name` and leaves the rest untouched. + */ +export interface PetPatch { + readonly name: Tristate; + readonly tag: Tristate; + readonly weightKg: Tristate; +} + +/** A patch with every field Absent — the identity element a caller spreads over. */ +export function emptyPetPatch(): PetPatch { + return {name: absent(), tag: absent(), weightKg: absent()}; +} + +/** + * Narrow an already-parsed wire value to a JSON object. + * + * An array is `typeof 'object'` and non-null, so the array check is not decoration: without it a + * `[1, 2, 3]` arriving where a DTO was expected is silently reshaped into `{'0': 1, ...}` rather + * than rejected. + */ +function asObject( + input: unknown, + label: string, +): Readonly> { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + throw new TypeError(`${label}: expected a JSON object`); + } + return input as Readonly>; +} + +function requireString( + record: Readonly>, + key: string, + label: string, +): string { + const value = record[key]; + if (typeof value !== 'string') { + throw new TypeError(`${label}: "${key}" must be a string`); + } + return value; +} + +function nullableString( + record: Readonly>, + key: string, + label: string, +): string | null { + const value = record[key]; + if (value === undefined || value === null) return null; + if (typeof value !== 'string') { + throw new TypeError(`${label}: "${key}" must be a string or null`); + } + return value; +} + +/** Decode witness for {@link Pet}. */ +export const PET_SCHEMA: Schema = Object.freeze({ + parse(input: unknown): Pet { + const record = asObject(input, 'Pet'); + return { + id: requireString(record, 'id', 'Pet'), + name: requireString(record, 'name', 'Pet'), + tag: nullableString(record, 'tag', 'Pet'), + }; + }, +}); + +/** Decode witness for {@link PetEvent}; renames the wire's `pet_id`. */ +export const PET_EVENT_SCHEMA: Schema = Object.freeze({ + parse(input: unknown): PetEvent { + const record = asObject(input, 'PetEvent'); + return { + kind: requireString(record, 'kind', 'PetEvent'), + petId: requireString(record, 'pet_id', 'PetEvent'), + }; + }, +}); + +const STRING_SCHEMA: Schema = Object.freeze({ + parse(input: unknown): string { + if (typeof input !== 'string') throw new TypeError('expected a string'); + return input; + }, +}); + +const NUMBER_SCHEMA: Schema = Object.freeze({ + parse(input: unknown): number { + if (typeof input !== 'number') throw new TypeError('expected a number'); + return input; + }, +}); + +/** + * The wire-shaped half of {@link PetPatch}: `tristateObject` keys by WIRE name, so the rename to + * `weightKg` happens in {@link PET_PATCH_SCHEMA}'s own `parse` and not in the combinator. + */ +const PET_PATCH_WIRE_SCHEMA = tristateObject({ + name: STRING_SCHEMA, + tag: STRING_SCHEMA, + weight_kg: NUMBER_SCHEMA, +}); + +/** + * Decode witness for {@link PetPatch}. + * + * Only the canary uses it — a service does not normally decode its own request bodies. It is here + * so the merge-patch round trip is asserted on a `PetPatch`, not on a raw JSON document: an + * assertion over the document alone proves the encoder emitted the right bytes but nothing about + * the three states surviving a full round trip. + */ +export const PET_PATCH_SCHEMA: Schema = Object.freeze({ + parse(input: unknown): PetPatch { + const wire = PET_PATCH_WIRE_SCHEMA.parse(asObject(input, 'PetPatch')); + return {name: wire.name, tag: wire.tag, weightKg: wire.weight_kg}; + }, +}); diff --git a/examples/petstore/src/operation.ts b/examples/petstore/src/operation.ts new file mode 100644 index 0000000..c32e8ee --- /dev/null +++ b/examples/petstore/src/operation.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/src/operation.ts +/** + * The static/per-call split core does not have. + * + * **This file is finding 3.** `@dexpace/core`'s `OperationDescriptor` carries `method`, + * `pathTemplate`, `pathParams`, `query`, `headers` and `body` in one interface. Four of those six + * change on every call, so a descriptor cannot be the module-level constant a generated operation + * table needs — a generator using it directly would have to build a fresh object per call and + * would have nowhere to hang an operation's declared auth requirement. + * + * Splitting it costs nothing structurally: `Operation & OperationInput` is `OperationDescriptor` + * plus a `name` and an `auth` slot, and {@link assemble} is the two-line merge that proves it. The + * split is additive, so lifting it into core would not break the published `OperationDescriptor` — + * `OperationDescriptor` can stay exactly as it is and be re-expressed as the union of the two + * halves. + */ +import type { + AuthDescriptor, + Body, + Headers, + Method, + OperationDescriptor, + QueryParams, +} from '@dexpace/core'; + +/** + * The half that is fixed when the SDK is generated: everything the frozen OpenAPI document knows. + * + * A generated operation table is a module of these, one per `operationId`, each frozen once at + * module load and reused by every call. + */ +export interface Operation { + /** The `operationId` from the document; carried for diagnostics and tracing, never sent. */ + readonly name: string; + /** The HTTP method. */ + readonly method: Method; + /** The path template, `{name}` placeholders intact. */ + readonly pathTemplate: string; + /** + * The operation's declared auth requirement — AUTH-4's `operation` tier. + * + * Core has the slot (`AuthTiers.operation`) and no source for it; this field is that source. See + * FINDINGS.md, finding 4, for what the executor then has to do with it. + */ + readonly auth?: AuthDescriptor | undefined; +} + +/** + * The half that changes per call: exactly `OperationDescriptor` minus `method` and `pathTemplate`. + * + * `?: T | undefined` rather than `?: T` throughout, because `exactOptionalPropertyTypes` is on and + * a generated facade assigns every field including the ones it has nothing for. + */ +export interface OperationInput { + /** Values for the path template's `{name}` placeholders. */ + readonly pathParams?: Readonly> | undefined; + /** Query parameters appended after any the base URL already carries. */ + readonly query?: QueryParams | undefined; + /** Headers carried onto the assembled request as-is. */ + readonly headers?: Headers | undefined; + /** The already-encoded request body. */ + readonly body?: Body | undefined; +} + +/** An empty input — a frozen singleton, since a facade method with no arguments needs one per call. */ +export const NO_INPUT: OperationInput = Object.freeze({}); + +/** + * Merge the two halves back into the descriptor `buildRequest` takes. + * + * The whole of core's assembly seam is reachable through this one line, which is the point: the + * split is a re-shaping of the same data, not a parallel model. + */ +export function assemble( + operation: Operation, + input: OperationInput, +): OperationDescriptor { + return { + method: operation.method, + pathTemplate: operation.pathTemplate, + ...input, + }; +} diff --git a/examples/petstore/src/service-core.ts b/examples/petstore/src/service-core.ts new file mode 100644 index 0000000..0f2b294 --- /dev/null +++ b/examples/petstore/src/service-core.ts @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/src/service-core.ts +/** + * The executor tier a generated client delegates to. + * + * **This file is finding 1 — it is the payload of the spike.** Nothing in `@dexpace/core` exports + * an object with `execute` / `executeRequest` / `paginate` / `events` plus ownership-aware close, + * so every service SDK would write this. It turns out to be thin, and the reason it is thin is + * worth stating: `Runtime` implements `Transport`, so the pipeline drops straight into `Paginator`, + * into `sseStreamFrom`, and into a plain `send()` with no adapter between them. + * + * What is NOT thin, and is the actual finding, is everything the executor has to decide that core + * does not: + * + * - **Auth tier precedence** ({@link requestOptions}). Core's `AuthTiers` is + * `perCall ?? operation ?? client`, resolved inside `authStep`. Only `perCall` and `client` have + * a source; `RequestOptions.auth` fills `perCall` and the step's own settings fill `client`. So + * an operation's declared descriptor has to be folded into the `perCall` slot HERE, and the + * `call.auth ?? operation.auth` precedence — the top two-thirds of AUTH-4's chain — is + * reimplemented in this file. See FINDINGS.md, finding 4. + * - **Status-to-error mapping** ({@link ServiceCore.execute}). Core produces one class; the map is + * applied on the way out. See finding 2. + * - **Which failures reach a stream** ({@link ServiceCore.events}). `sseStreamFrom` does not look + * at the status, so a 404 would be parsed as an event stream unless the executor checks first. + * + * And one thing that is genuinely free: **ownership**. `Runtime.close()` is a documented no-op that + * never touches its terminal transport (PIPE-27), so "borrowed" needs no bookkeeping — the executor + * closes only what it built itself. + */ +import { + DexpaceError, + Paginator, + RequestOptions, + buildRequest, + decodeSuccessResponse, + sseStreamFrom, + standardResilience, + toHttpError, + typedSseStream, +} from '@dexpace/core'; +import type { + AuthDescriptor, + DecodeTarget, + PaginationStrategy, + Request, + Response, + Runtime, + Serde, + SseMapper, + SseStream, + StandardResilienceOptions, + Transport, +} from '@dexpace/core'; +import {assemble, type Operation, type OperationInput} from './operation.js'; +import {remapStatusError, type StatusErrorMap} from './errors.js'; + +/** Per-call overrides every entry point accepts. */ +export interface CallOptions { + /** AUTH-4's `perCall` tier. Beats the operation's own descriptor, which beats the client default. */ + readonly auth?: AuthDescriptor | undefined; + /** Per-call timeout, threaded through `RequestOptions`. */ + readonly timeoutMs?: number | undefined; + /** Per-call retry cap, threaded through `RequestOptions`. */ + readonly maxRetries?: number | undefined; + /** Cancellation for this call. */ + readonly signal?: AbortSignal | undefined; +} + +/** {@link ServiceCore.execute} and {@link ServiceCore.executeRequest}: what to decode into. */ +export interface ExecuteOptions extends CallOptions { + /** The runtime type witness plus its diagnostic label. */ + readonly responseType: DecodeTarget; +} + +/** {@link ServiceCore.paginate}: which strategy walks the collection, and how far. */ +export interface PaginateOptions extends CallOptions { + readonly strategy: PaginationStrategy; + /** Maximum page exchanges; unbounded when omitted. */ + readonly maxPages?: number | undefined; +} + +/** {@link ServiceCore.events}: how each SSE frame becomes a model. */ +export interface EventsOptions extends CallOptions { + readonly mapper: SseMapper; +} + +/** Everything a {@link ServiceCore} is built from. */ +export interface ServiceCoreInit { + /** The absolute base URL every operation is projected onto. */ + readonly baseUrl: string | URL; + /** + * A terminal transport the core OWNS: it is wrapped in `standardResilience()` and closed by + * {@link ServiceCore.close}. Mutually exclusive with `runtime`. + */ + readonly transport?: Transport | undefined; + /** + * An already-assembled pipeline the core BORROWS: used as-is and never closed. Mutually exclusive + * with `transport`. + */ + readonly runtime?: Runtime | undefined; + /** Pillar overrides, applied only on the owned-transport path where the preset is built here. */ + readonly resilience?: StandardResilienceOptions | undefined; + /** The wire codec. Required: core owns none (SEAM-1), so the executor has to be told. */ + readonly serde: Serde; + /** The declarative status-to-error table; omitted leaves `HttpStatusError` unmapped. */ + readonly errors?: StatusErrorMap | undefined; +} + +/** + * Fold the operation tier into `RequestOptions`. + * + * `call.auth ?? operation?.auth` is AUTH-4's precedence, minus its last step — the `client` tier is + * the one core still resolves itself, from the `authStep` settings fixed at pipeline construction. + * + * Returning `undefined` when there is nothing to say matters: an empty `RequestOptions` would still + * occupy the `perCall` slot as "no descriptor", and the point of the chain is that an ABSENT tier + * falls through while a PRESENT one does not. + */ +function requestOptions( + operation: Operation | undefined, + call: CallOptions, +): RequestOptions | undefined { + const auth = call.auth ?? operation?.auth; + if ( + auth === undefined && + call.timeoutMs === undefined && + call.maxRetries === undefined + ) { + return undefined; + } + return RequestOptions.newBuilder() + .auth(auth) + .timeoutMs(call.timeoutMs) + .maxRetries(call.maxRetries) + .build(); +} + +/** The shared executor every generated facade method delegates to. */ +export class ServiceCore { + readonly #baseUrl: string | URL; + readonly #runtime: Runtime; + readonly #ownedTransport: Transport | undefined; + readonly #serde: Serde; + readonly #errors: StatusErrorMap | undefined; + + constructor(init: ServiceCoreInit) { + const {transport, runtime} = init; + this.#baseUrl = init.baseUrl; + this.#serde = init.serde; + this.#errors = init.errors; + if (transport !== undefined && runtime === undefined) { + this.#runtime = standardResilience(transport, init.resilience); + this.#ownedTransport = transport; + } else if (runtime !== undefined && transport === undefined) { + this.#runtime = runtime; + this.#ownedTransport = undefined; + } else { + throw new TypeError( + 'ServiceCore takes exactly one of `transport` (owned) or `runtime` (borrowed)', + ); + } + } + + /** The pipeline every call goes through — owned or borrowed alike. */ + get runtime(): Runtime { + return this.#runtime; + } + + /** Assemble, send, and decode a 2xx into `T`; map a failure status through the error table. */ + async execute( + operation: Operation, + input: OperationInput, + call: ExecuteOptions, + ): Promise { + const response = await this.dispatch(operation, input, call); + return this.#decode(response, call.responseType); + } + + /** The same, for a request a caller already built — an escape hatch out of the operation table. */ + async executeRequest( + request: Request, + call: ExecuteOptions, + ): Promise { + const response = await this.#runtime.send( + request, + requestOptions(undefined, call), + call.signal, + ); + return this.#decode(response, call.responseType); + } + + /** Assemble and send, with no decode: the raw response, still open, still the caller's to close. */ + dispatch( + operation: Operation, + input: OperationInput, + call: CallOptions = {}, + ): Promise { + const request = buildRequest(this.#baseUrl, assemble(operation, input)); + return this.#runtime.send( + request, + requestOptions(operation, call), + call.signal, + ); + } + + /** + * A lazy walk over a paginated collection. + * + * Nothing is sent until the returned paginator is iterated (PAGE-6), so this method is + * synchronous and the generated facade needs no `await`. + */ + paginate( + operation: Operation, + input: OperationInput, + paging: PaginateOptions, + ): Paginator { + return new Paginator({ + transport: this.#runtime, + initialRequest: buildRequest(this.#baseUrl, assemble(operation, input)), + strategy: paging.strategy, + maxPages: paging.maxPages, + options: requestOptions(operation, paging), + signal: paging.signal, + }); + } + + /** + * A lazy stream of mapped SSE events. + * + * Lazy for the same reason `paginate` is: the request is sent on the first pull, so the facade + * method stays synchronous. The status check runs before the parser ever sees a byte. + */ + events( + operation: Operation, + input: OperationInput, + streaming: EventsOptions, + ): AsyncIterable { + const open = async (): Promise => { + const response = await this.dispatch(operation, input, streaming); + await this.#failOnErrorStatus(response); + return sseStreamFrom(response, {signal: streaming.signal}); + }; + return { + async *[Symbol.asyncIterator](): AsyncGenerator { + yield* typedSseStream(await open(), streaming.mapper); + }, + }; + } + + /** + * Release what this core created, and nothing else. + * + * An OWNED transport is closed. A BORROWED runtime is left alone — and so is the transport + * underneath it, which `Runtime.close()` would not have touched anyway (PIPE-27). + */ + async close(): Promise { + if (this.#ownedTransport !== undefined) { + await this.#ownedTransport.close(); + } + } + + async #decode(response: Response, target: DecodeTarget): Promise { + try { + return await decodeSuccessResponse( + response, + this.#serde.deserializer, + target, + ); + } catch (error: unknown) { + throw remapStatusError(error, this.#errors); + } + } + + /** + * Turn a non-2xx into the mapped typed error before any streaming reader is built. + * + * `toHttpError` covers 4xx/5xx and returns `null` for anything else, so an unfollowed 3xx or a + * 1xx lands in the second branch — closed, then reported as itself rather than being handed to + * an SSE parser that would read it as a malformed event stream. + */ + async #failOnErrorStatus(response: Response): Promise { + if (response.status.isSuccess) return; + const failure = await toHttpError(response); + if (failure !== null) throw remapStatusError(failure, this.#errors); + await response.close(); + throw new DexpaceError( + `response status ${String(response.status.code)} is neither a success nor an error status`, + ); + } +} diff --git a/examples/petstore/src/support.ts b/examples/petstore/src/support.ts new file mode 100644 index 0000000..c65bd14 --- /dev/null +++ b/examples/petstore/src/support.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +// examples/petstore/src/support.ts +/** + * The hand-authored runtime binders the generated facade names. + * + * A generated SDK ships a small shim beside its facades; this is that shim. The facade holds no + * logic — it binds arguments and names a symbol from here. + * + * What this file measures: + * + * - **`jsonBody` is thin, and `petPatchToWire` is not.** Core's `serdeBody(value, serde, mediaType)` + * already does encode-plus-wrap, so the body binder is one line. The projection beside it is the + * cost: `Schema` is decode-only, so a model whose field names differ from its wire names needs + * a hand-written encoder per model. See FINDINGS.md, finding 6. + * - **`PET_PAGE_STRATEGY` needed no decorator.** Python wraps the certified `CursorStrategy` in a + * `_PetPageStrategy` that re-decodes each raw item, because its strategy is configured by wire + * FIELD NAMES and hands back raw documents. Node's `cursorStrategy` takes an `extract` callback + * instead, so the decode happens inside it and the wrapper class disappears. See finding 5. + * - **`PET_EVENT_MAPPER` is a plain function.** `SseMapper` is `(eventName, joinedData) => + * MapperOutcome`, and `MAPPER_DONE` is the `[DONE]` sentinel's answer. + */ +import { + MAPPER_DONE, + cursorStrategy, + mapperValue, + serdeBody, + type Body, + type MapperOutcome, + type PaginationStrategy, + type Response, + type Schema, + type Serde, + type SseMapper, +} from '@dexpace/core'; +import {jsonSerde} from '@dexpace/codec-json'; +import { + PET_EVENT_SCHEMA, + PET_SCHEMA, + type Pet, + type PetEvent, + type PetPatch, +} from './models.js'; + +/** + * The one shared codec instance every binder reuses. + * + * `jsonSerde()` freezes its bundle and holds no per-call state (SERDE-29), so a single instance + * serves every model and every concurrent call. The Tristate wiring is on by default, which is what + * makes the merge-patch body's three states survive the encode. + */ +const SERDE: Serde = jsonSerde(); + +const TEXT_ENCODER = new TextEncoder(); + +/** + * Encode an already-projected wire document into a request body. + * + * @param document - the wire-shaped value, not the model. The projection is the caller's job + * because core carries no encode witness — see {@link petPatchToWire}. + * @param mediaType - overrides the serde's own `application/json`; the petstore's PATCH operation + * declares `application/merge-patch+json` in the frozen document, and the generator passes it + * through. + */ +export function jsonBody(document: unknown, mediaType?: string): Body { + return serdeBody(document, SERDE, mediaType); +} + +/** + * Project a {@link PetPatch} onto its wire shape. + * + * Only the KEYS change here. The `Tristate` values are passed through untouched and resolved by + * `jsonSerde()`'s replacer at encode time — Absent drops the key, Null writes `null`, Present + * writes the value. Resolving them here instead would collapse Absent and Null before the replacer + * ever saw them, which is precisely the interop bug `Tristate` exists to prevent. + */ +export function petPatchToWire( + patch: PetPatch, +): Readonly> { + return {name: patch.name, tag: patch.tag, weight_kg: patch.weightKg}; +} + +/** One page of the `/pets` collection, as the frozen document describes it. */ +interface PetPage { + readonly items: readonly Pet[]; + readonly cursor: string | null; +} + +const PET_PAGE_SCHEMA: Schema = Object.freeze({ + parse(input: unknown): PetPage { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + throw new TypeError('pet page: expected a JSON object'); + } + const record = input as Readonly>; + const rawItems = record.data; + if (!Array.isArray(rawItems)) { + throw new TypeError('pet page: "data" must be an array'); + } + // `Array.isArray` narrows an `unknown` to `any[]`, and `any` would then flow into + // `PET_SCHEMA.parse` unchecked. Re-typing to `unknown[]` keeps the decode honest. + const data = rawItems as readonly unknown[]; + const next = record.next_cursor; + if (next !== null && next !== undefined && typeof next !== 'string') { + throw new TypeError('pet page: "next_cursor" must be a string or null'); + } + return { + items: data.map(item => PET_SCHEMA.parse(item)), + cursor: next ?? null, + }; + }, +}); + +/** + * Pagination for `listPets`: cursor continuation over a `data` array, splicing `?cursor=` onto the + * request that produced the page. + * + * `extract` reads the body exactly once and never closes the response — both are the strategy + * contract's obligations, and the paginator closes each page itself. + */ +export const PET_PAGE_STRATEGY: PaginationStrategy = cursorStrategy({ + parameterName: 'cursor', + extract: async (response: Response) => { + const page = SERDE.deserializer.deserialize( + await response.bytes(), + PET_PAGE_SCHEMA, + 'PetPage', + ); + return {items: page.items, cursor: page.cursor}; + }, +}); + +/** The sentinel `watchPets` ends on, spelled exactly as the frozen document's fixture sends it. */ +const DONE_SENTINEL = '[DONE]'; + +/** + * SSE mapping for `watchPets`: `[DONE]` ends the stream, every other frame decodes into a + * {@link PetEvent}. + * + * Synchronous by contract — `SseMapper` returns a `MapperOutcome`, not a promise — which is + * why the decode goes through the deserializer's in-memory entry point rather than its streaming + * one. + */ +export const PET_EVENT_MAPPER: SseMapper = ( + eventName: string | undefined, + joinedData: string, +): MapperOutcome => { + if (joinedData === DONE_SENTINEL) return MAPPER_DONE; + return mapperValue( + SERDE.deserializer.deserialize( + TEXT_ENCODER.encode(joinedData), + PET_EVENT_SCHEMA, + 'PetEvent', + ), + ); +}; diff --git a/examples/petstore/tsconfig.json b/examples/petstore/tsconfig.json new file mode 100644 index 0000000..bb43728 --- /dev/null +++ b/examples/petstore/tsconfig.json @@ -0,0 +1,31 @@ +{ + // Standalone and referenced by nothing. `bun run typecheck` names each package's project + // explicitly and this one is deliberately absent from that list, so the example is never a + // gate — check it by hand with `bunx tsc -p examples/petstore/tsconfig.json --noEmit`. + // + // It exists anyway, and is not optional: `eslint.config.js` runs the type-aware tier with + // `projectService: true`, which resolves each `.ts` file against the NEAREST enclosing + // tsconfig. Without this file — or with an `include` that misses a file — `gts lint .` fails + // at the repository root with "was not found by the project service", which is a blocking CI + // step. See FINDINGS.md, finding 7. + // + // `types: ["bun"]` mirrors `tests/tsconfig.json`: the canary and regen suites import + // `bun:test`, and nothing else in the tree supplies those globals. + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "target": "ES2023", + "lib": [ + "ES2023", + "DOM", + "DOM.AsyncIterable" + ], + "types": [ + "bun" + ] + }, + "include": [ + "src/**/*.ts", + "*.test.ts" + ] +}