Skip to main content

Code Example


// ✅ Import framework utilities and helpers
import {
DateString,
AppContext,
useResponseSchemas,
ResponseBadRequest,
ResponseErrorSchema,
ResponseInternalServerError,
ResponseForbidden,
response400,
responseForbidden,
responseNull,
response200
} 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 this import for session type, this file is generated by the jwt-auth plugin
import { UserSession } from "@base/api/auth/auth.service.js";

// 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.Optional(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 using QueryListContactSchema
.query(QueryListContactSchema)
// Then we define the route handler, we can use useSession for type-safe session access
.handler(async (req) => {
try {
// RULE: Use useSession for type-safe session access
const session = useSession<UserSession>(req);
const { items, total } = await contactService.listContacts(session.id, req.query);
// We need to return the data with status code 200 registered in the code() method above
return sendSuccess({
items: items,
total: total,
skip: req.query.skip,
take: req.query.take
});
} catch (error) {
// RULE: Check if error is ResponseError, otherwise return 400 error with message
return error instanceof ResponseError ? error : sendError("Failed to list contacts", {
// Not required, we can use it for add details to the error response
errors: [{
message: "Failed to list contacts"
}]
});
}
})
// 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(OutputUploadSchema)) // 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) => {
try {
const params = req.params; // RULE: Use req.params for dynamic route params
const body = req.body; // RULE: Use req.body for request body
const { isPrivate } = req.query; // RULE: Use req.query for query parameters
// RULE: Use useSession for type-safe session access
const session = useSession<{ userId: string }>(req);
// RULE: Use req.tempFiles for file fields, this a temp file from the client, we need to save it to the storage and get the url
const file = req.tempFiles[0];
// use import { useS3Provider } from "@tsdiapi/s3";
const s3provider = useS3Provider();
// Manual upload
const upload = await s3provider.uploadToS3({
buffer: file.buffer,
mimetype: file.mimetype,
originalname: file.filename
}, isPrivate);
return response200(upload); // or responseSuccess(upload)
} catch (error) {
// RULE: Check if error is ResponseError, otherwise return 400 error with message
return error instanceof ResponseError ? error : response400("Failed to upload file");
}
})
.build();

