Skip to Content

Routes

Typed routes, with a class or without one.

A route is created with route.get, route.post, route.put, route.patch, route.delete, route.head, route.options or route.all. It takes a path and an options object whose handle function produces the response.

import { route } from '@sukujs/core';
import { z } from 'zod';

const findUser = route.get('/users/:id', {
  params: z.object({ id: z.coerce.number() }),
  handle: ({ params }) => ({ id: params.id }),
});

The schemas are the single source of truth. They validate the request at runtime and type the handler at compile time.

Two ways to write them

Both styles create the same kind of route, and can be mixed freely.

Services arrive through the constructor, and handlers use this.

@Routes('/users')
export class UsersRoutes {
  constructor(private readonly users: UsersService) {}

  findOne = route.get('/:id', {
    params: z.object({ id: z.coerce.number() }),
    handle: ({ params }) => this.users.find(params.id),
  });

  create = route.post('/', {
    body: CreateUser,
    handle: ({ body }) => this.users.create(body),
  });
}

Best when a resource has several routes that share dependencies.

Register either kind in a feature, or directly on the app.

feature('users', { routes: [UsersRoutes, findOne] });

A missing entry in services is caught at startup, with the route and service named.

What the handler receives

One object. Each part is typed by its schema, or is the raw value when there is none.

FieldWith a schemaWithout
paramsThe schema's output.Strings, keyed by the names in the path.
queryThe schema's output.An object of strings. Repeated keys become arrays.
bodyThe schema's output.undefined. Call ctx.body() to read it yourself.
headersThe schema's output.The Headers object.
ctxThe request context.
requestThe Fetch Request.
servicesOne field per entry in services.

Handlers shows how to read each part and how to set status, headers and cookies.

Options

OptionPurpose
params, query, body, headersSchemas for the request. See validation.
responseSchema for the returned value. Validates it and strips undeclared fields.
servicesServices to resolve from the request scope, by name.
middlewaresMiddlewares for this route only.
onErrorError handler for this route only.
statusSuccess status. Default 200, or 201 for POST.
nameIdentity in logs and the route table.
metaFree-form data for middlewares, read as ctx.route.meta.
handleThe handler. May be async.

Paths

PatternMatches
/users/meThat exact path.
/users/:idOne segment, available as id.
/files/*pathThe rest of the path, including nothing.

Static segments win over parameters, and parameters over wildcards. Trailing slashes are ignored, and HEAD falls back to the GET route. A known path with the wrong method gets 405 with an Allow header.

Checked against the schema

The path is read at compile time. A params schema must cover every parameter in it.

route.get('/users/:id', {
  params: z.object({ userId: z.string() }),   // compile error: `id` is missing
  handle: () => {},
});

route.get('/users/:id', {
  handle: ({ params }) => params.idd,          // compile error: no `idd` in the path
});

Return values

ReturnedResponse
An object or arrayJSON.
A stringtext/plain.
undefined or null204 No Content.
Uint8Array or ArrayBufferapplication/octet-stream.
ReadableStream, Blob, FormDataPassed to Response as the body.
A ResponseSent as it is.
import { redirect } from '@sukujs/core';

route.get('/old', { handle: () => redirect('/new', 301) });
route.post('/jobs', { status: 202, handle: ({ ctx }) => queue.add(ctx) });

Route names

A class field's name, or a group's key, becomes the route's name. It does not affect the URL. It identifies the route in the route table, in logs, and in anything generated from your routes.

app.routes;
// [{ method: 'GET', path: '/users/:id', name: 'UsersRoutes.findOne' }, ...]

Groups

route.group shares a prefix, middlewares and an error handler without a class.

export const admin = route.group('/admin', { middlewares: [requireAdmin] }, {
  stats: route.get('/stats', { handle: () => getStats() }),
  purge: route.post('/purge', { handle: () => purge() }),
});

Classes in detail

@Routes(prefix, options) accepts middlewares, onError and lifetime.

A routes class is a singleton by default. Fields that are not routes are ignored, and a class with no route fields is rejected at startup.

For request-bound services, prefer the route's services option, which works in a singleton class:

@Routes('/orders')
export class OrdersRoutes {
  constructor(private readonly catalog: Catalog) {}

  place = route.post('/', {
    body: PlaceOrder,
    services: { uow: UnitOfWork },          // scoped: one per request
    handle: ({ body, uow }) => uow.orders.place(body, this.catalog),
  });
}

Alternatively make the whole class scoped with @Routes('/orders', { lifetime: 'scoped' }).

Scoped classes are built once at startup

Routes live on instances, so Suku constructs each class once at startup to discover them. A scoped class must therefore be constructible without a live request. If its constructor needs the RequestContext, use the services option instead.

On this page