Octopus
1.x
Docs/Octopus/Codegen
Open

Reading15 min
Updated31 Jul 2026
Sourcev1/cli/codegen.mdx

Codegen

octopus gen reads a single configuration file — octopus-gen.yaml — that lists your backend services and their API specs, then produces, in one pass:

  1. Octopus config fragmentsroutes + upstreams YAML (or JSON) you can hand straight to octopus serve.

  2. The Octopus Schema — an intermediate *.json describing every service, scoped operation, and DTO type.

  3. A TypeScript client SDK — a namespace-chained, fully-typed REST client built from that schema.

  4. TanStack Query hooks — React hooks generated per operation (optional).

  5. Per-service packages — standalone re-export packages, one per service (optional).

Everything is driven by the schema in GenConfig; each output stage is independently optional and only runs if its section is present in the config.

Running it01

# uses ./octopus-gen.yaml by default
octopus gen

# point at a different config file
octopus gen --config my-gen.yaml
octopus gen -c my-gen.yaml

gen takes a single -c/--config path to one gen file (default octopus-gen.yaml). This is different from serve and validate, which take one or more gateway config paths. The gen file itself is not gateway config — it is a recipe for producing config and clients.

A typical workflow: generate, then serve the generated fragments alongside your hand-written config (the generated directory is just more config files, and serve merges directories):

octopus gen
octopus serve -c config.yaml -c ./generated/
Note

Services are processed independently. If one service's spec can't be fetched or parsed, octopus gen logs a warning and skips that service, continuing with the rest — it does not abort the run.

What it consumes02

Each entry under services names a backend and points at an API spec. The spec is loaded from either a local file or an HTTP(S) URL (the source is whichever of spec or endpoint is set; spec wins if both are present):

  • A source starting with http:// or https:// is fetched over HTTP with Accept: application/json.

  • Anything else is read from disk. The file is parsed as JSON first, then YAML as a fallback.

Three source types are supported via the type field:

typeParserNotes
openapiOpenAPI paths → routes + operationsThe primary, fully-exercised path.
farpParsed with the same OpenAPI extractorPoint endpoint/spec at the FARP schema document.
asyncapiAsyncAPI 2.x and 3.x channels → WebSocket/SSE channelsProtocol auto-detected from x-protocol, bindings, or tags.
Warning

Any other type value is a hard error for that service. The example config ships with asyncapi and farp services commented out — openapi is the most-tested path.

Configuration (octopus-gen.yaml)03

The config below is grounded in the fields the generator actually deserializes. Optional fields fall back to the defaults shown in the comments.

# Where all generated files are written (default: ./generated)
output_dir: "./generated"

# ── Octopus config fragments ─────────────────────────────────────────────
# Omit this whole section to skip config-fragment generation.
config:
  format: yaml          # yaml | json (default: yaml)
  split: true           # true = one file per service; false = single merged file (default: true)

# ── Octopus Schema ───────────────────────────────────────────────────────
# Omit to skip. The TypeScript client is built from this schema.
schema:
  output: "octopus-schema.json"   # relative to output_dir unless it starts with / or .

# ── TypeScript client SDK ────────────────────────────────────────────────
# Only runs when present AND enabled: true.
client:
  enabled: true
  output_dir: "./generated/client"     # default: ./generated/client
  package_name: "@myapp/api-client"    # default: @octopus/api-client

  # Standalone per-service re-export packages under <output_dir>/per-service/
  per_service_packages: true

  # TanStack Query (React Query) hooks
  tanstack_query:
    enabled: true
    version: 5            # 5 -> @tanstack/react-query; 4 -> @tanstack/react-query; <4 -> react-query

  # Default auth wiring baked into the generated fetcher
  auth:
    default_strategy: bearer    # bearer | api_key | custom
    header: "Authorization"
    prefix: "Bearer "

  # WebSocket client runtime (only emitted if an AsyncAPI service has WS channels)
  websocket:
    enabled: true
    reconnect: true
    ping_interval: 30

  # SSE client runtime (only emitted if an AsyncAPI service has SSE channels)
  sse:
    enabled: true
    post_support: true

  # Type-name cleanup applied to every type extracted from specs
  types:
    # Exact-match renames (checked first)
    rename:
      "ComExampleModelsUser": "User"
      "ComExampleModelsPost": "Post"
    # Regex transforms, applied in order to whatever survives renaming
    transforms:
      - pattern: "^Dto"
        replace: ""
      - pattern: "Response$"
        replace: ""
      - pattern: "^ComExample"
        replace: ""

