-
Notifications
You must be signed in to change notification settings - Fork 17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add strict type enforcement for requests & responses #218
Comments
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). Expandopenapi: "3.0.0"
info:
version: 1.0.0
title: Swagger Petstore
description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification
termsOfService: http://swagger.io/terms/
contact:
name: Swagger API Team
email: apiteam@swagger.io
url: http://swagger.io
license:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
servers:
- url: https://petstore.swagger.io/v2
paths:
/pets:
get:
description: |
Returns all pets from the system that the user has access to
Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia.
Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien.
operationId: findPets
parameters:
- name: tags
in: query
description: tags to filter by
required: false
style: form
schema:
type: array
items:
type: string
- name: limit
in: query
description: maximum number of results to return
required: false
schema:
type: integer
format: int32
responses:
'200':
description: pet response
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
description: Creates a new pet in the store. Duplicates are allowed
operationId: addPet
requestBody:
description: Pet to add to the store
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NewPet'
responses:
'200':
description: pet response
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/pets/{id}:
get:
description: Returns a user based on a single ID, if the user does not have access to the pet
operationId: find pet by id
parameters:
- name: id
in: path
description: ID of pet to fetch
required: true
schema:
type: integer
format: int64
responses:
'200':
description: pet response
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
description: deletes a single pet based on the ID supplied
operationId: deletePet
parameters:
- name: id
in: path
description: ID of pet to delete
required: true
schema:
type: integer
format: int64
responses:
'204':
description: pet deleted
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Pet:
allOf:
- $ref: '#/components/schemas/NewPet'
- type: object
required:
- id
properties:
id:
type: integer
format: int64
NewPet:
type: object
required:
- name
properties:
name:
type: string
tag:
type: string
Error:
type: object
required:
- code
- message
properties:
code:
type: integer
format: int32
message:
type: string Note that ref can also be used with other files, making modularity easy. There's 3 ways we can do openapi:
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. |
IntroI 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. ValidationFirst 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 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>; DocumentationI 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 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 Generating ComponentsFor 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 Generating PathsThe 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;
},
); ConclusionIn 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:
|
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.
The text was updated successfully, but these errors were encountered: