Create Production-Ready SDKs for tRPC

Create Production-Ready SDKs for tRPC

This tutorial will show you how to create an SDK for an API built with tRPC.

We'll explore how to generate an OpenAPI document for our tRPC API, and then we'll use this document to create an SDK using Speakeasy.

Here's what we'll cover:

  1. Adding trpc-openapi to a tRPC project.

  2. Generating an OpenAPI specification using trpc-openapi.

  3. Improving the generated OpenAPI specification for better downstream SDK generation.

  4. Using the Speakeasy CLI to create an SDK based on the generated OpenAPI specification.

  5. Using the Speakeasy OpenAPI extensions to improve created SDKs.

  6. Automating this process as part of a CI/CD pipeline.

If you want to follow along, you can use the tRPC Speakeasy Bar example repository.

The SDK Creation Pipeline

With Speakeasy, you can create SDKs from an OpenAPI specification.

tRPC does not natively export OpenAPI documents, but the trpc-openapi package adds this functionality. We'll start this tutorial by adding trpc-openapi to a project, and then we'll add a script to generate an OpenAPI schema and save it as a file.

The quality of your OpenAPI specification will ultimately determine the quality of created SDKs and documentation, so we'll dive into ways you can improve the generated specification.

With our new and improved OpenAPI specification in hand, we'll take a look at how to create SDKs using Speakeasy.

Finally, we'll add this process to a CI/CD pipeline so that Speakeasy automatically creates fresh SDKs whenever your tRPC API changes in the future.

Requirements

To follow along with this tutorial, you will need:

  • An existing tRPC app, or you can clone our example application.

  • Some familiarity with tRPC.

  • Node.js installed (we used Node v20.5.1).

  • The Speakeasy CLI installed. You'll use the CLI to create the SDK once you have generated your OpenAPI spec.

Supported OpenAPI Versions

Speakeasy supports OpenAPI v3 and v3.1. As of October 2023, trpc-openapi can generate schemas that adhere to the OpenAPI v3.0.3 specification.

This OpenAPI version is not a limitation, but it is important to keep the versions used in mind when debugging code generation. Refer to the OpenAPI Initiative for an overview of the differences between OpenAPI 3.0 and 3.1.0.

Why Speakeasy and tRPC?

tRPC's focus on type safety and developer experience sets it apart from other TypeScript API frameworks. By using TypeScript's type system along with a schema library like Zod, tRPC allows your server and client code to share types.

One of the tRPC's stated goals is to cut down on the need for codegen, but we believe there is still a place for code generation in the tRPC ecosystem. While tRPC's default client is useful for writing internal clients in a monorepo where a client can import the server's AppRouter, it does not make it easy to publish production-ready SDKs for use by internal and external developers. Nor does tRPC's type-safety extend to SDKs in languages other than TypeScript.

Speakeasy can help you create type-safe, production-ready SDKs for your tRPC API in various languages, so that you can focus on building your API, confident that your users will have a great developer experience.

How To Generate an OpenAPI Spec With tRPC

We'll use trpc-openapi to create REST endpoints for our tRPC procedures and then create the OpenAPI spec that describes these endpoints.

To generate an OpenAPI spec for tRPC, we'll use trpc-openapi to create REST endpoints for our tRPC procedures, then create an OpenAPI document that describes these endpoints.

Add trpc-openapi to a Project

Install trpc-openapi:

npm install trpc-openapi

Use initTRPC.meta<OpenApiMeta>() to create a new tRPC instance with OpenAPI support:

import { initTRPC } from "@trpc/server";
import { OpenApiMeta } from "trpc-openapi";

const t = initTRPC.meta<OpenApiMeta>().create();

Add OpenAPI meta to a procedure by passing an openapi object to the meta function. This object contains the HTTP method and path for the generated REST endpoint.

import { initTRPC } from "@trpc/server";
import { OpenApiMeta } from "trpc-openapi";
import { z } from "zod";

