Saltar al contenido

c

Guardando datos en MongoDB

Antes de pasar al tema principal de persistir datos en una base de datos, veremos algunas formas diferentes de depurar aplicaciones de Node.

Depuración en aplicaciones de Node

Depurar (debugging) aplicaciones de Node es un poco más difícil que depurar JavaScript que se ejecuta en el navegador. Imprimir en la consola es un método probado y confiable, siempre vale la pena hacerlo. Hay personas que piensan que se deberían utilizar métodos más sofisticados en su lugar, pero no estoy de acuerdo. Incluso los desarrolladores de código abierto de élite del mundo utilizan este método.

Visual Studio Code

El depurador de Visual Studio Code puede ser útil en algunas situaciones. Puedes iniciar la aplicación en modo de depuración de la siguiente manera (en esta y en las próximas imágenes, las notas tienen un campo date que se ha eliminado de la versión actual de la aplicación):

captura de pantalla mostrando como ejecutar el depurador de vscode

Ten en cuenta que la aplicación no debería ejecutarse en otra consola, de lo contrario, el puerto ya estará en uso.

NB Una versión más reciente de Visual Studio Code puede tener Run en lugar de Debug. Además, es posible que debas configurar tu archivo launch.json para comenzar a depurar. Esto se puede hacer eligiendo Add Configuration... en el menú desplegable, que se encuentra junto al botón de reproducción verde y arriba del menú VARIABLES, y seleccionando Run "npm start" in a debug terminal. Para obtener instrucciones de configuración más detalladas, visita la documentación de depuración de Visual Studio Code.

A continuación, puedes ver una captura de pantalla donde la ejecución del código se ha detenido a medio camino de guardar una nueva nota:

captura de pantalla de la ejecución de un breakpoint en vscode

La ejecución se ha detenido en el breakpoint (punto de interrupción) de la línea 69. En la consola puedes ver el valor de la variable de note. En la ventana superior izquierda puede ver otras cosas relacionadas con el estado de la aplicación.

Las flechas en la parte superior se pueden utilizar para controlar el flujo del depurador.

Por alguna razón, no uso mucho el debugger de Visual Studio Code.

Chrome dev tools

La depuración también es posible con la consola de desarrollo de Chrome iniciando su aplicación con el comando:

node --inspect index.js

También puedes pasar la bandera --inspect a nodemon

nodemon --inspect index.js

Puedes acceder al depurador haciendo clic en el icono verde - el logotipo de node - que aparece en la consola de desarrollo de Chrome:

herramientas de desarrolladores con logotipo verde de node

La vista de depuración funciona de la misma manera que con las aplicaciones React. La pestaña Sources se puede usar para establecer breakpoints donde se pausará la ejecución del código.

pestaña "Sources" de las herramientas de desarrollo con breakpoint y variables de observación

Todos los mensajes console.log de la aplicación aparecerán en la pestaña Console del depurador. También puedes inspeccionar valores de variables y ejecutar tu propio código JavaScript.

pestaña "Consola" de las herramientas de desarrollo mostrando el objeto de nota escrito

Cuestionar todo

Depurar aplicaciones Full Stack puede parecer complicado al principio. Pronto nuestra aplicación también tendrá una base de datos además del frontend y el backend, y habrá muchas áreas con potenciales errores en la aplicación.

Cuando la aplicación "no funciona", primero tenemos que averiguar dónde ocurre realmente el problema. Es muy común que el problema exista en un lugar donde no lo esperabas, y pueden pasar minutos, horas o incluso días antes de que encuentres la fuente del problema.

La clave es ser sistemático. Dado que el problema puede estar en cualquier lugar, debes cuestionarlo todo y eliminar todas las posibilidades una por una. El registro en la consola, Postman, los depuradores y la experiencia te ayudarán.

Cuando ocurren errores, la peor de todas las estrategias posibles es continuar escribiendo código. Garantizará que tu código pronto tendrá aún más errores, y depurarlos será aún más difícil. El principio Jidoka (detenerse y reparar) de Toyota Production Systems también es muy eficaz en esta situación.

MongoDB

