Skip to main content

Client Generation

One of the best things about Contract is that you can generate typed clients in any language. Your Go service becomes a TypeScript client, Python client, or any other language - with full type safety.
Native SDK Generation: Mizu now includes built-in SDK generators for Go, Python, and TypeScript that produce idiomatic, zero-dependency clients with better developer experience than OpenAPI-based generation. See SDK Generation for details.

Two Approaches to Client Generation

ApproachBest ForProsCons
Native SDKsGo, Python, TypeScriptIdiomatic code, zero deps, better DX, streaming supportLimited language support
OpenAPI (this page)Other languages, ecosystem compatibility40+ languages, mature toolingGeneric code, more dependencies

Native SDK Generation

Built-in generators for Go, Python, and TypeScript with OpenAI-style developer experience

OpenAPI Generation

Use OpenAPI spec with third-party generators for any language (continue reading below)

How It Works

Contract provides machine-readable descriptions of your API:
Your Go Service
     ↓
OpenAPI Spec / MCP Tools
     ↓
Client Generator (openapi-generator, etc.)
     ↓
TypeScript Client, Python Client, Go Client, etc.

Two Ways to Get API Metadata

EndpointFormatBest For
/openapi.jsonOpenAPI 3.1Most languages, best tooling
/mcp (tools/list)MCP toolsAI assistants

OpenAPI Client Generation

OpenAPI has the most mature tooling. This is the recommended approach.

Step 1: Serve OpenAPI Spec

import (
    "github.com/go-mizu/mizu"
    contract "github.com/go-mizu/mizu/contract/v2"
    "github.com/go-mizu/mizu/contract/v2/transport/rest"
    "github.com/go-mizu/mizu/contract/v2/transport/openapi"

    "yourapp/todo"
)

impl := todo.NewService()
svc := contract.Register[todo.API](impl,
    contract.WithDefaultResource("todos"),
)

app := mizu.New()
rest.Mount(app.Router, svc)
openapi.Mount(app.Router, "/openapi.json", svc)

app.Listen(":8080")

Step 2: Download the Spec

curl http://localhost:8080/openapi.json > openapi.json

Step 3: Generate Clients

Use any OpenAPI code generator. Here are the most popular:

TypeScript

Using openapi-typescript (types only):
npm install -D openapi-typescript
npx openapi-typescript openapi.json -o ./src/api/types.ts
Using openapi-typescript-codegen (full client):
npm install -D openapi-typescript-codegen
npx openapi-typescript-codegen --input openapi.json --output ./src/api
Using openapi-generator:
npm install -g @openapitools/openapi-generator-cli
openapi-generator-cli generate -i openapi.json -g typescript-fetch -o ./api-client

Python

Using openapi-python-client:
pip install openapi-python-client
openapi-python-client generate --path openapi.json --output-path ./client
Using openapi-generator:
openapi-generator-cli generate -i openapi.json -g python -o ./python-client

Go

Using oapi-codegen:
go install github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@latest
oapi-codegen -package client openapi.json > client/client.go
Using openapi-generator:
openapi-generator-cli generate -i openapi.json -g go -o ./go-client

Other Languages

# Rust
openapi-generator-cli generate -i openapi.json -g rust -o ./rust-client

# Java
openapi-generator-cli generate -i openapi.json -g java -o ./java-client

# Ruby
openapi-generator-cli generate -i openapi.json -g ruby -o ./ruby-client

# PHP
openapi-generator-cli generate -i openapi.json -g php -o ./php-client

Handwritten Clients

Sometimes you don’t need code generation - a simple handwritten client works fine.

Go Client

package todoclient

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

type Client struct {
    baseURL    string
    httpClient *http.Client
}

func New(baseURL string) *Client {
    return &Client{
        baseURL:    baseURL,
        httpClient: &http.Client{},
    }
}

// Types
type Todo struct {
    ID    string `json:"id"`
    Title string `json:"title"`
    Done  bool   `json:"done"`
}

type CreateInput struct {
    Title string `json:"title"`
}

type ListOutput struct {
    Items []*Todo `json:"items"`
    Count int     `json:"count"`
}