const t = initTRPC.meta<OpenApiMeta>().create();

export const appRouter = t.router({
  findByProductCode: t.procedure
    .meta({ openapi: { method: "GET", path: "/find" } })
    .input(z.object({ code: z.string() }))
    .output(z.object({ drink: z.object({ name: z.string() }) }))
    .query(async ({ input }) => {
      const drink = {
        name: "Old Fashioned",
      }
      return { drink: drink };
    }),
});

Add a new script to generate an OpenAPI document based on the tRPC router:

import { generateOpenApiDocument } from "trpc-openapi";

import { appRouter } from "./router";

export const openApiDocument = generateOpenApiDocument(appRouter, {
  title: 'tRPC OpenAPI',
  version: '1.0.0',
  baseUrl: 'http://localhost:3000',
});

Add a script to save the generated OpenAPI document to a file:

import { openApiDocument } from "./openapi";

console.log(JSON.stringify(openApiDocument, null, 2));

Run the script to generate an OpenAPI document:

ts-node generateOpenApi.ts > openapi-spec.json

Add this document to the package.json file as a script:

{
  "scripts": {
    "generate-openapi": "ts-node generateOpenApi.ts > openapi-spec.json"
  }
}

From now on, we can generate an OpenAPI document by running npm run generate-openapi.

When we inspect the generated OpenAPI document, we can see that it contains a single endpoint for the findByProductCode procedure, but it's missing a lot of information.

Let's see how we can improve this document.

How To Improve the OpenAPI Info Section

The OpenAPI info section contains information about the API, such as the title, description, and version. Speakeasy uses this information to create documentation and SDKs.

The GenerateOpenApiDocumentOptions type from trpc-openapi allows us to add this information to our OpenAPI document:

export type GenerateOpenApiDocumentOptions = {
    title: string;
    description?: string;
    version: string;
    baseUrl: string;
    docsUrl?: string;
    tags?: string[];
    securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes'];
};

We can add this information to our generateOpenApiDocument call:

import { generateOpenApiDocument } from "trpc-openapi";

import { appRouter } from "./router";

export const openApiDocument = generateOpenApiDocument(appRouter, {
  title: "Speakeasy Bar API",
  description: "An API to order drinks from the Speakeasy Bar",
  version: "1.0.0",
  baseUrl: "http://localhost:3000",
  docsUrl: "http://example.com/docs",
  tags: ["drinks"],
});

Run npm run generate-openapi and see how this information is added to the OpenAPI document:

{
  "openapi": "3.0.3",
  "info": {
    "title": "Speakeasy Bar API",
    "description": "An API to order drinks from the Speakeasy Bar",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "http://localhost:3000"
    }
  ],
  "tags": [
    {
      "name": "drinks"
    }
  ],
  "externalDocs": {
    "url": "http://example.com/docs"
  },
  // ...
}

Improving the OpenAPI Paths

We can improve our OpenAPI document by adding fields to the procedure's input and output schemas, and by adding examples, documentation, and metadata.

Expanding the Procedure’s Input and Output Schemas

Let's create a Drink model and add a few field types to see how these are represented in the OpenAPI document.

Create a new file called models.ts and specify a Drink model using Zod:

import { z } from "zod";

const DrinkType = z.enum([
    "NON_ALCOHOLIC",
    "BEER",
    "WINE",
    "SPIRIT",
    "OTHER",
]).describe('The type of drink');
type DrinkType = z.infer<typeof DrinkType>;

export const ProductCode = z.string().describe('The product code of the drink');
export type ProductCode = z.infer<typeof ProductCode>;

export const DrinkSchema = z.object({
    name: z.string().describe('The name of the drink'),
    type: DrinkType,
    price: z.number().describe('The price of the drink'),
    stock: z.number().describe('The number of drinks in stock'),
    productCode: ProductCode,
    description: z.string().nullable().describe('A description of the drink'),
});

