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:
Octopus config fragments —
routes+upstreamsYAML (or JSON) you can hand straight tooctopus serve.The Octopus Schema — an intermediate
*.jsondescribing every service, scoped operation, and DTO type.A TypeScript client SDK — a namespace-chained, fully-typed REST client built from that schema.
TanStack Query hooks — React hooks generated per operation (optional).
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.yamlgen 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/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://orhttps://is fetched over HTTP withAccept: 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:
type | Parser | Notes |
openapi | OpenAPI paths → routes + operations | The primary, fully-exercised path. |
farp | Parsed with the same OpenAPI extractor | Point endpoint/spec at the FARP schema document. |
asyncapi | AsyncAPI 2.x and 3.x channels → WebSocket/SSE channels | Protocol auto-detected from x-protocol, bindings, or tags. |
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
| Field | Required | Purpose |
name | yes | Used for file names, the upstream name, and the top-level client namespace. |
type | yes | openapi, asyncapi, or farp. |
spec | one of spec/endpoint | File path or URL of the spec/manifest. |
endpoint | one of spec/endpoint | Alternative HTTP endpoint for the spec; used if spec is unset. |
prefix | no | Route prefix (e.g. /example). Becomes the service base_path and a strip_prefix on routes. |
upstream.host / upstream.port | yes | Backend target. |
upstream.lb_policy | no | Defaults to round_robin. |
auth.provider | no | Auth provider name attached to generated routes. |
auth.require_roles / auth.require_scopes | no | Role/scope requirements copied onto routes. |
auth.skip_auth | no | Marks routes as auth-exempt. |
skip_paths | no | Paths to exclude. "/foo/*" matches any path starting with /foo/; otherwise exact match. |
scopes | no | Manual path → { method → scope } overrides. |
What it produces04
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.yamlWith 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: 7900These 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, andgenerated_at(Unix seconds).A
servicesmap: per service, thebase_path,upstream(host/port), optionalauth(strategy: bearer, provider, roles, scopes), anoperationsmap keyed by scope, and achannelsmap for WS/SSE.A
typesmap: every DTO extracted from the spec'scomponents.schemas, cleaned for output (x-*extensions,xml, andexampleare 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:
Manual mapping from the service's
scopes:block (exactpath+METHOD).operationIdfrom the spec —listUsers→example.users.list,users_list→example.users.list.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:
| Method | Collection (e.g. /users) | Single resource (e.g. /users/{id}) |
| GET | list | get |
| POST | create | create |
| PUT | replaceAll | update |
| PATCH | updateAll | patch |
| DELETE | deleteAll | delete |
So GET /api/v1/users → example.users.list, GET /api/v1/users/{id} → example.users.get, and
GET /api/v1/users/{userId}/posts → example.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.list →
useExampleUsersList), 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
warnand skipped; the command still exits successfully. Check the logs if an expected service is missing from the output.openapiis the most-tested type.farpreuses the OpenAPI extractor, andasyncapi(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:, orclient:section to skip that stage. The client always builds its schema in-memory, so a standaloneschema:output is optional.The generated package is not publish-ready. It is emitted
privateat version0.0.0with.tsentry points — wire it into your build, don'tnpm publishit directly.