Routes
Route builders with middleware, guards, and handlers
Routes define individual endpoints in your controllers. JustScale uses a fluent builder API that lets you chain middleware, guards, and handlers with full type safety at every step.
Route Builders
The HTTP transport provides five route builders: Get, Post, Put,Delete, and Patch. Each builder supports the same fluent API:
import { createController } from '@justscale/core';
import { Get, Post, Put, Delete, Patch } from '@justscale/http';
import { UserService } from './user-service';
export const UsersController = createController('/users', {
inject: { users: UserService },
routes: (services) => ({
list: Get('/'),
getOne: Get('/:id'),
create: Post('/'),
update: Put('/:id'),
remove: Delete('/:id'),
partialUpdate: Patch('/:id'),
}),
});Path Parameters
Define path parameters with :paramName syntax. They're automatically extracted and typed in the handler context:
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { PlayerService } from './player-service';
export const PlayersController = createController('/players', {
inject: { players: PlayerService },
routes: (services) => ({
// Single param
getOne: Get('/:playerId').handle(({ params }) => {
params.playerId; // Type: string
}),
// Multiple params
getHand: Get('/:playerId/games/:gameId/hands/:handId')
.handle(({ params }) => {
params.playerId; // Type: string
params.gameId; // Type: string
params.handId; // Type: string
}),
}),
});The .handle() Method
Every route must end with .handle(), which receives your route handler function. The handler gets a context object with transport context (params, body, res) and middleware additions. Services are accessed via the service object from the routes function closure:
import { createController } from '@justscale/core';
import { Get, Post } from '@justscale/http';
import { UserService } from './user-service';
export const UsersController = createController('/users', {
inject: { users: UserService },
routes: (services) => ({
// Minimal handler - services accessed via closure
list: Get('/').handle(({ res }) => {
res.json({ users: services.users.findAll() });
}),
// Handler with params
getOne: Get('/:id').handle(({ params, res }) => {
const user = services.users.findById(params.id);
res.json({ user });
}),
// Async handler
create: Post('/').handle(async ({ body, res }) => {
const user = await services.users.create(body);
res.json({ user });
}),
}),
});The .use() Method - Middleware
Chain middleware with .use(). Each middleware can add properties to the context, which are then available in the handler and subsequent middleware:
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';
import { body } from '@justscale/http';
import { PlayerService } from './player-service';
import { CreatePlayerSchema } from './schemas';
export const PlayersController = createController('/players', {
inject: { players: PlayerService },
routes: (services) => ({
// parseAuth adds { user: User }
create: Post('/')
.use(parseAuth)
.apply(body(CreatePlayerSchema)) // Adds { body: CreatePlayerDto }
.handle(({ user, body, res }) => {
// Both 'user' and 'body' are available and typed
const player = services.players.create(body, user);
res.json({ player });
}),
}),
});Middleware Context Accumulation
The beauty of .use() is that context accumulates. Each middleware adds to what came before:
import { Get } from '@justscale/http';
import { parseAuth, loadUserProfile, checkSubscription } from './middleware';
Get('/protected')
.use(parseAuth) // ctx now has: { user: User }
.use(loadUserProfile) // ctx now has: { user, profile: Profile }
.use(checkSubscription) // ctx now has: { user, profile, subscription: Sub }
.handle(({ user, profile, subscription, res }) => {
// All three available and typed!
});The .guard() Method
Guards gate access to a route. They receive the accumulated context and return a boolean. If a guard returns false, the request is blocked:
import { createController } from '@justscale/core';
import { Delete } from '@justscale/http';
import { parseAuth } from '@justscale/http';
import { UserService } from './user-service';
// Simple inline guard
export const UsersController = createController('/users', {
inject: { users: UserService },
routes: (services) => ({
// Only admins can delete
remove: Delete('/:id')
.use(parseAuth)
.guard(({ user }) => user.role === 'admin')
.handle(({ params, res }) => {
services.users.delete(params.id);
res.json({ success: true });
}),
}),
});Throwing Guards
Guards can also throw errors for custom error responses:
import { Delete } from '@justscale/http';
import { parseAuth } from '@justscale/http';
// In a controller with services available
Delete('/:id')
.use(parseAuth)
.guard(({ user }) => {
if (user.role !== 'admin') {
throw new Error('Admin access required');
}
return true;
})
.handle(({ params, res }) => {
services.users.delete(params.id);
res.json({ success: true });
});Combining .use() and .guard()
You can chain multiple .use() and .guard() calls. They execute in order:
import { Post } from '@justscale/http';
import { parseAuth, body } from '@justscale/http/builder';
import { CreateUserSchema } from './schemas';
// In a controller with services available
Post('/admin/users')
.use(parseAuth) // 1. Parse authentication
.guard(({ user }) => user.isAdmin) // 2. Check admin
.apply(body(CreateUserSchema)) // 3. Parse and validate body
.guard(({ body }) => !body.dangerous) // 4. Additional validation
.handle(({ body, res }) => {
// Only executed if all guards pass
const user = services.users.create(body);
res.json({ user });
});Schema Validation
Use Zod schemas with body() middleware for automatic validation. Always pair with .returns() for complete type safety:
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';
import { body } from '@justscale/http/builder';
import { UserService } from './user-service';
import { CreateUserBody, UserResponse, ErrorResponse } from './schemas';
export const UsersController = createController('/users', {
inject: { users: UserService },
routes: (services) => ({
create: Post('/')
.apply(body(CreateUserBody))
.returns(UserResponse, 201)
.returns(ErrorResponse, 400) // Validation errors
.handle(async ({ body, res }) => {
// body is typed as: { name: string, email: string, age: number }
const user = await services.users.create(body);
res.status(201).json({ user });
}),
}),
});Response Types
Define response schemas with .returns() for type safety and OpenAPI generation. Chain multiple .returns() calls for different status codes:
import { createController } from '@justscale/core';
import { Get, Post } from '@justscale/http';
import { body, populate } from '@justscale/http';
import { UserService } from './user-service';
import {
UserSchema,
UserResponse,
UsersListResponse,
ErrorResponse,
CreateUserBody,
} from './schemas';
// Routes with complete response declarations
routes: (services) => ({
// GET /users - single success response
list: Get('/')
.returns(UsersListResponse)
.handle(({ res }) => {
res.json({ users: services.users.findAll() });
}),
// GET /users/:userId - success or 404
getOne: Get('/:userId')
.use(populate(services.users, 'user', 'userId'))
.returns(UserResponse)
.returns(404) // No body, just status
.handle(({ user, res }) => {
res.json({ user });
}),
// POST /users - 201 success, 400 validation error, 409 conflict
create: Post('/')
.apply(body(CreateUserBody))
.returns(UserResponse, 201) // Success with 201
.returns(ErrorResponse, 400) // Validation error
.returns(ErrorResponse, 409) // Conflict (email exists)
.handle(async ({ body, res }) => {
const existing = await services.users.findByEmail(body.email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
const user = await services.users.create(body);
res.status(201).json({ user });
}),
});Response Types Summary
.returns(Schema)— 200 response with body.returns(Schema, 201)— Custom status with body.returns(ErrorSchema, 400)— Error response with body.returns(404)— Status only, no body
Direct Handler Form
For simple routes without middleware or guards, pass the handler directly as a second argument:
import { Get } from '@justscale/http';
routes: (services) => ({
// Builder form
list: Get('/').handle(({ res }) => {
res.json({ users: services.users.findAll() });
}),
// Direct form (less common)
ping: Get('/ping', ({ res }) => {
res.json({ status: 'ok' });
}),
});Route Execution Order
When a request comes in, the route is processed in this order:
- 1. Middleware - All
.use()calls execute in order - 2. Guards - All
.guard()calls execute in order - 3. Handler - The final
.handle()function executes
If any middleware throws an error or any guard returns false, execution stops immediately.
Best Practices
- Use descriptive route names -
list,getOne,create, etc. - Validate early - Use
body()before guards to fail fast on invalid input - Chain thoughtfully - Order matters: parse, then validate, then guard, then handle
- Keep handlers focused - Move business logic to services
- Leverage type inference - Let TypeScript infer types from your schemas