API-First: Processo e ferramentas

No post anterior tive a oportunidade de falar de uma forma mais abrangente sobre o API-First. O exercício é interessante, uma vez que é possível focar no conceito, ao invés de ferramentas e processos. Mas o objetivo desse artigo é cairmos de cabeça em como exercitar a ideia de forma prática.

O problema a ser solucionado será explicado mais abaixo, mas antes de cairmos na tentação de pensar no framework web, na modelagem de dados, em qual servidor de aplicação usar, ou em virtualizar com Docker e Kubernetes, vamos focar primeiro na especificação.

Diferença de fluxos
Diferença de fluxos

Com sorte seremos capazes de produzir uma API que atenda a necessidade dos nossos stakeholders.

O problema

Vamos "reciclar" o problema proposto no post sobre Django REST Framework e construir um "Feedly-clone". Então, partiremos da análise do seguinte requisito funcional:

Eu como usuário, Quero poder visualizar notícias recentes de websites que me cadastrei, Para que assim eu fique atualizado sobre assuntos de meu interesse

Com essa user story, podemos pensar no processo que envolve a prática do API-First.

O processo

No post anterior mencionei alguns passos que não podem faltar em um contexto de API-First. Jennifer Riggins, no "How to Design Great APIs with Api-First Design" traz uma visão mais prática sobre como o processo pode ser definido:

  • Planeje: Mesmo antes de começar, decida o propósito do produto e comece a esboçar a API;
  • Projete e valide: Debata o conceito com outros stakeholders e progrida com o design da API. Prove o conceito através de mocks (mais a seguir) e compreenda como a API será utilizada;
  • Oficialize a especificação: Construa a especificação de acordo com o planejamento e design. Gere a documentação baseada na spec, enriqueça o mock com os casos de uso e faça o release da mesma;
  • Teste: Teste para garantir que a API funcione. Teste para garantir que os casos de uso são atendidos. Teste para se assegurar que nenhuma nova alteração quebrou o contrato estabelecido. E considere testes automatizados sempre que possível;
  • Implemente: Não apenas você (backend), é hora de outros stakeholders (mobile, por exemplo) fazerem parte do processo de concepção. Quanto antes interagirem, mais cedo e (teoricamente) mais fácil será a reação à mudança;
  • Opere e engage: Publique-a! Interaja com seus clientes, aprenda com necessidades que ainda precisam ser atendidas, e repita o processo. API-First é tão sobre negócios quanto é sobre tecnologia.

Exemplo de processo com o API-First
Exemplo de processo com o API-First

Vamos esmiuçar de forma prática cada um dos passos acima.

Antes de ir: Single source of truth

A abordagem adotada nesse post seguirá valores apresentados no "Three Principles of API First Design". Mais especificamente:

Your API comes first, then the implementation

E nesse exemplo vamos separar especificação da implementação de forma "concreta". Ou seja, serão dois artefatos independentes.

Existem alternativas a esse approach, por exemplo, o uso do drf-yasg. Esta é uma ferramenta que gera a especificação (Swagger e OpenAPI Spec) automaticamente de acordo com o código fonte do seu projeto Django (com REST Framework).

Algo deve ser muito claro independente do que optar: Só deve haver uma verdade absoluta; No caso desse artigo, será a especificação escrita à parte.

1: Planeje

Vamos usar REST como padrão de comunicação entre o nosso servidor e os clientes. Portanto, poderemos utilizar Open API Spec para especificar a API. Caso a solução fosse a adoção de outra tecnologia (por exemplo, GraphQL ou gRPC), alternativas para o passo de descrição e documentação da interface devem ser aplicadas.

Além disso, seremos o mais "compliant" possível com o RESTful, ou seja, seguiremos à risca recomendações em relação a path, status codes e verbos. O versionamento da API acontecerá via fragmento no path (ex.: /v1/), e a princípio apresentaremos apenas respostas em JSON.

