API con Node.js, Express y Prisma

Capa de presentación

Así como dividir la aplicación en módulos es una buena práctica, así también lo es dividir la aplicación en capas, cada una independiente y responsable de una cosa a la vez. La capa de presentación será la capa más externa y visible de la aplicación y es la que accederá el cliente que utilice la API, su responsabilidad será gestionar las peticiones y respuestas a través de la interfaz del protocolo HTTP pero no de los datos.

Router y routes en Express

Una de los principales componentes de express después de los middlewares es el Router que permite definir rutas (routes), y como se mencionó anteriormente, los endpoints se definirán a partir de una ruta, un verbo HTTP y un callback (middleware).

Definir las rutas

Anteriormente, definimos un middleware que procese la petición a la ruta raíz de la API con el verbo GET:

app.get('/', (req, res, next) => {
  res.json({
    message: 'Welcome to the API',
  });
});

...

De la misma manera, podemos definir todas las rutas que se necesiten, por ejemplo:

app.get('/api/todos', (req, res, next) => {
  res.json({
    message: 'GET all todos',
  });
});

...

Dentro de cada middleware se realiza el procesamiento de la petición, pero imaginemos por un momento que se definen 10 rutas más con diferentes verbos HTTP para diferentes operaciones relacionadas con este recurso y por algún motivo toca cambiar el nombre del recurso de /api/todos a /api/tasks/, tocaría modificar todas las rutas una por una, lo cual no es muy dinámico e inclusive se puede incurrir en errores. Para esto Express tiene un objeto llamado route el cual permite establecer un punto de montaje para el recurso (por ejemplo todos) y adjuntarle todas las operaciones con los verbos que se necesiten, entonces tomando nuevamente el ejemplo anterior solo tocaría modificar el recurso en el punto de montaje y no en todas las rutas.

Cambiemos la definición que se había escrito anteriormente para obtener todos los ítems del recurso introduciendo el método route de express:

app.route('/api/todos')
  .get((req, res, next) => {
    res.json({
    message: 'GET all todos',
  });
});

...

Se generó un cambio significativo en la estructura de la definición, el punto de montaje se establece con el método route y se anexa en este caso el método get (GET) con el middleware correspondiente, pero también se le puede anexar diferentes métodos como: POST, PUT, DELETE, etc. Lo interesante es que solo dependen del primer parámetro: el nombre de la ruta.

Es aconsejable organizar los recursos, por lo tanto, creemos un directorio que contenga todos los directorios y archivos relacionados con la API:

mkdir -p app/api
touch app/api/index.js

En el nuevo archivo creado se mueve la definición de la ruta del archivo app/index.js y se convierte en un módulo con el siguiente contenido:

// app/api/index.js

import { Router } from 'express';

// eslint-disable-next-line new-cap
export const router = Router();

router.route('/todos').get((req, res, next) => {
  res.json({
    message: 'GET all todos',
  });
});

En el fragmento de código anterior se importa solamente el Router, no toda la librería de express, por lo tanto, el Router permite crear una definición independiente a la aplicación de express que se vaya a utilizar.

Modificamos el archivo (app/index.js) importando el módulo definido anteriormente y reemplazando la definición de la ruta por el punto de montaje:

// app/index.js

import express from 'express';

import { router as api } from './api/index.js';

export const app = express();

// Setup router and routes
app.use('/api', api);

// No route found handler
...

Se han realizado unos cambios muy importantes, en cuanto a organización, responsabilidad y modularidad:

  1. Se creó un Router para manejar todas las operaciones relacionadas con el recurso de todos, pero estas rutas a su vez son independientes del prefijo que se vaya a usar, es decir, ya no tiene marcado en el código antes de cada ruta el prefijo /api.
  2. Se agregó el punto de montaje /api, pero esta vez le hemos adjuntado el Router que contiene todas las operaciones del recurso /todos (por el momento), es decir, que al final para acceder al recurso sigue siendo /api/todos.

Esto brinda una enorme ventaja a la hora de organizar los recursos y renombrarlos.

Al revisar la dirección localhost:3000/api/todos se obtiene el siguiente resultado:

{
  "message": "Get all todos"
}

Más información:

Manejo de versiones de la API

Es normal que un REST API tenga diferentes versiones en el tiempo, pues surgen nuevas funcionalidades o cambios importantes, pero se debe asegurar cuando se vayan a realizar cambios no dañar la versión actual de la API que ya está publicada, ya que la pueden estar utilizando otros sistemas o clientes. Esto no sucede con otros paradigmas actuales como GraphQL que en vez de rutas maneja un lenguaje donde el cliente es el que estructura la respuesta del servidor y realmente no existen rutas estáticas definidas.

Más información:

Es recomendable siempre versionar la API desde la creación de la misma, creamos un nuevo directorio con este fin y se mueve el archivo que habíamos creado antes:

