Taller de APIs web sin servidores

1. Descripción general

El objetivo de este codelab es ganar experiencia con el procesamiento "sin servidores" servicios que ofrece Google Cloud:

  • Cloud Functions: Se usa para implementar pequeñas unidades de lógica empresarial en forma de funciones, que reaccionan a varios eventos (mensajes de Pub/Sub, archivos nuevos en Cloud Storage, solicitudes HTTP y mucho más).
  • App Engine: Permite implementar y entregar aplicaciones web, APIs web, backends para dispositivos móviles y recursos estáticos, con capacidades de aumento y reducción rápida de la escala verticalmente.
  • Cloud Run: Permite implementar y escalar contenedores que pueden contener cualquier lenguaje, entorno de ejecución o biblioteca.

Descubrir cómo aprovechar esos servicios sin servidores para implementar y escalar las APIs web y de REST, además de ver algunos buenos principios de diseño RESTful en el camino

En este taller, crearemos un explorador de estanterías que constará de lo siguiente:

  • Una Cloud Function: para importar el conjunto de datos inicial de los libros disponibles en nuestra biblioteca, en la base de datos de documentos de Cloud Firestore.
  • Un contenedor de Cloud Run, que expondrá una API de REST sobre el contenido de nuestra base de datos.
  • Frontend web de App Engine: Para navegar por la lista de libros, llama a nuestra API de REST.

Así se verá el frontend web al final de este codelab:

705e014da0ca5e90.png

Qué aprenderás

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

2. Configuración y requisitos

Configuración del entorno de autoaprendizaje

  1. Accede a Google Cloud Console y crea un proyecto nuevo o reutiliza uno existente. Si aún no tienes una cuenta de Gmail o de Google Workspace, debes crear una.

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • El Nombre del proyecto es el nombre visible de los participantes de este proyecto. Es una cadena de caracteres que no se utiliza en las APIs de Google. Puedes actualizarla cuando quieras.
  • El ID del proyecto es único en todos los proyectos de Google Cloud y es inmutable (no se puede cambiar después de configurarlo). La consola de Cloud genera automáticamente una cadena única. Por lo general, no importa cuál sea. En la mayoría de los codelabs, deberás hacer referencia al ID de tu proyecto (suele identificarse como PROJECT_ID). Si no te gusta el ID que se generó, podrías generar otro aleatorio. También puedes probar uno propio y ver si está disponible. No se puede cambiar después de este paso y se usa el mismo durante todo el proyecto.
  • Recuerda que hay un tercer valor, un número de proyecto, que usan algunas APIs. Obtén más información sobre estos tres valores en la documentación.
  1. A continuación, deberás habilitar la facturación en la consola de Cloud para usar las APIs o los recursos de Cloud. Ejecutar este codelab no costará mucho, tal vez nada. Para cerrar recursos y evitar que se generen cobros más allá de este instructivo, puedes borrar los recursos que creaste o borrar el proyecto. Los usuarios nuevos de Google Cloud son aptos para participar en el programa Prueba gratuita de $300.

Inicia Cloud Shell

Si bien Google Cloud y Spanner se pueden operar de manera remota desde tu laptop, en este codelab usarás Google Cloud Shell, un entorno de línea de comandos que se ejecuta en la nube.

En Google Cloud Console, haz clic en el ícono de Cloud Shell en la barra de herramientas en la parte superior derecha:

84688aa223b1c3a2.png

El aprovisionamiento y la conexión al entorno deberían tomar solo unos minutos. Cuando termine el proceso, debería ver algo como lo siguiente:

320e18fedb7fbe0.png

Esta máquina virtual está cargada con todas las herramientas de desarrollo que necesitarás. Ofrece un directorio principal persistente de 5 GB y se ejecuta en Google Cloud, lo que permite mejorar considerablemente el rendimiento de la red y la autenticación. Todo tu trabajo en este codelab se puede hacer en un navegador. No es necesario que instales nada.

3. Prepara el entorno y habilita las APIs de Cloud

Para usar los distintos servicios que necesitaremos durante este proyecto, habilitaremos algunas APIs. Para ello, iniciaremos el siguiente comando en Cloud Shell:

$ gcloud services enable \
      appengine.googleapis.com \
      cloudbuild.googleapis.com \
      cloudfunctions.googleapis.com \
      compute.googleapis.com \
      firestore.googleapis.com \
      run.googleapis.com

Después de un tiempo, deberías ver que la operación finaliza correctamente:

Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.

También configuraremos una variable de entorno que necesitaremos en el camino: la región de la nube en la que implementaremos nuestra función, aplicación y contenedor:

$ export REGION=europe-west3

Como almacenaremos datos en la base de datos de Cloud Firestore, tendremos que crear la base de datos:

$ gcloud app create --region=${REGION}
$ gcloud firestore databases create --location=${REGION}

Más adelante en este codelab, cuando implementes la API de REST, deberás ordenar y filtrar los datos. Para ello, crearemos tres índices:

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=language,order=ascending \
      --field-config field-path=updated,order=descending 

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=author,order=ascending \
      --field-config field-path=updated,order=descending 

Esos 3 índices corresponden a búsquedas que haremos por autor o idioma y, a la vez, mantendremos el orden en la colección a través de un campo actualizado.

4. Obtén el código

Obtén el código del siguiente repositorio de GitHub:

$ git clone https://github.com/glaforge/serverless-web-apis

El código de la aplicación se escribe con Node.JS.

Para este lab, verás la siguiente estructura de carpetas:

serverless-web-apis
 |
 ├── data
 |   ├── books.json
 |
 ├── function-import
 |   ├── index.js
 |   ├── package.json
 |
 ├── run-crud
 |   ├── index.js
 |   ├── package.json
 |   ├── Dockerfile
 |
 ├── appengine-frontend
     ├── public
     |   ├── css/style.css
     |   ├── html/index.html
     |   ├── js/app.js
     ├── index.js
     ├── package.json
     ├── app.yaml

Estas son las carpetas relevantes:

  • data: Esta carpeta contiene datos de muestra de una lista de 100 libros.
  • function-import: Esta función ofrecerá un extremo para importar datos de muestra.
  • run-crud: Este contenedor expondrá una API web para acceder a los datos de los libros almacenados en Cloud Firestore.
  • appengine-frontend: Esta aplicación web de App Engine mostrará un frontend simple de solo lectura para navegar por la lista de libros.

5. Datos de biblioteca de libros de muestra

En la carpeta de datos, tenemos un archivo books.json que contiene una lista de cien libros, que probablemente vale la pena leer. Este documento JSON es un array que contiene objetos JSON. Veamos la forma de los datos que transferiremos a través de una Cloud Function:

[
  {
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  },
  {
    "isbn": "9781414251196",
    "author": "Hans Christian Andersen",
    "language": "Danish",
    "pages": 784,
    "title": "Fairy tales",
    "year": 1836
  },
  ...
]

Todas las entradas de libros en este array contienen la siguiente información:

  • isbn: Es el código ISBN-13 que identifica el libro.
  • author: Es el nombre del autor del libro.
  • language: Es el idioma hablado en el que está escrito el libro.
  • pages: Indica la cantidad de páginas del libro.
  • title: Es el título del libro.
  • year: Es el año en el que se publicó el libro.

6. Un extremo de función para importar datos de libros de muestra

En esta primera sección, implementaremos el extremo que se usará para importar datos de libros de muestra. Para ello, usaremos Cloud Functions.

Explora el código

Comencemos por analizar el archivo package.json:

{
    "name": "function-import",
    "description": "Import sample book data",
    "license": "Apache-2.0",
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9"
    },
    "devDependencies": {
        "@google-cloud/functions-framework": "^3.1.0"
    },
    "scripts": {
        "start": "npx @google-cloud/functions-framework --target=parseBooks"
    }
}

En las dependencias del entorno de ejecución, solo necesitamos el módulo de NPM @google-cloud/firestore para acceder a la base de datos y almacenar los datos de nuestros libros. De forma interna, el entorno de ejecución de Cloud Functions también proporciona el framework web Express, por lo que no es necesario declararlo como una dependencia.

En las dependencias de desarrollo, declaramos Functions Framework (@google-cloud/functions-framework), que es el framework del entorno de ejecución que se usa para invocar tus funciones. Es un framework de código abierto que también puedes usar de forma local en tu máquina (en nuestro caso, dentro de Cloud Shell) para ejecutar funciones sin implementar cada vez que realices un cambio, lo que mejora el ciclo de retroalimentación sobre el desarrollo.

Para instalar las dependencias, usa el comando install:

$ npm install

La secuencia de comandos start usa Functions Framework para proporcionarte un comando que puedes usar a fin de ejecutar la función de forma local con la siguiente instrucción:

$ npm start

Puedes usar curl o, potencialmente, la vista previa web de Cloud Shell para que las solicitudes HTTP GET interactúen con la función.

Ahora, echemos un vistazo al archivo index.js que contiene la lógica de nuestra función de importación de datos de libros:

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Creamos una instancia del módulo de Firestore y apuntamos a la colección de libros (similar a una tabla en bases de datos relacionales).

functions.http('parseBooks', async (req, resp) => {
    if (req.method !== "POST") {
        resp.status(405).send({error: "Only method POST allowed"});
        return;
    }
    if (req.headers['content-type'] !== "application/json") {
        resp.status(406).send({error: "Only application/json accepted"});
        return;
    }
    ... 
})

Exportamos la función parseBooks de JavaScript. Esta es la función que declararemos cuando la implementemos más adelante.

En las siguientes instrucciones, verifica lo siguiente:

  • Solo aceptamos solicitudes HTTP POST. De lo contrario, mostramos un código de estado 405 para indicar que los demás métodos HTTP no están permitidos.
  • Solo aceptamos cargas útiles de application/json. De lo contrario, enviamos un código de estado 406 para indicar que este no es un formato de carga útil aceptable.
    const books = req.body;

    const writeBatch = firestore.batch();

    for (const book of books) {
        const doc = bookStore.doc(book.isbn);
        writeBatch.set(doc, {
            title: book.title,
            author: book.author,
            language: book.language,
            pages: book.pages,
            year: book.year,
            updated: Firestore.Timestamp.now()
        });
    }

Luego, podemos recuperar la carga útil de JSON a través del body de la solicitud. Estamos preparando una operación por lotes de Firestore para almacenar todos los libros de forma masiva. Iteramos el array JSON que consta de los detalles de los libros y revisamos los campos isbn, title, author, language, pages y year. El código ISBN del libro se usará como clave primaria o identificador.

    try {
        await writeBatch.commit();
        console.log("Saved books in Firestore");
    } catch (e) {
        console.error("Error saving books:", e);
        resp.status(400).send({error: "Error saving books"});
        return;
    };

    resp.status(202).send({status: "OK"});

Ahora que la mayor parte de los datos está lista, podemos confirmar la operación. Si la operación de almacenamiento falla, se mostrará un código de estado 400 para indicar que falla. De lo contrario, podemos mostrar una respuesta OK, con un código de estado 202 que indica que se aceptó la solicitud de guardado masivo.

Ejecuta y prueba la función de importación

Antes de ejecutar el código, instalaremos las dependencias con lo siguiente:

$ npm install

Para ejecutar la función de manera local, gracias a Functions Framework, usaremos el comando de secuencia de comandos start que definimos en package.json:

$ npm start

> start
> npx @google-cloud/functions-framework --target=parseBooks

Serving function...
Function: parseBooks
URL: http://localhost:8080/

Para enviar una solicitud POST HTTP a tu función local, puedes ejecutar lo siguiente:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       http://localhost:8080/

Cuando inicies este comando, verás el siguiente resultado, que confirma que la función se está ejecutando de forma local:

{"status":"OK"}

También puedes ir a la IU de la consola de Cloud para verificar que los datos realmente estén almacenados en Firestore:

409982568cebdbf8.png

En la captura de pantalla anterior, podemos ver la colección books creada, la lista de documentos de libros identificados por el código ISBN del libro y los detalles de esa entrada de libro en particular a la derecha.

Implementa la función en la nube

Para implementar la función en Cloud Functions, usaremos el siguiente comando en el directorio function-import:

$ gcloud functions deploy bulk-import \
         --gen2 \
         --trigger-http \
         --runtime=nodejs20 \
         --allow-unauthenticated \
         --max-instances=30
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

Implementamos la función con un nombre simbólico de bulk-import. Esta función se activa a través de solicitudes HTTP. Usamos el entorno de ejecución Node.JS 20. Implementaremos la función públicamente (lo ideal sería proteger ese extremo). Especificamos la región en la que queremos que resida la función. Apuntamos a las fuentes en el directorio local y usamos parseBooks (la función de JavaScript exportada) como punto de entrada.

Después de unos minutos o menos, la función se implementa en la nube. En la IU de la consola de Cloud, deberías ver que aparece la siguiente función:

c910875d4dc0aaa8.png

En el resultado de la implementación, deberías poder ver la URL de tu función, que sigue una determinada convención de nombres (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}) y, por supuesto, también puedes encontrar esta URL del activador HTTP en la IU de la consola de Cloud, en la pestaña del activador:

380ffc46eb56441e.png

También puedes recuperar la URL a través de la línea de comandos con gcloud:

$ export BULK_IMPORT_URL=$(gcloud functions describe bulk-import \
                                  --region=$REGION \
                                  --format 'value(httpsTrigger.url)')
$ echo $BULK_IMPORT_URL

Almacenémoslo en la variable de entorno BULK_IMPORT_URL de modo que podamos volver a utilizarlo para probar la función implementada.

Prueba la función implementada

Con un comando curl similar al que usamos antes para probar la función que se ejecuta de forma local, probaremos la función implementada. El único cambio será la URL:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       $BULK_IMPORT_URL

Nuevamente, si se ejecuta correctamente, debería mostrar el siguiente resultado:

{"status":"OK"}

Ahora que la función de importación está implementada y lista, y ya subimos los datos de muestra, es momento de desarrollar la API de REST para exponer este conjunto de datos.

7. El contrato de la API de REST

Si bien no definimos un contrato de API usando, por ejemplo, la especificación de OpenAPI, veremos los distintos extremos de nuestra API de REST.

La API intercambia los objetos JSON de libros, que consisten en lo siguiente:

  • isbn (opcional): Es un String de 13 caracteres que representa un código ISBN válido.
  • author: Es un objeto String que no está vacío y que representa el nombre del autor del libro.
  • language: Es un objeto String que no está vacío y que contiene el idioma en el que se escribió el libro.
  • pages: Es un Integer positivo para la cantidad de páginas del libro.
  • title: Es un String que no está vacío con el título del libro.
  • year: Es un valor Integer para el año de publicación del libro.

Ejemplo de carga útil del libro:

{
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  }

OBTENER /books

Obtén la lista de todos los libros, potencialmente filtradas por autor o idioma, y paginadas por ventanas de 10 resultados a la vez.

Carga útil del cuerpo: ninguna.

Parámetros de consulta:

  • author (opcional): Filtra la lista de libros por autor.
  • language (opcional): Filtra la lista de libros por idioma.
  • page (opcional, predeterminado = 0): Indica la clasificación de la página de resultados que se mostrará.

Muestra un array JSON de objetos Book.

Códigos de estado:

  • 200: Cuando la solicitud se realiza correctamente para recuperar la lista de libros
  • 400: Si se produce un error,

POST /books y POST /books/{isbn}

Publica una nueva carga útil del libro, ya sea con un parámetro de ruta de acceso isbn (en cuyo caso no se necesita el código isbn en la carga útil del libro) o sin él (en cuyo caso el código isbn debe estar presente en la carga útil del libro).

Carga útil del cuerpo: Un objeto de libro.

Parámetros de consulta: ninguno.

Muestra: nada.

Códigos de estado:

  • 201: Cuando el libro se almacena correctamente,
  • 406: Si el código isbn no es válido,
  • 400: Si se produce un error,

OBTÉN /books/{isbn}

Recupera un libro de la biblioteca, identificado por su código isbn, que se pasa como un parámetro de ruta de acceso.

Carga útil del cuerpo: ninguna.

Parámetros de consulta: ninguno.

Muestra un objeto JSON de libro o un objeto de error si el libro no existe.

Códigos de estado:

  • 200: Si el libro se encuentra en la base de datos,
  • 400: Si se produce un error,
  • 404: Si no se pudo encontrar el libro,
  • 406: Si el código isbn no es válido.

PUT /books/{isbn}

Actualiza un libro existente, identificado por su isbn que se pasa como parámetro de ruta.

Carga útil del cuerpo: Un objeto de libro. Solo se pueden pasar los campos que necesitan una actualización; los demás son opcionales.

