Skip to main content

Middlewares

Middlewares allow you to reuse logic by handling tasks like validation, access control, or response formatting. They offer several features:

  • Passing data to other middlewares.
  • Preventing further execution based on conditions.
  • Sending responses, so you don't need to handle every case manually.

Note: Schema validators are also treated as middlewares.

Basic​

builder
.middleware(async () => {
const randomValue = Math.random();
if (randomValue > 0.5) return { next: true, randomValue };
return { next: false, statusCode: 500, message: "Unlucky" };
})
.get(async ({ data }) => {
return success({
message: `You won with ${data.randomValue}`,
});
});

In this example:

  • If the middleware returns { next: true }, the execution proceeds to the next handler. You can also type-safely add data, as shown with randomValue in data.randomValue
  • If the middleware returns { next: false }, it sends an immediate response, which requires a statusCode. It's recommended to include a result: 'some-error' as const: the client can check res.result instead of res.statusCode, and if it relies on result, every response needs one.

Middleware Chaining​

Some middlewares need to use data coming from schema validators. In order to use the data from the schema validation you have to keep the middleware function in the builder code. This, however, prevents you to just move the middleware function out of the builder. To solve this, Cuple introduces a wrapper called link, and you can connect links with each other.

  • .buildLink() Creates a link from a build chain. Use this instead of the final middleware (get, post, ...).
  • .chain(fooLink).chain(barLink) to bring multiple links into the build chain

Example:​

const authLink = builder
.headersSchema(
z.looseObject({
authorization: z.string(),
})
)
.middleware(async ({ data }) => {
if (data.headers.authorization === "foo")
return {
next: true,
};
return {
next: false,
statusCode: 401 as const,
};
})
.buildLink();

const routes = {
getAuthedWelcomeMessage: builder
.chain(authLink)
.get(async () => {
return success({ message: "hi" });
}),
};

Middlewares can depend on the output of previous middlewares. For example, a role-check middleware might depend on the result of an authentication middleware.

Example:​


const authLink = builder
.middleware(async () => ({ next: true, auth: { userId: 12345 } }))
.buildLink();

const adminRoleLink = builder
.expectChain<typeof authLink>()
.middleware(async ({ data }) => {
const user = await getUser(data.auth.userId)
if(user.role !== 'admin') {
return { next: false, statusCode: 403, message: "This is admin only." }
}
return { next: true, user }
})
.buildLink();

return {
someRoute: builder
.chain(authLink)
.chain(adminRoleLink)
.get(async ({ data }) => {
return success({
message: `You have access! Good ${data.user.name}`
});
}),
};

In this example:

  • adminRoleLink depends on the output of authLink.

Dealing with service dependencies​

In more complex applications, middleware may need additional services like a database or external APIs. To keep the code maintainable and testable, it's best to inject these dependencies rather than import them directly. You can achieve this by creating middleware link factories.

Example:​

function createAuthLink(builder: CupleBuilder) {
return builder
.middleware(async () => ({ next: true, auth: { userId: 12345 } }))
.buildLink()
}
type AuthLink = ReturnType<typeof createAuthLink>

function createAdminRoleLink(builder: CupleBuilder, userRepository: UserRepository) {
return builder
.expectChain<AuthLink>()
.middleware(async ({ data }) => {
const user = await getUser(data.auth.userId)
if(user.role !== 'admin') {
return { next: false, statusCode: 403, message: "Admins only." }
}
return { next: true, user }
})
.buildLink()
}

function createAdminModule(builder: CupleBuilder, userRepository: UserRepository) {
const authLink = createAuthLink(builder);
const adminRoleLink = createAdminRoleLink(builder, userRepository);
return {
someRoute: builder
.chain(authLink)
.chain(adminRoleLink)
.get(async ({ data }) => {
return success({
message: `You have access! Good ${data.user.name}`
});
}),
};
}
  • note: CupleBuilder is an exported type from the index.ts file

Express middlewares​

Middlewares added with app.use still run before Cuple routes, but their effects aren't in the types. Cuple parses JSON bodies itself, so a middleware that changes normal request handling (rewrites the body, or sends its own response) can break Cuple endpoints.

Why it works this way

Middlewares return data instead of attaching it to req. req.user set by a middleware is invisible to TypeScript, and a route that forgot the middleware still compiles. Returned data is typed in data, and exists only on routes that use the middleware.

next: true | false makes the return a discriminated union, so TypeScript knows which return is data for the next step and which is a response to the client.

Links exist so a middleware made of several builder steps, like auth (headersSchema + middleware), can be moved out of the route as one piece.