An elegant, extensible client code generator that introspects Forge routes and generates type-safe clients for multiple languages.
- Multi-Language Support: Currently supports Go, TypeScript and Dart, extensible to Rust and other languages
- Comprehensive API Coverage: Generates clients for REST, WebSocket, and SSE endpoints
- Smart Auth Integration: Detects and generates proper authentication code (Bearer, API Key, Basic, OAuth2)
- Advanced Streaming: Includes reconnection, heartbeat, and connection state management for WebSocket/SSE
- Type-Safe: Generates fully typed clients from OpenAPI/AsyncAPI specifications
- Clean API: Modern, idiomatic code generation for each target language
- Extensible: Plugin-based architecture for adding new language generators
ir.go: Intermediate Representation - Language-agnostic API spec data structuresintrospector.go: Extracts route information from Forge Router (runtime introspection)spec_parser.go: Parses OpenAPI 3.1.0 and AsyncAPI 3.0.0 specifications from filesgenerator.go: Central orchestration and language generator registryauth.go: Authentication scheme detection and code generation utilitiesstreaming.go: Streaming features (reconnection, heartbeat, state management)config.go: Generator configuration with feature flagsoutput.go: File writing and README generation
generator.go: Main generator with client struct and auth configtypes.go: Type definitions from schemasrest.go: REST endpoint methodswebsocket.go: WebSocket clients with goroutines and channelssse.go: Server-Sent Events clients
Features:
- Idiomatic Go code with context support
- Graceful error handling
- Connection pooling and reconnection
- Structured with proper package layout
generator.go: Main generator with Axios-based client- Type-safe interfaces from OpenAPI schemas
- Modern async/await patterns
- NPM package generation with
package.jsonandtsconfig.json
- A Dart package with immutable models, codecs, a typed
RestClientand a sealedApiErrorhierarchy, and nobuild_runnerstep for the consumer - With
--hooks, theops.darttable and one-line bindings over theforge_clientruntime indart-packages/ - int64 as an extension type over
Stringby default, plainintwith--int64=int - Documented in
docs/content/docs/dart-client/
# Generate from OpenAPI/AsyncAPI spec file
forge client generate --from-spec ./api/openapi.yaml --language go --output ./sdk
# With full options
forge client generate \
--from-spec ./api/openapi.yaml \
--language typescript \
--output ./clients/typescript \
--package "@myorg/api-client" \
--base-url "https://api.example.com" \
--auth \
--streaming \
--reconnection \
--heartbeat \
--state-management \
--field-naming camel \
--field-overrides "User.user_id=userIdentifier"See "Field Naming" below for --field-naming/--field-overrides and the
breaking change they relate to.
# List all endpoints from spec
forge client list --from-spec ./api/openapi.yaml
# Filter by type
forge client list --from-spec ./api/openapi.yaml --type rest
forge client list --from-spec ./api/openapi.yaml --type ws
forge client list --from-spec ./api/openapi.yaml --type sse# Interactive configuration wizard
forge client initCreates .forge-client.yaml:
clients:
- language: go
output: clients/go
package: apiclient
base_url: https://api.example.com
features:
reconnection: true
heartbeat: true
state_management: trueimport (
"context"
"github.com/xraph/forge/internal/client"
"github.com/xraph/forge/internal/client/generators/golang"
)
// Create generator
gen := client.NewGenerator()
// Register language generators
gen.Register(golang.NewGenerator())
// Configure generation
config := client.GeneratorConfig{
Language: "go",
OutputDir: "./sdk",
PackageName: "apiclient",
BaseURL: "https://api.example.com",
IncludeAuth: true,
IncludeStreaming: true,
Features: client.Features{
Reconnection: true,
Heartbeat: true,
StateManagement: true,
TypedErrors: true,
},
}
// Generate from spec file
generatedClient, err := gen.GenerateFromFile(context.Background(), "./openapi.yaml", config)
if err != nil {
log.Fatal(err)
}
// Write to disk
outputMgr := client.NewOutputManager()
err = outputMgr.WriteClient(generatedClient, config.OutputDir)sdk/
├── go.mod # Go module file
├── README.md # Usage instructions
├── client.go # Main client with auth config
├── types.go # Generated type definitions
├── rest.go # REST endpoint methods
├── websocket.go # WebSocket clients
├── sse.go # SSE clients
└── errors.go # Error types
Example usage:
import "your-module/sdk"
client := sdk.NewClient(
sdk.WithBaseURL("https://api.example.com"),
sdk.WithAuth(sdk.AuthConfig{
BearerToken: "your-token",
}),
)
// REST call
result, err := client.GetUser(ctx, "user-123")
// WebSocket
ws := client.NewChatWSClient()
ws.Connect(ctx)
ws.OnMessage(func(msg ChatMessage) {
fmt.Println("Received:", msg)
})
ws.Send(ChatMessage{Text: "Hello"})
// SSE
sse := client.NewNotificationSSEClient()
sse.Connect(ctx)
sse.OnNotification(func(notif Notification) {
fmt.Println("Notification:", notif)
})client/
├── package.json # NPM package config
├── tsconfig.json # TypeScript config
├── README.md # Usage instructions
└── src/
├── index.ts # Barrel exports
├── types.ts # Type definitions
├── codecs.ts # Wire <-> client-side field name encode/decode (see "Field Naming" below)
├── capabilities.ts # Declared scopes as a typed union, plus can() (see below)
├── client.ts # Main client class
├── rest.ts # REST methods
├── websocket.ts # WebSocket clients
└── sse.ts # SSE clients
codecs.ts is only emitted when it would do real work -- see "Field Naming"
below for exactly when that is (and is not) the case.
capabilities.ts is emitted only when some route declares scopes through
WithRequiredAuth; with none, its Capability union would have no members and
every function over it would be uncallable. It imports nothing, so it is
emitted in every generation mode rather than only where a runtime dependency is
already declared.
It is a UX affordance and never a security boundary -- every answer is computed from client-held state, authorization stays server-side and unconditional, and nothing in the generated request path consults it. The generated file says so in its own header, which is the only place a reader at the call site will see it.
Example usage:
import { Client } from '@myorg/api-client';
const client = new Client({
baseURL: 'https://api.example.com',
auth: {
bearerToken: 'your-token',
},
});
// REST call
const user = await client.getUser('user-123');
// WebSocket
const ws = new ChatWSClient();
await ws.connect();
ws.onMessage((msg) => {
console.log('Received:', msg);
});
ws.send({ text: 'Hello' });The client generator automatically detects and generates appropriate auth code for:
- Bearer Token (JWT)
- API Key (header or query parameter)
- Basic Auth
- OAuth 2.0 (all flows)
- Custom Headers
Detection is based on OpenAPI security schemes in the specification.
- Exponential backoff strategy
- Configurable max attempts and delays
- Automatic resume with last event ID (SSE)
- Periodic ping messages for WebSocket
- Configurable intervals
- Connection health monitoring
- Connection state tracking (disconnected, connecting, connected, reconnecting, closed, error)
- State change callbacks
- Thread-safe state access
-
Create a new package:
generators/<language>/ -
Implement the
LanguageGeneratorinterface:
type LanguageGenerator interface {
Name() string
SupportedFeatures() []string
Generate(ctx context.Context, spec APISpec, config GeneratorConfig) (*GeneratedClient, error)
Validate(spec APISpec) error
}-
Create generator files:
generator.go- Main generator and client structuretypes.go- Type system mappingrest.go- REST endpoint generationwebsocket.go- WebSocket client generation (if supported)sse.go- SSE client generation (if supported)
-
Register the generator:
gen.Register(yourlang.NewGenerator())Each language generator must map OpenAPI/JSON Schema types to native types:
| JSON Schema | Go | TypeScript | Rust |
|---|---|---|---|
| string | string | string | String |
| integer | int | number | i32/i64 |
| number | float64 | number | f64 |
| boolean | bool | boolean | bool |
| array | []T | T[] | Vec |
| object | struct | interface | struct |
- Language: Target language (go, typescript, rust)
- OutputDir: Where to write generated files
- PackageName: Package/module name
- APIName: Main client class/struct name
- BaseURL: Default API base URL
- IncludeAuth: Generate authentication configuration
- IncludeStreaming: Generate WebSocket/SSE clients
- Module: Go module path (Go only)
- Version: Generated client version
- FieldNaming: Client-side identifier style for schema properties --
camel,pascal,snake, orpreserve(TypeScript only; see "Field Naming" below). Empty resolves tocamelwhenLanguageis"typescript",preserveotherwise. - FieldOverrides: Per-field client-side name overrides that bypass
FieldNamingentirely (see "Field Naming" below).
- Reconnection: Auto-reconnect for streaming
- Heartbeat: Connection health checks
- StateManagement: Track connection state
- TypedErrors: Generate typed error responses
- RequestRetry: Auto-retry failed requests
- Timeout: Request timeout configuration
- Middleware: Request/response interceptors
- Logging: Built-in logging support
Breaking change. Generating a TypeScript client with the default configuration now renames every schema property to camelCase --
user.user_idin an OpenAPI spec becomesuser.userIdin the generated client, notuser.user_id. If you are upgrading an existing generated client and want the old, wire-cased behaviour back, setFieldNaming: "preserve"(Go API) or pass--field-naming preserve(CLI) -- see "Escape hatch" below.
By default, the TypeScript generator renders every schema property under a
client-side name derived from its wire (JSON) name, and generates a codec
(src/codecs.ts) that renames payloads at the HTTP boundary so the actual
runtime values match the declared TypeScript types: a request encodes
client-side names back to wire names before the request is sent, and a
response decodes wire names to client-side names after it arrives.
| Value | Example (wire user_id) |
Notes |
|---|---|---|
camel (default for TypeScript) |
userId |
Standard TypeScript/JavaScript convention |
pascal |
UserId |
|
snake |
user_id |
No-op if the wire name is already snake_case |
preserve |
user_id |
Wire name rendered verbatim; the pre-Phase-3 behaviour |
An unset FieldNaming (Go zero value, or omitting --field-naming on the
CLI) resolves to camel when Language is "typescript", and to
preserve for every other language -- so a Go generator caller is
completely unaffected. An unrecognised strategy value passed to the Go API
directly (e.g. a typo'd client.GeneratorConfig{FieldNaming: "kebab"}) silently falls back to preserve rather than erroring, since
GeneratorConfig currently has no validation path for this field; the CLI
layer (--field-naming) does NOT share this leniency -- an unrecognised
CLI value is rejected outright.
FieldOverrides renames one specific field differently from whatever
FieldNaming strategy is configured -- including under preserve (see
"Escape hatch" below). Each key is either:
- Schema-scoped:
"SchemaName.wire_name"-- applies only within that schema (and any inline nested object using that same namespace id, e.g."Order.shipping.street_name"). - Global:
"wire_name"-- applies everywhere that wire name occurs with no schema-scoped entry taking precedence.
A schema-scoped key always wins over a global one for the same wire name. An override's value is used verbatim -- it is never case-converted, even if it happens to look like a wire name.
config := client.GeneratorConfig{
Language: "typescript",
FieldNaming: client.NamingCamel,
FieldOverrides: map[string]string{
"User.user_id": "userIdentifier", // schema-scoped
"api_key": "apiKey", // global
},
}On the CLI, --field-overrides takes a single comma-separated
key=clientName list (schema-scoped and global keys use the same format as
above):
forge client generate --language typescript \
--field-overrides "User.user_id=userIdentifier,api_key=apiKey"Known ambiguity: a schema name or wire name containing a literal .
makes the concatenated key ambiguous -- schema "User.Detail" + wire "id"
and schema "User" + wire "Detail.id" both produce the same key,
"User.Detail.id". OpenAPI schema and property names are conventionally
dot-free, so this is treated as an accepted, unresolved edge case rather
than requiring an escaping scheme.
Known limitation: when a nested object is reachable through more than
one composition path (e.g. both Addr.payload.x and Base.payload.x
resolve to logically "the same" property), an override must be repeated
once per namespace it is reachable through -- there is no single override
that applies to every path at once.
Generation fails outright (producing no output files at all) if two
distinct wire names in the same object namespace would resolve to the same
client-side name under the configured FieldNaming/FieldOverrides --
this includes top-level schemas, inline nested objects, array items,
additionalProperties values, and oneOf/anyOf/allOf members. The
error names both wire names, the schema, and the exact FieldOverrides key
that would resolve the collision.
This check also runs under preserve whenever FieldOverrides is
non-empty -- an override renames a field even under preserve, so two
overrides that map different wire names to the same client name are still
a real collision, not just an ordinary no-op-renaming pair of wire names.
Two narrower gaps are known and not yet closed: a collision cannot be
detected through an allOf member that is itself a union (its alternatives
are invisible to the guard), and an allOf member that is a bare array or
an additionalProperties-only schema is silently unwarned about (though
harmless, since such a member degrades to passthrough rather than
contributing fields).
Set FieldNaming: "preserve" (Go API) or --field-naming preserve (CLI)
to keep every generated field name exactly as the wire declares it -- byte-
identical to the pre-Phase-3 output. When preserve is set AND
FieldOverrides is empty, the entire codec table (src/codecs.ts, its
imports, and every bodyCodec/responseCodec reference) is omitted
entirely as dead weight, since nothing would ever need renaming. Setting
even one FieldOverrides entry keeps the codec table alive, since that one
field still needs to be renamed at the HTTP boundary.
src/ops.ts is what the @forge-go/client-core runtime reads, and it
drives the generated HTTPClient#request -- never the typed per-endpoint
methods, whose parameters are positional and per-endpoint. Two things
follow, and they are one change because either alone is a regression:
-
Each
OperationMetacarriesbodyCodec/responseCodec, the same idsrest.gowrites into the typed methods'RequestConfig, resolved by the same functions (requestBodyCodecRef/responseCodecRef). Without them the transport ships wire-cased bodies and returns un-decoded responses, so hooks and the direct REST client disagree about the shape of the same generated type. -
The
entitiestable'sidField, and the KEYS of itsfields, are emitted in the CLIENT-side naming (viatsFieldName, the same function the type renderer and the codec table use), because the runtime normalizes a response thatdecodehas already renamed. The VALUES offieldsare typenames, not field names, and are never renamed. A table still naming wire fields against a decoded payload does not fail loudly: a type whose id field is absent is simply not an entity, so the cache quietly stops caching. -
The DERIVED item cache tag,
Type:{IDField}, is renamed for the same reason: the runtime resolves aprovidestemplate against the decoded response (QueryRegistry#settle), soOrder:{order_number}would resolve to nothing, the query would be registered under no item tag, and a later write to that order would invalidate nothing. The exact tagDeriveTagsbuilds is rewritten byrenameDerivedIDTags. -
A HAND-DECLARED template (
Customer:{req.customer_id},Shipment:{res.shipment.external_id}) is renamed byrenameDeclaredTags, which walks each dotted segment through the schema graph from the operation's body or response type, so every segment is renamed under the type it belongs to (the same namespace aFieldOverridefor it is keyed under). Path and query parameters keep their wire names, because the transport substitutes them by that name and that is the key the caller supplies. A segment the schema cannot answer for stops the rewrite there. Before this, a server declaringWithInvalidatesagainst a snake_case wire produced a manifest whose templates named nothing the runtime could see, and the tag invalidated nothing.
All of the above are identity under preserve with no FieldOverrides, so
that configuration emits a byte-identical ops.ts.
- A discriminated union whose members are THEMSELVES discriminated unions encodes entirely in camelCase (the rename does not apply) -- generation warns, but the warning's stated reason (implemented as of this writing) is inaccurate; the underlying gap is tracked, not yet fixed.
- A schema property literally named
additionalPropertiescan alias an internal codec id, risking silent data corruption if such a property occurs in practice. additionalPropertiesdeclared on anallOfcomposition is dropped by both the type renderer and the codec table (so it is silently untyped and unrenamed), though the collision guard still walks it.- A stream payload the application handles itself, off the generated
WebSocketClient/SSEClientmessage handlers, is decoded through the message's codec; the live-query path decodes through the ENTITY's codec, emitted asdecodeon eachstreamsrow (see writeStreams). The two are the same codec whenever the message payload is the entity. A message whose payload is an envelope around the entity is decoded whole by the class and by the binding's entity codec on the live path, which renames only the entity's own fields; declare the envelope as the message type and bind the entity if both need renaming. - A media type of
application/json; charset=utf-8(or any other parameterized JSON content type) is not recognized by the generator's spec-side content-type lookups, which match"application/json"exactly. A request/response body declared with a parameterized content type is typed asBlobinstead of the schema type, gets no codec reference, and generation does not warn -- while the runtime HTTP client still JSON-parses the response body regardless.
The client generator includes comprehensive tests:
- Unit tests: Test IR conversion, type mapping, code generation
- Integration tests: Generate clients and verify compilation
- Fixture tests: Test against sample OpenAPI/AsyncAPI specs
Run tests:
cd v2/internal/client
go test ./...- Lazy initialization: Clients are created on-demand
- Connection pooling: Reuse HTTP connections
- Streaming efficiency: Goroutines/async for concurrent processing
- Memory management: Proper cleanup and resource disposal
- Rust generator
- Python generator
- gRPC support
- GraphQL support
- Code formatting integration (gofmt, prettier, rustfmt)
- Validation middleware generation
- Rate limiting client-side
- Circuit breaker patterns
- Metrics and telemetry hooks
- Mock client generation for testing
When adding features or fixing bugs:
- Update IR types if needed (
ir.go) - Update language generators
- Add tests
- Update documentation
- Run linters:
golangci-lint run
Part of the Forge framework. See main project license.