Get Production-Ready Client Libraries from Zod Schemas
Tags: typescript, api, zod
Published: 2023-10-24 14:20:14

Many Speakeasy users define their TypeScript data parsing schemas using Zod, a powerful and flexible schema validation library for TypeScript. 

In this tutorial, we'll take a detailed look at how to set up _Zod OpenAPI_ to generate an OpenAPI schema based on Zod schemas. Then we'll use Speakeasy to read our generated OpenAPI schema and generate a production-ready client SDK.

## An Example Schema: Burgers and Orders

We'll start with a tiny example schema describing two main types: Burgers and Orders. A burger is a menu item with an ID, name, and description. An order has an ID, a non-empty list of burger IDs, the time the order was placed, a table number, a status, and an optional note for the kitchen.

Anticipating our CRUD app, we'll also add additional schemas describing fields for creating new objects without IDs or updating existing objects where all fields are optional.

If you would like to follow along, save the following TypeScript code in a new file called `index.ts`:

```typescript
import { z } from "zod";

/**
 * The burger schema describes the shape of a burger object as saved in the database.
 */
const burgerSchema = z.object({
  id: z.number().min(1),
  name: z.string().min(1).max(50),
  description: z.string().max(255).optional(),
});

/**
 * The burgerCreateSchema describes the shape of a burger object when creating a new
 * burger.
 */
const burgerCreateSchema = burgerSchema.omit({ id: true });

/**
 * The burgerUpdateSchema describes the shape of a burger object when updating an
 * existing burger.
 */
const burgerUpdateSchema = burgerSchema.partial().omit({ id: true });

/**
 * The orderStatusEnum describes the possible values for the status field of an order.
 */
const orderStatusEnum = z.enum([
  "pending",
  "in_progress",
  "ready",
  "delivered",
]);

/**
 * The orderSchema describes the shape of an order object as saved in the database.
 */
const orderSchema = z.object({
  id: z.number(),
  burger_ids: z.array(z.number().min(1)).nonempty(),
  time: z.string().datetime(),
  table: z.number().min(1),
  status: orderStatusEnum,
  note: z.string().optional(),
});

/**
 * The orderCreateSchema describes the shape of an order object when creating a new
 * order.
 */
const orderCreateSchema = orderSchema.omit({ id: true });

/**
 * The orderUpdateSchema describes the shape of an order object when updating an
 * existing order.
 */
const orderUpdateSchema = orderSchema.partial().omit({ id: true });
```

## An Overview of Zod OpenAPI

