Taller de API web sin servidores

1. Resumen

El objetivo de este codelab es obtener experiencia en los servicios “sin servidores” que ofrece Google Cloud Platform:

  • Cloud Functions: Implementa unidades pequeñas 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: Para implementar y entregar aplicaciones web, API web, backends para dispositivos móviles, recursos estáticos y con escalamiento vertical y funciones rápidas.
  • Cloud Run: Se usa para implementar y escalar contenedores que pueden contener cualquier lenguaje, entorno de ejecución o biblioteca.

Y a fin de descubrir cómo aprovechar esos servicios sin servidores para implementar y escalar API web y de REST, y, al mismo tiempo, ver algunos principios de diseño RESTful atractivos.

En este taller, crearemos un explorador de estantería que consta de lo siguiente:

  • Una función de Cloud Functions: para importar el conjunto de datos inicial de libros disponibles en nuestra biblioteca, en la base de datos de documentos de Cloud Firestore,
  • Un contenedor de Cloud Run que expone una API de REST en el contenido de nuestra base de datos
  • Un frontend web de App Engine: para explorar la lista de libros, llama a nuestra API de REST

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

b6964f26b9624565.png

Qué aprenderá

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

2. Configuración y requisitos

Configuración del entorno a su propio ritmo

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

96a9c957bc475304.png

b9a10ebdf5b5a415.png

a1e3c01a38fa61c2.png

Recuerde el ID de proyecto, un nombre único en todos los proyectos de Google Cloud (el nombre anterior ya se encuentra en uso y no lo podrá usar). Se mencionará más adelante en este codelab como PROJECT_ID.

  1. A continuación, deberás habilitar la facturación en Cloud Console para usar los recursos de Google Cloud recursos.

Ejecutar este codelab no debería costar mucho, tal vez nada. Asegúrate de seguir las instrucciones de la sección “Cómo limpiar” que te aconsejará hacer el cierre de los recursos para no incurrir en facturación más allá de este instructivo. Los usuarios nuevos de Google Cloud son aptos para participar en el programa de prueba gratuita de USD 300.

Iniciar Cloud Shell

Si bien Google Cloud se puede operar de forma 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 GCP Console, haga clic en el ícono de Cloud Shell en la barra de herramientas superior derecha:

bce75f34b2c53987.png

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

f6ef2b5f13479f3a.png

Esta máquina virtual está cargada con todas las herramientas para desarrolladores que necesitará. Ofrece un directorio principal persistente de 5 GB y se ejecuta en Google Cloud, lo que mejora enormemente el rendimiento y la autenticación de la red. Puede realizar todo su trabajo en este lab usando un navegador.

3. Preparar el entorno y habilitar las API de Cloud

Para usar los diversos servicios que necesitaremos en este proyecto, habilitaremos algunas API. 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ía ver que la operación se completó 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 la función, la app y el contenedor:

$ export REGION=europe-west3

Dado que almacenaremos datos en la base de datos de Cloud Firestore, tendremos que crearla:

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

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

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

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

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

Esos 3 índices corresponden a las búsquedas que hacemos por autor o idioma, mientras se sigue ordenando la colección mediante un campo actualizado.

4. Obtén el código

Obtenga 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.

Tendrá la siguiente estructura de carpetas que es relevante para este lab:

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 expone 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 de solo lectura simple para explorar la lista de libros.

5. Muestra de datos de la biblioteca de libros

En la carpeta de datos, hay un archivo books.json que contiene una lista de cien libros, probablemente vale la pena leerlo. Este documento JSON es un array que contiene objetos JSON. Veamos la forma de los datos que transferiremos mediante 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 nuestro libro en este array contienen la siguiente información:

  • isbn: Es el código ISBN-13 que identifica el libro.
  • author: el nombre del autor del libro.
  • language: El idioma en el que se escribe el libro.
  • pages: la cantidad de páginas del libro.
  • title: es el título del libro.
  • year: Es el año en 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 los 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": "^1.7.1"
    },
    "scripts": {
        "start": "npx @google-cloud/functions-framework --target=parseBooks"
    }
}

En las dependencias del entorno de ejecución, solo necesitamos el módulo 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 de tiempo de ejecución que se usa para invocar sus funciones. Es un framework de código abierto que también se puede usar de manera local en su máquina (en nuestro caso, dentro de Cloud Shell) para ejecutar funciones sin realizar una implementación cada vez que realiza un cambio, con lo que se mejora el bucle de comentarios de desarrollo.

Para instalar las dependencias, usa el comando install:

$ npm install

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

$ npm start

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

Ahora, veamos el 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');

Crear una instancia del módulo de Firestore y apuntar a la colección de libros (similar a una tabla de las bases de datos relacionales)

exports.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 de JavaScript parseBooks. Esta es la función que declararemos más adelante cuando la implementemos.

En las siguientes instrucciones, se verifica que:

  • Solo aceptamos solicitudes HTTP POST y, de lo contrario, se muestra un código de estado 405 para indicar que no se permiten otros métodos HTTP.
  • 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 del libro, a través de los campos isbn, title, author, language, pages y year. El código ISBN del libro servirá como identificador o clave primaria.

    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á listo, podemos confirmar la operación. Si la operación de almacenamiento falla, mostraremos un código de estado 400 para indicar que falla. De lo contrario, podemos mostrar una respuesta correcta, 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 al framework de Functions, usaremos el comando de la 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 el siguiente comando:

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

Cuando inicie este comando, verá el siguiente resultado, lo que confirma que la función se ejecuta de forma local:

{"status":"OK"}

También puede ir a la IU de Cloud Console para verificar que los datos se hayan almacenado en Firestore:

d6a2b31bfa3443f2.png

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

Implemente 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 \
         --trigger-http \
         --runtime=nodejs12 \
         --allow-unauthenticated \
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

Implementamos la función con un nombre simbólico de bulk-import. Esta función se activa mediante solicitudes HTTP. Usamos el entorno de ejecución de Node.js 12. Implementamos la función públicamente (idealmente, debemos proteger ese extremo). Especificamos la región donde 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 Cloud Console, debería ver la función:

c3156d50ba917ddd.png

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

2d19539de3de98eb.png

También puedes recuperar la URL mediante 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

Vamos a almacenarlo en la variable de entorno BULK_IMPORT_URL para poder reutilizarla a fin de probar la función implementada.

Prueba la función implementada

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

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

Una vez más, si se ejecuta correctamente, debería mostrar el siguiente resultado:

{"status":"OK"}

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

7. El contrato de la API de REST

Si bien no definiremos un contrato de API usando, por ejemplo, la Especificación de API abierta, veremos los distintos puntos finales de la API de REST.

La API intercambia objetos JSON, que consisten en:

  • isbn (opcional): Es un String de 13 caracteres que representa un código ISBN válido.
  • author: es un objeto String no vacío que representa el nombre del autor del libro.
  • language: es un objeto String no vacío que contiene el idioma en el que se escribió el libro.
  • pages: Es un Integer positivo para el recuento de páginas del libro.
  • title: es un objeto String no vacío con el título del libro.
  • year: Es un valor de 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 filtrados por autor o idioma, y paginados 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 arreglo JSON de objetos de libro.

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 el código isbn no se necesita en la carga útil del libro) o sin él (en cuyo caso el código isbn debe estar presente en el 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

OBTENER /books/{isbn}

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

Carga útil del cuerpo: ninguna

Parámetros de consulta: ninguno

Muestra un objeto JSON de un 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 con su isbn pasado como parámetro de ruta de acceso.

Carga útil del cuerpo: Un objeto de libro. Solo se pueden pasar los campos que necesitan una actualización, mientras que los otros son opcionales.

Parámetros de consulta: ninguno

Muestra: el libro actualizado.

Códigos de estado:

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

BORRAR /libros/{isbn}

Borra un libro existente, identificado con su isbn que se pasó como parámetro de la 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. Implementar y exponer una API de REST en un contenedor

Explora el código

Dockerfile

Comencemos con Dockerfile, que será responsable de crear un contenedor para el código de nuestra aplicación:

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

Estamos usando una imagen "delgada" de Node.js 14. Estamos trabajando en el directorio /usr/src/app. Copiamos el archivo package.json (más detalles a continuación) que define nuestras dependencias, entre otras cosas. 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 ver el archivo package.json:

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 14.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 sucede con Dockerfile.

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

  • El módulo de NPM de Firestore para acceder a los datos del libro en la base de datos,
  • La biblioteca cors para manejar solicitudes de CORS (uso compartido de recursos entre orígenes), ya que nuestra API de REST se invocará desde el código del cliente del frontend de tu aplicación web de App Engine
  • El marco de trabajo Express, que será nuestro marco de trabajo web para diseñar la API,
  • Y luego, el módulo isbn3, que ayuda a validar códigos ISBN del libro.

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

index.js

Pasemos a la parte del código, que ofrece una descripción detallada de index.js:

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

Exigimos el módulo Firestore y hacemos referencia a la colección books, donde se almacenan los datos de nuestros 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'],
}));

Utilizamos Express, como nuestro marco de trabajo web, para implementar nuestra API de REST. Usaremos el módulo body-parser para analizar las cargas útiles JSON que se intercambiaron con nuestra API.

El módulo querystring es útil para manipular las URL. Este será el caso cuando creemos los encabezados Link con fines de paginación (más adelante abordaremos este tema).

Luego, configuramos el módulo cors. Explicamos los encabezados que queremos que se pasen a través de CORS, ya que la mayoría suelen quitarse. Sin embargo, aquí queremos conservar 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 isbn3 de NPM para analizar y validar códigos ISBN, y desarrollaremos una pequeña función de utilidad que analice los códigos ISBN y responda con un código de estado 406 en la respuesta, si los códigos ISBN son no válido.

  • GET /books

Veamos el extremo GET /books, pieza por pieza:

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 realizar consultas en la base de datos mediante la preparación de una consulta. Esta consulta dependerá de los parámetros opcionales de búsqueda, para filtrar por autor o idioma. También devolveremos la lista de libros por partes de 10.

Si se produce un error durante el proceso de recuperación, mostraremos un error con un código de estado 400.

Veamos la porción 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 según el orden de la última actualización (la última actualización se muestra primero). También paginaremos el resultado definiendo un límite (la cantidad de elementos que se debe mostrar) 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.

Para finalizar las explicaciones de este extremo, veamos una práctica recomendada: usar el encabezado Link para definir vínculos de URI a la primera, anterior, siguiente o última página de datos (en este caso, solo brindaremos 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 los datos. Además, agregaremos un vínculo next si la página de datos está completa (es decir, contiene la cantidad máxima de libros según la definición de la constante PAGE_SIZE, suponiendo que hay otra con más datos). Luego, usamos la función resource#links() de Express para crear el encabezado adecuado con la sintaxis correcta.

El encabezado del vínculo tendrá un aspecto similar al siguiente:

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

Ambos extremos están aquí para crear un libro nuevo. Uno pasa el código ISBN en la carga útil del libro, mientras que el otro lo pasa como parámetro de ruta. De cualquier manera, ambos llaman a la 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 y, de lo contrario, se mostrará desde la función (y se establecerá un código de estado 406). Recuperamos los campos de libro de la carga útil que se pasó en el cuerpo de la solicitud. Luego, almacenaremos los detalles del libro en Firestore. Se muestra 201 cuando se realiza correctamente y 400 si se produce un error.

Cuando se muestra correctamente, también se configura el encabezado de ubicación para proporcionar 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 con 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. Realizamos una consulta a Firestore para recuperar el libro. La propiedad snapshot.exists es útil para saber si se encontró un libro. De lo contrario, enviamos un mensaje de 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/hora updated para recordar cuándo actualizamos por última vez ese registro. 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 guardan los campos nuevos en la carga útil) y se borran los campos existentes de la actualización anterior o de la creación inicial.

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

  • DELETE /books/:isbn

