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:

users-controller.tsTypeScript
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:

players-controller.tsTypeScript
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:

users-controller.tsTypeScript
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:

players-controller.tsTypeScript
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:

protected-route.tsTypeScript
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:

users-controller.tsTypeScript
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:

delete-route.tsTypeScript
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:

admin-route.tsTypeScript
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:

users-controller.tsTypeScript
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:

users-controller.tsTypeScript
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:

routes.tsTypeScript
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