/*
* Use InputContactSchema for request body
*/
useRoute()
.controller("contacts")
.post("/")
.version("1")
.code(200, OutputContactSchema)
.code(400, ResponseErrorSchema)
.body(InputContactSchema)
.handler(async (req) => {
try {
const contact = await contactService.create(req.body);
return response200(contact);
} catch (error) {
return error instanceof ResponseError ? error : response400("Failed to create 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) => {
try {
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();
} catch (error) {
return error instanceof ResponseError ? error : response400("Failed to delete contact");
}
})
.build();
// Service implementation example
import { Service } from "typedi";
import {
ResponseBadRequest,
ResponseForbidden,
} from "@tsdiapi/server";


// Use this import for prisma types
import { Prisma } from '@generated/prisma/index.js';
// Use this import for prisma client type
import type { PrismaClient } from "@generated/prisma/index.js";
// Use this import for prisma client
import { usePrisma } from "@tsdiapi/prisma";
// Use this import for input schemas types
import { InputContactSchemaType } from "../typebox-schemas/models/index.js";
// Use this import for session type, this file is generated by the jwt-auth plugin
import { UserSession } from '@base/api/auth/auth.service.js';

@Service()
export class ContactService {
// Don't use prisma client directly, use usePrisma() inside methods
async findById(id: string) {
const prisma = usePrisma<PrismaClient>();
const contact = await prisma.contact.findUnique({ where: { id } });
if (!contact) {
throw new ResponseBadRequest(`Contact with ID ${id} not found`);
}
return contact;
}

async create(data: InputContactSchemaType) {
const prisma = usePrisma<PrismaClient>();
try {
return await prisma.contact.create({ data });
} catch (error) {
throw new ResponseBadRequest("Failed to create contact");
}
}

async update(session: UserSession, id: string, data: InputContactSchemaType) {
const prisma = usePrisma<PrismaClient>();
const contact = await this.findById(id);

// Always check ownership
if (contact.userId !== session.id) {
throw new ResponseForbidden(
"You do not have permission to update this contact"
);
}

try {
return await prisma.contact.update({
where: { id },
data,
});
} catch (error) {
throw new ResponseBadRequest("Failed to update contact");
}
}

async delete(session: UserSession, id: string) {
const prisma = usePrisma<PrismaClient>();
const contact = await this.findById(id);

// Always check ownership
if (contact.userId !== session.id) {
throw new ResponseForbidden(
"You do not have permission to delete this contact"
);
}

try {
await prisma.contact.delete({ where: { id } });
} catch (error) {
throw new ResponseBadRequest("Failed to delete contact");
}
}

async listContacts(session: UserSession, query: QueryListContactSchemaType) {
const prisma = usePrisma<PrismaClient>();
const where: Prisma.ContactWhereInput = {
userId: session.id,
// Date range filtering
...(query.dateAtLte || query.dateAtGte ? {
createdAt: {
...(query.dateAtGte && { gte: query.dateAtGte }),
...(query.dateAtLte && { lte: query.dateAtLte })
}
} : {}),

// Search filtering
...(query?.search ? {
OR: [
{ name: { contains: query.search, mode: "insensitive" } },
{ email: { contains: query.search, mode: "insensitive" } },
{ phone: { contains: query.search, mode: "insensitive" } }
]
} : {})
};

// Get paginated results
const results = await prisma.contact.findMany({
take: query.take || 100,
skip: query.skip || 0,
...(query?.orderBy ? {
orderBy: {
[query.orderBy]: query.orderDirection || 'asc'
}
} : {}),
where: where
});

// Get total count for pagination
const total = await prisma.contact.count({
where: where
});

return {
items: results,
total: total
};
}
}
// ─────────────────────────────────────────────────────────────
// API DEVELOPMENT RULES AND BEST PRACTICES
// ─────────────────────────────────────────────────────────────

/* 1. DATE HANDLING
Always use DateString from @tsdiapi/server for date fields:
Correct: createdAt: DateString()
Incorrect: createdAt: Type.String({ format: "date-time" })
*/

/* 2. FILE HANDLING
Always use Type.String({ format: "binary" }) for file fields:
Correct: file: Type.String({ format: "binary" })
Incorrect: file: Type.String()
- Use .acceptMultipart() for file uploads
- Define fileOptions with size limits and accepted types
- Access files via req.tempFiles
*/

/* 3. RESPONSE CODES
Every route MUST register ALL possible response codes. There are several ways to define response codes:

A. Using individual code() calls:
.code(200, OutputSchema) // Success response
.code(400, ErrorSchema) // Bad request
.code(401, ErrorSchema) // Unauthorized
.code(403, ErrorSchema) // Forbidden
.code(404, ErrorSchema) // Not found
.code(500, ErrorSchema) // Internal server error

B. Using codes() with predefined codes object:
const { codes } = useResponseSchemas(OutputSchema, ErrorSchema);
.codes(codes) // Will include all standard codes

or use manual codes
.codes({200: OutputSchema, 400: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, 500: ErrorSchema})

C. Using response builders:
.code(200, Type.Null()) // For 204 No Content
.code(400, ResponseBadRequest)
.code(403, ResponseForbidden)
.code(500, ResponseInternalServerError)

Rules for response codes:
- Success codes use 200 for all operations (including POST)
- Error codes (400, 401, 403, 404, 500) must be documented
- Each code MUST have a corresponding schema
- All possible error scenarios MUST be documented
- For authenticated routes, 403 response code is REQUIRED
- Use appropriate response builders for common error cases
*/

/* 4. SESSION HANDLING
Session object contains all data from JWT token:
const token = await authProvider.signIn({
userId: "123",
role: "admin",
permissions: ["read", "write"]
});
Access in route handler:
const { userId, role, permissions } = req.session;
For type-safe access, use useSession:
const session = useSession<UserSession>(req);
*/

/* 5. ERROR HANDLING
There are several approaches to error handling in TSDIAPI:

1. Using Response Schemas (Recommended):
const { codes, sendSuccess, sendError } = useResponseSchemas(
SuccessSchema,
ErrorSchema
);

useRoute("contacts")
.post("/")
.version('1')
.codes(codes) // Registers only 200, 400, 401, 403
.handler(async (req) => {
try {
const contact = await contactService.create(req.body);
return sendSuccess(contact);
} catch (error) {
return error instanceof ResponseError ? error : response400("Failed to create contact");
}
})
.build();

2. Using buildExtraResponseCodes (Recommended):
useRoute("contacts")
.post("/")
.version('1')
.codes(buildExtraResponseCodes(ContactSchema, ErrorSchema)) // Registers only 200, 400, 401, 403
.handler(async (req) => {
try {
const contact = await contactService.create(req.body);
return response200(contact);
} catch (error) {
return error instanceof ResponseError ? error : response400("Failed to create contact");
}
})
.build();

3. Service Layer Error Handling:
@Service()
class ContactService {
async findById(id: string) {
const contact = await this.prisma.contact.findUnique({ where: { id } });
if (!contact) {
throw new ResponseBadRequest(`Contact with ID ${id} not found`);
}
return contact;
}
}

4. Include Error Details:
const ValidationErrorDetails = Type.Object({
field: Type.String(),
message: Type.String()
});

const ErrorDetailsSchema = Type.Object({
errors: Type.Array(ValidationErrorDetails)
});

const { register: errorRegister, send: sendError } = useResponseErrorSchema(400, ErrorDetailsSchema);
useRoute("contacts")
.post("/")
.code(...errorRegister)
.handler(async (req) => {
try {
const contact = await contactService.create(req.body);
return response200(contact);
} catch (error) {
if (error instanceof ResponseError) {
return error;
}
return sendError(`Invalid input`, {
errors: [{
field: "email",
message: "Invalid email format"
}]
});
}
});

5. Handle Specific Error Types:
try {
const session = useSession<UserSession>(req);
const contact = await service.setMainContact(session?.id, req.params.id);
return { status: 200, data: contact };
} catch (error) {
if (error instanceof DatabaseError) {
return { status: 500, data: { error: error.message } };
}
if (error instanceof ValidationError) {
return { status: 400, data: { error: error.message } };
}
if (error instanceof UnauthorizedError) {
return { status: 401, data: { error: error.message } };
}
if (error instanceof ForbiddenError) {
return { status: 403, data: { error: error.message } };
}
return error instanceof ResponseError ? error : response400("Operation failed");
}

6. Handle Async Errors:
try {
await someAsyncOperation();
} catch (error) {
return error instanceof ResponseError ? error : response400("Operation failed");
}
*/

/* 6. SERVICE LAYER BEST PRACTICES
- Always wrap database operations in try-catch blocks
- Throw appropriate custom errors
- Handle relationships carefully
- Use TypeDI for dependency injection
- Keep business logic in service layer
- Use try-catch blocks for database operations
*/

/* 7. CONTROLLER NAMING AND VERSIONING
- use .controller(<controllerName>) for controller name
- Always specify version for each route
- Use semantic versioning (e.g., "1", "2")
- When making breaking changes, increment major version
- Support multiple versions simultaneously
- Each route must be defined separately with single HTTP method
*/

/* 8. FEATURE STRUCTURE
A feature must contain at least:
- name.service.ts (business logic)
- name.module.ts (route definitions)
*/

/* 9. TYPEBOX SCHEMA GENERATION
- Use Output schemas for responses
- Use Input schemas for request bodies
- Import from @base/api/typebox-schemas/models
*/