629 lines
14 KiB
Markdown
629 lines
14 KiB
Markdown
---
|
|
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<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/`:
|
|
|
|
```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<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
|
|
```typescript
|
|
@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`:
|
|
|
|
```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"
|
|
}
|
|
}
|
|
```
|