O Guia Definitivo para construção de APIs REST

Ou: como não escrever APIs que fedem

Tua API, correndo pelo mato.
Your API stinks!
— Me

O “I” de “API” é de “Interface”, e me parece que “o mal do século” no mundo da programação é a falta de boas interfaces. Nós temos bom hardware, boas linguagens de programação, boa velocidade de rede para comunicar processos inter-máquinas (inter-continentalmente, inclusive) e temos bons protocolos à nossa disposição. Mas o desenho de interfaces parece avançar como “a lesma subindo o poço” dos problemas de Física do Ensino Médio: sobe um metro e meio, desce um metro, sobre um metro e meio, desce um metro…

Enquanto os protocolos cumprem o papel de linguagem (e uma espécie de meio, especialmente nos níveis “físicos” dos protocolos de rede, por exemplo), definindo como um componente deve “falar” com o outro (desculpa, São Dijkstra, eu antropomor… fi… zo… descaradamente, mesmo), as interfaces definem o que um componente deve dizer ao outro e o que esperar como resposta.

Ora, na vida real há uma porção de exemplos em que devemos escolher uma “interface” adequada para nossa comunicação. Um processo de bug reporting, por exemplo, dentro de uma equipe de desenvolvimento, pode ser tão ruim quanto isso:

Cara, tá dando pau nos arquivos…

Ou tão bom quanto:

Ao tentar baixar um arquivo, encontrei esse problema: ao invés de receber status_code 302 com “Location” adequado, está vindo erro 401. Veja:
$ http GET “http://localhost:8000/v1/files/42?_download=1" “Authorization: token $TOKEN”
HTTP/1.1 401 Unauthorized
Connection: keep-alive
Content-Type: text/html; charset=utf-8
Date: Fri, 05 May 2017 21:57:37 GMT
Server: gunicorn/19.6.0
Transfer-Encoding: chunked
Vary: Cookie
Via: 1.1 vegur
X-Frame-Options: SAMEORIGIN
O token que estou utilizando é válido. Acabei de checar.
Se eu acesso o endpoint sem o _download=1 ou com _download=0, tudo funciona como deveria (status_code 200).

Perceba que o protocolo em ambos os casos está correto: o idioma português. O meio pode estar correto, também: pode ser uma Issue no Github ou no bug tracker da escolha do time. Mas a interface no primeiro exemplo, é horrível, pois ela é o gatilho para “a dança do bug report ruim”, cujo segundo passo é “que tipo de problema você está tendo?”. Por isso as equipes de desenvolvimento escolhem mais do que um protocolo (o idioma português?) ou um meio (Bugs no Jira?), mas também o jeito certo de se reportar um bug: isso é uma espécie de interface. Se é definido que o bug report deve ser como no segundo exemplo, um report como o do primeiro será rejeitado por não atender uma série de critérios pré-estabelecidos.

Com isso em mente, vamos falar sobre as interfaces entre programas de computador, as APIs.

Por que APIs fedem

1- Porque são “quase” REST. Ênfase no “quase”.

Quase.

Usar HTTP e responder com JSON (e/ou XML) não faz da sua API uma API REST. O cúmulo maior da bizarrice em comunicações da história da computação, o SOAP, usa (/pode usar) HTTP e serializa dados com XML, mas… né?, não por isso chamamos SOAP de REST.

O problema aqui é cair no meio-termo: você poderia estar utilizando um padrão simples reconhecido universalmente ou usar qualquer outro padrão, mas não: o seu jeito tupiniquim acaba caindo no “quase”: é bem parecido com REST — mas não é REST. E, não tendo sequer um nome, você simplesmente não fala nada a respeito na documentação da API.

Ou, o que é ainda pior: você diz que é REST, mas, na verdade, não é.

1.1- Ignorância sobre o HTTP

Num sei e num quero sabê!

Muitas vezes, essas APIs quase-REST são resultado de uma tentativa legítima mas frustrada de sê-lo. E o motivo de não conseguir fazer isso direito, pelo que entendo, reside na não-confiança no protocolo HTTP. O que, em geral, é meramente por ignorância a respeito do mesmo.

