Este artigo, apesar do título inocente, provocou uma discussão tão prolixa no Stackoverflow que não podíamos ignorá-la. Uma tentativa de apreender a imensidão - para falar claramente sobre o design competente da API REST - aparentemente, o autor teve sucesso de várias maneiras, mas não completamente. Em qualquer caso, esperamos competir com o original no grau de discussão, bem como no fato de que nos juntaremos ao exército de fãs do Express.
Gostar de ler!
APIs REST são um dos tipos mais comuns de serviços da Web disponíveis hoje. Com a ajuda deles, vários clientes, incluindo aplicativos de navegador, podem trocar informações com o servidor por meio da API REST.
Portanto, é muito importante projetar a API REST corretamente para que você não tenha problemas ao longo do caminho. Considere a segurança, o desempenho e a usabilidade da API da perspectiva do consumidor.
Do contrário, causaremos problemas para os clientes que usam nossas APIs - o que é frustrante e irritante. Se não seguirmos convenções comuns, só vamos confundir quem manterá nossa API, assim como os clientes, pois a arquitetura será diferente daquela que todos esperam ver.
Este artigo examinará como projetar APIs REST de modo que sejam simples e compreensíveis para todos que as consomem. Garantiremos sua durabilidade, segurança e agilidade, pois os dados transmitidos aos clientes por meio de tal API podem ser confidenciais.
Como há muitos motivos e opções para a falha de um aplicativo de rede, devemos garantir que os erros em qualquer API REST sejam tratados sem problemas e acompanhados por códigos HTTP padrão para ajudar o consumidor a lidar com o problema.
Aceite JSON e retorne JSON em resposta
As APIs REST devem aceitar JSON para a carga útil da solicitação e também enviar respostas JSON. JSON é um padrão de transferência de dados. Quase qualquer tecnologia de rede é adaptada para usá-lo: JavaScript tem métodos integrados para codificar e decodificar JSON, seja por meio da API Fetch ou por meio de outro cliente HTTP. As tecnologias do lado do servidor usam bibliotecas para decodificar JSON com pouca ou nenhuma intervenção de sua parte.
Existem outras maneiras de transferir dados. O próprio XML não é amplamente suportado em estruturas; normalmente você precisa converter os dados para um formato mais conveniente, que geralmente é JSON. Do lado do cliente, principalmente do navegador, não é tão fácil lidar com esses dados. Você tem que fazer muito trabalho extra apenas para garantir a transferência normal de dados.
Os formulários são convenientes para a transferência de dados, especialmente se vamos transferir arquivos. Mas para transferir informações na forma de texto e numérica, você pode fazer sem formulários, já que a maioria dos frameworks permite que JSON seja enviado sem processamento adicional - basta pegar os dados no lado do cliente. Esta é a maneira mais direta de lidar com eles.
Para garantir que o cliente interprete o JSON recebido de nossa API REST exatamente como JSON,
Content-Type
o cabeçalho de resposta deve ser definido com um valor application/json
após a solicitação ser feita. Muitas estruturas de aplicativos do lado do servidor definem o cabeçalho de resposta automaticamente. Alguns clientes HTTP examinam Content-Type
o cabeçalho da resposta e analisam os dados de acordo com o formato especificado ali.
A única exceção ocorre quando tentamos enviar e receber arquivos que são transferidos entre o cliente e o servidor. Em seguida, você precisa processar os arquivos recebidos como resposta e enviar os dados do formulário do cliente para o servidor. Mas este é um assunto para outro artigo.
Também precisamos ter certeza de que JSON é a resposta de nossos terminais. Muitas estruturas de servidor têm esse recurso integrado.
Vamos dar um exemplo de uma API que aceita uma carga JSON. Este exemplo usa a estrutura de back-end Express para Node.js. Podemos usar um programa como middleware
body-parser
para analisar o corpo da solicitação JSON e, em seguida, chamar um método res.json
com o objeto que queremos retornar como uma resposta JSON. Isso é feito assim:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.post('/', (req, res) => {
res.json(req.body);
});
app.listen(3000, () => console.log('server started'));
bodyParser.json()
analisa a string do corpo da solicitação em JSON, convertendo-a em um objeto JavaScript e, em seguida, atribuindo o resultado ao objeto req.body
.
Defina o cabeçalho Content-Type na resposta para um valor
application/json; charset=utf-8
sem nenhuma alteração. O método mostrado acima é aplicável à maioria das outras estruturas de back-end.
Usamos nomes para caminhos para endpoints, não verbos
Os nomes dos caminhos para os terminais não devem ser verbos, mas nomes. Este nome representa o objeto do ponto de extremidade que recuperamos de lá ou que manipulamos.
A questão é que o nome do nosso método de solicitação HTTP já contém um verbo. Colocar verbos nos nomes dos caminhos para o terminal da API é impraticável; além disso, o nome acaba sendo desnecessariamente longo e não contém nenhuma informação valiosa. Os verbos escolhidos pelo desenvolvedor podem ser colocados de forma simples dependendo de seu capricho. Por exemplo, algumas pessoas preferem a opção 'get' e outras preferem 'retrieve', então é melhor se limitar ao verbo HTTP GET familiar que informa o que o terminal está fazendo.
A ação deve ser especificada no nome do método HTTP da solicitação que estamos fazendo. Os métodos mais comuns contêm os verbos GET, POST, PUT e DELETE.
GET busca recursos. O POST envia novos dados para o servidor. PUT atualiza os dados existentes. DELETE exclui dados. Cada um desses verbos corresponde a uma das operações do grupo CRUD .
Considerando os dois princípios discutidos acima, para recebermos novos artigos, devemos criar roteiros da forma GET
/articles/
. Da mesma forma, usamos POST /articles/
para atualizar um novo artigo, PUT /articles/:id
para atualizar um artigo com o fornecido id
. O método DELETE foi /articles/:id
projetado para excluir um artigo com um determinado ID.
/articles
É um recurso da API REST. Por exemplo, você pode usar o Express para fazer o seguinte com artigos:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.get('/articles', (req, res) => {
const articles = [];
// ...
res.json(articles);
});
app.post('/articles', (req, res) => {
// ...
res.json(req.body);
});
app.put('/articles/:id', (req, res) => {
const { id } = req.params;
// ...
res.json(req.body);
});
app.delete('/articles/:id', (req, res) => {
const { id } = req.params;
// ...
res.json({ deleted: id });
});
app.listen(3000, () => console.log('server started'));
No código acima, definimos pontos de extremidade para manipular artigos. Como você pode ver, não há verbos nos nomes dos caminhos. Apenas nomes. Os verbos são usados apenas em nomes de métodos HTTP.
Os endpoints POST, PUT e DELETE aceitam um corpo de solicitação JSON e retornam uma resposta JSON também, incluindo um endpoint GET.
As coleções são chamadas de substantivos no plural
As coleções devem ser nomeadas com substantivos no plural. Não é sempre que precisamos pegar apenas um item de uma coleção, então precisamos ser consistentes e usar substantivos plurais nos nomes das coleções.
O plural também é usado para consistência com as convenções de nomenclatura em bancos de dados. Como regra, uma tabela contém não um, mas vários registros, e a tabela é nomeada de acordo.
Ao trabalhar com um endpoint,
/articles
usamos plural ao nomear todos os endpoints.
Recursos de aninhamento ao trabalhar com objetos hierárquicos
O caminho dos terminais que lidam com recursos aninhados deve ser estruturado da seguinte forma: adicione o recurso aninhado como um nome de caminho após o nome do recurso pai.
Você precisa ter certeza de que em seu código o aninhamento de recursos corresponde exatamente ao aninhamento de informações em nossas tabelas de banco de dados. Caso contrário, a confusão é possível.
Por exemplo, se quisermos receber comentários sobre um novo artigo em um determinado ponto de extremidade, devemos anexar o caminho / comentários ao final do caminho
/articles
. Nesse caso, presume-se que consideramos a entidade de comentários como uma entidade filha article
em nosso banco de dados.
Por exemplo, você pode fazer isso com o seguinte código no Express:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.get('/articles/:articleId/comments', (req, res) => {
const { articleId } = req.params;
const comments = [];
// articleId
res.json(comments);
});
app.listen(3000, () => console.log('server started'));
No código acima, você pode usar o método GET no caminho
'/articles/:articleId/comments'
. Recebemos comentários comments
sobre o artigo correspondente articleId
e, em seguida, o devolvemos em resposta. Adicionamos 'comments'
após o segmento do caminho '/articles/:articleId'
para indicar que este é um recurso filho /articles
.
Isso faz sentido, pois os comentários são objetos filho
articles
e presume-se que cada artigo tenha seu próprio conjunto de comentários. Caso contrário, essa estrutura pode ser confusa para o usuário, uma vez que geralmente é usada para acessar objetos filhos. O mesmo princípio se aplica ao trabalhar com terminais POST, PUT e DELETE. Todos eles usam o mesmo aninhamento de estrutura ao construir nomes de caminho.
Tratamento limpo de erros e códigos de erro padrão de retorno
Para evitar confusão quando ocorre um erro na API, você precisa lidar com os erros com cuidado e retornar os códigos de resposta HTTP que indicam qual erro ocorreu. Isso fornece aos mantenedores da API informações suficientes para entender o problema. É inaceitável que erros travem o sistema, portanto, eles não podem ser deixados sem processamento, e o consumidor da API deve lidar com esse processamento.
Os códigos de erro HTTP mais comuns são:
- 400 Solicitação inválida - indica que a entrada recebida do cliente falhou na validação.
- 401 Não autorizado - significa que o usuário não efetuou login e, portanto, não tem permissão para acessar o recurso. Normalmente, esse código é emitido quando o usuário não está autenticado.
- 403 Proibido - indica que o usuário está autenticado, mas não está autorizado a acessar o recurso.
- 404 Não encontrado - significa que o recurso não foi encontrado
- O 500 Erro interno do servidor é um erro do servidor e provavelmente não deve ser lançado explicitamente.
- 502 Gateway inválido - indica uma mensagem de resposta inválida do servidor upstream.
- 503 Serviço indisponível - significa que algo inesperado aconteceu no lado do servidor - por exemplo, sobrecarga do servidor, falha de alguns elementos do sistema, etc.
Você deve emitir exatamente os códigos que correspondem ao erro que impediu nosso aplicativo. Por exemplo, se quisermos rejeitar dados recebidos como carga útil de solicitação, então, de acordo com as regras da API Express, devemos retornar um código de 400:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
//
const users = [
{ email: 'abc@foo.com' }
]
app.use(bodyParser.json());
app.post('/users', (req, res) => {
const { email } = req.body;
const userExists = users.find(u => u.email === email);
if (userExists) {
return res.status(400).json({ error: 'User already exists' })
}
res.json(req.body);
});
app.listen(3000, () => console.log('server started'));
No código acima, mantemos na matriz de usuários uma lista de usuários existentes que possuem e-mail conhecido.
Além disso, se tentarmos enviar uma carga útil com um valor
email
já presente nos usuários, obteremos uma resposta com um código 400 e uma mensagem 'User already exists'
indicando que esse usuário já existe. Com essas informações, o usuário pode melhorar - substitua o endereço de e-mail por outro que ainda não está na lista.
Os códigos de erro sempre devem ser acompanhados por mensagens informativas o suficiente para corrigir o erro, mas não tão detalhadas que essas informações possam ser usadas por invasores que pretendem roubar nossas informações ou travar o sistema.
Sempre que nossa API falha ao desligar adequadamente, devemos lidar com a falha com cuidado, enviando informações de erro para tornar mais fácil para o usuário corrigir a situação.
Permitir classificação, filtragem e paginação de dados
As bases por trás da API REST podem crescer muito. Às vezes, há tantos dados que é impossível recuperá-los de uma só vez, pois isso tornará o sistema mais lento ou até mesmo desativado. Portanto, precisamos de uma maneira de filtrar os itens.
Também precisamos de maneiras de paginar os dados para que retornemos apenas alguns resultados por vez. Não queremos perder muito tempo com os recursos tentando obter todos os dados solicitados de uma vez.
A filtragem e a paginação de dados podem melhorar o desempenho, reduzindo o uso de recursos do servidor. Quanto mais dados se acumulam no banco de dados, mais importantes essas duas possibilidades se tornam.
Aqui está um pequeno exemplo em que a API pode aceitar uma string de consulta com vários parâmetros. Vamos filtrar os itens por seus campos:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
//
const employees = [
{ firstName: 'Jane', lastName: 'Smith', age: 20 },
//...
{ firstName: 'John', lastName: 'Smith', age: 30 },
{ firstName: 'Mary', lastName: 'Green', age: 50 },
]
app.use(bodyParser.json());
app.get('/employees', (req, res) => {
const { firstName, lastName, age } = req.query;
let results = [...employees];
if (firstName) {
results = results.filter(r => r.firstName === firstName);
}
if (lastName) {
results = results.filter(r => r.lastName === lastName);
}
if (age) {
results = results.filter(r => +r.age === +age);
}
res.json(results);
});
app.listen(3000, () => console.log('server started'));
No código acima, temos uma variável
req.query
que nos permite obter parâmetros de solicitação. Podemos então extrair valores de propriedade desestruturando parâmetros de consulta individuais em variáveis; JavaScript tem uma sintaxe especial para isso.
Por fim, aplicamos o filtro em cada valor de parâmetro de consulta para encontrar os itens que desejamos retornar.
Feito isso, retornamos os resultados como uma resposta. Portanto, ao fazer uma solicitação GET para o seguinte caminho com uma string de consulta:
/employees?lastName=Smith&age=30
Nós temos:
[
{
"firstName": "John",
"lastName": "Smith",
"age": 30
}
]
como a resposta retornada porque a filtragem estava ativada
lastName
e age
.
Da mesma forma, você pode aceitar o parâmetro de consulta da página e retornar um grupo de registros ocupando posições de
(page - 1) * 20
a page * 20
.
Também na string de consulta, você pode especificar os campos pelos quais a classificação será realizada. Nesse caso, podemos classificá-los por esses campos separados. Por exemplo, podemos precisar extrair uma string de consulta de um URL como este:
http://example.com/articles?sort=+author,-datepublished
Onde
+
significa "para cima" e –
"para baixo". Assim, classificamos por nome de autor em ordem alfabética e por data de publicação do mais recente ao mais antigo.
Siga as práticas de segurança comprovadas
A comunicação entre o cliente e o servidor deve ser principalmente privada, pois frequentemente enviamos e recebemos informações confidenciais. Portanto, usar SSL / TLS para segurança é uma necessidade.
O certificado SSL não é tão difícil de carregar no servidor, e o próprio certificado é gratuito ou muito barato. Não há razão para desistir de permitir que nossas APIs REST se comuniquem por canais seguros em vez de canais abertos.
Uma pessoa não deve ter acesso a mais informações do que solicitou. Por exemplo, um usuário comum não deve obter acesso às informações de outro usuário. Além disso, ele não deve ser capaz de visualizar os dados dos administradores.
Para promover o princípio do menor privilégio, você deve implementar a verificação de função para uma função específica ou fornecer mais granularidade de funções para cada usuário.
Se decidirmos agrupar usuários em várias funções, então as funções precisam receber direitos de acesso que garantam que tudo o que o usuário precisa seja feito e nada mais. Se prescrevermos com mais detalhes os direitos de acesso para cada oportunidade fornecida ao usuário, precisamos garantir que o administrador possa conceder esses recursos a qualquer usuário ou retirá-los. Além disso, você precisa adicionar algumas funções predefinidas que podem ser aplicadas a um grupo de usuários para que você não precise definir os direitos necessários para cada usuário manualmente.
Dados de cache para melhorar o desempenho
O cache pode ser adicionado para retornar dados de um cache de memória local, em vez de recuperar alguns dados do banco de dados sempre que os usuários solicitarem. A vantagem do armazenamento em cache é que os usuários podem recuperar dados mais rapidamente. No entanto, esses dados podem estar desatualizados. Isso também pode ser repleto de problemas ao depurar em ambientes de produção, quando algo dá errado e continuamos olhando para dados antigos.
Há uma variedade de opções de cache disponíveis, como Redis , cache na memória e muito mais. Você pode alterar a maneira como os dados são armazenados em cache conforme necessário.
Por exemplo, o Express fornece middleware
apicache
para adicionar capacidade de cache ao seu aplicativo sem configuração complicada. O cache simples na memória pode ser adicionado ao servidor desta forma:
const express = require('express');
const bodyParser = require('body-parser');
const apicache = require('apicache');
const app = express();
let cache = apicache.middleware;
app.use(cache('5 minutes'));
//
const employees = [
{ firstName: 'Jane', lastName: 'Smith', age: 20 },
//...
{ firstName: 'John', lastName: 'Smith', age: 30 },
{ firstName: 'Mary', lastName: 'Green', age: 50 },
]
app.use(bodyParser.json());
app.get('/employees', (req, res) => {
res.json(employees);
});
app.listen(3000, () => console.log('server started'));
O código de cima refere-se simplesmente
apicache
com apicache.middleware
, resultando em:
app.use(cache('5 minutes'))
e isso é o suficiente para aplicar o cache de todo o aplicativo. Armazenamos em cache, por exemplo, todos os resultados em cinco minutos. Posteriormente, este valor pode ser ajustado dependendo do que precisamos.
Controle de versão de API
Precisamos ter versões diferentes da API para o caso de fazermos alterações que possam atrapalhar o cliente. O controle de versão pode ser feito em uma base semântica (por exemplo, 2.0.6 significa que a versão principal é 2, e este é o sexto patch). Este princípio agora é aceito na maioria das aplicações.
Dessa forma, você pode retirar gradualmente os terminais antigos em vez de forçar todos a alternar simultaneamente para a nova API. Você pode manter a versão v1 para quem não quer mudar nada, e fornecer a versão v2 com todos os seus novos recursos para quem está pronto para atualizar. Isso é especialmente importante no contexto de APIs públicas. Eles precisam ter versões para não quebrar aplicativos de terceiros que usam nossas APIs.
O controle de versão geralmente é feito adicionando
/v1/
,/v2/
, etc., adicionado no início do caminho da API.
Por exemplo, veja como fazer no Express:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.get('/v1/employees', (req, res) => {
const employees = [];
//
res.json(employees);
});
app.get('/v2/employees', (req, res) => {
const employees = [];
//
res.json(employees);
});
app.listen(3000, () => console.log('server started'));
Apenas adicionamos o número da versão ao início do caminho que leva ao terminal.
Conclusão
A principal lição do projeto de APIs REST de alta qualidade é manter a consistência seguindo os padrões e convenções da web. Os códigos de status JSON, SSL / TLS e HTTP são essenciais na web hoje.
O desempenho é igualmente importante. Você pode aumentá-lo sem retornar muitos dados de uma vez. Além disso, você pode usar o cache para evitar solicitar os mesmos dados repetidamente.
Os caminhos do terminal devem ser nomeados de forma consistente. Você deve usar substantivos em seus nomes, pois os verbos estão presentes nos nomes de métodos HTTP. Os caminhos de recursos aninhados devem seguir o caminho do recurso pai. Eles devem comunicar o que recebemos ou manipulamos, para que não tenhamos que consultar adicionalmente a documentação para entender o que está acontecendo.