Parámetros de consulta: ninguno.

Muestra el libro actualizado.

Códigos de estado:

  • 200: Cuando el libro se actualice correctamente,
  • 400: Si se produce un error,
  • 406: Si el código isbn no es válido.

BORRAR /books/{isbn}

Borra un libro existente, identificado por su isbn pasado como parámetro de ruta de acceso.

Carga útil del cuerpo: ninguna.

Parámetros de consulta: ninguno.

Muestra: nada.

Códigos de estado:

  • 204: Cuando el libro se borra correctamente,
  • 400: Si se produce un error,

8. Implementa y expón una API de REST en un contenedor

Explora el código

Dockerfile

Comencemos por analizar Dockerfile, que será responsable de alojar el código de la aplicación en contenedores:

FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . ./
CMD [ "node", "index.js" ]

Estamos usando una imagen "slim" de Node.JS 20. Estamos trabajando en el directorio /usr/src/app. Copiamos el archivo package.json (más detalles a continuación) que define nuestras dependencias, entre otros elementos. Instalamos las dependencias con npm install y copiamos el código fuente. Por último, indicamos cómo se debe ejecutar esta aplicación con el comando node index.js.

package.json

A continuación, podemos observar el archivo package.json:

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 20.0.0"
    },
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "scripts": {
        "start": "node index.js"
    }
}

Especificamos que queremos usar Node.JS 14, como fue el caso con Dockerfile.

Nuestra aplicación de API web depende de lo siguiente:

  • El módulo de NPM de Firestore para acceder a los datos de los libros en la base de datos.
  • La biblioteca cors para manejar las solicitudes de CORS (uso compartido de recursos entre dominios), ya que nuestra API de REST se invocará desde el código de cliente del frontend de nuestra aplicación web de App Engine.
  • El framework Express, que será el framework web para diseñar nuestra API,
  • Luego, se incluye el módulo isbn3, que ayuda a validar los códigos ISBN de los libros.

También especificamos la secuencia de comandos start, que será útil para iniciar la aplicación de manera local con fines de desarrollo y prueba.

index.js

Pasemos a la esencia del código, con un análisis detallado de index.js:

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Necesitamos el módulo de Firestore y hacer referencia a la colección books, en la que se almacenan nuestros datos de libros.

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.json());

const querystring = require('querystring');

const cors = require('cors');
app.use(cors({
    exposedHeaders: ['Content-Length', 'Content-Type', 'Link'],
}));

Usamos Express, como framework web, para implementar nuestra API de REST. Usamos el módulo body-parser para analizar las cargas útiles de JSON que se intercambian con nuestra API.

El módulo querystring es útil para manipular URLs. Este será el caso cuando creemos encabezados Link para paginación (más adelante hablaremos de este tema).

Luego, configuraremos el módulo cors. Explicamos de manera explícita los encabezados que queremos que se pasen a través de CORS, ya que la mayoría se suele quitar, pero aquí queremos mantener la longitud y el tipo de contenido habituales, así como el encabezado Link que especificaremos para la paginación.

const ISBN = require('isbn3');

function isbnOK(isbn, res) {
    const parsedIsbn = ISBN.parse(isbn);
    if (!parsedIsbn) {
        res.status(406)
            .send({error: `Invalid ISBN: ${isbn}`});
        return false;
    }
    return parsedIsbn;
}

Utilizaremos el módulo de NPM isbn3 para analizar y validar los códigos ISBN. Desarrollaremos una pequeña función de utilidad que analizará los códigos ISBN y responderá con un código de estado 406 en la respuesta, si los códigos ISBN no son válidos.

  • GET /books

Veamos el extremo GET /books, parte por parte:

app.get('/books', async (req, res) => {
    try {
        var query = new Firestore().collection('books');

        if (!!req.query.author) {
            console.log(`Filtering by author: ${req.query.author}`);
            query = query.where("author", "==", req.query.author);
        }
        if (!!req.query.language) {
            console.log(`Filtering by language: ${req.query.language}`);
            query = query.where("language", "==", req.query.language);
        }

        const page = parseInt(req.query.page) || 0;

        // - -  - -  - -  - -  - -  - -

    } catch (e) {
        console.error('Failed to fetch books', e);
        res.status(400)
            .send({error: `Impossible to fetch books: ${e.message}`});
    }
});

Nos estamos preparando para consultar la base de datos preparando una consulta. Esta consulta dependerá de los parámetros de consulta opcionales para filtrar por autor o por idioma. También devolveremos la lista de libros por partes de 10 libros.

Si se produce un error durante el proceso, mientras se recuperan los libros, se muestra un error con un código de estado 400.

Hagamos zoom en la parte recortada de ese extremo:

        const snapshot = await query
            .orderBy('updated', 'desc')
            .limit(PAGE_SIZE)
            .offset(PAGE_SIZE * page)
            .get();

        const books = [];

        if (snapshot.empty) {
            console.log('No book found');
        } else {
            snapshot.forEach(doc => {
                const {title, author, pages, year, language, ...otherFields} = doc.data();
                const book = {isbn: doc.id, title, author, pages, year, language};
                books.push(book);
            });
        }

En la sección anterior, filtramos por author y language, pero, en esta sección, ordenaremos la lista de libros por el orden de la fecha de última actualización (la última actualización va primero). También paginaremos el resultado definiendo un límite (la cantidad de elementos que se mostrarán) y un desplazamiento (el punto de partida desde el que se mostrará el siguiente lote de libros).

Ejecutamos la consulta, obtenemos la instantánea de los datos y colocamos esos resultados en un array de JavaScript que se mostrará al final de la función.

