API codegen — server OpenAPI → generated Studio client
Canonical Octonode repository documentation.
API codegen — server OpenAPI → generated Studio client
The Studio never hand-writes ordinary JSON fetch calls. The server describes itself with
OpenAPI, and a generator turns that spec into a typed client. Types flow server route → spec →
generated SDK → focused domain adapter or hook with no manual transport duplication.
packages/server/src/routes/api.routes.ts routes: validator() (request) + doc() (response)
packages/server/src/routes/response-schemas.ts Zod mirrors of response shapes (zod 4)
│ yarn workspace @octonode/server openapi → emits the OpenAPI spec to stdout
apps/studio/openapi.json committed spec (codegen input)
│ openapi-ts (@hey-api/openapi-ts) → reads openapi-ts.config.ts
apps/studio/src/api/gen/ generated SDK + types (committed, never hand-edit)
apps/studio/src/api/transport.ts auth, scope, and normalized generated-client errors
apps/studio/src/api/<domain>.ts transforms, status semantics, cache/static data, SSE
apps/studio/src/api/constants.ts shared transport and projection constants
apps/studio/src/api/types.ts generated aliases and adapter-only types
apps/studio/src/hooks/<scope>/ React Query hooks consume the focused API boundaryLive spec is also served: GET /api/openapi.json and Swagger UI at GET /api/docs.
Regenerate
yarn workspace @octonode/studio openapi # emits spec from the server, then runs openapi-tsThe gen/ folder and openapi.json are committed so Studio builds without a codegen
step. Regenerate (and commit the result) whenever a route's request/response shape changes.
Adding a new API service and wiring it to the client
- Route — in
packages/server/src/routes/api.routes.ts, add the handler. Document it with the localdoc(summary, tag, { status, schema }, errors?)helper and validate input withvalidator("json" | "query", zodSchema)(fromhono-openapi, not@hono/zod-validator).- Every query/path param must go through a
validatoror it won't appear in the spec (and the generated client can't pass it). ReusescopeQuerySchema/workflowQuerySchema/forceQuerySchemaor add one beside them.
- Every query/path param must go through a
- Response schema — model the response in
packages/server/src/routes/response-schemas.tsas az.object(...)and pass it asdoc(...)'sschema. Author these in the server's zod 4 — do not import@octonode/schema's Zod values (it's zod 3; the majors don't compose). Genuinely dynamic payloads stayjsonValue; known unions and nested structures must be described so the generated client can narrow them. - Regenerate —
yarn workspace @octonode/studio openapi. New SDK functions appear ingen/sdk.gen.tsasgetApiX/postApiX(named from method + path). - Consume — use the generated SDK call through the matching domain module or hook. Add an adapter only when it transforms data, enforces an application invariant, caches/static-loads data, or handles a documented exceptional status. Do not add a pass-through wrapper.
- Hook — expose it from
apps/studio/src/hooks/<scope>/as a React QueryuseQuery/useMutation(add a key tohooks/queryKeys.tsif needed). Components call the hook, never the generated transport directly.
Conventions
- SSE stays hand-rolled. Streaming endpoints (
streamRun,watchNodeSource) parse a streamingfetchresponse in their domain adapters for bearer auth, cancellation, and close-on-terminal-event semantics — not the generated SSE client. - There is no handwritten
client.tsor API barrel. Hooks import generated SDK functions or focused behavior-bearing modules; components never importapi/genor callfetch. - Generated calls return data and throw. The SDK is generated with
responseStyle: "data"andthrowOnError: true;transport.tsnormalizes thrown API payloads toError. The few 403/404/409 adapters explicitly request response fields throughwithResponse(). - Base URL is empty (
transport.tsconfiguresbaseUrl: ""): the SDK calls/api/...same-origin, which the Vite dev server proxies to:4000.