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 (`example/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 |
|---|---|
boundedContext.ts | The context's name, its domain error codes with their statuses, and the hops of a wait in it |
types.ts | The commands, events and states of the Manifest's schemas |
<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 |
<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 context file. 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';
/**
* The bounded context `eshop-ordering`.
* The eShop ordering: orders from placement to shipment
*/
export const ESHOP_ORDERING_BOUNDED_CONTEXT = 'eshop-ordering';
/** The domain error codes of `eshop-ordering`, each a problem's `code` with the status the Manifest gives it. */
export const EshopOrderingErrorCodes = {
/** 422: A payment pays the order's total amount. */
ORDER_WRONG_AMOUNT: 'eshop-ordering.order.wrong-amount',
/** 409: The order's status does not allow the command. */
ORDER_WRONG_STATUS: 'eshop-ordering.order.wrong-status',
} as const;
/** A {@link EshopOrderingErrorCodes} value. */
export type EshopOrderingErrorCode = (typeof EshopOrderingErrorCodes)[keyof typeof EshopOrderingErrorCodes];
/** 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(ESHOP_ORDERING_BOUNDED_CONTEXT),
/** Until a command's events are appended to the log of `eshop-ordering`. */
committed: (): Hop => wait.committed(ESHOP_ORDERING_BOUNDED_CONTEXT),
/** Until the snapshot of `eshop-ordering` holds the commit. */
snapshot: (): Hop => wait.snapshot(ESHOP_ORDERING_BOUNDED_CONTEXT),
/** Until a subscription of `eshop-ordering` handled the commit. */
handled: (subscription: EshopOrderingSubscription): Hop => wait.handled(subscription, ESHOP_ORDERING_BOUNDED_CONTEXT),
} 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.
// Code generated by wow-generator. DO NOT EDIT.
import type { EventView, 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 } } },
},
},
} 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 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 = EventView<'eshop-ordering.order.OrderAwaitingValidation', OrderAwaitingValidation> | EventView<'eshop-ordering.order.OrderCancelled', OrderCancelled> | EventView<'eshop-ordering.order.OrderPaid', OrderPaid> | EventView<'eshop-ordering.order.OrderShipped', OrderShipped> | EventView<'eshop-ordering.order.OrderStockConfirmed', OrderStockConfirmed> | EventView<'eshop-ordering.order.OrderStockItemConfirmed', OrderStockItemConfirmed> | EventView<'eshop-ordering.order.OrderSubmitted', OrderSubmitted> | SystemEvent;parseJson and stringifyJson of @ahoo-wang/wow-client do the same for a request of your own.
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.