Terminemos las explicaciones de este extremo con una práctica recomendada: usar el encabezado Link para definir vínculos URI a la primera, anterior, siguiente o última página de datos (en nuestro caso, solo proporcionaremos la información anterior y la siguiente).

        var links = {};
        if (page > 0) {
            const prevQuery = querystring.stringify({...req.query, page: page - 1});
            links.prev = `${req.path}${prevQuery != '' ? `?${prevQuery}` : ''}`;
        }
        if (snapshot.docs.length === PAGE_SIZE) {
            const nextQuery = querystring.stringify({...req.query, page: page + 1});
            links.next = `${req.path}${nextQuery != '' ? `?${nextQuery}` : ''}`;
        }
        if (Object.keys(links).length > 0) {
            res.links(links);
        }

        res.status(200).send(books);

Al principio, la lógica puede parecer un poco compleja, pero lo que haremos es agregar un vínculo anterior si no estamos en la primera página de datos. Además, agregamos un vínculo next si la página de datos está llena (es decir, contiene la cantidad máxima de libros según se define en la constante PAGE_SIZE, suponiendo que otro libro está generando más datos). Luego, usamos la función resource#links() de Express para crear el encabezado correcto con la sintaxis correcta.

A modo de información, el encabezado del vínculo será similar al siguiente:

link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
  • POST /books y POST /books/:isbn

Ambos extremos están aquí para crear un libro nuevo. Una pasa el código ISBN en la carga útil del libro, mientras que el otro lo pasa como parámetro de ruta de acceso. De cualquier manera, ambos llaman a nuestra función createBook():

