5 cosas que nunca ha hecho con una especificación REST

Publicado: 2022-03-11

La mayoría de los desarrolladores front-end y back-end se han ocupado antes de las especificaciones REST y las API RESTful. Pero no todas las API RESTful se crean de la misma manera. De hecho, rara vez son RESTful en absoluto...

¿Qué es una API RESTful?

es un mito

Si crees que tu proyecto tiene una API RESTful, lo más probable es que estés equivocado. La idea detrás de una API RESTful es desarrollar de una manera que siga todas las reglas y limitaciones arquitectónicas que se describen en la especificación REST. Sin embargo, siendo realistas, esto es en gran medida imposible en la práctica.

Por un lado, REST contiene demasiadas definiciones borrosas y ambiguas. Por ejemplo, en la práctica, algunos términos del método HTTP y los diccionarios de códigos de estado se utilizan de forma contraria a los fines previstos o no se utilizan en absoluto.

Por otro lado, el desarrollo REST crea demasiadas limitaciones. Por ejemplo, el uso de recursos atómicos no es óptimo para las API del mundo real que se usan en aplicaciones móviles. La denegación total del almacenamiento de datos entre solicitudes esencialmente prohíbe el mecanismo de "sesión de usuario" que se ve en casi todas partes.

Pero espera, ¡no es tan malo!

¿Para qué necesita una especificación de API REST?

A pesar de estos inconvenientes, con un enfoque sensato, REST sigue siendo un concepto increíble para crear API realmente geniales. Estas API pueden ser consistentes y tener una estructura clara, buena documentación y una alta cobertura de pruebas unitarias. Puede lograr todo esto con una especificación API de alta calidad.

Por lo general, una especificación de API REST está asociada con su documentación . A diferencia de una especificación, una descripción formal de su API, la documentación debe ser legible por humanos: por ejemplo, leída por los desarrolladores de la aplicación móvil o web que usa su API.

Una descripción correcta de la API no se trata solo de escribir bien la documentación de la API. En este artículo quiero compartir ejemplos de cómo puedes:

  • Haga que sus pruebas unitarias sean más simples y confiables;
  • Configure el preprocesamiento y la validación de la entrada del usuario;
  • Automatice la serialización y garantice la consistencia de la respuesta; e incluso
  • Disfrute de los beneficios de la escritura estática.

Pero primero, comencemos con una introducción al mundo de la especificación de API.

API abierta

OpenAPI es actualmente el formato más aceptado para las especificaciones de API REST. La especificación está escrita en un solo archivo en formato JSON o YAML que consta de tres secciones:

  1. Un encabezado con el nombre, la descripción y la versión de la API, así como cualquier información adicional.
  2. Descripciones de todos los recursos, incluidos los identificadores, los métodos HTTP, todos los parámetros de entrada, los códigos de respuesta y los tipos de datos del cuerpo, con enlaces a las definiciones.
  3. Todas las definiciones que se pueden usar para entrada o salida, en formato JSON Schema (que, eso sí, también se puede representar en YAML).

La estructura de OpenAPI tiene dos inconvenientes importantes: es demasiado compleja y, a veces, redundante. Un proyecto pequeño puede tener una especificación JSON de miles de líneas. Mantener este archivo manualmente se vuelve imposible. Esta es una amenaza importante para la idea de mantener la especificación actualizada mientras se desarrolla la API.

Hay varios editores que le permiten describir una API y generar resultados de OpenAPI. Los servicios adicionales y las soluciones en la nube basadas en ellos incluyen Swagger, Apiary, Stoplight, Restlet y muchos otros.

Sin embargo, estos servicios fueron un inconveniente para mí debido a la complejidad de la edición rápida de especificaciones y la alineación con los cambios de código. Además, la lista de características dependía de un servicio específico. Por ejemplo, crear pruebas unitarias completas basadas en las herramientas de un servicio en la nube es casi imposible. La generación de código y los endpoints simulados, si bien parecen ser prácticos, resultan en su mayoría inútiles en la práctica. Esto se debe principalmente a que el comportamiento del punto final generalmente depende de varias cosas, como los permisos de usuario y los parámetros de entrada, que pueden ser obvios para un arquitecto de API, pero no son fáciles de generar automáticamente a partir de una especificación de OpenAPI.

Tinyspec

En este artículo, usaré ejemplos basados ​​en mi propio formato de definición de API REST, tinyspec . Las definiciones consisten en pequeños archivos con una sintaxis intuitiva. Describen puntos finales y modelos de datos que se utilizan en un proyecto. Los archivos se almacenan junto al código, lo que proporciona una referencia rápida y la posibilidad de editarlos durante la escritura del código. Tinyspec se compila automáticamente en un formato OpenAPI completo que se puede usar de inmediato en su proyecto.

