diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd78787 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# dependencies +node_modules/ + +# Bun lockfile +bun.lockb + +# data files +data/ + +# macOS +.DS_Store diff --git a/README.md b/README.md index 6c5987f..4836611 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,24 @@ bun run src/server.ts > Use `bun run --watch src/server.ts` para modo de desenvolvimento com recarga automática. +### Configuração +- Variáveis de ambiente: + - `PORT` (opcional): porta do servidor. Padrão `3000`. + - `DATA_FILE` (opcional): caminho do arquivo de persistência. Padrão `data/todos.json`. + +Os dados agora são persistidos em disco (JSON). Ao iniciar, a API carrega o arquivo, e a cada criação/atualização/remoção grava novamente. As datas são armazenadas em ISO 8601. + ### Rotas disponíveis - `GET /health` — Verifica se a API está respondendo. -- `GET /todos` — Lista todos os itens; use `GET /todos?completed=true|false` para filtrar por status. +- `GET /` — Mensagem simples de boas-vindas. +- `GET /todos` — Lista itens. + - Parâmetros de query: + - `completed=true|false` (opcional) — filtra por status. + - `limit` (opcional, padrão `50`, máx `1000`) — paginação. + - `offset` (opcional, padrão `0`) — paginação. + - `sort` (opcional: `id|createdAt|updatedAt|title`, padrão `id`). + - `order` (opcional: `asc|desc`, padrão `asc`). + - Cabeçalhos de resposta: `X-Total-Count` com o total antes da paginação. - `POST /todos` — Cria um novo todo. Corpo esperado: ```json { @@ -24,7 +39,13 @@ bun run src/server.ts - `GET /todos/:id` — Recupera um item pelo `id`. - `PATCH /todos/:id` — Atualiza campos `title` e/ou `completed`. - `DELETE /todos/:id` — Remove um item. - -> O armazenamento é apenas em memória, ideal para prototipação rápida. + - Use `DELETE /todos?completed=true|false` para remover em massa por status. > As respostas incluem `createdAt` e `updatedAt` em formato ISO 8601. + +### Logs +- Cada requisição gera um log com `X-Request-Id`, método, rota, status e duração (ms). +- Todas as respostas incluem `X-Request-Id`. + +### Limite de Payload +- O corpo JSON é limitado a ~1MB. Requisições maiores retornam `413 Payload Too Large`. diff --git a/src/server.ts b/src/server.ts index d980222..823e64e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import { todoStore, type CreateTodoInput } from "./todoStore"; +import { todoStore, type CreateTodoInput, initTodoStore } from "./todoStore"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -41,17 +41,27 @@ const notFound = () => json({ error: "Not Found" }, { status: 404 }); const noContent = (status = 204) => new Response(null, { status, headers: corsHeaders }); -const safeJsonBody = async (req: Request) => { +const safeJsonBody = async ( + req: Request, + opts: { maxBytes?: number } = {}, +) => { try { - return { - ok: true as const, - value: (await req.json()) as unknown, - }; + const max = opts.maxBytes ?? 1_000_000; // 1MB default + const lenHeader = req.headers.get("content-length"); + if (lenHeader) { + const len = Number.parseInt(lenHeader, 10); + if (Number.isFinite(len) && len > max) { + return { ok: false as const, error: "Payload Too Large", status: 413 }; + } + } + const textBody = await req.text(); + const bytes = new TextEncoder().encode(textBody).length; + if (bytes > max) { + return { ok: false as const, error: "Payload Too Large", status: 413 }; + } + return { ok: true as const, value: JSON.parse(textBody) as unknown }; } catch { - return { - ok: false as const, - error: "Invalid JSON body", - }; + return { ok: false as const, error: "Invalid JSON body", status: 400 }; } }; @@ -134,120 +144,200 @@ const parseUpdateInput = (payload: unknown) => { return { ok: true as const, value: update }; }; +await initTodoStore(); + const server = Bun.serve({ port: Number(process.env.PORT ?? 3000), async fetch(req) { + const start = Date.now(); + const requestId = crypto.randomUUID(); const url = new URL(req.url); - if (req.method === "OPTIONS") { - return new Response(null, { status: 204, headers: corsHeaders }); - } - - if (req.method === "GET" && url.pathname === "/health") { - return text("ok"); - } - - const todosMatch = todosPattern.exec(req.url); - if (todosMatch) { - if (req.method === "GET") { - const completedParam = url.searchParams.get("completed"); - if ( - completedParam !== null && - completedParam !== "true" && - completedParam !== "false" - ) { - return json( - { - error: "Parameter 'completed' must be either 'true' or 'false'", - }, - { status: 400 }, - ); - } + const handle = async () => { + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders }); + } - const filter = - completedParam === null - ? {} - : { completed: completedParam === "true" }; + if (req.method === "GET" && url.pathname === "/") { + return text("Todo API (Bun) — veja /health e /todos"); + } - return json(todoStore.list(filter)); + if (req.method === "GET" && url.pathname === "/health") { + return text("ok"); } - if (req.method === "POST") { - if (!ensureJson(req)) { - return json( - { error: "Content-Type must be application/json" }, - { status: 415 }, + const todosMatch = todosPattern.exec(req.url); + if (todosMatch) { + if (req.method === "GET") { + const completedParam = url.searchParams.get("completed"); + const limitParam = url.searchParams.get("limit"); + const offsetParam = url.searchParams.get("offset"); + const sortBy = url.searchParams.get("sort") ?? undefined; + const order = url.searchParams.get("order") ?? undefined; + if ( + completedParam !== null && + completedParam !== "true" && + completedParam !== "false" + ) { + return json( + { + error: "Parameter 'completed' must be either 'true' or 'false'", + }, + { status: 400 }, + ); + } + + const filter = + completedParam === null + ? {} + : { completed: completedParam === "true" }; + + const maxLimit = 1000; + const limit = Math.min( + maxLimit, + Math.max(0, Number.parseInt(limitParam ?? "50", 10) || 50), ); + const offset = Math.max(0, Number.parseInt(offsetParam ?? "0", 10) || 0); + const validSort = new Set(["id", "createdAt", "updatedAt", "title"]); + const validOrder = new Set(["asc", "desc"]); + const sortOpt = validSort.has(String(sortBy)) ? (sortBy as any) : "id"; + const orderOpt = validOrder.has(String(order)) ? (order as any) : "asc"; + + const { items, total } = todoStore.list(filter, { + limit, + offset, + sortBy: sortOpt, + order: orderOpt, + }); + + return json(items, { headers: { "X-Total-Count": String(total) } }); } - const parsed = await safeJsonBody(req); - if (!parsed.ok) { - return json({ error: parsed.error }, { status: 400 }); + if (req.method === "POST") { + if (!ensureJson(req)) { + return json( + { error: "Content-Type must be application/json" }, + { status: 415 }, + ); + } + + const parsed = await safeJsonBody(req); + if (!parsed.ok) { + return json({ error: parsed.error }, { status: parsed.status ?? 400 }); + } + + const validated = parseCreateInput(parsed.value); + if (!validated.ok) { + return json({ error: validated.error }, { status: 400 }); + } + + const todo = todoStore.create(validated.value); + return json(todo, { status: 201 }); } - const validated = parseCreateInput(parsed.value); - if (!validated.ok) { - return json({ error: validated.error }, { status: 400 }); + if (req.method === "DELETE") { + const completedParam = url.searchParams.get("completed"); + if ( + completedParam !== "true" && + completedParam !== "false" + ) { + return json( + { error: "Parameter 'completed' must be 'true' or 'false'" }, + { status: 400 }, + ); + } + const removed = todoStore.deleteMany({ + completed: completedParam === "true", + }); + return json({ removed }); } - const todo = todoStore.create(validated.value); - return json(todo, { status: 201 }); + return methodNotAllowed(); } - return methodNotAllowed(); - } - - const todoMatch = todoIdPattern.exec(req.url); - if (todoMatch) { - const idParam = todoMatch.pathname.groups.id; - const id = Number.parseInt(idParam ?? "", 10); - if (!Number.isFinite(id)) { - return json({ error: "Invalid id" }, { status: 400 }); - } - - const existing = todoStore.get(id); - if (!existing) { - return notFound(); - } + const todoMatch = todoIdPattern.exec(req.url); + if (todoMatch) { + const idParam = todoMatch.pathname.groups.id; + const id = Number.parseInt(idParam ?? "", 10); + if (!Number.isFinite(id)) { + return json({ error: "Invalid id" }, { status: 400 }); + } - if (req.method === "GET") { - return json(existing); - } + const existing = todoStore.get(id); + if (!existing) { + return notFound(); + } - if (req.method === "PATCH") { - if (!ensureJson(req)) { - return json( - { error: "Content-Type must be application/json" }, - { status: 415 }, - ); + if (req.method === "GET") { + return json(existing); } - const parsed = await safeJsonBody(req); - if (!parsed.ok) { - return json({ error: parsed.error }, { status: 400 }); + if (req.method === "PATCH") { + if (!ensureJson(req)) { + return json( + { error: "Content-Type must be application/json" }, + { status: 415 }, + ); + } + + const parsed = await safeJsonBody(req); + if (!parsed.ok) { + return json({ error: parsed.error }, { status: parsed.status ?? 400 }); + } + + const validated = parseUpdateInput(parsed.value); + if (!validated.ok) { + return json({ error: validated.error }, { status: 400 }); + } + + const updated = todoStore.update(id, validated.value); + return json(updated); } - const validated = parseUpdateInput(parsed.value); - if (!validated.ok) { - return json({ error: validated.error }, { status: 400 }); + if (req.method === "DELETE") { + todoStore.delete(id); + return noContent(); } - const updated = todoStore.update(id, validated.value); - return json(updated); + return methodNotAllowed(); } - if (req.method === "DELETE") { - todoStore.delete(id); - return noContent(); - } + return notFound(); + }; - return methodNotAllowed(); + try { + const res = await handle(); + const duration = Date.now() - start; + const headers = new Headers(res.headers); + headers.set("X-Request-Id", requestId); + console.log( + `${requestId} ${req.method} ${url.pathname} -> ${res.status} ${duration}ms`, + ); + return new Response(res.body, { status: res.status, headers }); + } catch (err) { + console.error("Erro inesperado:", err); + const duration = Date.now() - start; + console.log(`${requestId} ${req.method} ${url.pathname} -> 500 ${duration}ms`); + return json( + { error: "Internal Server Error" }, + { status: 500, headers: { "X-Request-Id": requestId } }, + ); } - - return notFound(); }, }); console.log( `Todo API ouvindo em http://localhost:${server.port} (pid ${process.pid})`, ); + +// Encerramento gracioso +const shutdown = (signal: string) => { + console.log(`Recebido ${signal}, finalizando...`); + try { + server.stop(); + } catch {} + process.exit(0); +}; + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/src/todoStore.ts b/src/todoStore.ts index ef00b62..4fbc831 100644 --- a/src/todoStore.ts +++ b/src/todoStore.ts @@ -19,14 +19,97 @@ export type UpdateTodoInput = { class TodoStore { #todos = new Map(); #nextId = 1; + #dataFile?: string; - list(filter?: { completed?: boolean }) { + constructor(dataFile?: string) { + this.#dataFile = dataFile; + } + + async init() { + if (!this.#dataFile) return; + try { + const file = Bun.file(this.#dataFile); + if (await file.exists()) { + const raw = await file.text(); + if (raw.trim().length) { + const data = JSON.parse(raw) as Array<{ + id: number; + title: string; + completed: boolean; + createdAt: string; + updatedAt: string; + }>; + for (const t of data) { + this.#todos.set(t.id, { + id: t.id, + title: t.title, + completed: t.completed, + createdAt: new Date(t.createdAt), + updatedAt: new Date(t.updatedAt), + }); + } + // compute nextId + const maxId = Math.max(0, ...Array.from(this.#todos.keys())); + this.#nextId = maxId + 1; + } + } else { + // ensure directory exists + await this.#ensureDir(); + await Bun.write(this.#dataFile, "[]\n"); + } + } catch (err) { + console.error("Falha ao carregar os dados:", err); + } + } + + list( + filter?: { completed?: boolean }, + options?: { + sortBy?: "id" | "createdAt" | "updatedAt" | "title"; + order?: "asc" | "desc"; + offset?: number; + limit?: number; + }, + ) { let items = Array.from(this.#todos.values()); if (filter?.completed !== undefined) { items = items.filter((todo) => todo.completed === filter.completed); } - return items; + const total = items.length; + + const sortBy = options?.sortBy ?? "id"; + const order = options?.order ?? "asc"; + items.sort((a, b) => { + let av: number | string | Date; + let bv: number | string | Date; + switch (sortBy) { + case "createdAt": + av = a.createdAt; + bv = b.createdAt; + break; + case "updatedAt": + av = a.updatedAt; + bv = b.updatedAt; + break; + case "title": + av = a.title.toLowerCase(); + bv = b.title.toLowerCase(); + break; + case "id": + default: + av = a.id; + bv = b.id; + } + if (av < (bv as any)) return order === "asc" ? -1 : 1; + if (av > (bv as any)) return order === "asc" ? 1 : -1; + return 0; + }); + + const offset = Math.max(0, options?.offset ?? 0); + const limit = Math.max(0, options?.limit ?? items.length); + const paged = items.slice(offset, offset + limit); + return { items: paged, total }; } get(id: number) { @@ -44,6 +127,7 @@ class TodoStore { }; this.#todos.set(todo.id, todo); + this.#save().catch((e) => console.error("Erro ao salvar dados:", e)); return todo; } @@ -58,12 +142,55 @@ class TodoStore { }; this.#todos.set(id, updated); + this.#save().catch((e) => console.error("Erro ao salvar dados:", e)); return updated; } delete(id: number) { - return this.#todos.delete(id); + const ok = this.#todos.delete(id); + if (ok) this.#save().catch((e) => console.error("Erro ao salvar dados:", e)); + return ok; + } + + deleteMany(filter?: { completed?: boolean }) { + const before = this.#todos.size; + if (filter?.completed === undefined) { + // no-op: require a filter to avoid accidental wipe + return 0; + } + for (const [id, todo] of this.#todos) { + if (todo.completed === filter.completed) this.#todos.delete(id); + } + const removed = before - this.#todos.size; + if (removed > 0) + this.#save().catch((e) => console.error("Erro ao salvar dados:", e)); + return removed; + } + + async #ensureDir() { + if (!this.#dataFile) return; + const path = await import("node:path"); + const fs = await import("node:fs/promises"); + const dir = path.dirname(this.#dataFile); + try { + await fs.mkdir(dir, { recursive: true }); + } catch {} + } + + async #save() { + if (!this.#dataFile) return; + const data = Array.from(this.#todos.values()).map((t) => ({ + id: t.id, + title: t.title, + completed: t.completed, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + })); + await this.#ensureDir(); + await Bun.write(this.#dataFile, JSON.stringify(data, null, 2) + "\n"); } } -export const todoStore = new TodoStore(); +const dataFile = process.env.DATA_FILE ?? "data/todos.json"; +export const todoStore = new TodoStore(dataFile); +export const initTodoStore = () => todoStore.init();