async function createBook(isbn, req, res) {
    const parsedIsbn = isbnOK(isbn, res);
    if (!parsedIsbn) return;

    const {title, author, pages, year, language} = req.body;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            title, author, pages, year, language,
            updated: Firestore.Timestamp.now()
        });
        console.log(`Saved book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} created`});
    } catch (e) {
        console.error(`Failed to save book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to create book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
}

Verificamos que el código isbn sea válido; de lo contrario, lo mostraremos desde la función (y establecerás un código de estado 406). Recuperamos los campos de los libros de la carga útil pasada en el cuerpo de la solicitud. Luego, almacenaremos los detalles del libro en Firestore. Se muestra 201 si la operación es exitosa y 400 si se produce un error.

Cuando el resultado es exitoso, también establecemos el encabezado de ubicación para darle indicaciones al cliente de la API en la que se encuentra el recurso recién creado. El encabezado se verá de la siguiente manera:

Location: /books/9781234567898
  • GET /books/:isbn

Busquemos un libro, identificado a través de su ISBN, desde Firestore.

app.get('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        const docSnapshot = await docRef.get();

        if (!docSnapshot.exists) {
            console.log(`Book not found ${parsedIsbn.isbn13}`)
            res.status(404)
                .send({error: `Could not find book ${parsedIsbn.isbn13}`});
            return;
        }

        console.log(`Fetched book ${parsedIsbn.isbn13}`, docSnapshot.data());

        const {title, author, pages, year, language, ...otherFields} = docSnapshot.data();
        const book = {isbn: parsedIsbn.isbn13, title, author, pages, year, language};

        res.status(200).send(book);
    } catch (e) {
        console.error(`Failed to fetch book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to fetch book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Como siempre, verificamos si el ISBN es válido. Hacemos una consulta a Firestore para recuperar el libro. La propiedad snapshot.exists es útil para saber si realmente se encontró un libro. De lo contrario, enviaremos un error y un código de estado 404 No encontrado. Recuperamos los datos del libro y creamos un objeto JSON que representa el libro que se mostrará.

  • PUT /books/:isbn

Estamos usando el método PUT para actualizar un libro existente.

app.put('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            ...req.body,
            updated: Firestore.Timestamp.now()
        }, {merge: true});
        console.log(`Updated book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} updated`});
    } catch (e) {
        console.error(`Failed to update book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to update book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
});

Actualizamos el campo de fecha y hora updated para recordar cuándo actualizamos ese registro por última vez. Usamos la estrategia {merge:true}, que reemplaza los campos existentes con sus valores nuevos (de lo contrario, se quitan todos los campos y solo se guardarán los campos nuevos de la carga útil y se borrarán los campos existentes de la actualización anterior o la creación inicial).

También configuramos el encabezado Location para que apunte al URI del libro.

  • DELETE /books/:isbn

Eliminar libros es bastante sencillo. Solo llamamos al método delete() en la referencia del documento. Devolvemos un código de estado 204, ya que no mostramos ningún contenido.

app.delete('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.delete();
        console.log(`Book ${parsedIsbn.isbn13} was deleted`);

        res.status(204).end();
    } catch (e) {
        console.error(`Failed to delete book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to delete book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Iniciar el servidor Express / Node

Por último, pero no menos importante, iniciamos el servidor y escuchamos en el puerto 8080 de forma predeterminada:

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Books Web API service: listening on port ${port}`);
    console.log(`Node ${process.version}`);
});

Ejecuta la aplicación de manera local

Para ejecutar la aplicación de manera local, primero instalaremos las dependencias con el siguiente comando:

$ npm install

Y podemos comenzar con lo siguiente:

$ npm start

El servidor se iniciará en localhost y escuchará en el puerto 8080 de forma predeterminada.

También es posible compilar un contenedor de Docker y ejecutar la imagen del contenedor con los siguientes comandos:

$ docker build -t crud-web-api .

$ docker run --rm -p 8080:8080 -it crud-web-api

Ejecutar dentro de Docker también es una excelente manera de comprobar que la creación de contenedores de nuestra aplicación se ejecute correctamente mientras la compilamos en la nube con Cloud Build.

Prueba la API

Independientemente de cómo ejecutemos el código de la API de REST (directamente a través del nodo o de una imagen de contenedor de Docker), ahora podemos ejecutar algunas consultas en él.

  • Crear un libro nuevo (ISBN en la carga útil del cuerpo):
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • Crear un libro nuevo (ISBN en un parámetro de ruta):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • Cómo borrar un libro (el que creamos):
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • Recuperar un libro por ISBN:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • Para actualizar un libro existente, cambia solo el título:
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • Recupera la lista de libros (los primeros 10):
$ curl http://localhost:8080/books
  • Busca libros escritos por un autor en particular:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • Haz una lista de los libros escritos en inglés:
$ curl http://localhost:8080/books?language=English
  • Carga la 4a página de libros:
$ curl http://localhost:8080/books?page=3

También podemos combinar los parámetros de consulta author, language y books para definir mejor nuestra búsqueda.

Compila e implementa la API de REST alojada en contenedores

Como nos complace saber que la API de REST funciona según lo planeado, es el momento adecuado para implementarla en la nube, en Cloud Run.

Lo haremos en dos pasos:

  • Primero, compila la imagen de contenedor con Cloud Build. Para ello, usa el siguiente comando:
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • Luego, implementa el servicio con este segundo comando:
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

Con el primer comando, Cloud Build compila la imagen del contenedor y la aloja en Container Registry. El siguiente comando implementa la imagen de contenedor desde el registro y la implementa en la región de la nube.

En la IU de la consola de Cloud, podemos volver a verificar que nuestro servicio de Cloud Run ahora aparezca en la lista:

f62fbca02a8127c0.png

El último paso que haremos aquí es recuperar la URL del servicio recién implementado de Cloud Run, gracias al siguiente comando:

$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \
                                       --region=${REGION} \
                                       --platform=managed \
                                       --format='value(status.url)')

Necesitaremos la URL de nuestra API de REST de Cloud Run en la próxima sección, ya que el código de frontend de App Engine interactuará con la API.

9. Aloja una app web para explorar la biblioteca

La última pieza del rompecabezas para agregar algo de brillo a este proyecto es proporcionar un frontend web que interactúe con nuestra API de REST. Para ello, usaremos Google App Engine, con algún código JavaScript cliente que llamará a la API a través de solicitudes AJAX (mediante la API Fetch del cliente).

Nuestra aplicación, aunque se implementó en el entorno de ejecución de Node.js de App Engine, está compuesta principalmente por recursos estáticos. No hay mucho código de backend, ya que la mayor parte de la interacción del usuario será en el navegador a través de JavaScript del lado del cliente. No usaremos ningún framework de frontend de JavaScript sofisticado, solo usaremos JavaScript “vanilla”, con algunos componentes web para la IU, que usan la biblioteca de componentes web Shoelace:

  • una casilla de selección para seleccionar el idioma del libro:

6fb9f741000a2dc1.png

  • un componente de tarjeta para mostrar los detalles de un libro en particular (incluido un código de barras para representar el ISBN del libro, mediante la biblioteca JsBarcode):

3aa21a9e16e3244e.png

  • y un botón para cargar más libros desde la base de datos:

3925ad81c91bbac9.png

Al combinar todos esos componentes visuales, la página web resultante para explorar nuestra biblioteca se verá de la siguiente manera:

18a5117150977d6.png

El archivo de configuración app.yaml

Comencemos a analizar la base de código de esta aplicación de App Engine. Para ello, examinemos el archivo de configuración app.yaml. Es un archivo específico de App Engine que permite configurar variables de entorno, los diferentes "controladores" de la aplicación o especificar que algunos recursos son elementos estáticos, que la CDN integrada de App Engine entregará.

runtime: nodejs14

env_variables:
  RUN_CRUD_SERVICE_URL: CHANGE_ME

handlers:

- url: /js
  static_dir: public/js

- url: /css
  static_dir: public/css

- url: /img
  static_dir: public/img

- url: /(.+\.html)
  static_files: public/html/\1
  upload: public/(.+\.html)

- url: /
  static_files: public/html/index.html
  upload: public/html/index\.html

- url: /.*
  secure: always
  script: auto

Especificamos que nuestra aplicación es de Node.js y que queremos usar la versión 14.

Luego, definimos una variable de entorno que apunta a nuestra URL de servicio de Cloud Run. Necesitaremos actualizar el marcador de posición CHANGE_ME con la URL correcta (consulta a continuación cómo cambiarla).

Luego, definimos varios controladores. Las 3 primeras apuntan a la ubicación del código del cliente HTML, CSS y JavaScript, en la carpeta public/ y sus subcarpetas. El cuarto indica que la URL raíz de nuestra aplicación de App Engine debe apuntar a la página index.html. De esa manera, no veremos el sufijo index.html en la URL cuando accedas a la raíz del sitio web. Y la última es la predeterminada que enrutará todas las demás URLs (/.*) a nuestra aplicación Node.JS (es decir, la parte "dinámica" de la aplicación, a diferencia de los recursos estáticos que describimos).

Actualicemos ahora la URL de la API web del servicio de Cloud Run.

En el directorio appengine-frontend/, ejecuta el siguiente comando para actualizar la variable de entorno que apunta a la URL de nuestra API de REST basada en Cloud Run:

$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml

O cambia manualmente la cadena CHANGE_ME en app.yaml con la URL correcta:

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

El archivo package.json de Node.JS

{
    "name": "appengine-frontend",
    "description": "Web frontend",
    "license": "Apache-2.0",
    "main": "index.js",
    "engines": {
        "node": "^14.0.0"
    },
    "dependencies": {
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "devDependencies": {
        "nodemon": "^2.0.7"
    },
    "scripts": {
        "start": "node index.js",
        "dev": "nodemon --watch server --inspect index.js"
    }
}

Una vez más, hacemos hincapié en que queremos ejecutar esta aplicación con Node.JS 14. Dependemos del marco de trabajo Express y del módulo isbn3 de NPM para validar los libros Códigos ISBN.

En las dependencias de desarrollo, usaremos el módulo nodemon para supervisar los cambios en los archivos. Si bien podemos ejecutar nuestra aplicación de manera local con npm start, hacer algunos cambios en el código, detener la app con ^C y, luego, reiniciarla, es un poco tedioso. En su lugar, podemos usar el siguiente comando para que la aplicación se vuelva a cargar o reiniciar automáticamente cuando se realicen cambios:

$ npm run dev

El código Node.JS index.js

const express = require('express');
const app = express();

app.use(express.static('public'));

const bodyParser = require('body-parser');
app.use(bodyParser.json());

Necesitamos el framework web Express. Especificamos que el directorio público contiene recursos estáticos que el middleware static puede entregar (al menos cuando se ejecuta de forma local en modo de desarrollo). Por último, necesitamos body-parser para analizar las cargas útiles de JSON.

Veamos el par de rutas que definimos:

app.get('/', async (req, res) => {
    res.redirect('/html/index.html');
});

app.get('/webapi', async (req, res) => {
    res.send(process.env.RUN_CRUD_SERVICE_URL);
});

El primero que coincida con / redireccionará a index.html en nuestro directorio de public/html. Como en el modo de desarrollo no se ejecuta en el entorno de ejecución de App Engine, no obtenemos el enrutamiento de URL de App Engine. En cambio, aquí, solo redireccionaremos la URL raíz al archivo HTML.

El segundo extremo que definimos /webapi mostrará la URL de nuestra API de REST de Cloud RUN. De esta manera, el código JavaScript del cliente sabrá a dónde llamar para obtener la lista de libros.

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Book library web frontend: listening on port ${port}`);
    console.log(`Node ${process.version}`);
    console.log(`Web API endpoint ${process.env.RUN_CRUD_SERVICE_URL}`);
});

