Using OpenAPI to Detect Breaking Changes in tRPC

fralleee

Roland Chelwing

Posted on October 16, 2023

Using OpenAPI to Detect Breaking Changes in tRPC

Building a new service in a monorepo can be quite an adventure. Picture this: within the digital walls of our monorepo, we were meticulously crafting two applications — a Next.js app and an Expo mobile app. As a modern-day dynamic duo, these two had a unique relationship. The Next.js app hosted a tRPC server, which both the web and mobile apps leaned on.

Honest Abe taking a moment’s pause to consider the ramifications of adding the mobile app.

“Give me six hours to debug, and I will use the first four blaming the monorepo.” — Abraham Lincoln

However, there was a small catch. Our mobile app was a tad sensitive. Whenever our tRPC server introduced breaking changes to the API, the mobile app would, understandably, throw a mini tantrum. These issues would be effortlessly flagged during code reviews in a perfect world. But let’s be real; we don’t live in a utopia. Relying solely on code reviews is like trusting your dog around a plate of bacon — sometimes, temptations win.

The solution to this issue involves a three-step plan:

  • Extract an OpenAPI spec from the tRPC router, utilizing zod validation to map the input/output

  • Compare the newly-minted OpenAPI spec from the pull request to its counterpart in the main branch using the OpenAPI-Diff tool.

  • Because we’re fans of efficiency, we will automate the entire process.

Let's address this step by step with some example code. Keeping it light and fun.

*If you’re bored and want to jump straight to the coded solution, here you go:
https://github.com/Fralleee/trpc-breaking-changes-detection

Setting the stage

Before diving into the technicalities, we need to ensure that everyone is on the same page regarding the setup. We will be using a Next.js app with a tRPC server set up.

Although detecting breaking changes might seem redundant for an app that solely consumes its own API, presenting this solution offers clarity without excessive complexity.

Our app is a straightforward Todo application equipped with a basic tRPC router and user-friendly visuals for managing todo items. The app itself does not matter. We’ll be looking at tRPC routes and workflows.

Not that it matters. But this is what the app looks like.

The tRPC router contains basic validation using Zod.

    import { z } from "zod";
    import { router, publicProcedure } from "@/server/trpc";
    import { todos as initialData } from "@/server/db";

    let todos = [...initialData];

    export const todoRouter = router({
      getTodos: publicProcedure.query(() => todos),
      addTodo: publicProcedure
        .input(z.object({ content: z.string() }))
        .mutation(({ input }) => {
          todos.push({
            id: todos.length + 1,
            content: input.content,
            done: false,
          });
          return true;
        }
      ),
      setDone: publicProcedure
        .input(z.object({ id: z.number(), done: z.boolean() }))
        .mutation(({ input }) => {
          todos = todos.map(todo => {
            if (todo.id === input.id) {
              return { ...todo, done: input.done };
            }
            return todo;
          });
        }),
    });
Enter fullscreen mode Exit fullscreen mode

Generating the OpenAPI Specification

First of all, we will require some tooling. Let’s install some dependencies:

    pnpm install -D trpc-openapi
Enter fullscreen mode Exit fullscreen mode

While trpc-openapi originally was used to expose REST endpoints of the tRPC router, we will use it to generate an OpenAPI specification for our API.

Why OpenAPI specification, you might ask? Using standards is cool and will let us use many pre-built tools for this purpose.

For this magic trick, we’ll sprinkle some meta-information on our routes and define our inputs *and *outputs, courtesy of zod.

Here’s a peek at our getTodos route:

      getTodos: publicProcedure
        // This little gem helps the generateOpenApiDocument function 
        // locate the route
        .meta({
          openapi: {
            method: "GET",
            path: "/todo.getTodos",
          },
        })
        // Even in the void of input, we specify its presence
        .input(z.void())
        // And voila! The output reveal
        .output(z.array(z.object({ id: z.number(), content: z.string(), done: z.boolean() })))
        .query(() => {
          return todos;
        }),
Enter fullscreen mode Exit fullscreen mode

After this revamp, all our routes align like stars, ensuring our OpenAPI specification is on point.

The next act? Schema generation. Into our repository, we tuck in a scripts folder and craft a typescript scroll titled generate-schema.ts.

    import { generateOpenApiDocument } from "trpc-openapi";
    import path from "path";
    import { writeFileSync, existsSync, mkdirSync } from "fs";
    import { appRouter } from "../app/server";

    const filePath = path.resolve("./", "schema.json");
    const dirname = path.dirname(filePath);
    if (!existsSync(dirname)) {
      mkdirSync(dirname, { recursive: true });
    }

    const openApiDocument = generateOpenApiDocument(appRouter, {
      title: "tRPC OpenAPI",
      version: "1.0.0",
      baseUrl: "http://localhost:3000/api/trpc",
      description: "tRPC OpenAPI example"
    });

    const schemaString = JSON.stringify(openApiDocument, null, 2);
    writeFileSync(filePath, schemaString);

    console.log("OpenAPI document generated successfully.");