Para almacenar nuestras notas guardadas indefinidamente, necesitamos una base de datos. La mayoría de los cursos que se imparten en la Universidad de Helsinki utilizan bases de datos relacionales. En este curso usaremos MongoDB, que es una base de datos de documentos.

La razón para usar Mongo como la base de datos es su menor complejidad en comparación con una base de datos relacional. La parte 13 del curso muestra cómo construir backends de Node.js que utilizan una base de datos relacional.

Las bases de datos de documentos difieren de las bases de datos relacionales en cómo organizan los datos, así como en los lenguajes de consulta que admiten. Las bases de datos de documentos generalmente se clasifican bajo el término general NoSQL.

Puedes leer más sobre bases de datos de documentos y NoSQL en el material del curso de la semana 7 del curso Introducción a las bases de datos. Lamentablemente, el material actualmente solo está disponible en finlandés.

Lee ahora los capítulos sobre colecciones y documentos del manual de MongoDB para tener una idea básica de cómo una base de datos de documentos almacena datos.

Naturalmente, puedes instalar y ejecutar MongoDB en tu propia computadora. Sin embargo, Internet también está lleno de servicios de base de datos de Mongo que puedes utilizar. Nuestro proveedor preferido de MongoDB en este curso será MongoDB Atlas.

Una vez que hayas creado y accedido a tu cuenta, comencemos seleccionando la opción gratuita:

mongodb deploy a cloud database free shared

Elige el proveedor de la nube y la ubicación, y crea el clúster:

mongodb picking shared, aws and region

Esperemos a que el clúster esté listo para su uso. Esto puede llevar algunos minutos.

NB No continúes antes de que el clúster esté listo.

Usemos la pestaña security para crear credenciales de usuario para la base de datos. Ten en cuenta que estas no son las mismas credenciales que utilizas para iniciar sesión en MongoDB Atlas. Estas se usarán para que tu aplicación se conecte a la base de datos.

mongodb security quickstart

A continuación, debemos definir las direcciones IP que tienen permitido el acceso a la base de datos. Por simplicidad, permitiremos el acceso desde todas las direcciones IP:

mongodb network access/add ip access list

Nota: En caso de que el menú modal sea diferente para ti, según la documentación de MongoDB, agregar 0.0.0.0 como una IP permite el acceso desde cualquier lugar.

Finalmente, estamos listos para conectarnos a nuestra base de datos. Comienza haciendo clic en connect:

mongodb database deployment connect

y elige: Connect to your application:

mongodb connect application

La vista muestra el MongoDB URI, que es la dirección de la base de datos que proporcionaremos a la librearía de cliente de MongoDB que agregaremos a nuestra aplicación.

La dirección se ve así:

mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority

Ahora estamos listos para usar la base de datos.

Podríamos usar la base de datos directamente desde nuestro código JavaScript con la librería de controladores oficial MongoDb Node.js, pero es bastante engorroso de usar. En su lugar, usaremos la libería Mongoose que ofrece una API de nivel superior.

Mongoose podría describirse como un object document mapper (ODM) o mapeador de objetos a documentos en castellano, guardar objetos JavaScript como documentos en Mongo es sencillo con esta libería.

Instalemos Mongoose:

npm install mongoose

No agreguemos ningún código relacionado con Mongo a nuestro backend por el momento. En cambio, hagamos una aplicación de práctica creando un nuevo archivo, mongo.js en la raíz del backend de la aplicación de notas:

const mongoose = require('mongoose')

if (process.argv.length<3) {
  console.log('give password as argument')
  process.exit(1)
}

const password = process.argv[2]

const url =
  `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority`

mongoose.set('strictQuery',false)

mongoose.connect(url)

const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

const note = new Note({
  content: 'HTML is easy',
  important: true,
})

note.save().then(result => {
  console.log('note saved!')
  mongoose.connection.close()
})

NB: Dependiendo de la región que seleccionaste al crear tu clúster, el MongoDB URI puede ser diferente al del ejemplo proporcionado anteriormente. Debes verificar y usar el URI correcto que se generó a partir de MongoDB Atlas.

