Octonode Playbook

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 boundary

Live 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-ts

The 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

  1. Route — in packages/server/src/routes/api.routes.ts, add the handler. Document it with the local doc(summary, tag, { status, schema }, errors?) helper and validate input with validator("json" | "query", zodSchema) (from hono-openapi, not @hono/zod-validator).
    • Every query/path param must go through a validator or it won't appear in the spec (and the generated client can't pass it). Reuse scopeQuerySchema / workflowQuerySchema / forceQuerySchema or add one beside them.
  2. Response schema — model the response in packages/server/src/routes/response-schemas.ts as a z.object(...) and pass it as doc(...)'s schema. 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 stay jsonValue; known unions and nested structures must be described so the generated client can narrow them.
  3. Regenerateyarn workspace @octonode/studio openapi. New SDK functions appear in gen/sdk.gen.ts as getApiX / postApiX (named from method + path).
  4. 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.
  5. Hook — expose it from apps/studio/src/hooks/<scope>/ as a React Query useQuery / useMutation (add a key to hooks/queryKeys.ts if needed). Components call the hook, never the generated transport directly.

Conventions

  • SSE stays hand-rolled. Streaming endpoints (streamRun, watchNodeSource) parse a streaming fetch response in their domain adapters for bearer auth, cancellation, and close-on-terminal-event semantics — not the generated SSE client.
  • There is no handwritten client.ts or API barrel. Hooks import generated SDK functions or focused behavior-bearing modules; components never import api/gen or call fetch.
  • Generated calls return data and throw. The SDK is generated with responseStyle: "data" and throwOnError: true; transport.ts normalizes thrown API payloads to Error. The few 403/404/409 adapters explicitly request response fields through withResponse().
  • Base URL is empty (transport.ts configures baseUrl: ""): the SDK calls /api/... same-origin, which the Vite dev server proxies to :4000.

On this page