5 coisas que você nunca fez com uma especificação REST
Publicados: 2022-03-11A maioria dos desenvolvedores front-end e back-end já lidou com especificações REST e APIs RESTful antes. Mas nem todas as APIs RESTful são criadas iguais. Na verdade, eles raramente são RESTful…
O que é uma API RESTful?
É um mito.
Se você acha que seu projeto possui uma API RESTful, provavelmente está enganado. A ideia por trás de uma API RESTful é desenvolver de maneira que siga todas as regras e limitações de arquitetura descritas na especificação REST. Realisticamente, no entanto, isso é praticamente impossível na prática.
Por um lado, REST contém muitas definições confusas e ambíguas. Por exemplo, na prática, alguns termos do método HTTP e dicionários de código de status são usados de forma contrária aos propósitos pretendidos, ou não são usados.
Por outro lado, o desenvolvimento REST cria muitas limitações. Por exemplo, o uso de recursos atômicos é abaixo do ideal para APIs do mundo real que são usadas em aplicativos móveis. A negação total de armazenamento de dados entre solicitações basicamente proíbe o mecanismo de “sessão do usuário” visto em quase todos os lugares.
Mas espere, não é tão ruim assim!
Para que você precisa de uma especificação de API REST?
Apesar dessas desvantagens, com uma abordagem sensata, REST ainda é um conceito incrível para criar APIs realmente ótimas. Essas APIs podem ser consistentes e ter uma estrutura clara, boa documentação e alta cobertura de teste de unidade. Você pode conseguir tudo isso com uma especificação de API de alta qualidade.
Normalmente, uma especificação de API REST é associada à sua documentação . Ao contrário de uma especificação - uma descrição formal de sua API - a documentação deve ser legível por humanos: por exemplo, lida pelos desenvolvedores do aplicativo móvel ou da Web que usa sua API.
Uma descrição correta da API não é apenas escrever bem a documentação da API. Neste artigo, quero compartilhar exemplos de como você pode:
- Torne seus testes unitários mais simples e confiáveis;
- Configurar pré-processamento e validação de entrada do usuário;
- Automatize a serialização e garanta a consistência das respostas; e até mesmo
- Aproveite os benefícios da digitação estática.
Mas primeiro, vamos começar com uma introdução ao mundo da especificação da API.
OpenAPI
OpenAPI é atualmente o formato mais amplamente aceito para especificações de API REST. A especificação é escrita em um único arquivo no formato JSON ou YAML que consiste em três seções:
- Um cabeçalho com o nome, descrição e versão da API, bem como qualquer informação adicional.
- Descrições de todos os recursos, incluindo identificadores, métodos HTTP, todos os parâmetros de entrada, códigos de resposta e tipos de dados do corpo, com links para definições.
- Todas as definições que podem ser usadas para entrada ou saída, no formato JSON Schema (que, sim, também pode ser representada em YAML).
A estrutura do OpenAPI tem duas desvantagens significativas: é muito complexa e às vezes redundante. Um projeto pequeno pode ter uma especificação JSON de milhares de linhas. Manter este arquivo manualmente torna-se impossível. Essa é uma ameaça significativa à ideia de manter a especificação atualizada enquanto a API está sendo desenvolvida.
Existem vários editores que permitem que você descreva uma API e produza uma saída OpenAPI. Serviços adicionais e soluções em nuvem com base neles incluem Swagger, Apiary, Stoplight, Restlet e muitos outros.
No entanto, esses serviços foram inconvenientes para mim devido à complexidade de edição rápida de especificações e alinhamento com alterações de código. Além disso, a lista de recursos dependia de um serviço específico. Por exemplo, criar testes de unidade completos com base nas ferramentas de um serviço em nuvem é quase impossível. A geração de código e os endpoints simulados, embora pareçam práticos, acabam sendo inúteis na prática. Isso ocorre principalmente porque o comportamento do endpoint geralmente depende de várias coisas, como permissões de usuário e parâmetros de entrada, que podem ser óbvios para um arquiteto de API, mas não são fáceis de gerar automaticamente a partir de uma especificação OpenAPI.
Tinyspec
Neste artigo, usarei exemplos baseados em meu próprio formato de definição de API REST, tinyspec . As definições consistem em pequenos arquivos com uma sintaxe intuitiva. Eles descrevem terminais e modelos de dados que são usados em um projeto. Os arquivos são armazenados ao lado do código, fornecendo uma referência rápida e a capacidade de serem editados durante a escrita do código. Tinyspec é compilado automaticamente em um formato OpenAPI completo que pode ser usado imediatamente em seu projeto.
Também usarei exemplos de Node.js (Koa, Express) e Ruby on Rails, mas as práticas que demonstrarei são aplicáveis à maioria das tecnologias, incluindo Python, PHP e Java.
Onde a especificação da API balança
Agora que temos alguns antecedentes, podemos explorar como tirar o máximo proveito de uma API especificada corretamente.
1. Testes Unitários de Endpoint
O desenvolvimento orientado por comportamento (BDD) é ideal para desenvolver APIs REST. É melhor escrever testes de unidade não para classes, modelos ou controladores separados, mas para terminais específicos. Em cada teste você emula uma requisição HTTP real e verifica a resposta do servidor. Para Node.js existem os pacotes supertest e chai-http para emular requisições, e para Ruby on Rails existe o airborne.
Digamos que temos um esquema de User
e um endpoint GET /users
que retorna todos os usuários. Aqui está uma sintaxe tinyspec que descreve isso:
# user.models.tinyspec User {name, isAdmin: b, age?: i} # users.endpoints.tinyspec GET /users => {users: User[]}
E aqui está como escreveríamos o teste correspondente:
Node.js
describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); expect(users[0].name).to.be('string'); expect(users[0].isAdmin).to.be('boolean'); expect(users[0].age).to.be.oneOf(['boolean', null]); }); });
Ruby nos trilhos
describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect_json_types('users.*', { name: :string, isAdmin: :boolean, age: :integer_or_null, }) end end
Quando já temos a especificação que descreve as respostas do servidor, podemos simplificar o teste e apenas verificar se a resposta segue a especificação. Podemos usar modelos tinyspec, cada um dos quais pode ser transformado em uma especificação OpenAPI que segue o formato JSON Schema.
Qualquer objeto literal em JS (ou Hash
em Ruby, dict
em Python, array associativo em PHP e até mesmo Map
em Java) pode ser validado para conformidade com o esquema JSON. Existem até plugins apropriados para testar frameworks, por exemplo jest-ajv (npm), chai-ajv-json-schema (npm) e json_matchers para RSpec (rubygem).
Antes de usar esquemas, vamos importá-los para o projeto. Primeiro, gere o arquivo openapi.json
com base na especificação tinyspec (você pode fazer isso automaticamente antes de cada execução de teste):
tinyspec -j -o openapi.json
Node.js
Agora você pode usar o JSON gerado no projeto e obter a chave de definitions
dele. Essa chave contém todos os esquemas JSON. Os esquemas podem conter referências cruzadas ( $ref
), portanto, se você tiver algum esquema incorporado (por exemplo, Blog {posts: Post[]}
), precisará desembrulhar para uso na validação. Para isso, usaremos json-schema-deref-sync (npm).
import deref from 'json-schema-deref-sync'; const spec = require('./openapi.json'); const schemas = deref(spec).definitions; describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); // Chai expect(users[0]).to.be.validWithSchema(schemas.User); // Jest expect(users[0]).toMatchSchema(schemas.User); }); });
Ruby nos trilhos
O módulo json_matchers
sabe como lidar com referências $ref
, mas requer arquivos de esquema separados no local especificado, então você precisará dividir o arquivo swagger.json
em vários arquivos menores primeiro:
# ./spec/support/json_schemas.rb require 'json' require 'json_matchers/rspec' JsonMatchers.schema_root = 'spec/schemas' # Fix for json_matchers single-file restriction file = File.read 'spec/schemas/openapi.json' swagger = JSON.parse(file, symbolize_names: true) swagger[:definitions].keys.each do |key| File.open("spec/schemas/#{key}.json", 'w') do |f| f.write(JSON.pretty_generate({ '$ref': "swagger.json#/definitions/#{key}" })) end end
Veja como será o teste:
describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect(result[:users][0]).to match_json_schema('User') end end
Escrever testes dessa maneira é incrivelmente conveniente. Especialmente se o seu IDE suportar a execução de testes e depuração (por exemplo, WebStorm, RubyMine e Visual Studio). Dessa forma, você evita o uso de outros softwares, e todo o ciclo de desenvolvimento da API é limitado a três etapas:
- Projetando a especificação em arquivos tinyspec.
- Escrevendo um conjunto completo de testes para endpoints adicionados/editados.
- Implementar o código que satisfaz os testes.
2. Validando os dados de entrada
OpenAPI descreve não apenas o formato de resposta, mas também os dados de entrada. Isso permite validar dados enviados pelo usuário em tempo de execução e garantir atualizações de banco de dados consistentes e seguras.
Digamos que temos a seguinte especificação, que descreve o patch de um registro de usuário e todos os campos disponíveis que podem ser atualizados:
# user.models.tinyspec UserUpdate !{name?, age?: i} # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => {success: b}
Anteriormente, exploramos os plugins para validação em teste, mas para casos mais gerais, existem os módulos de validação ajv (npm) e json-schema (rubygem). Vamos usá-los para escrever um controlador com validação:
Node.js (Koa)
Este é um exemplo para Koa, o sucessor do Express, mas o código Express equivalente seria semelhante.
import Router from 'koa-router'; import Ajv from 'ajv'; import { schemas } from './schemas'; const router = new Router(); // Standard resource update action in Koa. router.patch('/:id', async (ctx) => { const updateData = ctx.body.user; // Validation using JSON schema from API specification. await validate(schemas.UserUpdate, updateData); const user = await User.findById(ctx.params.id); await user.update(updateData); ctx.body = { success: true }; }); async function validate(schema, data) { const ajv = new Ajv(); if (!ajv.validate(schema, data)) { const err = new Error(); err.errors = ajv.errors; throw err; } }
Neste exemplo, o servidor retornará uma resposta 500 Internal Server Error
se a entrada não corresponder à especificação. Para evitar isso, podemos pegar o erro do validador e formar nossa própria resposta que conterá informações mais detalhadas sobre campos específicos que falharam na validação e seguir a especificação.
Vamos adicionar a definição para FieldsValidationError
:
# error.models.tinyspec Error {error: b, message} InvalidField {name, message} FieldsValidationError < Error {fields: InvalidField[]}
E agora vamos listá-lo como uma das possíveis respostas de endpoint:
# users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError
Essa abordagem permite escrever testes de unidade que testam a correção de cenários de erro quando dados inválidos vêm do cliente.
3. Serialização do modelo
Quase todas as estruturas de servidor modernas usam mapeamento relacional de objeto (ORM) de uma forma ou de outra. Isso significa que a maioria dos recursos que uma API usa são representados por modelos e suas instâncias e coleções.

