Criando uma API estilo Bit.ly com Node.js puro

Sem Express, sem dependências — só código nativo pra entender como tudo funciona

sábado, 5 de abril de 2025

A motivação desse projeto foi pra entender como funciona certos conceitos de um Express da vida. Fazer uma API REST com Express é bem simples, já que ele te entrega uma estrutura pronta.

Pra fazer essa API eu precisava de uma ideia de projeto legal, pra não ser só mais um TodoList API. E é tentador fazer esse tipo de projeto dada a complexidade, pra ser sincero.

Então lembrei daquele Bit.ly (encurtador de links), e o veredito foi esse mesmo.

Antes de começar

Não iremos precisar de um package.json porque não teremos dependências — somente módulos nativos do Node. E por conta disso, não teremos TypeScript também.

Nota: Esse projeto não deve ser colocado em produção. Isso é um "projeto de laboratório" e deve ser considerado como tal.

Criando um servidor Node.js

Pra lidar com o protocolo HTTP podemos utilizar o módulo nativo node:http, pois ele possui uma função chamada createServer que iremos usar pra criar o servidor.

Essa função recebe um parâmetro chamado requestListener — uma callback que fica ouvindo requisições:

createServer(() => {});

Essa callback possui dois parâmetros, request e response, e é com eles que podemos manipular o que está vindo do mundo externo: corpo de requisição, respostas, headers...

createServer((request, response) => {});

Essa função retorna alguns métodos, mas o mais importante pra gente agora é o .listen, que faz nosso servidor ficar aberto em uma porta de rede específica como 3000, 8080, 3001.

No primeiro parâmetro temos a porta e no segundo uma callback que normalmente é usada para criar logs informando que o servidor está rodando corretamente:

.listen(3000, () => {
  console.log("Server started at http://localhost:3000")
});

Dito isso, podemos criar um servidor assim:

import { createServer } from "node:http";

const app = createServer((request, response) => {
  response.writeHead(200, { "content-type": "application/json" });
  response.end(JSON.stringify({ ok: true }));
});

app.listen(3000, () => {
  console.log("Server started at http://localhost:3000");
});
  • O método .writeHead serve pra escrever os cabeçalhos HTTP e o status code da requisição.
  • O método .end serve pra finalizar a requisição enviando como resposta uma string no formato JSON.

Pra iniciar o servidor basta abrir um terminal e rodar:

node index.js

Porém se você acessar qualquer endpoint, o Node irá sempre retornar {"ok":true} como resposta.

Como criar endpoints no Node.js?

Essa distinção de rotas por métodos e endpoints vem do padrão REST.

O Node não tem internamente esse mecanismo de manipulação de rotas. Isso significa que se quisermos ter rotas que façam coisas diferentes de acordo com o método HTTP e endpoint, teremos que implementar do zero!

Vamos modificar um pouco nosso index.js:

const links = [
  {
    id: "9d66dd18-0125-4677-8fd1-3a541da16e1d",
    original_url: "https://wesleydmscn.com/",
    short_code: "wesleydmscn",
    created_at: "2025-04-05T13:37:01.869Z",
  },
];

const app = createServer((request, response) => {
  if (request.url === "/links" && request.method === "GET") {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify(links));
  } else {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ ok: true }));
  }
});

app.listen(3000, () => {
  console.log("Server started at http://localhost:3000");
});

Acima criamos uma lista em memória pra guardar os links e adicionamos um endpoint usando condicionais em cima das propriedades url e method do objeto request.

Agora se acessarmos http://localhost:3000/links o servidor irá responder com:

[
  {
    "id": "9d66dd18-0125-4677-8fd1-3a541da16e1d",
    "original_url": "https://wesleydmscn.com/",
    "short_code": "wesleydmscn",
    "created_at": "2025-04-05T13:37:01.869Z"
  }
]

Legal né? Mas não dá pra ficar criando endpoint desse jeito — muitos ifs, legibilidade vai pro ralo...

Então vamos refatorar esse código pra simplificar a criação de novos endpoints.

Organizando nosso código

  • Vamos mover nosso index.js pra uma pasta src/
  • Vamos mover nossa lista links pra src/mocks/links.js
  • Criar um arquivo src/route.js pra centralizar as rotas
// src/mocks/links.js
export default [
  {
    id: "9d66dd18-0125-4677-8fd1-3a541da16e1d",
    original_url: "https://wesleydmscn.com/",
    short_code: "wesleydmscn",
    created_at: "2025-04-05T13:37:01.869Z",
  },
];
// src/route.js
export default [
  {
    endpoint: "/links",
    method: "GET",
    handler: (request, response) => {},
  },
];

Nota: O handler é a função que vai fornecer o recurso — ela será responsável por devolver os dados.

