Creación de una API REST de Node.js/TypeScript, parte 1: Express.js

Publicado: 2022-03-11

¿Cómo escribo una API REST en Node.js?

Al crear un back-end para una API REST, Express.js suele ser la primera opción entre los marcos de trabajo de Node.js. Si bien también admite la creación de HTML estático y plantillas, en esta serie nos centraremos en el desarrollo de back-end mediante TypeScript. La API REST resultante será una que cualquier marco de front-end o servicio de back-end externo podrá consultar.

Necesitarás:

  • Conocimientos básicos de JavaScript y TypeScript
  • Conocimientos básicos de Node.js
  • Conocimiento básico de la arquitectura REST (consulte esta sección de mi artículo anterior de la API REST si es necesario)
  • Una instalación lista de Node.js (preferiblemente versión 14+)

En una terminal (o símbolo del sistema), crearemos una carpeta para el proyecto. Desde esa carpeta, ejecute npm init . Eso creará algunos de los archivos de proyecto básicos de Node.js que necesitamos.

A continuación, agregaremos el marco Express.js y algunas bibliotecas útiles:

 npm i express debug winston express-winston cors

Hay buenas razones por las que estas bibliotecas son las favoritas de los desarrolladores de Node.js:

  • debug es un módulo que usaremos para evitar llamar a console.log() mientras desarrollamos nuestra aplicación. De esta manera, podemos filtrar fácilmente las declaraciones de depuración durante la resolución de problemas. También se pueden apagar por completo en producción en lugar de tener que quitarlos manualmente.
  • winston es responsable de registrar las solicitudes en nuestra API y las respuestas (y errores) devueltas. express-winston winston integra directamente con Express.js, por lo que todo el código de registro de Winston relacionado con la API estándar ya está hecho.
  • cors es una pieza del middleware Express.js que nos permite habilitar el uso compartido de recursos entre orígenes. Sin esto, nuestra API solo se podría utilizar desde los front-end que se sirven desde exactamente el mismo subdominio que nuestro back-end.

Nuestro back-end usa estos paquetes cuando se está ejecutando. Pero también necesitamos instalar algunas dependencias de desarrollo para nuestra configuración de TypeScript. Para eso, ejecutaremos:

 npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript

Estas dependencias son necesarias para habilitar TypeScript para el propio código de nuestra aplicación, junto con los tipos usados ​​por Express.js y otras dependencias. Esto puede ahorrar mucho tiempo cuando usamos un IDE como WebStorm o VSCode al permitirnos completar algunos métodos de función automáticamente mientras codificamos.

Las dependencias finales en package.json deberían ser así:

 "dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" }

Ahora que tenemos todas las dependencias requeridas instaladas, ¡comencemos a construir nuestro propio código!

Estructura del proyecto de la API REST de TypeScript

Para este tutorial, vamos a crear solo tres archivos:

  1. ./app.ts
  2. ./common/common.routes.config.ts
  3. ./users/users.routes.config.ts

La idea detrás de las dos carpetas de la estructura del proyecto ( common y users ) es tener módulos individuales que tengan sus propias responsabilidades. En este sentido, eventualmente vamos a tener algunos o todos los siguientes para cada módulo:

  • Configuración de rutas para definir las solicitudes que nuestra API puede manejar
  • Servicios para tareas como conectarse a nuestros modelos de base de datos, realizar consultas o conectarse a servicios externos que requiera la solicitud específica
  • Middleware para ejecutar validaciones de solicitudes específicas antes de que el controlador final de una ruta maneje sus detalles
  • Modelos para definir modelos de datos que coincidan con un esquema de base de datos dado, para facilitar el almacenamiento y la recuperación de datos
  • Controladores para separar la configuración de la ruta del código que finalmente (después de cualquier middleware) procesa una solicitud de ruta, llama a las funciones de servicio anteriores si es necesario y da una respuesta al cliente

Esta estructura de carpetas proporciona un diseño de API REST básico, un punto de partida temprano para el resto de esta serie de tutoriales y suficiente para comenzar a practicar.

Un archivo de rutas comunes en TypeScript

En la carpeta common , creemos el archivo common.routes.config.ts para que tenga el siguiente aspecto:

 import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } }

La forma en que estamos creando las rutas aquí es opcional. Pero como estamos trabajando con TypeScript, nuestro escenario de rutas es una oportunidad para practicar el uso de la herencia con la palabra clave extends , como veremos en breve. En este proyecto, todos los archivos de ruta tienen el mismo comportamiento: tienen un nombre (que usaremos con fines de depuración) y acceso al objeto principal de la Application Express.js.

