Generate a Client
wow-generator reads a service's Manifest and writes one typed client per aggregate: a method per command, the queries of its snapshots and events, its state reads, its error codes and the hops of its waits. The application calls the service only through these clients, so a route or a field the service changes shows up as a compile error, not as a failed request.
Where the Manifest comes from
The service serves its Manifest at GET /wow/manifest, and its build commits the same document as manifest/baseline.json, which check holds to what the service serves (The Manifest). Generate from the baseline: it needs no running service, it is the version that was reviewed, and a change to it is a change to the clients.
pnpm add @ahoo-wang/fetcher @ahoo-wang/fetcher-eventstream @ahoo-wang/wow-client
pnpm add -D @ahoo-wang/wow-generator typescript
pnpm exec wow-generator generate \
--input ../ordering-server/manifest/baseline.json \
--output src/generated/ordering \
--strict--input also takes an http(s) URL, with -H 'Name: value' for the headers it needs. --strict fails a run that logged a warning. One run reads one Manifest; a front end of several services runs the generator once per service, each into a directory of its own. The eShop's web UI does it for its five services:
// Generates the clients of the eShop's five services from their Manifest
// baselines (`examples/eshop/<context>/<context>-server/manifest/baseline.json`,
// which each server's `check` holds to what it serves) into
// src/generated/<context>/, one wow-generator run per service:
//
// pnpm --filter @ahoo-wang/wow-generator... build
// pnpm --filter wow-eshop-web generate
//
// The output is committed byte for byte; src/generated.test.ts fails when it
// differs from what this writes.
import { execFileSync } from 'node:child_process';
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const CONTEXTS = ['catalog', 'basket', 'ordering', 'inventory', 'payment'];
const WEB = fileURLToPath(new URL('..', import.meta.url));
export const ESHOP = join(WEB, '..');
export const CLI = join(WEB, '../../../typescript/wow-generator/dist/cli.js');
/** The Manifest baseline of the service of `context`. */
export function baselineOf(context) {
return join(ESHOP, context, `${context}-server`, 'manifest', 'baseline.json');
}
/** Writes the clients of `context` into `output`. */
export function generate(context, output) {
execFileSync(
process.execPath,
[CLI, 'generate', '-i', baselineOf(context), '-o', output, '--strict', '--quiet'],
{ stdio: 'inherit' },
);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
for (const context of CONTEXTS) {
const output = join(WEB, 'src', 'generated', context);
rmSync(output, { recursive: true, force: true });
generate(context, output);
}
}The CLI reference lists every option and exit code.
What it writes
For each bounded context, a directory named after it:
| File | Holds |
|---|---|
contract.ts | <Context>Contract: the context's name, its context-level error codes and its aggregates, each with its error codes; the <Context>ErrorCode union, and the hops of a wait in it |
manifest.ts | <Context>Manifest: the routes of the context's commands and of its service routes, and the property of the body that names the aggregate of a command whose body does (idProperty), as its Manifest lists them, with no schema. The command clients send by it, so they fetch no Manifest |
client.ts | <Context>Client: the context's CommandClient (commands) and, per aggregate, its command, snapshot, event and state clients; its service routes (routes) when it has any |
routes.ts | <Context>Routes: a method per service route of the context, on ServiceRoutes of @ahoo-wang/wow-client/routes, answering the route's answer, undefined beside it for a route the Manifest marks responseOptional (a null answered as 204 No Content), and void for a route of no answer; written only when the Manifest lists service routes |
types.ts | The commands, events and states of the Manifest's schemas, each named after its class's simple name. A name two classes share, or one the generated modules already bind (a TypeScript global they use such as Promise or Record, a name they import from @ahoo-wang/wow-client such as CommandClient, or one the generator declares such as <Aggregate>Event), is qualified by the class's package: shop.Record is ShopRecord |
<aggregate>/aggregate.ts | The aggregate's descriptor, the state.* fields a query names, and the union of its events |
<aggregate>/commandClient.ts | <Aggregate>CommandClient: a method per command, a …Stream twin of each, and the lifecycle commands the aggregate lists. An internal command ("internal": true, Keep a command internal) has none, nor an entry in manifest.ts, nor a type only it uses: the public port does not serve it |
<aggregate>/queryClient.ts | <Aggregate>SnapshotQueryClient, <Aggregate>EventQueryClient and <Aggregate>StateClient |
index.ts | Everything above, exported again |
.wow-generator.json | The files the run wrote, so a later run deletes the ones it no longer writes |
The eShop ordering's contract. Its error codes are the domain's rejections only: a command that breaks its constraints answers the framework's wow.command.invalid (FrameworkErrorCodes.COMMAND_INVALID) with its violations (Send Commands).
// Code generated by wow-generator. DO NOT EDIT.
import { type Hop, wait } from '@ahoo-wang/wow-client';
import { OrderAggregate } from './order/aggregate.js';
/**
* The bounded context `eshop-ordering`: its name, its error codes and its aggregates, as the Kotlin `EshopOrderingContract` names them. A problem's `code` is one of its `Errors`, or of an aggregate's.
* The eShop ordering: orders from placement to shipment
*/
export const EshopOrderingContract = {
NAME: 'eshop-ordering',
Order: OrderAggregate,
} as const;
/** An error code of `eshop-ordering`, of the context or of one of its aggregates. */
export type EshopOrderingErrorCode = 'eshop-ordering.order.wrong-amount' | 'eshop-ordering.order.wrong-status';
/** The subscriptions of `eshop-ordering` a `handled` hop names. */
export type EshopOrderingSubscription = 'OrderProcessSaga.onOrderAwaitingValidation' | 'OrderProcessSaga.onOrderCancelled' | 'OrderProcessSaga.onOrderShipped' | 'OrderProcessSaga.onOrderSubmitted' | 'OrderProcessSaga.onReleaseRejected' | 'OrderProcessSaga.onReserveCommitted' | 'OrderProcessSaga.onReserveRejected' | 'OrderSummaryProjection.onEvent' | 'ShipmentNotifier.onOrderShipped';
/** The hops of a wait chain (`Wow-Wait`) in `eshop-ordering`. */
export const eshopOrderingWait = {
/** Until a command of `eshop-ordering` passed admission. */
accepted: (): Hop => wait.accepted(EshopOrderingContract.NAME),
/** Until a command's events are appended to the log of `eshop-ordering`. */
committed: (): Hop => wait.committed(EshopOrderingContract.NAME),
/** Until the snapshot of `eshop-ordering` holds the commit. */
snapshot: (): Hop => wait.snapshot(EshopOrderingContract.NAME),
/** Until a subscription of `eshop-ordering` handled the commit. */
handled: (subscription: EshopOrderingSubscription): Hop => wait.handled(subscription, EshopOrderingContract.NAME),
} as const;Every file starts with // Code generated by wow-generator. DO NOT EDIT., and the next run overwrites it. Commit the output with .wow-generator.json.
The output is plain TypeScript, without decorators, and its relative imports end in .js, so it compiles under "moduleResolution": "bundler" and "nodenext" alike. A bundler project keeps its own settings; a Node project that runs the compiled output, with "type": "module" in its package.json, can use this tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}CI compiles this page's code with exactly this tsconfig.json.
Exact numbers
A field of a Kotlin Long, BigInteger or BigDecimal (schema format int64, bigint or decimal) is a string in the generated types, holding the digits exactly as the service wrote them; a JavaScript number would round them. The wire still carries JSON numbers: the aggregate's descriptor lists those fields, the clients read them from the response's text and write them back into a command body unquoted.
This holds at every depth and on every path whose values the Manifest describes: a state, an event, a command body, a query's records, and a service route's body and answer (the two values it does not describe are below). A recursive type (a tree whose children are trees) is marked once, in the context's textNumbers.ts, and read wherever a value reaches it. The alternatives of a union keep their own numbers: a value that is a decimal or an object of them is read as whichever it is, and objects told apart by a member with a constant value, as Jackson's @JsonTypeInfo writes its subtypes, each by their own. Alternatives that read one number differently, one exactly and one as a number, with no such member, fail the generation (exit code 4) with the schema, the path and both formats: give them a discriminator, or the same number format.
// Code generated by wow-generator. DO NOT EDIT.
import type { CommittedEvent, NoAccess, SystemEvent } from '@ahoo-wang/wow-client';
import type { OrderAwaitingValidation, OrderCancelled, OrderPaid, OrderShipped, OrderStockConfirmed, OrderStockItemConfirmed, OrderSubmitted } from '../types.js';
/**
* The aggregate `eshop-ordering.order`.
* A buyer's order, from its submission to its shipment
*/
export const OrderAggregate = {
context: 'eshop-ordering',
name: 'order',
path: '/eshop-ordering/order',
ownership: 'owner',
snapshot: true,
textNumbers: {
state: { items: { '*': { unitPrice: true } }, totalAmount: true },
events: {
'eshop-ordering.order.OrderPaid': { amount: true },
'eshop-ordering.order.OrderSubmitted': { items: { '*': { unitPrice: true } } },
},
commands: {
PayOrder: { amount: true },
SubmitOrder: { items: { '*': { unitPrice: true } } },
},
},
Errors: {
/** 422: A payment pays the order's total amount. */
WRONG_AMOUNT: 'eshop-ordering.order.wrong-amount',
/** 409: The order's status does not allow the command. */
WRONG_STATUS: 'eshop-ordering.order.wrong-status',
},
} as const;
/** The `state.*` paths of `order`'s snapshots a query filters, sorts and projects on. */
export type OrderStateField = 'state.cancelReason' | 'state.confirmedProducts' | 'state.items' | 'state.paymentId' | 'state.status' | 'state.totalAmount';
/** The access attributes of `eshop-ordering.order`: it declares none. */
export type OrderAccess = NoAccess;
/** The events of `order`, named as the server sends them, `eshop-ordering.order.<Event>` at its current revision (the `events` query upgrades older records), and the system events every aggregate has. */
export type OrderEvent = CommittedEvent<'eshop-ordering.order.OrderAwaitingValidation', OrderAwaitingValidation> | CommittedEvent<'eshop-ordering.order.OrderCancelled', OrderCancelled> | CommittedEvent<'eshop-ordering.order.OrderPaid', OrderPaid> | CommittedEvent<'eshop-ordering.order.OrderShipped', OrderShipped> | CommittedEvent<'eshop-ordering.order.OrderStockConfirmed', OrderStockConfirmed> | CommittedEvent<'eshop-ordering.order.OrderStockItemConfirmed', OrderStockItemConfirmed> | CommittedEvent<'eshop-ordering.order.OrderSubmitted', OrderSubmitted> | SystemEvent;parseJson and stringifyJson of @ahoo-wang/wow-client do the same for a request of your own, and a hand-written client takes the same marks (TextNumbers): ServiceRoutes by textNumbers: { 'context.route': { request, response } }, DatasetClient.dataset(context, dataset, numbers) for a dataset's rows. The marks take these forms:
| Form | Marks | Example |
|---|---|---|
true | this value is an exact number | { price: true } |
members, '*' | an object's members by name; '*' every item of an array and every value of a map | { lines: { '*': { amount: true } } } |
() => marks | marks read only where a value reaches them: a recursive type's | const tree: TextNumbers = { amount: true, children: { '*': () => tree } } |
textNumberOr(marks) | an exact number, or an object or array marked marks, whichever the value is | textNumberOr({ amount: true }) for decimal | { amount: decimal } |
textNumbersBy(member, cases) | an object's marks chosen by the string value of its member | textNumbersBy('kind', { decimal: { amount: true } }) |
Two kinds of value are read without marks, so their large numbers are still rounded:
- A command result's
resultsare the service's own values, which the Manifest gives no schema: aLongorBigDecimala command returns there arrives as a JavaScriptnumber. Return such a value as a string, or read it from the aggregate's state. - An aggregation's metric values: a sum or a maximum over an exact field that exceeds what a
numberholds is rounded.
Keep the clients in step
Generate again whenever the baseline changes, and let CI fail when the committed clients differ from what the generator writes now:
pnpm exec wow-generator generate --input ../ordering-server/manifest/baseline.json --output src/generated/ordering --strict
git diff --exit-code -- src/generatedThen compile: a command whose body changed, a removed field or a renamed aggregate fails tsc in the code that uses it. The eShop holds its clients to the baselines in a test, src/generated.test.ts, which generates into a temporary directory and compares every file.
Keep the generator on the same version as the wow-client the generated code compiles against, which is the version of the services it calls.
From any OpenAPI document
The generator also writes TypeScript models and Fetcher decorator clients from an OpenAPI 3 document that does not come from Wow, a third-party API's, say; Generate a Client from Any OpenAPI Document walks through it. A Wow service's own OpenAPI document (OpenAPI) is for other tools: generate its TypeScript clients from the Manifest.