One point of friction that often slows down engineering teams is a misunderstanding about API contracts.
Whether it’s hotels or online dating, as engineering teams learn more about the domain they’re working in, they’ll need to adjust parts of the application’s data model. Sometimes, this is to correct misconceptions that they originally baked into their APIs. Other times, it’s to add new areas of the domain to that they haven’t encountered before.
Bit by bit, the team’s original mental model of the domain will shift, especially as APIs are deprecated or become bloated with old data.
When this happens, having a shared API contract across the engineering team is very valuable.
As requirements change and the application grows, the contract serves as a guidepost for engineers on both sides of the stack.
This blog outlines three possible approaches to enforcing and sharing a schema in order to enforce consistency in API design.
OpenAPI (aka Swagger)
Swagger, recently renamed to OpenAPI, is an open source API specification.
When using OpenAPI, engineers can write these contract documents first, often as YAML or JSON, before implementing them in code.
This lets engineers use those contracts in code generation tools to build common boilerplate, validation middleware, cross-language type safety, and tests. It also frees engineers to focus the application’s business logic.
In a full-stack Typescript application, a contract for a users endpoint might look like this:
openapi: 3.0.0
info:
title: Typescript Full Stack OpenAPI Template
version: 0.0.1
paths:
/api/v1/users/{userId}:
get:
summary: Get a user by their ID
parameters:
- in: path
name: userId
schema:
type: integer
required: true
description: Numeric ID of the user
responses:
'200':
description: A JSON object of a single user
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/api/v1/users:
get:
summary: Returns a list of users.
responses:
'200':
description: A JSON array of user names
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
required:
- id
- first_name
- last_name
properties:
first_name:
type: string
last_name:
type: string
id:
type: number
This schema can then be fed into a code generation tool like openapi-ts to generate clients automatically. The types generated by this library can also be shared across the stack.
For instance, here is part of the automatically generated types.gen.ts
file from running openapi-ts
to build the application:
export type User = {
first_name: string;
last_name: string;
id: number;
};
export type GetApiV1UsersByUserIdResponse = User;
These types can be used across the stack to improve developer tooling. This tool can also generate client code directly, like this:
import { getApiV1Users } from "@/api"
const { data } = await getApiV1Users()
In this example, the data
variable is explicitly typed as GetApiV1UsersByUserIdResponse
from the generated types. On the server, we can use a library like express-openapi-validator to validate that our API responses adhere to this schema:
import * as OpenApiValidator from 'express-openapi-validator'
import { fileURLToPath } from 'url'
import path from 'path'
const __filename = fileURLToPath(import.meta.url)
const spec = path.join(path.dirname(__filename), '..', 'api.yaml')
export default OpenApiValidator.middleware({
apiSpec: spec,
validateRequests: true,
validateResponses: true,
})
This middleware validates that responses from the server conform to the schema.
When to Use It
The OpenAPI specification and tooling around it is very stable, and it’s a great default choice since many different languages can interface with OpenAPI specifications and generate code from it.
It’s also a great choice if you anticipate that the application’s APIs will be exposed externally, and you want to generate documentation automatically.
When not to Use It
Writing YAML or JSON schemas directly instead of typing APIs in your language of choice can be a bit clunky. Approaches that use Remote Call Procedures, like TRPC, are schema-first alternatives that may provide a better developer experience if your entire application is written in one language.
RPCs and TRPC
TRPC, or TypeScript remote procedure call, is a newer technology than OpenAPI, and a good option for engineering teams that are working exclusively in Typescript and want to define their API contracts in the same language. The approach here is also applicable with GRPC and other RPC based approaches, but TRPC has an advantage in that the client code can be written in the same language as the schema files.
Like other RPCs, TRPC defines contracts at the method level, rather than in an external schema file like OpenAPI.
This makes the developer tooling top notch, as TPRC integrates seamlessly in real-time (no build step) with LSPs and other editor tools. This can make iterating on API designs faster than when using an OpenAPI specification.
To use TRPC, we can put our schema and the router code in the same file:
import { z } from 'zod';
import { trpc } from 'trpc';
const User = z.object({
id: z.number(),
first_name: z.string(),
last_name: z.string(),
});
export const usersRouter = trpc.router({
getSingleUser: trpc.query
.query('getSingleUser')
.param('userId', z.number())
.queryFn(({ userId }) => {
// Your logic to fetch a user by ID
return { id: userId, first_name: 'John', last_name: 'Doe' };
}),
getUsers: trpc.query.query('getUsers').queryFn(() => {
// Your logic to fetch a list of users
return [{ id: 1, first_name: 'Alice', last_name: 'Smith' }, { id: 2, first_name: 'Bob', last_name: 'Johnson' }];
}),
});
Defining our API contracts and server logic in one place improves the locality of our code. We could layer additional tooling on top of TRPC to export an OpenAPI schema too, to get the best of both worlds.
When to Use It
When building a full-stack application that uses the same language on both sides of an API.
When not to Use It
It might not be the right fit if when working in multiple languages and the schema needs to be language agnostic. By defining the specification in Typescript using Zod, we require engineers to have specific language expertise when domain modeling, which may not always be desirable, particularly if your backend engineers are well-versed in a different language. In those cases, you may prefer to use YAML or JSON for it’s language-agnostic flexibility.
AsyncAPI
AsyncAPI is an offshoot of the OpenAPI specification focused specifically on event-driven architectures.
Like OpenAPI, AsyncAPI expects you to define a schema file for your APIs. However, since AsyncAPI is focused on event-driven architectures, it provides better primitives for dealing with asynchronous code, such as different protocols and support for producers and consumers, rather than servers and clients.
Here’s what an AsyncAPI YAML file looks like:
asyncapi: 3.0.0
info:
title: Some example API
version: 0.1.0
channels:
user:
address: 'users.{userId}'
title: Users channel
description: This channel is used to exchange messages about user events.
messages:
userSignedUp:
$ref: '#/components/messages/userSignedUp'
userCompletedOrder:
$ref: '#/components/messages/userCompletedOrder'
parameters:
userId:
$ref: '#/components/parameters/userId'
servers:
- $ref: '#/servers/production'
bindings:
amqp:
is: queue
queue:
exclusive: true
tags:
- name: user
description: User-related messages
externalDocs:
description: 'Find more info here'
url: 'https://example.com'
In this example, we define a user
channel used to exchange messages about users. The different types of messages that the channel expects
and their shapes are defined within. The AsyncAPI project has tooling that you can use to
convert these files into client libraries and boilerplate code.
When to Use It
For enforcing type safety in event-driven architectures. It’s a handy extension of the OpenAPI specification.