Minha sugestão: leia a RFC:

(Caso você já tenha alguma familiaridade com as RFCs, repare que a 7230 torna a 2616 obsoleta. E há, sim, algumas diferenças bem importantes entre elas. ;-)

1.2- Ignorância sobre os métodos do HTTP

Métodos são coloquialmente conhecidos como “verbos” e não é sem razão. Eles são descritos, geralmente, por meio de verbos, como GET, POST ou DELETE. E é claro que há exceções, como HEAD e OPTIONS, por isso mesmo “verbo” é só um “apelido carinhoso”, não uma regra.

Vide a RFC 7231: https://tools.ietf.org/html/rfc7231#section-4.3 .

GET

Basicamente, para extrair dados de um documento específico ou listar os documentos de um recurso.

Digamos que estamos implementando um sistema para vender canos e temos um recurso Pipe. O endpoint correspondente a este recurso é identificado pela URL https://<domínio>/v1/pipes/ . Logo, eu posso listar os Canos do sistema com:

GET /v1/pipes/

Ou posso extrair informações a respeito de um Cano específico com:

GET /v1/pipes/:pipe_id

1.2.1- GET em listagens retornando 404!

Wait… What?

Essa discussão é complicada, uma vez que o protocolo HTTP não diz muito a respeito. Mas o que ele diz é suficiente:

The 404 (Not Found) status code indicates that the origin server did not find a current representation for the target resource or is not willing to disclose that one exists.

Digamos que eu não tenho nenhum Cano registrado e envio essa requisição para o sistema:

GET /v1/pipes/

Que código status de status o servidor deve retornar? Óbvio: 200 (OK). Afinal, (1) o servidor conseguiu encontrar uma representação para o recurso alvo? Sim! É uma lista com zero elementos! Ou (2) o servidor não quer revelar se uma representação existe? Não há por que não (a não ser no caso de não haver permissão para tal ou algo assim).

Agora, se eu tentar acessar um recurso inexistente, aí faz sentido que aquilo que, supostamente, seria uma listagem, retorne um 404 (NOT FOUND):

GET /v1/cars/

Não há “Carros” no sistema! Logo, o código 404 faz todo o sentido: tal endpoint sequer existe!

Então, fica a regra: listagens com zero elementos não devem retornar status 404!

POST

Para criar um documento novo. Como em:

POST /v1/pipes/
{"name": "100mm", "color": "white", "objective": "Pass shit through it. Literally."}

1.2.2- Recursos e ações sendo confundidos

Cena de ação, cheia de recursos.

Uma característica comum das APIs que fedem é a confusão que fazem entre recursos e seus endpoints e a responsabilidade pela tomada de ações. O que lembra um pouco os problemas típicos do SOAP sobre HTTP: o servidor respondia com status 200 (OK) dizendo “aconteceu um erro”.

Hein?

O mesmo acontece quando você, na sua loja de canos, cria um recurso como este para criar Cartões que vão dentro dos Canos:

POST /pipes/:id/create_card.json

Ué? POST já não quer dizer “criar”? Então por que precisamos do create_ na URL do endpoint? Além disso, que espécie de recurso é esse? Recursos são tudo sobre dados, porque as ações já são cobertas pelo HTTP. Certo?

E mais: por que o endpoint de Canos vai criar um Cartão???

Lembre: recursos são sobre dados. Se você coloca um verbo (como “create”) numa URL, algo errado está acontecendo.

O jeito certo de criar-se um cartão, nesse caso, seria:

POST /cards
{"pipe": <pipe-id>, "name": "Example name", ...}

PUT

Usado para alterar um documento já existente. Curiosamente, espera-se que o servidor faça uma substituição do que já existia pelo que você está enviando, então você deve enviar um documento completo.

Caso você referencie um identificador que ainda não existe, espera-se, a princípio, que o servidor crie um novo documento com os dados que você enviou e com o identificador que você usou na URL.

Mas você não é obrigado, também. Logo:

1.2.3- PUTASCREATE

