Using OpenAPI to Detect Breaking Changes in tRPC
Roland Chelwing
Posted on October 16, 2023
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.
“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.
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;
});
}),
});
Generating the OpenAPI Specification
First of all, we will require some tooling. Let’s install some dependencies:
pnpm install -D trpc-openapi
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;
}),
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.");
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 */
}
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.
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
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
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
--------------------------------------------------------------------------
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
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
--------------------------------------------------------------------------
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:
Generate our OpenAPI specification and commit it to our branch.
Run the OpenAPI Diff tool and compare our branch schema with the main schema.
Setting it up:
Create a workflows directory: /.github/workflows.
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
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.
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!
Posted on October 16, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.