Routing is how an Ooneex app decides which controller handles which request. You never wire up a route table by hand: you decorate a controller class with a route decorator, and the framework registers the path, method, and validation rules for you. When a request arrives, the router matches it against the registered routes and runs the matched controller’s index(context) method. A route and its controller are two halves of the same unit — the decorator describes the contract (path, method, what input is valid, who is allowed in), and the controller supplies the behavior.
Because every route is declared on the class that serves it, the route definition lives next to the code that runs, and the same configuration object drives parameter parsing, validation, access control, and named URL generation.
Why this matters
- Declarative, co-located routes. The route lives on the controller it serves — no central route file drifting out of sync with handlers.
- Validation at the boundary. Path params, query strings, and request bodies are validated with
@ooneex/validation schemas before your controller runs, so index receives typed, trusted data.
- Access control in the contract. Roles, permission classes, and env/ip/host restrictions are part of the route definition, so who-can-reach-this is visible where the route is declared.
- Named routes, not hardcoded URLs. Every route has a name; generate URLs from the name with
router.generate(...) instead of stringly-typed paths scattered across the codebase.
- HTTP and WebSocket together. The same decorator family declares both, so socket endpoints follow the same naming and validation conventions as HTTP ones.
How it works
The router holds a map of paths to route configurations. Each Route.* decorator builds a route config from the path and your options, marks the HTTP method (or isSocket: true for sockets), attaches the controller class, and registers it with the container as a singleton. At request time the framework matches the path and method, validates the input against the route’s schemas, enforces any access rules, and invokes the matched controller’s index(context).
| Stage | What happens |
|---|
| Declaration | A @Route.get(...) / @Route.post(...) / @Route.socket(...) decorator registers the controller class with its path, method, and config. |
| Matching | An incoming request is matched by path and HTTP method (sockets match by path only). |
| Validation | params, queries, and payload schemas run; invalid input is rejected before the controller. |
| Access control | roles, permission, env, ip, and host are checked; disallowed requests are rejected. |
| Handling | The matched controller’s index(context) runs with context.params, context.queries, and context.payload populated. |
The decorator takes the path as its first argument and the config object as its second:
Route.get("/api/users/:id", {
name: "api.users.show",
version: 1,
description: "Get a user by id",
});
Defining a route
Import Route and decorate a controller class. The method decorator name maps to the HTTP method: Route.get, Route.post, Route.put, Route.delete, Route.patch, Route.options, and Route.head. The controller implements IController and exposes an index(context) method that returns a response.
import { Route } from "@ooneex/routing";
import type { ContextType, IController } from "@ooneex/controller";
import type { IResponse } from "@ooneex/http-response";
@Route.get("/api/users", {
name: "api.users.list",
version: 1,
description: "List all users",
})
export class UserListController implements IController {
public async index(context: ContextType): Promise<IResponse> {
return context.response.json({
users: [{ id: 1, name: "John" }],
});
}
}
Configuration options
Every option except path and method (which come from the decorator) is passed in the config object.
| Field | Type | Description |
|---|
name | string | Unique route name in namespace.resource.action form. Used for lookup and URL generation. Required. |
version | number | Numeric API version for the route. Required. |
description | string | Human-readable description of the route. Required. |
params | Record<string, AssertType> | Schema per path parameter (the :id segments). Validates context.params. |
queries | AssertType | Schema for the query string. Validates context.queries. |
payload | AssertType | Schema for the request body. Validates context.payload. |
response | AssertType | Schema describing the response shape (used for documentation and codegen). |
roles | Uppercase<string>[] | Roles allowed to access the route. |
permission | PermissionClassType | A custom permission class evaluated per request. |
featureFlag | FeatureFlagClassType | A feature-flag class gating the route. |
env | EnvironmentNameType[] | Environments in which the route is available. |
ip | string[] | Allowed IP addresses or CIDR ranges. |
host | string[] | Allowed hostnames. |
cache | string | Cache key prefix that enables response caching for the route. |
The decorator supplies path, method, isSocket, and controller
automatically — you never set those in the config object.
Route naming
Every route has a unique name following the namespace.resource.action convention, for example api.users.list or admin.products.create. Names must be unique across the whole app; registering two routes with the same name throws a RouterException. The name is what you pass to router.generate(...) and what the router uses for lookups.
Valid namespaces:
api, client, admin, public, auth, webhook, internal, external, system, metrics, docs
The middle resource segment names the thing the route acts on (users, products, users.orders), and the final action segment names the operation (list, show, create, update, delete, search, and many more).
Path parameters
Dynamic path segments are written with a leading colon, like :id. Each declared parameter should have a schema in params; after validation the values are available on context.params.
import { Route } from "@ooneex/routing";
import { Assert } from "@ooneex/validation";
import type { ContextType, IController } from "@ooneex/controller";
import type { IResponse } from "@ooneex/http-response";
@Route.get("/api/users/:id", {
name: "api.users.show",
version: 1,
description: "Get a user by id",
params: {
id: Assert("string.uuid"),
},
})
export class UserShowController implements IController {
public async index(context: ContextType): Promise<IResponse> {
const { id } = context.params;
const user = await this.userRepository.findById(id);
return context.response.json({ user });
}
}
A path may declare several parameters — /api/users/:userId/orders/:orderId — and each is matched into context.params by name.
Query validation
Provide a queries schema to validate and coerce the query string. The schema handles defaults and optional keys, so the controller reads ready-to-use values from context.queries.
import { Route } from "@ooneex/routing";
import { Assert } from "@ooneex/validation";
import type { ContextType, IController } from "@ooneex/controller";
import type { IResponse } from "@ooneex/http-response";
@Route.get("/api/products", {
name: "api.products.search",
version: 1,
description: "Search products",
queries: Assert({
q: "string",
page: "number = 1",
limit: "number = 10",
"category?": "string",
}),
})
export class ProductSearchController implements IController {
public async index(context: ContextType): Promise<IResponse> {
const { q, page, limit, category } = context.queries;
const products = await this.search(q, { page, limit, category });
return context.response.json({ products });
}
}
Payload validation
For routes that accept a request body, declare a payload schema. The body is validated before index runs, and the parsed value is on context.payload.
import { Route } from "@ooneex/routing";
import { Assert } from "@ooneex/validation";
import type { ContextType, IController } from "@ooneex/controller";
import type { IResponse } from "@ooneex/http-response";
@Route.post("/api/users", {
name: "api.users.create",
version: 1,
description: "Create a new user",
payload: Assert({
email: "string.email",
name: "string >= 2",
password: "string >= 8",
}),
})
export class UserCreateController implements IController {
public async index(context: ContextType): Promise<IResponse> {
const { email, name, password } = context.payload;
const user = await this.userService.create({ email, name, password });
return context.response.json({ user }, 201);
}
}
See validation for the full schema syntax, and response for the response builder used in these handlers.
Response validation
Declare a response schema to describe the shape index returns. Unlike params, queries, and payload, the response schema is not a runtime gate on the request — it documents the route’s output contract and drives type derivation and codegen, so generated clients and API docs stay in sync with what the controller actually sends back.
import { Route } from "@ooneex/routing";
import { Assert } from "@ooneex/validation";
import type { ContextType, IController } from "@ooneex/controller";
import type { IResponse } from "@ooneex/http-response";
@Route.get("/api/users/:id", {
name: "api.users.show",
version: 1,
description: "Get a user by id",
params: {
id: Assert("string.uuid"),
},
response: Assert({
user: {
id: "string.uuid",
email: "string.email",
name: "string",
},
}),
})
export class UserShowController implements IController {
public async index(context: ContextType): Promise<IResponse> {
const user = await this.userRepository.findById(context.params.id);
return context.response.json({ user });
}
}
Because the schema is the same @ooneex/validation construct used for input, the response shape lives in the route contract alongside its inputs — describe it once on the decorator and the generated types and documentation follow.
WebSocket routes
Route.socket(path, config) declares a WebSocket endpoint. It takes the same config as the HTTP decorators (the method is ignored and isSocket is set to true), but the controller’s context comes from @ooneex/socket and exposes a channel for subscribing and publishing.
import { Route } from "@ooneex/routing";
import { Assert } from "@ooneex/validation";
import type { ContextType, IController } from "@ooneex/socket";
import type { IResponse } from "@ooneex/http-response";
@Route.socket("/ws/chat/:roomId", {
name: "api.chat.connect",
version: 1,
description: "Connect to a chat room",
params: {
roomId: Assert("string"),
},
})
export class ChatController implements IController {
public async index(context: ContextType): Promise<IResponse> {
const { roomId } = context.params;
await context.channel.subscribe();
return context.response.json({ connected: true, room: roomId });
}
}
Generating URLs
Look routes up by name and build their URL with router.generate(name, params). Path parameters are interpolated from the params object; missing required parameters throw a RouterException.
import { router } from "@ooneex/routing";
const userUrl = router.generate("api.users.show", { id: "123" });
// "/api/users/123"
const orderUrl = router.generate("api.users.orders.show", {
userId: "123",
orderId: "456",
});
// "/api/users/123/orders/456"
Generating from names keeps URLs in one place: rename a path in its decorator and every call site that uses the route name keeps working.
Access control
Routes can declare who is allowed to reach them. These checks run before the controller, so an unauthorized request never reaches index.
Role-based access — list the roles permitted to call the route:
@Route.delete("/admin/users/:id", {
name: "admin.users.delete",
version: 1,
description: "Delete a user (admin only)",
roles: ["ROLE_ADMIN", "ROLE_SUPER_ADMIN"],
})
export class UserDeleteController implements IController {
public async index(context: ContextType): Promise<IResponse> {
await this.userService.delete(context.params.id);
return context.response.json({ deleted: true });
}
}
Permission classes — for logic beyond a fixed role list, point permission at a permission class that decides per request:
@Route.put("/api/users/:id", {
name: "api.users.update",
version: 1,
description: "Update a user profile",
permission: CanEditOwnProfile,
})
export class UserUpdateController implements IController {
// only the owner may update — the permission class enforces it
}
Environment, IP, and host restrictions — limit a route to certain environments or network origins:
@Route.get("/internal/metrics", {
name: "internal.metrics.show",
version: 1,
description: "Internal metrics endpoint",
env: ["local", "development"],
ip: ["127.0.0.1", "10.0.0.0/8"],
host: ["metrics.internal"],
})
export class MetricsController implements IController {
// reachable only from the listed envs, IP ranges, and hosts
}
Access checks compose with the request middleware pipeline: middleware runs first and may short-circuit, then the route’s own access rules apply, then the controller runs.
Best practices
- Name every route consistently. Follow
namespace.resource.action so names are predictable and router.generate calls read clearly.
- Validate at the boundary. Declare
params, queries, and payload schemas so index works with trusted, typed data instead of re-checking input by hand.
- Generate URLs, don’t hardcode them. Use
router.generate(name, params) everywhere a route URL is needed.
- Keep one route per controller. A controller’s
index serves a single route; split distinct operations into distinct controllers.
- Declare access in the route. Put
roles, permission, and env/ip/host rules on the route so the contract states who may reach it.
- Version your API routes. Set
version deliberately so clients and generated docs track route revisions.
Scaffolding
Generate a controller and its route together with the CLI rather than wiring one by hand:
See controller:create for the full command reference, and the controller page for how the generated index(context) method consumes the validated request.