Como nossa "entidade" é um link, vamos criar um controller pra centralizar todas as funções handler e importar nossa lista links pra usar como banco de dados em memória:

// src/controllers/link.controller.js
import rawLinks from "../mocks/links.js";

let links = rawLinks;

export default {
  listLinks(request, response) {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify(links));
  },
}

Utilizar nosso controller no endpoint de buscar todos os links:

// src/route.js
import linkController from "./controllers/link.controller.js";

export default [
  {
    endpoint: "/links",
    method: "GET",
    handler: linkController.listLinks,
  },
]

Com isso sua estrutura de pastas deve ficar assim:

└── src/
  ├── controllers/
  │   └── link.controller.js
  ├── mocks/
  │   └── links.js
  ├── index.js
  └── route.js

Refatorando o src/index.js

O código já tá bem legal, mas falta fazer funcionar haha. Vamos mudar a lógica de condicionais para uma mais simples:

// src/index.js
import routes from "./route.js";

const app = createServer((request, response) => {
  const route = routes.find(
    ({ endpoint, method }) => endpoint === request.url && method === request.method
  );

  if (route) {
    return route.handler(request, response);
  }

  response.writeHead(404, { "content-type": "application/json" });
  response.end(
    JSON.stringify({ error: `Cannot ${request.method} ${request.url}` })
  );
});

Agora nosso servidor só irá responder se existir a rota. Caso não exista, ele irá responder com 404 e uma mensagem de erro em JSON: "Cannot GET /outro-endpoint".

É muito comum uma API ter um logger. Vamos adicionar um simples pra toda vez que alguém mandar uma requisição termos esse histórico:

const app = createServer((request, response) => {
  console.log(`Request method: ${request.method} | Endpoint: ${request.url}`);
  // resto do código...

O nosso terminal ficará assim:

Server started at http://localhost:3000
Request method: GET | Endpoint: /links
Request method: GET | Endpoint: /outro-endpoint
Request method: GET | Endpoint: /outro-endpoint-2

Criando os outros endpoints

Antes de criar o resto dos endpoints, já percebeu que pra cada um a gente está precisando repetir isso?

response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ error: `Cannot ${request.method} ${request.url}` }));

Pois é — a gente pode melhorar essa experiência criando uma função helper que facilite isso. Vamos criar src/helpers/extend-response.js com uma função responsável por retornar o objeto response com os seguintes métodos utilitários:

  • .status(statusCode) — recebe um status code como parâmetro.
  • .json(body) — recebe um body como parâmetro.
// src/helpers/extend-response.js
export function extendResponse(response) {
  response.status = function (statusCode) {
    this.statusCode = statusCode;
    return this;
  };

  response.json = function (body) {
    response.setHeader("content-type", "application/json");
    response.end(JSON.stringify(body));
    return this;
  };

  return { response };
}

E pra usar essa função:

const app = createServer((request, res) => {
  const { response } = extendResponse(res);

  console.log(`Request method: ${request.method} | Endpoint: ${request.url}`);

  const route = routes.find(
    ({ endpoint, method }) => endpoint === request.url && method === request.method
  );

  if (route) {
    return route.handler(request, response);
  }

  response.status(404).json({ error: `Cannot ${request.method} ${request.url}` });
});

Importante: É necessário renomear o parâmetro do createServer para res para não ter conflito de variáveis.

Dica: O this como retorno dos métodos utilitários serve para que seja possível encadear os métodos, ex: .status().json(). Isso lembra bastante o Express :)

Ficou muito melhor né? Agora vamos atualizar nosso handler:

// src/controllers/link.controller.js
// ...
export default {
  listLinks(request, response) {
    response.status(200).json(links);
  },
}

Pra fazer esse endpoint a gente só precisa criar o controller e adicionar mais uma rota:

// src/controllers/link.controller.js
// ...
export default {
  listLinks(_, response) {...},
  getLinkByShortCode(request, response) {
    const { short_code } = request.params;

    const link = links.find((link) => link.short_code === short_code);

    if (!link) {
      return response.status(400).json({ error: "Link not found" });
    }

    response.status(200).json(link);
  },
}
// src/route.js
// ...
export default [
  {...},
  {
    endpoint: "/links/:short_code",
    method: "GET",
    handler: linkController.getLinkByShortCode,
  },
]

Seria muito lindo se fosse só isso né? Mas tem um garfo aí.

O que é :short_code? Pra quem veio do Express como eu, isso funciona como mágica. Mas a triste notícia é que parâmetros de URL dinâmicos não existem por padrão no Node.

E novamente isso não tem nada a ver com o Node em si — é algo que precisa ser implementado. E o que fazemos quando isso acontece? Vamos implementar!

Criando uma lógica para parâmetros de URL dinâmicos

