Skip to content

Generate services from OpenAPI (tempest gen api)

If your backend is FastAPI (or anything exposing an OpenAPI 3.x spec), tempest gen api reads it and generates a typed client for you — the Tempest way: Zod schemas, TS types and a service class per route group (tag), with Zod input validation.

No hand-written fetch, no keeping types in sync with the backend by hand.

npx tempest gen api http://127.0.0.1:8000/openapi.json --out src/api
# or from a file:
npx tempest gen api ./openapi.json --out src/api

What it generates

For each route group (the operation's tag in OpenAPI) you get a folder with three files:

src/api/
├── index.ts            # re-exports everything
├── users/
│   ├── schemas.ts      # Zod schemas (z.object…) for the group's models
│   ├── types.ts        # TS types (z.infer of the schemas)
│   └── service.ts      # UsersService class with one method per route
└── products/
    ├── schemas.ts
    ├── types.ts
    └── service.ts

schemas.ts — Zod per model

import { z } from "zod";

export const UserSchema = z.object({
  id: z.number().int(),
  email: z.string().email(),
  role: z.enum(["admin", "member"]).optional(),
  bio: z.string().nullable().optional(),
});

export const UserCreateSchema = z.object({
  email: z.string().email(),
  name: z.string().optional(),
});

types.ts — inferred types

import type { z } from "zod";
import * as S from "./schemas";

export type User = z.infer<typeof S.UserSchema>;
export type UserCreate = z.infer<typeof S.UserCreateSchema>;

service.ts — one class per group, with validation

import type { ApiClient } from "tempest-react-sdk";
import { encodeIdSegment } from "tempest-react-sdk";
import * as S from "./schemas";
import type { User, UserCreate } from "./types";

export class UsersService {
  constructor(private readonly api: ApiClient) {}

  /** `GET /users` */
  async listUsers(params: { limit?: number }): Promise<User[]> {
    return this.api.get<User[]>("/users", { params });
  }

  /** `POST /users` */
  async createUser(body: UserCreate): Promise<User> {
    S.UserCreateSchema.parse(body); // validate the input at runtime
    return this.api.post<User>("/users", { body });
  }

  /** `GET /users/{id}` */
  async getUser(id: number): Promise<User> {
    return this.api.get<User>(`/users/${encodeIdSegment(id)}`);
  }
}

A path parameter becomes exactly one segment

Note the encodeIdSegment(id) above: every path parameter goes through it before reaching the URL. The id value often comes from outside the app — a React Router param, a search param, a value another user stored. Left raw, "../../account" would send DELETE /users/{id} to DELETE /account, with the signed-in user's Bearer, and "7?role=admin" would inject a query string. It is the same protection createDataProvider has applied since 0.77.0:

Value passed Request sent
7 GET /users/7
"../../account" GET /users/..%2F..%2Faccount
"7?role=admin" GET /users/7%3Frole%3Dadmin
"7#x" GET /users/7%23x
"", ".", "..", null, undefined none — throws TypeError before the fetch

A parameter typed as boolean, array or object goes through encodeIdSegment too (with a type assertion, since it accepts string | number): true becomes true, ["a", "b"] becomes a%2Cb.

Regenerate to get the protection

Services generated before this version interpolate the parameter raw. Run tempest gen api again — no method signature changes.

How to use it

The class takes an ApiClient (from createApiClient) in its constructor — you inject yours, with the base URL and token:

import { createApiClient } from "tempest-react-sdk";
import { UsersService } from "@/api/users";
import { useAuth } from "@/stores/auth";

const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  getToken: () => useAuth.getState().token,
});

export const users = new UsersService(api);

// users.listUsers({ limit: 20 })  →  Promise<User[]>
// users.createUser({ email: "a@b.c" })  →  Zod-validates, then POST

With TanStack Query (already mounted by AppProviders):

import { useQuery } from "@tanstack/react-query";
import { users } from "@/api";

function UserList() {
  const { data } = useQuery({ queryKey: ["users"], queryFn: () => users.listUsers({}) });
  // data: User[] | undefined
}

Re-generate when the backend changes

Run tempest gen api again after changing the backend — the files under src/api/ are generated, so treat them as a build artifact (don't edit by hand; commit them if you like, but regenerate when the contract changes).

The output stays inside the project

Before the first write, gen api checks the --out, every route-group folder and every file: a symlink (dangling included) in place of any of them, a path outside the project and an ancestor that is a symlink leading out are refused, and nothing is written. A src/api that is a link in a cloned repository does not carry the generated files somewhere else on disk.

Validates input, types the output

The method validates the body with Schema.parse(...) (fails early on invalid data) and types the response. The response is not validated at runtime by default — trust the backend contract, or validate manually with the matching schema if you need a guarantee.

Current support

v1 covers the common FastAPI spec: components.schemas (object, enum, array, $ref, nullable, anyOf/oneOf/allOf), path/query params, JSON body and response, grouped by the first tag. YAML and multiple content-types are not supported yet — point at the /openapi.json (JSON).

Third-party spec text never becomes code

Every string from the spec reaches the generated TypeScript escaped for the position it lands in: a tag, summary or path inside a comment cannot close the /** */, path segments become template-literal text, and pattern becomes new RegExp("..."). A name that must be an identifier and is not — {user-id}, user.Response, User-Out — becomes a valid one: camelCase for a parameter (userId), PascalCase for a schema or type (UserResponse), a _ prefix for a reserved word or a leading digit (_string, _2fa). Wherever the name matters at runtime the original wins: the URL fills {user-id} with the value of userId, and an object key is an escaped string ("display-name"). A name that already is an identifier is kept. Two names that map to the same identifier (User-Out and user.Out) are refused with an error naming both, and so is a path {placeholder} that is not a declared parameter — there is no safe variable for it.

The same goes for names the generator itself uses. A path parameter that would land on body, params, S or encodeIdSegment becomes bodyParam/paramsParam/SParam/encodeIdSegmentParam; an operationId of constructor or api becomes constructor2/api2; a schema named ApiClient, OffsetPage, CursorPage, Promise, Record, z, S or encodeIdSegment gets a _ prefix; and two tags whose barrel names coincide (user 1 and user1) are refused, naming both. A pattern that does not compile as a JavaScript regex fails generation, naming the schema and the property path — instead of breaking the app on import.

Recap

  • tempest gen api <url|file> --out src/api generates, per route group, three files: schemas.ts (Zod), types.ts (z.infer) and service.ts (class).
  • The service class takes an injected ApiClient and validates the body with Zod before calling the API.
  • Every path parameter goes through encodeIdSegment: it stays in one segment, and ""/"."/".." throw before the request.
  • Treat src/api/ as a generated artifact — regenerate when the contract changes.
  • See also: HTTP / createApiClient · Query · tempest CLI.