Smart Client Library
Generate typed API clients from OpenAPI and Proto specs. Consuming a service feels like calling local functions — transport, retry, auth, and telemetry are handled automatically.
Overview
@putnami/client bridges the gap between API definition and consumption:
Service A (defines API) Service B (consumes API)
├── openapi.json → ├── UsersClient (generated)
├── api.proto → │ ├── .list()
└── clientGenerator() │ ├── .getById({ id })
│ └── .create({ name, email })
└── "feels like local dev"Setup
Add the clientGenerator plugin to the service that exposes the API:
import { application, api, http, openapi, proto, grpc } from '@putnami/application';
import { clientGenerator } from '@putnami/client';
const app = application()
.use(http({ port: 3000 }))
.use(api())
.use(openapi({ title: 'Users API', version: '1.0.0' }))
.use(proto({ packageName: 'users.v1' }))
.use(grpc())
.use(clientGenerator({ packageName: '@myorg/users-client' }));Run bunx putnami build --impacted to generate the client at clients/ts/.
Usage
import { UsersClient } from '@myorg/users-client';
import { ClientBuilder } from '@putnami/client';
const users = await ClientBuilder.for(UsersClient)
.baseUrl('http://users-api:3000')
.checkDrift()
.build();
const user = await users.getUser({ id: '123' });
// user: { id: string, name: string, email: string } — fully typedThe ClientBuilder auto-negotiates transport and encoding. For synchronous initialization use .buildSync() instead.
Architecture
Transport
Determined at generation time from the API spec:
| Spec | Transport | Protocol |
|---|---|---|
| OpenAPI | HTTP/JSON | Standard REST |
| Proto | Connect | Connect protocol over HTTP |
When both specs exist, Connect is preferred (binary efficiency, streaming support).
Interceptor Chain
Every outgoing request passes through:
Auth → Telemetry → Routing Context → Retry → Transport| Interceptor | What it does |
|---|---|
| Auth | Forwards JWT from incoming request, or obtains client credentials token |
| Telemetry | Records client.{service}.request, .error, .duration metrics |
| Routing Context | Propagates trace ID, request ID, region, experiment headers |
| Retry | Exponential backoff with jitter on transient failures (429, 502, 503, 504) |
| Circuit Breaker | Fail-fast when downstream is unhealthy, with optional health probing |
Service Discovery
URLs resolved from configuration:
# conf/.env.local.yaml
client:
services:
users-api: http://localhost:3000
# conf/.env.production.yaml
client:
services:
users-api: https://users-api.internal:443Or environment variables: CLIENT_SERVICE_USERS_API_URL=http://...
DI Integration
import { set, get } from '@putnami/runtime';
set(UsersClient, new UsersClient({ baseUrl, transport: 'http' }));
const users = get(UsersClient);Error Handling
import { ClientRequestError, ClientServerError } from '@putnami/client';
try {
await users.getUser({ id: 'invalid' });
} catch (error) {
if (error instanceof ClientRequestError) { /* 4xx */ }
if (error instanceof ClientServerError) { /* 5xx */ }
}Circuit Breaker
Prevent cascading failures with the circuit breaker interceptor:
import { circuitBreakerInterceptor } from '@putnami/client';
const users = new UsersClient({
baseUrl: 'http://users-api:3000',
transport: 'http',
interceptors: [
circuitBreakerInterceptor({
failureThreshold: 5,
resetTimeoutMs: 30000,
healthCheckUrl: 'http://users-api:3000/_/health',
}),
],
});Three states: Closed (normal) → Open (fail-fast) → Half-open (trial requests). When healthCheckUrl is set, the breaker probes it while open and transitions to half-open when the service recovers.
Proto Binary Encoding
Generated proto clients use binary encoding automatically — zero config. The generated client embeds proto field metadata at generation time. If the service doesn't support Connect, the ClientBuilder falls back to HTTP/JSON.
Spec Drift Detection
Generated clients embed a spec hash. Enable drift detection to warn at startup if the API has changed:
const users = await ClientBuilder.for(UsersClient)
.baseUrl('http://users-api:3000')
.checkDrift()
.build();
// Logs a warning if spec hash differs from live service — non-blockingStreaming
Streaming RPCs use WebSocket transport automatically. Server-streaming, client-streaming, and bidirectional streaming are all supported:
const stream = client.watchOrders({ userId: '123' });
stream.onMessage((order) => console.log('New order:', order));
stream.onComplete(() => console.log('Done'));
stream.cancel(); // clean upCode Generation Pipeline
OpenAPI spec (authoritative v1 contract)
↓
Spec Reader → Intermediate Representation (IR)
↓
TS Generator → clients/ts · Go Generator → clients/goThe IR is language-agnostic, so the same OpenAPI spec drives both a TypeScript and a Go emitter — see Cross-language clients. OpenAPI is the authoritative v1 contract; Proto/Connect is out of scope for v1.
Configuration Reference
ClientBuilder (recommended)
| Method | Description |
|---|---|
.baseUrl(url) |
Service base URL (required) |
.clientId(id) |
Client identity for X-Client-Id |
.timeout(ms) |
Request timeout |
.retry(config) |
Retry configuration |
.interceptors([...]) |
Custom interceptors |
.checkDrift() |
Enable spec drift detection |
.build() |
Async build with transport negotiation |
.buildSync() |
Sync build using embedded metadata |
ClientConfig (manual)
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl |
string |
required | Service base URL |
transport |
'http' | 'connect' |
required | Transport protocol |
timeoutMs |
number |
30000 | Request timeout |
retry.maxRetries |
number |
3 | Max retry attempts |
retry.baseDelayMs |
number |
200 | Base backoff delay |
retry.maxDelayMs |
number |
5000 | Max backoff delay |
retry.retryableStatuses |
number[] |
[429,502,503,504] | Retryable HTTP codes |
retry.jitter |
boolean |
true | Randomize delay |
interceptors |
Interceptor[] |
[] | Custom interceptors |
ClientGeneratorConfig
| Option | Type | Default | Description |
|---|---|---|---|
targets |
('ts' | 'go')[] |
['ts'] |
Languages to generate |
output |
string |
clients/ts |
TypeScript output directory |
packageName |
string |
auto | npm package name for the TS client |
go.output |
string |
clients/go |
Go output directory |
go.modulePath |
string |
'' |
Go module path — set it to make clients/go a standalone module |
go.packageName |
string |
client |
Go package name |
go.clientName |
string |
Client |
Generated Go client struct name |
Cross-language clients (TS ↔ Go)
A single provider can generate clients in both languages from one OpenAPI spec — a TypeScript service can hand a Go service a typed Go client, and vice versa. Set targets to include both:
.use(
clientGenerator({
packageName: '@myorg/users-client', // the TypeScript client
targets: ['ts', 'go'],
go: { modulePath: 'github.com/myorg/users-client-go' },
}),
)How emission is split:
- The same-language client is emitted in-app during
putnami build(the TypeScript client for a TS provider here; a Go provider emits its Go client the same way viaapi.Clients(...)). - The cross-language client is emitted by
putnami clientgen— a neutral, scheduler-mediated command that runs the other toolchain's emitter against the provider's own.gen/schema/openapi.json. No extension shells out to the other; the shared spec is the only seam. A provider opts intoclientgenby listing/tooling/clientgen-extensionin itsputnami.jsonextensions; a fresh provider then bootstraps its first client (no committed client required).
putnami clientgen # builds the provider, then emits every configured clientThe result lives side by side under clients/:
clients/
├── ts/ → @myorg/users-client (TypeScript package)
└── go/ → standalone Go module (go.mod + client.gen.go), or a package when modulePath is emptyGenerated clients are committed (like a .pb.go) and regenerated on demand; a clientsync-style test fails the build if a committed client drifts from the spec. v1 scope: OpenAPI HTTP only — gRPC/proto/Connect and streaming are not generated cross-language.