El código también asume que se le pasará la contraseña de las credenciales que creamos en MongoDB Atlas, como un parámetro de línea de comando. Podemos acceder al parámetro de la línea de comandos así:

const password = process.argv[2]

Cuando el código se ejecuta con el comando node mongo.js yourPassword, Mongo agregará un nuevo documento a la base de datos.

NB: Ten en cuenta que la contraseña es la contraseña creada para el usuario de la base de datos, no su contraseña de MongoDB Atlas. Además, si creaste una contraseña con caracteres especiales, deberas codificar esa contraseña en la URL.

Podemos ver el estado actual de la base de datos en MongoDB Atlas desde Browse collections, en la pestaña Database.

Botón para explorar colecciones en las bases de datos de MongoDB

Según la vista, el documento que coincide con la nota se ha añadido a la colección notes en la base de datos myFirstDatabase.

Pestaña de colecciones de MongoDB en la base de datos myfirst app notes

Destruyamos la base de datos predeterminada test y cambiemos el nombre de la base de datos referenciada en nuestra cadena de conexión a noteApp, modificando la URI:

const url =
  `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority`

Ejecutemos nuestro código de nuevo.

Pestaña de colecciones de MongoDB en la base de datos noteApp con la colección notes

Los datos ahora se almacenan en la base de datos correcta. La vista también ofrece la función de create database, que se puede utilizar para crear nuevas bases de datos desde el sitio web. No es necesario crear la base de datos de esta manera, ya que MongoDB Atlas crea automáticamente una nueva base de datos cuando una aplicación intenta conectarse a una base de datos que aún no existe.

Schema

Después de establecer la conexión a la base de datos, definimos el esquema para una nota y el modelo correspondiente:

const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

Primero definimos el esquema de una nota que se almacena en la variable noteSchema. El esquema le dice a Mongoose cómo se almacenarán los objetos de nota en la base de datos.

En la definición del modelo Note, el primer parámetro de "Note" es el nombre singular del modelo. El nombre de la colección será el plural notes en minúsculas, porque la convención de Mongoose es nombrar automáticamente las colecciones como el plural (por ejemplo, notes) cuando el esquema se refiere a ellas en singular (por ejemplo, Note).

Las bases de datos de documentos como Mongo no tienen esquema, lo que significa que la base de datos en sí no se preocupa por la estructura de los datos que se almacenan en la base de datos. Es posible almacenar documentos con campos completamente diferentes en la misma colección.

La idea detrás de Mongoose es que los datos almacenados en la base de datos reciben un esquema al nivel de la aplicación que define la forma de los documentos almacenados en una colección determinada.

Crear y guardar objetos

A continuación, la aplicación crea un nuevo objeto de nota con la ayuda del modelo Note:

const note = new Note({
  content: 'HTML is Easy',
  important: false,
})

Los modelos son funciones constructoras que crean nuevos objetos JavaScript basados ​​en los parámetros proporcionados. Dado que los objetos se crean con la función constructora del modelo, tienen todas las propiedades del modelo, que incluyen métodos para guardar el objeto en la base de datos.

Guardar el objeto en la base de datos ocurre con el método save, que se puede proporcionar con un controlador de eventos con el método then:

note.save().then(result => {
  console.log('note saved!')
  mongoose.connection.close()
})

Cuando el objeto se guarda en la base de datos, el controlador de eventos proporcionado a then se invoca. El controlador de eventos cierra la conexión de la base de datos con el comando mongoose.connection.close(). Si la conexión no se cierra, el programa nunca terminará su ejecución.

El resultado de la operación de guardar está en el parámetro result del controlador de eventos. El resultado no es tan interesante cuando almacenamos un objeto en la base de datos. Puedes imprimir el objeto en la consola si deseas verlo más de cerca mientras implementas tu aplicación o durante la depuración.

Guardemos también algunas notas más modificando los datos en el código y ejecutando el programa nuevamente.

NB: Desafortunadamente, la documentación de Mongoose no es muy consistente, con partes de ella usando callbacks en sus ejemplos y otras partes, otros estilos, por lo que no se recomienda copiar y pegar código directamente desde allí. No se recomienda mezclar promesas con callbacks de la vieja escuela en el mismo código.

Obteniendo objetos de la base de datos

Comentemos el código para generar nuevas notas y reemplázalo con lo siguiente:

Note.find({}).then(result => {
  result.forEach(note => {
    console.log(note)
  })
  mongoose.connection.close()
})

Cuando se ejecuta el código, el programa imprime todas las notas almacenadas en la base de datos:

salida de notes como JSON al ejecutar el comando node mongo.js

Los objetos se recuperan de la base de datos con el método find del modelo Note. El parámetro del método es un objeto que expresa condiciones de búsqueda. Dado que el parámetro es un objeto vacío {}, obtenemos todas las notas almacenadas en la colección notes.

Las condiciones de búsqueda se adhieren a la sintaxis de consulta de búsqueda de Mongo.

Podríamos restringir nuestra búsqueda para incluir solo notas importantes de la siguiente manera:

Note.find({ important: true }).then(result => {
  // ...
})

Backend conectado a una base de datos

Ahora tenemos suficiente conocimiento para comenzar a usar Mongo en nuestra aplicación.

Comencemos rápidamente copiando y pegando las definiciones de Mongoose en el archivo index.js:

const mongoose = require('mongoose')

const password = process.argv[2]

// DO NOT SAVE YOUR PASSWORD TO GITHUB!!
const url =
  `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority`

mongoose.set('strictQuery',false)
mongoose.connect(url)

const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

Cambiemos el controlador para obtener todas las notas al siguiente formato:

app.get('/api/notes', (request, response) => {
  Note.find({}).then(notes => {
    response.json(notes)
  })
})

Podemos verificar en el navegador que el backend funciona para mostrar todos los documentos:

api/notes en el navegador muestra notas en JSON

La aplicación funciona casi a la perfección. El frontend asume que cada objeto tiene un id único en el campo de id. Tampoco queremos retornar el campo de control de versiones de mongo __v al frontend.

Una forma de formatear los objetos devueltos por Mongoose es modificar el método toJSON del esquema, que se utiliza en todas las instancias de los modelos producidos con ese esquema.

Para modificar el método, necesitamos cambiar las opciones configurables del esquema. Las opciones se pueden cambiar utilizando el método set del esquema. Consulta aquí para obtener más información sobre este método: https://mongoosejs.com/docs/guide.html#options. Consulta https://mongoosejs.com/docs/guide.html#toJSON y https://mongoosejs.com/docs/api.html#document_Document-toObject para obtener más información sobre la opción toJSON.

Consulta https://mongoosejs.com/docs/api/document.html#transform para obtener más información sobre la función transform.

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

Aunque la propiedad _id de los objetos Mongoose parece un string, de hecho es un objeto. El método toJSON que definimos lo transforma en un string solo para estar seguros. Si no hiciéramos este cambio, nos causaría más daño en el futuro una vez que comencemos a escribir pruebas.

No es necesario hacer cambios en el controlador:

app.get('/api/notes', (request, response) => {
  Note.find({}).then(notes => {
    response.json(notes)
  })
})

El código utiliza automáticamente el toJSON definido al formatear las notas para la respuesta.

Moviendo la configuración de la base de datos a su propio módulo

Antes de refactorizar el resto del backend para usar la base de datos, extraigamos el código específico de Mongoose en su propio módulo.

Creemos un nuevo directorio para el módulo llamado models y agreguemos un archivo llamado note.js:

const mongoose = require('mongoose')

mongoose.set('strictQuery', false)

const url = process.env.MONGODB_URI
console.log('connecting to', url)
mongoose.connect(url)
  .then(result => {    console.log('connected to MongoDB')  })  .catch(error => {    console.log('error connecting to MongoDB:', error.message)  })
const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

module.exports = mongoose.model('Note', noteSchema)

La definición de módulos de Node difiere ligeramente de la forma de definir módulos ES6 en la parte 2.

