@sonic-tech/catena
v0.2.0
Published
A lightweight and extensible library for building robust Node.js APIs, fast.
Downloads
1,247
Readme
Build type-safe APIs
Catena is an lightweight library for building end-to-end type-safe APIs on top of Express. It's inspired by tRPC but unlike tRPC, you can just plug it into your existing Express codebase. The main goal is to have a unified chain of useful steps to safely handle requests with a great DX and minimal overhead.
Installation
- Install Catena and its peer dependencies
npm i @sonic-tech/catena zod express
- Also make sure that you have Express' types installed, if you want to leverage the full type-safeness.
npm i -D @types/express
Documentation
Examples
A simple handler
The first example is as simple as it gets. Looks like a normal express handler and also does the same.
// ...
import { Handler } from '@sonic-tech/catena'
const app = express()
app.use(express.json())
app.get(
'/',
new Handler()
.resolve((req, res) => {
res.status(200).send('Hello World')
})
// Make sure that `.express()` always is the last method in the handler chain. It converts the logical chain into an express handler.
.express()
)
Validating requests
There are many occasions where you want to validate parts of the incoming data. Here's how Catena does it.
// ...
import { Handler } from '@sonic-tech/catena'
const app = express()
app.use(express.json());
app.post(
'/user/:uuid',
new Handler().
.validate("params", {
uuid: z.string().uuid()
})
.validate("body", {
username: z.string().optional(),
age: z.number().min(13).optional(),
email: z.string().email(),
})
.resolve(async (req, res) => {
// All 4 properties are strongly typed based on the zod schemas
const { uuid } = req.params
const { username, age, email } = req.body
// ...
res.status(200).send("User created!")
})
.express()
)
Transformers
If you want to have a secure and unified way of returning data to the client, use transformers. Transformers let you create and send JSON DTOs based on the data that the resolver returned.
This is the recommended way of returning data to the client.
// ...
import { Handler } from '@sonic-tech/catena'
const app = express()
app.use(express.json());
app.get(
'/user/:uuid',
new Handler().
.validate("params", {
uuid: z.string().uuid()
})
.resolve(async (req, res) => {
const userIncludingPassword = await UserService.getUser(req.uuid)
return userIncludingPassword
})
.transform((data) => {
return {
data: {
uuid: data.uuid,
email: data.email
}
}
})
.express()
)
Middlewares
Catena extends middlewares by establishing a shared context that can be passed from one middleware to another and finally to the resolver. You can write inline middlewares or just pass in a function.
// ...
import { Handler, HTTPError } from '@sonic-tech/catena'
import { AnotherMiddleware } from "..."
const app = express()
app.use(express.json());
app.get(
'/user/:uuid',
new Handler().
.validate("params", {
uuid: z.string().uuid()
})
.validate("headers", {
// key in header validations must always be lower-case!
authorization: z.string()
})
.middleware((req) => {j
const requestingUser = await SecurityService.getAuthorizedUser(req.headers.authorization);
if (!requestingUser) {
// Throw errors when you want to stop further request processing while returning an error at the same time
throw new HTTPError(400, 'This should fail')
}
return {
requestingUser
}
})
.middleware(AnotherMiddleware)
.resolve(async (req, res, context) => {
// You can access the merged type-safe context of all middlewares in the resolver
const { requestingUser } = context
const userIncludingPassword = await UserService.getUser(req.uuid)
return userIncludingPassword
})
.transform((data) => {
return {
data: {
uuid: data.uuid,
email: data.email
}
}
})
.express()
)
Setting up Catena + Express
Catena currently supports Express; work is underway to support Next.js.
To setup Catena with Express make sure to do the following:
- Install
express
and@types/express
using yarn/npm/pnpm/bun - Suffix all Catena handlers with
.express()
(!). This function will transform the Catena chain into an Express request handler. Without appending this method, you're going to get a type-error and the handler will not work.
Also, you have to use the express.json()
(or body-parser) middleware in order for the validators to work with JSON content.
const app = express()
app.use(express.json())
Core Concepts
Chaining
Catena features a handler that is assembled as a chain out of the following elements. Only the resolve
element is required, all other elements are optional.
validate
: Chain zod-powered validators forbody
,params
,query
andheaders
that only allow requests that match the zod schemamiddleware
: Middlewares can return values into a shared "context", which is type-safe and accessible to all following chain elements. To block further request processing, e.g. because the requesting entity is not authorized, throwHTTPError
resolve
: The resolver should be used to handle all business logic, like a normal express handler would do. It either returns data that is passed to the transformer or may use theres
object to send a response without a transformertransform
: The transformer can be used to generate a DTO that can be send as response body by just returning it
The elements in this chain are processed one after the other. So if a middleware is executed after a validator, you can be sure that the validation has been passed. If there are multiple chained middlewares, you always access the context of all previous middlewares in the following middlewares.
All Catena chain elements are bundled into one actual Express handler with the .express()
method at the end of the chain. Internally, we are not calling Express' next()
until all chain elements have completed or there has been an uncaught error. If there is an uncaught error, that is not HTTPError
, we are calling next(err)
. You may then handle errors on router level or append another middleware.
Validators
Validators are virtual middlewares that validate the given request data using zod. You can create validators for body
, params
, query
and headers
. Objects validated by validators are type guarded for all following middlewares and the resolver.
Method Signature
.validate(type: "body" | "params" | "query" | "headers", zodObject: object | z.object)
The second parameter can either be a z.object()
or just a normal object that contains keys with zod validations as values. So both of the following usages are valid:
new Handler().validate('body', {
email: z.string().email(),
})
// .resolve ...
new Handler().validate(
'body',
z.object({
email: z.string().email(),
})
// .resolve ...
)
Using just an object is more readable, while using z.object
has the advantage of being able to infer the validator type and use it e.g. in services as argument type. Example:
const bodyValidation = z.object({
email: z.string().email(),
})
new Handler().validate('body', bodyValidation) //.resolve ...
const myServiceMethod = (body: z.infer<typeof bodyValidation>) => {
// ...
}
Caveats
- For
headers
validations, always use lower-case only keys! ~~Authorization: z.string()
~~ -->authorization: z.string()
. The reason for this is that Express converts all headers to lower case to comply with the RFC for HTTP requests, which states that header keys are case-insensitive.
Middlewares
Middlewares are functions that you can connect prior to the resolver in order to add custom logic such as authorization, non-trivial validation, etc. before the resolver is executed.
Middleware method signature
.middleware(req: RequestWithValidations, res: Response, next: NextFunction, context: MiddlewareContext)
req
extends the default express object with the type-safe inference made by the validators onbody
,params
,query
andheaders
.res
is the default ExpressResponse
objectnext
is the default ExpressNextFunction
context
is a simple object that is empty at the beginning of the first middleware in the chain. It can be filled with any values by the middlewares and passed to other middlewares and the resolver, see below
Catena Middlewares
Catena exposes a more straightforward way to write middlewares, while also supporting all existing middlewares.
Context
Instead of overwriting or extending some parts of the req
object to pass along context, you can just return an object. This object will be merged with the existing context
object and will be accessible to all following middlewares and the resolver (type-safe, of course).
import { Handler, HTTPError } from '@sonic-tech/catena'
new Handler()
.validate('params', {
uuid: z.string().uuid(),
})
.middleware(async (req) => {
const user = await UserService.getUser(req.params.uuid) // -> { email: "...", uuid: "..." }
return {
user,
}
})
.middleware(async (req, res, next, context) => {
// email and uuid can be extracted from the context type-safely
const { email, uuid } = context.user
if (!email.endsWith('@mycompany.com')) {
throw new HTTPError(403, 'Company not allowed')
}
const organization = await OrganizationService().getByUser(uuid)
return {
organization,
}
})
.resolve(async (req, res, context) => {
/**
* The resolver has access to both user and organization
* since the context objects have been merged
*/
const { user, organization } = context
// ...
})
.express()
A middleware does not need to return anything. If it returns void, the context objects stays as is.
Sending something to the client using res.send
causes the chain to stop, i.e. the next chain element is not executed.
Errors
Instead of using res.send
to send error messages, you can import HTTPError
from Catena and use throw new HTTPError(statusCode, errorMessage)
which will automatically resolve the request using the given status code and a standardized error message. An example can be seen in the code snippet of the last section.
Existing Middlewares
You can also use any middleware that works with express out of the box using the default (req, res, next)
syntax.
import { MySecondMiddleware } from "../middlewares"
const MyExistingMiddleware = (req, res, next) => {
if(!req.body.continue) {
res.status(400).send("Bad Request")
return
}
next()
}
new Handler()
.middleware(MyExistingMiddleware)
.middleware(MySecondMiddleware)
.resolve(...)
.transform(...)
.express()
Resolver
The resolver may be used as the core handler of the request, just as you used the handler function before.
Method Signature
.resolve((req: RequestWithValidations, res: Response, context: MiddlewareContext) => any | Promise<any>)
- The
req
is the default Express request object, except thatreq.body
,req.params
,req.query
andreq.headers
are typed by the infered zod validations from the validators - The
res
object is the default Express response object - The
context
object is the merged Context you passed along the middlewares
Context
As with middlewares, you can access the merged context of all previous middlewares (= all middlewares) through the context
object.
Return Values
You can use res
to send responses directly to the client without a transformer.
However, it is recommended to return values instead. Those values are then passed to the Transformer instead of being sent to the client directly.
new Handler()
.middleware(() => {
return {
user: {
uuid: '...',
},
}
})
.resolve((req, res, context) => {
const { user } = context
const subscription = await UserService.getUserSubscription(user.uuid)
// Just pass the data to the transformer instead of using res.send.
return subscription
})
.transform((data) => {
// data has the type of "subscription"
return {
userSubscriptionUuid: data.uuid,
}
})
.express()
Transformer
Transformers can be used to create sanitized data transfer objects based on the resolved data. This enables your resolver to just handle the business logic, independent of what single values should or should not be returned to the client.
For example, the resolver might get a user object from the database for a given query. Of course you don't want to return the user's password to the client. To keep things clean, just pass the user object from the resolver (which shouldn't have to care about sanitizing values) to the transformer, which then takes the given object and only returns values that are appropriate to be returned.
Example
new Handler()
.validate('params', {
uuid: z.string().uuid(),
})
.resolve(async (req) => {
const { uuid } = req.params
// This object contains confidential information, like a password
const user = await UserService.getUser(uuid)
return user
})
.transform((data) => {
return {
uuid: data.uuid,
email: data.email,
// leave out the password, since we don't want to send it to the client
}
})
.express()
You may return any value. If you choose to return an object or array, they are send with res.json()
. All other return values are send with res.send()
.
If you want to use a different status code, set headers, cookie, etc. you can use e.g. res.status(201)
before the return statement. We'll use this res
object for sending the response internally
Type-sharing across codebases
Request and response types of API handlers can be inferred and exported. You can use this to share your types e.g. with the frontend, like tRPC does.
Each handler exposes a types
interface that contains request
and response
types.
The request type contains the expected types for body
, query
, params
and headers
.
The response
type represents the type of the value that is returned by the transformer.
Example
Backend
const myRequestHandler = new Handler()
.validate("body", {
email: z.string().email()
})
.resolve(...)
.transform((data) => {
return {
data: {
uuid: data.uuid,
email: data.email,
age: data.age
}
}
})
.express()
app.get("/user", myRequestHandler);
export type GetUserTypes = typeof myRequestHandler.types;
Frontend
// It's important to only import using `import type`. This way, no business logic will be leaked to the frontend but just the type
import type { GetUserTypes } from 'backend/handler'
/**
* Body type inferred as
* {
* email: string
* }
*/
const body: GetUserTypes['request']['body'] = {
email: '[email protected]',
}
const myRequestResponse = await fetch('/user', {
body: JSON.stringify(body),
})
/**
* Type inferred as
* {
* data: {
* uuid: string;
* email: string;
* age: number;
* }
*/
const responseData: GetUserTypes['response'] = await myRequestResponse.json()
File-based Routing
We found express-file-routing to be a great addition to projects that use Catena, if you like file-based routing.
An example of Catena + file-based routing:
// /users/[uuid].ts
export const POST = new Handler()
.validate('params', {
uuid: z.string().uuid(),
})
.validate('body', {
email: z.string().email().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
})
.resolve((req) => {
const updatedUser = new UserService.updateUser(req.params.uuid)
return updatedUser
})
.transform((user) => {
return {
uuid: user.uuid,
email: user.email,
name: user.firstName + ' ' + user.lastName,
}
})
.express()