También usaré ejemplos de Node.js (Koa, Express) y Ruby on Rails, pero las prácticas que demostraré son aplicables a la mayoría de las tecnologías, incluidas Python, PHP y Java.

Donde las especificaciones API son geniales

Ahora que tenemos algunos antecedentes, podemos explorar cómo aprovechar al máximo una API especificada correctamente.

1. Pruebas unitarias de punto final

El desarrollo basado en el comportamiento (BDD) es ideal para desarrollar API REST. Es mejor escribir pruebas unitarias no para clases, modelos o controladores separados, sino para puntos finales particulares. En cada prueba, emula una solicitud HTTP real y verifica la respuesta del servidor. Para Node.js existen los paquetes supertest y chai-http para emular solicitudes, y para Ruby on Rails existe airborne.

Digamos que tenemos un esquema de User y un punto final GET /users que devuelve todos los usuarios. Aquí hay una sintaxis de tinyspec que describe esto:

 # user.models.tinyspec User {name, isAdmin: b, age?: i} # users.endpoints.tinyspec GET /users => {users: User[]}

Y así es como escribiríamos la prueba correspondiente:

Nodo.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 on Rails

 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

Cuando ya tenemos la especificación que describe las respuestas del servidor, podemos simplificar la prueba y simplemente verificar si la respuesta sigue la especificación. Podemos usar modelos tinyspec, cada uno de los cuales se puede transformar en una especificación OpenAPI que sigue el formato JSON Schema.

Cualquier objeto literal en JS (o Hash en Ruby, dict en Python, matriz asociativa en PHP e incluso Map en Java) se puede validar para cumplir con el esquema JSON. Incluso hay complementos apropiados para probar marcos, por ejemplo, jest-ajv (npm), chai-ajv-json-schema (npm) y json_matchers para RSpec (rubygem).

Antes de usar esquemas, importémoslos al proyecto. Primero, genere el archivo openapi.json basado en la especificación tinyspec (puede hacerlo automáticamente antes de cada ejecución de prueba):

 tinyspec -j -o openapi.json

Nodo.js

Ahora puede usar el JSON generado en el proyecto y obtener la clave de definitions de él. Esta clave contiene todos los esquemas JSON. Los esquemas pueden contener referencias cruzadas ( $ref ), por lo que si tiene esquemas incrustados (por ejemplo, Blog {posts: Post[]} ), debe desenvolverlos para usarlos en la validación. Para ello, utilizaremos 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 on Rails

El módulo json_matchers sabe cómo manejar las referencias $ref , pero requiere archivos de esquema separados en la ubicación especificada, por lo que primero deberá dividir el archivo swagger.json en varios archivos más pequeños:

 # ./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

Así es como se verá la prueba:

 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

Escribir pruebas de esta manera es increíblemente conveniente. Especialmente si su IDE admite la ejecución de pruebas y la depuración (por ejemplo, WebStorm, RubyMine y Visual Studio). De esta manera, puede evitar el uso de otro software y todo el ciclo de desarrollo de la API se limita a tres pasos:

  1. Diseño de la especificación en archivos tinyspec.
  2. Escribir un conjunto completo de pruebas para puntos finales agregados/editados.
  3. Implementar el código que satisfaga las pruebas.

2. Validación de datos de entrada

OpenAPI describe no solo el formato de respuesta, sino también los datos de entrada. Esto le permite validar los datos enviados por el usuario en tiempo de ejecución y garantizar actualizaciones de bases de datos consistentes y seguras.

Digamos que tenemos la siguiente especificación, que describe el parcheo de un registro de usuario y todos los campos disponibles que pueden actualizarse:

 # user.models.tinyspec UserUpdate !{name?, age?: i} # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => {success: b}

Anteriormente, exploramos los complementos para la validación en prueba, pero para casos más generales, existen los módulos de validación ajv (npm) y json-schema (rubygem). Usémoslos para escribir un controlador con validación:

Node.js (Koa)

Este es un ejemplo de Koa, el sucesor de Express, pero el código equivalente de Express sería similar.

 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; } }

En este ejemplo, el servidor devuelve una respuesta 500 Internal Server Error si la entrada no coincide con la especificación. Para evitar esto, podemos detectar el error del validador y formar nuestra propia respuesta que contendrá información más detallada sobre campos específicos que fallaron en la validación y seguir la especificación.

Agreguemos la definición de FieldsValidationError :

 # error.models.tinyspec Error {error: b, message} InvalidField {name, message} FieldsValidationError < Error {fields: InvalidField[]}

Y ahora enumerémoslo como una de las posibles respuestas de punto final:

 # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError

Este enfoque le permite escribir pruebas unitarias que prueban la exactitud de los escenarios de error cuando los datos no válidos provienen del cliente.

3. Serialización del modelo