// Methods

func (c *Client) Create(ctx context.Context, input *CreateInput) (*Todo, error) {
    return doRequest[Todo](ctx, c, "POST", "/todos", input)
}

func (c *Client) List(ctx context.Context) (*ListOutput, error) {
    return doRequest[ListOutput](ctx, c, "GET", "/todos", nil)
}

func (c *Client) Get(ctx context.Context, id string) (*Todo, error) {
    return doRequest[Todo](ctx, c, "GET", "/todos/"+id, nil)
}

func (c *Client) Delete(ctx context.Context, id string) error {
    _, err := doRequest[struct{}](ctx, c, "DELETE", "/todos/"+id, nil)
    return err
}

// Helper
func doRequest[T any](ctx context.Context, c *Client, method, path string, body any) (*T, error) {
    var bodyReader *bytes.Reader
    if body != nil {
        data, _ := json.Marshal(body)
        bodyReader = bytes.NewReader(data)
    }

    req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 400 {
        return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
    }

    var result T
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, err
    }
    return &result, nil
}
Usage:
client := todoclient.New("http://localhost:8080")

todo, err := client.Create(ctx, &todoclient.CreateInput{Title: "Test"})
if err != nil {
    log.Fatal(err)
}
fmt.Println(todo.ID)

Python Client

from dataclasses import dataclass
from typing import List, Optional
import requests

@dataclass
class Todo:
    id: str
    title: str
    done: bool

@dataclass
class ListOutput:
    items: List[Todo]
    count: int

class TodoClient:
    def __init__(self, base_url: str):
        self.base_url = base_url.rstrip('/')

    def create(self, title: str) -> Todo:
        response = requests.post(
            f"{self.base_url}/todos",
            json={"title": title}
        )
        response.raise_for_status()
        data = response.json()
        return Todo(**data)

    def list(self) -> ListOutput:
        response = requests.get(f"{self.base_url}/todos")
        response.raise_for_status()
        data = response.json()
        return ListOutput(
            items=[Todo(**item) for item in data["items"]],
            count=data["count"]
        )

    def get(self, id: str) -> Optional[Todo]:
        response = requests.get(f"{self.base_url}/todos/{id}")
        if response.status_code == 404:
            return None
        response.raise_for_status()
        return Todo(**response.json())

    def delete(self, id: str) -> None:
        response = requests.delete(f"{self.base_url}/todos/{id}")
        response.raise_for_status()
Usage:
client = TodoClient("http://localhost:8080")

# Create
todo = client.create("Buy milk")
print(f"Created: {todo.id}")

# List
result = client.list()
print(f"Total: {result.count}")

# Get
todo = client.get("1")
if todo:
    print(f"Found: {todo.title}")

JSON-RPC Client

For JSON-RPC transport, clients need to handle the JSON-RPC envelope:

TypeScript

interface JsonRpcRequest {
  jsonrpc: '2.0';
  id: number;
  method: string;
  params?: any;
}

interface JsonRpcResponse<T> {
  jsonrpc: '2.0';
  id: number;
  result?: T;
  error?: { code: number; message: string };
}

class JsonRpcClient {
  private nextId = 1;

  constructor(private baseUrl: string) {}

  async call<T>(method: string, params?: any): Promise<T> {
    const request: JsonRpcRequest = {
      jsonrpc: '2.0',
      id: this.nextId++,
      method,
      params,
    };

    const response = await fetch(this.baseUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(request),
    });

    const rpcResponse: JsonRpcResponse<T> = await response.json();

    if (rpcResponse.error) {
      throw new Error(`${rpcResponse.error.code}: ${rpcResponse.error.message}`);
    }

    return rpcResponse.result!;
  }

  // Batch multiple calls in one request
  async batch<T extends any[]>(calls: { method: string; params?: any }[]): Promise<T> {
    const requests = calls.map((call, i) => ({
      jsonrpc: '2.0' as const,
      id: this.nextId++,
      method: call.method,
      params: call.params,
    }));

    const response = await fetch(this.baseUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(requests),
    });

    const responses: JsonRpcResponse<any>[] = await response.json();
    return responses.map((r) => r.result) as T;
  }
}