mkdir -p app/api/v1
mv app/api/index.js app/api/v1/index.js

Actualizamos el punto de montaje de la API en el archivo principal de la aplicación:

// app/index.js

import express from 'express';

import { router as api } from './api/v1/index.js';

export const app = express();

// Setup router and routes
app.use('/api/v1', api);
app.use('/api', api);

...

Este cambio nos permite usar dos puntos de montaje empleando la misma definición de la API. Normalmente, la última versión de la API se usa bajo el prefijo /api solamente, y si el cliente quiere utilizar alguna versión específica, le coloca el prefijo /api/v1. Aprovechando al máximo la independencia que provee el Router de Express.

Si en el futuro se lanza la versión 2 de la API, solo tendríamos que crear el directorio llamado v2 (al mismo nivel que v1) y asociarlo con el punto de montaje/api/v2 y a su vez /api, garantizando así que los clientes que desean acceder a la versión “legacy” (Versión 1) de la API sería exclusivamente con el prefijo/api/v1.

Estructura de la API

Hasta el momento se ha organizado un poco mejor la API, pero no es suficiente, ya que la ruta de /todos y todas las que creemos a partir de allí, nuevamente están atadas al prefijo del nombre del recurso, por lo tanto, una buena práctica es crear un directorio por cada recurso, pero también un archivo para con cada una de los diferentes componentes, el primero sería el módulo de las rutas. Se crea el directorio y archivo correspondiente:

mkdir -p app/api/v1/todos
touch app/api/v1/todos/routes.js

Se copia el contenido de app/api/v1/index.js al nuevo archivo creado app/api/v1/todos/routes.js pero con un ligero cambio, reemplazar el punto de montaje de/todos a /

// app/api/v1/todos/routes.js

import { Router } from 'express';

// eslint-disable-next-line new-cap
export const router = Router();

router.get('/', (req, res, next) => {
  res.json({
    message: 'GET all todos',
  });
});

Ahora se reescribe el archivo principal de la API y se establece el punto de montaje para la ruta de /todos:

// app/api/v1/index.js

import { Router } from 'express';

import { router as todos } from './todos/routes.js';

// eslint-disable-next-line new-cap
export const router = Router();

router.use('/todos', todos);

Este es un punto importante de granularidad de los montajes de las rutas y ahora estas son independientes de sus puntos de montaje, por ejemplo si el día de mañana si se necesita cambiar el nombre del recurso de /todos por /tasks sencillamente se hace en el archivo principal de la API y las rutas seguirán funcionando sin ningún problema, pues nuevamente ya no dependen de prefijos.

Todavía no es suficiente con la organización, debemos separar los diferentes componentes, entonces en el recurso de todos, se realiza el siguiente cambio:

// app/api/v1/todos/index.js

import { Router } from 'express';

// eslint-disable-next-line new-cap
export const router = Router();

router
  .route('/')
  .get('/', (req, res, next) => {})
  .post('/', (req, res, next) => {});

Como se puede observar sobre una misma ruta se puede adjuntar diferentes verbos, en este caso GET y POST y hemos reducido el middleware a su mínima expresión.

A continuación, se establece el contrato mínimo REST de la API para el recurso de todos, con sus respectivas rutas y verbos asociados:

// app/api/v1/todos/index.js

import { Router } from 'express';

// eslint-disable-next-line new-cap
export const router = Router();

/*
 * /api/todos/ POST      - CREATE
 * /api/todos/ GET       - READ ALL
 * /api/todos/:id GET    - READ ONE
 * /api/todos/:id PUT    - UPDATE
 * /api/todos/:id DELETE - DELETE
 */

router
  .route('/')
  .post((req, res, next) => {})
  .get((req, res, next) => {});

router
  .route('/:id')
  .get((req, res, next) => {})
  .put((req, res, next) => {})
  .delete((req, res, next) => {});

I> La cadena :id es un parámetro dinámico, el cual se verá con más detalle en la próxima sección.

Ahora las rutas para el recurso todos tienen al menos el contrato de REST API básico, pero aún falta un paso final para terminar de organizar los middlewares asociados a estas.

I> Es muy importante nombrar que las rutas también soportan expresiones regulares y glob o wild card (*), así se pueden construir muchísimas rutas dependiendo la necesidad.

Controladores

Se introduce un término conceptual que es: el controlador, el cual contendrá las acciones asociadas a cada verbo de la ruta. Esto corresponde al patrón de diseño Modelo - Vista - Controlador, pero en este caso nuestras vistas son las rutas.

Creamos el archivo de los controladores:

touch app/api/v1/todos/controller.js

Y colocamos el siguiente contenido en el archivo creado previamente:

// app/api/v1/todos/controller.js

export const create = (req, res, next) => {
  res.json({});
};

export const all = (req, res, next) => {
  res.json({});
};

export const read = (req, res, next) => {
  res.json({});
};