Casi todos los marcos de servidores modernos utilizan el mapeo relacional de objetos (ORM) de una forma u otra. Esto significa que la mayoría de los recursos que utiliza una API están representados por modelos y sus instancias y colecciones.

El proceso de formación de las representaciones JSON para que estas entidades se envíen en la respuesta se denomina serialización .

Hay una serie de complementos para realizar la serialización: por ejemplo, sequelize-to-json (npm), acts_as_api (rubygem) y jsonapi-rails (rubygem). Básicamente, estos complementos le permiten proporcionar la lista de campos para un modelo específico que debe incluirse en el objeto JSON, así como reglas adicionales. Por ejemplo, puede cambiar el nombre de los campos y calcular sus valores dinámicamente.

Se vuelve más difícil cuando necesita varias representaciones JSON diferentes para un modelo, o cuando el objeto contiene entidades anidadas: asociaciones. Luego, comienza a necesitar funciones como herencia, reutilización y enlace de serializador.

Diferentes módulos brindan diferentes soluciones, pero consideremos esto: ¿Puede la especificación ayudar de nuevo? Básicamente, toda la información sobre los requisitos para las representaciones JSON, todas las combinaciones de campos posibles, incluidas las entidades incrustadas, ya están incluidas. Y esto significa que podemos escribir un solo serializador automatizado.

Permítanme presentarles el pequeño módulo Sequelize-Serialize (npm), que admite hacer esto para los modelos Sequelize. Acepta una instancia de modelo o una matriz, y el esquema requerido, y luego itera a través de él para construir el objeto serializado. También da cuenta de todos los campos obligatorios y utiliza esquemas anidados para sus entidades asociadas.

Entonces, digamos que necesitamos devolver todos los usuarios con publicaciones en el blog, incluidos los comentarios a estas publicaciones, desde la API. Vamos a describirlo con la siguiente especificación:

 # 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[]}

Ahora podemos construir la solicitud con Sequelize y devolver el objeto serializado que corresponde exactamente a la especificación descrita anteriormente:

 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); });

Esto es casi mágico, ¿no?

4. Escritura estática

Si eres lo suficientemente bueno como para usar TypeScript o Flow, es posible que ya te hayas preguntado: "¿Qué pasa con mis preciosos tipos estáticos?" Con los módulos sw2dts o swagger-to-flowtype, puede generar todos los tipos estáticos necesarios basados ​​en esquemas JSON y usarlos en pruebas, controladores y serializadores.

 tinyspec -j sw2dts ./swagger.json -o Api.d.ts --namespace Api

Ahora podemos usar tipos en los 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 }; });

Y pruebas:

 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); });

Tenga en cuenta que las definiciones de tipo generadas se pueden usar no solo en el proyecto de API, sino también en proyectos de aplicaciones de cliente para describir tipos en funciones que funcionan con la API. (Los desarrolladores de Angular estarán especialmente felices con esto).

5. Tipos de cadena de consulta de conversión

Si su API, por alguna razón, consume solicitudes con el tipo MIME application/x-www-form-urlencoded en lugar de application/json , el cuerpo de la solicitud se verá así:

 param1=value&param2=777&param3=false

Lo mismo ocurre con los parámetros de consulta (por ejemplo, en las solicitudes GET ). En este caso, el servidor web no reconocerá automáticamente los tipos: todos los datos estarán en formato de cadena, por lo que después del análisis obtendrá este objeto:

 { param1: 'value', param2: '777', param3: 'false' }

En este caso, la solicitud fallará en la validación del esquema, por lo que debe verificar manualmente los formatos de los parámetros correctos y convertirlos en los tipos correctos.

Como puede adivinar, puede hacerlo con nuestros buenos esquemas antiguos de la especificación. Digamos que tenemos este punto final y el siguiente esquema:

 # posts.endpoints.tinyspec GET /posts?PostsQuery # post.models.tinyspec PostsQuery { search, limit: i, offset: i, filter: { isRead: b } }

Así es como se ve la solicitud a este punto final:

 GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true

Escribamos la función castQuery para convertir todos los parámetros a los tipos requeridos:

 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; } }); }

Una implementación más completa con soporte para esquemas anidados, arreglos y tipos null está disponible en el módulo cast-with-schema (npm). Ahora vamos a usarlo en nuestro 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) }; });

Tenga en cuenta que tres de las cuatro líneas de código utilizan esquemas de especificación.

Mejores prácticas

Hay una serie de mejores prácticas que podemos seguir aquí.

Usar esquemas de creación y edición separados

Por lo general, los esquemas que describen las respuestas del servidor son diferentes de los que describen las entradas y se utilizan para crear y editar modelos. Por ejemplo, la lista de campos disponibles en las POST y PATCH debe estar estrictamente limitada y, por lo general, PATCH tiene todos los campos marcados como opcionales. Los esquemas que describen la respuesta pueden tener una forma más libre.