Enter fullscreen mode Exit fullscreen mode

This bit here? It’s elementary! We sketch a path, forge the OpenAPI specification, and etch it into existence.

And now, for the grand reveal — our schema:

    {
      "openapi": "3.0.3",
      "info": {
        "title": "tRPC OpenAPI",
        "description": "tRPC OpenAPI example",
        "version": "1.0.0"
      },
      "servers": [
        {
          "url": "http://localhost:3000/api/trpc"
        }
      ],
      "paths": {
        "/todo.getTodos": {
          "get": {
            "operationId": "todo-getTodos",
            "parameters": [],
            "responses": {
              "200": {
                "description": "Successful response",
                "content": {
                  "application/json": {
                    "schema": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "number"
                          },
                          "content": {
                            "type": "string"
                          },
                          "done": {
                            "type": "boolean"
                          }
                        },
                        "required": [
                          "id",
                          "content",
                          "done"
                        ],
                        "additionalProperties": false
                      }
                    }
                  }
                }
              },
              "default": {
                "$ref": "#/components/responses/error"
              }
            }
          }
        }
        /* Rest of the schema */
    }
Enter fullscreen mode Exit fullscreen mode

Every nook and cranny of this schema is carved from our meticulous zod specification. Now, let’s put this bad boy into use.

Detect breaking changes

Let’s talk about something as fragile as a house of cards: breaking changes. The last thing you want is your robust software architecture crumbling down because of a single overlooked alteration. Think of it like adding a new floor to your house without checking if the foundation can handle the weight. The results? Catastrophic.

To embark on this venture, let’s tweak our schematic naming a bit: rename schema.json to schema_v1.json.

Up next, we’ll enrich our getTodos route with an optional description property and generate a fresher schema. Let's christen this new creation as schema_nonbreaking.json.

But let’s also play the villain for a bit: we’ll strip the done property and spawn another schema, giving it the foreboding name, schema_breaking.json.

Honest Abe’s not-so-honest face when he sees a breaking change.

Armed with these files, we’re all set for some action.

Introducing our star player for detecting breaking changes: OpenAPI Diff. The good news? You can fire it up locally with Docker. Here’s how:

    docker run -v <PATH_TO_YOUR_REPO>:/opt/specs openapitools/openapi-diff /opt/specs/schema_v1.json /opt/specs/schema_v1.json
Enter fullscreen mode Exit fullscreen mode

Doing a quick mirror match, we pit the same schema against itself. As expected, the result bellows:

No differences. Specifications are equivalents

Now, how about we test the waters with our nonbreaking schema?

    docker run -v <PATH_TO_YOUR_REPO>:/opt/specs openapitools/openapi-diff /opt/specs/schema_v1.json /opt/specs/schema_nonbreaking.json
Enter fullscreen mode Exit fullscreen mode

The verdict is out:

==========================================================================
    ==                            API CHANGE LOG                            ==
    ==========================================================================
                                   tRPC OpenAPI
    --------------------------------------------------------------------------
    --                            What's Changed                            --
    --------------------------------------------------------------------------
      Return Type:
        - Changed 200 OK
          Media types:
            - Changed application/json
              Schema: Backward compatible
    - GET    /todo.getTodos
                       API changes are backward compatible
    --------------------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

And now, the moment of truth against our breaking changes:

docker run -v <PATH_TO_YOUR_REPO>:/opt/specs openapitools/openapi-diff /opt/specs/schema_v1.json /opt/specs/schema_breaking.json
Enter fullscreen mode Exit fullscreen mode

And it’s a heartbreak:

==========================================================================
    ==                            API CHANGE LOG                            ==
    ==========================================================================
                                   tRPC OpenAPI
    --------------------------------------------------------------------------
    --                            What's Changed                            --
    --------------------------------------------------------------------------
      Return Type:
        - Changed 200 OK
          Media types:
            - Changed application/json
              Schema: Broken compatibility
              Missing property: [n].done (boolean)
    - GET    /todo.getTodos
                     API changes broke backward compatibility
    --------------------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

It’s not fun when stuff breaks.

Feeling the chill down your spine when things snap? Us too. Now that we’ve established the efficacy of our detective tool let’s transition to ensuring we don’t have to play detective every single time. Cue automation.