[Zod OpenAPI](https://github.com/samchungy/zod-openapi) is a TypeScript library that helps developers define OpenAPI schemas as Zod schemas. The stated goal of the project is to cut down on code duplication, and it does a wonderful job of this.

Zod schemas map to OpenAPI schemas well, and the changes required to extract OpenAPI documents from a schema defined in Zod are often tiny.

Zod OpenAPI is maintained by one of the contributors to an earlier library called [Zod to OpenAPI](https://github.com/asteasolutions/zod-to-openapi). If you already use Zod to OpenAPI, the syntax will be familiar and you should be able to use either library. If you'd like to convert your _Zod to OpenAPI_ code to _Zod OpenAPI_ code, the _Zod OpenAPI_ library provides helpful [documentation for migrating code](https://github.com/samchungy/zod-openapi#migration).

## Install the Zod OpenAPI Library

Use npm to install `zod-openapi`:

```bash
npm install zod-openapi
```

## Extend Zod With OpenAPI

We'll add the `openapi` method to Zod by calling `extendZodWithOpenApi` once. Update `index.ts` to replace the first line with the following:

```typescript
import { extendZodWithOpenApi } from "zod-openapi";
import { z } from "zod";

extendZodWithOpenApi(z);
```

## Register and Generate a Component Schema

Next, we'll use the new `openapi` method provided by `extendZodWithOpenApi` to register an OpenAPI schema for the `burgerSchema`. Edit `index.ts` and add `.openapi({ref: "Burger"}` to the `burgerSchema` schema object.

We'll also add an OpenAPI generator, `OpenApiGeneratorV31`, and log the generated component to the console as YAML.

```typescript
import { extendZodWithOpenApi } from "zod-openapi";
import { z } from "zod";

extendZodWithOpenApi(z);

const burgerSchema = z
  .object({
    id: z.number().min(1),
    name: z.string().min(1).max(50),
    description: z.string().max(255).optional(),
  })
  // Register a Burger schema
  .openapi({
    ref: "Burger",
  });
```

## Add Metadata to Components

To generate an SDK that offers great developer experience, we recommend adding descriptions and examples to all fields in OpenAPI components.

With Zod OpenAPI, we'll call the `.openapi` method on each field, and add an example and description to each field.

We'll also add a description to the `Burger` component itself.

Edit `index.ts` and edit `burgerSchema` as follows:

```typescript
const burgerSchema = z
  .object({
    // Add .openapi() call
    id: z.number().min(1).openapi({
      description: "The unique identifier of the burger.",
      example: 1,
    }),
    // Add .openapi() call
    name: z.string().min(1).max(50).openapi({
      description: "The name of the burger.",
      example: "Veggie Burger",
    }),
    // Add .openapi() call
    description: z.string().max(255).optional().openapi({
      description: "The description of the burger.",
      example: "A delicious bean burger with avocado.",
    }),
  })
  // Add an object describing metadata to the Burger component
  .openapi({
    ref: "Burger",
    description: "A burger served at the restaurant.",
  });
```

Speakeasy will generate documentation and usage examples based on the descriptions and examples we added, but first, we'll need to generate an OpenAPI schema.

## Generating an OpenAPI Schema

Now that we know how to register components with metadata for our OpenAPI schema, let's generate a complete schema document.

Use npm to install `yaml`:

```bash
npm install yaml
```

Update `index.ts` to call `createDocument`:

```typescript
// Add createDocument import
import { extendZodWithOpenApi, createDocument } from "zod-openapi";
// Add yaml import
import * as yaml from "yaml";
import { z } from "zod";

extendZodWithOpenApi(z);

const burgerSchema = z
  .object({
    id: z.number().min(1).openapi({
      description: "The unique identifier of the burger.",
      example: 1,
    }),
    name: z.string().min(1).max(50).openapi({
      description: "The name of the burger.",
      example: "Veggie Burger",
    }),
    description: z.string().max(255).optional().openapi({
      description: "The description of the burger.",
      example: "A delicious bean burger with avocado.",
    }),
  })
  .openapi({
    ref: "Burger",
    description: "A burger served at the restaurant.",
  });

// Call createDocument
const document = createDocument({
  openapi: "3.1.0",
  info: {
    title: "Burger Restaurant API",
    description: "An API for managing burgers at a restaurant.",
    version: "1.0.0",
  },
  servers: [
    {
      url: "https://example.com",
      description: "The production server.",
    },
  ],
  components: {
    schemas: {
      burgerSchema,
    },
  },
});

console.log(yaml.stringify(document));
```

When we run `npx ts-node index.ts`, we'll see that the OpenAPI document configuration appears before the components.

```yaml
openapi: 3.1.0
info:
  title: Burger Restaurant API
  description: An API for managing burgers at a restaurant.
  version: 1.0.0
servers:
  - url: https://example.com
    description: The production server.
components:
  schemas:
    Burger:
      type: object
      properties:
        id:
          type: number
          minimum: 1
          description: The unique identifier of the burger.
          example: 1
        name:
          type: string
          minLength: 1
          maxLength: 50
          description: The name of the burger.
          example: Veggie Burger
        description:
          type: string
          maxLength: 255
          description: The description of the burger.
          example: A delicious bean burger with avocado.
      required:
        - id
        - name
      description: A burger served at the restaurant.
```

Next, we'll look at how to add paths and webhooks to our OpenAPI schema.

## Adding Paths and Webhooks

Paths define the endpoints of your API. For our burger restaurant, we might define endpoints for creating, reading, updating, and deleting burgers and orders.

For this tutorial, we'll register two paths: one to create a burger and another to get a burger by ID.

To register paths and webhooks, we'll define paths as Zod OpenAPI `ZodOpenApiOperationObject` types, then add our paths and webhooks to the document definition.

```typescript
import {
  extendZodWithOpenApi,
  // Add ZodOpenApiOperationObject import
  ZodOpenApiOperationObject,
  createDocument,
} from "zod-openapi";
import * as yaml from "yaml";
import { z } from "zod";

extendZodWithOpenApi(z);

// Extract BurgerIdSchema parameter for easier re-use
const BurgerIdSchema = z
  .number()
  .min(1)
  .openapi({
    ref: "BurgerId",
    description: "The unique identifier of the burger.",
    example: 1,
    param: {
      in: "path",
      name: "id",
    },
  });

const burgerSchema = z
  .object({
    id: BurgerIdSchema,
    name: z.string().min(1).max(50).openapi({
      description: "The name of the burger.",
      example: "Veggie Burger",
    }),
    description: z.string().max(255).optional().openapi({
      description: "The description of the burger.",
      example: "A delicious bean burger with avocado.",
    }),
  })
  .openapi({
    ref: "Burger",
    description: "A burger served at the restaurant.",
  });

// Update the burgerCreateSchema definition

const burgerCreateSchema = burgerSchema.omit({ id: true }).openapi({
  ref: "BurgerCreate",
  description: "A burger to create.",
});

const getBurger: ZodOpenApiOperationObject = {
  operationId: "getBurger",
  summary: "Get a burger",
  description: "Gets a burger from the database.",
  requestParams: {
    path: z.object({ id: BurgerIdSchema }),
  },
  responses: {
    "200": {
      description: "The burger was retrieved successfully.",
      content: {
        "application/json": {
          schema: burgerSchema,
        },
      },
    },
  },
};

const createBurger: ZodOpenApiOperationObject = {
  operationId: "createBurger",
  summary: "Create a new burger",
  description: "Creates a new burger in the database.",
  requestBody: {
    description: "The burger to create.",
    content: {
      "application/json": {
        schema: burgerCreateSchema,
      },
    },
  },
  responses: {
    "201": {
      description: "The burger was created successfully.",
      content: {
        "application/json": {
          schema: burgerSchema,
        },
      },
    },
  },
};

const createBurgerWebhook: ZodOpenApiOperationObject = {
  operationId: "createBurgerWebhook",
  summary: "New burger webhook",
  description: "A webhook that is called when a new burger is created.",
  requestBody: {
    description: "The burger that was created.",
    content: {
      "application/json": {
        schema: burgerSchema,
      },
    },
  },
  responses: {
    "200": {
      description: "The webhook was processed successfully.",
    },
  },
};

const document = createDocument({
  openapi: "3.1.0",
  info: {
    title: "Burger Restaurant API",
    description: "An API for managing burgers at a restaurant.",
    version: "1.0.0",
  },
  paths: {
    "/burgers": {
      post: createBurger,
    },
    "/burgers/{id}": {
      get: getBurger,
    },
  },
  webhooks: {
    "/burgers": {
      post: createBurgerWebhook,
    },
  },
  servers: [
    {
      url: "https://example.com",
      description: "The production server.",
    },
  ],
});

console.log(yaml.stringify(document));
```

When we run `npx ts-node index.ts`, our script generates the following schema:

```yaml
openapi: 3.1.0
info:
  title: Burger Restaurant API
  description: An API for managing burgers at a restaurant.
  version: 1.0.0
servers:
  - url: https://example.com
    description: The production server.
paths:
  /burgers:
    post:
      operationId: createBurger
      summary: Create a new burger
      description: Creates a new burger in the database.
      requestBody:
        description: The burger to create.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BurgerCreate"
      responses:
        "201":
          description: The burger was created successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Burger"
  "/burgers/{id}":
    get:
      operationId: getBurger
      summary: Get a burger
      description: Gets a burger from the database.
      parameters:
        - in: path
          name: id
          schema:
            $ref: "#/components/schemas/BurgerId"
          required: true
      responses:
        "200":
          description: The burger was retrieved successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Burger"
webhooks:
  /burgers:
    post:
      operationId: createBurgerWebhook
      summary: New burger webhook
      description: A webhook that is called when a new burger is created.
      requestBody:
        description: The burger that was created.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Burger"
      responses:
        "200":
          description: The webhook was processed successfully.
components:
  schemas:
    BurgerCreate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 50
          description: The name of the burger.
          example: Veggie Burger
        description:
          type: string
          maxLength: 255
          description: The description of the burger.
          example: A delicious bean burger with avocado.
      required:
        - name
      description: A burger to create.
    Burger:
      type: object
      properties:
        id:
          $ref: "#/components/schemas/BurgerId"
        name:
          type: string
          minLength: 1
          maxLength: 50
          description: The name of the burger.
          example: Veggie Burger
        description:
          type: string
          maxLength: 255
          description: The description of the burger.
          example: A delicious bean burger with avocado.
      required:
        - id
        - name
      description: A burger served at the restaurant.
    BurgerId:
      type: number
      minimum: 1
      description: The unique identifier of the burger.
      example: 1
```

## Add Tags and Tag Metadata

Tags allow you to organize your operations into groups. This is useful for documentation and SDK generation.

Update `index.ts` to add tags for the `Burger` and `Order` components and paths:

```typescript
const document = createDocument({
  openapi: "3.1.0",
  info: {
    title: "Burger Restaurant API",
    description: "An API for managing burgers at a restaurant.",
    version: "1.0.0",
  },
  tags: [
    {
      name: "burgers",
      description: "Operations for managing burgers.",
    },
    {
      name: "orders",
      description: "Operations for managing orders.",
    },
  ],
});
```

Then add the `burgers` tag to the paths and webhook we created earlier:

```typescript
const createBurger: ZodOpenApiOperationObject = {
  operationId: "createBurger",
  summary: "Create a new burger",
  description: "Creates a new burger in the database.",
  // Add burgers tag
  tags: ["burgers"],
  requestBody: {
    description: "The burger to create.",
    content: {
      "application/json": {
        schema: burgerCreateSchema,
      },
    },
  },
  responses: {
    "201": {
      description: "The burger was created successfully.",
      content: {
        "application/json": {
          schema: burgerSchema,
        },
      },
    },
  },
};

const getBurger: ZodOpenApiOperationObject = {
  operationId: "getBurger",
  summary: "Get a burger",
  // Add burgers tag
  tags: ["burgers"],
  description: "Gets a burger from the database.",
  requestParams: {
    path: z.object({ id: BurgerIdSchema }),
  },
  responses: {
    "200": {
      description: "The burger was retrieved successfully.",
      content: {
        "application/json": {
          schema: burgerSchema,
        },
      },
    },
  },
};

const createBurgerWebhook: ZodOpenApiOperationObject = {
  operationId: "createBurgerWebhook",
  summary: "New burger webhook",
  // Add burgers tag
  tags: ["burgers"],
  description: "A webhook that is called when a new burger is created.",
  requestBody: {
    description: "The burger that was created.",
    content: {
      "application/json": {
        schema: burgerSchema,
      },
    },
  },
  responses: {
    "200": {
      description: "The webhook was processed successfully.",
    },
  },
};
```

After adding our tags and generating our schema, we'll see a new top-level `tags` section in our schema:

```yaml
tags:
  - name: burgers
    description: Operations for managing burgers.
  - name: orders
    description: Operations for managing orders.
```

## Add Retries to Your SDK With `x-speakeasy-retries`

Speakeasy can generate SDKs that follow [custom rules for retrying failed requests](/docs/customize-sdks/retries). For instance, if your server failed to return a response within a specified time, you may want your users to retry their request without clobbering your server.

To add retries to SDKs generated by Speakeasy, add a top-level `x-speakeasy-retries` schema to your OpenAPI spec. You can also override the retry strategy per path by adding `x-speakeasy-retries` to each path.

### Adding Global Retries

To add global retries, we'll add a custom key to the document definition:

```typescript
const document = createDocument({
  openapi: "3.1.0",
  info: {
    title: "Burger Restaurant API",
    description: "An API for managing burgers at a restaurant.",
    version: "1.0.0",
  },
  // Add the x-speakeasy-retries extension to the document
  "x-speakeasy-retries": {
    strategy: "backoff",
    backoff: {
      initialInterval: 500,
      maxInterval: 60000,
      maxElapsedTime: 3600000,
      exponent: 1.5,
    },
    statusCodes: ["5XX"],
    retryConnectionErrors: true,
  },
});
```

### Adding Retries Per Path

To add `x-speakeasy-retries` to a single path, update the path and add the `x-speakeasy-retries` parameter as follows:

```typescript
const getBurger: ZodOpenApiOperationObject = {
  operationId: "getBurger",
  summary: "Get a burger",
  tags: ["burgers"],
  description: "Gets a burger from the database.",
  requestParams: {
    path: z.object({ id: BurgerIdSchema }),
  },
  responses: {
    "200": {
      description: "The burger was retrieved successfully.",
      content: {
        "application/json": {
          schema: burgerSchema,
        },
      },
    },
  },
  // Add the x-speakeasy-retries extension to the path
  "x-speakeasy-retries": {
    strategy: "backoff",
    backoff: {
      initialInterval: 500,
      maxInterval: 60000,
      maxElapsedTime: 3600000,
      exponent: 1.5,
    },
    statusCodes: ["5XX"],
    retryConnectionErrors: true,
  },
};
```

Rerun `npx ts-node index.ts` to generate a complete schema.

## Generate an SDK

With our OpenAPI schema complete, we can now generate an SDK using the Speakeasy SDK generator. We'll follow the instructions in the Speakeasy documentation to [generate SDKs for various platforms](/docs/create-client-sdks).

First, write your YAML schema to a new file called `openapi.yaml`. Run the following in the terminal:

```bash
npx ts-node index.ts > openapi.yaml
```

Then log in to your Speakeasy account or use the [Speakeasy CLI](/docs/speakeasy-cli/getting-started/) to generate a new SDK.

Here's how to use the CLI. In the terminal, run:

```bash
speakeasy generate sdk --schema openapi.yaml --lang python --out ./sdk
```

This generates a new Python SDK in the `./sdk` directory.

## Example Zod Schema and SDK Generator

The source code for our complete example is available in the [zod-burgers](https://github.com/speakeasy-api/speakeasy-zod-openapi) repository.

The repository contains a pregenerated Python SDK with instructions on how to generate more SDKs.

You can clone this repository to test how changes to the Zod schema definition results in changes to the generated SDK.

## Summary

In this tutorial, we learned how to generate OpenAPI schemas from Zod and create client SDKs with Speakeasy.

By following these steps, you can ensure that your API is well-documented, easy to use, and offers a great developer experience.