La interfaz pública del módulo se define estableciendo un valor en la variable module.exports. Estableceremos el valor para que sea el modelo Note. Las otras cosas definidas dentro del módulo, como las variables mongoose y url, no serán accesibles ni visibles para los usuarios del módulo.

La importación del módulo ocurre agregando la siguiente línea a index.js :

const Note = require('./models/note')

De esta forma la variable Note se asignará al mismo objeto que defina el módulo.

La forma en que se realiza la conexión ha cambiado ligeramente:

const url = process.env.MONGODB_URI

console.log('connecting to', url)

mongoose.connect(url)
  .then(result => {
    console.log('connected to MongoDB')
  })
  .catch(error => {
    console.log('error connecting to MongoDB:', error.message)
  })

No es una buena idea codificar la dirección de la base de datos en el código, por lo que la dirección de la base de datos se pasa a la aplicación a través de la variable de entorno MONGODB_URI.

El método para establecer la conexión ahora tiene funciones para lidiar con un intento de conexión exitoso y no exitoso. Ambas funciones simplemente registran un mensaje en la consola sobre el estado de éxito:

salida de node cuando se pasa username/password erroneo

Hay muchas formas de definir el valor de una variable de entorno. Una forma sería definirlo cuando se inicia la aplicación:

MONGODB_URI=address_here npm run dev

Una forma más sofisticada es utilizar la librería dotenv. Puedes instalar la librería con el comando:

npm install dotenv

Para usar la librería, creamos un archivo .env en la raíz del proyecto. Las variables de entorno se definen dentro del archivo y pueden verse así:

MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority
PORT=3001

También agregamos el puerto codificado del servidor en la variable de entorno PORT.

El archivo .env debe ignorarse de inmediato en .gitignore, ¡ya que no queremos publicar ninguna información confidencial públicamente!

.gitignore en vscode con .env añadido

Las variables de entorno definidas en el archivo .env se pueden utilizar con la expresión require('dotenv').config() y puedes referenciarlas en tu código como lo harías con las variables de entorno normales, con la sintaxis process.env.MONGODB_URI.

Cambiemos el archivo index.js de la siguiente manera:

require('dotenv').config()const express = require('express')
const app = express()
const Note = require('./models/note')
// ..

const PORT = process.env.PORTapp.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

Es importante que dotenv se importe antes de importar el modelo note. Esto asegura que las variables de entorno del archivo .env estén disponibles globalmente antes de que se importe el código de los otros módulos.

Nota importante para usuarios de Fly.io

Debido a que GitHub no se utiliza con Fly.io, el archivo .env también se copia a los servidores de Fly.io cuando se despliega la aplicación. Debido a esto, las variables de entorno definidas en el archivo estarán disponibles allí.

Sin embargo, una mejor opción es evitar que .env se copie a Fly.io creando en la raíz del proyecto el archivo .dockerignore, con el siguiente contenido:

.env

y estableciendo el valor de la variable de entorno desde la línea de comandos con el comando:

fly secrets set MONGODB_URI="mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority"

Dado que PORT también está definido en nuestro archivo .env, es esencial ignorar el archivo en Fly.io, ya que de lo contrario, la aplicación se inicia en el puerto incorrecto.

Al utilizar Render, la URL de la base de datos se proporciona definiendo la variable de entorno adecuada en el panel de control:

navegador mostrando variables de entorno de Render

Solo establece a la URL comenzando con mongodb+srv://... al campo value.

Usando la base de datos en los controladores de ruta

A continuación, cambiemos el resto de la funcionalidad del backend para usar la base de datos.

La creación de una nueva nota se logra así:

app.post('/api/notes', (request, response) => {
  const body = request.body

  if (body.content === undefined) {
    return response.status(400).json({ error: 'content missing' })
  }

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })

  note.save().then(savedNote => {
    response.json(savedNote)
  })
})

Los objetos de nota se crean con la función de constructor Note. La respuesta se envía dentro de la función callback para la operación save. Esto asegura que la respuesta se envíe solo si la operación se realizó correctamente. Discutiremos el manejo de errores un poco más adelante.

El parámetro savedNote en la función callback es la nota guardada y recién creada. Los datos devueltos en la respuesta son la versión formateada creada con el método toJSON :