Para finalizar, estamos ejecutando la app web de Express y escuchando en el puerto 8080 de forma predeterminada.

La página index.html

No analizaremos cada línea de esta larga página HTML. En su lugar, destaquemos algunas líneas clave.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/themes/base.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/shoelace.js"></script>

<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/dist/barcodes/JsBarcode.ean-upc.min.js"></script>

<script src="/js/app.js"></script>
<link rel="stylesheet" type="text/css" href="/css/style.css">

Las primeras dos líneas importan la biblioteca de componentes web de Shoelace (una secuencia de comandos y una hoja de estilo).

La siguiente línea importa la biblioteca JsBarcode para crear los códigos de barras de los códigos ISBN de los libros.

Las últimas líneas importan nuestro propio código JavaScript y la hoja de estilo CSS, que se encuentran en nuestros subdirectorios public/.

En el elemento body de la página HTML, usamos los componentes Shoelace con sus etiquetas de elementos personalizados, como:

<sl-icon name="book-half"></sl-icon>
...

<sl-select id="language-select" placeholder="Select a language..." clearable>
    <sl-menu-item value="English">English</sl-menu-item>
    <sl-menu-item value="French">French</sl-menu-item>
    ...
</sl-select>
...

<sl-button id="more-button" type="primary" size="large">
    More books...
</sl-button>
...

También usamos plantillas HTML y su capacidad de relleno de espacios para representar un libro. Crearemos copias de esa plantilla para completar la lista de libros y reemplazaremos los valores de los espacios por los detalles de los libros:

    <template id="book-card">
        <sl-card class="card-overview">
        ...
            <slot name="author">Author</slot>
            ... 
        </sl-card>
    </template>

Si tiene suficiente HTML, casi terminamos de revisar el código. Queda una última parte importante: el código JavaScript del cliente app.js que interactúa con nuestra API de REST.

El código de JavaScript del cliente de app.js

Comenzamos con un objeto de escucha de eventos de nivel superior que espera a que se cargue el contenido del DOM:

