Routing
TSDIAPI provides a powerful and flexible routing system that leverages TypeScript's type safety and Fastify's performance.
Automatic Route Loading
The TSDI API server automatically loads routes from specific files in your project during server initialization.
File Naming Convention
The server looks for two types of files:
*.module.ts
- Module files containing route definitions*.load.ts
- Loader files for additional route setup
Module Structure
Each module file should export a default function that takes an AppContext
as its parameter:
// feature.module.ts
import {
useResponseSchemas,
ResponseErrorSchema,
responseSuccess,
responseBadRequest,
} from "@tsdiapi/server";
import { Type } from "@sinclair/typebox";
const FeatureSchema = Type.Object({
message: Type.String(),
});
export default function FeatureModule({ useRoute }: AppContext): void {
useRoute("feature")
.get("/text")
.version("1")
.code(200, FeatureSchema)
.code(400, ResponseErrorSchema)
.handler(async (req) => {
return responseSuccess({ message: "Hello, world!" });
})
.build();
}
Loader Files
Loader files (*.load.ts
) are used for:
- Setting up route middleware
- Configuring global route options
- Registering route hooks
- Setting up authentication strategies
// auth.load.ts
import { useResponseSchemas } from "@tsdiapi/server";
import { Type } from "@sinclair/typebox";
const ErrorSchema = Type.Object({
error: Type.String(),
});
export default function AuthLoader({ useRoute }: AppContext): void {
// ...
}
Basic Usage
The routing system provides a type-safe, fluent API for defining routes. Here's a basic example:
import { useResponseSchemas } from "@tsdiapi/server";
import { Type } from "@sinclair/typebox";
import { Container } from "typedi";
const FeatureSchema = Type.Object({
message: Type.String(),
});
const ErrorDetailsSchema = Type.Object({
field: Type.String(),
message: Type.String(),
});
export default function FeatureModule({ useRoute }: AppContext): void {
// Multiple response schemas
const { codes, sendSuccess, sendError } = useResponseSchemas(
FeatureSchema,
ErrorDetailsSchema
);
useRoute("feature")
.get("/")
.version("1")
.codes(codes)
.handler(async (req) => {
const service = Container.get(FeatureService);
const hello = await service.getHello();
return sendSuccess({ message: hello });
})
.build();
}
About build() method
The build()
method is crucial for route definition. It:
- Finalizes the route configuration
- Registers the route in the Fastify instance
- Validates the route configuration
- Applies all middleware and hooks
- Sets up request/response schemas
Important: Every route definition must end with .build()
to be registered.
Route Configuration
Controller and URL
import { useResponseSchemas } from "@tsdiapi/server";
import { Type } from "@sinclair/typebox";
const UserSchema = Type.Object({
id: Type.String(),
name: Type.String(),
});
const ErrorSchema = Type.Object({
error: Type.String(),
});
export default function UserModule({ useRoute }: AppContext): void {
const { codes, sendSuccess } = useResponseSchemas(
UserSchema,
ErrorSchema
);
useRoute("users")
.get("/")
.version("1")
.codes(codes)
.handler(async (req) => {
const users = await userService.findAll();
return sendSuccess(users);
})
.build();
}
Request Parameters with Validation
useRoute("feature/:id")
.get("/")
.version("1")
.codes(codes)
.params(
Type.Object({
id: Type.String({
pattern: "^[0-9a-fA-F]{24}$", // MongoDB ObjectId pattern
}),
})
)
.handler(async (req) => {
const feature = await featureService.findById(req.params.id);
return responseSuccess(feature);
})
.build();
Request Body with Validation
import { responseSuccess, response400 } from "@tsdiapi/server";
useRoute("feature")
.post("/")
.version("1")
.codes(codes)
.body(
Type.Object({
name: Type.String(),
age: Type.Number({
minimum: 0,
maximum: 120,
}),
address: Type.Object({
street: Type.String(),
city: Type.String(),
zip: Type.String({
pattern: "^\\d{5}(-\\d{4})?$",
}),
}),
})
)
.handler(async (req) => {
const feature = await featureService.create(req.body);
return responseSuccess(feature);
})
.build();
Guards
Guards are used to protect routes and can return either a boolean or a response object:
useRoute("feature")
.get("/")
.version("1")
.codes(codes)
.guard(async (req) => {
if (!req.headers.authorization) {
return sendError("Unauthorized");
}
return true;
})
.handler(async (req) => {
const feature = await featureService.getFeature();
return responseSuccess(feature);
})
.build();
File Uploads
useRoute("feature")
.post("/upload")
.version("1")
.codes(codes)
.acceptMultipart()
.body(
Type.Object({
avatar: Type.String({ format: "binary" }),
document: Type.String({ format: "binary" }),
metadata: Type.Object({
title: Type.String(),
description: Type.String(),
}),
})
)
.fileOptions(
{
maxFileSize: 1024 * 1024 * 5, // 5MB
accept: ["image/jpeg", "image/png"],
},
"avatar"
)
.fileOptions(
{
maxFileSize: 1024 * 1024 * 10, // 10MB
accept: ["application/pdf", "application/msword"],
},
"document"
)
.handler(async (req) => {
const s3provider = useS3Provider();
const uploads = await Promise.all([
s3provider.uploadToS3({
buffer: req.tempFiles[0].buffer,
mimetype: req.tempFiles[0].mimetype,
originalname: req.tempFiles[0].filename,
}),
s3provider.uploadToS3({
buffer: req.tempFiles[1].buffer,
mimetype: req.tempFiles[1].mimetype,
originalname: req.tempFiles[1].filename,
}),
]);
return sendSuccess({
urls: {
avatar: uploads[0].url,
document: uploads[1].url,
},
});
})
.build();
Response Formats
The routing system supports various response formats:
JSON Response (default)
useRoute("feature")
.get("/data")
.version("1")
.codes(codes)
.handler(async (req) => {
const data = await featureService.getData();
return sendSuccess(data);
})
.build();
Binary Response
useRoute("feature")
.get("/download")
.version("1")
.binary()
.handler(async (req) => {
const fileBuffer = await getFileBuffer();
return fileBuffer;
})
.build();
Text Response
useRoute("feature")
.get("/text")
.version("1")
.text()
.handler(async (req) => {
return "Hello, world!";
})
.build();
Swagger Documentation
Enhance your API documentation in Swagger:
useRoute("users")
.post("/")
.version("1")
.codes(codes)
.summary("Create new user")
.description(
`
Creates a new user in the system.
Required permissions:
- user.create
Rate limiting:
- 10 requests per minute
`
)
.tags(["Users", "Management"])
.body(
Type.Object({
name: Type.String(),
email: Type.String(),
password: Type.String(),
})
)
.handler(async (req) => {
const user = await userService.create(req.body);
return sendSuccess(user);
})
.build();
Authentication
The routing system supports different authentication methods:
Bearer Token Authentication
import { JWTGuard, useSession } from "@tsdiapi/jwt-auth"; // Auth guard and session access
useRoute("feature")
.get("/protected")
.version("1")
.codes(codes)
.auth("bearer")
.guard(JWTGuard())
.handler(async (req) => {
const session = useSession<{ userId: string }>(req);
const data = await featureService.getProtectedData(session.userId);
return sendSuccess(data);
})
.build();
Resolvers
Resolvers pre-process and validate data before it reaches the handler:
import { responseSuccess, ResponseBadRequest, response400 } from "@tsdiapi/server";
useRoute("feature")
.get("/:id")
.version("1")
.codes(codes)
.params(
Type.Object({
id: Type.String(),
})
)
.resolve(async (req) => {
const sessionId = req.params.id;
const session = await client.session.findUnique({
where: { id: sessionId },
});
if (!session) {
throw new ResponseBadRequest("Session not found");
}
return session;
})
.handler(async (req) => {
const session = req.routeData;
const data = await featureService.getData(session);
return sendSuccess(data);
})
.build();
Complete Example
// ✅ Import framework utilities and helpers
import {
DateString,
AppContext,
useResponseSchemas,
ResponseBadRequest,
ResponseErrorSchema,
ResponseInternalServerError,
ResponseForbidden,
response400,
responseForbidden,
responseNull
} from "@tsdiapi/server"; // Core routing and types
import { Type } from "@sinclair/typebox"; // For schema definitions
import { JWTGuard, useSession } from "@tsdiapi/jwt-auth"; // Auth guard and session access
import { Container } from "typedi"; // DI container
import { useS3Provider } from "@tsdiapi/s3"; // S3 provider
// Use @base/api/typebox-schemas/models/index.js models for responses, request bodies and query parameters
// Use this import for responses
// Output to response
import { OutputContactSchema } from "@base/api/typebox-schemas/models/index.js";
// Input to request body
import { InputContactSchema } from "@base/api/typebox-schemas/models/index.js";
// Query to query parameters in list routes
import { QueryListContactSchema, OutputListContactSchema } from "@base/api/typebox-schemas/models/index.js";
import { usePrisma } from "@tsdiapi/prisma";
// Use this import for prisma client
import type { PrismaClient } from "@generated/prisma/index.js";
// Use this function to get prisma client but inside the service method (not outside the function) and pass the prisma client type: usePrisma<PrismaClient>()
import { usePrisma } from "@tsdiapi/prisma";
// ✅ Import service
import { ContactService } from "./contacts.service.js";
// 🔧 Generic reusable schema for all API error messages
// RULE: Use error schema to add details to the error response
const ErrorDetailsSchema = Type.Object({
errors: Type.Array(Type.Object({
message: Type.String()
}))
});
// Add file-related schemas
const OutputUploadSchema = Type.Object({
url: Type.String(),
key: Type.String(),
bucket: Type.String(),
region: Type.String()
});
// Simple schema with one regular field and one file
import { DateString } from "@tsdiapi/server"; // RULE: Use DateString for date fields!
const InputUploadSchema = Type.Object({
description: Type.String(),
dateField: DateString(),
// RULE: Use Type.String({ format: "binary" }) for file fields
file: Type.String({ format: "binary" })
});
// Multiple files schema
const InputMultiFilesSchema = Type.Object({
files: Type.Array(Type.String({ format: "binary" }))
});
// or
const InputWithFilesSchema = Type.Object({
photo: Type.String({ format: "binary" }),
document: Type.String({ format: "binary" }),
// and more fields
});
// ─────────────────────────────────────────────────────────────
// Main module export — loaded automatically by the framework
// File: src/api/contacts/contacts.module.ts
// ─────────────────────────────────────────────────────────────
export default function ContactsModule({ useRoute }: AppContext): void {
// 🧩 Dependency Injection — retrieve service instance via TypeDI
const contactService = Container.get(ContactService);
// Setup response schemas for the module
const { codes, sendError, sendSuccess, send } = useResponseSchemas(
OutputContactSchema,
ErrorDetailsSchema
);
// ─────────────────────────────────────────────────────────────
// GET /contacts — List contacts with optional filters
// RULES:
// 1. Every route MUST register ALL possible response codes
// 2. Success codes use 200 for all operations (including POST)
// 3. Error codes (400, 401, 403, 404, 500) must be documented
// 4. For authenticated routes, 403 response is REQUIRED
// 5. Each code MUST have a corresponding schema
// ─────────────────────────────────────────────────────────────
useRoute()
.controller("contacts") // RULE: Use controller name
.get("/") // RULE: Use HTTP method and path
.version("1") // RULE: Always specify API version
.codes(codes) // Use the predefined response codes
.summary("List all user contacts") // RULE: Use a meaningful summary, swagger will use it as a description
.tags(["Contacts"]) // RULE: Use tags for grouping, swagger will use it for grouping
.auth("bearer") // RULE: Use auth method and guard (import from @tsdiapi/jwt-auth)
.guard(JWTGuard()) // RULE: Use guard for authentication (import from @tsdiapi/jwt-auth)
// Then we define the query parameters
.query(
Type.Object({
// RULE: Optional fields must be explicitly marked
name: Type.Optional(Type.String()),
telegram: Type.Optional(Type.String()),
phoneNumber: Type.Optional(Type.String()),
hasWhatsapp: Type.Optional(Type.Boolean())
})
)
// Then we define the route handler, we can use resolve() for existence check and return error object instead of throwing
// resolver will be called before handler, and will return the data to the handler in req.routeData as type of the resolver return type
.resolve(async (req) => {
const contact = await contactService.getContactById(req.params.id);
if (!contact) {
// RULE: When resolver acts as a guard, throw the error instead of returning it
throw sendError("Contact not found", {
errors: [{
message: "Contact not found"
}]
});
}
return contact;
})
// Then we define the route handler, we can use useSession for type-safe session access
// We can use req.routeData for resolved data
.handler(async (req) => {
const session = useSession<{ userId: string }>(req);
const contact = req.routeData;
const data = await contactService.listContacts(session.userId, req.query);
return sendSuccess(data);
})
// We need to build the route, this is required for the route to be registered!
.build();
// ─────────────────────────────────────────────────────────────
// POST /contacts — Create a contact
// RULES:
// 1. Use Input schemas for request bodies
// 2. Success code is 200 (not 201)
// 3. Always validate user ownership
// ─────────────────────────────────────────────────────────────
useRoute()
.controller("contacts")
.put("/:id") // RULE: Use HTTP method and path with dynamic route (:id), we need to define the params for the dynamic route see below
.version("1")
.codes(buildExtraResponseCodes(InputUploadSchema)) // Use the predefined response codes
// We need to define the params for the dynamic route (:id), we can use req.params for them
.params(Type.Object({ id: Type.String() }))
.summary("Create contact")
.tags(["Contacts"])
// We can use auth method and guard, we can use async function for custom validation
.auth('bearer', async (req, reply) => {
const isValid = await isBearerValid(req);
if (!isValid) {
// Error without payload (details in the error schema)
return responseForbidden('Invalid access token');
}
return true;
})
// Use query parameters for optional fields
.query(Type.Object({
isPrivate: Type.Boolean()
}))
// Required for file fields
.acceptMultipart() // RULE: Use acceptMultipart() for file fields
.body(InputUploadSchema) // RULE: Use Input schema for request body
// RULE: Use fileOptions for file fields validation
.fileOptions(
{
maxFileSize: 10 * 1024 * 1024, // 10MB
accept: ["image/*", "application/pdf"]
},
"file" // Not required, we can use it for set validation rules for the specific file field
)
.handler(async (req) => {
const params = req.params;
const body = req.body;
const { isPrivate } = req.query;
const session = useSession<{ userId: string }>(req);
const file = req.tempFiles[0];
const s3provider = useS3Provider();
const upload = await s3provider.uploadToS3({
buffer: file.buffer,
mimetype: file.mimetype,
originalname: file.filename
}, isPrivate);
return sendSuccess(upload);
})
.build();
/*
* Use InputContactSchema for request body
*/
useRoute()
.controller("contacts")
.post("/")
.version("1")
.codes(codes)
.body(InputContactSchema)
.handler(async (req) => {
const contact = await contactService.create(req.body);
return sendSuccess(contact);
})
.build();
// ─────────────────────────────────────────────────────────────
// DELETE /contacts/:id — Delete a contact
// RULES:
// 1. Use resolve() for existence check
// 2. Return 204 for successful deletion
// 3. Always check ownership before deletion
// ─────────────────────────────────────────────────────────────
useRoute()
.controller("contacts")
.delete(":id")
.version("1")
.code(204, Type.Null())
.code(400, ResponseErrorSchema)
.code(403, ResponseErrorSchema)
.summary("Delete contact")
.tags(["Contacts"])
.auth("bearer")
// use custom guard for custom validation
.guard(
async (req, reply) => {
const isValid = await isBearerValid(req);
if (!isValid) {
return responseForbidden('Invalid access token');
}
return true;
}
)
.params(Type.Object({ id: Type.String() }))
// Resolver acting as a guard - should throw errors
.resolve(async (req) => {
const contact = await contactService.getContactById(req.params.id);
if (!contact) {
throw new ResponseBadRequest("Contact not found");
}
return contact;
})
.handler(async (req) => {
const session = useSession<{ userId: string }>(req);
const contact = req.routeData;
// RULE: Always check ownership
if (contact.userId !== session.userId) {
throw new ResponseForbidden("Forbidden");
}
await contactService.deleteContact(req.params.id);
return responseNull();
})
.build();
List and Query Support
Schema Types
When working with lists and queries, we use several schema types:
// Import schema types from generated models
import {
QueryListCitySchema, // Schema for query parameters in list routes
OutputListCitySchema, // Schema for list response (items, total, skip, take)
InputCitySchema, // Schema for request body (create/update)
OutputCitySchema // Schema for single item response
} from "@base/api/typebox-schemas/models/index.js";
// Example of list route with query support
useRoute()
.controller("cities")
.get("/")
.version("1")
.query(QueryListCitySchema) // Use QueryListCitySchema for query parameters
.codes(codes)
.summary("List all cities")
.tags(["Cities"])
.handler(async (req) => {
const { items, total } = await cityService.listCities(req.query);
return sendSuccess({
items: items,
total: total,
skip: req.query.skip,
take: req.query.take
});
})
.build();
Service Implementation
The service layer handles the query parameters and returns paginated results:
@Service()
export class CityService {
async listCities(query: QueryListCitySchemaType) {
const prisma = usePrisma<PrismaClient>();
const where: Prisma.CityWhereInput = {
// Date range filtering
...(query.dateAtLte && query.dateAtGte ? {
createdAt: {
lte: query.dateAtLte,
gte: query.dateAtGte
}
} : {}),
...(query?.dateAtGte && !query?.dateAtLte ? {
createdAt: {
gte: query.dateAtGte
}
} : {}),
...(query?.dateAtLte && !query?.dateAtGte ? {
createdAt: {
lte: query.dateAtLte
}
} : {}),
// Search filtering
...(query?.search ? {
name: {
contains: query.search,
mode: "insensitive"
}
} : {}),
}
// Get paginated results
const results = await prisma.city.findMany({
take: query.take || 100,
skip: query.skip || 0,
...(query?.orderBy ? {
[query.orderBy]: query.orderDirection
} : {}),
where: where
});
// Get total count for pagination
const total = await prisma.city.count({
where: where
});
return {
items: results,
total: total
}
}
}
Query Schema Example
The query schema for list operations typically includes:
const QueryListCitySchema = Type.Object({
// Pagination
skip: Type.Optional(Type.Number()),
take: Type.Optional(Type.Number()),
// Sorting
orderBy: Type.Optional(Type.String()),
orderDirection: Type.Optional(Type.Union([
Type.Literal("asc"),
Type.Literal("desc")
])),
// Filtering
search: Type.Optional(Type.String()),
dateAtGte: Type.Optional(DateString()),
dateAtLte: Type.Optional(DateString())
});
Response Schema Example
The response schema for list operations includes:
const OutputListCitySchema = Type.Object({
items: Type.Array(OutputCitySchema),
total: Type.Number(),
skip: Type.Optional(Type.Number()),
take: Type.Optional(Type.Number())
});
Best Practices
-
Query Parameters:
- Use
QueryListCitySchema
for list route query parameters - Make all query parameters optional with
Type.Optional()
- Include pagination parameters (skip, take)
- Include sorting parameters (orderBy, orderDirection)
- Include filtering parameters (search, date ranges)
- Use
-
Response Format:
- Return paginated results with total count
- Include skip and take in response for client-side pagination
- Use consistent response format across all list endpoints
-
Service Layer:
- Handle all query parameters in service layer
- Implement proper filtering and sorting
- Return both items and total count
- Use Prisma's built-in pagination
-
Error Handling:
- Validate query parameters
- Handle database errors
- Return appropriate error responses