response.json(savedNote)

Usando el método findById de Mongoose, la obtención de una nota individual se cambia a lo siguiente:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id).then(note => {
    response.json(note)
  })
})

Verificación de la integración de frontend y backend

Cuando el backend se expande, es una buena idea probar el backend primero con el navegador, Postman o el cliente REST de VS Code. A continuación, intentemos crear una nueva nota después de utilizar la base de datos:

VS code cliente rest haciendo un post

Solo una vez que se haya verificado que todo funciona en el backend, es una buena idea probar que el frontend funciona con el backend. Es muy ineficiente probar cosas exclusivamente a través del frontend.

Probablemente sea una buena idea integrar el frontend y el backend una funcionalidad a la vez. Primero, podríamos implementar la búsqueda de todas las notas de la base de datos y probarlas a través del endpoint de backend en el navegador. Después de esto, podríamos verificar que el frontend funciona con el nuevo backend. Una vez que todo parezca funcionar, pasaríamos a la siguiente funcionalidad.

Una vez que introducimos una base de datos en la mezcla, es útil inspeccionar el estado persistente en la base de datos, por ejemplo, desde el panel de control en MongoDB Atlas. Muy a menudo, los pequeños programas auxiliares de Node como el programa mongo.js que escribimos anteriormente pueden ser muy útiles durante el desarrollo.

Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part3-4 de este repositorio de GitHub.

Manejo de errores

Si intentamos visitar la URL de una nota con un id que en realidad no existe, por ejemplo, http://localhost:3001/api/notes/5c41c90e84d891c15dfa3431 donde 5c41c90e84d891c15dfa3431 no es un id almacenado en la base de datos, entonces la respuesta será null.

Cambiemos este comportamiento para que si la nota con la identificación dada no existe, el servidor responderá a la solicitud con el código de estado HTTP 404 not found. Además, implementemos un bloque catch sencillo para manejar los casos en los que la promesa devuelta por el método findById es rechazada:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {        response.json(note)      } else {        response.status(404).end()      }    })
    .catch(error => {      console.log(error)      response.status(500).end()    })})

Si no se encuentra ningún objeto coincidente en la base de datos, el valor de note será null y se ejecutará el bloque else. Esto da como resultado una respuesta con el código de estado 404 not found. Si se rechaza la promesa retornada por el método findById, la respuesta tendrá el código de estado 500 internal server error. La consola muestra información más detallada sobre el error.

Además de la nota que no existe, hay una situación de error más que debe manejarse. En esta situación, estamos intentando obtener una nota con un tipo de id incorrecto , es decir, un id que no coincide con el formato del identificador de Mongo.

Si realizamos la siguiente solicitud, obtendremos el mensaje de error que se muestra a continuación:


Method: GET
Path:   /api/notes/someInvalidId
Body:   {}
---
{ CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id"
    at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11)
    at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13)
    ...

Dado un ID mal formado como argumento, el método findById arrojará un error que provocará el rechazo de la promesa retornada. Esto hará que se llame a la función callback definida en el bloque catch.

Hagamos algunos pequeños ajustes a la respuesta en el bloque catch:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note)
      } else {
        response.status(404).end() 
      }
    })
    .catch(error => {
      console.log(error)
      response.status(400).send({ error: 'malformatted id' })    })
})

Si el formato del id es incorrecto, terminaremos en el controlador de errores definido en el bloque catch. El código de estado apropiado para la situación es 400 Bad Request, porque la situación se ajusta perfectamente a la descripción:

El código de estado 400 (Solicitud incorrecta) indica que el servidor no puede o no procesará la solicitud debido a algo que se percibe como un error del cliente (por ejemplo, sintaxis de solicitud incorrecta, formato de mensaje de solicitud inválido o enrutamiento de solicitud engañoso).

También hemos agregado algunos datos a la respuesta para arrojar algo de luz sobre la causa del error.

Cuando se trata de Promesas, casi siempre es una buena idea agregar el manejo de errores y excepciones, porque de lo contrario te encontraras lidiando con errores extraños.