Primeiro a observar é que request.params não existe. Pra adicioná-lo, vamos fazer o seguinte:

  1. Formatar a URL para capturar o short_code
  2. Como :short_code é um placeholder, vamos identificá-lo na URL
  3. Se encontrado, adicionar um objeto params com esse short_code
import { extendResponse } from "./helpers/extend-response.js";

// ...

const BASE_URL = "http://localhost:3000";

const app = createServer((request, res) => {
  const parsedUrl = new URL(BASE_URL + request.url);
  const { response } = extendResponse(res);

  let { pathname } = parsedUrl;

  console.log(`Request method: ${request.method} | Endpoint: ${pathname}`);

  let short_code = null;

  const splitEndpoint = pathname.split("/").filter(Boolean);

  if (splitEndpoint.length > 1) {
    pathname = `/${splitEndpoint[0]}/:short_code`;
    short_code = splitEndpoint[1];
  }

  const route = routes.find(
    ({ endpoint, method }) => endpoint === pathname && method === request.method
  );

  if (route) {
    request.params = { short_code };
    return route.handler(request, response);
  }

  response.status(404).json({ error: `Cannot ${request.method} ${pathname}` });
});

O processo funciona assim:

  1. Dividindo a URL: A URL é dividida em partes com split("/"), transformando o caminho em um array de segmentos. Ex: /links/abc123["links", "abc123"].
  2. Identificando o parâmetro dinâmico: Se o array tiver mais de um segmento, o código assume que o segundo é um parâmetro dinâmico. Ele ajusta o pathname para um formato genérico como /links/:short_code e armazena o valor real (abc123) em short_code.
  3. Buscando a rota correspondente: O código procura na lista de rotas uma que corresponda ao pathname ajustado e ao método HTTP.
  4. Passando os parâmetros para o handler: O valor do parâmetro dinâmico é armazenado em request.params, facilitando o acesso dentro do handler.

Muito loco né? E detalhe: isso funciona para qualquer novo endpoint que tiver um nível de parâmetro de URL dinâmico.

Até agora temos apenas dois endpoints: listar todos os links e buscar um pelo short_code. Agora vamos adicionar o endpoint para criar um novo link.

// src/controllers/link.controller.js
import { randomUUID } from "node:crypto";

// ...

export default {
  listLinks(request, response) {...},
  getLinkByShortCode(request, response) {...},
  createLink(request, response) {
    const { body } = request;

    const linkExists = links.find(
      (link) => link.short_code === body.short_code
    );

    if (linkExists) {
      return response.status(400).json({ error: "Short code already exists" });
    }

    const newLink = {
      id: randomUUID(),
      original_url: body.original_url,
      short_code: body.short_code,
      created_at: new Date().toISOString(),
    };

    links.push(newLink);

    response.status(201).json(newLink);
  },
}
// src/route.js
// ...
export default [
  {...},
  {...},
  {
    endpoint: "/links",
    method: "POST",
    handler: linkController.createLink,
  },
]

E assim como o request.params, o request.body também não existe nativamente. 😂

Como funciona um body de uma requisição?

Fluxo de stream de dados do corpo de uma requisição HTTP

Quando trabalhamos com APIs, o body de uma requisição HTTP é onde os dados enviados pelo cliente são armazenados. Esses dados geralmente acompanham métodos como POST, PUT ou PATCH. Mas por que eles são tratados como streams?

O body de uma requisição HTTP pode estar em diferentes formatos:

  • JSON: {"name":"John","age":30}
  • Formulários: name=John&age=30
  • Texto puro: Hello, world!
  • Arquivos binários: imagens, vídeos, etc.

No Node, o body de uma requisição HTTP não chega como um único bloco de dados. Em vez disso, ele é enviado como uma stream — os dados chegam ao servidor em pedaços (chunks) ao longo do tempo.

Com isso em mente, precisamos criar um bodyParser simples:

// src/helpers/body-parser.js
export function bodyParser(request, callback) {
  let body = "";

  if (!["POST", "PUT", "PATCH"].includes(request.method)) {
    return callback();
  }

  request.on("data", (chunk) => {
    body += chunk;
  });

  request.on("end", () => {
    body = JSON.parse(body);
    request.body = body;
    callback();
  });
}

O que ele está fazendo:

  1. Inicialização: Uma variável body é criada para armazenar os dados recebidos.
  2. Recebendo dados: O evento data é acionado sempre que um chunk chega. Esses pedaços são concatenados em body.
  3. Finalizando a transmissão: Quando todos os dados foram recebidos, o evento end é acionado e o body completo é convertido de JSON para um objeto JavaScript.
  4. Callback: Após o processamento, o callback é chamado para continuar o fluxo da aplicação.

Por exemplo, ao fazer um POST com o body:

{
  "name": "John Doe",
  "age": 22
}

