title: Routers description: Learn about Routers in Kaito, and how you can interface with them in your app
Routers
Routers are a collection of routes and their metadata. They can be merged together to allow for separating logic into different files.
- Routers are immutable, and all methods return a new router instance
- You chain router methods:
router.post().get()
- Use
.through()
to change the context instance for all routes defined after the.through()
Creating a Router
You can create a new router using the create
function from @kaito-http/core
. This will give you a router instance that you can use to define routes and their behavior.
import {create} from '@kaito-http/core';
export const router = create({
getContext: async (req, head) => {
// Your context creation logic here
return {
// ...context properties
};
},
});
const app = router.get(...);
And then you are safe to use the router around your app, which will guarantee context type safety.
Router Merging
Routers can be merged, which brings one router’s routes into another, with a prefix. This is incredibly useful for larger apps, for example when you have multiple versions of an API.
import {v1} from './routers/v1';
import {v2} from './routers/v2';
export const api = router.merge('/v1', v1).merge('/v2', v2);
You can expect all type information to be carried over as well as the route names and correct prefixes.
.params()
The .params()
method is a type-safe way to declare what parameters a router expects to receive when it is merged into another router. This is exclusively used with router merging - you cannot use .params()
effectively without merging the router somewhere that provides those parameters.
How it works
When you call .params()
on a router, you’re declaring “this router needs these parameters to work”. Then, when you merge this router somewhere else, Kaito ensures that the merge path provides all the required parameters:
// Declare that this router needs a userId parameter
const userRouter = router
.params({
userId: z.string().min(3),
})
.get('/', async ({params}) => {
// params.userId is guaranteed to exist because we can only
// mount this router at a path that provides userId
return {id: params.userId};
});
// This works - we're mounting at a path that provides userId
const app = router.merge('/users/:userId', userRouter);
// This would be a type error - we're not providing the required userId parameter
const badApp = router.merge('/users', userRouter);
Nested Router Parameters
The real power of .params()
comes when working with deeply nested routers that need parameters from multiple levels up:
// This router needs both userId and postId
const commentsRouter = router
.params({
userId: z.string(),
postId: z.string(),
})
// We can access params in .through()!
.through(async (ctx, params) => {
// params.userId and params.postId are available here
const [user, post] = await Promise.all([db.users.findById(params.userId), db.posts.findById(params.postId)]);
if (!user || !post) {
throw new KaitoError(404, 'Not found');
}
return {
...ctx,
user,
post,
};
})
.get('/', async ({ctx, params}) => {
// We have access to both the validated params
// and the resolved data from .through()
return {
user: ctx.user,
post: ctx.post,
};
});
// This router provides postId and forwards userId to commentsRouter
const postsRouter = router
.params({
userId: z.string(),
})
.merge('/posts/:postId/comments', commentsRouter);
// Finally, we provide userId at the top level
const app = router.merge('/users/:userId', postsRouter);
You can only call .params()
once on a router. Attempting to call it multiple times will result in a type error, as
this could break existing routes that consume the parameters.
.through()
Kaito takes a different approach to traditional express.js style “middleware.” This is primarily due to the unpredictable nature of such patterns. Kaito offers a superior alternative, .through()
.
How to use .through()
.through()
accepts a function that receives two arguments:
- The current context
- The router’s parameters (if defined using
.params()
)
The function should return the next context (learn more about context here). This will swap out the context for all routes defined after the .through()
. You can also throw any kind of errors inside the callback, and they will be caught and handled as you would expect.
Examples
Take the following snippet:
const postsRouter = router
.params({
userId: z.string(),
})
.through(async (ctx, params) => {
// params.userId is available and validated here
const user = await ctx.db.users.findById(params.userId);
if (!user) {
throw new KaitoError(404, 'User not found');
}
return {
...ctx,
user,
};
})
.post('/', async ({ctx}) => {
// ctx.user is now defined with the user we found
await ctx.db.posts.create({
authorId: ctx.user.id,
// ... other post data
});
});
Multiple .through()
calls
You can call .through()
multiple times, where each .through()
will accept the result of the previous call.
const usersRouter = router
.through(async ctx => {
const session = await ctx.getSession();
if (!session) {
throw new KaitoError(401, 'You are not logged in');
}
return {
...ctx,
user: session.user,
};
})
.post('/posts', async ({ctx}) => {
const post = await ctx.db.posts.create(ctx.user.id);
return post;
})
.through(async ctx => {
// ctx.user is guaranteed to exist here, because of the previous `.through()`
const checkIfUserIsAdmin = await checkIfUserIsAdmin(ctx.user);
if (!checkIfUserIsAdmin) {
throw new KaitoError(403, 'Forbidden');
}
return {
...ctx,
user: {
...ctx.user,
isAdmin: true,
},
};
})
.delete('/posts', async ({ctx, body, query, params}) => {
ctx.user.isAdmin; // => true
await deleteAllPosts();
});
Composition
A nice pattern that .through()
enables is to export a router from another file that already has some ‘through-logic’ applied to it. This allows for extremely powerful composition of routers.
export const authedRouter = router.through(async ctx => {
const session = await ctx.getSession();
if (!session) {
throw new KaitoError(401, 'You are not logged in');
}
return {
...ctx,
user: session.user,
};
});
import {authedRouter} from '../routers/authed.ts';
// Note: I am not calling calling authedRouter here. All router methods are immutable
// so we can just import the router and use it as is, rather than instantiating it again
// for the sake of some syntax
export const postsRouter = authedRouter.post('/', async ({ctx}) => {
// There is now NO .through() logic here, but we still
// get access to a strongly typed `ctx.user` object! Incredible right?
await ctx.db.posts.create(ctx.user.id);
});