diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index b210f9c..e1c2efe 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -22,25 +22,46 @@ The contract splits into two fragments, `orders` and `customers`, each a `RouterContract` in its own right: ```ts +import { oc } from "@orpc/contract"; +import { z } from "zod"; + +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; + +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; + +// The unmarked fragment names its tenant on the input; the marked one does not, +// because a caller's identity establishes it there. +const tenanted = z.object({ tenantId: z.string() }); + +const customerView = z.object({ id: z.string(), name: z.string() }); +export type CustomerView = z.infer; + +// Same shape as `orderRef`, deliberately not the same schema: reusing it would +// type a customer id as "which order it was about". +const customerRef = z.object({ id: z.string() }); +export type CustomerRef = z.infer; + const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }; const customersContract = { find: oc - .input(type<{ readonly id: string }>()) - .output(type()) - .errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }), + .input(tenanted.extend({ id: z.string() })) + .output(customerView) + .errors({ NOT_FOUND: { data: customerRef } }), }; export const contract = { @@ -49,8 +70,18 @@ export const contract = { }; ``` -The two fragments are module-private; `contract` is the package's only value -export, and every consumer reaches a fragment through it — +The wire shapes are **zod schemas**, with the view types inferred from them +rather than declared beside them. They are not the entities: `Order`'s fields +are branded (`OrderId`, `Quantity`) and a brand is a compile-time fiction that +does not survive serialization, so the transport speaks its own shape and each +slice's controller is the one place the two are converted. oRPC's `type()` +would say the same thing to the compiler and check nothing at runtime, which +is how `{ quantity: "abc" }` reaches a use case typed `number`; a schema is +what makes the boundary real, and inferring the type from it is what keeps +the checked shape and the compiled one from drifting. + +The two fragments are module-private; `contract` and the view types are the +package's exports, and every consumer reaches a fragment through it — `contract.orders`, `contract.customers`. Each slice lives under `slices//` — a `controller.ts` implementing that diff --git a/docs/examples/order-application.md b/docs/examples/order-application.md index 07ea4f3..020530c 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -221,23 +221,38 @@ the router's exhaustive `mapErrCases`, so adding a domain error without a code stops the router compiling: ```ts +import { oc } from "@orpc/contract"; +import { z } from "zod"; + +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; + +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; + export const orderContract = { orders: { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }, }; ``` +All three are **schemas**, with the wire types inferred from them rather than +declared beside them — one definition, so what a procedure checks and what the +compiler believes cannot drift apart. That is why `zod` is in the list above: +oRPC's `type()` would type the same procedures and validate nothing, and an +input nobody checks arrives typed as whatever the contract claimed. + `order-temporal-contract` declares one workflow and five activities, four errors marked `nonRetryable`; `order-amqp-contract` one exchange, one event and one subscriber queue with a `retry` / dead-letter policy. Their specs diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 8af5230..2f7959e 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -37,18 +37,19 @@ that a client should be able to read without taking the server: ```ts import { authenticated } from "@btravstack/contract"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type<{ readonly id: string }>()), + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(z.object({ id: z.string() })), }; const customersContract = { find: oc - .input(type<{ readonly id: string }>()) - .output(type<{ readonly name: string }>()), + .input(z.object({ id: z.string() })) + .output(z.object({ name: z.string() })), }; export const contract = { diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index b20694f..62c0a98 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -37,26 +37,36 @@ The contract lives in its own package, because a client needs it and needs none of the server: ```ts -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; -export type OrderView = { readonly id: string; readonly quantity: number }; -export type OrderRef = { readonly id: string }; +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; + +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; export const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }; ``` +The shapes are **schemas**, and the types are inferred from them rather than +declared beside them: one definition, so what is checked at the boundary and +what the compiler believes cannot drift. oRPC's `type()` would declare the +same types and validate nothing — `{ quantity: "abc" }` would reach `place` +typed `number`. + ## Step 2 — the router, as a provider `HttpRouter(ordersContract)` is di's own `Provider(port)` on the starter's diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index c5d889c..37dccd3 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -25,25 +25,37 @@ positional form already takes, just smaller — and the root contract is a record of them: ```ts +import { oc } from "@orpc/contract"; +import { z } from "zod"; + +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; + +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; + +const customerView = z.object({ id: z.string(), name: z.string() }); +export type CustomerView = z.infer; + const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }; const customersContract = { find: oc - .input(type<{ readonly id: string }>()) - .output(type()) - .errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }), + .input(z.object({ id: z.string() })) + .output(customerView) + .errors({ NOT_FOUND: { data: orderRef } }), }; export const contract = { @@ -52,9 +64,12 @@ export const contract = { }; ``` -The fragments stay module-private; `contract` is the only export, and every -consumer below reaches a fragment through it — `contract.orders`, -`contract.customers`. +The fragments stay module-private; `contract` and the view types inferred from +its schemas are the only exports, and every consumer below reaches a fragment +through it — `contract.orders`, `contract.customers`. A schema is what a +fragment is made of, not a bare `type()`: it validates what arrives at the +slice, and inferring the view type from it keeps the checked shape and the +compiled one from drifting apart. ## Step 2 — a controller per slice diff --git a/docs/index.md b/docs/index.md index 1ad7649..35ff017 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,20 +40,25 @@ single router through ```ts import { runMain } from "@btravstack/core"; import { HttpModule, HttpRouter } from "@btravstack/http"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; import { P } from "unthrown"; +import { z } from "zod"; import { OrderApplicationModule, PlaceOrder } from "./application.js"; import { OrderPersistenceModule } from "./persistence.js"; // The contract comes first; a client can take it without the server. +// Schemas, not oRPC's `type()`: they check what arrives, not just what compiles. +const orderView = z.object({ id: z.string(), quantity: z.number() }); +const orderRef = z.object({ id: z.string() }); + const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type<{ readonly id: string; readonly quantity: number }>()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type<{ readonly id: string }>() }, - CONFLICT: { data: type<{ readonly id: string }>() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), }; diff --git a/docs/reference/contract.md b/docs/reference/contract.md index 9a3e6ea..b83ff59 100644 --- a/docs/reference/contract.md +++ b/docs/reference/contract.md @@ -44,20 +44,21 @@ procedure (which protects itself): ```ts import { authenticated } from "@btravstack/contract"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type<{ readonly id: string }>()), + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(z.object({ id: z.string() })), }; export const contract = { orders: authenticated(ordersContract), customers: { find: oc - .input(type<{ readonly id: string }>()) - .output(type<{ readonly name: string }>()), + .input(z.object({ id: z.string() })) + .output(z.object({ name: z.string() })), }, }; ``` @@ -77,12 +78,13 @@ import { isAuthenticated, type IsMarked, } from "@btravstack/contract"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; const quote = authenticated( oc - .input(type<{ readonly id: string }>()) - .output(type<{ readonly total: number }>()), + .input(z.object({ id: z.string() })) + .output(z.object({ total: z.number() })), ); export type QuoteIsMarked = IsMarked; // true diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index f28ff87..25437ab 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -19,21 +19,21 @@ to stop. It takes about ten minutes. ::: code-group ```sh [pnpm] -pnpm add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc +pnpm add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc zod ``` ```sh [npm] -npm install @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc +npm install @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc zod ``` ```sh [yarn] -yarn add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc +yarn add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpc zod ``` ::: -Every one of those is a **peer** of `@btravstack/http`, so your application -holds a single copy of each ([why](/explanation/peer-dependencies)). The +Every one of those but `zod` is a **peer** of `@btravstack/http`, so your +application holds a single copy of each ([why](/explanation/peer-dependencies)). The project needs `"type": "module"` in its `package.json` — `main.ts` ends in a top-level `await` — TypeScript in `strict` mode, and Node `>=20`. @@ -74,18 +74,22 @@ procedure, `hello`, with a typed input and output: ```ts // contract.ts -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; export const contract = { hello: oc - .input(type<{ readonly name: string }>()) - .output(type<{ readonly message: string }>()), + .input(z.object({ name: z.string() })) + .output(z.object({ message: z.string() })), }; ``` -`oc` is oRPC's contract builder; `type()` declares a shape without a schema -library. A client can import this file and call the service without the -server's code — which is why it is its own file. +`oc` is oRPC's contract builder, and the schemas are **validated at the +boundary**: a client that posts `{ name: 42 }` is rejected before `hello` runs. +Reach for oRPC's `type()` only where you genuinely trust a shape without +checking it — it validates nothing, so an unchecked input arrives typed as +whatever the contract claimed. A client can import this file and call the +service without the server's code — which is why it is its own file. ## Step 4 — Implement the contract as a router diff --git a/docs/tutorial/second-runtime.md b/docs/tutorial/second-runtime.md index f18b886..0c7a2c0 100644 --- a/docs/tutorial/second-runtime.md +++ b/docs/tutorial/second-runtime.md @@ -36,8 +36,8 @@ yarn add @btravstack/temporal @temporalio/worker @temporalio/activity @temporali `@btravstack/core`, `config`, `di` and `unthrown` are already there from lesson one; the rest are `@btravstack/temporal`'s peers. `zod` is for the -contract — Temporal persists every input and output, so its contract wants a -real schema rather than a `type()` shape. +contract, the same as lesson one's — and it earns its place twice over here, +because Temporal persists every input and output and replays them later. You also need a Temporal service to poll. The [Temporal CLI](https://docs.temporal.io/cli) ships one for development: diff --git a/examples/order-api-contract/package.json b/examples/order-api-contract/package.json index b081090..b56bbb0 100644 --- a/examples/order-api-contract/package.json +++ b/examples/order-api-contract/package.json @@ -15,7 +15,8 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { - "@orpc/contract": "catalog:" + "@orpc/contract": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@btravstack/contract": "workspace:*", diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index 5d05c08..364c887 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -1,16 +1,26 @@ import { authenticated } from "@btravstack/contract"; -import { oc, type } from "@orpc/contract"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; /** * What an order looks like on the wire. Not the entity: `Order`'s fields are * branded (`OrderId`, `Quantity`), and a brand is a compile-time fiction that * does not survive serialization. The transport speaks its own shape, and * the orders slice's controller is the one place the two are converted. + * + * A **schema**, with the type inferred from it rather than declared beside it. + * `type()` — what this contract used before — is oRPC's escape hatch for + * "trust this without validating", so every procedure accepted whatever a + * client sent and `{ quantity: "abc" }` reached the use case typed `number`. + * One definition means the checked shape and the compiled shape cannot drift, + * which is what `order-temporal-contract` and `order-amqp-contract` already do. */ -export type OrderView = { readonly id: string; readonly quantity: number }; +const orderView = z.object({ id: z.string(), quantity: z.number() }); +export type OrderView = z.infer; /** The payload every declared error carries — which order it was about. */ -export type OrderRef = { readonly id: string }; +const orderRef = z.object({ id: z.string() }); +export type OrderRef = z.infer; /** * An **unauthenticated** input names its tenant, because this API serves @@ -27,32 +37,43 @@ export type OrderRef = { readonly id: string }; * contrast is the lesson — where a caller's identity establishes the tenant, * the input has nothing to say about it. */ -export type Tenanted = { readonly tenantId: string }; +const tenanted = z.object({ tenantId: z.string() }); +export type Tenanted = z.infer; /** What a customer looks like on the wire. */ -export type CustomerView = { readonly id: string; readonly name: string }; +const customerView = z.object({ id: z.string(), name: z.string() }); +export type CustomerView = z.infer; + +/** + * What the customers fragment's `NOT_FOUND` carries. The same *shape* as + * `orderRef` and deliberately not the same schema: reusing that one would type + * a customer id as "which order it was about", and the exported type would lie + * to a client about which entity it names. + */ +const customerRef = z.object({ id: z.string() }); +export type CustomerRef = z.infer; /** The orders slice's own fragment — a contract in its own right, so the slice can be served alone. */ const ordersContract = { place: oc - .input(type<{ readonly id: string; readonly quantity: number }>()) - .output(type()) + .input(z.object({ id: z.string(), quantity: z.number() })) + .output(orderView) .errors({ - INVALID_QUANTITY: { data: type() }, - CONFLICT: { data: type() }, + INVALID_QUANTITY: { data: orderRef }, + CONFLICT: { data: orderRef }, }), find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type() } }), + .input(orderRef) + .output(orderView) + .errors({ NOT_FOUND: { data: orderRef } }), }; /** The customers slice's own fragment. Reached as `contract.customers`; a fragment is a contract in its own right, so the slice can be served alone. */ const customersContract = { find: oc - .input(type()) - .output(type()) - .errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }), + .input(tenanted.extend({ id: z.string() })) + .output(customerView) + .errors({ NOT_FOUND: { data: customerRef } }), }; /** diff --git a/examples/order-api-contract/src/index.ts b/examples/order-api-contract/src/index.ts index 2bc7d18..3769bcf 100644 --- a/examples/order-api-contract/src/index.ts +++ b/examples/order-api-contract/src/index.ts @@ -1,5 +1,6 @@ export { contract, + type CustomerRef, type CustomerView, type OrderRef, type OrderView, diff --git a/examples/order-api/src/api.spec.ts b/examples/order-api/src/api.spec.ts index 6f55ff9..da62cc1 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -335,6 +335,44 @@ describe("order-api", () => { ); }); + it("refuses a malformed input before the use case is reached", async ({ + serve, + clientFor, + api, + }) => { + // GIVEN the real composition root and a credentialed caller + const app = serve(api); + const client = await clientFor(app); + + // WHEN a procedure is called with an input the contract's schema rejects, + // past the client's own types + const refused = await client.orders.place({ id: "o-1", quantity: "abc" } as never); + + // THEN oRPC refused it before dispatch. This is the property `type()` + // did not have: it validates nothing, so `"abc"` reached the use case + // typed `number`. `BAD_REQUEST` is undeclared, so it lands on the defect + // channel like any error the contract does not model + expect(refused).toBeDefectWith( + expect.objectContaining({ constructor: ORPCError, code: "BAD_REQUEST", inferable: false }), + ); + }); + + it("never enters the handler for a malformed input", async ({ serve, clientFor, recording }) => { + // GIVEN the real graph, recording every line its logger writes + const client = await clientFor(serve(recording.api)); + + // WHEN a malformed input is sent, past the client's own types + await client.orders.place({ id: "o-rejected", quantity: "abc" } as never); + + // THEN neither the controller nor the interactor wrote a line: oRPC + // refused the input before dispatch, so the handler was never entered. The + // request-scope line still lands, because the unit opened. Asserting on + // the absence of those two rather than on the stored row, because the + // DOMAIN would refuse `"abc"` too — a test that checks nothing was stored + // passes whether or not the contract validates, and pins nothing + expect(recording.lines().map((line) => line.message)).toEqual(["request finished"]); + }); + it("serves the unmarked fragment to a caller presenting nothing", async ({ tenant, serve, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 823e399..637d30d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -431,6 +431,9 @@ importers: '@orpc/contract': specifier: 'catalog:' version: 2.0.0-beta.28(@opentelemetry/api@1.9.1) + zod: + specifier: 'catalog:' + version: 4.4.3 devDependencies: '@btravstack/contract': specifier: workspace:*