Automation

Harnessing the power of GitHub Actions, we can set up an automated flow to detect API changes swiftly.

Here are the steps involved:

  1. Generate our OpenAPI specification and commit it to our branch.

  2. Run the OpenAPI Diff tool and compare our branch schema with the main schema.

Setting it up:

  1. Create a workflows directory: /.github/workflows.

  2. Inside, create a YAML file named openapi-diff.yml.

Here’s a breakdown of the workflow:

    name: API Breaking Changes Check

    on:
      pull_request:
        types:
          - opened
          - reopened
          - synchronize
          - ready_for_review

    jobs:
      generate-schema:
        if: "!github.event.pull_request.draft"
        runs-on: ubuntu-latest
        outputs:
          LATEST_SHA: ${{ env.LATEST_SHA }}
          SCHEMA_CHANGED: ${{ env.SCHEMA_CHANGED }}
        steps:
          - name: 📥 Check out head branch
            uses: actions/checkout@v4
            with:
              ref: ${{ github.head_ref }}
              fetch-depth: 0

          - name: 📦 Setup pnpm
            uses: pnpm/action-setup@v2
            with:
              version: 8

          - name: 🛠️ Setup Node.js
            uses: actions/setup-node@v3
            with:
              node-version: "20.7"
              cache: "pnpm"

          - name: 📦 Install dependencies for @nira/dsa-web
            run: pnpm install --filter @nira/dsa-web

          - name: ✍️ Generate Schema
            run: npm run generate-schema --prefix apps/web

          - name: 🚀 Commit generated schema
            run: |
              git config user.name github-actions
              git config user.email github-actions@github.com
              git add apps/web/schema.json
              if git commit -m "chore: generated schema [no ci]"; then
                echo "SCHEMA_CHANGED=true" >> $GITHUB_ENV
              else
                echo "No changes to commit"
              fi
              git push

          - name: Capture last commit SHA
            run: echo "LATEST_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV

      schema-diff:
        needs: generate-schema
        if: "!github.event.pull_request.draft && needs.generate-schema.outputs.SCHEMA_CHANGED == 'true'"
        runs-on: ubuntu-latest
        steps:
          - name: 📥 Check out head branch at latest SHA
            uses: actions/checkout@v4
            with:
              ref: ${{ needs.generate-schema.outputs.LATEST_SHA }}
              path: head

          - name: 📥 Check out master branch
            uses: actions/checkout@v4
            with:
              ref: main
              path: base

          - name: ⚖️ Run OpenAPI Diff (from HEAD rev)
            uses: docker://openapitools/openapi-diff:latest
            with:
              args: --fail-on-incompatible base/apps/web/schema.json head/apps/web/schema.json
Enter fullscreen mode Exit fullscreen mode

This action is tailored to run only on non-draft pull requests, essentially, those that are primed for review or merging.

The [no ci] tag in the commit message ensures we don't get trapped in an endless workflow loop, which, though amusing, isn't our objective here.

After committing the new schema, we capture the latest commit SHA to ensure that subsequent jobs in the workflow are aware of the most recent changes.

In the schema-diff job, we use the captured commit SHA to checkout the latest version of the PR branch. This ensures the job operates on the updated schema.

At the end, we’ll receive a nonblocking check. This serves as an alert, spotlighting any breaking changes made to our API.

Final thoughts

In today's ever-evolving tech landscape, the necessity for swift iterations and releases cannot be overstated. However, the cost of recklessness, especially when it comes to API changes, can be staggering. By implementing the processes and automation outlined in this article, we can take a proactive stance against unwanted and unexpected breaking changes.

Using the combined prowess of tRPC, OpenAPI, and GitHub Actions, we’ve paved the way to ensure that our APIs evolve without inadvertently crippling dependent systems. But beyond the technicalities, the core lesson here is about mindfulness. It’s about ensuring that our drive for innovation doesn’t steamroll our commitment to stability.

If Abraham Lincoln were a modern-day developer, he might jest, “Four scores and seven pull requests ago, our predecessors brought forth a glitch-free API.” While he wouldn’t be donning spectacles to pore over OpenAPI specs, Honest Abe’s legendary integrity could inspire us to ensure our code remains unbroken.

Coding or checking out memes? We might never know.

So, the next time you’re on the brink of pushing a new update or tinkering with an existing endpoint, remember the tools and strategies at your disposal. Embrace change, but do it with eyes wide open.

Found a different approach to handling breaking changes or automating processes? I’d love to hear from you. Drop your methods or tools below!

💖 💪 🙅 🚩
fralleee
Roland Chelwing

Posted on October 16, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related