516 lines
12 KiB
Markdown
516 lines
12 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.
|
|
|
|
---
|
|
|
|
## Step 1: Define Route
|
|
|
|
Create or update route file in `src/routes/`:
|
|
|
|
```javascript
|
|
// src/routes/courses.routes.js
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const courseController = require('../controllers/course.controller');
|
|
const { authenticate, authorize } = require('../middleware/auth');
|
|
const { validate } = require('../middleware/validation');
|
|
const { courseSchema } = require('../validators/course.validator');
|
|
|
|
// Public routes
|
|
router.get('/', courseController.list);
|
|
router.get('/:id', courseController.getById);
|
|
|
|
// Instructor routes
|
|
router.post(
|
|
'/',
|
|
authenticate,
|
|
authorize(['INSTRUCTOR', 'ADMIN']),
|
|
validate(courseSchema.create),
|
|
courseController.create
|
|
);
|
|
|
|
router.put(
|
|
'/:id',
|
|
authenticate,
|
|
authorize(['INSTRUCTOR', 'ADMIN']),
|
|
validate(courseSchema.update),
|
|
courseController.update
|
|
);
|
|
|
|
module.exports = router;
|
|
```
|
|
|
|
---
|
|
|
|
## Step 2: Create Validator
|
|
|
|
Create validator in `src/validators/`:
|
|
|
|
```javascript
|
|
// src/validators/course.validator.js
|
|
const Joi = require('joi');
|
|
|
|
const multiLangSchema = Joi.object({
|
|
th: Joi.string().required(),
|
|
en: Joi.string().required()
|
|
});
|
|
|
|
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()
|
|
})
|
|
};
|
|
|
|
module.exports = { courseSchema };
|
|
```
|
|
|
|
---
|
|
|
|
## Step 3: Create Controller
|
|
|
|
Create controller in `src/controllers/`:
|
|
|
|
```javascript
|
|
// src/controllers/course.controller.js
|
|
const courseService = require('../services/course.service');
|
|
|
|
class CourseController {
|
|
async list(req, res) {
|
|
try {
|
|
const { page = 1, limit = 20, category, search } = req.query;
|
|
|
|
const result = await courseService.list({
|
|
page: parseInt(page),
|
|
limit: parseInt(limit),
|
|
category: category ? parseInt(category) : undefined,
|
|
search
|
|
});
|
|
|
|
return res.status(200).json(result);
|
|
} catch (error) {
|
|
console.error('List courses error:', error);
|
|
return res.status(500).json({
|
|
error: {
|
|
code: 'INTERNAL_ERROR',
|
|
message: 'Failed to fetch courses'
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async getById(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
const course = await courseService.getById(parseInt(id));
|
|
|
|
if (!course) {
|
|
return res.status(404).json({
|
|
error: {
|
|
code: 'COURSE_NOT_FOUND',
|
|
message: 'Course not found'
|
|
}
|
|
});
|
|
}
|
|
|
|
return res.status(200).json(course);
|
|
} catch (error) {
|
|
console.error('Get course error:', error);
|
|
return res.status(500).json({
|
|
error: {
|
|
code: 'INTERNAL_ERROR',
|
|
message: 'Failed to fetch course'
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async create(req, res) {
|
|
try {
|
|
const course = await courseService.create(req.body, req.user.id);
|
|
return res.status(201).json(course);
|
|
} catch (error) {
|
|
console.error('Create course error:', error);
|
|
return res.status(500).json({
|
|
error: {
|
|
code: 'INTERNAL_ERROR',
|
|
message: 'Failed to create course'
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async update(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Check ownership
|
|
const course = await courseService.getById(parseInt(id));
|
|
if (!course) {
|
|
return res.status(404).json({
|
|
error: {
|
|
code: 'COURSE_NOT_FOUND',
|
|
message: 'Course not found'
|
|
}
|
|
});
|
|
}
|
|
|
|
if (course.created_by !== req.user.id && req.user.role.code !== 'ADMIN') {
|
|
return res.status(403).json({
|
|
error: {
|
|
code: 'FORBIDDEN',
|
|
message: 'You do not have permission to update this course'
|
|
}
|
|
});
|
|
}
|
|
|
|
const updated = await courseService.update(parseInt(id), req.body);
|
|
return res.status(200).json(updated);
|
|
} catch (error) {
|
|
console.error('Update course error:', error);
|
|
return res.status(500).json({
|
|
error: {
|
|
code: 'INTERNAL_ERROR',
|
|
message: 'Failed to update course'
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = new CourseController();
|
|
```
|
|
|
|
---
|
|
|
|
## Step 4: Create Service
|
|
|
|
Create service in `src/services/`:
|
|
|
|
```javascript
|
|
// src/services/course.service.js
|
|
const { PrismaClient } = require('@prisma/client');
|
|
const prisma = new PrismaClient();
|
|
|
|
class CourseService {
|
|
async list({ page, limit, category, search }) {
|
|
const skip = (page - 1) * limit;
|
|
|
|
const where = {
|
|
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,
|
|
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,
|
|
limit,
|
|
total,
|
|
totalPages: Math.ceil(total / limit)
|
|
}
|
|
};
|
|
}
|
|
|
|
async getById(id) {
|
|
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, userId) {
|
|
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, data) {
|
|
return prisma.course.update({
|
|
where: { id },
|
|
data,
|
|
include: {
|
|
category: true,
|
|
instructors: {
|
|
include: { user: { include: { profile: true } } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = new CourseService();
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: Register Route
|
|
|
|
Update `src/app.js`:
|
|
|
|
```javascript
|
|
const express = require('express');
|
|
const app = express();
|
|
|
|
// Middleware
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
// Routes
|
|
const authRoutes = require('./routes/auth.routes');
|
|
const courseRoutes = require('./routes/courses.routes');
|
|
|
|
app.use('/api/auth', authRoutes);
|
|
app.use('/api/courses', courseRoutes);
|
|
|
|
module.exports = app;
|
|
```
|
|
|
|
---
|
|
|
|
## Step 6: Write Tests
|
|
|
|
Create test file in `tests/integration/`:
|
|
|
|
```javascript
|
|
// tests/integration/courses.test.js
|
|
const request = require('supertest');
|
|
const app = require('../../src/app');
|
|
|
|
describe('Course API', () => {
|
|
let instructorToken;
|
|
|
|
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 7: Run Tests
|
|
|
|
// turbo
|
|
```bash
|
|
npm test -- courses.test.js
|
|
```
|
|
|
|
---
|
|
|
|
## Step 8: Test Manually
|
|
|
|
// turbo
|
|
```bash
|
|
# Create course
|
|
curl -X POST http://localhost:4000/api/courses \
|
|
-H "Authorization: Bearer <token>" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"title": {"th": "ทดสอบ", "en": "Test"},
|
|
"description": {"th": "รายละเอียด", "en": "Description"},
|
|
"category_id": 1,
|
|
"price": 990
|
|
}'
|
|
|
|
# List courses
|
|
curl http://localhost:4000/api/courses?page=1&limit=10
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist
|
|
|
|
- [ ] Route defined with proper middleware
|
|
- [ ] Validator created with Joi/Zod
|
|
- [ ] Controller created with error handling
|
|
- [ ] Service created with business logic
|
|
- [ ] Route registered in app.js
|
|
- [ ] Tests written (unit + integration)
|
|
- [ ] All tests passing
|
|
- [ ] Manual testing done
|
|
- [ ] Documentation updated
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
1. **Separation of Concerns**: Routes → Controllers → Services → Database
|
|
2. **Validation**: Always validate input at route level
|
|
3. **Authorization**: Check permissions in controller
|
|
4. **Error Handling**: Use try-catch and return proper error codes
|
|
5. **Multi-Language**: Use JSON structure for user-facing text
|
|
6. **Pagination**: Always paginate list endpoints
|
|
7. **Testing**: Write tests before deploying
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### File Upload Endpoint
|
|
```javascript
|
|
const multer = require('multer');
|
|
const upload = multer({ dest: 'uploads/' });
|
|
|
|
router.post(
|
|
'/upload',
|
|
authenticate,
|
|
upload.single('file'),
|
|
validate(uploadSchema),
|
|
controller.upload
|
|
);
|
|
```
|
|
|
|
### Ownership Check
|
|
```javascript
|
|
// In controller
|
|
const resource = await service.getById(id);
|
|
if (resource.user_id !== req.user.id && req.user.role.code !== 'ADMIN') {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
```
|
|
|
|
### Soft Delete
|
|
```javascript
|
|
// In service
|
|
async delete(id) {
|
|
return prisma.course.update({
|
|
where: { id },
|
|
data: { is_deleted: true, deleted_at: new Date() }
|
|
});
|
|
}
|
|
```
|