export type Drink = z.infer<typeof DrinkSchema>;

Back in the router.ts file, import these models and update the procedure's input and output schemas. In the example app, we also added a mock database.

import { initTRPC } from "@trpc/server";
import { OpenApiMeta } from "trpc-openapi";
import { z } from "zod";

import { ProductCode, DrinkSchema } from "./models";
import { db } from "./db";  // Mock database

const t = initTRPC.meta<OpenApiMeta>().create();

export const appRouter = t.router({
  findByProductCode: t.procedure
    .meta({ openapi: { method: "GET", path: "/find" } })
    .input(z.object({ code: ProductCode }))
    .output(z.object({ drink: DrinkSchema.optional() }))
    .query(async ({ input }) => {
      const drink = await db.drink.findByProductCode(input.code);
      return { drink: drink };
    }),
});

If we regenerate the OpenAPI document, we can see that the Drink model is now included in the document with all of its fields:

{
   "paths": {
    "/find": {
      "get": {
        "operationId": "findByProductCode",
        "summary": "Find a drink by product code",
        "description": "Pass the product code of the drink to search for",
        "tags": [
          "drinks"
        ],
        "parameters": [
          {
            "name": "code",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The product code of the drink",
            "example": "1234"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "drink": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string",
                          "description": "The name of the drink"
                        },
                        "type": {
                          "type": "string",
                          "enum": [
                            "NON_ALCOHOLIC",
                            "BEER",
                            "WINE",
                            "SPIRIT",
                            "OTHER"
                          ],
                          "description": "The type of drink"
                        },
                        "price": {
                          "type": "number",
                          "description": "The price of the drink"
                        },
                        "stock": {
                          "type": "number",
                          "description": "The number of drinks in stock"
                        },
                        "productCode": {
                          "type": "string",
                          "description": "The product code of the drink"
                        },
                        "description": {
                          "type": "string",
                          "nullable": true,
                          "description": "A description of the drink"
                        }
                      },
                      "required": [
                        "name",
                        "type",
                        "price",
                        "stock",
                        "productCode",
                        "description"
                      ],
                      "additionalProperties": false
                    }
                  },
                  "additionalProperties": false
                },
                "example": {
                  "drink": {
                    "name": "Beer",
                    "type": "BEER",
                    "price": 5,
                    "stock": 10,
                    "productCode": "1234",
                    "description": "A nice cold beer"
                  }
                }
              }
            }
          },
          "default": {
            "$ref": "#/components/responses/error"
          }
        }
      }
    }
  },
  // ...
}

Speakeasy will use the descriptions in these fields to create documentation for the SDK.

OpenAPI Model Schemas in tRPC

At Speakeasy, we recommend using OpenAPI model schemas so that schemas are reusable across multiple procedures. This simplifies SDK code creation, makes it easier to maintain your OpenAPI document, and provides a better developer experience for your users.

At the time of writing, there is an open issue on the tRPC repository to add support for OpenAPI model schemas. Until this is implemented, we'll need to be content with the duplication of schemas across procedures.

Under the hood, trpc-openapi uses the Zod to Json Schema package, which supports custom strategies for generating references to schemas, but this functionality is not yet exposed in trpc-openapi.

Adding a Summary, Description, Examples, and Tags to a Procedure

We can enrich our OpenAPI document by adding a summary, description, examples, and tags to our procedure's metaobject.

The trpc-openapi package uses these fields to generate the summary, description, example, and tags fields in the OpenAPI document.

The example field is also used to add examples to the input schema.

import { initTRPC } from "@trpc/server";
import { OpenApiMeta } from "trpc-openapi";
import { z } from "zod";

import { ProductCode, DrinkSchema } from "./models";
import { db } from "./db";

const t = initTRPC.meta<OpenApiMeta>().create();

