UNPKG

opencode-agent-kit

Version:

Multi-stack OpenCode agent toolkit — 33+ specialized AI agents, 200+ skills, 46 commands, 8 MCP servers (Nuxt, React, Node.js, Laravel, CI3, Android, Flutter, DevOps, SEO, SonarQube, and more)

952 lines (756 loc) 24.7 kB
--- name: node-express-prisma description: Express 5 route/controller/DTO pattern, Prisma ORM schema & migration workflow, JWT auth middleware, error handling, class-validator DTO, response envelope format, and testing strategy with Vitest/Supertest. Skill untuk agent @node-developer Node.js Backend Developer. version: 1.0.0 author: opencode-agent-kit --- # Node Express Prisma Skill Skill khusus untuk agent `@node-developer` — Node.js Backend Developer. Berisi pattern, kode contoh, dan workflow standar untuk membangun REST API dengan Express 5, Prisma ORM, PostgreSQL, JWT auth, class-validator DTO, response envelope, dan testing Vitest/Supertest. --- ## 1. Project Structure Standar ``` src/ ├── routes/ # Route definition files (*.route.ts) ├── controllers/ # Controller handlers (*.controller.ts) ├── dtos/ # Request & Response DTOs (*.dto.ts, *.response.dto.ts) ├── middlewares/ # Express middlewares (*.middleware.ts) ├── services/ # Business logic layer ├── utils/ # Helpers & utilities (*.util.ts) ├── prisma/ # Prisma schema & migrations │ ├── schema.prisma │ └── migrations/ ├── tests/ # Vitest test files ├── app.ts # Express app setup (middleware, routes) └── server.ts # Entry point (listen) ``` ### File Naming Convention | Tipe File | Pattern | Contoh | |------------------|-----------------------|-------------------------------| | Request DTO | `*.dto.ts` | `create-user.dto.ts` | | Response DTO | `*.response.dto.ts` | `user.response.dto.ts` | | Controller | `*.controller.ts` | `user.controller.ts` | | Route | `*.route.ts` | `user.route.ts` | | Middleware | `*.middleware.ts` | `auth.middleware.ts` | | Utility | `*.util.ts` | `envelope.util.ts` | --- ## 2. Response Envelope Format Semua API response harus melalui satu helper envelope yang konsisten. ### Envelope Utility (`envelope.util.ts`) ```typescript // Response envelope shape export interface ApiResponse<T = unknown> { success: boolean data: T | null message: string errors?: Record<string, string[]> meta?: { page?: number limit?: number total?: number totalPages?: number } } // Success export function success<T>(data: T, message = 'OK'): ApiResponse<T> { return { success: true, data, message } } // Success with pagination export function paginated<T>( data: T[], meta: { page: number; limit: number; total: number }, message = 'OK', ): ApiResponse<T[]> { return { success: true, data, message, meta: { ...meta, totalPages: Math.ceil(meta.total / meta.limit), }, } } // Error export function error( message: string, errors?: Record<string, string[]>, ): ApiResponse<null> { return { success: false, data: null, message, errors } } ``` **Aturan:** - `success: true` untuk response sukses - `success: false` untuk response error - `data` berisi payload atau `null` saat error - `message` memberikan konteks - `errors` opsional untuk validasi field-level - `meta` opsional untuk pagination metadata --- ## 3. DTO dengan class-validator ### Request DTO (`create-user.dto.ts`) ```typescript import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator' export class CreateUserDto { @IsString() @MinLength(3) @MaxLength(50) name!: string @IsEmail() email!: string @IsString() @MinLength(8) password!: string } ``` ### Response DTO (`user.response.dto.ts`) ```typescript import { Expose, Transform } from 'class-transformer' export class UserResponseDto { @Expose() id!: string @Expose() name!: string @Expose() email!: string @Expose() createdAt!: Date // Jangan expose password @Expose() @Transform(() => undefined) password?: never } ``` ### Validation Middleware (`validate.middleware.ts`) ```typescript import { Request, Response, NextFunction } from 'express' import { validate, ValidationError } from 'class-validator' import { plainToInstance } from 'class-transformer' import { error } from '../utils/envelope.util' import { ClassConstructor } from 'class-transformer' export function validateDto(dtoClass: ClassConstructor<unknown>, source: 'body' | 'query' | 'params' = 'body') { return async (req: Request, res: Response, next: NextFunction) => { const dtoObj = plainToInstance(dtoClass, req[source]) const errors: ValidationError[] = await validate(dtoObj as object) if (errors.length > 0) { const formatted: Record<string, string[]> = {} for (const err of errors) { formatted[err.property!] = Object.values(err.constraints!) } return res.status(400).json(error('Validation failed', formatted)) } req[source] = dtoObj next() } } ``` ### Response DTO Serialization ```typescript import { plainToInstance } from 'class-transformer' // Dalam controller: const userDto = plainToInstance(UserResponseDto, user, { excludeExtraneousValues: true, }) ``` --- ## 4. Controller Pattern ### Thin Controller Rule Controller hanya bertanggung jawab untuk: memanggil service, mapping response, dan error handling. ```typescript // user.controller.ts import { Request, Response, NextFunction } from 'express' import { plainToInstance } from 'class-transformer' import { UserService } from '../services/user.service' import { UserResponseDto } from '../dtos/user.response.dto' import { CreateUserDto } from '../dtos/create-user.dto' import { success } from '../utils/envelope.util' const userService = new UserService() export class UserController { async create(req: Request, res: Response, next: NextFunction) { try { const dto = req.body as CreateUserDto const user = await userService.create(dto) const response = plainToInstance(UserResponseDto, user, { excludeExtraneousValues: true, }) return res.status(201).json(success(response, 'User created')) } catch (err) { next(err) // Delegasikan ke error handler global } } async findAll(req: Request, res: Response, next: NextFunction) { try { const page = Number(req.query.page) || 1 const limit = Number(req.query.limit) || 10 const { data, total } = await userService.findAll(page, limit) const response = data.map(u => plainToInstance(UserResponseDto, u, { excludeExtraneousValues: true }), ) return res.status(200).json(success(response, 'Users fetched')) } catch (err) { next(err) } } async findById(req: Request, res: Response, next: NextFunction) { try { const user = await userService.findById(req.params.id) if (!user) { return res.status(404).json(success(null, 'User not found')) } const response = plainToInstance(UserResponseDto, user, { excludeExtraneousValues: true, }) return res.status(200).json(success(response, 'User fetched')) } catch (err) { next(err) } } } ``` --- ## 5. Route Registration ### User Route (`user.route.ts`) ```typescript import { Router } from 'express' import { UserController } from '../controllers/user.controller' import { validateDto } from '../middlewares/validate.middleware' import { authMiddleware } from '../middlewares/auth.middleware' import { CreateUserDto } from '../dtos/create-user.dto' const router = Router() const controller = new UserController() // Public routes router.post('/users', validateDto(CreateUserDto), controller.create) // Protected routes router.get('/users', authMiddleware, controller.findAll) router.get('/users/:id', authMiddleware, controller.findById) export default router ``` **Aturan Routing:** 1. Tempatkan `explicit routes` sebelum `parameterized routes` 2. Auth middleware ditempatkan sebelum handler 3. Validation middleware dipasang per route 4. Route registration harus eksplisit dan readable --- ## 6. App Setup (`app.ts`) ```typescript import express from 'express' import { errorHandler } from './middlewares/error.middleware' import userRoutes from './routes/user.route' const app = express() app.use(express.json()) // Routes app.use('/api/v1', userRoutes) // Global error handler (harus setelah routes) app.use(errorHandler) export default app ``` ### Server Entry (`server.ts`) ```typescript import app from './app' import { PrismaClient } from '@prisma/client' const prisma = new PrismaClient() const PORT = process.env.PORT || 3000 async function main() { await prisma.$connect() app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) } main().catch((e) => { console.error(e) process.exit(1) }) export { prisma } ``` --- ## 7. Global Error Handler (`error.middleware.ts`) ```typescript import { Request, Response, NextFunction } from 'express' import { error } from '../utils/envelope.util' // Custom error class export class AppError extends Error { constructor( public statusCode: number, public override message: string, public errors?: Record<string, string[]>, ) { super(message) } } // Global error handler middleware export function errorHandler( err: Error, _req: Request, res: Response, _next: NextFunction, ) { console.error('[ErrorHandler]', err) if (err instanceof AppError) { return res .status(err.statusCode) .json(error(err.message, err.errors)) } // Prisma known errors if (err.constructor?.name === 'PrismaClientKnownRequestError') { const prismaErr = err as any if (prismaErr.code === 'P2002') { return res.status(409).json(error('Resource already exists')) } if (prismaErr.code === 'P2025') { return res.status(404).json(error('Resource not found')) } } // Fallback — jangan bocorkan detail error ke client return res.status(500).json(error('Internal server error')) } ``` --- ## 8. JWT Auth Middleware (`auth.middleware.ts`) ```typescript import { Request, Response, NextFunction } from 'express' import jwt from 'jsonwebtoken' import { error } from '../utils/envelope.util' import { AppError } from './error.middleware' export interface JwtPayload { userId: string email: string role: string } // Extend Express Request declare global { namespace Express { interface Request { user?: JwtPayload } } } export function authMiddleware(req: Request, _res: Response, next: NextFunction) { const authHeader = req.headers.authorization if (!authHeader?.startsWith('Bearer ')) { throw new AppError(401, 'Missing or invalid authorization header') } const token = authHeader.split(' ')[1] try { const secret = process.env.JWT_SECRET if (!secret) throw new Error('JWT_SECRET not configured') const decoded = jwt.verify(token, secret) as JwtPayload req.user = decoded next() } catch (err) { if (err instanceof AppError) throw err throw new AppError(401, 'Invalid or expired token') } } // Role-based guard (opsional) export function requireRole(...roles: string[]) { return (req: Request, _res: Response, next: NextFunction) => { if (!req.user || !roles.includes(req.user.role)) { throw new AppError(403, 'Insufficient permissions') } next() } } ``` ### JWT Utility (`jwt.util.ts`) ```typescript import jwt from 'jsonwebtoken' import type { JwtPayload } from '../middlewares/auth.middleware' export function generateToken(payload: Omit<JwtPayload, 'iat' | 'exp'>): string { const secret = process.env.JWT_SECRET if (!secret) throw new Error('JWT_SECRET not configured') return jwt.sign(payload, secret, { expiresIn: '7d' }) } export function verifyToken(token: string): JwtPayload { const secret = process.env.JWT_SECRET if (!secret) throw new Error('JWT_SECRET not configured') return jwt.verify(token, secret) as JwtPayload } ``` **Security Checklist:** - JWT_SECRET harus dari environment variable, jangan hardcoded - Token expiry wajib (default: 7d) - Gunakan `Bearer` scheme di Authorization header - Jangan expose error detail invalid token ke client - Role guard dipasang setelah authMiddleware --- ## 9. Prisma ORM — Schema & Migration Workflow ### Schema (`prisma/schema.prisma`) ```prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(uuid()) name String email String @unique password String role String @default("user") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("users") } ``` ### Migration Workflow ```bash # 1. Buat migration baru npx prisma migrate dev --name add_user_table # 2. Apply migration ke production npx prisma migrate deploy # 3. Regenerate Prisma Client setelah schema berubah npx prisma generate # 4. Reset database (development only) npx prisma migrate reset # 5. Seed database npx prisma db seed ``` ### Prisma Service Pattern (`services/user.service.ts`) ```typescript import { PrismaClient } from '@prisma/client' import bcrypt from 'bcrypt' import { prisma } from '../server' import { CreateUserDto } from '../dtos/create-user.dto' export class UserService { async create(dto: CreateUserDto) { const hashedPassword = await bcrypt.hash(dto.password, 12) return prisma.user.create({ data: { name: dto.name, email: dto.email, password: hashedPassword, }, select: { id: true, name: true, email: true, createdAt: true }, }) } async findAll(page: number, limit: number) { const skip = (page - 1) * limit const [data, total] = await Promise.all([ prisma.user.findMany({ skip, take: limit, orderBy: { createdAt: 'desc' } }), prisma.user.count(), ]) return { data, total } } async findById(id: string) { return prisma.user.findUnique({ where: { id } }) } async findByEmail(email: string) { return prisma.user.findUnique({ where: { email } }) } } ``` ### Transaction Pattern ```typescript // Multi-step write dalam transaction await prisma.$transaction(async (tx) => { const user = await tx.user.create({ data: { ... } }) const profile = await tx.profile.create({ data: { userId: user.id, ... } }) return user }) ``` **Aturan Database:** - Gunakan atomic writes untuk consistency-critical operations - Query hanya relasi dan field yang dibutuhkan (gunakan `select` atau `include` eksplisit) - Hindari hidden N+1 queries - Gunakan transaction untuk multi-step writes - Index kolom yang sering difilter --- ## 10. Authentication / Login Endpoint ### Auth Route (`auth.route.ts`) ```typescript import { Router } from 'express' import { AuthController } from '../controllers/auth.controller' import { validateDto } from '../middlewares/validate.middleware' import { LoginDto } from '../dtos/login.dto' const router = Router() const controller = new AuthController() router.post('/auth/login', validateDto(LoginDto), controller.login) router.post('/auth/register', validateDto(CreateUserDto), controller.register) export default router ``` ### Login DTO (`login.dto.ts`) ```typescript import { IsEmail, IsString } from 'class-validator' export class LoginDto { @IsEmail() email!: string @IsString() password!: string } ``` ### Auth Controller (`auth.controller.ts`) ```typescript import { Request, Response, NextFunction } from 'express' import bcrypt from 'bcrypt' import { UserService } from '../services/user.service' import { generateToken } from '../utils/jwt.util' import { LoginDto } from '../dtos/login.dto' import { success, error } from '../utils/envelope.util' import { AppError } from '../middlewares/error.middleware' const userService = new UserService() export class AuthController { async login(req: Request, res: Response, next: NextFunction) { try { const { email, password } = req.body as LoginDto const user = await userService.findByEmail(email) if (!user || !(await bcrypt.compare(password, user.password))) { throw new AppError(401, 'Invalid email or password') } const token = generateToken({ userId: user.id, email: user.email, role: user.role, }) return res.status(200).json(success({ token, userId: user.id }, 'Login successful')) } catch (err) { next(err) } } async register(req: Request, res: Response, next: NextFunction) { try { const user = await userService.create(req.body) const token = generateToken({ userId: user.id, email: user.email, role: 'user', }) return res.status(201).json(success({ token, userId: user.id }, 'Registration successful')) } catch (err) { next(err) } } } ``` --- ## 11. Testing dengan Vitest + Supertest ### Setup Test (`vitest.config.ts`) ```typescript import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, environment: 'node', setupFiles: ['./src/tests/setup.ts'], testTimeout: 15000, }, }) ``` ### Test Setup (`tests/setup.ts`) ```typescript import { PrismaClient } from '@prisma/client' import { beforeAll, afterAll, afterEach } from 'vitest' const prisma = new PrismaClient() beforeAll(async () => { // Pastikan koneksi database test tersedia await prisma.$connect() }) afterEach(async () => { // Bersihkan data test antar test (jika menggunakan database yang sama) await prisma.user.deleteMany() }) afterAll(async () => { await prisma.$disconnect() }) export { prisma } ``` ### Integration Test (`tests/user.test.ts`) ```typescript import { describe, it, expect, beforeAll } from 'vitest' import supertest from 'supertest' import app from '../app' const request = supertest(app) describe('POST /api/v1/users', () => { it('should create a new user', async () => { const res = await request .post('/api/v1/users') .send({ name: 'Test User', email: 'test@example.com', password: 'password123', }) expect(res.status).toBe(201) expect(res.body.success).toBe(true) expect(res.body.data).toHaveProperty('id') expect(res.body.data).toHaveProperty('name', 'Test User') expect(res.body.data).not.toHaveProperty('password') // Response DTO harus exclude }) it('should reject invalid email', async () => { const res = await request .post('/api/v1/users') .send({ name: 'Test', email: 'not-an-email', password: 'password123', }) expect(res.status).toBe(400) expect(res.body.success).toBe(false) expect(res.body.errors).toBeDefined() }) }) describe('GET /api/v1/users', () => { it('should require auth token', async () => { const res = await request.get('/api/v1/users') expect(res.status).toBe(401) }) it('should return users list with valid token', async () => { // Create user & get token via login const loginRes = await request .post('/api/v1/auth/login') .send({ email: 'test@example.com', password: 'password123' }) const token = loginRes.body.data.token const res = await request .get('/api/v1/users') .set('Authorization', `Bearer ${token}`) expect(res.status).toBe(200) expect(res.body.success).toBe(true) expect(Array.isArray(res.body.data)).toBe(true) }) }) ``` ### Unit Test — Controller (`tests/controllers/user.controller.test.ts`) ```typescript import { describe, it, expect, vi } from 'vitest' import { Request, Response, NextFunction } from 'express' import { UserController } from '../../controllers/user.controller' // Mock UserService vi.mock('../../services/user.service', () => ({ UserService: vi.fn().mockImplementation(() => ({ create: vi.fn().mockResolvedValue({ id: '1', name: 'Test', email: 'test@test.com', createdAt: new Date(), }), })), })) describe('UserController.create', () => { it('should return 201 on success', async () => { const controller = new UserController() const req = { body: { name: 'Test', email: 'test@test.com', password: 'pw' } } as Request const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response const next = vi.fn() as NextFunction await controller.create(req, res, next) expect(res.status).toHaveBeenCalledWith(201) expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ success: true }), ) }) }) ``` ### Script Package.json ```json { "scripts": { "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "type-check": "tsc --noEmit" } } ``` ### Test Strategy Summary | Layer | Tools | Fokus | |-----------------|-----------------|------------------------------------------| | Unit (Service) | Vitest + mock | Business logic, validasi, edge cases | | Unit (DTO) | class-validator | Validasi rules per field | | Integration | Vitest + Supertest | Full HTTP request/response cycle | | E2E (smoke) | Supertest | Critical paths: login, CRUD, auth guard | **Aturan Testing:** - Jangan tes Prisma query langsung di unit test — mock service layer - Integration test harus isolated: cleanup data antar test (`afterEach`) - Test auth guard: pastikan 401 tanpa token, 403 tanpa role yang sesuai - Test envelope shape: verifikasi `success`, `data`, `message` field ada --- ## 12. Endpoint Addition Workflow 1. **DTO Layer**: Buat/extend request DTO (`*.dto.ts`) + response DTO (`*.response.dto.ts`) 2. **Service Layer**: Implement business logic dengan Prisma queries 3. **Controller Layer**: Implement handler dengan standardized success/error + plainToInstance 4. **Route Layer**: Register route dengan middleware order yang benar (validate → auth → handler) 5. **Test Layer**: Tambah integration test di file test yang sesuai ### Query/List Pattern 1. Parse pagination/filter/sort secara aman dari `req.query` 2. Bangun `where`/`orderBy` clause yang deterministic 3. Query + count dengan filter yang sama (`Promise.all`) 4. Return envelope dengan pagination metadata ### Mutation Pattern 1. Validasi payload dengan DTO middleware 2. Cek ownership/authorization 3. Eksekusi transaction untuk multi-step writes 4. Emit log/audit entries jika domain-critical 5. Return mapped response DTO --- ## 13. Environment Variables (.env) ``` DATABASE_URL=postgresql://user:password@localhost:5432/mydb JWT_SECRET=your-super-secret-key-change-in-production PORT=3000 NODE_ENV=development ``` **Wajib divalidasi di startup server.** --- ## 14. Package Dependencies ```json { "dependencies": { "express": "^5.0.0", "@prisma/client": "^5.x", "class-validator": "^0.14.x", "class-transformer": "^0.5.x", "jsonwebtoken": "^9.x", "bcrypt": "^5.x", "reflect-metadata": "^0.2.x" }, "devDependencies": { "typescript": "^5.x", "prisma": "^5.x", "vitest": "^1.x", "supertest": "^6.x", "@types/express": "^5.x", "@types/jsonwebtoken": "^9.x", "@types/bcrypt": "^5.x", "@types/supertest": "^6.x", "tsx": "^4.x" } } ``` --- ## 15. TypeScript Config (`tsconfig.json`) ```json { "compilerOptions": { "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "resolveJsonModule": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "node", "sourceMap": true, "declaration": true, "declarationMap": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` --- ## Integration dengan Agent @node-developer Skill ini dipanggil oleh agent `@node-developer` (Node.js Backend Developer) saat: - Membangun endpoint REST API baru - Melakukan perubahan schema database - Implementasi auth & authorization - Menulis integration/unit tests - Refactor controller/service/route **Pastikan konsisten dengan agent conventions:** - File naming: `*.dto.ts`, `*.controller.ts`, `*.route.ts` - Response envelope: `success()`, `paginated()`, `error()` - Error handling: `AppError` + global `errorHandler` - Validation: `validateDto()` middleware di route level - Testing: `vitest` + `supertest` untuk integration test