Nunca es una mala idea imprimir el objeto que causó la excepción a la consola en el controlador de errores:

.catch(error => {
  console.log(error)  response.status(400).send({ error: 'malformatted id' })
})

La razón por la que se llama al controlador de errores puede ser algo completamente diferente de lo que habías anticipado. Si registras el error en la consola, puedes ahorrarte largas y frustrantes sesiones de depuración. Además, la mayoría de los servicios modernos en los que despliegas tu aplicación admiten algún tipo de sistema de registro que puedes usar para verificar estos registros. Como se mencionó, Fly.io es uno.

Cada vez que trabajas en un proyecto con un backend, es fundamental estar atento a la salida de la consola del backend. Si estás trabajando en una pantalla pequeña, basta con ver una pequeña porción de la salida en segundo plano. Cualquier mensaje de error llamará tu atención incluso cuando la consola esté muy atrás en segundo plano:

captura de pantalla mostrando trozo pequeño de salida de consola

Mover el manejo de errores al middleware

Hemos escrito el código para el controlador de errores entre el resto de nuestro código. Esta puede ser una solución razonable a veces, pero hay casos en los que es mejor implementar todo el manejo de errores en un solo lugar. Esto puede ser particularmente útil si más adelante queremos reportar datos relacionados con errores a un sistema de seguimiento de errores externo como Sentry.

Cambiemos el controlador de la ruta /api/notes/:id, para que pase el error hacia adelante con la función next. La función next se pasa al controlador como tercer parámetro:

app.get('/api/notes/:id', (request, response, next) => {  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note)
      } else {
        response.status(404).end()
      }
    })
    .catch(error => next(error))})

El error que se pasa hacia adelante es dado a la función next como parámetro. Si se llamó a next sin un argumento, entonces la ejecución simplemente pasaría a la siguiente ruta o middleware. Si se llama a la función next con un argumento, la ejecución continuará en el middleware del controlador de errores.

Los controladores de errores de Express son middleware que se definen con una función que acepta cuatro parámetros. Nuestro controlador de errores se ve así:

const errorHandler = (error, request, response, next) => {
  console.error(error.message)

  if (error.name === 'CastError') {
    return response.status(400).send({ error: 'malformatted id' })
  } 

  next(error)
}

// este debe ser el último middleware cargado, ¡también todas las rutas deben ser registrada antes que esto!
app.use(errorHandler)

El controlador de errores comprueba si el error es una excepción CastError, en cuyo caso sabemos que el error fue causado por un ID de objeto no válido para Mongo. En esta situación, el controlador de errores enviará una respuesta al navegador con el objeto de respuesta pasado como parámetro. En todas las demás situaciones de error, el middleware pasa el error al controlador de errores Express predeterminado.

Ten en cuenta que el middleware de manejo de errores debe ser el último middleware cargado, también todas las rutas deben registrarse antes que el error-handler!

El orden de carga del middleware

El orden de ejecución del middleware es el mismo que el orden en el que se cargan en Express con la función app.use. Por esta razón, es importante tener cuidado al definir el middleware.

El orden correcto es el siguiente:

app.use(express.static('build'))
app.use(express.json())
app.use(logger)

app.post('/api/notes', (request, response) => {
  const body = request.body
  // ...
})

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

// controlador de solicitudes con endpoint desconocido
app.use(unknownEndpoint)

const errorHandler = (error, request, response, next) => {
  // ...
}

// controlador de solicitudes que resulten en errores
app.use(errorHandler)

El middleware json-parser debería estar entre los primeros middleware cargados en Express. Si el orden fuera el siguiente:

app.use(logger) // request.body es undefined!

app.post('/api/notes', (request, response) => {
  // request.body es undefined!
  const body = request.body
  // ...
})

app.use(express.json())

Entonces, los datos JSON enviados con las solicitudes HTTP no estarían disponibles para el middleware del registrador o el controlador de ruta POST, ya que request.body estaría undefined en ese punto.

También es importante que el middleware para manejar rutas no admitidas esté junto al último middleware que se cargó en Express, justo antes del controlador de errores.