export const appRouter = t.router({
  findByProductCode: t.procedure
    .meta({
      openapi: {
        method: "GET",
        path: "/find",
        summary: "Find a drink by product code",
        description: "Pass the product code of the drink to search for",
        tags: ["drinks"],
        example: {
          request: {
            code: "1234",
          },
          response: {
            drink: {
              name: "Beer",
              type: "BEER",
              price: 5.0,
              stock: 10,
              productCode: "1234",
              description: "A nice cold beer",
            },
          },
        },
      },
    })
    .input(z.object({ code: ProductCode }))
    .output(z.object({ drink: DrinkSchema.optional() }))
    .query(async ({ input }) => {
      const drink = await db.drink.findByProductCode(input.code);
      return { drink: drink };
    }),
});

If we regenerate the OpenAPI document now, we can see that the summary, description, and example fields have been added to it.

Add Metadata to Tags

The trpc-openapi package defines tags as a list of strings, but OpenAPI allows you to add metadata to tags. For example, you can add a description or a link to documentation to a tag.

Since trpc-openapi uses the openapi-types package OpenAPIV3.Document type, which allows tags defined as a list of strings or a list of objects, we can extend our document to include tag objects with metadata even though trpc-openapi uses a list of strings.

Let's add a description to the drinks tag:

import { generateOpenApiDocument } from "trpc-openapi";

import { appRouter } from "./router";

const openApiDocument = generateOpenApiDocument(appRouter, {
  title: "Speakeasy Bar API",
  description: "An API to order drinks from the Speakeasy Bar",
  version: "1.0.0",
  baseUrl: "http://localhost:3000",
  docsUrl: "http://example.com/docs",
  tags: ["drinks"],
});

// add metadata to tags
openApiDocument.tags = [
  {
    name: "drinks",
    description: "Operations related to drinks",
  },
];

export { openApiDocument };

Now we can see that the description field has been added to the drinks tag in the generated OpenAPI document:

{
  "tags": [
    {
      "name": "drinks",
      "description": "Operations related to drinks"
    }
  ],
}

Add Speakeasy Extensions to Methods

The OpenAPI vocabulary can sometimes be insufficient for your code creation needs. For these situations, Speakeasy provides a set of OpenAPI extensions. For example, you may want to give an SDK method a different name from the OperationId. You can use the Speakeasy x-speakeasy-name-override extension to do so.

This time, unfortunately, the openapi-types package OperationObject type does not allow for custom extensions, so we need to add the extension to the generated OpenAPI document manually.

Ideally, we would create a new type that extends OpenAPIV3.Document and adds the x-speakeasy-name-override extension to the OperationObject type, but for this tutorial, we'll keep it simple and add the extension by casting the path item to any.

Let's add an x-speakeasy-name-override extension to the findByProductCode procedure.

First, we extend the OpenAPIV3.OperationObject and OpenAPIV3.Document types to add the x-speakeasy-name-override and other extensions:

import { OpenAPIV3 } from 'openapi-types';

export type IExtensionName = `x-${string}`;
export type IExtensionType = any;
export type ISpecificationExtension = {
    [extensionName: IExtensionName]: IExtensionType;
};

export type ExtendedDocument = OpenAPIV3.Document & ISpecificationExtension;
export type ExtendedOperationObject = OpenAPIV3.OperationObject<ISpecificationExtension>;

Then we import our extended operation type and add the x-speakeasy-name-override extension to the findByProductCode procedure:

import { generateOpenApiDocument } from "trpc-openapi";

import { ExtendedOperationObject } from "./extended-types";
import { appRouter } from "./router";

const openApiDocument = generateOpenApiDocument(appRouter, {
  title: "Speakeasy Bar API",
  description: "An API to order drinks from the Speakeasy Bar",
  version: "1.0.0",
  baseUrl: "http://localhost:3000",
  docsUrl: "http://example.com/docs",
  tags: ["drinks"],
});

// add metadata to tags
openApiDocument.tags = [
  {
    name: "drinks",
    description: "Operations related to drinks",
  },
];