Cuando genera puntos finales CRUDL automáticamente, tinyspec usa los sufijos New y Update . Los esquemas User* se pueden definir de la siguiente manera:

 User {id, email, name, isAdmin: b} UserNew !{email, name} UserUpdate !{email?, name?}

Intente no usar los mismos esquemas para diferentes tipos de acciones para evitar problemas de seguridad accidentales debido a la reutilización o herencia de esquemas más antiguos.

Siga las convenciones de nomenclatura del esquema

El contenido de los mismos modelos puede variar para diferentes puntos finales. Use los sufijos With* y For* en los nombres de esquema para mostrar la diferencia y el propósito. En tinyspec, los modelos también pueden heredar unos de otros. Por ejemplo:

 User {name, surname} UserWithPhotos < User {photos: Photo[]} UserForAdmin < User {id, email, lastLoginAt: d}

Los sufijos se pueden variar y combinar. Su nombre aún debe reflejar la esencia y hacer que la documentación sea más fácil de leer.

Separación de puntos finales según el tipo de cliente

A menudo, el mismo punto final devuelve datos diferentes según el tipo de cliente o la función del usuario que envió la solicitud. Por ejemplo, los puntos finales GET /users y GET /messages pueden ser significativamente diferentes para los usuarios de aplicaciones móviles y los administradores de back office. El cambio del nombre del punto final puede ser una sobrecarga.

Para describir el mismo punto final varias veces, puede agregar su tipo entre paréntesis después de la ruta. Esto también facilita el uso de etiquetas: divide la documentación del punto final en grupos, cada uno de los cuales está destinado a un grupo de clientes API específico. Por ejemplo:

 Mobile app: GET /users (mobile) => UserForMobile[] CRM admin panel: GET /users (admin) => UserForAdmin[]

Herramientas de documentación de la API REST

Después de obtener la especificación en formato tinyspec u OpenAPI, puede generar una documentación atractiva en formato HTML y publicarla. Esto hará felices a los desarrolladores que usan su API, y seguro que es mejor que llenar una plantilla de documentación de API REST a mano.

Además de los servicios en la nube mencionados anteriormente, existen herramientas CLI que convierten OpenAPI 2.0 a HTML y PDF, que se pueden implementar en cualquier alojamiento estático. Aquí hay unos ejemplos:

  • bootprint-openapi (npm, usado por defecto en tinyspec)
  • swagger2markup-cli (jar, hay un ejemplo de uso, se usará en tinyspec Cloud)
  • redoc-cli (npm)
  • espinillas anchas (npm)

¿Tienes más ejemplos? Compártelas en los comentarios.

Lamentablemente, a pesar de que se lanzó hace un año, OpenAPI 3.0 todavía tiene un soporte deficiente y no pude encontrar ejemplos adecuados de documentación basada en él tanto en soluciones en la nube como en herramientas CLI. Por la misma razón, tinyspec aún no es compatible con OpenAPI 3.0.

Publicación en GitHub

Una de las formas más sencillas de publicar la documentación son las páginas de GitHub. Simplemente habilite el soporte para páginas estáticas para su carpeta /docs en la configuración del repositorio y almacene la documentación HTML en esta carpeta.

Alojar la documentación HTML de su especificación REST desde su carpeta /docs a través de GitHub Pages.

Puede agregar el comando para generar documentación a través de tinyspec o una herramienta CLI diferente en su archivo scripts/package.json para actualizar la documentación automáticamente después de cada confirmación:

 "scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }

Integración continua

Puede agregar la generación de documentación a su ciclo de CI y publicarla, por ejemplo, en Amazon S3 con diferentes direcciones según el entorno o la versión de la API (como /docs/2.0 , /docs/stable y /docs/staging ).

Nube diminuta

Si le gusta la sintaxis de tinyspec, puede convertirse en uno de los primeros en adoptar tinyspec.cloud. Planeamos crear un servicio en la nube basado en él y una CLI para la implementación automatizada de documentación con una amplia variedad de plantillas y la capacidad de desarrollar plantillas personalizadas.

Especificación REST: un mito maravilloso

El desarrollo de API REST es probablemente uno de los procesos más agradables en el desarrollo de servicios web y móviles modernos. No hay navegador, sistema operativo ni zoológicos del tamaño de una pantalla, y todo está totalmente bajo su control, al alcance de su mano.

Este proceso se facilita aún más gracias a la compatibilidad con la automatización y las especificaciones actualizadas. Una API que utiliza los enfoques que he descrito se vuelve bien estructurada, transparente y confiable.

La conclusión es, si estamos haciendo un mito, ¿por qué no convertirlo en un mito maravilloso?

Relacionado: ActiveResource.js ORM: Creación de un potente SDK de JavaScript para su API JSON, rápido