๐ฃ๏ธ Route
A route bundles an HTTP method, path, validators and a business logic together. Nodeblocks provides the withRoute helper which stores metadata and plugs into Express automatically when the service router is generated. Routes support both HTTP and WebSocket protocols for real-time communication.
1๏ธโฃ Anatomyโ
A route is defined using the withRoute helper, which bundles HTTP method, path, validators, and handler logic:
import { primitives, blocks, validators } from '@nodeblocks/backend-sdk';
const { compose, withRoute } = primitives;
const { isAuthenticated } = validators;
const { getUserById } = blocks;
export const getUserRoute = withRoute({
method: 'GET',
path: '/users/:userId',
validators: [isAuthenticated()],
handler: compose(/* handler chain */),
});
Below is the minimal shape returned by withRoute.
interface Route {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; // Required for HTTP routes
protocol?: 'http' | 'ws'; // Specify protocol (defaults to 'http')
path: string; // Express-style path, supports :params
validators?: Validator[]; // optional list executed **before** handler chain
handler: RouteHandler; // composable function chain
}
Protocolโ
Routes support two protocols:
| Protocol | Purpose | Handler Return Type |
|---|---|---|
http | Traditional REST API endpoints | Data object or Result |
ws | Real-time WebSocket connections | RxJS Subject |
Methodโ
The HTTP verb that follows REST conventions (only for HTTP routes):
| Verb | Purpose |
|---|---|
GET | Read/retrieve resources |
POST | Create new resources |
PATCH | Partially update resources |
DELETE | Remove resources |
Pathโ
An Express-style template. Use plural nouns for collections (/users) and sub-paths for actions (/users/:id/lock). Dynamic parts become requestParams.
Validatorsโ
Synchronous or async hooks that run before any database work. They receive the same payload object.
The idiomatic way to reject a request is to throw new NodeblocksError(status, message):
import {NodeblocksError} from '../primitives/error';
export const validateEmail: Validator = ({ params }) => {
if (!params.requestBody.email?.includes('@')) {
throw new NodeblocksError(400, 'Invalid email');
}
};
Handlerโ
Routes delegate their business logic to handler chainsโcomposed functions that process requests and format responses. Every handler chain follows this pattern:
- Blocks or Handlers - Perform business logic (database operations, validations, transformations)
- Terminator Handler - Final handler that formats the HTTP response
Recommended: Composing with Blocksโ
The recommended approach is to compose routes using blocks directly with applyPayloadArgs. Blocks are pure functions that contain business logic and can be composed directly in routes:
import { primitives, blocks, validators } from '@nodeblocks/backend-sdk';
const { compose, flatMapAsync, withRoute, lift, applyPayloadArgs, orThrow } = primitives;
const { isAuthenticated } = validators;
const { getUserById, normalizeUser, UserNotFoundBlockError, UserBlockError } = blocks;
export const getUserRoute = withRoute({
method: 'GET',
path: '/users/:userId',
validators: [isAuthenticated()],
handler: compose(
// Use block directly with applyPayloadArgs
applyPayloadArgs(
getUserById,
[
['context', 'db', 'users'],
['params', 'requestParams', 'userId']
],
'user'
),
// Chain additional blocks
flatMapAsync(
applyPayloadArgs(
normalizeUser,
[['context', 'data', 'user']],
'normalizedUser'
)
),
// Terminator formats final response with error mapping
lift(
orThrow(
[
[UserNotFoundBlockError, 404],
[UserBlockError, 500],
],
[['context', 'data', 'normalizedUser'], 200]
)
)
),
});
Key benefits of using blocks:
- Pure functions - easier to test without payload mocking
- Reusable - same blocks can be used across different routes
- Composable - chain multiple blocks together seamlessly
- Type-safe - better TypeScript inference
Legacy: Composing with Handlersโ
The legacy approach uses custom handlers that wrap business logic. This is still supported but not recommended for new code:
import { primitives, handlers } from '@nodeblocks/backend-sdk';
import {createUser, getUserById, normalizeUserTerminator} from '../handlers';
const { compose, flatMapAsync, withRoute, lift } = primitives;
export const createUserRoute = withRoute({
method: 'POST',
path: '/users',
validators: [isAuthenticated()],
handler: compose(
createUser, // Handler: write to DB
flatMapAsync(getUserById), // Handler: read back the created entity
lift(normalizeUserTerminator) // Terminator handler: format final HTTP response
),
});
Note: For detailed information on blocks, see Blocks ยป. For handler patterns, see Handler explanation ยป.
2๏ธโฃ Good Practicesโ
- Path semantics โ nouns for resources (
/users), verbs for actions (/users/:id/lock). - Complete handler chains โ always end with a terminator handler that formats the final response.
- Use blocks โ prefer composing blocks directly over custom handlers for better reusability and testability.
- Keep logic small โ prefer multiple composables to a single monolith.
- Validators first โ do lightweight checks before DB calls.
- Stateless โ routes should not mutate global state; everything arrives via
payload.
Recommended: Block Composition Patternโ
const { block1, block2, BlockError1, BlockError2 } = blocks;
handler: compose(
applyPayloadArgs(block1, [/* paths */], 'result1'), // Block: business logic
flatMapAsync(applyPayloadArgs(block2, [/* paths */], 'result2')), // Block: more logic
lift(orThrow([[BlockError1, 404], [BlockError2, 500]], [['context', 'data', 'result2']])) // Terminator: maps errors, formats response
)
Legacy: Handler Chain Patternโ
handler: compose(
handler1, // Business logic: returns Result
flatMapAsync(handler2), // More business logic: returns Result
lift(terminatorHandler) // Response formatting: handles Result, returns data
)
3๏ธโฃ WebSocket Routesโ
WebSocket routes enable real-time communication by returning RxJS Subjects instead of static data:
import { Subject } from 'rxjs';
import { primitives } from '@nodeblocks/backend-sdk';
const { withRoute } = primitives;
export const realtimeDataRoute = withRoute({
protocol: 'ws',
path: '/api/realtime',
handler: (payload) => {
const subject = new Subject();
// Setup real-time data source (e.g., database change stream)
const changeStream = payload.context.db.data.watch();
changeStream.on('change', (changeDoc) => {
subject.next({
type: 'data_change',
data: changeDoc.fullDocument,
timestamp: Date.now()
});
});
// Cleanup when connection closes
subject.subscribe({
complete: () => changeStream.close(),
error: () => changeStream.close()
});
return subject;
},
validators: [
// WebSocket routes can also use validators
(payload) => {
// Validate WebSocket connection headers, etc.
return Promise.resolve();
}
]
});
WebSocket vs HTTP Routesโ
| Aspect | HTTP Routes | WebSocket Routes |
|---|---|---|
| Protocol | 'http' (default) | 'ws' |
| Method | Required (GET, POST, etc.) | Not applicable |
| Handler Return | Data object or Result | RxJS Subject |
| Communication | Request-response | Bidirectional streaming |
| Use Cases | REST APIs, CRUD operations | Real-time updates, live data |
โก๏ธ Nextโ
Learn about Feature ยป to see how to compose routes and schemas into reusable units, or explore WebSocket Services ยป for real-time functionality