Imaginemos que depois de ponderar com outros stakeholders, chegamos à seguinte conclusão:

[
  {
    "id": 1,
    "title": "Título do artigo publicado no canal ABC",
    "pub_date": "data-com-timezone-incluso",
    "summary": "Resumo do artigo",
    "content": "Conteúdo completo do artigo",
    "image": "imagem-principal-do-artigo.png",
    "channel": "ABC",
    "url": "http://article-1.html"
  },
  {
    "id": 2,
    "title": "Título do artigo publicado no canal XYZ",
    "pub_date": "data-com-timezone-incluso",
    "summary": "Resumo do artigo (2)",
    "content": "Conteúdo completo do artigo",
    "image": "imagem-principal-do-artigo-2.png",
    "channel": "XYZ",
    "url": "http://article-2.html"
  }
]

Além disso, esboçamos as seguintes rotas:

  • GET /api/v1/channels/<id-do-channel>: Pega a lista de artigos
  • GET /api/v1/articles/<id-do-artigo>: Pega o artigo completo

Vamos ocultar complexidades fora do escopo da user story apresentada (como por exemplo, paginação e autenticação). Além disso, propositalmente não definimos um formato "oficial" para pub_date, apenas determinamos que deverá ser uma data em UTC.

Por tratar-se de um esboço inicial que gerará material para a definição do style guide da API e dos endpoints em si, é natural deixarmos passar alguns detalhes que podem gerar problemas de integração.

2: Projete e valide

Abaixo, o documento representando exatamente o que foi acordado na seção anterior, com OpenApi Specification:

openapi: "3.0.2"
info:
  title: Feedly-clone
  version: v1
paths:
  /channels:
    summary: Set of channels, e.g. blogs, websites or podcasts
    get:
      responses:
        "200":
          description: List of channels
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Channel"

  /channels/{id}:
    parameters:
      - $ref: "#/components/parameters/id"
    summary: Details of a channel
    get:
      responses:
        "200":
          description: Channel detail response
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Channel"

  /articles:
    summary: Set of items
    get:
      responses:
        "200":
          description: List of articles
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Article"

  /articles/{id}:
    parameters:
      - $ref: "#/components/parameters/id"
    summary: Details of an article
    get:
      responses:
        "200":
          description: Article detail response
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Article"

components:
  parameters:
    id:
      name: id
      in: path
      required: true
      schema:
        type: integer
  schemas:
    Channel:
      properties:
        id:
          type: integer
        name:
          type: string

    Article:
      properties:
        id:
          type: integer
        title:
          type: string
        pub_date:
          type: string
        summary:
          type: string
        content:
          type: string
        image:
          type: string
        channel:
          type: string
        url:
          type: string

O artigo "Swagger na prática" pode ser de ajuda caso você não esteja familiarizado com a síntaxe acima.

Projete

A partir daqui podemos pensar sob a ótica de casos de uso. Práticas e ferramentas podem variar dependendo da sua criatividade e do seu time. Para fins didáticos vamos manter esse passo o mais simples possível.

Para início de conversa o path articles/ não está alinhado o suficiente com a semântica por trás do problema. O formato RSS chama o que estamos chamando de Article de Item, logo, se quisermos manter uma linguagem ubíqua é fundamental renomearmos esse elemento.

Projetar é uma arte, desconhecida pelo Império (movieweb.com)
Projetar é uma arte, desconhecida pelo Império (movieweb.com)

Outro ponto de controvérsia em nosso esboço é que o recurso articles/ poderia ser um nested resource de channels/{id}/:

  • channels/{id}: Detalhes do channel
  • channels/{id}/items: Lista de items daquele channel
  • channels/{channel-id}/items/{item-id}: Detalhes do item

Digamos que após consultar os stakeholders de mobile e web, eles concordem com a alteração. É exatamente esse tipo de questionamento que esse passo deve trazer, além de instigar a discussão com demais interessados.