document.addEventListener("DOMContentLoaded", async function(event) {
    ...
}

Una vez que esté listo, podemos configurar algunas variables y constantes de clave:

    const serverUrlResponse = await fetch('/webapi');
    const serverUrl = await serverUrlResponse.text();
    console.log('Web API endpoint:', serverUrl);
    
    const server = serverUrl + '/books';
    var page = 0;
    var language = '';

Primero, recuperaremos la URL de nuestra API de REST, gracias al código de nodo de App Engine que muestra la variable de entorno que configuramos inicialmente en app.yaml. Gracias a la variable de entorno, el extremo /webapi, al que se llama desde el código JavaScript del cliente, no tuvimos que codificar la URL de la API de REST en nuestro código de frontend.

También definiremos las variables page y language, que usaremos para realizar un seguimiento de la paginación y el filtrado de idioma.

    const moreButton = document.getElementById('more-button');
    moreButton.addEventListener('sl-focus', event => {
        console.log('Button clicked');
        moreButton.blur();

        appendMoreBooks(server, page++, language);
    });

Agregamos un controlador de eventos en el botón para cargar libros. Cuando se haga clic en él, se llamará a la función appendMoreBooks().

    const langSelect = document.getElementById('language-select');
    langSelect.addEventListener('sl-change', event => {
        page = 0;
        language = event.srcElement.value;
        document.getElementById('library').replaceChildren();
        console.log(`Language selected: "${language}"`);

        appendMoreBooks(server, page++, language);
    });

De manera similar a la casilla de selección, agregamos un controlador de eventos que recibe notificaciones de los cambios en la selección de idioma. Y, al igual que con el botón, también llamamos a la función appendMoreBooks() y pasamos la URL de la API de REST, la página actual y la selección de idioma.

Veamos esa función que recupera y adjunta libros:

async function appendMoreBooks(server, page, language) {
    const searchUrl = new URL(server);
    if (!!page) searchUrl.searchParams.append('page', page);
    if (!!language) searchUrl.searchParams.append('language', language);
        
    const response = await fetch(searchUrl.href);
    const books = await response.json();
    ... 
}

Arriba, creamos la URL exacta que se usará para llamar a la API de REST. En general, podemos especificar tres parámetros de consulta, pero en esta IU solo especificamos dos:

  • page: Es un número entero que indica la página actual para la paginación de los libros.
  • language: Es una cadena de idioma para filtrar por idioma escrito.

Luego, usamos la Fetch API para recuperar el array JSON que contiene los detalles de nuestro libro.

    const linkHeader = response.headers.get('Link')
    console.log('Link', linkHeader);
    if (!!linkHeader && linkHeader.indexOf('rel="next"') > -1) {
        console.log('Show more button');
        document.getElementById('buttons').style.display = 'block';
    } else {
        console.log('Hide more button');
        document.getElementById('buttons').style.display = 'none';
    }

Según si el encabezado Link está presente en la respuesta, ocultaremos o mostraremos el botón [More books...], ya que el encabezado Link es una sugerencia que nos indica si aún hay más libros por cargar (habrá una URL de next en el encabezado Link).

    const library = document.getElementById('library');
    const template = document.getElementById('book-card');
    for (let book of books) {
        const bookCard = template.content.cloneNode(true);

        bookCard.querySelector('slot[name=title]').innerText = book.title;
        bookCard.querySelector('slot[name=language]').innerText = book.language;
        bookCard.querySelector('slot[name=author]').innerText = book.author;
        bookCard.querySelector('slot[name=year]').innerText = book.year;
        bookCard.querySelector('slot[name=pages]').innerText = book.pages;
        
        const img = document.createElement('img');
        img.setAttribute('id', book.isbn);
        img.setAttribute('class', 'img-barcode-' + book.isbn)
        bookCard.querySelector('slot[name=barcode]').appendChild(img);

        library.appendChild(bookCard);
        ... 
    }
}

En la sección anterior de la función, para cada libro que muestra la API de REST, clonaremos la plantilla con algunos componentes web que representen un libro y propagaremos las ranuras de la plantilla con los detalles del libro.

JsBarcode('.img-barcode-' + book.isbn).EAN13(book.isbn, {fontSize: 18, textMargin: 0, height: 60}).render();

Para que el código ISBN sea un poco más atractivo, usamos la biblioteca JsBarcode para crear un código de barras atractivo como en la contraportada de un libro real.

Ejecuta y prueba la aplicación de manera local

Por ahora, es momento de ver la aplicación en acción. Primero, lo haremos de forma local, en Cloud Shell, antes de realizar la implementación real.

Instalamos los módulos de NPM que necesita nuestra aplicación con lo siguiente:

$ npm install

También ejecutamos la app con lo habitual:

$ npm start

También puedes volver a cargar automáticamente los cambios gracias a nodemon, con lo siguiente:

$ npm run dev

La aplicación se está ejecutando de forma local y podemos acceder a ella desde el navegador en http://localhost:8080.

Implementa la aplicación de App Engine

Ahora que tenemos la certeza de que nuestra aplicación se ejecuta correctamente a nivel local, es hora de implementarla en App Engine.

Para implementar la aplicación, iniciemos el siguiente comando:

$ gcloud app deploy -q

Después de aproximadamente un minuto, la aplicación debería implementarse.

La aplicación estará disponible en una URL con la forma https://${GOOGLE_CLOUD_PROJECT}.appspot.com.

Explora la IU de nuestra aplicación web de App Engine

Ahora puedes hacer lo siguiente:

  • Haz clic en el botón [More books...] para cargar más libros.
  • Selecciona un idioma en particular para ver los libros únicamente en ese idioma.
  • Puedes borrar la selección con la pequeña cruz del cuadro de selección para volver a la lista de todos los libros.

10. Realiza una limpieza (opcional)

Si no pretendes conservar la app, puedes borrar todo el proyecto para limpiar los recursos, ahorrar costos y ser un buen ciudadano de la nube en general:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. ¡Felicitaciones!

Gracias a Cloud Functions, App Engine y Cloud Run, creamos un conjunto de servicios para exponer varios extremos de las APIs web y el frontend web para almacenar, actualizar y navegar por una biblioteca de libros, siguiendo algunos patrones de diseño adecuados para el desarrollo de APIs de REST.

Temas abordados

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

Un paso más allá

Si deseas explorar más este ejemplo concreto y expandirlo, aquí hay una lista de aspectos que te recomendamos investigar:

  • Aprovecha API Gateway para proporcionar una fachada de API común a la función de importación de datos y al contenedor de la API de REST, para agregar funciones (como el manejo de claves de API para acceder a la API) o definir limitaciones de frecuencia para los consumidores de API.
  • Implementa el módulo de nodo Swagger-UI en la aplicación de App Engine para documentar y ofrecer una zona de pruebas para la API de REST.
  • En el frontend, más allá de la capacidad de navegación existente, agrega pantallas adicionales para editar los datos y crea nuevas entradas de libro. Además, dado que estamos usando la base de datos de Cloud Firestore, aprovecha su función en tiempo real para actualizar los datos de los libros que se muestran a medida que se realizan cambios.