Validasi, Error Handling & Dokumentasi API
Input validation, sanitization, error handling terstruktur, dokumentasi API dengan Swagger/OpenAPI.
Validasi & Error Handling
Data dari Client Tidak Boleh Dipercaya
Pernahkah kamu memasukkan data yang tidak wajar ke sebuah form? Umur diisi minus, harga diisi teks?
Kadang aplikasi langsung menolak dengan pesan "input tidak valid", tetapi kadang justru mengembalikan error yang tidak jelas. Yang lebih parah, data tetap tersimpan. Padahal, server seharusnya tidak pernah mempercayai data yang dikirim dari HP atau laptop kita. Bisa saja pengirimnya bukan aplikasi resmi, melainkan orang iseng atau program jahat yang sengaja mencoba menerobos sistem.
Maka dari itu server membutuhkan "penjaga pintu" yang memeriksa format dan keamanan data terlebih dahulu sebelum diproses. Tanpa itu, siapa saja bisa memasukkan apa pun ke dalam sistem kita.
Validasi & Sanitasi
Jika server tidak boleh mempercayai input dari client, bagaimana cara memeriksanya? Terdapat dua lapisan:
Validasi itu seperti satpam yang memeriksa apakah data sesuai aturan atau tidak. Jika tidak, langsung ditolak dan server mengembalikan error. Contoh: harga diisi -500, ditolak karena harus bilangan positif. Sifatnya hanya dua: lolos atau tidak.
Sanitasi itu seperti petugas kebersihan yang tidak menolak data, melainkan membersihkannya sebelum digunakan. Membuang karakter berbahaya, meng-escape tag HTML. Contoh: pengguna mengetik <script>alert('xss')</script> di kolom komentar. Validasi mungkin lolos karena formatnya berupa string, tetapi sanitasi akan mengubah skrip tersebut menjadi teks biasa, tidak dieksekusi oleh browser.
Intinya, validasi menolak sedangkan sanitasi membersihkan. Keduanya dipakai bersamaan, validasi lebih dahulu untuk memeriksa, lalu sanitasi sebagai jaga-jaga jika ada karakter berbahaya yang lolos.
- Input Validation: perbedaan validasi dan sanitasi beserta contohnya.
Validasi di Middleware
Jika validasi dan sanitasi penting, pertanyaan berikutnya adalah, diletakkan di mana?Bayangkan jika tidak ada middleware. Setiap controller harus memeriksa sendiri:POST /stalls diperiksa manual,PUT /stalls/:id diperiksa manual lagi,POST /reviews diperiksa manual lagi. Kodenya menjadi berulang, dan begitu ada aturan baru, kamu harus mengedit di banyak tempat. Rumit, dan mudah terlewat.
Middleware itu seperti gerbang pemeriksaan sebelum masuk gedung. Semua request melewati sana terlebih dahulu, diperiksa sekali, baru boleh melanjutkan ke controller. Jika datanya tidak valid, langsungdikembalikan, controller tidak akan pernah menerima request yang bermasalah.
Validasi di middleware = sekali ditulis, digunakan di semua endpoint. Controller cukup fokus pada logika bisnis, tidak perlu memikirkan pemeriksaan input lagi.
- Using middleware (Express): cara kerja middleware dan urutan pemasangannya.
Error Handling & Status Code
Jika validasi gagal atau terjadi error lain, server harus memberi tahu client dengan cara yang jelas dan konsisten.
Masalahnya, error mentah seperti stack trace tidak boleh dikembalikan langsung ke client. Selain membocorkan detail internal (nama file, struktur database, versi library), frontend juga menjadi bingung karena formatnya berbeda-beda tiap error. Solusinya adalah error handler terpusat. Semua error ditangkap di satu tempat, lalu dikembalikan dengan format yang sama, misalnya selalu{ success: false, message: "...", errors: ... }. Jadi frontend cukup membaca satu format, tidak perlu menebak-nebak.
Dan supaya frontend tahu jenis errornya tanpa baca isi body, server pakai HTTP status code:
| Kode | Arti |
|---|---|
400 | Request salah format |
401 | Belum login |
403 | Sudah login, tetapi tidak memiliki akses |
404 | Data/endpoint tidak ada |
422 | Format benar, tetapi nilai tidak valid |
500 | Error di server |
Di hands-on pertemuan ini, kegagalan validasi zod dibalas 400 (lihatValidationError). Kode 422 di tabel atas adalah konvensi alternatif yang dipakai sebagian API. Yang penting, pilih satu gaya dan pakai konsisten.
Error itu bukan aib, tetapi cara mengembalikannya harus rapi, konsisten, dan informatif.
Hands-on 1
Salin Project & Bersihkan
Kita tidak memulai dari nol. db,dto, dan repository sudah jadi di pertemuan 3, jadi project itu kita salin lalu ubah hanya bagian yang berkaitan.
Buat folder praktikum-pert-4/hands-on-1. Lalu salin seluruh isi folderpraktikum-pert-3/hands-on-2 ke folder baru tersebut.
Database tidak perlu dikonfigurasi ulang: file .env ikut tersalin, jadi koneksinya tetapreview_kantin dengan user praktikum_user yang sama. Seluruh langkah berikutnya dijalankan dari dalam folder hands-on-1.
Struktur project setelah dilengkapi:
praktikum-pert-4/
└─ hands-on-1/
├─ package.json tsconfig.json drizzle.config.ts
├─ .env # SAMA - hasil salinan, reuse database pertemuan-03
└─ src/
├─ index.ts # UBAH - +404 dan error handler paling akhir
├─ db/{schema.ts, index.ts} # SAMA - tak disentuh
├─ dtos/stallDto.ts # SAMA
├─ repositories/ # SAMA
│ ├─ stallRepository.ts
│ └─ menuItemRepository.ts
├─ services/stallService.ts # UBAH - throw NotFoundError (bukan string)
├─ controllers/ # UBAH - tanpa try/catch
│ └─ stallController.ts
├─ routes/stallRouter.ts # UBAH - + middleware validate
├─ schemas/stallSchema.ts # BARU - schema zod body/query/params + z.infer
├─ errors/ # BARU - AppError, NotFoundError, ValidationError
│ ├─ AppError.ts
│ ├─ NotFoundError.ts
│ └─ ValidationError.ts
└─ middlewares/ # BARU - validate, notFound, errorHandler
├─ validate.ts
├─ notFound.ts
└─ errorHandler.tsProject salinan masih membawa dependensi Swagger pertemuan 3. Kita tambah zod dan buang yang sudah tidak dipakai:
npm install zod tanpa patokan versi akan memasang zod v4. Padahal seluruh schema di pertemuan ini memakai API zod v3 (invalid_type_error dan required_error) yang diabaikan di v4, sehingga pesan validasi berbahasa Indonesia tidak muncul dan hanya tersisa pesan bawaan Inggris. Selain itu, zod-to-openapi 7.3.4 di Hands-on 2 mensyaratkan zod@^3, jadi instalasinya akan gagal (ERESOLVE) bila zod v4 terpasang.
npm uninstall hanya membersihkandependencies. Bagian scripts dipackage.json masih membawaprestart, predev, dandocs:gen dari pertemuan 3 yang memanggiltsx src/docs/swagger.ts. Kalau dibiarkan,npm run dev akan memicu predevlalu gagal karena file-nya sudah dihapus. Hapus ketiga script itu, sisakanstart dan dev saja. Perhatikan juga koma di akhir baris dev: kalau baris sesudahnya dihapus, koma itu harus ikut dihapus. Bila tertinggal, npm menolak membaca package.json dengan error EJSONPARSE.
Isi akhir package.json (sudah tanpa dependensi swagger). Blok ini adalah hasil akhir dari perintah di atas, jadi tidak perlu diketik manual. Samakan saja bila versi di komputermu berbeda:
{
"name": "hands-on-1",
"version": "1.0.0",
"private": true,
"description": "Hands-on 1 Pertemuan 04 - validasi input dengan zod dan error handling terstruktur",
"main": "src/index.ts",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts"
},
"type": "commonjs",
"dependencies": {
"dotenv": "^17.4.2",
"drizzle-orm": "^1.0.0-rc.5-5935859",
"express": "^5.2.1",
"mssql": "^11.0.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/mssql": "^9.1.11",
"@types/node": "^26.5.1",
"drizzle-kit": "^1.0.0-rc.5-5935859",
"tsx": "^4.23.13",
"typescript": "^7.0.2"
}
}Konfigurasi .env
.env sudah terbawa saat menyalin project. Bila belum ada, salin file.env.example menjadi .env.
Isinya persis sama dengan pertemuan 3, tidak perlu diubah:
# Salin file ini menjadi .env lalu sesuaikan bila perlu.
# Jangan commit file .env yang berisi kredensial asli.
DB_SERVER=localhost
DB_PORT=1433
DB_USER=praktikum_user
DB_PASSWORD=Praktikum2026!
DB_NAME=review_kantin
# Untuk SQL Server lokal biasanya: encrypt=false, trustServerCertificate=true
DB_ENCRYPT=false
DB_TRUST_SERVER_CERTIFICATE=trueSchema Validasi zod
Satu file berisi schema untuk params, query, dan body. Pola yang perlu diperhatikan:
z.coerce.number()mengubah query/path yang selalu berbentuk string menjadinumber, jadi ini konversi, bukan sekadar validasi..default(1)dipakai bila client tidak mengirim nilai..trim()membersihkan whitespace di awal/akhir (sanitasi)..strict()menolak kunci yang tidak dikenal dari body (dibalas400), bukan membuangnya..partial()membuat semua field opsional, dipakai untuk PUT.z.infermenurunkan tipe TypeScript otomatis dari schema.
import { z } from "zod";
// ------------------------------------------------------------------ params
// id pada /:id selalu coerce dari string query/path menjadi angka.
export const idParamSchema = z.object({
id: z.coerce
.number({ invalid_type_error: "id harus berupa angka" })
.int("id harus bilangan bulat")
.positive("id harus lebih dari 0"),
});
// ------------------------------------------------------------------- query
// Search & category opsional; page/limit punya default (zod.default = bila absent).
export const stallQuerySchema = z.object({
search: z.string().trim().max(100).optional(),
category: z.string().trim().max(50).optional(),
page: z.coerce
.number({ invalid_type_error: "page harus berupa angka" })
.int("page harus bilangan bulat")
.min(1, "page minimal 1")
.default(1),
limit: z.coerce
.number({ invalid_type_error: "limit harus berupa angka" })
.int("limit harus bilangan bulat")
.min(1, "limit minimal 1")
.max(100, "limit maksimal 100")
.default(10),
});
// --------------------------------------------------------------- field bantu
const optionalNullableText = (max: number) =>
z.string().trim().max(max).nullable().optional();
// -------------------------------------------------------------------- body
export const createStallSchema = z
.object({
ownerId: z
.number({ invalid_type_error: "ownerId harus berupa angka" })
.int("ownerId harus bilangan bulat")
.positive("ownerId harus lebih dari 0"),
name: z
.string({ required_error: "name wajib diisi" })
.trim()
.min(3, "name minimal 3 karakter")
.max(100, "name maksimal 100 karakter"),
category: optionalNullableText(50),
location: optionalNullableText(100),
description: optionalNullableText(1000),
})
// .strict() menolak (atau membuang) kunci yang tidak dikenal.
.strict();
// Semua kolom opsional untuk PUT (update sebagian).
export const updateStallSchema = createStallSchema.partial();
// ------------------------------------------------------------------ types
export type IdParam = z.infer<typeof idParamSchema>;
export type StallQuery = z.infer<typeof stallQuerySchema>;
export type CreateStallInput = z.infer<typeof createStallSchema>;
export type UpdateStallInput = z.infer<typeof updateStallSchema>;Berkat z.infer, tipeCreateStallInput danUpdateStallInput yang dipakai service serta controller selalu selaras dengan schema. Ubah schema, tipe ikut berubah, dan TypeScript langsung menegur bila kode lain belum menyesuaikan.
Custom Error Class
Sebelumnya error hanya new Error('pesan') tanpa status code. Sekarang kita punya hierarki error yang masing-masing membawa status code HTTP.
/**
* Error dasar aplikasi yang membawa status code HTTP.
* Subclass lain (NotFoundError, ValidationError) menurun dari sini.
*/
export class AppError extends Error {
constructor(
public readonly statusCode: number,
message: string,
public readonly details?: unknown,
) {
super(message);
this.name = "AppError";
}
}import { AppError } from "./AppError.ts";
/** Dipakai saat resource (mis. warung) tidak ditemukan. Memetakan ke HTTP 404. */
export class NotFoundError extends AppError {
constructor(message = "Data tidak ditemukan") {
super(404, message);
this.name = "NotFoundError";
}
}import { AppError } from "./AppError.ts";
export interface ValidationIssue {
field: string;
message: string;
}
/** Diterapkan dari ZodError oleh middleware validate. Memetakan ke HTTP 400. */
export class ValidationError extends AppError {
constructor(public readonly issues: ValidationIssue[]) {
super(400, "Validasi gagal", issues);
this.name = "ValidationError";
}
}Jenis error jadi terdata rapi (instanceof bisa dibedakan), punya properti statusCode sungguhan, dan bisa membawa detail tambahan (errors). Error handler cukup mengecekinstanceof, tidak ada lagi penerjemahan string manual yang rawan typo.
Middleware validate
Ini factory middleware: ia menerima schema dan sumber (body/query/params), lalu mengembalikan handler Express. Kalau sukses, data yang lolos disimpan dires.locals.validated[source] untuk dibaca controller. Kalau gagal,ZodError diubah menjadiValidationError(issues) lalu diteruskan lewatnext(error).
import type { NextFunction, Request, RequestHandler, Response } from "express";
import type { ZodSchema } from "zod";
import {
ValidationError,
type ValidationIssue,
} from "../errors/ValidationError.ts";
export type RequestSource = "body" | "query" | "params";
/**
* Middleware validasi berbasis schema zod.
* Hasil parse yang sukses ditaruh di `res.locals.validated[source]`,
* dibaca controller lewat `getValidated()`.
*/
export function validate(
schema: ZodSchema,
source: RequestSource,
): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
const issues: ValidationIssue[] = result.error.issues.map((issue) => ({
field: issue.path.join(".") || source,
message: issue.message,
}));
return next(new ValidationError(issues));
}
const validated = (res.locals.validated ?? {}) as Record<
RequestSource,
unknown
>;
validated[source] = result.data;
res.locals.validated = validated;
return next();
};
}
/** Mengambil data hasil validasi dari satu sumber request di controller. */
export function getValidated<T>(res: Response, source: RequestSource): T {
const validated = (res.locals.validated ?? {}) as Record<
RequestSource,
unknown
>;
return validated[source] as T;
}safeParse mengembalikan objek{ success, data | error } tanpa melempar exception, jadi alurnya kita kontrol sendiri: map issue menjadi{ field, message }, lalu lemparValidationError. Di controller, data valid dibaca dengangetValidated<T>(res, 'body'), bukan darireq.body.
Not Found & Error Handler Terpusat
Error middleware Express punya 4 parameter(error, req, res, next). Express mengenali jenis middleware ini dari jumlah parameternya. Di sinilah semua error bermuara.
import type { ErrorRequestHandler } from "express";
import { AppError } from "../errors/AppError.ts";
import { NotFoundError } from "../errors/NotFoundError.ts";
import { ValidationError } from "../errors/ValidationError.ts";
/**
* Mengambil kode error SQL Server dari sebuah error.
* Sebagian versi Drizzle membungkus error driver (mis. `DrizzleQueryError`),
* sehingga `number` berada di `cause`, bukan di level atas.
*/
function getSqlErrorNumber(error: unknown): number | undefined {
let current: unknown = error;
for (let depth = 0; depth < 5; depth += 1) {
if (typeof current !== "object" || current === null) return undefined;
const code = (current as { number?: unknown }).number;
if (typeof code === "number") return code;
current = (current as { cause?: unknown }).cause;
}
return undefined;
}
/**
* Error handling terpusat: satu tempat untuk mengubah error menjadi
* respons JSON yang konsisten. Didaftarkan paling akhir (setelah router).
*/
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
// 1) Body JSON yang rusak (express.json otomatis melempar SyntaxError).
const bodyError = error as SyntaxError & { status?: number; type?: string };
if (
error instanceof SyntaxError &&
bodyError.status === 400 &&
bodyError.type === "entity.parse.failed"
) {
res.status(400).json({
status: "fail",
message: "JSON pada body tidak valid",
});
return;
}
// 2) Error aplikasi yang disengaja (NotFoundException, ValidationError, dll).
if (
error instanceof ValidationError ||
error instanceof NotFoundError ||
error instanceof AppError
) {
res.status(error.statusCode).json({
status: "fail",
message: error.message,
...(error.details !== undefined ? { errors: error.details } : {}),
});
return;
}
// 3) Konflik constraint database mssql:
// 2627 = unique violation, 547 = foreign key violation.
// Kode error ditelusuri lewat rantai `cause` karena sebagian versi Drizzle
// membungkus error driver sehingga `number` tidak ada di level atas.
const sqlNumber = getSqlErrorNumber(error);
if (sqlNumber === 2627 || sqlNumber === 547) {
res.status(409).json({
status: "fail",
message: "Data bentrok dengan data yang sudah ada",
});
return;
}
// 4) Fallback: error tak terduga. Detail tidak dibocorkan ke client.
console.error("Unhandled error:", error);
res.status(500).json({
status: "error",
message: "Terjadi kesalahan pada server",
});
};Empat cabang di atas:
- JSON rusak:
express.json()melemparSyntaxErrorbila body bukan JSON valid (mis.{bad json), kita balas400yang ramah. - Error aplikasi:
ValidationError,NotFoundError, atauAppErrorlain memakaistatusCodedanmessage-nya, pluserrorsbila membawa detail. - Constraint database: error mssql
2627(unique) dan547(foreign key) diterjemahkan menjadi409 Conflict. - Fallback
500: error tak terduga di-log ke terminal, detail internalnya tidak dibocorkan ke client.
404 otomatis untuk rute yang tidak dikenal:
import type { Request, Response } from "express";
/** Menangkap rute yang tidak dikenal dan membalas 404 JSON. */
export function notFoundHandler(req: Request, res: Response) {
res.status(404).json({
status: "fail",
message: `Route tidak ditemukan: ${req.method} ${req.originalUrl}`,
});
}Refactor Controller, Router & Service
Controller kini tanpa try/catch karena input sudah valid. Bila service melempar NotFoundError, Express 5 otomatis meneruskan rejection handler async ke errorHandler.
import type { Request, Response } from "express";
import { StallService } from "../services/stallService.ts";
import { getValidated } from "../middlewares/validate.ts";
import type {
CreateStallInput,
IdParam,
StallQuery,
UpdateStallInput,
} from "../schemas/stallSchema.ts";
/**
* Handler tanpa try/catch: data sudah divalidasi middleware `validate`
* (diambil lewat `getValidated`), dan error apa pun yang dilempar akan
* diteruskan Express ke `errorHandler` (Express 5 menangkap promise-reject
* dari async handler secara otomatis).
*/
export class StallController {
private stallService: StallService;
constructor(stallService: StallService = new StallService()) {
this.stallService = stallService;
}
getStalls = async (_req: Request, res: Response): Promise<void> => {
const query = getValidated<StallQuery>(res, "query");
const { data, total } = await this.stallService.getAllStalls({
search: query.search,
category: query.category,
page: query.page,
limit: query.limit,
});
res.status(200).json({
status: "success",
meta: { page: query.page, limit: query.limit, total },
data,
});
};
getStallById = async (_req: Request, res: Response): Promise<void> => {
const { id } = getValidated<IdParam>(res, "params");
const stall = await this.stallService.getStallById(id);
res.status(200).json({ status: "success", data: stall });
};
getStallMenus = async (_req: Request, res: Response): Promise<void> => {
const { id } = getValidated<IdParam>(res, "params");
const menus = await this.stallService.getStallMenus(id);
res.status(200).json({ status: "success", data: menus });
};
createStall = async (_req: Request, res: Response): Promise<void> => {
const body = getValidated<CreateStallInput>(res, "body");
const stall = await this.stallService.createStall(body);
res.status(201).json({ status: "success", data: stall });
};
updateStall = async (_req: Request, res: Response): Promise<void> => {
const { id } = getValidated<IdParam>(res, "params");
const body = getValidated<UpdateStallInput>(res, "body");
const stall = await this.stallService.updateStall(id, body);
res.status(200).json({ status: "success", data: stall });
};
deleteStall = async (_req: Request, res: Response): Promise<void> => {
const { id } = getValidated<IdParam>(res, "params");
const stall = await this.stallService.deleteStall(id);
res.status(200).json({ status: "success", data: stall });
};
}Router memasang middleware validatesebelum controller:
import { Router } from "express";
import { StallController } from "../controllers/stallController.ts";
import { validate } from "../middlewares/validate.ts";
import {
createStallSchema,
idParamSchema,
stallQuerySchema,
updateStallSchema,
} from "../schemas/stallSchema.ts";
const stallRouter = Router();
const stallController = new StallController();
// Setiap route divalidasi dulu: body -> body, query -> query, id -> params.
stallRouter.get(
"/",
validate(stallQuerySchema, "query"),
stallController.getStalls,
);
stallRouter.post(
"/",
validate(createStallSchema, "body"),
stallController.createStall,
);
stallRouter.get(
"/:id",
validate(idParamSchema, "params"),
stallController.getStallById,
);
stallRouter.put(
"/:id",
validate(idParamSchema, "params"),
validate(updateStallSchema, "body"),
stallController.updateStall,
);
stallRouter.delete(
"/:id",
validate(idParamSchema, "params"),
stallController.deleteStall,
);
stallRouter.get(
"/:id/menus",
validate(idParamSchema, "params"),
stallController.getStallMenus,
);
export { stallRouter };Service melempar NotFoundError, bukanError('STALL_NOT_FOUND') lagi:
import {
StallRepository,
type FindAllParams,
} from "../repositories/stallRepository.ts";
import { MenuItemRepository } from "../repositories/menuItemRepository.ts";
import type { MenuItemDto, StallResponseDto } from "../dtos/stallDto.ts";
import { NotFoundError } from "../errors/NotFoundError.ts";
import type {
CreateStallInput,
UpdateStallInput,
} from "../schemas/stallSchema.ts";
type StallRow = NonNullable<Awaited<ReturnType<StallRepository["findById"]>>>;
export class StallService {
private stallRepository: StallRepository;
private menuItemRepository: MenuItemRepository;
constructor(
stallRepository: StallRepository = new StallRepository(),
menuItemRepository: MenuItemRepository = new MenuItemRepository(),
) {
this.stallRepository = stallRepository;
this.menuItemRepository = menuItemRepository;
}
// Mapping row DB -> DTO API (sekaligus logika bisnis isPopular).
private toDto(row: StallRow): StallResponseDto {
const avgRating = Number(row.avgRating);
return {
id: row.id,
ownerId: row.ownerId,
name: row.name,
category: row.category,
location: row.location,
description: row.description,
avgRating,
reviewCount: row.reviewCount,
isPopular: avgRating >= 4.7,
};
}
async getAllStalls(params: FindAllParams) {
const { rows, total } = await this.stallRepository.findAll(params);
return { data: rows.map((row) => this.toDto(row)), total };
}
async getStallById(id: number): Promise<StallResponseDto> {
const row = await this.stallRepository.findById(id);
if (!row) throw new NotFoundError("Warung tidak ditemukan");
return this.toDto(row);
}
async getStallMenus(id: number): Promise<MenuItemDto[]> {
const row = await this.stallRepository.findById(id);
if (!row) throw new NotFoundError("Warung tidak ditemukan");
return this.menuItemRepository.findByStallId(id);
}
async createStall(input: CreateStallInput): Promise<StallResponseDto> {
const row = await this.stallRepository.create(input);
if (!row) throw new NotFoundError("Warung gagal dibuat");
return this.toDto(row);
}
async updateStall(
id: number,
input: UpdateStallInput,
): Promise<StallResponseDto> {
const row = await this.stallRepository.update(id, input);
if (!row) throw new NotFoundError("Warung tidak ditemukan");
return this.toDto(row);
}
async deleteStall(id: number): Promise<StallResponseDto> {
const row = await this.stallRepository.remove(id);
if (!row) throw new NotFoundError("Warung tidak ditemukan");
return this.toDto(row);
}
}Entry Point
Urutan middleware itu penting: JSON parser, lalu route, lalu 404, dan terakhirerror handler. Error handler yang dipasang sebelum route tidak akan pernah mendeteksi error dari route.
import express, { type Application } from "express";
import { sql } from "drizzle-orm";
import { getDb } from "./db/index.ts";
import { stallRouter } from "./routes/stallRouter.ts";
import { notFoundHandler } from "./middlewares/notFound.ts";
import { errorHandler } from "./middlewares/errorHandler.ts";
const app: Application = express();
const PORT: number = 3000;
app.use(express.json());
// Tanpa try/catch: Express 5 meneruskan handler async yang reject ke errorHandler.
app.get("/health", async (_req, res) => {
const db = await getDb();
await db.execute(sql`SELECT 1 AS ok`);
res
.status(200)
.json({ status: "success", message: "Server dan database terhubung" });
});
app.use("/api/v1/stalls", stallRouter);
// 404 untuk rute yang tidak dikenal, lalu error handling terpusat (paling akhir).
app.use(notFoundHandler);
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Server berjalan di http://localhost:${PORT}`);
});notFoundHandler hanya menangkap rute yang tidak cocok dengan rute mana pun. Karena Router sudah dipasang lebih dulu, request valid dijawab di sana, dan sisanya baru jatuh ke sini.
Uji Validasi & Error
Kasus uji valid akan dibalas 200 atau201:
Contoh respons list (GET /api/v1/stalls?page=1&limit=2):
{
"status": "success",
"meta": { "page": 1, "limit": 2, "total": 9 },
"data": [
{ "id": 1, "ownerId": 2, "name": "Warung Bu Tini", "category": "Kwetiau", "isPopular": false },
{ "id": 2, "ownerId": 2, "name": "Kopi Senja", "category": "Minuman", "isPopular": false }
]
}Contoh respons POST (201). Kolom opsional diisi null oleh database:
{
"status": "success",
"data": {
"id": 12,
"ownerId": 2,
"name": "Warung Baru",
"category": null,
"location": null,
"description": null,
"avgRating": 0,
"reviewCount": 0,
"isPopular": false
}
}Kasus uji invalid semuanya harus dibalas 400dengan daftar errors:
Perhatikan bahwa dua error dilaporkan sekaligus, bukan berhenti di error pertama.
{
"status": "fail",
"message": "Validasi gagal",
"errors": [
{ "field": "ownerId", "message": "ownerId harus lebih dari 0" },
{ "field": "name", "message": "name minimal 3 karakter" }
]
}Contoh respons error lainnya:
400:{ "status": "fail", "message": "JSON pada body tidak valid" }400kunci asing: pesanUnrecognized key: isAdminpadafield: body. Inilah.strict()yang menolak, bukan membuang.404:{ "status": "fail", "message": "Warung tidak ditemukan" }404rute asing:{ "status": "fail", "message": "Route tidak ditemukan: GET /api/v1/xyz" }
Rangkuman kasus uji:
| Method | Endpoint | Perilaku |
|---|---|---|
GET | /health | 200 koneksi DB OK; error DB menjadi 500 lewat error handler |
GET | /api/v1/stalls?... | 200; query invalid menjadi 400 |
GET/PUT/DELETE | /api/v1/stalls/:id | 404 bila tak ada; id non-angka menjadi 400 |
POST | /api/v1/stalls | 201; body invalid menjadi 400 |
GET | /api/v1/stalls/:id/menus | 200; 404 bila warung tak ada |
rute lain | apa pun | 404 lewat notFoundHandler |
Troubleshooting:
| Gejala | Kemungkinan penyebab & solusi |
|---|---|
400 JSON pada body tidak valid padahal JSON tampak benar | Di PowerShell, body JSON butuh tanda kutip single: -d '{"a":1}'. Backslash \" bukan escape di PowerShell, jadi JSON-nya rusak. |
500 Terjadi kesalahan pada server | Error tak terduga; cek log terminal (console.error). Kalau soal DB, cek kembali prasyarat pertemuan-03. |
Pesan validasi default berbahasa Inggris (mis. Expected number, received nan) | Itu pesan bawaan zod. Pastikan kamu memakai zod v3 (lihat langkah install di atas). Di zod v4 opsi invalid_type_error diabaikan dan penggantinya adalah error. Untuk pesan custom di v3: .number({ invalid_type_error: '...' }) atau .min(1, '...'). |
Cannot connect to localhost | Server/instans SQL Server tidak aktif, TCP belum aktif, atau .env salah. DB tidak perlu dibuat ulang, sudah di-setup di pertemuan-03. |
Error ERESOLVE saat install | Versi typescript dkk bentrok; pastikan mengikuti package.json di atas, lalu npm install ulang. |
- zod: dokumentasi schema, safeParse, z.coerce, z.infer, strict.
- Error Handling (Express): error middleware (4 argumen), urutan pendaftaran, async error.
Kontrak API
Kontrak API & Swagger
API itu seperti kontrak. Jika frontend ingin menggunakan endpoint yang kamu buat, ia harus mengetahui endpoint apa saja yang tersedia, parameter apa yang dibutuhkan, dan response apa yang dikembalikan termasuk saat terjadi error.
Masalahnya, jika kontrak ini hanya dikirim lewat chat atau dokumen terpisah, mudah menjadi usang. Kode sudah berubah, dokumennya masih versi lama. Akibatnya frontend mengirim request yang salah, dan membuang-buang waktu untuk debugging. Karena itu, dokumentasi sebaiknya dibuat secara otomatis dan interaktif. Dengan Swagger/OpenAPI, dokumentasi dapat digenerate langsung dari kode, ditampilkan di/docs, dan dapat dicoba langsung dari browser tanpa perlu membuka Postman.
Alur yang dipakai di pertemuan 3:
- tulis komentar
#swagger.*di route, swagger-autogenmembaca komentar itu,- menghasilkan file
swagger-output.json(spek OpenAPI), - file itu disajikan
swagger-ui-expressdi/docs.
Intinya, OpenAPI adalah standarnya dan Swagger alat untuk membuatnya. Dokumentasi bukan hanya untuk orang lain, tetapi juga untuk kamu sendiri tiga bulan kemudian yang sudah lupa fungsi masing-masing endpoint.
Di pertemuan 4 ini pendekatannya berganti. Schema zod yang sudah digunakan untuk validasi langsung dibangkitkan menjadi spek OpenAPI 3.0, sehingga tidak ada file generate dan tidak ada komentar anotasi. Keduanya sah, berikut perbedaannya:
| Aspek | Pertemuan-03 swagger-autogen | Pertemuan-04 zod-to-openapi |
|---|---|---|
| Versi spec | Swagger 2.0 | OpenAPI 3.0 |
| Generator | Scan file route + komentar #swagger.* | Dari schema zod (satu sumber) |
| Schema | #/definitions/... | #/components/schemas/... |
| Body request | parameters[body] / definitions | requestBody + content + $ref |
| Konsistensi dengan validasi | Perlu jaga dua tempat | Otomatis sinkron |
| Dokumen | File swagger-output.json hasil generate | Objek JS di memori |
Kamu tidak perlu memigrasikan project pertemuan 3. Ini hanya cara lain untuk masalah yang sama. swagger-autogen cepat untuk prototipe, sedangkanzod-to-openapi lebih aman karena spek tidak bisa melenceng dari schema validasi.
Hands-on 2
Instalasi Dependensi Dokumentasi
Hands-on 2 dibangun dari project hands-on 1, jadi semua validasi dan error handler sudah utuh. Yang berubah hanya beberapa file:
Dari folder praktikum-pert-4, salin seluruh isi folderhands-on-1 ke folder baru bernamahands-on-2. Seluruh langkah berikutnya dijalankan dari dalam folder baru itu.
praktikum-pert-4/
├─ hands-on-1/ # sudah jadi (hands-on 1)
└─ hands-on-2/
├─ package.json tsconfig.json drizzle.config.ts
├─ .env # SAMA - ikut tersalin dari hands-on 1
└─ src/
├─ index.ts # UBAH - + /docs (swagger-ui-express)
├─ docs/openapi.ts # BARU - registri & generator OpenAPI 3.0
├─ schemas/stallSchema.ts # UBAH - + extendZodWithOpenApi & .openapi()
├─ errors/ # SAMA dengan hands-on 1
├─ middlewares/ # SAMA
├─ db/{schema.ts, index.ts} # SAMA
├─ dtos/stallDto.ts # SAMA
├─ repositories/ # SAMA
├─ services/stallService.ts # SAMA
├─ controllers/ # SAMA
└─ routes/stallRouter.ts # SAMAPasangan stabil untuk zod v3 adalah zod-to-openapiv7.x. Versi 8 ke atas sudah bermigrasi ke zod v4, dan pemasangan zod v4 akan mengubah perilaku validasi yang kita bangun di hands-on 1.
Isi akhir package.json (hasil akhir, tidak perlu diketik manual):
{
"name": "hands-on-2",
"version": "1.0.0",
"private": true,
"description": "Hands-on 2 Pertemuan 04 - dokumentasi API OpenAPI 3.0 yang dibangkitkan dari schema zod (zod-to-openapi)",
"main": "src/index.ts",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts"
},
"type": "commonjs",
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.4",
"dotenv": "^17.4.2",
"drizzle-orm": "^1.0.0-rc.5-5935859",
"express": "^5.2.1",
"mssql": "^11.0.2",
"swagger-ui-express": "^5.0.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/mssql": "^9.1.11",
"@types/node": "^26.5.1",
"@types/swagger-ui-express": "^4.1.8",
"drizzle-kit": "^1.0.0-rc.5-5935859",
"tsx": "^4.23.13",
"typescript": "^7.0.2"
}
}Yang perlu diperhatikan, tidak ada script docs:gen seperti pertemuan 3. Dokumentasi dibangkitkan di memori, jadi tidak ada langkah generate tambahan.
.env terbawa dari hands-on 1; isinya sama persis, tidak perlu dikonfigurasi ulang.
Schema zod + Metadata OpenAPI
Skemanya sama persis dengan hands-on 1, hanya ditambah dua hal:
extendZodWithOpenApi(z)menambahkan method.openapi()ke semua tipe zod. Wajib dipanggil sekali, sebelum schema memakai.openapi(...).- Metadata
.openapi({ example, description })pada tiap field. Inilah yang dibaca generator untuk mengisi contoh dan deskripsi di Swagger UI.
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
// Menambahkan method `.openapi()` ke semua type zod.
// Wajib dipanggil SEBELUM schema memakai `.openapi({ ... })`.
extendZodWithOpenApi(z);
// ------------------------------------------------------------------ params
// id pada /:id selalu coerce dari string query/path menjadi angka.
export const idParamSchema = z.object({
id: z.coerce
.number({ invalid_type_error: "id harus berupa angka" })
.int("id harus bilangan bulat")
.positive("id harus lebih dari 0")
.openapi({ example: 3, description: "ID warung" }),
});
// ------------------------------------------------------------------- query
// Search & category opsional; page/limit punya default (zod.default = bila absent).
export const stallQuerySchema = z.object({
search: z
.string()
.trim()
.max(100)
.optional()
.openapi({ description: "Cari berdasarkan nama warung" }),
category: z
.string()
.trim()
.max(50)
.optional()
.openapi({ description: "Filter kategori warung", example: "Minuman" }),
page: z.coerce
.number({ invalid_type_error: "page harus berupa angka" })
.int("page harus bilangan bulat")
.min(1, "page minimal 1")
.default(1)
.openapi({ description: "Nomor halaman (default 1)", example: 1 }),
limit: z.coerce
.number({ invalid_type_error: "limit harus berupa angka" })
.int("limit harus bilangan bulat")
.min(1, "limit minimal 1")
.max(100, "limit maksimal 100")
.default(10)
.openapi({
description: "Jumlah data per halaman (maks 100)",
example: 10,
}),
});
// --------------------------------------------------------------- field bantu
const optionalNullableText = (max: number) =>
z.string().trim().max(max).nullable().optional();
// -------------------------------------------------------------------- body
export const createStallSchema = z
.object({
ownerId: z
.number({ invalid_type_error: "ownerId harus berupa angka" })
.int("ownerId harus bilangan bulat")
.positive("ownerId harus lebih dari 0")
.openapi({
example: 2,
description: "ID owner (USERS.id dengan role owner)",
}),
name: z
.string({ required_error: "name wajib diisi" })
.trim()
.min(3, "name minimal 3 karakter")
.max(100, "name maksimal 100 karakter")
.openapi({ example: "Warung Baru", description: "Nama warung" }),
category: optionalNullableText(50).openapi({
example: "Nasi",
description: "Kategori warung (mis. Nasi, Bakso, Minuman)",
}),
location: optionalNullableText(100).openapi({
example: "Kantin FK",
description: "Lokasi warung",
}),
description: optionalNullableText(1000).openapi({
example: "Nasi goreng dadakan",
description: "Deskripsi warung",
}),
})
// .strict() menolak (atau membuang) kunci yang tidak dikenal.
.strict();
// Semua kolom opsional untuk PUT (update sebagian).
export const updateStallSchema = createStallSchema.partial();
// ------------------------------------------------------------------ types
export type IdParam = z.infer<typeof idParamSchema>;
export type StallQuery = z.infer<typeof stallQuerySchema>;
export type CreateStallInput = z.infer<typeof createStallSchema>;
export type UpdateStallInput = z.infer<typeof updateStallSchema>;.openapi() hanyalah metadata tambahan di samping definisi zod. Aturan .min(), .max(),.strict(), dan .partial()bekerja seperti biasa. Artinya dokumentasi dan validasi lahir dari satu definisi yang sama.
Bangun Dokumen OpenAPI
Generator bekerja dalam tiga tahap: daftarkan komponen schema, daftarkan tiap endpoint, lalu bangkitkan dokumen di memori.
Bagian 1. Komponen schema. Perhatikan bahwa schema body yang dipakai validasi(createStallSchema danupdateStallSchema) ikut didaftarkan sebagai komponenStallInput/StallUpdate, supaya bisa dipakai ulang lewat $ref dan tidak terduplikasi di tiap path:
import {
OpenAPIRegistry,
OpenApiGeneratorV3,
} from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
import {
createStallSchema,
idParamSchema,
stallQuerySchema,
updateStallSchema,
} from "../schemas/stallSchema.ts";
const registry = new OpenAPIRegistry();
// ----------------------------------------------------------------- schema
// Komponen schema untuk RESPONS (dibuat khusus di sini). Schema untuk BODY
// (createStallSchema, updateStallSchema) sudah didefinisikan di stallSchema.ts
// dan hanya "didaftarkan" agar dipakai ulang lewat $ref.
const stallSchema = registry.register(
"Stall",
z.object({
id: z.number().openapi({ example: 1 }),
ownerId: z.number().openapi({ example: 2 }),
name: z.string().openapi({ example: "Warung Bu Tini" }),
category: z.string().nullable().openapi({ example: "Kwetiau" }),
location: z.string().nullable().openapi({ example: "Kantin FKIP" }),
description: z
.string()
.nullable()
.openapi({ example: "Kedai kwetiau goreng & kuah" }),
avgRating: z.number().openapi({ example: 4.5 }),
reviewCount: z.number().openapi({ example: 2 }),
isPopular: z.boolean().openapi({ example: false }),
}),
);
const menuSchema = registry.register(
"Menu",
z.object({
id: z.number().openapi({ example: 1 }),
stallId: z.number().openapi({ example: 2 }),
name: z.string().openapi({ example: "Kwetiau Goreng Spesial" }),
price: z.number().openapi({ example: 15000 }),
isAvailable: z.boolean().openapi({ example: true }),
}),
);
const errorSchema = registry.register(
"ErrorResponse",
z.object({
status: z.string().openapi({ example: "fail" }),
message: z.string().openapi({ example: "Validasi gagal" }),
errors: z
.array(z.object({ field: z.string(), message: z.string() }))
.optional()
.openapi({
example: [{ field: "name", message: "name minimal 3 karakter" }],
}),
}),
);
// Schema body yang DIPAKAI VALIDASI di-handler juga didaftarkan sebagai komponen
// (single source of truth: satu schema bertugas untuk validasi + dokumentasi).
// Nilai kembalian `register` dipakai sebagai referensi ($ref) di requestBody.
const stallInput = registry.register("StallInput", createStallSchema);
const stallUpdate = registry.register("StallUpdate", updateStallSchema);
const stallListResponse = registry.register(
"StallListResponse",
z.object({
status: z.literal("success"),
meta: z.object({
page: z.number().openapi({ example: 1 }),
limit: z.number().openapi({ example: 10 }),
total: z.number().openapi({ example: 10 }),
}),
data: z.array(stallSchema),
}),
);
const stallDetailResponse = registry.register(
"StallDetailResponse",
z.object({
status: z.literal("success"),
data: stallSchema,
}),
);
const menuListResponse = registry.register(
"MenuListResponse",
z.object({
status: z.literal("success"),
data: z.array(menuSchema),
}),
);Bagian 2. Daftarkan tiap endpoint. Setiap registerPath menyebut method, summary, request (params/body), dan semua kemungkinan respons(200/201/400/404/409/500), persis yang sudah kita bangun di error handler:
// ------------------------------------------------------------------ routes
registry.registerPath({
method: "get",
path: "/health",
summary: "Cek kesehatan server & database",
responses: {
200: {
description: "Server dan database terhubung",
content: {
"application/json": {
schema: z.object({
status: z.literal("success"),
message: z.string(),
}),
},
},
},
500: {
description: "Kesalahan pada server",
content: { "application/json": { schema: errorSchema } },
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/stalls",
summary: "Daftar warung",
description:
"Daftar warung dengan filter `search`/`category` dan pagination.",
request: { query: stallQuerySchema },
responses: {
200: {
description: "Daftar warung + meta pagination",
content: { "application/json": { schema: stallListResponse } },
},
400: {
description: "Query parameter tidak valid",
content: { "application/json": { schema: errorSchema } },
},
500: {
description: "Kesalahan pada server",
content: { "application/json": { schema: errorSchema } },
},
},
});
registry.registerPath({
method: "post",
path: "/api/v1/stalls",
summary: "Tambah warung",
request: {
body: {
description: "Data warung baru",
content: { "application/json": { schema: stallInput } },
},
},
responses: {
201: {
description: "Warung berhasil dibuat",
content: { "application/json": { schema: stallDetailResponse } },
},
400: {
description: "Body tidak valid",
content: { "application/json": { schema: errorSchema } },
},
409: {
description: "Data bentrok dengan data yang sudah ada",
content: { "application/json": { schema: errorSchema } },
},
500: {
description: "Kesalahan pada server",
content: { "application/json": { schema: errorSchema } },
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/stalls/{id}",
summary: "Detail warung",
request: { params: idParamSchema },
responses: {
200: {
description: "Detail warung",
content: { "application/json": { schema: stallDetailResponse } },
},
400: {
description: "Parameter id tidak valid",
content: { "application/json": { schema: errorSchema } },
},
404: {
description: "Warung tidak ditemukan",
content: { "application/json": { schema: errorSchema } },
},
500: {
description: "Kesalahan pada server",
content: { "application/json": { schema: errorSchema } },
},
},
});
registry.registerPath({
method: "put",
path: "/api/v1/stalls/{id}",
summary: "Update warung",
request: {
params: idParamSchema,
body: {
description: "Data warung yang diubah (boleh sebagian)",
content: { "application/json": { schema: stallUpdate } },
},
},
responses: {
200: {
description: "Warung ter-update",
content: { "application/json": { schema: stallDetailResponse } },
},
400: {
description: "Body/parameter tidak valid",
content: { "application/json": { schema: errorSchema } },
},
404: {
description: "Warung tidak ditemukan",
content: { "application/json": { schema: errorSchema } },
},
409: {
description: "Data bentrok dengan data yang sudah ada",
content: { "application/json": { schema: errorSchema } },
},
500: {
description: "Kesalahan pada server",
content: { "application/json": { schema: errorSchema } },
},
},
});
registry.registerPath({
method: "delete",
path: "/api/v1/stalls/{id}",
summary: "Hapus warung",
request: { params: idParamSchema },
responses: {
200: {
description: "Warung terhapus",
content: { "application/json": { schema: stallDetailResponse } },
},
404: {
description: "Warung tidak ditemukan",
content: { "application/json": { schema: errorSchema } },
},
500: {
description: "Kesalahan pada server",
content: { "application/json": { schema: errorSchema } },
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/stalls/{id}/menus",
summary: "Daftar menu sebuah warung",
request: { params: idParamSchema },
responses: {
200: {
description: "Daftar menu milik warung",
content: { "application/json": { schema: menuListResponse } },
},
404: {
description: "Warung tidak ditemukan",
content: { "application/json": { schema: errorSchema } },
},
500: {
description: "Kesalahan pada server",
content: { "application/json": { schema: errorSchema } },
},
},
});Endpoint lain mengikuti pola yang sama persis, cukup ganti method,path, dan daftar responses-nya:
GET /api/v1/stallsmemakairequest: { query: stallQuerySchema }, respons200(list + meta) dan400.GET /healthrespons200.PUT /api/v1/stalls/{id}memakaiparams+ bodystallUpdate, respons200/400/404.DELETE /api/v1/stalls/{id}memakaiparams, respons200/404.GET /api/v1/stalls/{id}/menusmemakaiparams, respons200(list menu) dan404.
Bagian 3. Bangkitkan dokumen di memori, tanpa file dan tanpa langkahdocs:gen.
// ----------------------------------------------------------------- generate
// Dokumen OpenAPI 3.0 dihasilkan IN-MEMORY (tanpa file), lalu disajikan
// swagger-ui-express di /docs — selalu sinkron dengan schema terbaru.
const generator = new OpenApiGeneratorV3(registry.definitions);
export const openApiDocument = generator.generateDocument({
openapi: "3.0.0",
info: {
title: "Review Kantin API",
version: "1.0.0",
description:
"Dokumentasi OpenAPI 3.0 yang dibangkitkan otomatis dari schema zod " +
"(@asteasolutions/zod-to-openapi) — satu sumber kebenaran untuk validasi dan dokumentasi.",
},
servers: [{ url: "http://localhost:3000" }],
});registry.register(...) mengembalikan referensi yang diregistri, sehingga generator menerjemahkannya menjadi{ "$ref": "#/components/schemas/StallInput" }. Kalau memakai schema mentah, objek schema akan di-inline penuh di dalamrequestBody sehingga spek membengkak dan repetitif.
Contoh hasil generate, fragmen spek untuk POST /api/v1/stalls:
{
"openapi": "3.0.0",
"info": { "title": "Review Kantin API", "version": "1.0.0" },
"paths": {
"/api/v1/stalls": {
"post": {
"summary": "Tambah warung",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/StallInput" }
}
}
},
"responses": {
"201": { "description": "Warung berhasil dibuat" },
"400": { "description": "Body tidak valid" }
}
}
}
},
"components": {
"schemas": {
"StallInput": {
"type": "object",
"properties": {
"ownerId": { "type": "number", "example": 2 },
"name": { "type": "string", "minLength": 3, "example": "Warung Baru" },
"category": { "type": "string", "maxLength": 50, "nullable": true }
},
"required": ["ownerId", "name"],
"additionalProperties": false
}
}
}
}Sajikan di Swagger UI
Kita pasang Swagger UI di /docs dengan dokumen yang sudah kita bangkitkan. openApiDocument sudah berupa objekOpenAPIV3.Document, jadi tinggal diberikan keswaggerUi.setup(...):
import express, { type Application } from "express";
import { sql } from "drizzle-orm";
import swaggerUi from "swagger-ui-express";
import { getDb } from "./db/index.ts";
import { stallRouter } from "./routes/stallRouter.ts";
import { notFoundHandler } from "./middlewares/notFound.ts";
import { errorHandler } from "./middlewares/errorHandler.ts";
import { openApiDocument } from "./docs/openapi.ts";
const app: Application = express();
const PORT: number = 3000;
app.use(express.json());
// Dokumentasi API OpenAPI 3.0 (dibangkitkan dari schema zod di memori).
app.use("/docs", swaggerUi.serve, swaggerUi.setup(openApiDocument));
// Tanpa try/catch: Express 5 meneruskan handler async yang reject ke errorHandler.
app.get("/health", async (_req, res) => {
const db = await getDb();
await db.execute(sql`SELECT 1 AS ok`);
res
.status(200)
.json({ status: "success", message: "Server dan database terhubung" });
});
app.use("/api/v1/stalls", stallRouter);
// 404 untuk rute yang tidak dikenal, lalu error handling terpusat (paling akhir).
app.use(notFoundHandler);
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Server berjalan di http://localhost:${PORT}`);
});Struktur middleware lain (validasi, error handler) tetap persis seperti hands-on 1./docs ditempatkan di atasnotFoundHandler danerrorHandler supaya tidak dianggap rute asing.
Uji Endpoint via /docs
- Buka
http://localhost:3000/docs(atau/docs/) untuk melihat halaman Swagger UI. - Pilih endpoint, klik Try it out, isi parameter/body, lalu Execute. Request benar-benar dikirim ke server, jadi validasi dan error handling tetap berlaku.
- Coba kasus invalid untuk melihat respons
400yang sudah terdokumentasi.
Respons 400 yang tampil di Swagger UI persis seperti di hands-on 1:
{
"status": "fail",
"message": "Validasi gagal",
"errors": [
{ "field": "name", "message": "name wajib diisi" }
]
}Swagger UI memanggil http://localhost:3000 langsung dari browser kamu, jadi pastikan tidak ada proxy atau CORS manager yang memblokir originlocalhost:3000.
Troubleshooting:
| Gejala | Kemungkinan penyebab & solusi |
|---|---|
/docs membalas 301 lalu kosong | Swagger UI me-redirect ke /docs/; buka langsung http://localhost:3000/docs/. |
Error UnsupportedZodType saat generate | Tipe zod yang belum didukung, biasanya karena zod v4. Pastikan pasangan versinya: zod v3 + zod-to-openapi 7.3.4. |
requestBody ter-inline, bukan $ref | Pakai nilai kembalian registry.register('X', schema) di content.schema, bukan variabel schema aslinya. |
| Contoh/deskripsi tidak muncul | Field belum diberi .openapi({ example, description }). Metadata example lebih diutamakan daripada examples. |
| Validasi menolak request dari Swagger UI | Spek hanya dokumentasi; validasi dan error handling tetap bekerja dari middleware validate. Pastikan isi body sesuai contoh. |
- npm: zod-to-openapi: registri, registerPath, dan contoh lengkap.
- OpenAPI Specification 3.0.3: components/schemas, requestBody, responses, $ref.
- npm: swagger-ui-express: penyajian spec OpenAPI/Swagger sebagai UI.
