14 KiB
14 KiB
| 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/:
// 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<any> {
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<any> {
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<any> {
// 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/:
// 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/:
// 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/:
// 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:
# 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:
// 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:
{
"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/:
// 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
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
- TSOA Decorators: Use proper decorators (@Get, @Post, @Security, etc.)
- Type Safety: Define interfaces for all DTOs
- Documentation: Add JSDoc comments for Swagger descriptions
- Validation: Validate input at controller level
- Authorization: Use @Security decorator for protected routes
- Multi-Language: Use JSON structure for user-facing text
- Pagination: Always paginate list endpoints
- Testing: Write tests before deploying
- Auto-Generate: Always run
npm run tsoa:genafter route changes
Common TSOA Patterns
File Upload Endpoint
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
@Put('{id}')
@Security('jwt')
public async update(
@Path() id: number,
@Body() body: UpdateDto,
@Request() req: any
): Promise<any> {
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
@Delete('{id}')
@Security('jwt')
public async delete(@Path() id: number): Promise<void> {
await service.softDelete(id);
this.setStatus(204);
}
Package.json Scripts
Add these scripts to package.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"
}
}