Agora, eu sei que há algumas seitas de programadores que preferem usar o verbo PUT para criar documentos novos. E, veja bem, nada impede que você implemente seus verbos da maneira que achar melhor. Se você quiser iniciar um novo culto, como os “To DELETE is to Create”, basta escrever algumas linhas de código e você terá um servidor que usa o verbo DELETE do HTTP para criar novos objetos.

Mas sua API federá.

O protocolo HTTP é bem claro:

  • Ao usar o verbo POST, a URL em questão deve representar um recurso, não um documento.
  • Ao usar o verbo PUT, a URL em questão deve representar um documento, não um recurso.

Logo:

POST /v1/pipes/1 = errado
POST /v1/pipes/ = correto
PUT /v1/pipes/ = errado
PUT /v1/pipes/1 = correto!

Então você até pode usar o PUT para criar novos documentos, contanto que faça sentido dentro da arquitetura proposta que o cliente seja quem defina o identificador do documento! Se você está usando IDs numéricos sequenciais, por exemplo, usar PUT pode se tornar uma tarefa complicada demais.

Pessoalmente, acredito que sejam muito poucos os casos em que é uma boa ideia criar documentos usando PUT.

PATCH

Do inglês, “remendo”, o verbo PATCH é o irmão mais novo do PUT e responde à pergunta óbvia “mas por que diabos eu preciso reenviar o documento inteiro se eu só quero alterar um ou dois campos?”.

Usando o verbo PATCH você pode modificar um número limitado de campos do documento, o que pode ser muito conveniente.

1.2.4- PATCH implementado errado

Eu absolutamente não faço a menor ideia de onde surgiu esse mito, mas há uma porção de desenvolvedores por aí que acreditam firmemente que o verbo PATCH só deve ser usado para alterar “um e somente um” campo por requisição. O que não faz o menor sentido, inclusive porque o conceito comum de “campos” (geralmente os desenvolvedores tem um documento serializado em JSON ou XML na cabeça quando dizem isso) mal é citado na definição deste método HTTP. O que a definição diz é que o corpo da requisição PATCH deve dizer como o documento deve ser alterado. Em momento algum define-se qualquer tipo de regra de implementação.

Lembre-se: o HTTP é um protocolo. Ele não desce ao nível da implementação, pois pode-se usar HTTP para praticamente qualquer coisa. Uma requisição PATCH poderia muito bem alterar um arquivo binário ou alterar o tom de uma nota musical dentro de uma canção, o que faria com que o conceito de “campos” sequer fizesse sentido!

2- Porque não entendem que mais simples é mais robusto.

“Robustez é fruto de transparência e simplicidade”.
(Eric Raymond, The Art of Unix Programming)

2.1- No afã de parecer bonito, acolhe-se parasitas

Digamos que temos uma loja de parafusos. Além do recurso Bolt, temos o recurso Nut (porca). O segundo é completamente dependente do primeiro. Logo, é fácil que surja na cabeça de algum desenvolvedor esse tipo de URL para se acessar a lista de porcas de um determinado parafuso:

/v1/bolts/:bolt_id/nuts/

Mas isso é péssimo. Veja só: o próximo passo lógico é tentar acessar uma porca individual. Logo, você acaba com a URL:

/v1/bolts/:bolt_id/nuts/:nut_id

Tudo parece okay, contanto que você tenha conseguido o ID de uma porca por meio da listagem de porcas de determinado parafuso. Mas tudo vira uma bagunça quando você acaba obtendo o ID da porca por qualquer outro meio! Dessa maneira, você percebe que :bold_id é uma informação parasitante: ela só está na URL, na verdade, para te atrapalhar.

Logo, evite ao máximo o “aninhamento” de URLs de recursos. Que cada recurso tenha sua URL específica e própria, podendo ser acessado simplesmente por meio de seu identificador, sem precisar de alguma outra informação.

Com a URL acima, caso você tenha apenas o ID da Porca, não conseguirá acesso direto a ela, sendo obrigado a fazer uma varredura pelos Parafusos.

Um esquema de URLs melhor seria:

/v1/bolts/:bolt_id
/v1/nuts/:nut_id

2.2- Tentam resolver tudo via URL PATH e ainda acham bonito

Seguindo o esquema acima, como faríamos para listar apenas as porcas relacionadas a determinado parafuso?