Ahora, podemos comenzar a crear el archivo de ruta de los usuarios. En la carpeta de users , users.routes.config.ts y comencemos a codificarlo así:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } }

Aquí, estamos importando la clase CommonRoutesConfig y extendiéndola a nuestra nueva clase, llamada UsersRoutes . Con el constructor, enviamos la aplicación (el objeto express.Application principal) y el nombre UsersRoutes al constructor de CommonRoutesConfig .

Este ejemplo es bastante simple, pero al escalar para crear varios archivos de ruta, esto nos ayudará a evitar el código duplicado.

Supongamos que quisiéramos agregar nuevas funciones en este archivo, como el registro. Podríamos agregar el campo necesario a la clase CommonRoutesConfig , y luego todas las rutas que extienden CommonRoutesConfig tendrán acceso a él.

Uso de funciones abstractas de TypeScript para una funcionalidad similar en todas las clases

¿Qué pasa si nos gustaría tener alguna funcionalidad que sea similar entre estas clases (como configurar los puntos finales de la API), pero que necesita una implementación diferente para cada clase? Una opción es usar una característica de TypeScript llamada abstracción .

Vamos a crear una función abstracta muy simple que la clase UsersRoutes (y las futuras clases de enrutamiento) heredará de CommonRoutesConfig . Digamos que queremos forzar que todas las rutas tengan una función (para que podamos llamarla desde nuestro constructor común) llamada configureRoutes() . Ahí es donde declararemos los puntos finales del recurso de cada clase de enrutamiento.

Para hacer esto, agregaremos tres cosas rápidas a common.routes.config.ts :

  1. La palabra clave abstract a nuestra línea de class , para habilitar la abstracción para esta clase.
  2. Una nueva declaración de función al final de nuestra clase, abstract configureRoutes(): express.Application; . Esto obliga a cualquier clase que extienda CommonRoutesConfig a proporcionar una implementación que coincida con esa firma; si no lo hace, el compilador de TypeScript generará un error.
  3. Una llamada a this.configureRoutes(); al final del constructor, ya que ahora podemos estar seguros de que esta función existirá.

El resultado:

 import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; }

Con eso, cualquier clase que extienda CommonRoutesConfig debe tener una función llamada configureRoutes() que devuelva un objeto express.Application . Eso significa que users.routes.config.ts necesita actualizarse:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } }

Como resumen de lo que hemos hecho:

Primero estamos importando el archivo common.routes.config , luego el módulo express . Luego definimos la clase UserRoutes , diciendo que queremos que amplíe la clase base CommonRoutesConfig , lo que implica que prometemos que implementará configureRoutes() .

