# Generating a client with Hey API

> Generate a typed SDK of the REST routes from the OpenAPI document, with Hey API.

Experimental: Contracts and OpenAPI might change or go away in any release.

[Hey API](https://heyapi.dev) (`@hey-api/openapi-ts`) turns an OpenAPI document into a TypeScript SDK, with plugins for validators, TanStack Query, mocks and more. Point it at the document `experimental_generateOpenApi` writes, and apps that can't import the router's type get typed functions for its REST routes: another team's app, a public API's users, another repository. An app that can import the router's type is better served by the tRPC client, which needs no generation step.

## The document

This router serves the blog over REST: a query with a declared `NOT_FOUND` error, a mutation behind a bearer token that takes a file, and a subscription. The document declares the `bearer` scheme that `authed` routes require ([Security and tags](https://v12.trpc.io/v12/docs/openapi/document.md#security-and-tags)):

```ts title="rest.ts"
/**
 * The blog of `./server.ts` over REST, documented for clients generated from
 * OpenAPI. `pnpm --filter @samples/docs generate:api` writes its document to
 * `../openapi.json`, and Hey API writes `./api/` from that.
 */
import {
  experimental_generateOpenApi,
  experimental_openapi,
} from '@trpc12/trpc/openapi';
import { error, initTrpc, tracked } from '@trpc12/trpc/server';
import { z } from 'zod';

import { db, PostSchema, type Context } from './server.ts';

const t = initTrpc<{ ctx: Context }>().concat(experimental_openapi());

const authed = t.procedure
  .use((opts) =>
    opts.ctx.user
      ? opts.next({ ctx: { user: opts.ctx.user } })
      : error({ code: 'UNAUTHORIZED' }),
  )
  .meta({ route: { spec: (op) => ({ ...op, security: [{ bearer: [] }] }) } });

export const restRouter = {
  post: {
    byId: t.procedure
      .meta({ route: { method: 'GET', path: '/posts/{id}' } })
      .errors({ NOT_FOUND: { data: z.object({ id: z.string() }) } })
      .input(z.object({ id: z.string() }))
      .output(PostSchema)
      .query(
        (opts) =>
          db.post.find(opts.input.id) ??
          error({ code: 'NOT_FOUND', data: { id: opts.input.id } }),
      ),
    create: authed
      .meta({ route: { method: 'POST', path: '/posts', successStatus: 201 } })
      .input(z.object({ title: z.string().min(1), cover: z.file().optional() }))
      .output(PostSchema)
      .mutation((opts) =>
        db.post.create({ title: opts.input.title, author: opts.ctx.user.name }),
      ),
    onAdd: t.procedure
      .meta({ route: { path: '/posts/events' } })
      .output(PostSchema)
      .subscription(async function* () {
        for (const post of db.post.list()) yield tracked(post.id, post);
      }),
  },
};

export const document = experimental_generateOpenApi(restRouter, {
  info: { title: 'Blog', version: '1.0.0' },
  servers: [{ url: 'http://localhost:3000' }],
  components: {
    securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } },
  },
});
```

A script writes it where Hey API reads it:

```ts
await writeFile('openapi.json', JSON.stringify(document, null, 2));
```

## Generating the SDK

```ts
// @filename: openapi-ts.config.ts
import { defineConfig } from '@hey-api/openapi-ts';

export default defineConfig({
  input: './openapi.json',
  output: 'src/api',
  plugins: [
    '@hey-api/client-fetch',
    '@hey-api/typescript',
    { name: '@hey-api/sdk', transformer: true },
    { name: '@hey-api/transformers', dates: true },
  ],
});
```

```sh
npx @hey-api/openapi-ts
```

Hey API 0.99 needs TypeScript 5 or 6: it uses TypeScript's JavaScript API, which TypeScript 7 doesn't have. A project on TypeScript 7, which `experimental_generateOpenApiFromTypes` needs, can run it with TypeScript 6 alongside: `pnpm dlx --package=@hey-api/openapi-ts --package=typescript@6 openapi-ts`.

What the document gives Hey API:

- Each `operationId` is the procedure's path, so `post.byId` is `postById()`. `route.operationId` renames one.
- Each declared error is a response whose `code` is a constant. The `default` response leaves out the codes the operation documents, so `error.defined && error.code === 'NOT_FOUND'` narrows to the declared error and its `data` ([The document](https://v12.trpc.io/v12/docs/openapi/document.md)).
- Operations with `security` send the credentials from the client's `auth` option.
- A body with a file is `multipart/form-data`, so the SDK sends a `FormData` ([Inputs](https://v12.trpc.io/v12/docs/openapi/routes.md#input)).
- `Date`s are `date-time` strings, which `transformers` with `dates: true` turns back into `Date`s.

## Calling the API

```ts
import { client } from './api/client.gen';
import { postById, postCreate } from './api';

client.setConfig({
  baseUrl: 'https://api.example.com',
  auth: () => localStorage.getItem('token') ?? undefined,
});

const result = await postById({ path: { id: '1' } });
if (result.error) {
  const error = result.error;
  if (error.defined && error.code === 'NOT_FOUND') {
    console.log(`No post ${error.data.id}`);
  } else {
    console.log(error.message);
  }
} else {
  console.log(result.data.title, result.data.createdAt.getFullYear());
}

const cover = new File(['…'], 'cover.png', { type: 'image/png' });
await postCreate({ body: { title: 'Hello', cover } });
```

## Subscriptions

A subscription is a stream of server-sent events. Its function returns `{ stream }`, and Hey API reconnects after a dropped connection with `Last-Event-ID`, so [`tracked()`](https://v12.trpc.io/v12/docs/subscriptions/overview.md#resuming) events resume. Hey API yields the data of every event, though: tRPC ends a stream with a `done` event, whose data is empty, and sends a failure as an `error` event with the error as data. `onSseEvent` runs just before each item is yielded, so it tells them apart:

```ts
import { postOnAdd } from './api';

let event: string | undefined;
const events = await postOnAdd({
  onSseEvent: (e) => {
    event = e.event;
  },
});
for await (const post of events.stream) {
  if (event === 'done') break;
  if (event === 'error')
    throw new Error('Subscription failed', { cause: post });
  console.log(post.title);
}
```