Por ejemplo, el siguiente orden de carga causaría un problema:

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

// controlador de solicitudes con endpoint desconocido
app.use(unknownEndpoint)

app.get('/api/notes', (request, response) => {
  // ...
})

Ahora, el manejo de los endpoints desconocidos se ordena antes que el controlador de solicitudes HTTP. Dado que el controlador de endpoint desconocido responde a todas las solicitudes con 404 unknown endpoint, no se llamará a ninguna ruta o middleware después de que el middleware de endpoint desconocido haya enviado la respuesta. La única excepción a esto es el controlador de errores que debe estar al final, después del controlador de endpoints desconocido.

Otras operaciones

Agreguemos algunas funcionalidades que faltan a nuestra aplicación, incluida la eliminación y actualización de una nota individual.

La forma más fácil de eliminar una nota de la base de datos es con el método findByIdAndDelete:

app.delete('/api/notes/:id', (request, response, next) => {
  Note.findByIdAndDelete(request.params.id)
    .then(result => {
      response.status(204).end()
    })
    .catch(error => next(error))
})

En los dos casos "exitosos" de eliminar un recurso, el backend responde con el código de estado 204 no content. Los dos casos diferentes son eliminar una nota que existe y eliminar una nota que no existe en la base de datos. El parámetro callback result podría usarse para verificar si un recurso realmente se eliminó, y podríamos usar esa información para devolver códigos de estado diferentes para los dos casos si lo consideramos necesario. Cualquier excepción que ocurra se pasa al controlador de errores.

El cambio de la importancia de una nota se puede lograr fácilmente con el método findByIdAndUpdate.

app.put('/api/notes/:id', (request, response, next) => {
  const body = request.body

  const note = {
    content: body.content,
    important: body.important,
  }

  Note.findByIdAndUpdate(request.params.id, note, { new: true })
    .then(updatedNote => {
      response.json(updatedNote)
    })
    .catch(error => next(error))
})

En el código anterior, también permitimos que se edite el contenido de la nota.

Observa que el método findByIdAndUpdate recibe un objeto JavaScript normal como argumento, y no un nuevo objeto de nota creado con la función constructora Note.

Hay un detalle importante con respecto al uso del método findByIdAndUpdate. De forma predeterminada, el parámetro updatedNote del controlador de eventos recibe el documento original sin las modificaciones. Agregamos el parámetro opcional { new: true }, que hará que nuestro controlador de eventos sea llamado con el nuevo documento modificado en lugar del original.

Después de probar el backend directamente con Postman o el cliente REST de VS Code, podemos verificar que parece funcionar. El frontend también parece funcionar con el backend usando la base de datos.

Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part3-5 de este repositorio de GitHub.

Un verdadero juramento de desarrollador full stack

Una vez más, es tiempo para los ejercicios. La complejidad de nuestra aplicación ha dado otro paso, ya que ahora, además del frontend y el backend, también tenemos una base de datos. Realmente hay muchas fuentes potenciales de errores.

Así que debemos extender una vez más nuestro juramento:

El desarrollo full stack es extremadamente difícil, por eso utilizaré todos los medios posibles para facilitarlo.

  • Mantendré la consola de desarrollador del navegador abierta todo el tiempo.
  • Utilizaré la pestaña de red de las herramientas de desarrollo del navegador para asegurarme de que el frontend y el backend estén comunicándose como espero.
  • Vigilaré constantemente el estado del servidor para asegurarme de que los datos enviados por el frontend se guarden allí como espero.
  • Observaré la base de datos: ¿guarda el backend los datos allí en el formato correcto?
  • Progresaré con pequeños pasos.
  • Escribiré muchas declaraciones de console.log para asegurarme de entender cómo se comporta el código y ayudar a señalar problemas.
  • Si mi código no funciona, no escribiré más código. En su lugar, comenzaré a eliminar código hasta que funcione o simplemente regresaré a un estado en el que todo aún funcionaba.
  • Cuando pida ayuda en el canal de Discord o Telegram del curso o en cualquier otro lugar, formularé mis preguntas correctamente, consulta aquí cómo pedir ayuda.