Para enviar información a la clase CommonRoutesConfig , estamos usando el constructor de la clase. Espera recibir el objeto express.Application , que describiremos con mayor profundidad en el siguiente paso. Con super() , le pasamos al constructor de CommonRoutesConfig la aplicación y el nombre de nuestras rutas, que en este escenario es UsersRoutes. ( super() , a su vez, llamará a nuestra implementación de configureRoutes() .

Configuración de las rutas Express.js de los puntos finales de los usuarios

La función configureRoutes() es donde crearemos los puntos finales para los usuarios de nuestra API REST. Allí, usaremos la aplicación y sus funcionalidades de ruta desde Express.js.

La idea de usar la función app.route() es evitar la duplicación de código, lo cual es fácil ya que estamos creando una API REST con recursos bien definidos. El recurso principal para este tutorial son los usuarios . Tenemos dos casos en este escenario:

  • Cuando la persona que llama a la API desea crear un nuevo usuario o enumerar todos los usuarios existentes, el punto final inicialmente solo debe tener users al final de la ruta solicitada. (No entraremos en el filtrado de consultas, la paginación u otras consultas similares en este artículo).
  • Cuando la persona que llama quiere hacer algo específico para un registro de usuario específico, la ruta de recursos de la solicitud seguirá el patrón users/:userId .

La forma en que funciona .route() en Express.js nos permite manejar los verbos HTTP con un encadenamiento elegante. Esto se debe a que .get() , .post() , etc., todos devuelven la misma instancia de IRoute que la primera llamada .route() . La configuración final será así:

 configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // this middleware function runs before any request to /users/:userId // but it doesn't accomplish anything just yet--- // it simply passes control to the next applicable function below using next() next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; }

El código anterior permite que cualquier cliente de API REST llame al punto final de nuestros users con una POST o GET . Del mismo modo, permite que un cliente llame a nuestro punto final /users/:userId con una solicitud GET , PUT , PATCH o DELETE .

Pero para /users/:userId , también agregamos middleware genérico usando la función all() , que se ejecutará antes que cualquiera de las funciones get() , put() , patch() o delete() . Esta función será beneficiosa cuando (más adelante en la serie) creemos rutas a las que solo deben acceder usuarios autenticados.

Es posible que haya notado que en nuestra función .all() , como con cualquier pieza de middleware, tenemos tres tipos de campos: Request , Response y NextFunction .

  • La solicitud es la forma en que Express.js representa la solicitud HTTP que se manejará. Este tipo actualiza y amplía el tipo de solicitud nativo de Node.js.
  • La respuesta es igualmente cómo Express.js representa la respuesta HTTP, nuevamente extendiendo el tipo de respuesta nativa de Node.js.
  • No menos importante, NextFunction sirve como una función de devolución de llamada, lo que permite que el control pase a través de cualquier otra función de middleware. En el camino, todo el middleware compartirá los mismos objetos de solicitud y respuesta antes de que el controlador finalmente envíe una respuesta al solicitante.

Nuestro archivo de punto de entrada de Node.js, app.ts

Ahora que hemos configurado algunos esqueletos de ruta básicos, comenzaremos a configurar el punto de entrada de la aplicación. Vamos a crear el archivo app.ts en la raíz de nuestra carpeta de proyecto y comenzar con este código:

 import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug';

Solo dos de estas importaciones son nuevas en este punto del artículo:

  • http es un módulo nativo de Node.js. Es necesario para iniciar nuestra aplicación Express.js.
  • body-parser es un middleware que viene con Express.js. Analiza la solicitud (en nuestro caso, como JSON) antes de que el control vaya a nuestros propios controladores de solicitudes.

Ahora que hemos importado los archivos, comenzaremos a declarar las variables que queremos usar:

 const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app');

La función express() devuelve el objeto principal de la aplicación Express.js que pasaremos a lo largo de nuestro código, comenzando por agregarlo al objeto http.Server . (Tendremos que iniciar el http.Server después de configurar nuestra express.Application ).

Escucharemos en el puerto 3000, que TypeScript inferirá automáticamente que es un Number , en lugar de los puertos estándar 80 (HTTP) o 443 (HTTPS) porque normalmente se usarían para el front-end de una aplicación.

¿Por qué Puerto 3000?

No existe una regla de que el puerto deba ser 3000 (si no se especifica, se asignará un puerto arbitrario), pero 3000 se usa en los ejemplos de documentación tanto para Node.js como para Express.js, por lo que continuamos con la tradición aquí.

¿Puede Node.js compartir puertos con el front-end?

Todavía podemos ejecutar localmente en un puerto personalizado, incluso cuando queremos que nuestro back-end responda a las solicitudes en los puertos estándar. Esto requeriría un proxy inverso para recibir solicitudes en el puerto 80 o 443 con un dominio o subdominio específico. Luego los redirigiría a nuestro puerto interno 3000.

La matriz de routes realizará un seguimiento de nuestros archivos de rutas con fines de depuración, como veremos a continuación.

Finalmente, debugLog terminará como una función similar a console.log , pero mejor: es más fácil de ajustar porque se ajusta automáticamente a lo que queramos llamar nuestro contexto de archivo/módulo. (En este caso, lo llamamos "aplicación" cuando lo pasamos en una cadena al constructor debug() ).

Ahora, estamos listos para configurar todos nuestros módulos de middleware Express.js y las rutas de nuestra API:

 // here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // here we are adding middleware to allow cross-origin requests app.use(cors()); // here we are preparing the expressWinston logging middleware configuration, // which will automatically log all HTTP requests handled by Express.js const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, log requests as one-liners } // initialize the logger with the above configuration app.use(expressWinston.logger(loggerOptions)); // here we are adding the UserRoutes to our array, // after sending the Express.js application object to have the routes added to our app! routes.push(new UsersRoutes(app)); // this is a simple route to make sure everything is working properly const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) });

El expressWinston.logger conecta a Express.js, registrando automáticamente los detalles, a través de la misma infraestructura que la debug , para cada solicitud completa. Las opciones que le hemos pasado formatearán y colorearán cuidadosamente la salida del terminal correspondiente, con un registro más detallado (el valor predeterminado) cuando estemos en modo de depuración.

Tenga en cuenta que tenemos que definir nuestras rutas después de configurar expressWinston.logger .

Finalmente y lo más importante:

 server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); // our only exception to avoiding console.log(), because we // always want to know when the server is done starting up console.log(runningMessage); });

Esto realmente inicia nuestro servidor. Una vez que se inicia, Node.js ejecutará nuestra función de devolución de llamada, que en el modo de depuración informa los nombres de todas las rutas que hemos configurado, hasta ahora, solo UsersRoutes . Después de eso, nuestra devolución de llamada nos notifica que nuestro back-end está listo para recibir solicitudes, incluso cuando se ejecuta en modo de producción.

Actualización de package.json para transpilar TypeScript a JavaScript y ejecutar la aplicación

Ahora que tenemos nuestro esqueleto listo para ejecutarse, primero necesitamos una configuración repetitiva para habilitar la transpilación de TypeScript. Agreguemos el archivo tsconfig.json en la raíz del proyecto:

 { "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } }

Luego, solo necesitamos agregar los toques finales a package.json en forma de los siguientes scripts:

 "scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" },

El script de test es un marcador de posición que reemplazaremos más adelante en la serie.

El tsc en el script de start pertenece a TypeScript. Es responsable de transpilar nuestro código TypeScript a JavaScript, que generará en la carpeta dist . Luego, simplemente ejecutamos la versión compilada con node ./dist/app.js .

Pasamos --unhandled-rejections=strict a Node.js (incluso con Node.js v16+) porque, en la práctica, la depuración con un enfoque directo de "bloquear y mostrar la pila" es más sencillo que un registro más sofisticado con un objeto expressWinston.errorLogger . Esto suele ser cierto incluso en producción, donde es probable que dejar que Node.js continúe ejecutándose a pesar de un rechazo no controlado deje el servidor en un estado inesperado, lo que permite que ocurran errores adicionales (y más complicados).

El script de debug llama al script de start , pero primero define una variable de entorno DEBUG . Esto tiene el efecto de habilitar todas nuestras declaraciones debugLog() (además de otras similares del mismo Express.js, que usa el mismo módulo de debug que nosotros) para generar detalles útiles en el terminal, detalles que (convenientemente) están ocultos cuando se ejecutan. el servidor en modo de producción con un npm start .

Intente ejecutar npm run debug usted mismo y, luego, compárelo con npm start para ver cómo cambia la salida de la consola.

Sugerencia: puede limitar la salida de depuración a las propias declaraciones debugLog() de nuestro archivo app.ts usando DEBUG=app en lugar de DEBUG=* . El módulo de debug es generalmente bastante flexible y esta característica no es una excepción.

Los usuarios de Windows probablemente necesitarán cambiar la export a SET ya que export es la forma en que funciona en Mac y Linux. Si su proyecto necesita admitir múltiples entornos de desarrollo, el paquete cross-env proporciona una solución sencilla aquí.

Prueba del back-end de Live Express.js

Con npm run debug o npm start todavía en marcha, nuestra API REST estará lista para atender solicitudes en el puerto 3000. En este punto, podemos usar cURL, Postman, Insomnia, etc. para probar el back-end.

Dado que solo hemos creado un esqueleto para el recurso de los usuarios, podemos simplemente enviar solicitudes sin un cuerpo para ver que todo funcione como se esperaba. Por ejemplo:

 curl --request GET 'localhost:3000/users/12345'

Nuestro back-end debería devolver la respuesta GET requested for id 12345 .

En cuanto a POST ing:

 curl --request POST 'localhost:3000/users' \ --data-raw ''

Este y todos los demás tipos de solicitudes para las que construimos esqueletos se verán bastante similares.

Preparado para el desarrollo rápido de la API REST de Node.js con TypeScript

En este artículo, comenzamos a crear una API REST configurando el proyecto desde cero y sumergiéndonos en los conceptos básicos del marco Express.js. Luego, dimos nuestro primer paso para dominar TypeScript mediante la creación de un patrón con UsersRoutesConfig extendiendo CommonRoutesConfig , un patrón que reutilizaremos para el próximo artículo de esta serie. Terminamos configurando nuestro punto de entrada app.ts para usar nuestras nuevas rutas y package.json con scripts para compilar y ejecutar nuestra aplicación.

Pero incluso los conceptos básicos de una API REST creada con Express.js y TypeScript son bastante complicados. En la siguiente parte de esta serie, nos enfocamos en crear controladores adecuados para el recurso de los usuarios y profundizamos en algunos patrones útiles para servicios, middleware, controladores y modelos.

El proyecto completo está disponible en GitHub, y el código al final de este artículo se encuentra en la toptal-article-01 .