# ── Services ─────────────────────────────────────────────────────────────
services:
  - name: example
    type: openapi
    spec: "http://localhost:7900/openapi.json"   # file path or URL
    prefix: "/example"                             # route prefix in the gateway
    upstream:
      host: localhost
      port: 7900
      lb_policy: round_robin                      # default: round_robin
    auth:
      provider: "internal-jwt"
      require_roles: [user]
      # require_scopes: [...]
      # skip_auth: false
    skip_paths:                                   # exact match, or trailing-* prefix match
      - "/health"
      - "/metrics"
      - "/openapi.json"
      - "/swagger/*"
    # Manual scope overrides: path -> { METHOD -> scope }. Falls back to auto-derivation.
    scopes:
      "/api/v1/users":
        GET: "example.users.list"
        POST: "example.users.create"
      "/api/v1/users/{id}":
        GET: "example.users.get"
        PUT: "example.users.update"
        DELETE: "example.users.delete"

  - name: portal
    type: openapi
    spec: "./specs/portal-openapi.yaml"
    prefix: "/portal"
    upstream:
      host: portal.internal
      port: 8080
    auth:
      provider: "internal-jwt"
    skip_paths:
      - "/health"

A live, fuller version of this file (with commented AsyncAPI and FARP examples) ships as octopus-gen.example.yaml at the repo root.

Service fields

FieldRequiredPurpose
nameyesUsed for file names, the upstream name, and the top-level client namespace.
typeyesopenapi, asyncapi, or farp.
specone of spec/endpointFile path or URL of the spec/manifest.
endpointone of spec/endpointAlternative HTTP endpoint for the spec; used if spec is unset.
prefixnoRoute prefix (e.g. /example). Becomes the service base_path and a strip_prefix on routes.
upstream.host / upstream.portyesBackend target.
upstream.lb_policynoDefaults to round_robin.
auth.providernoAuth provider name attached to generated routes.
auth.require_roles / auth.require_scopesnoRole/scope requirements copied onto routes.
auth.skip_authnoMarks routes as auth-exempt.
skip_pathsnoPaths to exclude. "/foo/*" matches any path starting with /foo/; otherwise exact match.
scopesnoManual path → { method → scope } overrides.

What it produces04

1

Octopus config fragments

When the config: section is present, the generator walks each spec's paths, skips skip_paths and any method outside GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS, and emits gateway routes plus an upstream definition per service.

With split: true (the default) it writes two files per service, named from the service name:

generated/
  example.routes.yaml      # { routes: [...] }
  example.upstream.yaml    # { upstreams: [...] }
  portal.routes.yaml
  portal.upstream.yaml

With split: false it writes a single generated-config.yaml (or .json) containing both merged routes and upstreams arrays. The extension follows format (yaml or json).

A generated route carries the prefixed path, the HTTP method, the upstream name, and — when a prefix is set — a strip_prefix. Auth fields from the service are copied in; if the spec marks an operation as having no security while the service declares auth, that route gets skip_auth: true.

# example.routes.yaml (excerpt)
routes:
  - path: /example/api/v1/users
    methods:
      - GET
    upstream: example
    strip_prefix: /example
    auth_provider: internal-jwt
    require_roles:
      - user
# example.upstream.yaml
upstreams:
  - name: example
    lb_policy: round_robin
    instances:
      - id: example-1
        host: localhost
        port: 7900

These map directly onto the gateway's routes and upstreams config. The split fragments include the per-route skip_prefix/require_scopes/skip_auth fields; the merged file emits a slightly leaner route shape (path, methods, upstream, prefix, provider, roles).

The Octopus Schema

When the schema: section is present, the generator writes a single intermediate JSON document (default octopus-schema.json, under output_dir unless the path is absolute or starts with .). This is the contract the TypeScript client is generated from — and it is also built in-memory when generating the client, so the client works even if you omit the standalone schema: output.

It contains:

  • A top-level $schema, version, and generated_at (Unix seconds).

  • A services map: per service, the base_path, upstream (host/port), optional auth (strategy: bearer, provider, roles, scopes), an operations map keyed by scope, and a channels map for WS/SSE.

  • A types map: every DTO extracted from the spec's components.schemas, cleaned for output (x-* extensions, xml, and example are stripped; $refs are rewritten to #/types/<Name>).

Each operation records its method, path, summary, tags, params (split into path/query/header), and request_body/response type references. Array responses (e.g. an OpenAPI array of $ref: User) are recorded as #/types/User[].

Scopes

Every operation is assigned a dot-separated scope like example.users.list. The scope determines where the operation lands in the client's namespace chain. Scopes resolve in priority order:

  1. Manual mapping from the service's scopes: block (exact path + METHOD).

  2. operationId from the spec — listUsersexample.users.list, users_listexample.users.list.

  3. Path + method heuristics — the resource segments of the path plus a verb derived from the method.

The path heuristic skips api and version segments (v1, v2, …) and path parameters, then chooses a verb based on whether the path ends in a parameter:

MethodCollection (e.g. /users)Single resource (e.g. /users/{id})
GETlistget
POSTcreatecreate
PUTreplaceAllupdate
PATCHupdateAllpatch
DELETEdeleteAlldelete

So GET /api/v1/usersexample.users.list, GET /api/v1/users/{id}example.users.get, and GET /api/v1/users/{userId}/postsexample.users.posts.list. Use the scopes: block to override any of these explicitly.

TypeScript client SDK

When client.enabled: true, the generator writes a TypeScript package into client.output_dir. The client is namespace-chained: the scope path becomes the call path. example.users.list becomes client.example.users.list().

import { createClient } from '@myapp/api-client';

const client = createClient({
  baseURL: 'https://gateway.example.com',
  auth: { token: process.env.API_TOKEN },
});

// Namespace chain mirrors the scope: example.users.list
const users = await client.example.users.list();

// Path/query/body params are passed as a typed options object
const user = await client.example.users.get({ path: { id: '123' } });
const created = await client.example.users.create({ body: { name: 'Ada' } });

The emitted package layout is:

generated/client/
  package.json                  # name, exports, TanStack peerDependency
  index.ts                      # createClient() + per-service factories
  types.ts                      # generated DTO types
  core/
    types.ts                    # ClientConfig, AuthConfig, RequestConfig, ApiError
    fetcher.ts                  # the request() implementation (auth, timeout, interceptors)
    ws.ts                       # WebSocket runtime (only if a service has WS channels)
    sse.ts                      # SSE runtime (only if a service has SSE channels)
  services/
    example/                     # one folder per service: index.ts + per-namespace files
    portal/
  tanstack/                     # only if tanstack_query.enabled
    example.hooks.ts
    portal.hooks.ts
    index.ts
  per-service/                  # only if per_service_packages: true
    example/{package.json,index.ts}
    portal/{package.json,index.ts}

ClientConfig (from core/types.ts) takes baseURL, default auth, headers, cookies, credentials, a timeout, an optional custom fetch, and onRequest/onResponse interceptor arrays. Each call accepts the same per-request overrides plus signal, responseType, and timeout. Auth defaults (header name, prefix, strategy) come from client.auth.

The generated package.json is marked private with version: "0.0.0", main/types pointing at index.ts, an exports map (., ./types, and ./tanstack when hooks are enabled), and a @tanstack/react-query peer dependency derived from tanstack_query.version. It is meant to be consumed in-tree by a TypeScript toolchain, not published as-is.

TanStack Query hooks

With client.tanstack_query.enabled, the generator emits a hook per operation under tanstack/, one file per service plus a barrel index.ts. Read operations (GET) become useQuery hooks; write operations (POST/PUT/DELETE/PATCH) become …Mutation hooks built on useMutation. Hook names are derived from the service name and the relative scope (e.g. example.users.listuseExampleUsersList), and the full scope is used as the query key.

import { useExampleUsersList } from '@myapp/api-client/tanstack';

function UserList(config) {
  const { data, isLoading } = useExampleUsersList(config);
  // ...
}

version: 5 and version: 4 both import from @tanstack/react-query; any lower value imports from the legacy react-query package.

Per-service packages

With client.per_service_packages: true, the generator also emits a standalone re-export package per service under per-service/<service>/. Each is a small package.json (named <package_name-without-/api-client>-<service>, private, 0.0.0) plus an index.ts that re-exports that service's factory (e.g. createExampleService as createExampleClient) and the shared types from the main package. This lets a frontend depend on just the slice of the API it uses.

WebSocket & SSE clients (AsyncAPI)

For asyncapi services, channels become typed real-time endpoints. WebSocket channels are mapped to /ws<channel> and SSE channels to /events<channel>; protocol is detected from the x-protocol extension, ws/http bindings, or SSE-flavored tags. The schema records each channel's send/receive message types, and the client emits a typed ws.ts/sse.ts runtime plus channel methods on the service namespace. These runtimes are only written when a service actually defines channels of that kind, gated additionally by client.websocket.enabled / client.sse.enabled.

Notes and honest limits05

  • Per-service skipping is silent-ish. A failing service is logged at warn and skipped; the command still exits successfully. Check the logs if an expected service is missing from the output.

  • openapi is the most-tested type. farp reuses the OpenAPI extractor, and asyncapi (2.x and 3.x) drives the WS/SSE paths; the example config ships those two commented out.

  • Output stages are independent. Drop the config:, schema:, or client: section to skip that stage. The client always builds its schema in-memory, so a standalone schema: output is optional.

  • The generated package is not publish-ready. It is emitted private at version 0.0.0 with .ts entry points — wire it into your build, don't npm publish it directly.