Skip to main content

Anatomy of Request handlers

Basics​

Here's a very basic request handler:

builder.get(async () => {
return {
result: 'success' as const,
statusCode: 200 as const,
message: "Hey!"
}
})
  • The builder is the request handler builder that you've created during the installation.
  • The .get sets the final middleware with the appropriate HTTP method.
    • The supported methods are: get, post, put, patch, delete
  • The async () => { /* ... */ } is the function where your controller logic leaves
    • The return type should contain these fields:
      • result: names what happened, e.g. "post-not-found" or "email-taken". It identifies the response, so the client can tell two failures with the same status code apart.
      • statusCode: the HTTP status code, the category of the response. For client-side typing it needs to be const as well.

Response factory methods​

Adding result and statusCode manually can be noisy. It's advised to create factory methods for common response types like success.

success is included in the package:

import { success } from '@cuple/server'

builder.get(async () => {
return success({ message: "Hey!" })
})

A failed schema returns invalid-body, invalid-query, invalid-params or invalid-headers (see Schema validation). For a rule a schema can't check, return the same result with invalidInput, so the client handles both the same way:

import { invalidInput } from '@cuple/server'

builder.post(async ({ data }) => {
if (data.body.password1 !== data.body.password2)
return invalidInput("body", [{
code: "custom",
path: ["password2"],
message: "Passwords do not match.",
}]);
// ...
})

For common failures there are notFound (404), unauthorized (401), forbidden (403) and conflict (409). Each has a default result and message, and you can override both and add fields:

import { conflict, notFound } from '@cuple/server'

builder.get(async () => {
return notFound() // { result: "not-found", statusCode: 404, message: "Not found." }
})

builder.post(async ({ data }) => {
return conflict({ result: "email-taken", message: "This email is in use.", email: data.body.email })
})

The default result is fine while a route has one failure of that kind. When it can have more, name each one (post-not-found, author-not-found), so the client can tell them apart.

For any other status code there's the generic apiResponse:

import { apiResponse } from '@cuple/server'

builder.post(async () => {
return apiResponse("too-many-requests", 429, {
message: "Slow down a bit.",
});
})

Access Request and Response object​

The first argument is an object containing 3 elements:

  • req: Express' Request object
  • res: Express' Response object
  • data: custom data from the middlewares
builder
.chain(auth)
.bodySchema(
z.strictObject({
oldPassword: z.string(),
password1: z.string().min(6),
password2: z.string().min(6),
}),
)
.post(async ({ req, res, data }) => {

// You can access request object
req.body

// but you have better, type-safe way to deal with the incoming data
data.body.password1

})
Why it works this way

A response is a discriminated union, because errors need types too. When the server rejects bad input, the client should get that error with its real shape (the issues), not just a success type and an unknown. So every response is one member of a union like { result: "success", ... } | { result: "invalid-body", ... }, and TypeScript narrows it.

The statusCode is there because Cuple deliberately stays on HTTP. A Cuple backend should look like any industry-standard API, so every response carries a real status code. A discriminated union could narrow on statusCode alone, and you can do that. But different outcomes can share a status code: a 404 can mean "no such route" or "no such order". So result identifies the response, and statusCode is its category: generic code (a redirect on any 401) branches on the status, and specific code (showing "this post was deleted") on the result.

Handlers return their response instead of calling res.send, because only a returned value is in the handler's type, and so in the client's. A response sent by hand is invisible to both. Returning also reads better than a callback. req and res are still passed in, so that you can do anything Express allows. They're there to help, not to limit what you can do.