O processo de formação das representações JSON para que essas entidades sejam enviadas na resposta é chamado de serialização .
Existem vários plugins para fazer serialização: Por exemplo, sequelize-to-json (npm), acts_as_api (rubygem) e jsonapi-rails (rubygem). Basicamente, esses plugins permitem que você forneça a lista de campos para um modelo específico que deve ser incluído no objeto JSON, além de regras adicionais. Por exemplo, você pode renomear campos e calcular seus valores dinamicamente.
Fica mais difícil quando você precisa de várias representações JSON diferentes para um modelo ou quando o objeto contém entidades aninhadas — associações. Então você começa a precisar de recursos como herança, reutilização e vinculação de serializador.
Módulos diferentes fornecem soluções diferentes, mas vamos considerar o seguinte: a especificação pode ajudar novamente? Basicamente todas as informações sobre os requisitos para representações JSON, todas as combinações de campos possíveis, incluindo entidades incorporadas, já estão nele. E isso significa que podemos escrever um único serializador automatizado.
Deixe-me apresentar o módulo pequeno sequelize-serialize (npm), que suporta fazer isso para os modelos Sequelize. Ele aceita uma instância de modelo ou uma matriz e o esquema necessário e, em seguida, itera por meio dele para construir o objeto serializado. Ele também considera todos os campos obrigatórios e usa esquemas aninhados para suas entidades associadas.
Então, digamos que precisamos retornar todos os usuários com postagens no blog, incluindo os comentários a essas postagens, da API. Vamos descrevê-lo com a seguinte especificação:
# models.tinyspec Comment {authorId: i, message} Post {topic, message, comments?: Comment[]} User {name, isAdmin: b, age?: i} UserWithPosts < User {posts: Post[]} # blogUsers.endpoints.tinyspec GET /blog/users => {users: UserWithPosts[]}
Agora podemos construir a requisição com Sequelize e retornar o objeto serializado que corresponde exatamente à especificação descrita acima:
import Router from 'koa-router'; import serialize from 'sequelize-serialize'; import { schemas } from './schemas'; const router = new Router(); router.get('/blog/users', async (ctx) => { const users = await User.findAll({ include: [{ association: User.posts, required: true, include: [Post.comments] }] }); ctx.body = serialize(users, schemas.UserWithPosts); });
Isso é quase mágico, não é?
4. Digitação Estática
Se você é legal o suficiente para usar TypeScript ou Flow, você já deve ter perguntado: “E os meus preciosos tipos estáticos?!” Com os módulos sw2dts ou swagger-to-flowtype, você pode gerar todos os tipos estáticos necessários com base em esquemas JSON e usá-los em testes, controladores e serializadores.
tinyspec -j sw2dts ./swagger.json -o Api.d.ts --namespace Api
Agora podemos usar tipos em controladores:
router.patch('/users/:id', async (ctx) => { // Specify type for request data object const userData: Api.UserUpdate = ctx.request.body.user; // Run spec validation await validate(schemas.UserUpdate, userData); // Query the database const user = await User.findById(ctx.params.id); await user.update(userData); // Return serialized result const serialized: Api.User = serialize(user, schemas.User); ctx.body = { user: serialized }; });
E testes:
it('Update user', async () => { // Static check for test input data. const updateData: Api.UserUpdate = { name: MODIFIED }; const res = await request.patch('/users/1', { user: updateData }); // Type helper for request response: const user: Api.User = res.body.user; expect(user).to.be.validWithSchema(schemas.User); expect(user).to.containSubset(updateData); });
Observe que as definições de tipo geradas podem ser usadas não apenas no projeto de API, mas também em projetos de aplicativo cliente para descrever tipos em funções que funcionam com a API. (Os desenvolvedores angulares ficarão especialmente felizes com isso.)
5. Casting Query String Tipos
Se sua API por algum motivo consumir solicitações com o tipo MIME application/x-www-form-urlencoded
em vez de application/json
, o corpo da solicitação ficará assim:
param1=value¶m2=777¶m3=false
O mesmo vale para parâmetros de consulta (por exemplo, em solicitações GET
). Nesse caso, o servidor da web não reconhecerá os tipos automaticamente: Todos os dados estarão no formato de string, portanto, após a análise, você obterá este objeto:
{ param1: 'value', param2: '777', param3: 'false' }
Nesse caso, a solicitação falhará na validação do esquema, portanto, você precisa verificar os formatos dos parâmetros corretos manualmente e convertê-los nos tipos corretos.
Como você pode imaginar, você pode fazer isso com nossos bons e velhos esquemas da especificação. Digamos que temos este endpoint e o seguinte esquema:
# posts.endpoints.tinyspec GET /posts?PostsQuery # post.models.tinyspec PostsQuery { search, limit: i, offset: i, filter: { isRead: b } }
Aqui está a aparência da solicitação para este endpoint:
GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true
Vamos escrever a função castQuery
para converter todos os parâmetros para os tipos necessários:
function castQuery(query, schema) { _.mapValues(query, (value, key) => { const { type } = schema.properties[key] || {}; if (!value || !type) { return value; } switch (type) { case 'integer': return parseInt(value, 10); case 'number': return parseFloat(value); case 'boolean': return value !== 'false'; default: return value; } }); }
Uma implementação mais completa com suporte para esquemas aninhados, matrizes e tipos null
está disponível no módulo cast-with-schema (npm). Agora vamos usá-lo em nosso código:
router.get('/posts', async (ctx) => { // Cast parameters to expected types const query = castQuery(ctx.query, schemas.PostsQuery); // Run spec validation await validate(schemas.PostsQuery, query); // Query the database const posts = await Post.search(query); // Return serialized result ctx.body = { posts: serialize(posts, schemas.Post) }; });
Observe que três das quatro linhas de código usam esquemas de especificação.
Melhores Práticas
Há uma série de práticas recomendadas que podemos seguir aqui.
Usar esquemas de criação e edição separados
Normalmente, os esquemas que descrevem as respostas do servidor são diferentes daqueles que descrevem as entradas e são usados para criar e editar modelos. Por exemplo, a lista de campos disponíveis em solicitações POST
e PATCH
deve ser estritamente limitada, e PATCH
geralmente tem todos os campos marcados como opcionais. Os esquemas que descrevem a resposta podem ser mais livres.
Quando você gera endpoints CRUDL automaticamente, o tinyspec usa os postfixes New
e Update
. Os esquemas User*
podem ser definidos da seguinte maneira:
User {id, email, name, isAdmin: b} UserNew !{email, name} UserUpdate !{email?, name?}
Tente não usar os mesmos esquemas para diferentes tipos de ação para evitar problemas de segurança acidentais devido à reutilização ou herança de esquemas mais antigos.
Siga as convenções de nomenclatura do esquema
O conteúdo dos mesmos modelos pode variar para diferentes endpoints. Use os postfixes With*
e For*
em nomes de esquema para mostrar a diferença e o propósito. No tinyspec, os modelos também podem herdar uns dos outros. Por exemplo:
User {name, surname} UserWithPhotos < User {photos: Photo[]} UserForAdmin < User {id, email, lastLoginAt: d}
Os postfixes podem ser variados e combinados. Seu nome ainda deve refletir a essência e tornar a documentação mais simples de ler.
Separando endpoints com base no tipo de cliente
Muitas vezes, o mesmo endpoint retorna dados diferentes com base no tipo de cliente ou na função do usuário que enviou a solicitação. Por exemplo, os terminais GET /users
e GET /messages
podem ser significativamente diferentes para usuários de aplicativos móveis e gerentes de back office. A alteração do nome do terminal pode ser uma sobrecarga.
Para descrever o mesmo endpoint várias vezes, você pode adicionar seu tipo entre parênteses após o caminho. Isso também facilita o uso da tag: você divide a documentação do endpoint em grupos, cada um destinado a um grupo de clientes de API específico. Por exemplo:
Mobile app: GET /users (mobile) => UserForMobile[] CRM admin panel: GET /users (admin) => UserForAdmin[]
Ferramentas de documentação da API REST
Depois de obter a especificação no formato tinyspec ou OpenAPI, você pode gerar uma documentação de boa aparência no formato HTML e publicá-la. Isso deixará os desenvolvedores que usam sua API felizes e com certeza é melhor preencher um modelo de documentação da API REST manualmente.
Além dos serviços em nuvem mencionados anteriormente, existem ferramentas CLI que convertem OpenAPI 2.0 em HTML e PDF, que podem ser implantados em qualquer hospedagem estática. aqui estão alguns exemplos:
- bootprint-openapi (npm, usado por padrão em tinyspec)
- swagger2markup-cli (jar, há um exemplo de uso, será usado em tinyspec Cloud)
- redoc-cli (npm)
- vitral (npm)
Você tem mais exemplos? Compartilhe-os nos comentários.
Infelizmente, apesar de ter sido lançado há um ano, o OpenAPI 3.0 ainda é pouco suportado e não consegui encontrar exemplos adequados de documentação baseada nele tanto em soluções em nuvem quanto em ferramentas CLI. Pela mesma razão, tinyspec ainda não suporta OpenAPI 3.0.
Publicando no GitHub
Uma das maneiras mais simples de publicar a documentação é o GitHub Pages. Apenas habilite o suporte para páginas estáticas para sua pasta /docs
nas configurações do repositório e armazene a documentação HTML nesta pasta.
Você pode adicionar o comando para gerar a documentação por meio do tinyspec ou de uma ferramenta CLI diferente em seu arquivo scripts/package.json
para atualizar a documentação automaticamente após cada confirmação:
"scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }
Integração contínua
Você pode adicionar a geração de documentação ao seu ciclo de CI e publicá-la, por exemplo, no Amazon S3 em endereços diferentes dependendo do ambiente ou da versão da API (como /docs/2.0
, /docs/stable
e /docs/staging
.)
Tinyspec Cloud
Se você gosta da sintaxe do tinyspec, pode se tornar um dos primeiros a adotar o tinyspec.cloud. Planejamos construir um serviço de nuvem baseado nele e uma CLI para implantação automatizada de documentação com uma ampla variedade de modelos e a capacidade de desenvolver modelos personalizados.
Especificação REST: Um Mito Maravilhoso
O desenvolvimento de API REST é provavelmente um dos processos mais agradáveis no desenvolvimento de serviços móveis e web modernos. Não há navegador, sistema operacional e zoológicos do tamanho de tela, e tudo está totalmente sob seu controle, ao seu alcance.
Este processo é ainda mais facilitado pelo suporte para automação e especificações atualizadas. Uma API usando as abordagens que descrevi torna-se bem estruturada, transparente e confiável.
A conclusão é que, se estamos criando um mito, por que não torná-lo um mito maravilhoso?