Este artículo, a pesar de su título inocente, provocó una discusión tan detallada sobre Stackoverflow que no pudimos ignorarlo. Un intento de captar la inmensidad, para hablar claramente sobre el diseño competente de la API REST, aparentemente, el autor tuvo éxito de muchas maneras, pero no del todo. En cualquier caso, esperamos competir con el original en el grado de discusión, así como en el hecho de que nos uniremos al ejército de fans de Express.
¡Disfruta leyendo!
Las API REST son uno de los tipos más comunes de servicios web disponibles en la actualidad. Con su ayuda, varios clientes, incluidas las aplicaciones del navegador, pueden intercambiar información con el servidor a través de la API REST.
Por lo tanto, es muy importante diseñar la API REST correctamente para que no tenga problemas en el camino. Considere la seguridad, el rendimiento y la usabilidad de la API desde la perspectiva del consumidor.
De lo contrario, provocaremos problemas a los clientes que utilicen nuestras API, lo cual es frustrante y molesto. Si no seguimos las convenciones comunes, entonces solo confundiremos a quienes mantendrán nuestra API, así como a los clientes, ya que la arquitectura será diferente a la que todos esperan ver.
Este artículo verá cómo diseñar API REST de tal manera que sean simples y comprensibles para todos los que las consumen. Aseguraremos su durabilidad, seguridad y rapidez, ya que los datos transferidos a los clientes a través de dicha API pueden ser confidenciales.
Dado que existen muchas razones y opciones para que una aplicación de red falle, debemos asegurarnos de que los errores en cualquier API REST se manejen con elegancia y estén acompañados de códigos HTTP estándar para ayudar al consumidor a lidiar con el problema.
Acepte JSON y devuelva JSON en respuesta
Las API REST deben aceptar JSON para la carga útil de la solicitud y también enviar respuestas JSON. JSON es un estándar de transferencia de datos. Casi cualquier tecnología de red está adaptada para usarlo: JavaScript tiene métodos integrados para codificar y decodificar JSON, ya sea a través de la API Fetch o mediante otro cliente HTTP. Las tecnologías del lado del servidor utilizan bibliotecas para decodificar JSON con poca o ninguna intervención de su parte.
Hay otras formas de transferir datos. XML en sí mismo no es muy compatible con los marcos; por lo general, debe convertir los datos a un formato más conveniente, que generalmente es JSON. En el lado del cliente, especialmente en el navegador, no es tan fácil manejar estos datos. Tiene que hacer mucho trabajo adicional solo para garantizar la transferencia normal de datos.
Los formularios son convenientes para transferir datos, especialmente si vamos a transferir archivos. Pero para transferir información en forma de texto y numérica, puede prescindir de los formularios, ya que la mayoría de los marcos permiten que JSON se envíe sin procesamiento adicional, simplemente tome los datos del lado del cliente. Esta es la forma más sencilla de lidiar con ellos.
Para asegurarse de que el cliente interprete el JSON recibido de nuestra API REST exactamente como JSON, establezca
Content-Type
el encabezado de respuesta en un valor application/json
después de que se realice la solicitud. Muchos marcos de aplicaciones del lado del servidor establecen el encabezado de respuesta automáticamente. Algunos clientes HTTP miran Content-Type
el encabezado de respuesta y analizan los datos de acuerdo con el formato especificado allí.
La única excepción ocurre cuando intentamos enviar y recibir archivos que se transfieren entre el cliente y el servidor. Luego, debe procesar los archivos recibidos como respuesta y enviar los datos del formulario desde el cliente al servidor. Pero este es un tema para otro artículo.
También debemos asegurarnos de que JSON sea la respuesta de nuestros puntos finales. Muchos marcos de servidor tienen esta función incorporada.
Tomemos un ejemplo de una API que acepta una carga útil JSON. Este ejemplo usa el marco de backend Express para Node.js. Podemos usar un programa
body-parser
para analizar el cuerpo de la solicitud JSON como un middleware y luego llamar a un método res.json
con el objeto que queremos devolver como respuesta JSON. Esto se hace así:
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()
analiza la cadena del cuerpo de la solicitud en JSON, la convierte en un objeto JavaScript y luego asigna el resultado al objeto req.body
.
Establezca el encabezado Content-Type en la respuesta a un valor
application/json; charset=utf-8
sin ningún cambio. El método que se muestra arriba es aplicable a la mayoría de los otros marcos de backend.
Usamos nombres para rutas a puntos finales, no verbos
Los nombres de las rutas a los puntos finales no deben ser verbos, sino nombres. Este nombre representa el objeto del punto final que recuperamos desde allí o que manipulamos.
El caso es que el nombre de nuestro método de solicitud HTTP ya contiene un verbo. Poner verbos en los nombres de las rutas al punto final de la API no es práctico; además, el nombre resulta innecesariamente largo y no contiene ninguna información valiosa. Los verbos elegidos por el desarrollador se pueden poner simplemente en función de su capricho. Por ejemplo, algunas personas prefieren la opción 'obtener' y otras prefieren 'recuperar', por lo que es mejor limitarse al verbo HTTP GET familiar que le dice qué está haciendo el punto final.
La acción debe especificarse en el nombre del método HTTP de la solicitud que estamos realizando. Los métodos más comunes contienen los verbos GET, POST, PUT y DELETE.
GET recupera recursos. POST envía nuevos datos al servidor. PUT actualiza los datos existentes. DELETE borra datos. Cada uno de estos verbos corresponde a una de las operaciones del grupo CRUD .
Teniendo en cuenta los dos principios discutidos anteriormente, para recibir nuevos artículos, debemos crear rutas de la forma GET
/articles/
. De manera similar, usamos POST /articles/
para actualizar un nuevo artículo, PUT /articles/:id
para actualizar un artículo con el dado id
. El método DELETE está /articles/:id
diseñado para eliminar un artículo con un ID determinado.
/articles
Es un recurso de API REST. Por ejemplo, puede utilizar Express para hacer lo siguiente con los artículos:
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'));
En el código anterior, hemos definido puntos finales para manipular artículos. Como puede ver, no hay verbos en los nombres de las rutas. Solo nombres. Los verbos se utilizan solo en los nombres de los métodos HTTP.
Los puntos finales POST, PUT y DELETE aceptan un cuerpo de solicitud JSON y también devuelven una respuesta JSON, incluido un punto final GET.
Las colecciones se llaman sustantivos en plural
Las colecciones deben nombrarse con sustantivos en plural. No es frecuente que necesitemos tomar solo un elemento de una colección, por lo que debemos ser consistentes y usar sustantivos en plural en los nombres de las colecciones.
El plural también se utiliza para mantener la coherencia con las convenciones de nombres en las bases de datos. Como regla general, una tabla contiene no uno, sino muchos registros, y la tabla se nombra en consecuencia.
Cuando trabajamos con un punto final,
/articles
usamos plural al nombrar todos los puntos finales.
Anidar recursos al trabajar con objetos jerárquicos
La ruta de los puntos finales que se ocupan de los recursos anidados debe estructurarse de la siguiente manera: agregue el recurso anidado como un nombre de ruta después del nombre del recurso principal.
Debe asegurarse de que el anidamiento de recursos en el código sea exactamente el mismo que el anidamiento de información en nuestras tablas de base de datos. De lo contrario, es posible la confusión.
Por ejemplo, si queremos recibir comentarios para un nuevo artículo en un determinado punto final, debemos adjuntar la ruta / comentarios al final de la ruta
/articles
. En este caso, se supone que consideramos la entidad de comentarios como una entidad secundaria article
en nuestra base de datos.
Por ejemplo, puede hacer esto con el siguiente código en 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'));
En el código anterior, puede usar el método GET en la ruta
'/articles/:articleId/comments'
. Recibimos comentarios comments
sobre el artículo que coincide articleId
y luego lo devolvemos en respuesta. Agregamos 'comments'
después del segmento de ruta '/articles/:articleId'
para indicar que se trata de un recurso secundario /articles
.
Esto tiene sentido ya que los comentarios son objetos secundarios
articles
y se asume que cada artículo tiene su propio conjunto de comentarios. De lo contrario, esta estructura puede resultar confusa para el usuario, ya que normalmente se utiliza para acceder a objetos secundarios. El mismo principio se aplica cuando se trabaja con puntos finales POST, PUT y DELETE. Todos usan la misma estructura de anidamiento al construir nombres de ruta.
Manejo ordenado de errores y devuelve códigos de error estándar
Para evitar confusiones cuando se produce un error en la API, maneje los errores con cuidado y devuelva los códigos de respuesta HTTP que indiquen qué error ocurrió. Esto proporciona a los mantenedores de API suficiente información para comprender el problema. Es inaceptable que los errores bloqueen el sistema, por lo tanto, no se pueden dejar sin procesar y el consumidor de API debe ocuparse de dicho procesamiento.
Los códigos de error HTTP más comunes son:
- 400 Solicitud incorrecta: indica que la entrada recibida del cliente no se pudo validar.
- 401 No autorizado: significa que el usuario no ha iniciado sesión y, por lo tanto, no tiene permiso para acceder al recurso. Normalmente, este código se emite cuando el usuario no está autenticado.
- 403 Prohibido: indica que el usuario está autenticado pero no está autorizado para acceder al recurso.
- 404 No encontrado: significa que no se encontró el recurso.
- El error 500 Internal server es un error del servidor y probablemente no debería arrojarse explícitamente.
- 502 Puerta de enlace incorrecta: indica un mensaje de respuesta no válido del servidor ascendente.
- 503 Servicio no disponible: significa que sucedió algo inesperado en el lado del servidor, por ejemplo, sobrecarga del servidor, falla de algunos elementos del sistema, etc.
Debes emitir exactamente los códigos que corresponden al error que impidió nuestra aplicación. Por ejemplo, si queremos rechazar datos que vinieron como una carga útil de solicitud, entonces, de acuerdo con las reglas de la API Express, debemos devolver un 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'));
En el código anterior, tenemos en la matriz de usuarios una lista de usuarios existentes que tienen correo electrónico conocido.
Además, si intentamos transferir una carga útil con un valor
email
ya presente en los usuarios, obtenemos una respuesta con un código de 400 y un mensaje 'User already exists'
que indica que dicho usuario ya existe. Con esta información, el usuario puede mejorar: reemplace la dirección de correo electrónico con la que aún no está en la lista.
Los códigos de error siempre deben ir acompañados de mensajes que sean lo suficientemente informativos para corregir el error, pero no tan detallados como para que esta información pueda ser utilizada por atacantes que pretendan robar nuestra información o bloquear el sistema.
Siempre que nuestra API no se apague correctamente, debemos manejar la falla con cuidado enviando información de error para que sea más fácil para el usuario corregir la situación.
Permitir ordenar, filtrar y paginar datos
Las bases detrás de la API REST pueden crecer mucho. A veces hay tantos datos que es imposible recuperarlos todos de una vez, ya que esto ralentizará el sistema o incluso lo hará caer. Por lo tanto, necesitamos una forma de filtrar elementos.
También necesitamos formas de paginar los datos (paginación) para que solo devolvamos unos pocos resultados a la vez. No queremos tomar demasiado tiempo en recursos tratando de obtener todos los datos solicitados a la vez.
Tanto el filtrado como la paginación de datos pueden mejorar el rendimiento al reducir el uso de recursos del servidor. Cuantos más datos se acumulen en la base de datos, más importantes se vuelven estas dos posibilidades.
A continuación, se muestra un pequeño ejemplo en el que la API puede aceptar una cadena de consulta con varios parámetros. Filtremos los elementos por sus 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'));
En el código anterior, tenemos una variable
req.query
que nos permite obtener parámetros de solicitud. Luego, podemos extraer valores de propiedad mediante la desestructuración de parámetros de consulta individuales en variables; JavaScript tiene una sintaxis especial para esto.
Finalmente, aplicamos un filtro en cada valor de parámetro de consulta para encontrar los elementos que queremos devolver.
Una vez hecho esto, devolvemos resultados como respuesta. Por lo tanto, al realizar una solicitud GET a la siguiente ruta con una cadena de consulta:
/employees?lastName=Smith&age=30
Obtenemos:
[
{
"firstName": "John",
"lastName": "Smith",
"age": 30
}
]
como respuesta devuelta porque el filtrado estaba activado
lastName
y age
.
Del mismo modo, puede aceptar el parámetro de consulta de la página y devolver un grupo de registros que ocupan posiciones desde
(page - 1) * 20
hasta page * 20
.
También en la cadena de consulta, puede especificar los campos por los que se realizará la clasificación. En este caso, podemos ordenarlos por estos campos separados. Por ejemplo, es posible que necesitemos extraer una cadena de consulta de una URL como esta:
http://example.com/articles?sort=+author,-datepublished
Donde
+
significa "arriba" y –
"abajo". Así que ordenamos por nombre de autor alfabéticamente y por fecha de publicación, de la más reciente a la más antigua.
Adhiérase a prácticas de seguridad probadas
La comunicación entre el cliente y el servidor debe ser principalmente privada, ya que a menudo enviamos y recibimos información confidencial. Por lo tanto, el uso de SSL / TLS para la seguridad es imprescindible.
El certificado SSL no es tan difícil de cargar en el servidor, y el certificado en sí es gratis o muy barato. No hay razón para renunciar a permitir que nuestras API REST se comuniquen a través de canales seguros en lugar de canales abiertos.
Una persona no debe tener acceso a más información de la que solicitó. Por ejemplo, un usuario común no debería tener acceso a la información de otro usuario. Además, no debería poder ver los datos de los administradores.
Para promover el principio de privilegio mínimo, debe implementar la comprobación de roles para un rol específico o proporcionar más granularidad de roles para cada usuario.
Si decidimos agrupar a los usuarios en varios roles, entonces los roles deben contar con derechos de acceso que garanticen que todo lo que el usuario necesita se haga y nada más. Si prescribimos con mayor detalle los derechos de acceso a cada oportunidad que se le brinda al usuario, entonces debemos asegurarnos de que el administrador pueda otorgar estas capacidades a cualquier usuario, o eliminar estas capacidades. Además, debe agregar algunos roles predefinidos que se pueden aplicar a un grupo de usuarios para que no tenga que configurar manualmente los derechos necesarios para cada usuario.
Almacene los datos en caché para mejorar el rendimiento
El almacenamiento en caché se puede agregar para devolver datos de una memoria caché local en lugar de recuperar algunos datos de la base de datos siempre que los usuarios lo soliciten. La ventaja del almacenamiento en caché es que los usuarios pueden recuperar datos más rápido. Sin embargo, estos datos pueden estar desactualizados. Esto también puede estar plagado de problemas al depurar en entornos de producción, cuando algo salió mal y continuamos mirando los datos antiguos.
Hay una variedad de opciones de almacenamiento en caché disponibles, como Redis , almacenamiento en caché en memoria y más. Puede cambiar la forma en que se almacenan en caché los datos según sea necesario.
Por ejemplo, Express proporciona middleware
apicache
para agregar capacidad de almacenamiento en caché a su aplicación sin una configuración complicada. El almacenamiento en caché en memoria simple se puede agregar al servidor de esta manera:
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'));
El código anterior simplemente se refiere a
apicache
con apicache.middleware
, lo que resulta en:
app.use(cache('5 minutes'))
y eso es suficiente para aplicar el almacenamiento en caché en toda la aplicación. Almacenamos en caché, por ejemplo, todos los resultados en cinco minutos. Posteriormente, este valor se puede ajustar en función de lo que necesitemos.
Control de versiones de API
Debemos tener diferentes versiones de la API en caso de que les hagamos cambios que puedan afectar al cliente. El control de versiones se puede hacer de forma semántica (por ejemplo, 2.0.6 significa que la versión principal es la 2, y este es el sexto parche). Este principio ahora se acepta en la mayoría de las aplicaciones.
De esta manera, puede retirar gradualmente los puntos finales antiguos en lugar de obligar a todos a cambiar simultáneamente a la nueva API. Puede guardar la versión v1 para aquellos que no quieran cambiar nada y proporcionar la versión v2 con todas sus nuevas características para aquellos que estén listos para actualizar. Esto es especialmente importante en el contexto de las API públicas. Deben tener una versión para no romper las aplicaciones de terceros que utilizan nuestras API.
El control de versiones generalmente se realiza agregando
/v1/
,/v2/
, etc., agregado al comienzo de la ruta de la API.
Por ejemplo, así es como se hace en 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'));
Simplemente agregamos el número de versión al comienzo de la ruta que conduce al punto final.
Conclusión
La conclusión clave del diseño de API REST de alta calidad es mantener la coherencia siguiendo los estándares y convenciones de la web. Los códigos de estado JSON, SSL / TLS y HTTP son imprescindibles en la web hoy en día.
El rendimiento es igualmente importante. Puede aumentarlo sin devolver demasiados datos a la vez. Además, puede utilizar el almacenamiento en caché para evitar pedir los mismos datos una y otra vez.
Las rutas de los extremos deben tener un nombre coherente. Debe utilizar sustantivos en sus nombres, ya que los verbos están presentes en los nombres de los métodos HTTP. Las rutas de recursos anidadas deben seguir la ruta de recursos principal. Deben comunicar lo que recibimos o manipulamos, para que no tengamos que consultar adicionalmente la documentación para entender qué está sucediendo.