--- description: How to create a new API endpoint --- # Create API Endpoint Workflow Follow these steps to create a new API endpoint for the E-Learning Platform backend using TypeScript and TSOA. --- ## Step 1: Define Route with TSOA Controller Create or update controller file in `src/controllers/`: ```typescript // src/controllers/course.controller.ts import { Request, Response } from 'express'; import { Controller, Get, Post, Put, Delete, Route, Tags, Security, Body, Path, Query, SuccessResponse } from 'tsoa'; import { courseService } from '../services/course.service'; import { CreateCourseDto, UpdateCourseDto, CourseListQuery } from '../types/course.types'; @Route('api/courses') @Tags('Courses') export class CourseController extends Controller { /** * Get list of courses * @summary List all approved courses */ @Get('/') @SuccessResponse(200, 'Success') public async list( @Query() page: number = 1, @Query() limit: number = 20, @Query() category?: number, @Query() search?: string ): Promise<{ data: any[]; pagination: { page: number; limit: number; total: number; totalPages: number; }; }> { const result = await courseService.list({ page: parseInt(page.toString()), limit: parseInt(limit.toString()), category: category ? parseInt(category.toString()) : undefined, search }); return result; } /** * Get course by ID * @summary Get course details */ @Get('{id}') @SuccessResponse(200, 'Success') public async getById(@Path() id: number): Promise { const course = await courseService.getById(parseInt(id.toString())); if (!course) { this.setStatus(404); throw new Error('Course not found'); } return course; } /** * Create a new course * @summary Create course (Instructor/Admin only) */ @Post('/') @Security('jwt', ['INSTRUCTOR', 'ADMIN']) @SuccessResponse(201, 'Created') public async create( @Body() body: CreateCourseDto, @Request() req: any ): Promise { const course = await courseService.create(body, req.user.id); this.setStatus(201); return course; } /** * Update course * @summary Update course (Instructor/Admin only) */ @Put('{id}') @Security('jwt', ['INSTRUCTOR', 'ADMIN']) @SuccessResponse(200, 'Success') public async update( @Path() id: number, @Body() body: UpdateCourseDto, @Request() req: any ): Promise { // Check ownership const course = await courseService.getById(parseInt(id.toString())); if (!course) { this.setStatus(404); throw new Error('Course not found'); } if (course.created_by !== req.user.id && req.user.role.code !== 'ADMIN') { this.setStatus(403); throw new Error('You do not have permission to update this course'); } const updated = await courseService.update(parseInt(id.toString()), body); return updated; } } ``` --- ## Step 2: Create Type Definitions Create types in `src/types/`: ```typescript // src/types/course.types.ts /** * Multi-language text structure */ export interface MultiLang { th: string; en: string; } /** * Create course DTO */ export interface CreateCourseDto { /** Course title in Thai and English */ title: MultiLang; /** Course description in Thai and English */ description: MultiLang; /** Category ID */ category_id: number; /** Course price */ price: number; /** Is the course free? */ is_free?: boolean; /** Does the course have a certificate? */ have_certificate?: boolean; /** Course thumbnail URL */ thumbnail?: string; } /** * Update course DTO */ export interface UpdateCourseDto { title?: MultiLang; description?: MultiLang; category_id?: number; price?: number; is_free?: boolean; have_certificate?: boolean; thumbnail?: string; } /** * Course list query parameters */ export interface CourseListQuery { page?: number; limit?: number; category?: number; search?: string; } ``` --- ## Step 3: Create Validation Schemas Create validator in `src/validators/`: ```typescript // src/validators/course.validator.ts import Joi from 'joi'; const multiLangSchema = Joi.object({ th: Joi.string().required(), en: Joi.string().required() }); export const courseSchema = { create: Joi.object({ title: multiLangSchema.required(), description: multiLangSchema.required(), category_id: Joi.number().integer().positive().required(), price: Joi.number().min(0).required(), is_free: Joi.boolean().default(false), have_certificate: Joi.boolean().default(false), thumbnail: Joi.string().uri().optional() }), update: Joi.object({ title: multiLangSchema.optional(), description: multiLangSchema.optional(), category_id: Joi.number().integer().positive().optional(), price: Joi.number().min(0).optional(), is_free: Joi.boolean().optional(), have_certificate: Joi.boolean().optional(), thumbnail: Joi.string().uri().optional() }) }; ``` --- ## Step 4: Create Service Create service in `src/services/`: ```typescript // src/services/course.service.ts import { PrismaClient } from '@prisma/client'; import { CreateCourseDto, UpdateCourseDto, CourseListQuery } from '../types/course.types'; const prisma = new PrismaClient(); class CourseService { async list({ page, limit, category, search }: CourseListQuery) { const skip = ((page || 1) - 1) * (limit || 20); const where: any = { is_deleted: false, status: 'APPROVED' }; if (category) { where.category_id = category; } if (search) { where.OR = [ { title: { path: ['th'], string_contains: search } }, { title: { path: ['en'], string_contains: search } } ]; } const [data, total] = await Promise.all([ prisma.course.findMany({ where, skip, take: limit || 20, include: { category: true, instructors: { where: { is_primary: true }, include: { user: { include: { profile: true } } } } }, orderBy: { created_at: 'desc' } }), prisma.course.count({ where }) ]); return { data, pagination: { page: page || 1, limit: limit || 20, total, totalPages: Math.ceil(total / (limit || 20)) } }; } async getById(id: number) { return prisma.course.findUnique({ where: { id }, include: { category: true, instructors: { include: { user: { include: { profile: true } } } }, chapters: { include: { lessons: { select: { id: true, title: true, type: true, sort_order: true } } }, orderBy: { sort_order: 'asc' } } } }); } async create(data: CreateCourseDto, userId: number) { return prisma.course.create({ data: { ...data, status: 'DRAFT', created_by: userId, instructors: { create: { user_id: userId, is_primary: true } } }, include: { category: true, instructors: { include: { user: { include: { profile: true } } } } } }); } async update(id: number, data: UpdateCourseDto) { return prisma.course.update({ where: { id }, data, include: { category: true, instructors: { include: { user: { include: { profile: true } } } } } }); } } export const courseService = new CourseService(); ``` --- ## Step 5: Generate TSOA Routes and Swagger Docs // turbo After creating the controller, generate routes and API documentation: ```bash # Generate TSOA routes and Swagger spec npm run tsoa:gen # This will: # 1. Generate routes in src/routes/tsoa-routes.ts # 2. Generate swagger.json in public/swagger.json # 3. Validate all TSOA decorators ``` --- ## Step 6: Register Routes in App Update `src/app.ts`: ```typescript // src/app.ts import express from 'express'; import swaggerUi from 'swagger-ui-express'; import { RegisterRoutes } from './routes/tsoa-routes'; const app = express(); // Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Register TSOA routes RegisterRoutes(app); // Swagger documentation app.use('/api-docs', swaggerUi.serve, async (_req, res) => { return res.send( swaggerUi.generateHTML(await import('../public/swagger.json')) ); }); export default app; ``` --- ## Step 7: Configure TSOA Create `tsoa.json` in project root: ```json { "entryFile": "src/app.ts", "noImplicitAdditionalProperties": "throw-on-extras", "controllerPathGlobs": ["src/controllers/**/*.controller.ts"], "spec": { "outputDirectory": "public", "specVersion": 3, "securityDefinitions": { "jwt": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" } } }, "routes": { "routesDir": "src/routes", "middleware": "express", "authenticationModule": "./src/middleware/auth.ts" } } ``` --- ## Step 8: Write Tests Create test file in `tests/integration/`: ```typescript // tests/integration/courses.test.ts import request from 'supertest'; import app from '../../src/app'; describe('Course API', () => { let instructorToken: string; beforeAll(async () => { const res = await request(app) .post('/api/auth/login') .send({ username: 'instructor1', password: 'password123' }); instructorToken = res.body.token; }); describe('POST /api/courses', () => { it('should create course with valid data', async () => { const courseData = { title: { th: 'คอร์สทดสอบ', en: 'Test Course' }, description: { th: 'รายละเอียด', en: 'Description' }, category_id: 1, price: 990, is_free: false }; const response = await request(app) .post('/api/courses') .set('Authorization', `Bearer ${instructorToken}`) .send(courseData); expect(response.status).toBe(201); expect(response.body).toHaveProperty('id'); expect(response.body.status).toBe('DRAFT'); }); it('should return 400 with invalid data', async () => { const response = await request(app) .post('/api/courses') .set('Authorization', `Bearer ${instructorToken}`) .send({ title: 'Invalid' }); expect(response.status).toBe(400); }); it('should return 401 without auth', async () => { const response = await request(app) .post('/api/courses') .send({}); expect(response.status).toBe(401); }); }); describe('GET /api/courses', () => { it('should list courses', async () => { const response = await request(app) .get('/api/courses') .query({ page: 1, limit: 10 }); expect(response.status).toBe(200); expect(response.body).toHaveProperty('data'); expect(response.body).toHaveProperty('pagination'); }); }); }); ``` --- ## Step 9: Run Tests // turbo ```bash npm test -- courses.test.ts ``` --- ## Step 10: View API Documentation After generating TSOA routes, access Swagger UI: ``` http://localhost:4000/api-docs ``` --- ## Checklist - [ ] Controller created with TSOA decorators - [ ] Type definitions created - [ ] Validator created with Joi - [ ] Service created with business logic - [ ] TSOA routes generated (`npm run tsoa:gen`) - [ ] Routes registered in app.ts - [ ] Tests written (unit + integration) - [ ] All tests passing - [ ] API documentation accessible at /api-docs - [ ] Manual testing done --- ## Best Practices 1. **TSOA Decorators**: Use proper decorators (@Get, @Post, @Security, etc.) 2. **Type Safety**: Define interfaces for all DTOs 3. **Documentation**: Add JSDoc comments for Swagger descriptions 4. **Validation**: Validate input at controller level 5. **Authorization**: Use @Security decorator for protected routes 6. **Multi-Language**: Use JSON structure for user-facing text 7. **Pagination**: Always paginate list endpoints 8. **Testing**: Write tests before deploying 9. **Auto-Generate**: Always run `npm run tsoa:gen` after route changes --- ## Common TSOA Patterns ### File Upload Endpoint ```typescript import { UploadedFile } from 'express-fileupload'; @Post('upload') @Security('jwt', ['INSTRUCTOR', 'ADMIN']) public async upload( @UploadedFile() file: Express.Multer.File ): Promise<{ url: string }> { const result = await uploadService.uploadFile(file); return { url: result.url }; } ``` ### Ownership Check ```typescript @Put('{id}') @Security('jwt') public async update( @Path() id: number, @Body() body: UpdateDto, @Request() req: any ): Promise { const resource = await service.getById(id); if (!resource) { this.setStatus(404); throw new Error('Not found'); } if (resource.user_id !== req.user.id && req.user.role.code !== 'ADMIN') { this.setStatus(403); throw new Error('Forbidden'); } return await service.update(id, body); } ``` ### Soft Delete ```typescript @Delete('{id}') @Security('jwt') public async delete(@Path() id: number): Promise { await service.softDelete(id); this.setStatus(204); } ``` --- ## Package.json Scripts Add these scripts to `package.json`: ```json { "scripts": { "dev": "nodemon --exec ts-node src/server.ts", "build": "npm run tsoa:gen && tsc", "tsoa:gen": "tsoa spec-and-routes", "start": "node dist/server.js", "test": "jest" } } ```