Por último, mas não menos importante, vamos supor que nossos possíveis clientes não estejam propensos a mostrar o conteúdo completo dos artigos em sua listagem. Logo, eles estariam fazendo "over-fetching" de informação. Podemos remover esse campo (content) de channels/{id}/items/{id}.

A especificação final você pode conferir aqui.

Antes de ir: Over-fetching e Under-fetching

Over-fetching (trazer mais conteúdo do que o necessário em um request) e under-fetching (trazer menos conteúdo do que o necessário) são problemas comuns em APIs REST que possuem pluralidade de clientes.

Soluções variam, das mais ingênuas (criar um endpoint para mobile e um para web, por exemplo) até as mais rebuscadas (como deixar o REST de lado e adotar GraphQL). O "meio-termo" seria a adoção de algum mecanismo que permita ao cliente especificar quais campos ele deseja carregar na requisição.

E é exatamente isso que o FlexFields faz para soluções escritas em REST Framework.

Valide

É hora de validarmos o contrato.

Poderíamos escrever views muito simples em Django (por exemplo) que atendam a especificação ao responder conteúdo estático. Mas possivelmente a melhor forma de executarmos esse passo seja através de ferramentas chamadas mock servers.

As opções são as mais variadas, em diferentes linguagens. A minha sugestão é o Prism, uma ferramenta escrita em Javascript, fácil de instalar e usar.

Veja como instalar o Prism.

Executando o Prism com a especificação passada como argumento, temos um servidor fornecendo o contrato via REST:

$ prism mock openapi.yml

› [CLI] …  awaiting  Starting Prism…
› [CLI] ℹ  info      GET        http://127.0.0.1:4010/channels
› [CLI] ℹ  info      GET        http://127.0.0.1:4010/channels/869
› [CLI] ℹ  info      GET        http://127.0.0.1:4010/channels/857/items
› [CLI] ℹ  info      GET        http://127.0.0.1:4010/channels/226/items/12
› [CLI] ▶  start     Prism is listening on http://127.0.0.1:4010

$ curl http://127.0.0.1:4010/channels/1/items/1

{"id":1,"title":"Anthony Mackie fala sobre se tornar o Capitão América","pub_date":"Sat, 29 Feb 2020 19:21:37 +0000","summary":"Quero que o meu Capitão América represente todo mundo, não só um grupo específico de pessoas","image":"https://uploads.jovemnerd.com.br/wp-content/uploads/2020/02/anthony-mackie-capitao-america.jpg","content":"Conteúdo completo","url":"https://jovemnerd.com.br/nerdbunker/anthony-mackie-fala-sobre-se-tornar-o-capitao-america/"}

Isso permite que novas avaliações sejam realizadas, modificações aconteçam com antecedência, casos de uso sejam testados, e até mesmo que outros stakeholders comecem a desenvolver a parte deles sem precisar esperar pelo backend todo estar pronto.

3: Oficialize a especificação

Nesse passo estruturamos o release da especificação, gerenciamos o processo e futuras iterações, e (claro) versionamos o arquivo.

Enquanto trabalhei na Loadsmart, mantínhamos a especificação em um repositório Git e tínhamos um build no CI para publicar a documentação baseada na versão do arquivo que encontrava-se no master.

Possuímos várias opções para esse passo. Talvez o ReDoc seja a ferramenta mais estilosa para gerar documentação a partir do OpenAPI.

Para fins didáticos vou usar a ferramenta do Swagger e expor a documentação no SwaggerHub: https://app.swaggerhub.com/apis-docs/kplaube/feedly-clone/v1

Documentação publicada no Swagger UI
Documentação publicada no Swagger UI

4: Teste

Teste sempre. Automatize sempre que possível.

Nessa etapa validaremos e garantiremos o funcionamento do contrato. Casos de uso podem ser necessários para estressar a coerência da API. Particularmente, não acho que essa seja a camada mais interessante para testar coisas além do contrato estabelecido (como por exemplo, a própria lógica de negócio). Pode tornar-se uma linha tênue em sua pirâmide de testes.