Muito simples: com um filtro cujos parâmetros sejam passados via query string no GET:

/v1/nuts/?bolt_id=:bolt_id

A URL daquela loja de canos já citada implementa algo que mostra esse problema de tentar enfiar tudo no PATH da URL:

POST /pipes/:id/create_card.json

Aquele .json no final parece muito prático, mas a mesma funcionalidade poderia ter sido implementada via um header HTTP na requisição, como ‘Content-Type’ (para o caso do GET, usar-se-ia o header 'Accept').

Mas, mas…, você deve estar balbuciando, vendo quão mais prático é colocar o .json ou .xml no fim da URL.

Okay. Mas reserve alguns segundo para pensar nesta palavra:

CONSISTÊNCIA

E quando quisermos que a listagem nos retorne os documentos em determinado formato?

GET /cards/.json (???)

Será isso uma opção adequada?

Ah, já sei! Podemos marretar um pouco mais a API:

GET /cards/list_cards.json

Que tal? Pior ainda, não? Pois é. Para isso usamos headers para definir o “media type” das requisições. Isso quando a API não deixa explícito que suporta apenas um media type.

Vide:

GET /cards
Accept: application/json

3- Porque não entendem que nada do que foi será de novo do jeito que já foi um dia, que tudo passa, tudo sempre passará e que a vida vem em ondas como o mar, num indo e vindo infinito.

Indo e vindo infinito…

A loja de canos é um exemplo do que não fazer: as URLs dos recursos não são “versionadas”. Veja que não há referência alguma à versão da API que está sendo utilizada.

Agora pense no medo dos desenvolvedores das centenas de empresas que já estão integrando seus sistemas com as APIs deles quando vêem que, além de não haver versionamento algum, a documentação oficial ainda diz que o produto é “beta”. (Pois que se libere uma “vê zero” da API, então!)

Sempre versione sua API. Lembre-se: depois que uma equipe de desenvolvimento de outra empresa tiver passado um ano inteiro desenvolvendo um produto que se integre na sua API, eles ficarão furiosos (com razão) se, do dia pra noite, os endpoints pararem de funcionar, as respostas venham diferentes ou coisas assim. Uma vez lançada uma versão, ela não será “deslançada” jamais.

Exemplo simples de versionamento:

/v1/pipes/

ou:

/v<versão>/<o-restante-do-path>/

Ah, e não pire, também, nos números: as coisas mudam, mas mudar uma API inteira demora um tanto. Use meramente números inteiros.

4- Porque o desenvolvedor não usa, ele mesmo, outras APIs

Estranho o cara já era, na verdade.

Por mais incrível que pareça, ouso dizer que esse é o caso da maioria das equipes de desenvolvimento, mesmo nas grandes empresas. As APIs fedem simplesmente porque os desenvolvedores não tem a experiência e, digamos, “intimidade” com outras APIs. Esses erros que para mim são óbvios não o eram para os desenvolvedores que os cometeram. Em muitos casos, os programadores sequer haviam feito trabalhos “fortes” de integração com APIs de terceiros antes de começar a desenvolver a própria API!

E isso é um problema difícil de resolver, já que contratar desenvolvedores menos experientes, em geral, é a maneira que as empresas encontram para cortar custos. É uma solução errada, mas também devemos lembrar que é difícil encontrar bons desenvolvedores, então, muitas vezes, essa é a única opção que elas tem.

Se você é um desenvolvedor com pouca experiência em uso de APIs de terceiros, já fez bem em ler este artigo. Procure bons materiais a respeito, fuja das ideias da Microsoft e não perca tempo em discussões do tipo “tem que ter a barra no final da URL” versus “não precisa da barra no final da URL”.

Se você está procurando desenvolvedores para criar uma API para sua aplicação, procure primeiro por alguém que tenha experiência nesse assunto. Por mais que o custo seja caro e possa demorar mais para encontrar, é um investimento que realmente vale a pena.

4.1- Paginação baseada em número de página

Fruto da inexperiência, muitos desenvolvedores resolvem que suas listagens serão paginadas (o que é bom), mas baseiam o sistema de paginação nos blogs que lêem (quem nunca viu o /page/2/nas URLs?) ao invés de basear-se nos SGBDs (com limit e offset).

