elearning/Backend/.windsurf/workflows/create-api-endpoint.md

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

  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

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"
  }
}