Borrar libros es bastante sencillo. Solo llamamos al método delete() en la referencia del documento. Le mostramos un código de estado 204, ya que no devolvemos 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}`});
    }
});

Inicia el servidor Express o Node.

Por último, aunque 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 lo siguiente:

$ npm install

Luego, 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 en Docker también es una excelente manera de verificar que la creación de contenedores de nuestra aplicación se ejecute sin problemas mientras la compilamos en la nube con Cloud Build.

Cómo probar la API

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

  • Crea 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
  • Cree un libro nuevo (ISBN en un parámetro de ruta de acceso):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • Borra 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
  • Para buscar los libros escritos por un autor específico, sigue estos pasos:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • Indica los libros escritos en inglés:
$ curl http://localhost:8080/books?language=English
  • Carga la cuarta página de los libros:
$ curl http://localhost:8080/books?page=3

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

Compila e implementa la API de REST alojada en contenedores

Nos alegra que la API de REST funcione según lo planeado, por lo que es el momento de implementarlo en Cloud Run.

Lo haremos en dos pasos:

  • Primero, compila la imagen de contenedor con Cloud Build mediante 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 de contenedor y la aloja en Container Registry. El siguiente comando implementa la imagen de contenedor del registro y la implementa en la región de la nube.

Podemos verificar nuevamente en la IU de Cloud Console que nuestro servicio de Cloud Run ahora aparezca en la lista:

4ca13b0a703b2126.png

Un último paso que haremos aquí es recuperar la URL del servicio de Cloud Run recién implementado, mediante el 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 siguiente sección, ya que nuestro código de frontend de App Engine interactuará con la API.

9. Aloja una aplicación web para explorar la biblioteca

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

Nuestra aplicación, a pesar de estar implementada en el entorno de ejecución de Node.js 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 estará en el navegador mediante JavaScript del cliente. No usaremos ningún framework de JavaScript de frontend elegante, solo usaremos JavaScript "vanilla", con algunos componentes web para la IU mediante la biblioteca de componentes web de Shoelace:

  • una casilla para seleccionar el idioma del libro:

1b7bf64bd327b1ee.png

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

4dd54e4d5ee53367.png

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

4766c796a9d87475.png

Al combinar todos esos componentes visuales, la página web resultante para explorar nuestra biblioteca tendrá el siguiente aspecto:

fb6eae65811c8ac1.png

El archivo de configuración de app.yaml

Comencemos con la base de código de esta aplicación de App Engine. Para ello, veamos su archivo de configuración app.yaml. Este es un archivo específico de App Engine y permite configurar variables de entorno, los diversos controladores de la aplicación o especificar que algunos recursos son recursos estáticos entregado por la CDN integrada de App Engine.

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 una 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 (consulte a continuación cómo cambiarla).

Luego, definimos varios controladores. Las primeras 3 apuntan a la ubicación del código del cliente HTML, CSS y JavaScript, en la carpeta public/ y sus subcarpetas. La cuarta indica que la URL raíz de nuestra aplicación de App Engine debe dirigir a la página index.html. De esta manera, no veremos el sufijo index.html en la URL cuando accedas a la raíz del sitio web. La última es la predeterminada que enrutará todas las otras URL (/.*) a nuestra aplicación Node.js (es decir, la parte "dinámica" de la aplicación, en contraste con la recursos que describimos".

Actualicemos 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

También puedes cambiar de forma manual la string 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"
    }
}

Queremos enfatizar que queremos ejecutar esta aplicación con Node.js 14. Dependemos del marco de trabajo Express y del módulo NPM isbn3 para validar los códigos ISBN de los libros.

En las dependencias de desarrollo, usaremos el módulo nodemon para supervisar los cambios del archivo. Si bien podemos ejecutar nuestra aplicación de manera local con npm start, realizar 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 hacer que la aplicación se vuelva a cargar o reiniciar automáticamente cuando se realicen cambios:

$ npm run dev

El código de Node.js de index.js

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

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

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

Exigimos el framework web Express. Especificamos que el directorio público contiene elementos estáticos que el middleware static puede entregar (al menos cuando se ejecuta de manera local en modo de desarrollo). Por último, exigimos que body-parser analice nuestras cargas útiles JSON.

Veamos algunas 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 los index.html de nuestro directorio public/html. Al igual que en el modo de desarrollo, no ejecutaremos el enrutamiento de URL de App Engine en el entorno de ejecución de App Engine. En su lugar, 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á adó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, ejecutamos la aplicación web Express y escuchamos en el puerto 8080 de forma predeterminada.

La página de index.html

No analizaremos todas las líneas de esta página HTML larga. 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 del libro.

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

En el body de la página HTML, usamos los componentes de Calzado con sus etiquetas de elementos personalizados, como se muestra a continuación:

<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>
...

Además, utilizamos plantillas HTML y su capacidad de relleno de espacios para representar un libro. Crearemos copias de esa plantilla para propagar la lista de libros y reemplazaremos los valores de los espacios con los detalles de los libros:

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

Casi HTML, ya casi terminamos con la revisión del código. Solo queda una parte de la carne: el código JavaScript de cliente app.js, que interactúa con la API de REST.

El código 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) {
    ...
}

Cuando esté lista, podemos configurar algunas variables y constantes 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, que se llama desde el código del cliente de JavaScript, no tuvimos que codificar la URL de la API de REST en nuestro código de frontend.

También definimos las variables page y language, que usaremos para hacer un seguimiento de la paginación y el filtrado de idiomas.

    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 forma similar al cuadro de selección, agregamos un controlador de eventos para recibir notificaciones sobre los cambios en la selección de idioma. Y, como con el botón, también llamamos a la función appendMoreBooks(), pasando la URL de la API de REST, la página actual y la selección de idioma.

Echemos un vistazo a esa función que recupera y agrega 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();
    ...
}

En la parte superior, creamos la URL exacta que se usará para llamar a la API de REST. En general, podemos especificar tres parámetros de búsqueda, pero aquí en esta IU solo especificamos dos:

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

Luego, utilizamos la API de Fetch 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';
    }

En función de si el encabezado Link está presente en la respuesta, mostraremos u ocultaremos el botón [More books...], ya que el encabezado Link es una sugerencia que nos indica si quedan más libros por cargar (tendrá ser una URL 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 representan un libro y propagaremos los espacios 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, utilizamos la biblioteca JsBarcode para crear un buen código de barras, como en la contraportada de libros reales.

Cómo ejecutar y probar la aplicación de manera local

Es suficiente el código por ahora; es hora de ver la aplicación en acción. Primero, lo haremos de manera local, dentro de Cloud Shell, antes de implementar de verdad.

Instalamos los módulos NPM que necesita nuestra aplicación con los siguientes elementos:

$ npm install

Y podemos ejecutar la app con lo siguiente:

$ npm start

O bien, con la recarga automática de cambios gracias a nodemon, que incluye lo siguiente:

$ npm run dev

La aplicación se ejecuta de manera local y podemos acceder a ella desde el navegador en http://localhost:8080.

Implemente la aplicación de App Engine

Ahora que estamos seguros de que nuestra aplicación se ejecuta de manera local, es momento de implementarla en App Engine.

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

$ gcloud app deploy -q

Después de aproximadamente un minuto, se debe implementar la aplicación.

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, puede 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 solo en ese idioma.
  • Puedes borrar la selección con la cruz pequeña en el cuadro de selección para regresar a la lista de todos los libros.

10. Realiza una limpieza (opcional)

Si no deseas conservar la app, puedes borrar recursos para ahorrar costos y ser un buen ciudadano de la nube general mediante la eliminación de todo el proyecto:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT}

11. ¡Felicitaciones!

Gracias a Cloud Functions, App Engine y Cloud Run, creamos un conjunto de servicios para exponer diversos extremos de la API web y el frontend web a fin de almacenar, actualizar y explorar una biblioteca de libros, y seguir algunos patrones de diseño útiles para el desarrollo de la API de REST en el camino.

Temas abordados

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

Más allá

Si quieres obtener más información sobre este ejemplo concreto y expandirlo, aquí te ofrecemos una lista de elementos que puedes investigar:

  • Aprovecha la Puerta de enlace de API 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, a fin de 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 a fin de documentar y ofrecer una zona de pruebas de pruebas para la API de REST.
  • En el frontend, además de la capacidad de navegación existente, agrega pantallas adicionales para editar los datos y crea nuevas entradas de libros. Además, debido a que usamos la base de datos de Cloud Firestore, aprovecha su función en tiempo real para actualizar los datos de los libros que se muestran cuando se realizan cambios.