Open Timothy-Gonzalez opened 1 month ago
OpenAPI spec seems to be the standard. Also works easily with a bunch of documentation tools like Swagger. Even better, we can defined the spec and convert to ts interfaces using openapi-typescript.
Alternative is zod, need to investigate which will work best.
Investigated - openapi is the easier to work with, especially if we want to use Swagger. If we use openapi, we can also use it for pretty much any other documentation generation. This will also enable using openapi clients for the other systems teams, so they can have a much easier time using our api (would require them to convert over to an openapi client, though).
Note that ref can also be used with other files, making modularity easy.
There's 3 ways we can do openapi:
Zod -> ts types & validation & openapi spec
import { generateSchema } from '@anatine/zod-openapi';
const aZodSchema = z.object({
uid: z.string().nonempty(),
firstName: z.string().min(2),
lastName: z.string().optional(),
email: z.string().email(),
phoneNumber: z.string().min(10).optional(),
})
const myOpenApiSchema = generateSchema(aZodSchema);
Generates:
{
"type": "object",
"properties": {
"uid": {
"type": "string",
"minLength": 1
},
"firstName": {
"type": "string",
"minLength": 2
},
"lastName": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"phoneNumber": {
"type": "string",
"minLength": 10
}
},
"required": [
"uid",
"firstName",
"email"
]
}
ts types -> openapi spec
// src/users/usersController.ts
import {
Body,
Controller,
Get,
Path,
Post,
Query,
Route,
SuccessResponse,
} from "tsoa";
import { User } from "./user";
import { UsersService, UserCreationParams } from "./usersService";
@Route("users")
export class UsersController extends Controller {
@Get("{userId}")
public async getUser(
@Path() userId: number,
@Query() name?: string
): Promise<User> {
return new UsersService().get(userId, name);
}
@SuccessResponse("201", "Created") // Custom success response
@Post()
public async createUser(
@Body() requestBody: UserCreationParams
): Promise<void> {
this.setStatus(201); // set return status 201
new UsersService().create(requestBody);
return;
}
}
There's a ton of other options, but I'd like to choose something that doesn't overcomplicate the API and avoids restructuring everything. Mainly, more of library than a framework.
Swagger makes this very nice:
Also, no need to use postman/insomniac/etc, you can just try requests:
I conducted an investigation into several alternatives by implementing them for a simple CRUD Users app.
The full thing is very verbose, so let me summarize.
First of all, Zod is awesome for validation and we should use it. There are other solutions like Typebox / json schema but they are much harder to use. Zod also integrated natively with ts types. (You can do type a = z.infer<typeof ZodSchema>
to get the type from a schema. Here's an example:
export const UserSchema = z.object({
id: UserIdSchema,
userName: z
.string()
.min(1)
.max(20)
.regex(/^[a-z0-9]+$/, "Username must be alphanumeric"),
displayName: z.string().min(1).max(50),
});
export type User = z.infer<typeof UserSchema>;
I also determined we should use OpenAPI (a documentation specification based off of json schemas), since it is widely supported by a lot of tooling, namely Swagger (see above) which makes docs much nicer. Also, generation tooling around OpenAPI is great, so with just the spec you can get a client that lets you do client.getUser(id)
for example, without writing any code.
The difficult part was figuring out HOW to integrate this together with our application (currently express).
There are two main options:
The problem with the first option is the tooling for directly editing yaml/json aren't great, and modularizing the files requires a lot of ugly $ref-ing or a merge step which breaks the very little intellesense you get.
So, we're left with the second option. There's a lot of options in this subset, but it's more new. There's a lot of ways to do annotation through decorators, explicit definition, etc.
There's two main parts we need to convert into the spec: components and paths. Paths are routes like GET /user/
which need to define what they take in, and what they receive. Components are objects used in paths, like a User
component.
For components, we can use a zod conversion library which adds metadata:
export const UserSchema = z.object({
id: UserIdSchema,
userName: z
.string()
.min(1)
.max(20)
.regex(/^[a-z0-9]+$/, "Username must be alphanumeric")
.openapi({
description: "The user's username, which must be alphanumeric",
example: "username1",
}),
displayName: z.string().min(1).max(50).openapi({
description: "The user's display name, with no limitations on characters allowed",
example: "1 Full Display Name",
}),
}).openapi({
ref: "User",
description: "A user"
});
Note that descriptions and examples can be added easily, and the ref
is used to add the schema to the generated document.
The last part we need to generate is paths, which there are many tools for.
One popular choice is tsoa which through decorators, you can define routes. The problem is this doesn't work very well with zod. This could be viable alternative, but the decorators are very verbose and not type-checked well.
One option is registering them directly in ts, next to route definitions:
Registry.registerPath({
method: "post",
path: "/users",
summary: "Create a new user",
request: {
body: {
content: {
"application/json": {
schema: UserSchema,
},
},
},
},
responses: {
200: {
description: "Successfully created user",
content: {
"application/json": {
schema: UserSchema,
},
},
},
},
});
usersRouter.post("/", validateRequestBody(UserSchema), (req, res) => {
const newUser = createUser(req.body);
res.status(200).json(newUser);
});
The better alternative is using express middleware:
usersRouter.post(
"/",
specification({
summary: "Create a new user",
body: UserSchema,
responses: {
200: {
description: "Successfully created user",
schema: UserSchema,
},
},
}),
(req, res) => {
const newUser = createUser(req.body);
res.status(200).json(newUser);
},
);
However, this requires a lot of hacky code, and isn't as pretty behind the scenes. It feels like this really pushes express to the limit of what it was meant for.
Alternatively, using a REST library that support schema validation natively might be a better solution. Here's Fastify:
app.post(
"/",
{
schema: {
description: "Create a new user",
body: UserSchema,
response: {
200: UserSchema.openapi({
description: "Successfully created user",
}),
},
},
},
(req, _reply) => {
return createUser(req.body);
},
);
Fastify also supports proper typing, and allows you to return for successful requests without the whole .status .send:
app.get(
"/:id",
{
schema: {
description: "Get a user by id",
params: z.object({
id: UserIdSchema,
}),
response: {
200: UserSchema.openapi({
description: "Successfully got user",
}),
404: UserNotFoundErrorSchema,
},
},
},
(req, reply) => {
const user = getUser(req.params.id);
if (!user) {
reply.status(404).send(UserNotFoundError);
}
return user;
},
);
In conclusion, OpenAPI and Zod are great tools we should use. For generation, we either stay with express and release the demons, or switch everything over to Fastify for questionable benefit. Whatever solution we choose, something in the middle won't really work. The whole point of this change is to have strict parity between the docs and code while changing as little as possible and keeping the codebase easy to work with.
Long term, I think the express middleware without the hacky part makes sense. To make this migration easy, there are 3 parts:
specification
middleware, creating schemas as needed
Right now, types are a suggestion and we cast for nearly every request. We should have strict typing for requests and responses, and not be writing validation code for types.