O Dredd é uma ferramenta bem interessante para testar a especificação construída. Ela utiliza um servidor como alvo, portanto, podemos adicioná-la ao build pipeline, levantarmos o nosso serviço, e ela operará como uma espécie de Component Test.

Executando contra o nosso servidor mockado, tudo deveria funcionar "out of the box":

$ dredd ./openapi.yml http://127.0.0.1:4010

warn: API description parser warning in /Users/klaus/Desktop/openapi.yml:66 (from line 66 column 7 to column 13): 'Parameter Object' contains unsupported key 'schema'
warn: API description parser warning in /Users/klaus/Desktop/openapi.yml:73 (from line 73 column 7 to column 13): 'Parameter Object' contains unsupported key 'schema'
warn: API description parser warning in /Users/klaus/Desktop/openapi.yml:102 (from line 102 column 7 to column 12): 'Schema Object' contains unsupported key 'allOf'
pass: GET (200) /channels duration: 63ms
pass: GET (200) /channels/1 duration: 20ms
pass: GET (200) /channels/1/items duration: 14ms
pass: GET (200) /channels/1/items/1 duration: 11ms
complete: 4 passing, 0 failing, 0 errors, 0 skipped, 4 total
complete: Tests took 116ms

Um outro extra muito interessante é a possibilidade de escrever hooks em Python, o que nos permite criar casos de uso de acordo com diferentes aspectos e contextos.

5: Implemente

Agora sim! Finalmente a parte divertida.

Agora é correr para o Django e implementar a API. Se você estiver interessado no Django REST Framework, escrevi sobre ele recentemente no "Construindo APIs em Django com Django REST Framework". Não perca.

Ao fim do desenvolvimento, não esqueça de executar o Dredd apontando para o serviço Django. Aliás, colocar essa checagem em um CI é uma excelente ideia! Dependendo do seu release train, a falha dessa checagem pode inclusive bloquear o seu Continuous Delivery.

6: Opere e engage

A sua funcionalidade está pronta. O seu produto está documentado. Agora é hora de "ir para a rua".

Com essa ideia de "uma API para todos governar", fica mais fácil ser adepto da corrente do "eat your own dog food". Tendo outros times da sua própria organização como usuários fica mais fácil coletar feedbacks e reiniciar todo o processo descrito com novas demandas e possíveis correções.

Para terceiros, é fundamental tratar a API como parte do negócio, e ela (e sua documentação) precisa ter espaço de destaque no website da sua empresa. O Twillio faz isso muito bem:

Exemplo de exposição de documentação
Exemplo de exposição de documentação

Além disso, existem marketplaces de APIs espalhados pela web que podem servir como canal de propaganda da sua API. O RapidAPI é um bom exemplo de plataforma para conectar-se com possíveis consumidores.

Não esqueça que propiciar SDKs para os seus consumidores faz parte dessa etapa. Não basta apenas escrevermos o backend, precisamos nos engajar em escrever clientes para diferentes linguagens. O Swagger Codegen pode ajudar nessa tarefa, gerando SDKs a partir da especificação da sua API.

Considerações finais

Abordamos o processo, os passos, e quais ferramentas podemos utilizar para atender os requisitos necessários em cada etapa. Faltou abordar de forma prática como "colocar isso tudo junto para funcionar". O post ficou gigante, e possivelmente abordaremos em uma parte 2 como colocar isso tudo em um CI, e em como orquestrar release de especificação e deploy de aplicação.

Há uma pluralidade interessante de ferramentas e serviços que giram em torno do conceito de API-First. Se por um lado isso pode significar aumentar a complexidade do seu processo de desenvolvimento, por outro é também uma certa garantia de que existem outros players investindo nessa prática, e que se você tem na sua API o seu principal produto, talvez ficar por fora possa ocasionar um problema de estratégia.

Até a próxima.

Referências