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 typed

The 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:443

Or 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-blocking

Streaming

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 up

Code Generation Pipeline

OpenAPI spec (authoritative v1 contract)

    Spec Reader → Intermediate Representation (IR)

    TS Generator → clients/ts   ·   Go Generator → clients/go

The 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

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 via api.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 into clientgen by listing /tooling/clientgen-extension in its putnami.json extensions; a fresh provider then bootstraps its first client (no committed client required).
putnami clientgen   # builds the provider, then emits every configured client

The 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 empty

Generated 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.