O servidor não recebe isso como um único bloco. Ele pode receber assim:

Primeiro chunk:  { "name": "John
Segundo chunk:   Doe", "age":
Terceiro chunk:  22 }

O bodyParser junta esses pedaços, monta o body completo e o transforma em um objeto JavaScript.

Usando o bodyParser no index.js

// src/index.js
import { bodyParser } from "./helpers/body-parser.js";
// ...
const app = createServer((request, res) => {
  const parsedUrl = new URL(BASE_URL + request.url);
  const { response } = extendResponse(res);

  let { pathname } = parsedUrl;

  console.log(`Request method: ${request.method} | Endpoint: ${pathname}`);

  let short_code = null;

  const splitEndpoint = pathname.split("/").filter(Boolean);

  if (splitEndpoint.length > 1) {
    pathname = `/${splitEndpoint[0]}/:short_code`;
    short_code = splitEndpoint[1];
  }

  const route = routes.find(
    ({ endpoint, method }) => endpoint === pathname && method === request.method
  );

  if (route) {
    request.params = { short_code };
    return bodyParser(request, () => route.handler(request, response));
  }

  response.status(404).json({ error: `Cannot ${request.method} ${pathname}` });
});

E pronto, agora nosso endpoint de criação de link funciona! O retorno dele é:

{
  "id": "78d09eaa-722c-4620-8e20-0fb76e8b3b25",
  "original_url": "https://linkedin.com/in/wesleydmscn",
  "short_code": "linkedin-wesley",
  "created_at": "2025-04-05T20:30:30.650Z"
}
// src/controllers/link.controller.js
// ...
export default {
  listLinks(request, response) {...},
  getLinkByShortCode(request, response) {...},
  createLink(request, response) {...},
  deleteLink(request, response) {
    const { short_code } = request.params;

    const link = links.find((link) => link.short_code === short_code);

    if (!link) {
      return response.status(400).json({ error: "Link not found" });
    }

    links = links.filter((link) => link.short_code !== short_code);

    response.status(204).end();
  },
}
// src/route.js
// ...
export default [
  {...},
  {...},
  {...},
  {
    endpoint: "/links/:short_code",
    method: "DELETE",
    handler: linkController.deleteLink,
  },
]

E pronto, se fizermos uma requisição DELETE para http://localhost:3000/links/linkedin-wesley, esse link será excluído do banco em memória.

E por fim, vamos implementar o endpoint principal da nossa aplicação e, pra felicidade de vocês...

Não temos um response.redirect() haha.

Mas não esquenta, é mais simples do que as coisas que já vimos hoje.

Como funciona o redirecionamento HTTP?

Quando um servidor realiza um redirecionamento, ele instrui o cliente a acessar uma URL diferente. Para isso, dois elementos são essenciais:

1. Status code entre 300 e 399

Os códigos na faixa de 300–399 indicam ao cliente que o recurso pode ser encontrado em outro lugar:

  • 301 (Moved Permanently): O recurso foi movido permanentemente.
  • 302 (Found): O recurso está temporariamente em outra URL.
  • 307 (Temporary Redirect): Similar ao 302, mas preserva o método HTTP.
  • 308 (Permanent Redirect): Similar ao 301, mas também preserva o método HTTP.

2. Cabeçalho Location

O cabeçalho Location é obrigatório em respostas de redirecionamento. Ele informa ao cliente para qual URL ele deve ir. Sem ele, o cliente não saberá para onde redirecionar, mesmo que o status code esteja correto.

HTTP/1.1 301 Moved Permanently
Location: https://example.com/new-url

Implementando o redirecionamento

Primeiro adicionamos o novo endpoint no arquivo de rotas:

// src/route.js
// ...
export default [
  {...},
  {...},
  {...},
  {...},
  {
    endpoint: "/r/:short_code",
    method: "GET",
    handler: linkController.redirectToLink,
  },
];

Agora vamos estender a função extendResponse adicionando um método .redirect():

// src/helpers/extend-response.js
export function extendResponse(response) {
  // ...

  response.redirect = function (statusCode, location) {
    response.writeHead(statusCode, { Location: location });
    response.end();
  };

  // ...
}

E por fim, adicionar o método no controller:

// src/controllers/link.controller.js
// ...
export default {
  listLinks(request, response) {...},
  getLinkByShortCode(request, response) {...},
  createLink(request, response) {...},
  deleteLink(request, response) {...},
  redirectToLink(request, response) {
    const { short_code } = request.params;

    const link = links.find((link) => link.short_code === short_code);

    if (!link) {
      return response.status(400).json({ error: "Link not found" });
    }

    response.redirect(301, link.original_url);
  },
};

E pronto! Agora quando o usuário acessa a URL passando o short_code criado previamente, ele é redirecionado para o original_url.