// Typed wrapper
class TodoJsonRpcClient {
  private rpc: JsonRpcClient;

  constructor(baseUrl: string) {
    this.rpc = new JsonRpcClient(baseUrl + '/rpc');
  }

  create(input: CreateInput): Promise<Todo> {
    return this.rpc.call('Create', input);
  }

  list(): Promise<ListOutput> {
    return this.rpc.call('List');
  }

  // Batch create multiple todos in one request
  async createMany(inputs: CreateInput[]): Promise<Todo[]> {
    return this.rpc.batch(inputs.map(input => ({ method: 'Create', params: input })));
  }
}

Error Handling in Clients

Always handle errors properly in your clients:

TypeScript

class ApiError extends Error {
  constructor(
    message: string,
    public code: string,
    public status: number
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

async function handleResponse<T>(response: Response): Promise<T> {
  if (!response.ok) {
    const body = await response.text();
    throw new ApiError(body, 'HTTP_ERROR', response.status);
  }

  const data = await response.json();
  return data;
}

// In client methods
async create(input: CreateInput): Promise<Todo> {
  try {
    const response = await fetch(`${this.baseUrl}/todos`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(input),
    });
    return handleResponse<Todo>(response);
  } catch (error) {
    if (error instanceof ApiError) {
      // Handle specific error codes
      switch (error.status) {
        case 400:
          throw new Error('Invalid input: ' + error.message);
        case 404:
          throw new Error('Not found');
        default:
          throw error;
      }
    }
    throw error;
  }
}

Go

type APIError struct {
    Code    string
    Message string
    Status  int
}

func (e *APIError) Error() string {
    return fmt.Sprintf("[%d] %s: %s", e.Status, e.Code, e.Message)
}

func doRequest[T any](ctx context.Context, c *Client, method, path string, body any) (*T, error) {
    // ... (make request)

    if resp.StatusCode >= 400 {
        body, _ := io.ReadAll(resp.Body)
        return nil, &APIError{
            Code:    "HTTP_ERROR",
            Message: string(body),
            Status:  resp.StatusCode,
        }
    }

    // ... (decode response)
}

// Usage
todo, err := client.Get(ctx, "123")
if err != nil {
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        if apiErr.Status == 404 {
            // Handle not found
        }
    }
}

Automation with CI/CD

Automate client generation in your build:

GitHub Action

name: Generate Clients

on:
  push:
    paths:
      - 'api/**'  # When API changes

jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Start API server
        run: go run main.go &
        env:
          PORT: 8080

      - name: Wait for server
        run: sleep 5

      - name: Download OpenAPI spec
        run: curl http://localhost:8080/openapi.json > openapi.json

      - name: Generate TypeScript client
        run: npx openapi-typescript openapi.json -o client/ts/types.ts

      - name: Generate Python client
        run: |
          pip install openapi-python-client
          openapi-python-client generate --path openapi.json --output-path client/python

      - name: Commit generated clients
        run: |
          git add client/
          git commit -m "Update generated clients" || true
          git push

Best Practices

1. Version Your API

Include version in your service:
svc := contract.Register[todo.API](impl,
    contract.WithName("Todo"),
    contract.WithDefaultResource("todos"),
)

2. Keep Clients in Sync

Regenerate clients when your API changes. Consider:
  • Git submodules for shared clients
  • Package registry (npm, PyPI) for versioned clients
  • API versioning for breaking changes

3. Add Custom Headers

Clients often need authentication:
class TodoClient {
  constructor(private baseUrl: string, private token: string) {}

  private async fetch(path: string, options: RequestInit = {}) {
    return fetch(this.baseUrl + path, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.token}`,
        ...options.headers,
      },
    });
  }
}

4. Handle Rate Limits

async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (error instanceof ApiError && error.status === 429) {
        await sleep(Math.pow(2, i) * 1000);  // Exponential backoff
        continue;
      }
      throw error;
    }
  }
  throw new Error('Max retries exceeded');
}

See Also