server.jsconst express = require("express");
const fs = require("fs");
const path = require("path");
const app = express();
const PORT = 3000;
// JSON 본문(body) 파싱
app.use(express.json());
// (로컬 실습용) 간단 인증 키
const API_KEY = "dev-secret";
// todos.json 파일 경로 (server.js와 같은 폴더)
const DATA_PATH = path.join(__dirname, "todos.json");
function loadTodosFromFile() {
if (!fs.existsSync(DATA_PATH)) return [];
const raw = fs.readFileSync(DATA_PATH, "utf-8").trim();
if (raw.length === 0) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error("todos.json must be an array like []");
}
return parsed;
}
function saveTodosToFile(todos) {
fs.writeFileSync(DATA_PATH, JSON.stringify(todos, null, 2), "utf-8");
}
function fail(res, status, error, extra) {
const payload = { ok: false, error };
if (extra && typeof extra === "object") {
Object.assign(payload, extra);
}
return res.status(status).json(payload);
}
function requireApiKey(req, res, next) {
const key = req.header("x-api-key");
if (key !== API_KEY) {
return fail(res, 401, "unauthorized", {
hint: 'Set header "x-api-key" to the correct value',
});
}
return next();
}
// 시작할 때 파일에서 읽어서 메모리로 올림
let todos = loadTodosFromFile();
// 저장된 TODO 중 가장 큰 id를 찾아서, 그 다음 번호부터 시작합니다.
let nextId = 1;
for (const t of todos) {
if (typeof t.id === "number" && t.id >= nextId) {
nextId = t.id + 1;
}
}
app.get("/health", (req, res) => {
res.json({ ok: true });
});
app.get("/todos", (req, res) => {
res.json({ items: todos });
});
// POST /todos - 하나 추가 (인증 필요)
app.post("/todos", requireApiKey, (req, res) => {
const title = req.body?.title;
// 검증 1) 타입/빈 값
if (typeof title !== "string" || title.trim().length === 0) {
return fail(res, 400, "title is required", {
example: { title: "물 마시기" },
});
}
// 검증 2) 길이 제한
if (title.trim().length > 100) {
return fail(res, 400, "title is too long", { max: 100 });
}
const todo = {
id: nextId++,
title: title.trim(),
done: false,
createdAt: new Date().toISOString(),
};
todos.push(todo);
saveTodosToFile(todos);
res.status(201).json({ ok: true, item: todo });
});
// 새로 추가된 부분: PATCH /todos/:id - done 수정
app.patch("/todos/:id", requireApiKey, (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return fail(res, 400, "invalid_id", { example: "/todos/1" });
}
const todo = todos.find((t) => t.id === id);
if (!todo) {
return fail(res, 404, "todo_not_found", { id });
}
const done = req.body?.done;
if (done === undefined) {
todo.done = !todo.done;
} else if (typeof done === "boolean") {
todo.done = done;
} else {
return fail(res, 400, "done must be boolean", { example: { done: true } });
}
saveTodosToFile(todos);
return res.json({ ok: true, item: todo });
});
// 새로 추가된 부분: DELETE /todos/:id - 삭제
app.delete("/todos/:id", requireApiKey, (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return fail(res, 400, "invalid_id", { example: "/todos/1" });
}
const index = todos.findIndex((t) => t.id === id);
if (index === -1) {
return fail(res, 404, "todo_not_found", { id });
}
const deleted = todos.splice(index, 1)[0];
saveTodosToFile(todos);
return res.json({ ok: true, item: deleted });
});
// (연습용) 강제로 에러를 내는 엔드포인트
app.get("/debug/error", (req, res) => {
throw new Error("debug error");
});
// 404: 없는 주소는 여기로 떨어짐
app.use((req, res) => {
return fail(res, 404, "not_found", { path: req.path });
});
// 500: 서버에서 예외가 터졌을 때
app.use((err, req, res, next) => {
console.error(err);
// JSON 파싱이 깨진 경우(잘못된 JSON)
if (err instanceof SyntaxError) {
return fail(res, 400, "invalid_json");
}
return fail(res, 500, "internal_error");
});
app.listen(PORT, () => {
console.log(`Server running: http://localhost:${PORT}`);
});