Wesley Damasceno

Wesley Damasceno

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 funcionam 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.

Você pode ver o código-fonte completo no github, clicando aqui.

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 o 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


O 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 louco, né? E detalhe: isso funciona para qualquer novo endpoint que tenha um parâmetro de nível 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 HTTPFluxo 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.