export const update = (req, res, next) => {
  res.json({});
};

export const remove = (req, res, next) => {
  res.json({});
};

Es importante resaltar que:

  • Las acciones no necesariamente se deben llamar como los verbos, por ejemplo all, el cual se va a utilizar para obtener el listado de todas las tareas, cuando se realice una petición con el verbo GET a la raíz de la entidad todos (/api/v1/todos)
  • Se están exportando individualmente cada una de las acciones.
  • Se está dejando por ahora una respuesta genérica en cada acción que es un objeto JSON vacío.

Finalmente, se enlazan las acciones con las rutas, e inclusive si fuese el caso, se puede utilizar la misma acción para diferentes verbos en diferentes rutas:

// app/api/v1/todos/index.js

import { Router } from 'express';

import * as controller from './controller.js';

// eslint-disable-next-line new-cap
export const router = Router();

/*
 * /api/todos/ POST      - CREATE
 * /api/todos/ GET       - READ ALL
 * /api/todos/:id GET    - READ ONE
 * /api/todos/:id PUT    - UPDATE
 * /api/todos/:id DELETE - DELETE
 */

router.route('/').post(controller.create).get(controller.all);

router.route('/:id').get(controller.read).put(controller.update).delete(controller.remove);

Con esta estructura se ha llegado al nivel de organización deseado e inclusive las acciones en los controladores pueden ser utilizadas por otras rutas de otros recursos, lo cual es muy conveniente y lo utilizaremos más adelante.

Procesar parámetros de las rutas

Anteriormente, se estableció en el contrato de REST cuál sería la ruta para obtener una tarea: api/todos/:id. En Express se pueden crear parámetros dinámicos en las rutas, esto se conoce como params, en este caso :id es uno de ellos.

Funciona de la siguiente manera: al enviar la petición, si la ruta coincide con el prefijo /api/todos/todo lo que este después de este prefijo quedará almacenado en el parámetro con nombre id.

Modifiquemos la acción en el controlador de tareas para obtener este parámetro que nos ayudará a encontrar una tarea:

// /app/api/v1/todos/controller.js
export const read = async (req, res, next) => {
  const { params = {} } = req;
  const { id = '' } = params;

  res.json({
    data: {
      id
    }
  });
};

...

En el código anterior Express extrae automáticamente el parámetro id de la ruta de la petición que coincidió con la ruta dinámica que está declarada, por defecto Express deja todos los parámetros enviados por la URL en un objeto llamado params dentro del objeto de la petición (req).

Algo muy importante a tener en cuenta es que no estrictamente el id tienen que ser números, de hecho tomara todo lo que concuerde con la ruta:

  • /api/todos/abc1, entonces el parámetro id tendrá el valor de abc1
  • /api/todos/filter, entonces el parámetro id tendrá el valor de filter

Adicionalmente, se pueden declarar todos los parámetros dinámicos que se necesitan en una ruta, por ejemplo:

/api/todos/:year/:month/:day

Capturar parámetros por la URL

Anteriormente, se mencionó que los recursos se deben paginar, esta y otras operaciones se pueden realizar enviando parámetros específicos anexados a la URL mediante los query params.

En cada petición Express automáticamente reconoce, interpreta y almacena cada uno de estos parámetros en un objeto en la petición llamado query, en el siguiente ejemplo se realizará la siguiente petición:

GET /api/v1/todos?limit=10&offset=0

Por lo tanto, se modifica el siguiente archivo:

// app/api/v1/todos/controller.js

export const create = (req, res, next) => {
  res.json({});
};

export const all = (req, res, next) => {
  const { query = {} } = req;
  const { limit = 10, offset = 0 } = query;

  res.json({
    meta: {
      limit,
      offset,
    },
  });
};

...

En el código anterior se extrae el objeto query del objeto de la petición req, adicionalmente se extraen los query params que se necesitan como limit y offset, siempre es bueno darle valores por defecto a las variables que se crean, finalmente se devuelve estos valores en la respuesta.

Más información:

Capturar datos enviados como objetos JSON

En las peticiones los datos enviados por los métodos POST, PUT o PATCH son enviados en el cuerpo (body) de la petición, por lo tanto, no se pueden obtener por params o query, sino por body, esta tarea no es realizada automáticamente por Express como las anteriores, se debe indicar con un middleware:

// app/index.js

import express from 'express';

import { router as apiV1 } from './api/v1/index.js';

export const app = express();

// Parse JSON
app.use(express.json());

...

De ahora en adelante este primer middleware procesará cada petición y si contiene datos que fueron enviados en el body de la petición tipo JSON, los formateará y colocará como una propiedad llamada body en el objeto req, modifiquemos el controlador para ver su uso:

// app/api/v1/todos/controller.js

export const create = (req, res, next) => {
  const { body = {} } = req;

  res.json({
    data: body,
  });
};

...

Más información: