Primeros pasos con una pila de JavaScript Express y ES6+
Publicado: 2022-03-10Este artículo es la segunda parte de una serie, con la primera parte aquí, que proporcionó información básica y (con suerte) intuitiva sobre Node.js, ES6+ JavaScript, funciones de devolución de llamada, funciones de flecha, API, el protocolo HTTP, JSON, MongoDB y más.
En este artículo, nos basaremos en las habilidades que obtuvimos en el anterior, aprendiendo cómo implementar e implementar una base de datos MongoDB para almacenar información de la lista de libros del usuario, crear una API con Node.js y el marco de la aplicación web Express para exponer esa base de datos. y realizar operaciones CRUD sobre él, y más. En el camino, analizaremos la desestructuración de objetos ES6, la taquigrafía de objetos ES6, la sintaxis Async/Await, el operador de propagación y veremos brevemente CORS, la política del mismo origen y más.
En un artículo posterior, refactorizaremos nuestra base de código para separar las preocupaciones utilizando una arquitectura de tres capas y logrando la inversión de control a través de la inyección de dependencia, realizaremos seguridad y control de acceso basados en JSON Web Token y Firebase Authentication, aprenda cómo almacene contraseñas y emplee AWS Simple Storage Service para almacenar avatares de usuarios con búferes y secuencias de Node.js, mientras utiliza PostgreSQL para la persistencia de datos. En el camino, reescribiremos nuestra base de código desde cero en TypeScript para examinar los conceptos clásicos de programación orientada a objetos (como polimorfismo, herencia, composición, etc.) e incluso patrones de diseño como fábricas y adaptadores.
Una palabra de advertencia
Hay un problema con la mayoría de los artículos que discuten Node.js hoy en día. La mayoría de ellos, no todos, no van más allá de mostrar cómo configurar Express Routing, integrar Mongoose y quizás utilizar JSON Web Token Authentication. El problema es que no hablan de arquitectura, mejores prácticas de seguridad, principios de codificación limpios, cumplimiento de ACID, bases de datos relacionales, quinta forma normal, el teorema CAP o transacciones. Se supone que sabe todo lo que está por venir, o que no construirá proyectos lo suficientemente grandes o populares como para garantizar ese conocimiento mencionado anteriormente.
Parece que hay algunos tipos diferentes de desarrolladores de Node; entre otros, algunos son nuevos en la programación en general y otros provienen de una larga historia de desarrollo empresarial con C# y .NET Framework o Java Spring Framework. La mayoría de los artículos atienden al primer grupo.
En este artículo, voy a hacer exactamente lo que acabo de decir que muchos artículos están haciendo, pero en un artículo de seguimiento, vamos a refactorizar nuestra base de código por completo, permitiéndome explicar principios como Inyección de dependencia, Three- Arquitectura de capas (controlador/servicio/repositorio), mapeo de datos y registro activo, patrones de diseño, pruebas de unidad, integración y mutación, principios SOLID, unidad de trabajo, codificación contra interfaces, mejores prácticas de seguridad como HSTS, CSRF, NoSQL e inyección SQL Prevención, y así sucesivamente. También migraremos de MongoDB a PostgreSQL, utilizando el generador de consultas simple Knex en lugar de un ORM, lo que nos permitirá construir nuestra propia infraestructura de acceso a datos y conocer de cerca el lenguaje de consulta estructurado, los diferentes tipos de relaciones (One- a uno, muchos a muchos, etc.), y más. Este artículo, por lo tanto, debería atraer a los principiantes, pero los siguientes deberían atender a los desarrolladores intermedios que buscan mejorar su arquitectura.
En este, solo nos vamos a preocupar por la persistencia de los datos del libro. No nos encargaremos de la autenticación de usuarios, el hashing de contraseñas, la arquitectura ni nada complejo por el estilo. Todo eso vendrá en los próximos y futuros artículos. Por ahora, y muy básicamente, construiremos un método para permitir que un cliente se comunique con nuestro servidor web a través del protocolo HTTP para guardar la información del libro en una base de datos.
Nota : intencionalmente lo he mantenido extremadamente simple y quizás no tan práctico aquí porque este artículo, en sí mismo, es extremadamente largo, ya que me he tomado la libertad de desviarme para discutir temas complementarios. Por lo tanto, mejoraremos progresivamente la calidad y la complejidad de la API en esta serie, pero nuevamente, dado que estoy considerando esto como una de sus primeras introducciones a Express, intencionalmente mantendré las cosas extremadamente simples.
- Destrucción de objetos ES6
- Taquigrafía de objetos ES6
- Operador de propagación ES6 (...)
- Subiendo...
Destrucción de objetos ES6
ES6 Object Destructuring, o Destructuring Assignment Syntax, es un método mediante el cual extraer o desempaquetar valores de matrices u objetos en sus propias variables. Comenzaremos con las propiedades del objeto y luego discutiremos los elementos de la matriz.
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // Log properties: console.log('Name:', person.name); console.log('Occupation:', person.occupation);
Tal operación es bastante primitiva, pero puede ser algo complicada considerando que tenemos que seguir haciendo referencia a person.something
en todas partes. Supongamos que hubiera otros 10 lugares a lo largo de nuestro código donde tuviéramos que hacer eso; se volvería bastante arduo bastante rápido. Un método de brevedad sería asignar estos valores a sus propias variables.
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; const personName = person.name; const personOccupation = person.occupation; // Log properties: console.log('Name:', personName); console.log('Occupation:', personOccupation);
Tal vez esto parezca razonable, pero ¿y si tuviéramos otras 10 propiedades anidadas en el objeto person
también? Serían muchas líneas innecesarias solo para asignar valores a las variables, momento en el que estamos en peligro porque si las propiedades del objeto se modifican, nuestras variables no reflejarán ese cambio (recuerde, solo las referencias al objeto son inmutables con la asignación const
, no las propiedades del objeto), así que básicamente, ya no podemos mantener el "estado" (y estoy usando esa palabra libremente) sincronizado. Pasar por referencia vs pasar por valor podría entrar en juego aquí, pero no quiero alejarme demasiado del alcance de esta sección.
ES6 Object Destructing básicamente nos permite hacer esto:
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // This is new. It's called Object Destructuring. const { name, occupation } = person; // Log properties: console.log('Name:', name); console.log('Occupation:', occupation);
No estamos creando un nuevo objeto/objeto literal, estamos desempaquetando el name
y las propiedades de occupation
del objeto original y colocándolos en sus propias variables del mismo nombre. Los nombres que usamos tienen que coincidir con los nombres de las propiedades que deseamos extraer.
De nuevo, la sintaxis const { a, b } = someObject;
dice específicamente que esperamos que exista alguna propiedad a
y alguna propiedad b
dentro de someObject
(es decir, someObject
podría ser { a: 'dataA', b: 'dataB' }
, por ejemplo) y que queremos colocar cualquiera que sean los valores de esas claves/propiedades dentro de las variables const
del mismo nombre. Es por eso que la sintaxis anterior nos proporcionaría dos variables const a = someObject.a
y const b = someObject.b
.
Lo que eso significa es que hay dos lados en la Destrucción de Objetos. El lado "Plantilla" y el lado "Fuente", donde el lado const { a, b }
(el lado izquierdo) es la plantilla y el lado someObject
(el lado derecho) es el lado fuente , lo cual tiene sentido — estamos definiendo una estructura o "plantilla" a la izquierda que refleja los datos en el lado "fuente".
Una vez más, solo para dejar esto claro, aquí hay algunos ejemplos:
// ----- Destructure from Object Variable with const ----- // const objOne = { a: 'dataA', b: 'dataB' }; // Destructure const { a, b } = objOne; console.log(a); // dataA console.log(b); // dataB // ----- Destructure from Object Variable with let ----- // let objTwo = { c: 'dataC', d: 'dataD' }; // Destructure let { c, d } = objTwo; console.log(c); // dataC console.log(d); // dataD // Destructure from Object Literal with const ----- // const { e, f } = { e: 'dataE', f: 'dataF' }; // <-- Destructure console.log(e); // dataE console.log(f); // dataF // Destructure from Object Literal with let ----- // let { g, h } = { g: 'dataG', h: 'dataH' }; // <-- Destructure console.log(g); // dataG console.log(h); // dataH
En el caso de propiedades anidadas, refleje la misma estructura en su asignación de destrucción:
const person = { name: 'Richard P. Feynman', occupation: { type: 'Theoretical Physicist', location: { lat: 1, lng: 2 } } }; // Attempt one: const { name, occupation } = person; console.log(name); // Richard P. Feynman console.log(occupation); // The entire `occupation` object. // Attempt two: const { occupation: { type, location } } = person; console.log(type); // Theoretical Physicist console.log(location) // The entire `location` object. // Attempt three: const { occupation: { location: { lat, lng } } } = person; console.log(lat); // 1 console.log(lng); // 2
Como puede ver, las propiedades que decida sacar son opcionales, y para desempaquetar las propiedades anidadas, simplemente refleje la estructura del objeto original (la fuente) en el lado de la plantilla de su sintaxis de desestructuración. Si intenta desestructurar una propiedad que no existe en el objeto original, ese valor no estará definido.
Además, podemos desestructurar una variable sin declararla primero (asignación sin declaración) usando la siguiente sintaxis:
let name, occupation; const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; ;({ name, occupation } = person); console.log(name); // Richard P. Feynman console.log(occupation); // Theoretical Physicist
Precedemos la expresión con un punto y coma para asegurarnos de que no creamos accidentalmente una IIFE (Expresión de función invocada inmediatamente) con una función en una línea anterior (si existe tal función), y los paréntesis alrededor de la declaración de asignación son necesarios para evita que JavaScript trate tu lado izquierdo (plantilla) como un bloque.
Existe un caso de uso muy común de desestructuración dentro de los argumentos de función:
const config = { baseUrl: '<baseURL>', awsBucket: '<bucket>', secret: '<secret-key>' // <- Make this an env var. }; // Destructures `baseUrl` and `awsBucket` off `config`. const performOperation = ({ baseUrl, awsBucket }) => { fetch(baseUrl).then(() => console.log('Done')); console.log(awsBucket); // <bucket> }; performOperation(config);
Como puede ver, podríamos haber usado la sintaxis de desestructuración normal a la que estamos acostumbrados ahora dentro de la función, así:
const config = { baseUrl: '<baseURL>', awsBucket: '<bucket>', secret: '<secret-key>' // <- Make this an env var. }; const performOperation = someConfig => { const { baseUrl, awsBucket } = someConfig; fetch(baseUrl).then(() => console.log('Done')); console.log(awsBucket); // <bucket> }; performOperation(config);
Pero colocar dicha sintaxis dentro de la firma de la función realiza la desestructuración automáticamente y nos ahorra una línea.
Un caso de uso del mundo real de esto es React Functional Components for props
:
import React from 'react'; // Destructure `titleText` and `secondaryText` from `props`. export default ({ titleText, secondaryText }) => ( <div> <h1>{titleText}</h1> <h3>{secondaryText}</h3> </div> );
Opuesto a:
import React from 'react'; export default props => ( <div> <h1>{props.titleText}</h1> <h3>{props.secondaryText}</h3> </div> );
En ambos casos, también podemos establecer valores predeterminados para las propiedades:
const personOne = { name: 'User One', password: 'BCrypt Hash' }; const personTwo = { password: 'BCrypt Hash' }; const createUser = ({ name = 'Anonymous', password }) => { if (!password) throw new Error('InvalidArgumentException'); console.log(name); console.log(password); return { id: Math.random().toString(36) // <--- Should follow RFC 4122 Spec in real app. .substring(2, 15) + Math.random() .toString(36).substring(2, 15), name: name, // <-- We'll discuss this next. password: password // <-- We'll discuss this next. }; } createUser(personOne); // User One, BCrypt Hash createUser(personTwo); // Anonymous, BCrypt Hash
Como puede ver, en caso de que ese name
no esté presente cuando se desestructura, le proporcionamos un valor predeterminado. También podemos hacer esto con la sintaxis anterior:
const { a, b, c = 'Default' } = { a: 'dataA', b: 'dataB' }; console.log(a); // dataA console.log(b); // dataB console.log(c); // Default
Las matrices también se pueden desestructurar:
const myArr = [4, 3]; // Destructuring happens here. const [valOne, valTwo] = myArr; console.log(valOne); // 4 console.log(valTwo); // 3 // ----- Destructuring without assignment: ----- // let a, b; // Destructuring happens here. ;([a, b] = [10, 2]); console.log(a + b); // 12
Una razón práctica para la desestructuración de arreglos ocurre con React Hooks. (Y hay muchas otras razones, solo estoy usando React como ejemplo).
import React, { useState } from "react"; export default () => { const [buttonText, setButtonText] = useState("Default"); return ( <button onClick={() => setButtonText("Toggled")}> {buttonText} </button> ); }
Observe que useState
se está desestructurando fuera de la exportación, y las funciones/valores de la matriz se están desestructurando fuera del useState
. Nuevamente, no se preocupe si lo anterior no tiene sentido, tendría que entender React, y simplemente lo estoy usando como ejemplo.
Si bien hay más en la desestructuración de objetos ES6, cubriré un tema más aquí: Destructuring Renaming, que es útil para evitar colisiones de alcance o sombras variables, etc. Supongamos que queremos desestructurar una propiedad llamada name
de un objeto llamado person
, pero ya hay una variable con el nombre de name
en el alcance. Podemos renombrar sobre la marcha con dos puntos:
// JS Destructuring Naming Collision Example: const name = 'Jamie Corkhill'; const person = { name: 'Alan Turing' }; // Rename `name` from `person` to `personName` after destructuring. const { name: personName } = person; console.log(name); // Jamie Corkhill <-- As expected. console.log(personName); // Alan Turing <-- Variable was renamed.
Finalmente, también podemos establecer valores predeterminados con el cambio de nombre:
const name = 'Jamie Corkhill'; const person = { location: 'New York City, United States' }; const { name: personName = 'Anonymous', location } = person; console.log(name); // Jamie Corkhill console.log(personName); // Anonymous console.log(location); // New York City, United States
Como puede ver, en este caso, el name
de la person
( person.name
) se renombrará a personName
y se establecerá en el valor predeterminado de Anonymous
si no existe.
Y, por supuesto, lo mismo se puede realizar en las firmas de funciones:
const personOne = { name: 'User One', password: 'BCrypt Hash' }; const personTwo = { password: 'BCrypt Hash' }; const createUser = ({ name: personName = 'Anonymous', password }) => { if (!password) throw new Error('InvalidArgumentException'); console.log(personName); console.log(password); return { id: Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15), name: personName, password: password // <-- We'll discuss this next. }; } createUser(personOne); // User One, BCrypt Hash createUser(personTwo); // Anonymous, BCrypt Hash
Taquigrafía de objetos ES6
Suponga que tiene la siguiente fábrica: (cubriremos las fábricas más adelante)
const createPersonFactory = (name, location, position) => ({ name: name, location: location, position: position });
Se podría usar esta fábrica para crear un objeto de person
, de la siguiente manera. Además, tenga en cuenta que la fábrica devuelve implícitamente un objeto, evidente por los paréntesis alrededor de los corchetes de la función de flecha.
const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person); // { ... }
Eso es lo que ya sabemos de la sintaxis literal de objetos ES5. Observe, sin embargo, en la función de fábrica, que el valor de cada propiedad es el mismo nombre que el identificador de propiedad (clave) en sí. Es decir, location: location
o name: name
. Resultó que eso era algo bastante común entre los desarrolladores de JS.
Con la sintaxis abreviada de ES6, podemos lograr el mismo resultado reescribiendo la fábrica de la siguiente manera:
const createPersonFactory = (name, location, position) => ({ name, location, position }); const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person);
Produciendo la salida:
{ name: 'Jamie', location: 'Texas', position: 'Developer' }
Es importante darse cuenta de que solo podemos usar esta abreviatura cuando el objeto que deseamos crear se crea dinámicamente en función de las variables, donde los nombres de las variables son los mismos que los nombres de las propiedades a las que queremos asignar las variables.
Esta misma sintaxis funciona con valores de objeto:
const createPersonFactory = (name, location, position, extra) => ({ name, location, position, extra // <- right here. }); const extra = { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] }; const person = createPersonFactory('Jamie', 'Texas', 'Developer', extra); console.log(person);
Produciendo la salida:
{ name: 'Jamie', location: 'Texas', position: 'Developer', extra: { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] } }
Como ejemplo final, esto también funciona con objetos literales:
const id = '314159265358979'; const name = 'Archimedes of Syracuse'; const location = 'Syracuse'; const greatMathematician = { id, name, location };
Operador de propagación ES6 (…)
El operador de propagación nos permite hacer una variedad de cosas, algunas de las cuales discutiremos aquí.
En primer lugar, podemos extender las propiedades de un objeto a otro objeto:
const myObjOne = { a: 'a', b: 'b' }; const myObjTwo = { ...myObjOne }:
Esto tiene el efecto de colocar todas las propiedades de myObjOne
en myObjTwo
, de modo que myObjTwo
ahora es { a: 'a', b: 'b' }
. Podemos usar este método para anular propiedades anteriores. Supongamos que un usuario quiere actualizar su cuenta:
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
Lo mismo se puede realizar con arreglos:
const apollo13Astronauts = ['Jim', 'Jack', 'Fred']; const apollo11Astronauts = ['Neil', 'Buz', 'Michael']; const unionOfAstronauts = [...apollo13Astronauts, ...apollo11Astronauts]; console.log(unionOfAstronauts); // ['Jim', 'Jack', 'Fred', 'Neil', 'Buz, 'Michael'];
Observe aquí que creamos una unión de ambos conjuntos (matrices) al distribuir las matrices en una nueva matriz.
Hay mucho más en el operador Rest/Spread, pero está fuera del alcance de este artículo. Se puede usar para obtener múltiples argumentos para una función, por ejemplo. Si desea obtener más información, consulte la documentación de MDN aquí.
ES6 Asíncrono/Espera
Async/Await es una sintaxis para aliviar el dolor del encadenamiento de promesas.
La palabra clave reservada await
le permite "esperar" el cumplimiento de una promesa, pero solo se puede usar en funciones marcadas con la palabra clave async
. Supongamos que tengo una función que devuelve una promesa. En una nueva función async
, puedo await
el resultado de esa promesa en lugar de usar .then
y .catch
.
// Returns a promise. const myFunctionThatReturnsAPromise = () => { return new Promise((resolve, reject) => { setTimeout(() => resolve('Hello'), 3000); }); } const myAsyncFunction = async () => { const promiseResolutionResult = await myFunctionThatReturnsAPromise(); console.log(promiseResolutionResult); }; // Writes the log statement after three seconds. myAsyncFunction();
Hay algunas cosas a tener en cuenta aquí. Cuando usamos await
en una función async
, solo el valor resuelto entra en la variable del lado izquierdo. Si la función rechaza, es un error que debemos detectar, como veremos en un momento. Además, cualquier función marcada como async
, de forma predeterminada, devolverá una promesa.
Supongamos que necesito hacer dos llamadas a la API, una con la respuesta de la anterior. Usando promesas y encadenamiento de promesas, puede hacerlo de esta manera:
const makeAPICall = route => new Promise((resolve, reject) => { console.log(route) resolve(route); }); const main = () => { makeAPICall('/whatever') .then(response => makeAPICall(response + ' second call')) .then(response => console.log(response + ' logged')) .catch(err => console.error(err)) }; main(); // Result: /* /whatever /whatever second call /whatever second call logged */
Lo que sucede aquí es que primero llamamos a makeAPICall
pasándole /whatever
, que se registra la primera vez. La promesa se resuelve con ese valor. Luego llamamos a makeAPICall
nuevamente, pasándole /whatever second call
, que se registra, y nuevamente, la promesa se resuelve con ese nuevo valor. Finalmente, tomamos ese nuevo valor /whatever second call
con la que acaba de resolver la promesa, y lo registramos nosotros mismos en el registro final, agregando logged
al final. Si esto no tiene sentido, debería considerar el encadenamiento de promesas.
Usando async
/ await
, podemos refactorizar lo siguiente:
const main = async () => { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); };
Esto es lo que sucederá. Toda la función dejará de ejecutarse en la primera declaración de await
hasta que se resuelva la promesa de la primera llamada a makeAPICall
, luego de la resolución, el valor resuelto se colocará en resultOne
. Cuando eso suceda, la función se moverá a la segunda declaración de await
, deteniéndose nuevamente allí por la duración de la liquidación de la promesa. Cuando se resuelve la promesa, el resultado de la resolución se colocará en resultTwo
. Si la idea de la ejecución de funciones suena como un bloqueo, no temas, sigue siendo asíncrona, y discutiré por qué en un minuto.
Esto solo representa el camino "feliz". En el caso de que una de las promesas se rechace, podemos detectarlo con try/catch, ya que si la promesa se rechaza, se generará un error, que será el error con el que se rechazó la promesa.
const main = async () => { try { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); } catch (e) { console.log(e) } };
Como dije antes, cualquier función declarada async
devolverá una promesa. Por lo tanto, si desea llamar a una función asíncrona desde otra función, puede usar promesas normales o await
si declara la función de llamada async
. Sin embargo, si desea llamar a una función async
desde el código de nivel superior y esperar su resultado, entonces deberá usar .then
y .catch
.
Por ejemplo:
const returnNumberOne = async () => 1; returnNumberOne().then(value => console.log(value)); // 1
O bien, podría usar una expresión de función invocada inmediatamente (IIFE):
(async () => { const value = await returnNumberOne(); console.log(value); // 1 })();
Cuando usa await
en una función async
, la ejecución de la función se detendrá en esa declaración de espera hasta que se establezca la promesa. Sin embargo, todas las demás funciones son libres de continuar con la ejecución, por lo que no se asignan recursos de CPU adicionales ni se bloquea el subproceso. Lo diré nuevamente: las operaciones en esa función específica en ese momento específico se detendrán hasta que se cumpla la promesa, pero todas las demás funciones son gratuitas. Considere un servidor web HTTP: por solicitud, todas las funciones son gratuitas para todos los usuarios al mismo tiempo que se realizan las solicitudes, solo que la sintaxis async/await proporcionará la ilusión de que una operación es síncrona y bloqueante para hacer promete que es más fácil trabajar con él, pero nuevamente, todo seguirá siendo agradable y asíncrono.
Esto no es todo lo que hay que hacer con async
/ await
, pero debería ayudarlo a comprender los principios básicos.
OOP clásico Fábricas
Ahora vamos a dejar el mundo de JavaScript y entrar en el mundo de Java . Puede llegar un momento en que el proceso de creación de un objeto (en este caso, una instancia de una clase, de nuevo, Java) sea bastante complejo o cuando deseemos que se produzcan diferentes objetos en función de una serie de parámetros. Un ejemplo podría ser una función que crea diferentes objetos de error. Una fábrica es un patrón de diseño común en la Programación Orientada a Objetos y es básicamente una función que crea objetos. Para explorar esto, pasemos de JavaScript al mundo de Java. Esto tendrá sentido para los desarrolladores que provienen de un OOP clásico (es decir, no prototípico), con antecedentes de lenguaje tipificado estáticamente. Si no es uno de esos desarrolladores, no dude en omitir esta sección. Esta es una pequeña desviación, por lo que si seguir aquí interrumpe su flujo de JavaScript, de nuevo, omita esta sección.
Un patrón de creación común, Factory Pattern, nos permite crear objetos sin exponer la lógica de negocios requerida para realizar dicha creación.
Supongamos que estamos escribiendo un programa que nos permite visualizar formas primitivas en n dimensiones. Si proporcionamos un cubo, por ejemplo, veríamos un cubo 2D (un cuadrado), un cubo 3D (un cubo) y un cubo 4D (un Tesseract o Hypercube). Así es como se puede hacer esto, de manera trivial y salvo la parte de dibujo real, en Java.
// Main.java // Defining an interface for the shape (can be used as a base type) interface IShape { void draw(); } // Implementing the interface for 2-dimensions: class TwoDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 2D."); } } // Implementing the interface for 3-dimensions: class ThreeDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 3D."); } } // Implementing the interface for 4-dimensions: class FourDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 4D."); } } // Handles object creation class ShapeFactory { // Factory method (notice return type is the base interface) public IShape createShape(int dimensions) { switch(dimensions) { case 2: return new TwoDimensions(); case 3: return new ThreeDimensions(); case 4: return new FourDimensions(); default: throw new IllegalArgumentException("Invalid dimension."); } } } // Main class and entry point. public class Main { public static void main(String[] args) throws Exception { ShapeFactory shapeFactory = new ShapeFactory(); IShape fourDimensions = shapeFactory.createShape(4); fourDimensions.draw(); // Drawing a shape in 4D. } }
Como puede ver, definimos una interfaz que especifica un método para dibujar una forma. Al hacer que las diferentes clases implementen la interfaz, podemos garantizar que todas las formas se pueden dibujar (ya que todas deben tener un método de draw
reemplazable según la definición de la interfaz). Teniendo en cuenta que esta forma se dibuja de manera diferente dependiendo de las dimensiones dentro de las cuales se ve, definimos clases auxiliares que implementan la interfaz para realizar el trabajo intensivo de GPU de simular el renderizado n-dimensional. ShapeFactory
hace el trabajo de instanciar la clase correcta: el método createShape
es una fábrica y, como la definición anterior, es un método que devuelve un objeto de una clase. El tipo de retorno de createShape
es la interfaz IShape
porque la interfaz IShape
es el tipo base de todas las formas (porque tienen un método de draw
).
Este ejemplo de Java es bastante trivial, pero puede ver fácilmente cuán útil se vuelve en aplicaciones más grandes donde la configuración para crear un objeto puede no ser tan simple. Un ejemplo de esto sería un videojuego. Supongamos que el usuario tiene que sobrevivir a diferentes enemigos. Las clases e interfaces abstractas se pueden usar para definir funciones básicas disponibles para todos los enemigos (y métodos que se pueden anular), tal vez empleando el patrón de delegación (favorecer la composición sobre la herencia como sugirió la Banda de los Cuatro para que no se vea obligado a extender un clase base única y para facilitar las pruebas/simulacros/DI). Para los objetos enemigos instanciados de diferentes maneras, la interfaz permitiría la creación de objetos de fábrica mientras se basa en el tipo de interfaz genérica. Esto sería muy relevante si el enemigo se creara dinámicamente.
Otro ejemplo es una función constructora. Supongamos que utilizamos el patrón de delegación para que una clase delegue el trabajo a otras clases que respetan una interfaz. Podríamos colocar un método de build
estático en la clase para que construya su propia instancia (suponiendo que no esté usando un contenedor/marco de inyección de dependencia). En lugar de tener que llamar a cada colocador, puede hacer esto:
public class User { private IMessagingService msgService; private String name; private int age; public User(String name, int age, IMessagingService msgService) { this.name = name; this.age = age; this.msgService = msgService; } public static User build(String name, int age) { return new User(name, age, new SomeMessageService()); } }
Explicaré el patrón de delegación en un artículo posterior si no está familiarizado con él; básicamente, a través de la composición y en términos de modelado de objetos, crea una relación "tiene un" en lugar de "es-un". relación como la que obtendrías con la herencia. Si tiene una clase Mammal
y una clase Dog
, y Dog
extiende Mammal
, entonces Dog
is-a Mammal
. Mientras que, si tenía una clase Bark
y acaba de pasar instancias de Bark
al constructor de Dog
, entonces Dog
tiene un Bark
. Como puede imaginar, esto facilita especialmente las pruebas unitarias, ya que puede inyectar simulacros y afirmar hechos sobre el simulacro siempre que el simulacro respete el contrato de interfaz en el entorno de prueba.
El método de fábrica static
"construcción" anterior simplemente crea un nuevo objeto de User
y pasa un MessageService
concreto. Observe cómo esto se deriva de la definición anterior: no exponer la lógica comercial para crear un objeto de una clase o, en este caso, no exponer la creación del servicio de mensajería al llamador de la fábrica.
Una vez más, esto no es necesariamente cómo haría las cosas en el mundo real, pero presenta bastante bien la idea de una función/método de fábrica. En su lugar, podríamos usar un contenedor de inyección de dependencia, por ejemplo. Ahora volvamos a JavaScript.
Comenzando con Express
Express es un marco de aplicación web para nodos (disponible a través de un módulo NPM) que permite crear un servidor web HTTP. Es importante tener en cuenta que Express no es el único marco para hacer esto (existen Koa, Fastify, etc.) y que, como se vio en el artículo anterior, Node puede funcionar sin Express como una entidad independiente. (Express es simplemente un módulo diseñado para Node; Node puede hacer muchas cosas sin él, aunque Express es popular para servidores web).
Una vez más, permítanme hacer una distinción muy importante. Existe una dicotomía presente entre Node/JavaScript y Express. Node, el tiempo de ejecución/entorno en el que ejecuta JavaScript, puede hacer muchas cosas, como permitirle crear aplicaciones React Native, aplicaciones de escritorio, herramientas de línea de comandos, etc. Express no es más que un marco ligero que le permite usar Node/JS para construir servidores web en lugar de lidiar con la red de bajo nivel de Node y las API HTTP. No necesita Express para construir un servidor web.
Antes de comenzar esta sección, si no está familiarizado con las solicitudes HTTP y HTTP (GET, POST, etc.), lo invito a leer la sección correspondiente de mi artículo anterior, que está vinculado anteriormente.
Usando Express, configuraremos diferentes rutas a las que se pueden realizar solicitudes HTTP, así como los puntos finales relacionados (que son funciones de devolución de llamada) que se activarán cuando se realice una solicitud a esa ruta. No se preocupe si las rutas y los puntos finales no tienen sentido en este momento; los explicaré más adelante.
A diferencia de otros artículos, adoptaré el enfoque de escribir el código fuente a medida que avanzamos, línea por línea, en lugar de volcar todo el código base en un fragmento y luego explicarlo más adelante. Comencemos abriendo una terminal (estoy usando Terminus encima de Git Bash en Windows, que es una buena opción para los usuarios de Windows que quieren un Bash Shell sin configurar el subsistema de Linux), configurar el modelo de nuestro proyecto y abrirlo en el código de Visual Studio.
mkdir server && cd server touch server.js npm init -y npm install express code .
Dentro del archivo server.js
, comenzaré requiriendo express
usando la función require()
.
const express = require('express');
require('express')
le dice a Node que salga y obtenga el módulo Express que instalamos anteriormente, que actualmente se encuentra dentro de la carpeta node_modules
(porque eso es lo que hace npm install
: crea una carpeta node_modules
y coloca los módulos y sus dependencias allí). Por convención, y cuando se trata de Express, llamamos a la variable que contiene el resultado de retorno de require('express')
express
, aunque se puede llamar cualquier cosa.
This returned result, which we have called express
, is actually a function — a function we'll have to invoke to create our Express app and set up our routes. Again, by convention, we call this app
— app
being the return result of express()
— that is, the return result of calling the function that has the name express
as express()
.
const express = require('express'); const app = express(); // Note that the above variable names are the convention, but not required. // An example such as that below could also be used. const foo = require('express'); const bar = foo(); // Note also that the node module we installed is called express.
The line const app = express();
simply puts a new Express Application inside of the app
variable. It calls a function named express
(the return result of require('express')
) and stores its return result in a constant named app
. If you come from an object-oriented programming background, consider this equivalent to instantiating a new object of a class, where app
would be the object and where express()
would call the constructor function of the express
class. Remember, JavaScript allows us to store functions in variables — functions are first-class citizens. The express
variable, then, is nothing more than a mere function. It's provided to us by the developers of Express.
I apologize in advance if I'm taking a very long time to discuss what is actually very basic, but the above, although primitive, confused me quite a lot when I was first learning back-end development with Node.
Inside the Express source code, which is open-source on GitHub, the variable we called express
is a function entitled createApplication
, which, when invoked, performs the work necessary to create an Express Application:
A snippet of Express source code:
exports = module.exports = createApplication; /* * Create an express application */ // This is the function we are storing in the express variable. (- Jamie) function createApplication() { // This is what I mean by "Express App" (- Jamie) var app = function(req, res, next) { app.handle(req, res, next); }; mixin(app, EventEmitter.prototype, false); mixin(app, proto, false); // expose the prototype that will get set on requests app.request = Object.create(req, { app: { configurable: true, enumerable: true, writable: true, value: app } }) // expose the prototype that will get set on responses app.response = Object.create(res, { app: { configurable: true, enumerable: true, writable: true, value: app } }) app.init(); // See - `app` gets returned. (- Jamie) return app; }
GitHub: https://github.com/expressjs/express/blob/master/lib/express.js
With that short deviation complete, let's continue setting up Express. Thus far, we have required the module and set up our app
variable.
const express = require('express'); const app = express();
From here, we have to tell Express to listen on a port. Any HTTP Requests made to the URL and Port upon which our application is listening will be handled by Express. We do that by calling app.listen(...)
, passing to it the port and a callback function which gets called when the server starts running:
const PORT = 3000; app.listen(PORT, () => console.log(`Server is up on port {PORT}.`));
We notate the PORT
variable in capital by convention, for it is a constant variable that will never change. You could do that with all variables that you declare const
, but that would look messy. It's up to the developer or development team to decide on notation, so we'll use the above sparsely. I use const
everywhere as a method of “defensive coding” — that is, if I know that a variable is never going to change then I might as well just declare it const
. Since I define everything const
, I make the distinction between what variables should remain the same on a per-request basis and what variables are true actual global constants.
Here is what we have thus far:
const express = require('express'); const app = express(); const PORT = 3000; // We will build our API here. // ... // Binding our application to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`); });
Let's test this to see if the server starts running on port 3000.
I'll open a terminal and navigate to our project's root directory. I'll then run node server/server.js
. Note that this assumes you have Node already installed on your system (You can check with node -v
).
If everything works, you should see the following in the terminal:
Server is up on port 3000.
Go ahead and hit Ctrl + C
to bring the server back down.
If this doesn't work for you, or if you see an error such as EADDRINUSE
, then it means you may have a service already running on port 3000. Pick another port number, like 3001, 3002, 5000, 8000, etc. Be aware, lower number ports are reserved and there is an upper bound of 65535.
At this point, it's worth taking another small deviation as to understand servers and ports in the context of computer networking. We'll return to Express in a moment. I take this approach, rather than introducing servers and ports first, for the purpose of relevance. That is, it is difficult to learn a concept if you fail to see its applicability. In this way, you are already aware of the use case for ports and servers with Express, so the learning experience will be more pleasurable.
A Brief Look At Servers And Ports
A server is simply a computer or computer program that provides some sort of “functionality” to the clients that talk to it. More generally, it's a device, usually connected to the Internet, that handles connections in a pre-defined manner. In our case, that “pre-defined manner” will be HTTP or the HyperText Transfer Protocol. Servers that use the HTTP Protocol are called Web Servers.
When building an application, the server is a critical component of the “client-server model”, for it permits the sharing and syncing of data (generally via databases or file systems) across devices. It's a cross-platform approach, in a way, for the SDKs of platforms against which you may want to code — be they web, mobile, or desktop — all provide methods (APIs) to interact with a server over HTTP or TCP/UDP Sockets. It's important to make a distinction here — by APIs, I mean programming language constructs to talk to a server, like XMLHttpRequest
or the Fetch
API in JavaScript, or HttpUrlConnection
in Java, or even HttpClient
in C#/.NET. This is different from the kind of REST API we'll be building in this article to perform CRUD Operations on a database.
To talk about ports, it's important to understand how clients connect to a server. A client requires the IP Address of the server and the Port Number of our specific service on that server. An IP Address, or Internet Protocol Address, is just an address that uniquely identifies a device on a network. Public and private IPs exist, with private addresses commonly used behind a router or Network Address Translator on a local network. You might see private IP Addresses of the form 192.168.XXX.XXX
or 10.0.XXX.XXX
. When articulating an IP Address, decimals are called “dots”. So 192.168.0.1
(a common router IP Addr.) might be pronounced, “one nine two dot one six eight dot zero dot one”. (By the way, if you're ever in a hotel and your phone/laptop won't direct you to the AP captive portal, try typing 192.168.0.1 or 192.168.1.1 or similar directly into Chrome).
For simplicity, and since this is not an article about the complexities of computer networking, assume that an IP Address is equivalent to a house address, allowing you to uniquely identify a house (where a house is analogous to a server, client, or network device) in a neighborhood. One neighborhood is one network. Put together all of the neighborhoods in the United States, and you have the public Internet. (This is a basic view, and there are many more complexities — firewalls, NATs, ISP Tiers (Tier One, Tier Two, and Tier Three), fiber optics and fiber optic backbones, packet switches, hops, hubs, etc., subnet masks, etc., to name just a few — in the real networking world.) The traceroute
Unix command can provide more insight into the above, displaying the path (and associated latency) that packets take through a network as a series of “hops”.
Un número de puerto identifica un servicio específico que se ejecuta en un servidor. SSH, o Secure Shell, que permite el acceso de shell remoto a un dispositivo, normalmente se ejecuta en el puerto 22. FTP o Protocolo de transferencia de archivos (que podría, por ejemplo, usarse con un cliente FTP para transferir activos estáticos a un servidor) normalmente se ejecuta en Puerto 21. Podríamos decir, entonces, que los puertos son habitaciones específicas dentro de cada casa en nuestra analogía anterior, porque las habitaciones en las casas están hechas para diferentes cosas: un dormitorio para dormir, una cocina para preparar alimentos, un comedor para el consumo de dichos alimentos, etc., al igual que los puertos corresponden a programas que realizan servicios específicos. Para nosotros, los servidores web normalmente se ejecutan en el puerto 80, aunque usted es libre de especificar el número de puerto que desee, siempre que no estén en uso por algún otro servicio (no pueden colisionar).
Para acceder a un sitio web, necesita la dirección IP del sitio. A pesar de eso, normalmente accedemos a sitios web a través de una URL. Detrás de escena, un DNS o servidor de nombres de dominio convierte esa URL en una dirección IP, lo que permite que el navegador realice una solicitud GET al servidor, obtenga el HTML y lo represente en la pantalla. 8.8.8.8
es la dirección de uno de los servidores DNS públicos de Google. Puede imaginar que requerir la resolución de un nombre de host a una dirección IP a través de un servidor DNS remoto llevará tiempo, y tendría razón. Para reducir la latencia, los sistemas operativos tienen una caché de DNS, una base de datos temporal que almacena información de búsqueda de DNS, lo que reduce la frecuencia con la que deben realizarse dichas búsquedas. La caché de resolución de DNS se puede ver en Windows con el ipconfig /displaydns
CMD y se puede purgar mediante el ipconfig /flushdns
.
En un servidor Unix, los puertos de número inferior más comunes, como 80, requieren privilegios de nivel raíz ( escalados si proviene de Windows). Por ese motivo, usaremos el puerto 3000 para nuestro trabajo de desarrollo, pero permitiremos que el servidor elija el número de puerto (el que esté disponible) cuando lo implementemos en nuestro entorno de producción.
Finalmente, tenga en cuenta que podemos escribir las direcciones IP directamente en la barra de búsqueda de Google Chrome, evitando así el mecanismo de resolución de DNS. Escribir 216.58.194.36
, por ejemplo, lo llevará a Google.com. En nuestro entorno de desarrollo, cuando usamos nuestra propia computadora como nuestro servidor de desarrollo, localhost
y el puerto 3000. Una dirección tiene el formato hostname:port
, por lo que nuestro servidor estará activo en localhost:3000
. Localhost, o 127.0.0.1
, es la dirección de bucle invertido y significa la dirección de "esta computadora". Es un nombre de host y su dirección IPv4 se resuelve en 127.0.0.1
. Intente hacer ping a localhost en su máquina ahora mismo. Es posible que obtenga ::1
vuelta, que es la dirección de bucle invertido IPv6, o 127.0.0.1
, que es la dirección de bucle invertido IPv4. IPv4 e IPv6 son dos formatos de dirección IP diferentes asociados con diferentes estándares: algunas direcciones IPv6 se pueden convertir a IPv4, pero no todas.
Volviendo a Expresar
Mencioné solicitudes HTTP, verbos y códigos de estado en mi artículo anterior, Primeros pasos con Node: una introducción a las API, HTTP y ES6+ JavaScript. Si no tiene una comprensión general del protocolo, no dude en pasar a la sección "Solicitudes HTTP y HTTP" de ese artículo.
Para tener una idea de Express, simplemente configuraremos nuestros puntos finales para las cuatro operaciones fundamentales que realizaremos en la base de datos: Crear, Leer, Actualizar y Eliminar, conocidas colectivamente como CRUD.
Recuerde, accedemos a los puntos finales por rutas en la URL. Es decir, aunque las palabras "ruta" y "punto final" suelen usarse indistintamente, un punto final es técnicamente una función de lenguaje de programación (como las funciones de flecha ES6) que realiza alguna operación del lado del servidor, mientras que una ruta es detrás de lo que se encuentra el punto final. de . Especificamos estos puntos finales como funciones de devolución de llamada, que Express activará cuando el cliente realice la solicitud adecuada a la ruta detrás de la cual vive el punto final. Puede recordar lo anterior al darse cuenta de que son los puntos finales los que realizan una función y la ruta es el nombre que se utiliza para acceder a los puntos finales. Como veremos, la misma ruta se puede asociar con múltiples puntos finales mediante el uso de diferentes verbos HTTP (similar a la sobrecarga de métodos si proviene de un entorno de POO clásico con polimorfismo).
Tenga en cuenta que estamos siguiendo la arquitectura REST (Representational State Transfer) al permitir que los clientes realicen solicitudes a nuestro servidor. Esto es, después de todo, una API REST o RESTful. Las solicitudes específicas realizadas a rutas específicas dispararán puntos finales específicos que harán cosas específicas. Un ejemplo de tal "cosa" que podría hacer un punto final es agregar nuevos datos a una base de datos, eliminar datos, actualizar datos, etc.
Express sabe qué punto final activar porque le decimos, explícitamente, el método de solicitud (GET, POST, etc.) y la ruta: definimos qué funciones activar para combinaciones específicas de las anteriores, y el cliente realiza la solicitud, especificando un ruta y método. Para decirlo de manera más simple, con Node, le diremos a Express: "Oye, si alguien hace una solicitud GET a esta ruta, continúa y activa esta función (usa este punto final)". Las cosas pueden complicarse más: “Express, si alguien realiza una solicitud GET a esta ruta, pero no envía un token de portador de autorización válido en el encabezado de su solicitud, responda con un HTTP 401 Unauthorized
. Si poseen un token de portador válido, envíe cualquier recurso protegido que estuvieran buscando activando el punto final. Muchas gracias y que tengas un buen día.” De hecho, sería bueno si los lenguajes de programación pudieran tener un nivel tan alto sin filtrar la ambigüedad, pero no obstante demuestra los conceptos básicos.
Recuerde, el punto final, en cierto modo, vive detrás de la ruta. Por lo tanto, es imperativo que el cliente proporcione, en el encabezado de la solicitud, qué método quiere usar para que Express pueda decidir qué hacer. La solicitud se realizará a una ruta específica, que el cliente especificará (junto con el tipo de solicitud) cuando se comunique con el servidor, lo que le permitirá a Express hacer lo que debe hacer y a nosotros hacer lo que debemos hacer cuando Express activa nuestras devoluciones de llamada. . A eso se reduce todo.
En los ejemplos de código anteriores, llamamos a la función de listen
que estaba disponible en la app
, pasándole un puerto y una devolución de llamada. app
en sí, si recuerda, es el resultado de retorno de llamar a la variable express
como una función (es decir, express()
), y la variable express
es lo que llamamos el resultado de retorno al requerir 'express'
de nuestra carpeta node_modules
. Al igual que se llama a listen
en la app
, especificamos puntos finales de solicitud HTTP llamándolos en la app
. Veamos GET:
app.get('/my-test-route', () => { // ... });
El primer parámetro es una string
, y es la ruta detrás de la cual vivirá el punto final. La función de devolución de llamada es el punto final. Lo diré nuevamente: la función de devolución de llamada, el segundo parámetro, es el punto final que se activará cuando se realice una solicitud HTTP GET a cualquier ruta que especifiquemos como primer argumento ( /my-test-route
en este caso).
Ahora, antes de trabajar más con Express, necesitamos saber cómo funcionan las rutas. La ruta que especificamos como una cadena se llamará al realizar la solicitud a www.domain.com/the-route-we-chose-earlier-as-a-string
. En nuestro caso, el dominio es localhost:3000
, lo que significa que, para activar la función de devolución de llamada anterior, debemos realizar una solicitud GET a localhost:3000/my-test-route
. Si usamos una cadena diferente como el primer argumento anterior, la URL tendría que ser diferente para coincidir con lo que especificamos en JavaScript.
Cuando hable de estas cosas, es probable que escuche hablar de Glob Patterns. Podríamos decir que todas las rutas de nuestra API están ubicadas en localhost:3000/**
Glob Pattern, donde **
es un comodín que significa cualquier directorio o subdirectorio (tenga en cuenta que las rutas no son directorios) del cual la raíz es un padre: eso es todo.
Avancemos y agreguemos una declaración de registro en esa función de devolución de llamada para que en total tengamos:
// Getting the module from node_modules. const express = require('express'); // Creating our Express Application. const app = express(); // Defining the port we'll bind to. const PORT = 3000; // Defining a new endpoint behind the "/my-test-route" route. app.get('/my-test-route', () => { console.log('A GET Request was made to /my-test-route.'); }); // Binding the server to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`) });
Pondremos nuestro servidor en funcionamiento ejecutando node server/server.js
(con Node instalado en nuestro sistema y accesible globalmente desde las variables de entorno del sistema) en el directorio raíz del proyecto. Como antes, debería ver el mensaje de que el servidor está activo en la consola. Ahora que el servidor se está ejecutando, abra un navegador y visite localhost:3000
en la barra de direcciones URL.
Debería recibir un mensaje de error que indica Cannot GET /
. Presione Ctrl + Shift + I en Windows en Chrome para ver la consola del desarrollador. Allí, debería ver que tenemos un 404
(Recurso no encontrado). Eso tiene sentido: solo le hemos dicho al servidor qué hacer cuando alguien visita localhost:3000/my-test-route
. El navegador no tiene nada que representar en localhost:3000
(que es equivalente a localhost:3000/
con una barra inclinada).
Si observa la ventana de la terminal donde se ejecuta el servidor, no debería haber datos nuevos. Ahora, visite localhost:3000/my-test-route
en la barra de URL de su navegador. Es posible que vea el mismo error en la consola de Chrome (porque el navegador está almacenando en caché el contenido y todavía no tiene HTML para procesar), pero si ve su terminal donde se está ejecutando el proceso del servidor, verá que la función de devolución de llamada se activó. y el mensaje de registro fue efectivamente registrado.
Apague el servidor con Ctrl + C.
Ahora, démosle al navegador algo para procesar cuando se realiza una solicitud GET a esa ruta para que podamos perder el mensaje Cannot GET /
. Tomaré nuestro app.get()
de antes, y en la función de devolución de llamada, agregaré dos argumentos. Recuerde, la función de devolución de llamada que estamos pasando está siendo llamada por Express detrás de escena, y Express puede agregar los argumentos que quiera. En realidad agrega dos (bueno, técnicamente tres, pero lo veremos más adelante), y aunque ambos son extremadamente importantes, no nos importa el primero por ahora. El segundo argumento se llama res
, abreviatura de response
, y accederé a él configurando undefined
como primer parámetro:
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); });
Nuevamente, podemos llamar al argumento res
como queramos, pero res
es una convención cuando se trata de Express. res
es en realidad un objeto, y sobre él existen diferentes métodos para enviar datos al cliente. En este caso, accederé a la función de send(...)
disponible en res
para enviar HTML que el navegador mostrará. Sin embargo, no estamos limitados a devolver HTML y podemos optar por devolver texto, un objeto de JavaScript, una transmisión (las transmisiones son especialmente hermosas) o lo que sea.
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); res.send('<h1>Hello, World!</h1>'); });
Si apaga el servidor y luego lo vuelve a encender, y luego actualiza su navegador en la /my-test-route
, verá que se procesa el HTML.
La pestaña Red de las herramientas para desarrolladores de Chrome le permitirá ver esta solicitud GET con más detalles en lo que respecta a los encabezados.
En este punto, nos será muy útil comenzar a aprender sobre Express Middleware, funciones que se pueden activar globalmente después de que un cliente realiza una solicitud.
Intermediario exprés
Express proporciona métodos para definir middleware personalizado para su aplicación. De hecho, el significado de Express Middleware se define mejor en Express Docs, aquí)
Las funciones de middleware son funciones que tienen acceso al objeto de solicitud (
req
), el objeto de respuesta (res
) y la siguiente función de middleware en el ciclo de solicitud-respuesta de la aplicación. La siguiente función de middleware se indica comúnmente mediante una variable denominadanext
.
Las funciones de middleware pueden realizar las siguientes tareas:
- Ejecutar cualquier código.
- Realice cambios en los objetos de solicitud y respuesta.
- Terminar el ciclo de solicitud-respuesta.
- Llame a la siguiente función de middleware en la pila.
En otras palabras, una función de middleware es una función personalizada que nosotros (el desarrollador) podemos definir y que actuará como intermediario entre el momento en que Express recibe la solicitud y el momento en que se activa nuestra función de devolución de llamada adecuada. Podríamos hacer una función de log
, por ejemplo, que registrará cada vez que se realice una solicitud. Tenga en cuenta que también podemos elegir hacer que estas funciones de middleware se activen después de que nuestro punto final se haya activado, dependiendo de dónde lo coloque en la pila, algo que veremos más adelante.
Para especificar el middleware personalizado, debemos definirlo como una función y pasarlo a app.use(...)
.
const myMiddleware = (req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); } app.use(myMiddleware); // This is the app variable returned from express().
Todos juntos, ahora tenemos:
// Getting the module from node_modules. const express = require('express'); // Creating our Express Application. const app = express(); // Our middleware function. const myMiddleware = (req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); } // Tell Express to use the middleware. app.use(myMiddleware); // Defining the port we'll bind to. const PORT = 3000; // Defining a new endpoint behind the "/my-test-route" route. app.get('/my-test-route', () => { console.log('A GET Request was made to /my-test-route.'); }); // Binding the server to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`) });
Si vuelve a realizar las solicitudes a través del navegador, ahora debería ver que su función de middleware se está activando y registrando marcas de tiempo. Para fomentar la experimentación, intente eliminar la llamada a la next
función y vea qué sucede.
La función de devolución de llamada del middleware se llama con tres argumentos, req
, res
y next
. req
es el parámetro que omitimos al crear el controlador GET anteriormente, y es un objeto que contiene información sobre la solicitud, como encabezados, encabezados personalizados, parámetros y cualquier cuerpo que pueda haber sido enviado desde el cliente (como lo hace con una solicitud POST). Sé que estamos hablando de middleware aquí, pero tanto los puntos finales como la función de middleware se llaman con req
y res
. req
y res
serán los mismos (a menos que uno u otro lo alteren) tanto en el middleware como en el punto final dentro del alcance de una sola solicitud del cliente. Eso significa que, por ejemplo, podría usar una función de middleware para desinfectar los datos eliminando cualquier carácter que pueda estar destinado a realizar inyecciones de SQL o NoSQL y luego entregar el req
seguro al punto final.
res
, como se vio anteriormente, le permite enviar datos al cliente de varias maneras diferentes.
next
es una función de devolución de llamada que debe ejecutar cuando el middleware haya terminado de hacer su trabajo para llamar a la siguiente función de middleware en la pila o el punto final. Asegúrese de tener en cuenta que tendrá que llamar a esto en el bloque then
de cualquier función asíncrona que active en el middleware. Dependiendo de su operación asíncrona, puede o no querer llamarlo en el bloque catch
. Es decir, la función myMiddleware
se activa después de que se realiza la solicitud desde el cliente, pero antes de que se active la función de punto final de la solicitud. Cuando ejecutamos este código y realizamos una solicitud, debería ver el mensaje Middleware has fired...
antes del mensaje A GET Request was made to...
en la consola. Si no llama a next()
, la última parte nunca se ejecutará; su función de punto final para la solicitud no se activará.
Tenga en cuenta también que podría haber definido esta función de forma anónima, como tal (una convención a la que me apegaré):
app.use((req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); });
Para cualquier persona nueva en JavaScript y ES6, si la forma en que funciona lo anterior no tiene sentido inmediato, el siguiente ejemplo debería ayudar. Simplemente estamos definiendo una función de devolución de llamada (la función anónima) que toma otra función de devolución de llamada ( next
) como argumento. Llamamos función de orden superior a una función que toma un argumento de función. Mírelo de la siguiente manera: muestra un ejemplo básico de cómo podría funcionar el código fuente Express detrás de escena:
console.log('Suppose a request has just been made from the client.\n'); // This is what (it's not exactly) the code behind app.use() might look like. const use = callback => { // Simple log statement to see where we are. console.log('Inside use() - the "use" function has been called.'); // This depicts the termination of the middleware. const next = () => console.log('Terminating Middleware!\n'); // Suppose req and res are defined above (Express provides them). const req = res = null; // "callback" is the "middleware" function that is passed into "use". // "next" is the above function that pretends to stop the middleware. callback(req, res, next); }; // This is analogous to the middleware function we defined earlier. // It gets passed in as "callback" in the "use" function above. const myMiddleware = (req, res, next) => { console.log('Inside the myMiddleware function!'); next(); } // Here, we are actually calling "use()" to see everything work. use(myMiddleware); console.log('Moving on to actually handle the HTTP Request or the next middleware function.');
Primero llamamos use
que toma myMiddleware
como argumento. myMiddleware
, en sí mismo, es una función que toma tres argumentos: req
, res
y next
. Dentro de use
, se llama myMiddlware
y se pasan esos tres argumentos. next
hay una función definida en use
. myMiddleware
se define como callback
de llamada en el método de use
. Si hubiera colocado use
, en este ejemplo, en un objeto llamado app
, podríamos haber imitado la configuración de Express por completo, aunque sin enchufes ni conectividad de red.
En este caso, tanto myMiddleware
como callback
son funciones de orden superior, porque ambas toman funciones como argumentos.
Si ejecuta este código, verá la siguiente respuesta:
Suppose a request has just been made from the client. Inside use() - the "use" function has been called. Inside the middleware function! Terminating Middleware! Moving on to actually handle the HTTP Request or the next middleware function.
Tenga en cuenta que también podría haber usado funciones anónimas para lograr el mismo resultado:
console.log('Suppose a request has just been made from the client.'); // This is what (it's not exactly) the code behind app.use() might look like. const use = callback => { // Simple log statement to see where we are. console.log('Inside use() - the "use" function has been called.'); // This depicts the termination of the middlewear. const next = () => console.log('Terminating Middlewear!'); // Suppose req and res are defined above (Express provides them). const req = res = null; // "callback" is the function which is passed into "use". // "next" is the above function that pretends to stop the middlewear. callback(req, res, () => { console.log('Terminating Middlewear!'); }); }; // Here, we are actually calling "use()" to see everything work. use((req, res, next) => { console.log('Inside the middlewear function!'); next(); }); console.log('Moving on to actually handle the HTTP Request.');
Con eso resuelto, ahora podemos volver a la tarea actual: configurar nuestro middleware.
El hecho es que, por lo general, tendrá que enviar datos a través de una solicitud HTTP. Tiene algunas opciones diferentes para hacerlo: enviar parámetros de consulta de URL, enviar datos a los que se podrá acceder en el objeto req
del que aprendimos anteriormente, etc. Ese objeto no solo está disponible en la devolución de llamada para llamar a app.use()
, sino también a cualquier punto final. Antes usamos undefined
como relleno para poder centrarnos en res
para enviar HTML de vuelta al cliente, pero ahora necesitamos acceder a él.
app.use('/my-test-route', (req, res) => { // The req object contains client-defined data that is sent up. // The res object allows the server to send data back down. });
Las solicitudes HTTP POST pueden requerir que enviemos un objeto de cuerpo al servidor. Si tiene un formulario en el cliente y toma el nombre y el correo electrónico del usuario, es probable que envíe esos datos al servidor en el cuerpo de la solicitud.
Echemos un vistazo a cómo se vería eso en el lado del cliente:
<!DOCTYPE html> <html> <body> <form action="https://localhost:3000/email-list" method="POST" > <input type="text" name="nameInput"> <input type="email" name="emailInput"> <input type="submit"> </form> </body> </html>
En el lado del servidor:
app.post('/email-list', (req, res) => { // What do we now? // How do we access the values for the user's name and email? });
Para acceder al nombre y correo electrónico del usuario, tendremos que usar un tipo particular de middleware. Esto pondrá los datos en un objeto llamado body
disponible en req
. Body Parser fue un método popular para hacer esto, disponible por los desarrolladores de Express como un módulo NPM independiente. Ahora, Express viene preempaquetado con su propio middleware para hacer esto, y lo llamaremos así:
app.use(express.urlencoded({ extended: true }));
Ahora podemos hacer:
app.post('/email-list', (req, res) => { console.log('User Name: ', req.body.nameInput); console.log('User Email: ', req.body.emailInput); });
Todo lo que hace es tomar cualquier entrada definida por el usuario que se envíe desde el cliente y ponerla a disposición en el objeto de body
de req
. Tenga en cuenta que en req.body
, ahora tenemos nameInput
y emailInput
, que son los nombres de las etiquetas de input
en el HTML. Ahora, estos datos definidos por el cliente deben considerarse peligrosos (nunca, nunca confíes en el cliente) y deben ser desinfectados, pero lo cubriremos más adelante.
Otro tipo de middleware proporcionado por express es express.json()
. express.json
se usa para empaquetar cualquier carga útil JSON enviada en una solicitud del cliente en req.body
, mientras que express.urlencoded
empaquetará cualquier solicitud entrante con cadenas, matrices u otros datos codificados en URL en req.body
. En resumen, ambos manipulan req.body
, pero .json()
es para cargas útiles JSON y .urlencoded()
es para, entre otros, parámetros de consulta POST.
Otra forma de decir esto es que las solicitudes entrantes con un Content-Type: application/json
(como especificar un cuerpo POST con la API de fetch
) serán manejadas por express.json()
, mientras que las solicitudes con el encabezado Content-Type: application/x-www-form-urlencoded
(como los formularios HTML) se manejará con express.urlencoded()
. Es de esperar que ahora tenga sentido.
Inicio de nuestras rutas CRUD para MongoDB
Nota : Al realizar solicitudes PATCH en este artículo, no seguiremos la especificación JSONPatch RFC, un problema que corregiremos en el próximo artículo de esta serie.
Teniendo en cuenta que entendemos que especificamos cada punto final llamando a la función relevante en la app
, pasándole la ruta y una función de devolución de llamada que contiene los objetos de solicitud y respuesta, podemos comenzar a definir nuestras rutas CRUD para la API de Bookshelf. De hecho, y considerando que este es un artículo introductorio, no me preocuparé de seguir completamente las especificaciones de HTTP y REST, ni intentaré usar la arquitectura más limpia posible. Eso vendrá en un próximo artículo.
server.js
el archivo server.js que hemos estado usando hasta ahora y vaciaré todo para comenzar desde la pizarra limpia a continuación:
// Getting the module from node_modules. const express = require('express'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true )); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // ... // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Considere todo el siguiente código para ocupar la parte // ...
del archivo anterior.
Para definir nuestros puntos finales, y debido a que estamos construyendo una API REST, debemos discutir la forma correcta de nombrar las rutas. Nuevamente, debe consultar la sección HTTP de mi artículo anterior para obtener más información. Estamos tratando con libros, por lo que todas las rutas se ubicarán detrás de /books
(la convención de nomenclatura en plural es estándar).
Solicitud | Ruta |
---|---|
CORREO | /books |
OBTENER | /books/id |
PARCHE | /books/id |
ELIMINAR | /books/id |
Como puede ver, no es necesario especificar una ID al publicar un libro porque nosotros (o más bien, MongoDB) lo generaremos para nosotros, automáticamente, del lado del servidor. OBTENER, PARCHAR y ELIMINAR libros requerirá que pasemos esa identificación a nuestro punto final, lo cual discutiremos más adelante. Por ahora, simplemente creemos los puntos finales:
// HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); });
La sintaxis :id
le dice a Express que id
es un parámetro dinámico que se pasará en la URL. Tenemos acceso a él en el objeto params
que está disponible en req
. Sé que "tenemos acceso a él a req
" suena como magia y la magia (que no existe) es peligrosa en la programación, pero debes recordar que Express no es una caja negra. Es un proyecto de código abierto disponible en GitHub bajo una licencia MIT. Puede ver fácilmente su código fuente si desea ver cómo se colocan los parámetros de consulta dinámica en el objeto req
.
Todos juntos, ahora tenemos lo siguiente en nuestro archivo server.js
:
// Getting the module from node_modules. const express = require('express'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Continúe e inicie el servidor, ejecute node server.js
desde la terminal o la línea de comando, y visite su navegador. Abra la Consola de desarrollo de Chrome y, en la barra URL (Localizador uniforme de recursos), visite localhost:3000/books
. Ya debería ver el indicador en la terminal de su sistema operativo de que el servidor está activo, así como la declaración de registro para GET.
Hasta ahora, hemos estado usando un navegador web para realizar solicitudes GET. Eso es bueno para comenzar, pero pronto descubriremos que existen mejores herramientas para probar las rutas API. De hecho, podríamos pegar llamadas de fetch
directamente en la consola o usar algún servicio en línea. En nuestro caso, y para ahorrar tiempo, utilizaremos cURL
y Postman. Utilizo ambos en este artículo (aunque podrías usar cualquiera o ) para que pueda presentarlos si no los has usado. cURL
es una biblioteca (una biblioteca muy, muy importante) y una herramienta de línea de comandos diseñada para transferir datos utilizando varios protocolos. Postman es una herramienta basada en GUI para probar API. Después de seguir las instrucciones de instalación relevantes para ambas herramientas en su sistema operativo, asegúrese de que su servidor aún se esté ejecutando y luego ejecute los siguientes comandos (uno por uno) en una nueva terminal. Es importante que los escriba y los ejecute individualmente, y luego mire el mensaje de registro en la terminal separada de su servidor. Además, tenga en cuenta que el símbolo de comentario del lenguaje de programación estándar //
no es un símbolo válido en Bash o MS-DOS. Tendrá que omitir esas líneas, y solo las uso aquí para describir cada bloque de comandos cURL
.
// HTTP POST Request (Localhost, IPv4, IPv6) curl -X POST https://localhost:3000/books curl -X POST https://127.0.0.1:3000/books curl -X POST https://[::1]:3000/books // HTTP GET Request (Localhost, IPv4, IPv6) curl -X GET https://localhost:3000/books/123abc curl -X GET https://127.0.0.1:3000/books/book-id-123 curl -X GET https://[::1]:3000/books/book-abc123 // HTTP PATCH Request (Localhost, IPv4, IPv6) curl -X PATCH https://localhost:3000/books/456 curl -X PATCH https://127.0.0.1:3000/books/218 curl -X PATCH https://[::1]:3000/books/some-id // HTTP DELETE Request (Localhost, IPv4, IPv6) curl -X DELETE https://localhost:3000/books/abc curl -X DELETE https://127.0.0.1:3000/books/314 curl -X DELETE https://[::1]:3000/books/217
Como puede ver, el ID que se pasa como parámetro de URL puede tener cualquier valor. El indicador -X
especifica el tipo de solicitud HTTP (se puede omitir para GET), y proporcionamos la URL a la que se realizará la solicitud a partir de entonces. He duplicado cada solicitud tres veces, lo que le permite ver que todo sigue funcionando, ya sea que use el nombre de host localhost
, la dirección IPv4 ( 127.0.0.1
) a la que se resuelve localhost
o la dirección IPv6 ( ::1
) a la que se resuelve localhost
. Tenga en cuenta que cURL
requiere envolver las direcciones IPv6 entre corchetes.
Estamos en un lugar decente ahora: tenemos configurada la estructura simple de nuestras rutas y puntos finales. El servidor se ejecuta correctamente y acepta solicitudes HTTP como esperamos. Al contrario de lo que podría esperar, no queda mucho para llegar a este punto: solo tenemos que configurar nuestra base de datos, alojarla (usando una base de datos como servicio, MongoDB Atlas) y conservar los datos en ella (y realizar la validación y crear respuestas de error).
Configuración de una base de datos MongoDB de producción
Para configurar una base de datos de producción, nos dirigiremos a la página de inicio de MongoDB Atlas y nos registraremos para obtener una cuenta gratuita. A partir de entonces, cree un nuevo clúster. Puede mantener la configuración predeterminada, eligiendo una región aplicable al nivel de tarifa. Luego presione el botón "Crear clúster". El clúster tardará un tiempo en crearse y luego podrá obtener la URL y la contraseña de su base de datos. Toma nota de estos cuando los veas. Los codificaremos por ahora y luego los almacenaremos en variables de entorno por motivos de seguridad. Si necesita ayuda para crear un clúster y conectarse a él, lo remitiré a la documentación de MongoDB, particularmente a esta página y esta página, o puede dejar un comentario a continuación e intentaré ayudarlo.
Crear un modelo de mangosta
Se recomienda que comprenda los significados de Documentos y Colecciones en el contexto de NoSQL (No solo SQL: lenguaje de consulta estructurado). Como referencia, es posible que desee leer la Guía de inicio rápido de Mongoose y la sección MongoDB de mi artículo anterior.
Ahora tenemos una base de datos que está lista para aceptar operaciones CRUD. Mongoose es un módulo de nodo (u ODM, mapeador de documentos de objetos) que nos permitirá realizar esas operaciones (abstrayendo algunas de las complejidades), así como configurar el esquema o la estructura de la colección de la base de datos.
Como descargo de responsabilidad importante, existe mucha controversia en torno a los ORM y patrones como Active Record o Data Mapper. Algunos desarrolladores juran por los ORM y otros juran en contra de ellos (creyendo que se interponen en el camino). También es importante tener en cuenta que los ORM abstraen mucho, como la agrupación de conexiones, las conexiones de socket y el manejo, etc. Podría usar fácilmente el controlador nativo MongoDB (otro módulo NPM), pero requeriría mucho más trabajo. Si bien se recomienda que juegue con el controlador nativo antes de usar ORM, omito el controlador nativo aquí por brevedad. Para operaciones SQL complejas en una base de datos relacional, no todos los ORM estarán optimizados para la velocidad de consulta y es posible que termine escribiendo su propio SQL sin formato. ORMs can come into play a lot with Domain-Driven Design and CQRS, among others. They are an established concept in the .NET world, and the Node.js community has not completely caught up yet — TypeORM is better, but it's not NHibernate or Entity Framework.
To create our Model, I'll create a new folder in the server
directory entitled models
, within which I'll create a single file with the name book.js
. Thus far, our project's directory structure is as follows:
- server - node_modules - models - book.js - package.json - server.js
Indeed, this directory structure is not required, but I use it here because it's simple. Allow me to note that this is not at all the kind of architecture you want to use for larger applications (and you might not even want to use JavaScript — TypeScript could be a better option), which I discuss in this article's closing. The next step will be to install mongoose
, which is performed via, as you might expect, npm i mongoose
.
The meaning of a Model is best ascertained from the Mongoose documentation:
Models are fancy constructors compiled from
Schema
definitions. An instance of a model is called a document. Models are responsible for creating and reading documents from the underlying MongoDB database.
Before creating the Model, we'll define its Schema. A Schema will, among others, make certain expectations about the value of the properties provided. MongoDB is schemaless, and thus this functionality is provided by the Mongoose ODM. Let's start with a simple example. Suppose I want my database to store a user's name, email address, and password. Traditionally, as a plain old JavaScript Object (POJO), such a structure might look like this:
const userDocument = { name: 'Jamie Corkhill', email: '[email protected]', password: 'Bcrypt Hash' };
If that above object was how we expected our user's object to look, then we would need to define a schema for it, like this:
const schema = { name: { type: String, trim: true, required: true }, email: { type: String, trim: true, required: true }, password: { type: String, required: true } };
Notice that when creating our schema, we define what properties will be available on each document in the collection as an object in the schema. In our case, that's name
, email
, and password
. The fields type
, trim
, required
tell Mongoose what data to expect. If we try to set the name
field to a number, for example, or if we don't provide a field, Mongoose will throw an error (because we are expecting a type of String
), and we can send back a 400 Bad Request
to the client. This might not make sense right now because we have defined an arbitrary schema
object. However, the fields of type
, trim
, and required
(among others) are special validators that Mongoose understands. trim
, for example, will remove any whitespace from the beginning and end of the string. We'll pass the above schema to mongoose.Schema()
in the future and that function will know what to do with the validators.
Understanding how Schemas work, we'll create the model for our Books Collection of the Bookshelf API. Let's define what data we require:
Título
ISBN Number
Autor
Primer nombre
Apellido
Publishing Date
Finished Reading (Boolean)
I'm going to create this in the book.js
file we created earlier in /models
. Like the example above, we'll be performing validation:
const mongoose = require('mongoose'); // Define the schema: const mySchema = { title: { type: String, required: true, trim: true, }, isbn: { type: String, required: true, trim: true, }, author: { firstName:{ type: String, required: true, trim: true }, lastName: { type: String, required: true, trim: true } }, publishingDate: { type: String }, finishedReading: { type: Boolean, required: true, default: false } }
default
will set a default value for the property if none is provided — finishedReading
for example, although a required field, will be set automatically to false
if the client does not send one up.
Mongoose also provides the ability to perform custom validation on our fields, which is done by supplying the validate()
method, which attains the value that was attempted to be set as its one and only parameter. In this function, we can throw an error if the validation fails. Aquí hay un ejemplo:
// ... isbn: { type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } } // ...
Now, if anyone supplies an invalid ISBN to our model, Mongoose will throw an error when trying to save that document to the collection. I've already installed the NPM module validator
via npm i validator
and required it. validator
contains a bunch of helper functions for common validation requirements, and I use it here instead of RegEx because ISBNs can't be validated with RegEx alone due to a tailing checksum. Remember, users will be sending a JSON body to one of our POST routes. That endpoint will catch any errors (such as an invalid ISBN) when attempting to save, and if one is thrown, it'll return a blank response with an HTTP 400 Bad Request
status — we haven't yet added that functionality.
Finally, we have to define our schema of earlier as the schema for our model, so I'll make a call to mongoose.Schema()
passing in that schema:
const bookSchema = mongoose.Schema(mySchema);
To make things more precise and clean, I'll replace the mySchema
variable with the actual object all on one line:
const bookSchema = mongoose.Schema({ title:{ type: String, required: true, trim: true, }, isbn:{ type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } }, author:{ firstName: { type: String required: true, trim: true }, lastName:{ type: String, required: true, trim: true } }, publishingDate:{ type: String }, finishedReading:{ type: Boolean, required: true, default: false } });
Let's take a final moment to discuss this schema. We are saying that each of our documents will consist of a title, an ISBN, an author with a first and last name, a publishing date, and a finishedReading boolean.
-
title
will be of typeString
, it's a required field, and we'll trim any whitespace. -
isbn
will be of typeString
, it's a required field, it must match the validator, and we'll trim any whitespace. -
author
is of typeobject
containing a required, trimmed,string
firstName and a required, trimmed,string
lastName. -
publishingDate
is of type String (although we could make it of typeDate
orNumber
for a Unix timestamp. -
finishedReading
is a requiredboolean
that will default tofalse
if not provided.
With our bookSchema
defined, Mongoose knows what data and what fields to expect within each document to the collection that stores books. However, how do we tell it what collection that specific schema defines? We could have hundreds of collections, so how do we correlate, or tie, bookSchema
to the Book
collection?
The answer, as seen earlier, is with the use of models. We'll use bookSchema
to create a model, and that model will model the data to be stored in the Book collection, which will be created by Mongoose automatically.
Append the following lines to the end of the file:
const Book = mongoose.model('Book', bookSchema); module.exports = Book;
As you can see, we have created a model, the name of which is Book
(— the first parameter to mongoose.model()
), and also provided the ruleset, or schema, to which all data is saved in the Book collection will have to abide. We export this model as a default export, allowing us to require
the file for our endpoints to access. Book
is the object upon which we'll call all of the required functions to Create, Read, Update, and Delete data which are provided by Mongoose.
Altogether, our book.js
file should look as follows:
const mongoose = require('mongoose'); const validator = require('validator'); // Define the schema. const bookSchema = mongoose.Schema({ title:{ type: String, required: true, trim: true, }, isbn:{ type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } }, author:{ firstName: { type: String, required: true, trim: true }, lastName:{ type: String, required: true, trim: true } }, publishingDate:{ type: String }, finishedReading:{ type: Boolean, required: true, default: false } }); // Create the "Book" model of name Book with schema bookSchema. const Book = mongoose.model('Book', bookSchema); // Provide the model as a default export. module.exports = Book;
Connecting To MongoDB (Basics)
Don't worry about copying down this code. I'll provide a better version in the next section. To connect to our database, we'll have to provide the database URL and password. We'll call the connect
method available on mongoose
to do so, passing to it the required data. For now, we are going hardcode the URL and password — an extremely frowned upon technique for many reasons: namely the accidental committing of sensitive data to a public (or private made public) GitHub Repository. Realize also that commit history is saved, and that if you accidentally commit a piece of sensitive data, removing it in a future commit will not prevent people from seeing it (or bots from harvesting it), because it's still available in the commit history. CLI tools exist to mitigate this issue and remove history.
As stated, for now, we'll hard code the URL and password, and then save them to environment variables later. At this point, let's look at simply how to do this, and then I'll mention a way to optimize it.
const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: false, useUnifiedTopology: true });
Esto se conectará a la base de datos. Proporcionamos la URL que obtuvimos del panel de control de MongoDB Atlas, y el objeto pasado como el segundo parámetro especifica las características que se usarán para, entre otras cosas, evitar las advertencias de obsolescencia.
Mongoose, que utiliza el controlador nativo MongoDB central detrás de escena, tiene que intentar mantenerse al día con los cambios importantes realizados en el controlador. En una nueva versión del controlador, se cambió el mecanismo utilizado para analizar las URL de conexión, por lo que pasamos el useNewUrlParser: true
para especificar que queremos usar la última versión disponible del controlador oficial.
De forma predeterminada, si establece índices (y se denominan "índices" y no "índices") (que no trataremos en este artículo) en los datos de su base de datos, Mongoose utiliza la función ensureIndex()
disponible en el controlador nativo. MongoDB desechó esa función a favor de createIndex()
, por lo que establecer el indicador useCreateIndex
en verdadero le indicará a Mongoose que use el método createIndex()
del controlador, que es la función no obsoleta.
La versión original de Mongoose de findOneAndUpdate
(que es un método para encontrar un documento en una base de datos y actualizarlo) es anterior a la versión de Native Driver. Es decir, findOneAndUpdate()
no era originalmente una función de controlador nativo, sino una proporcionada por Mongoose, por lo que Mongoose tuvo que usar findAndModify
proporcionado en segundo plano por el controlador para crear la funcionalidad findOneAndUpdate
. Con el controlador ahora actualizado, contiene su propia función, por lo que no tenemos que usar findAndModify
. Esto podría no tener sentido, y está bien, no es una información importante en la escala de las cosas.
Finalmente, MongoDB dejó de usar su antiguo sistema de monitoreo de motores y servidores. Usamos el nuevo método con useUnifiedTopology: true
.
Lo que tenemos hasta ahora es una forma de conectarnos a la base de datos. Pero aquí está la cosa: no es escalable ni eficiente. Cuando escribimos pruebas unitarias para esta API, las pruebas unitarias utilizarán sus propios datos de prueba (o accesorios) en sus propias bases de datos de prueba. Por lo tanto, queremos una forma de poder crear conexiones para diferentes propósitos: algunas para entornos de prueba (que podemos activar y desactivar a voluntad), otras para entornos de desarrollo y otras para entornos de producción. Para hacer eso, construiremos una fábrica. (¿Recuerdas eso de antes?)
Conexión a Mongo: construcción de una implementación de una fábrica JS
De hecho, los Objetos de Java no son análogos en absoluto a los Objetos de JavaScript y, por lo tanto, lo que sabemos anteriormente del Patrón de diseño de fábrica no se aplicará. Simplemente proporcioné eso como un ejemplo para mostrar el patrón tradicional. Para obtener un objeto en Java, C# o C++, etc., tenemos que instanciar una clase. Esto se hace con la new
palabra clave, que indica al compilador que asigne memoria para el objeto en el montón. En C++, esto nos da un puntero al objeto que tenemos que limpiar nosotros mismos para que no tengamos punteros colgantes o fugas de memoria (C++ no tiene un recolector de basura, a diferencia de Node/V8 que se basa en C++) En JavaScript, el No es necesario hacer lo anterior, no necesitamos instanciar una clase para obtener un objeto, un objeto es solo {}
. Algunas personas dirán que todo en JavaScript es un objeto, aunque eso técnicamente no es cierto porque los tipos primitivos no son objetos.
Por las razones anteriores, nuestra fábrica JS será más simple y se apegará a la definición amplia de que una fábrica es una función que devuelve un objeto (un objeto JS). Dado que una función es un objeto (porque function
hereda del object
a través de la herencia prototípica), nuestro ejemplo a continuación cumplirá con este criterio. Para implementar la fábrica, crearé una nueva carpeta dentro del server
llamada db
. Dentro de db
, crearé un nuevo archivo llamado mongoose.js
. Este archivo establecerá conexiones con la base de datos. Dentro de mongoose.js
, crearé una función llamada connectionFactory
y la exportaré de forma predeterminada:
// Directory - server/db/mongoose.js const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; const connectionFactory = () => { return mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: false }); }; module.exports = connectionFactory;
Usando la forma abreviada proporcionada por ES6 para las funciones de flecha que devuelven una declaración en la misma línea que la firma del método, simplificaré este archivo eliminando la definición de connectionFactory
y simplemente exportando la fábrica de forma predeterminada:
// server/db/mongoose.js const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; module.exports = () => mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: true });
Ahora, todo lo que hay que hacer es solicitar el archivo y llamar al método que se exporta, así:
const connectionFactory = require('./db/mongoose'); connectionFactory(); // OR require('./db/mongoose')();
Puede invertir el control al proporcionar su URL de MongoDB como un parámetro para la función de fábrica, pero vamos a cambiar dinámicamente la URL como una variable de entorno según el entorno.
Los beneficios de hacer nuestra conexión como una función son que podemos llamar a esa función más adelante en el código para conectarnos a la base de datos desde archivos destinados a producción y aquellos destinados a pruebas de integración local y remota tanto en el dispositivo como con una canalización CI/CD remota. /construir servidor.
Construyendo nuestros terminales
Ahora comenzamos a agregar lógica relacionada con CRUD muy simple a nuestros puntos finales. Como se indicó anteriormente, es necesario un breve descargo de responsabilidad. Los métodos por los cuales implementamos nuestra lógica de negocios aquí no son los que debe reflejar para nada más que proyectos simples. Conectarse a bases de datos y realizar la lógica directamente dentro de los puntos finales está (y debería estar) mal visto, ya que pierde la capacidad de intercambiar servicios o DBMS sin tener que realizar una refactorización de toda la aplicación. No obstante, considerando que este es un artículo para principiantes, empleo estas malas prácticas aquí. Un artículo futuro de esta serie discutirá cómo podemos aumentar tanto la complejidad como la calidad de nuestra arquitectura.
Por ahora, volvamos a nuestro archivo server.js
y asegurémonos de que ambos tenemos el mismo punto de partida. Observe que agregué la instrucción require
para nuestra fábrica de conexiones de base de datos e importé el modelo que exportamos de ./models/book.js
.
const express = require('express'); // Database connection and model. require('./db/mongoose.js'); const Book = require('./models/book.js'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Voy a comenzar con app.post()
. Tenemos acceso al modelo del Book
porque lo exportamos desde el archivo dentro del cual lo creamos. Como se indica en los documentos de Mongoose, Book
es construible. Para crear un nuevo libro, llamamos al constructor y le pasamos los datos del libro, de la siguiente manera:
const book = new Book(bookData);
En nuestro caso, tendremos bookData
como el objeto enviado en la solicitud, que estará disponible en req.body.book
. Recuerde, el middleware express.json()
colocará cualquier dato JSON que enviemos en req.body
. Vamos a enviar JSON en el siguiente formato:
{ "book": { "title": "The Art of Computer Programming", "isbn": "ISBN-13: 978-0-201-89683-1", "author": { "firstName": "Donald", "lastName": "Knuth" }, "publishingDate": "July 17, 1997", "finishedReading": true } }
Lo que eso significa, entonces, es que el JSON que pasamos por alto se analizará, y el middleware express.json()
colocará el objeto JSON completo (el primer par de llaves) en req.body
. La única propiedad de nuestro objeto JSON es book
y, por lo tanto, el objeto book
estará disponible en req.body.book
.
En este punto, podemos llamar a la función constructora del modelo y pasar nuestros datos:
app.post('/books', async (req, res) => { // <- Notice 'async' const book = new Book(req.body.book); await book.save(); // <- Notice 'await' });
Note algunas cosas aquí. Llamar al método save
en la instancia que recibimos después de llamar a la función constructora conservará el objeto req.body.book
en la base de datos si y solo si cumple con el esquema que definimos en el modelo Mongoose. El acto de guardar datos en una base de datos es una operación asíncrona, y este método save()
devuelve una promesa, cuyo establecimiento esperamos con ansias. En lugar de encadenar una llamada .then()
, utilizo la sintaxis ES6 Async/Await, lo que significa que debo hacer la función de devolución de llamada a app.post
async
.
book.save()
rechazará con un ValidationError
si el objeto que envió el cliente no cumple con el esquema que definimos. Nuestra configuración actual genera un código muy escamoso y mal escrito, ya que no queremos que nuestra aplicación se bloquee en caso de que falle la validación. Para arreglar eso, rodearé la operación peligrosa en una cláusula de try/catch
. En caso de error, devolveré una solicitud incorrecta HTTP 400 o una entidad no procesable HTTP 422. Existe cierto debate sobre cuál usar, por lo que me quedaré con un 400 para este artículo, ya que es más genérico.
app.post('/books', async (req, res) => { try { const book = new Book(req.body.book); await book.save(); return res.status(201).send({ book }); } catch (e) { return res.status(400).send({ error: 'ValidationError' }); } });
Tenga en cuenta que utilizo ES6 Object Shorthand para devolver el objeto de book
directamente al cliente en el caso de éxito con res.send({ book })
— eso sería equivalente a res.send({ book: book })
. También devuelvo la expresión solo para asegurarme de que mi función salga. En el bloque catch
, configuro el estado en 400 explícitamente y devuelvo la cadena 'ValidationError' en la propiedad de error
del objeto que se devuelve. Un 201 es el código de estado de la ruta de éxito que significa "CREADO".
De hecho, esta tampoco es la mejor solución porque no podemos estar seguros de que el motivo del error haya sido una solicitud incorrecta por parte del cliente. Tal vez perdimos la conexión (supuestamente una conexión de socket interrumpida, por lo tanto, una excepción transitoria) a la base de datos, en cuyo caso probablemente deberíamos devolver un error de servidor interno 500. Una forma de verificar esto sería leer el objeto de error e
y devolver una respuesta de forma selectiva. Hagámoslo ahora, pero como he dicho varias veces, un artículo de seguimiento discutirá la arquitectura adecuada en términos de enrutadores, controladores, servicios, repositorios, clases de error personalizadas, middleware de error personalizado, respuestas de error personalizadas, modelo de base de datos/datos de entidad de dominio mapeo y separación de consulta de comando (CQS).
app.post('/books', async (req, res) => { try { const book = new Book(req.body.book); await book.save(); return res.send({ book }); } catch (e) { if (e instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'ValidationError' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Continúe y abra Postman (suponiendo que lo tenga, de lo contrario, descárguelo e instálelo) y cree una nueva solicitud. Realizaremos una solicitud POST a localhost:3000/books
. En la pestaña "Cuerpo" dentro de la sección Solicitud de cartero, seleccionaré el botón de radio "sin procesar" y seleccionaré "JSON" en el botón desplegable en el extremo derecho. Esto continuará y agregará automáticamente el Content-Type: application/json
a la solicitud. Luego copiaré y pegaré el objeto Book JSON de antes en el área de texto del cuerpo. Esto es lo que tenemos:
A partir de entonces, presionaré el botón Enviar y debería ver una respuesta 201 Creado en la sección "Respuesta" de Postman (la fila inferior). Vemos esto porque le pedimos específicamente a Express que responda con un 201 y el objeto Libro; si hubiéramos hecho res.send()
sin código de estado, express
habría respondido automáticamente con un 200 OK. Como puede ver, el objeto Book ahora se guarda en la base de datos y se ha devuelto al cliente como respuesta a la solicitud POST.
Si ve la colección de libros de la base de datos a través de MongoDB Atlas, verá que el libro se guardó.
También puede notar que MongoDB ha insertado los campos __v
y _id
. El primero representa la versión del documento, en este caso, 0, y el segundo es el ObjectID del documento, que MongoDB genera automáticamente y garantiza que tiene una baja probabilidad de colisión.
Un resumen de lo que hemos cubierto hasta ahora
Hemos cubierto mucho hasta ahora en el artículo. Tomemos un breve respiro repasando un breve resumen antes de volver a finalizar la API Express.
Aprendimos sobre la desestructuración de objetos de ES6, la sintaxis abreviada de objetos de ES6, así como el operador Rest/Spread de ES6. Los tres nos permiten hacer lo siguiente (y más, como se discutió anteriormente):
// Destructuring Object Properties: const { a: newNameA = 'Default', b } = { a: 'someData', b: 'info' }; console.log(`newNameA: ${newNameA}, b: ${b}`); // newNameA: someData, b: info // Destructuring Array Elements const [elemOne, elemTwo] = [() => console.log('hi'), 'data']; console.log(`elemOne(): ${elemOne()}, elemTwo: ${elemTwo}`); // elemOne(): hi, elemTwo: data // Object Shorthand const makeObj = (name) => ({ name }); console.log(`makeObj('Tim'): ${JSON.stringify(makeObj('Tim'))}`); // makeObj('Tim'): { "name": "Tim" } // Rest, Spread const [c, d, ...rest] = [0, 1, 2, 3, 4]; console.log(`c: ${c}, d: ${d}, rest: ${rest}`) // c: 0, d: 1, rest: 2, 3, 4
También cubrimos Express, Expess Middleware, Servidores, Puertos, Direccionamiento IP, etc. Las cosas se pusieron interesantes cuando supimos que existen métodos disponibles en el resultado de retorno de require('express')();
con los nombres de los Verbos HTTP, como app.get
y app.post
.
Si esa parte require('express')()
no tiene sentido para usted, este era el punto que estaba diciendo:
const express = require('express'); const app = express(); app.someHTTPVerb
Debería tener sentido de la misma manera que activamos la fábrica de conexiones antes para Mongoose.
Cada controlador de ruta, que es la función de punto final (o función de devolución de llamada), se pasa en un objeto req
y un objeto res
de Express detrás de escena. (Técnicamente, también son los next
, como veremos en un minuto). req
contiene datos específicos de la solicitud entrante del cliente, como encabezados o cualquier JSON enviado. res
es lo que nos permite devolver respuestas al cliente. La next
función también se pasa a los controladores.
Con Mongoose, vimos cómo podemos conectarnos a la base de datos con dos métodos: una forma primitiva y una forma más avanzada/práctica que toma prestado de Factory Pattern. Terminaremos usando esto cuando discutamos las pruebas de unidad e integración con Jest (y las pruebas de mutación) porque nos permitirá activar una instancia de prueba de la base de datos poblada con datos semilla contra los cuales podemos ejecutar aserciones.
Después de eso, creamos un objeto de esquema Mongoose y lo usamos para crear un modelo, y luego aprendimos cómo podemos llamar al constructor de ese modelo para crear una nueva instancia de él. En la instancia está disponible un método de save
(entre otros), que es de naturaleza asíncrona y que verificará que la estructura del objeto que pasamos cumpla con el esquema, resolviendo la promesa si es así y rechazando la promesa con un ValidationError
si no es asi. En el caso de una resolución, el nuevo documento se guarda en la base de datos y respondemos con un HTTP 200 OK/201 CREADO; de lo contrario, detectamos el error lanzado en nuestro punto final y devolvemos un HTTP 400 Bad Request al cliente.
A medida que sigamos construyendo nuestros puntos finales, aprenderá más sobre algunos de los métodos disponibles en el modelo y la instancia del modelo.
Terminando Nuestros Puntos Finales
Habiendo completado el POST Endpoint, manejemos GET. Como mencioné anteriormente, la sintaxis :id
dentro de la ruta le permite a Express saber que id
es un parámetro de ruta, accesible desde req.params
. Ya vio que cuando hace coincidir algún ID para el parámetro "comodín" en la ruta, se imprimía en la pantalla en los primeros ejemplos. Por ejemplo, si realizó una solicitud GET a "/books/test-id-123", entonces req.params.id
sería la cadena test-id-123
porque el nombre del parámetro era id
al tener la ruta como HTTP GET /books/:id
.
Entonces, todo lo que tenemos que hacer es recuperar esa ID del objeto req
y verificar si algún documento en nuestra base de datos tiene la misma ID, algo que Mongoose (y el controlador nativo) facilita mucho.
app.get('/books/:id', async (req, res) => { const book = await Book.findById(req.params.id); console.log(book); res.send({ book }); });
Puede ver que accesible en nuestro modelo hay una función a la que podemos llamar que encontrará un documento por su ID. Detrás de escena, Mongoose convertirá cualquier ID que pasemos a findById
al tipo del campo _id
en el documento, o en este caso, un ObjectId
. Si se encuentra un ID coincidente (y solo se encontrará uno porque ObjectId
tiene una probabilidad de colisión extremadamente baja), ese documento se colocará en nuestra variable constante de book
. De lo contrario, el book
será nulo, un hecho que usaremos en un futuro cercano.
Por ahora, reiniciemos el servidor (debe reiniciar el servidor a menos que esté usando nodemon
) y asegúrese de que todavía tengamos el documento de un libro anterior dentro de la Colección de Books
. Continúe y copie la identificación de ese documento, la parte resaltada de la imagen a continuación:
Y utilícelo para realizar una solicitud GET a /books/:id
con Postman de la siguiente manera (tenga en cuenta que los datos del cuerpo solo quedan de mi solicitud POST anterior. En realidad, no se está utilizando a pesar de que se muestra en la imagen a continuación) :
Al hacerlo, debe obtener el documento del libro con la identificación especificada dentro de la sección de respuesta del cartero. Tenga en cuenta que anteriormente, con la ruta POST, que está diseñada para "POST" o "empujar" nuevos recursos al servidor, respondimos con un 201 Creado, porque se creó un nuevo recurso (o documento). En el caso de GET, no se creó nada nuevo: solo solicitamos un recurso con una ID específica, por lo que obtuvimos un código de estado 200 OK, en lugar de 201 Creado.
Como es común en el campo del desarrollo de software, los casos extremos deben tenerse en cuenta: la entrada del usuario es intrínsecamente insegura y errónea, y es nuestro trabajo, como desarrolladores, ser flexibles con los tipos de entrada que podemos recibir y responder a ellos. respectivamente. ¿Qué hacemos si el usuario (o la persona que llama a la API) nos pasa una ID que no se puede convertir a un ObjectID de MongoDB, o una ID que se puede convertir pero que no existe?
Para el primer caso, Mongoose arrojará un CastError
, lo cual es comprensible porque si proporcionamos una ID como math-is-fun
, obviamente eso no es algo que se pueda convertir a un ObjectID, y la conversión a un ObjectID es específicamente lo que Mangosta está haciendo bajo el capó.
Para el último caso, podríamos corregir fácilmente el problema a través de una verificación nula o una cláusula de protección. De cualquier manera, voy a enviar de vuelta una respuesta HTTP 404 Not Found. Le mostraré algunas formas en que podemos hacer esto, una mala y luego una mejor.
En primer lugar, podríamos hacer lo siguiente:
app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) throw new Error(); return res.send({ book }); } catch (e) { return res.status(404).send({ error: 'Not Found' }); } });
Esto funciona y podemos usarlo muy bien. Espero que la declaración await Book.findById()
arroje un Mongoose CastError
si la cadena de ID no se puede convertir a un ObjectID, lo que hace que se ejecute el bloque catch
. Si se puede convertir pero el ObjectID correspondiente no existe, el book
será null
y la verificación nula arrojará un error, activando nuevamente el bloque catch
. Inside catch
, solo devolvemos un 404. Aquí hay dos problemas. En primer lugar, incluso si se encuentra el Libro pero se produce algún otro error desconocido, devolvemos un 404 cuando probablemente deberíamos darle al cliente un 500 general genérico. En segundo lugar, no estamos diferenciando realmente entre si el ID enviado es válido pero inexistente, o si es solo una mala identificación.
Entonces, aquí hay otra manera:
const mongoose = require('mongoose'); app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) return res.status(404).send({ error: 'Not Found' }); return res.send({ book }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Lo bueno de esto es que podemos manejar los tres casos de un 400, un 404 y un 500 genérico. Tenga en cuenta que después de Null Check on book
, uso la palabra clave return
en mi respuesta. Esto es muy importante porque queremos asegurarnos de que salimos del controlador de ruta allí.
Algunas otras opciones podrían ser que verifiquemos si la id
en req.params
se puede convertir a un ObjectID explícitamente en lugar de permitir que Mongoose se convierta implícitamente con mongoose.Types.ObjectId.isValid('id);
, pero hay un caso límite con cadenas de 12 bytes que hace que a veces esto funcione de forma inesperada.
Podríamos hacer que dicha repetición sea menos dolorosa con Boom
, una biblioteca de respuesta HTTP, por ejemplo, o podríamos emplear Error Handling Middleware. También podríamos transformar Mongoose Errors en algo más legible con Mongoose Hooks/Middleware como se describe aquí. Una opción adicional sería definir objetos de error personalizados y usar Middleware de manejo de errores Express global, sin embargo, guardaré eso para un próximo artículo en el que discutiremos mejores métodos arquitectónicos.
En el extremo de PATCH /books/:id
, esperamos que se pase un objeto de actualización que contenga actualizaciones para el libro en cuestión. Para este artículo, permitiremos que se actualicen todos los campos, pero en el futuro, mostraré cómo podemos rechazar las actualizaciones de campos particulares. Además, verá que la lógica de manejo de errores en nuestro punto final PATCH será la misma que en nuestro punto final GET. Esa es una indicación de que estamos violando los Principios DRY, pero nuevamente, hablaremos de eso más adelante.
Espero que todas las actualizaciones estén disponibles en el objeto de updates
de req.body
(lo que significa que el cliente enviará JSON que contiene un objeto de updates
) y usará la función Book.findByAndUpdate
con un indicador especial para realizar la actualización.
app.patch('/books/:id', async (req, res) => { const { id } = req.params; const { updates } = req.body; try { const updatedBook = await Book.findByIdAndUpdate(id, updates, { runValidators: true, new: true }); if (!updatedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: updatedBook }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Note algunas cosas aquí. Primero desestructuramos la id
de req.params
y las updates
de req.body
.
Disponible en el modelo Book
hay una función con el nombre de findByIdAndUpdate
que toma la ID del documento en cuestión, las actualizaciones a realizar y un objeto de opciones opcional. Normalmente, Mongoose no volverá a realizar la validación para las operaciones de actualización, por lo que el runValidators: true
lo pasamos cuando el objeto de options
lo obliga a hacerlo. Además, a partir de Mongoose 4, Model.findByIdAndUpdate
ya no devuelve el documento modificado, sino el documento original. El indicador new: true
(que es falso de forma predeterminada) anula ese comportamiento.
Finalmente, podemos construir nuestro extremo DELETE, que es bastante similar a todos los demás:
app.delete('/books/:id', async (req, res) => { try { const deletedBook = await Book.findByIdAndDelete(req.params.id); if (!deletedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: deletedBook }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Con eso, nuestra API primitiva está completa y puede probarla haciendo solicitudes HTTP a todos los puntos finales.
Un breve descargo de responsabilidad sobre la arquitectura y cómo la rectificaremos
Desde un punto de vista arquitectónico, el código que tenemos aquí es bastante malo, es desordenado, no es SECO, no es SÓLIDO, de hecho, incluso podría llamarlo abominable. Estos llamados "Manejadores de ruta" están haciendo mucho más que solo "entregar rutas": están interactuando directamente con nuestra base de datos. Eso significa que no hay absolutamente ninguna abstracción.
Seamos realistas, la mayoría de las aplicaciones nunca serán tan pequeñas o probablemente podría salirse con la suya con arquitecturas sin servidor con Firebase Database. Tal vez, como veremos más adelante, los usuarios quieran la capacidad de cargar avatares, citas y fragmentos de sus libros, etc. Tal vez queramos agregar una función de chat en vivo entre usuarios con WebSockets, e incluso lleguemos a decir que Abrirá nuestra aplicación para permitir que los usuarios se presten libros entre sí por un pequeño cargo, momento en el que debemos considerar la integración de pagos con la API de Stripe y la logística de envío con la API de Shippo.
Supongamos que procedemos con nuestra arquitectura actual y agregamos toda esta funcionalidad. Estos manipuladores de ruta, también conocidos como Acciones de controlador, terminarán siendo muy, muy grandes con una alta complejidad ciclomática . Tal estilo de codificación podría ser adecuado para nosotros en los primeros días, pero ¿qué sucede si decidimos que nuestros datos son referenciales y, por lo tanto, PostgreSQL es una mejor opción de base de datos que MongoDB? Ahora tenemos que refactorizar toda nuestra aplicación, eliminando Mongoose, modificando nuestros controladores, etc., todo lo cual podría generar errores potenciales en el resto de la lógica comercial. Otro ejemplo sería el de decidir que AWS S3 es demasiado costoso y deseamos migrar a GCP. Nuevamente, esto requiere un refactor de toda la aplicación.
Aunque hay muchas opiniones sobre la arquitectura, desde el diseño basado en dominios, la segregación de responsabilidad de consulta de comandos y el abastecimiento de eventos hasta el desarrollo basado en pruebas, SOILD, arquitectura en capas, arquitectura cebolla y más, nos centraremos en implementar una arquitectura en capas simple en artículos futuros, que constan de Controladores, Servicios y Repositorios, y emplean Patrones de Diseño como Composición, Adaptadores/Envolturas e Inversión de Control a través de Inyección de Dependencia. Si bien, hasta cierto punto, esto se podría realizar de alguna manera con JavaScript, también analizaremos las opciones de TypeScript para lograr esta arquitectura, lo que nos permitirá emplear paradigmas de programación funcional como cualquiera de las mónadas además de conceptos de OOP como genéricos.
Por ahora, hay dos pequeños cambios que podemos hacer. Debido a que nuestra lógica de manejo de errores es bastante similar en el bloque catch
de todos los puntos finales, podemos extraerlo a una función Express Error Handling Middleware personalizada al final de la pila.
Limpiando nuestra arquitectura
Actualmente, estamos repitiendo una gran cantidad de lógica de manejo de errores en todos nuestros puntos finales. En su lugar, podemos crear una función Express Error Handling Middleware, que es una función Express Middleware que se llama con un error, los objetos req y res, y la siguiente función.
Por ahora, construyamos esa función de middleware. Todo lo que voy a hacer es repetir la misma lógica de manejo de errores a la que estamos acostumbrados:
app.use((err, req, res, next) => { if (err instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'Validation Error' }); } else if (err instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { console.log(err); // Unexpected, so worth logging. return res.status(500).send({ error: 'Internal error' }); } });
Esto no parece funcionar con Mongoose Errors, pero en general, en lugar de usar if/else if/else
para determinar las instancias de error, puede cambiar el constructor del error. Sin embargo, dejaré lo que tenemos.
En un controlador de punto final/ruta síncrono , si arroja un error, Express lo detectará y lo procesará sin necesidad de trabajo adicional de su parte. Desafortunadamente, ese no es el caso para nosotros. Estamos tratando con código asíncrono . Para delegar el manejo de errores a Express con controladores de rutas asíncronas, detectamos el error nosotros mismos y lo pasamos a next()
.
Entonces, solo permitiré que next
sea el tercer argumento en el punto final, y eliminaré la lógica de manejo de errores en los bloques catch
a favor de simplemente pasar la instancia de error a next
, como tal:
app.post('/books', async (req, res, next) => { try { const book = new Book(req.body.book); await book.save(); return res.send({ book }); } catch (e) { next(e) } });
Si hace esto con todos los controladores de ruta, debería terminar con el siguiente código:
const express = require('express'); const mongoose = require('mongoose'); // Database connection and model. require('./db/mongoose.js')(); const Book = require('./models/book.js'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', async (req, res, next) => { try { const book = new Book(req.body.book); await book.save(); return res.status(201).send({ book }); } catch (e) { next(e) } }); // HTTP GET /books/:id app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) return res.status(404).send({ error: 'Not Found' }); return res.send({ book }); } catch (e) { next(e); } }); // HTTP PATCH /books/:id app.patch('/books/:id', async (req, res, next) => { const { id } = req.params; const { updates } = req.body; try { const updatedBook = await Book.findByIdAndUpdate(id, updates, { runValidators: true, new: true }); if (!updatedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: updatedBook }); } catch (e) { next(e); } }); // HTTP DELETE /books/:id app.delete('/books/:id', async (req, res, next) => { try { const deletedBook = await Book.findByIdAndDelete(req.params.id); if (!deletedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: deletedBook }); } catch (e) { next(e); } }); // Notice - bottom of stack. app.use((err, req, res, next) => { if (err instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'Validation Error' }); } else if (err instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { console.log(err); // Unexpected, so worth logging. return res.status(500).send({ error: 'Internal error' }); } }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Avanzando más, valdría la pena separar nuestro middleware de manejo de errores en otro archivo, pero eso es trivial y lo veremos en futuros artículos de esta serie. Además, podríamos usar un módulo NPM llamado express-async-errors
para no tener que llamar a next en el bloque catch, pero nuevamente, estoy tratando de mostrarle cómo se hacen las cosas oficialmente.
Una palabra sobre CORS y la política del mismo origen
Suponga que su sitio web se sirve desde el dominio myWebsite.com
pero su servidor está en myOtherDomain.com/api
. CORS significa Intercambio de recursos de origen cruzado y es un mecanismo mediante el cual se pueden realizar solicitudes de dominio cruzado. En el caso anterior, dado que el servidor y el código JS front-end se encuentran en diferentes dominios, estaría realizando una solicitud a través de dos orígenes diferentes, lo que comúnmente está restringido por el navegador por razones de seguridad y mitigado al proporcionar encabezados HTTP específicos.
La política del mismo origen es lo que realiza las restricciones antes mencionadas: un navegador web solo permitirá que se realicen solicitudes en el mismo origen.
Hablaremos de CORS y SOP más adelante cuando construyamos un front-end incluido en un paquete web para nuestra API de libros con React.
Conclusión y lo que sigue
Hemos discutido mucho en este artículo. Tal vez no todo fue completamente práctico, pero espero que te haya hecho sentir más cómodo trabajando con las funciones de JavaScript de Express y ES6. Si es nuevo en la programación y Node es el primer camino por el que se está embarcando, es de esperar que las referencias a lenguajes de tipos estáticos como Java, C++ y C# ayuden a resaltar algunas de las diferencias entre JavaScript y sus contrapartes estáticas.
Next time, we'll finish building out our Book API by making some fixes to our current setup with regards to the Book Routes, as well as adding in User Authentication so that users can own books. We'll do all of this with a similar architecture to what I described here and with MongoDB for data persistence. Finally, we'll permit users to upload avatar images to AWS S3 via Buffers.
In the article thereafter, we'll be rebuilding our application from the ground up in TypeScript, still with Express. We'll also move to PostgreSQL with Knex instead of MongoDB with Mongoose as to depict better architectural practices. Finally, we'll update our avatar image uploading process to use Node Streams (we'll discuss Writable, Readable, Duplex, and Transform Streams). Along the way, we'll cover a great amount of design and architectural patterns and functional paradigms, including:
- Controllers/Controller Actions
- Servicios
- Repositorios
- Mapeo de datos
- The Adapter Pattern
- The Factory Pattern
- The Delegation Pattern
- OOP Principles and Composition vs Inheritance
- Inversion of Control via Dependency Injection
- SOLID Principles
- Coding against interfaces
- Data Transfer Objects
- Domain Models and Domain Entities
- Either Monads
- Validación
- Decorators
- Logging and Logging Levels
- Unit Tests, Integration Tests (E2E), and Mutation Tests
- The Structured Query Language
- Relations
- HTTP/Express Security Best Practices
- Node Best Practices
- OWASP Security Best Practices
- Y más.
Using that new architecture, in the article after that, we'll write Unit, Integration, and Mutation tests, aiming for close to 100 percent testing coverage, and we'll finally discuss setting up a remote CI/CD pipeline with CircleCI, as well as Message Busses, Job/Task Scheduling, and load balancing/reverse proxying.
Hopefully, this article has been helpful, and if you have any queries or concerns, let me know in the comments below.