if (
  openApiDocument.paths &&
  openApiDocument.paths["/find"] &&
  openApiDocument.paths["/find"].get
) {
  (openApiDocument.paths["/find"].get as ExtendedOperationObject)[
    "x-speakeasy-name-override"
  ] = "searchDrink";
}

export { openApiDocument };

Add Retries to an SDK With x-speakeasy-retries

Speakeasy can create SDKs that follow custom rules for retrying failed requests. For instance, if your server fails to return a response within a specified time, you may want your users to retry their request without clobbering your server.

Add retries to Speakeasy-created SDKs by adding a top-level x-speakeasy-retries schema to your OpenAPI spec. You can also override the retry strategy per operation by adding x-speakeasy-retries.

Adding Global Retries and Retries per Endpoint

Let's add a global retry strategy to our OpenAPI document and override it for our findByProductCode procedure.

import { generateOpenApiDocument } from "trpc-openapi";

import { ExtendedDocument, ExtendedOperationObject } from "./extended-types";
import { appRouter } from "./router";

const openApiDocument = generateOpenApiDocument(appRouter, {
  title: "Speakeasy Bar API",
  description: "An API to order drinks from the Speakeasy Bar",
  version: "1.0.0",
  baseUrl: "http://localhost:3000",
  docsUrl: "http://example.com/docs",
  tags: ["drinks"],
});

// add metadata to tags
openApiDocument.tags = [
  {
    name: "drinks",
    description: "Operations related to drinks",
  },
];

(openApiDocument as ExtendedDocument)["x-speakeasy-retries"] = {
  strategy: "backoff",
  backoff: {
    initialInterval: 500,
    maxInterval: 60000,
    maxElapsedTime: 3600000,
    exponent: 1.5,
  },
  statusCodes: ["5XX"],
  retryConnectionErrors: true,
};

if (
  openApiDocument.paths &&
  openApiDocument.paths["/find"] &&
  openApiDocument.paths["/find"].get
) {
  (openApiDocument.paths["/find"].get as ExtendedOperationObject)[
    "x-speakeasy-name-override"
  ] = "searchDrink";
  (openApiDocument.paths["/find"].get as ExtendedOperationObject)[
    "x-speakeasy-retries"
  ] = {
    strategy: "backoff",
    backoff: {
      initialInterval: 500,
      maxInterval: 60000,
      maxElapsedTime: 3600000,
      exponent: 1.5,
    },
    statusCodes: ["5XX"],
    retryConnectionErrors: true,
  };
}

export { openApiDocument };

Regenerate the OpenAPI document and you can see that the x-speakeasy-retries field has been added to the document.

{
  "x-speakeasy-retries": {
    "strategy": "backoff",
    "backoff": {
      "initialInterval": 500,
      "maxInterval": 60000,
      "maxElapsedTime": 3600000,
      "exponent": 1.5
    },
    "statusCodes": [
      "5XX"
    ],
    "retryConnectionErrors": true
  },
  // ...
}

How To Create an SDK Based on the OpenAPI Spec

After following the steps above, we have an OpenAPI spec that is ready to use as the basis for a new SDK. Now we'll use Speakeasy to create an SDK.

In the root directory of your project, run the following:

speakeasy generate sdk \
    --schema openapi-spec.json \
    --lang typescript \
    --out ./sdk

This command uses Speakeasy to create a new TypeScript SDK based on the OpenAPI spec we generated and saved as openapi-spec.json. The Speakeasy CLI saves the new SDK in the ./sdk directory.

Add SDK Creation to GitHub Actions

The Speakeasy sdk-generation-action repository provides workflows to integrate the Speakeasy CLI in your CI/CD pipeline so that your client SDKs are recreated when your OpenAPI spec changes.

You can set up Speakeasy to automatically push a new branch to your SDK repositories so that your engineers can review and merge the SDK changes.

For an overview of how to set up automation for your SDKs, see the Speakeasy SDK Generation Action and Workflows documentation.