🚦 Routes
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
import {compose, flatMapAsync, withRoute, lift} from '../primitives';
import {createUser, getUserById, normalizeUserTerminator} from '../handlers';
export const createUserRoute = withRoute({
method: 'POST',
path: '/users',
validators: [verifyAuthentication(getBearerTokenInfo)], // optional array of validator functions
handler: compose(
createUser, // Handler: write to DB
flatMapAsync(getUserById), // Handler: read back the created entity
lift(normalizeUserTerminator) // Terminator handler: format final HTTP response
),
});
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:
- Handlers - Perform business logic (database operations, validations, transformations)
- Terminator Handler - Final handler that formats the HTTP response
handler: compose(
handler1, // Returns Result<RouteHandlerPayload, Error>
flatMapAsync(handler2), // Returns Result<RouteHandlerPayload, Error>
lift(terminatorHandler) // Handles Result, returns formatted response
)
Handlers have their own dedicated explanation covering signatures, mergeData
, Result patterns, and terminator requirements. ➜ 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.
- Keep handlers 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
.
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 { withRoute } from '../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 |
4️⃣ Adding Validators
import {withRoute, compose} from '../primitives';
import {NodeblocksError} from '../primitives/error';
import {ok, err} from 'neverthrow';
import {mergeData} from '../handlers';
// Custom validator that always blocks access
const youShallNotPass = () => {
throw new NodeblocksError(400, 'YOU SHALL NOT PASS!');
};
// Handler for getting secret data
const getSecretData = async (payload) => {
return ok(mergeData(payload, { hiddenSecret: '🧱🧱🎉🧱🧱' }));
};
// Terminator handler for formatting response
const secretTerminator = (result) => {
if (result.isErr()) {
throw result.error;
}
const payload = result.value;
const { context } = payload;
return { secret: context.data.secret };
};
export const getSecretRoute = withRoute({
method: 'GET',
path: '/secret',
validators: [youShallNotPass],
handler: compose(
getSecretData, // Handler: get the data
lift(secretTerminator) // Terminator: format final response
),
});
Validators throw NodeblocksError
on failure which bubbles up to the error middleware.
➡️ Next
Learn about Feature » to see how to compose routes and schemas into reusable units, or explore WebSocket Services » for real-time functionality