Se tua API faz paginação baseada no número da página (com o parâmetro ?_page=<page-number> , você já começa com uma decisão difícil e completamente sem sentido: “qual deve ser o tamanho default das páginas?”. Ela é difícil porque é realmente complicado encontrar-se uma boa base para definir tal número e é sem sentido porque o certo é permitir que o cliente escolha o tamanho da página.

Além disso, quando um cliente quiser implementar ele próprio um sistema de paginação, justamente naquele período intermediário entre a publicação da API e o “ah, acho que deveríamos permitir que o cliente escolha o tamanho da página”, você o obriga a fazer um enorme malabarismo para adequar o tamanho de página próprio (exemplo: 15 itens) com o tamanho de página do servidor (exemplo: 100 items).

E então, quando você resolver permitir que o cliente escolha o tamanho de página, você perceberá que tudo seria resolvido facilmente usando-se limit e offset , do jeito que deveria ser desde sempre.

4.2- “Erros” que não são erros

Isso cai um pouco no item de desconhecimento do protocolo HTTP, mas também sinaliza um pouco de falta de experiência: se o programador nunca implementou uma integração com APIs de terceiros, ele dará pouco valor para o retorno dos códigos de status HTTP correctos.

Por exemplo: digamos que no meu sistema de venda de Canos haja uma restrição: “name” e “color” do Cano devem ser únicos juntos. Ou seja: dentro do banco de dados não haverá N > 1 Canos que tenham exatamente os mesmos “name” e “color”. Nesta situação, o que o servidor deve retornar caso o cliente tente cadastrar um Cano com mesmo “name” e “color” já existentes?

400 e alguma coisa? Não, porque ele não fez nada de errado! A requisição foi para a URL correta e com payload e headers corretos. Então não pode ser 4xx.

Além disso, erros 400 geralmente querem dizer “corrija sua requisição antes de tentar novamente”. Mas o cliente iria corrigir o quê?

500 e alguma coisa? Não, porque impedir o salvamento de outra entidade assim não é um problema: é uma solução. Então, não, não pode-se dizer que houve um “erro” do lado do servidor.

Além disso, erros 500 geralmente querem dizer “aguarde um pouco e tente novamente”. Mas não importa o quanto o cliente aguarde: o resultado será sempre o mesmo.

A solução é simples: retornar código 200 (Ok) ao invés do 201 (Created), com o header Content-Location apontando para a entrada pré-existente. Vide https://tools.ietf.org/html/rfc7231#section-3.1.4.2 . Isso adiciona clareza à comunicação.

A questão aqui não é tanto a simplicidade da solução, mas o fato que um desenvolvedor sem experiência sequer pararia para pensar neste “problema”: o banco de dados acusando um conflito, ele simplesmente passaria o problema para frente com um código de status errado.

E sua API federia.

Como criar uma API que não feda

Aqui vai um resumo do que foi dito anteriormente:

  • Versione as URLs;
  • Seja consistente;
  • Evite aninhar recursos;
  • Não permita que verbos como “create”, “update” ou “delete” façam parte do PATH das URLs: para isso existe o HTTP;
  • Use POST para criar, PUT e PATCH para atualizar;
  • Filtre com query parameters;
  • Selecione o “media type” (como JSON/XML) via headers, não via PATH;
  • Use direito os códigos de retorno do HTTP;
  • Não retorne 404 para listagens com zero elementos;
  • Permita que o PATCH altere N ≥ 1 campos;
  • Faça paginação com limit e offset .
  • Cuidado com os status 4xx e 5xx: não aponte dedos onde não há culpados.
Faça uma API beautiful!

Talvez esse “guia” não seja “definitivo”, porque sempre há um jeito novo de fazer uma API feder. Mas, enfim, pelo menos eu tentei…


[Jabá Alert!]

E sabe onde está sendo feita uma API que não fede? Na Dronemapp! E precisamos de mais desenvolvedores além de mim, por lá. Veja:

https://medium.com/dronemapp/desenvolvedor-python-5330284f075b