Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# dependencies
node_modules/

# Bun lockfile
bun.lockb

# data files
data/

# macOS
.DS_Store
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,24 @@ bun run src/server.ts

> Use `bun run --watch src/server.ts` para modo de desenvolvimento com recarga automática.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

> For development, use `npm start`. For a containerized environment with auto-reloading, use `docker compose up --build`.

The project's contribution guidelines require using npm start or docker compose for running the application. The command bun run --watch src/server.ts does not follow these standards. Please update the documentation to reflect the standard commands for development to ensure a consistent workflow.

Kody Rule violation: Use npm start ou docker-start, nunca npm run build

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


### 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
{
Expand All @@ -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`.
272 changes: 181 additions & 91 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { todoStore, type CreateTodoInput } from "./todoStore";
import { todoStore, type CreateTodoInput, initTodoStore } from "./todoStore";

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
Expand Down Expand Up @@ -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 };
}
};

Expand Down Expand Up @@ -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);
};
Comment on lines +334 to +340

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug critical

const shutdown = (signal: string) => {
  console.log(`Received ${signal}, shutting down...`);
  server.stop(true);
};

The current shutdown implementation uses process.exit(0), which forcefully terminates the application. This is risky because it can interrupt ongoing requests, such as writing a new 'todo' to the database file, leading to data corruption or loss.

To ensure data integrity, the server should shut down gracefully. By using server.stop(true), you allow all in-flight requests to complete before the server stops. The process will then exit cleanly on its own once all operations are finished, preventing any data from being left in an inconsistent state.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
Loading