Taller de APIs web sin servidores

1. Descripción general

El objetivo de este codelab es adquirir experiencia con los servicios "sin servidor" que ofrece Google Cloud:

  • Cloud Functions: Para implementar pequeñas unidades de lógica empresarial en forma de funciones que reaccionan a diversos eventos (mensajes de Pub/Sub, archivos nuevos en Cloud Storage, solicitudes HTTP y mucho más)
  • App Engine: Para implementar y entregar apps web, APIs web, backends móviles y recursos estáticos, con capacidades de escalamiento rápido hacia arriba y hacia abajo
  • Cloud Run: Para implementar y escalar contenedores que pueden contener cualquier lenguaje, entorno de ejecución o biblioteca

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

En este taller, crearemos un explorador de estanterías que constará de los siguientes elementos:

  • Una Cloud Function: 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 expondrá una API de REST sobre el contenido de nuestra base de datos
  • Un 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 a lo largo de este proyecto, habilitaremos algunas APIs. Para ello, ejecutaremos 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 Cloud en la que implementaremos nuestra función, app y contenedor:

$ export REGION=europe-west3

Como almacenaremos datos en la base de datos de Cloud Firestore, deberemos crearla:

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

Más adelante en este codelab, cuando implementemos la API de REST, deberemos 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 las búsquedas que haremos por autor o idioma, mientras mantenemos 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.

Tendrás 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 expondrá una API web para acceder a los datos del libro almacenados en Cloud Firestore.
  • appengine-frontend: Esta aplicación web de App Engine mostrará un frontend simple de solo lectura para explorar la lista de libros.

5. Datos de la biblioteca de libros de ejemplo

En la carpeta de datos, tenemos un archivo books.json que contiene una lista de cien libros que probablemente valgan 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 nuestras 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: Es la cantidad de páginas del libro.
  • title: Es el título del libro.
  • year: 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 endpoint 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 de tiempo de ejecución, solo necesitamos el módulo @google-cloud/firestore de NPM 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 de 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 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 del 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 para ejecutar la función de forma local con la siguiente instrucción:

$ npm start

Puedes usar curl o, posiblemente, la vista previa en la Web de Cloud Shell para las solicitudes HTTP GET y, así, interactuar 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');

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;
    }
    ... 
})

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

Las siguientes instrucciones verifican lo siguiente:

  • Solo aceptamos solicitudes HTTP POST; de lo contrario, devolvemos un código de estado 405 para indicar que no se permiten otros métodos HTTP.
  • Solo aceptamos cargas útiles 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 JSON a través de body de la solicitud. Estamos preparando una operación por lotes de Firestore para almacenar todos los libros de forma masiva. Iteramos sobre el array JSON que consta de los detalles del libro y pasamos por los campos isbn, title, author, language, pages y year. El código ISBN del libro servirá como clave principal 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 falla la operación de almacenamiento, devolvemos un código de estado 400 para indicar que falló. De lo contrario, podemos devolver una respuesta OK, con un código de estado 202 que indica que se aceptó la solicitud de guardado masivo.

Cómo ejecutar y probar la función de importación

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

$ npm install

Para ejecutar la función de forma 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 HTTP POST a tu función local, puedes ejecutar lo siguiente:

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

Cuando ejecutes 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 Cloud Console para verificar que los datos se almacenen 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 el nombre simbólico bulk-import. Esta función se activa a través de solicitudes HTTP. Usamos el entorno de ejecución de Node.js 20. Implementamos la función de forma pública (lo ideal sería proteger ese extremo). Especificamos la región en la que queremos que resida la función. Señala las fuentes en el directorio local y usa parseBooks (la función de JavaScript exportada) como punto de entrada.

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

c910875d4dc0aaa8.png

En el resultado de la implementación, deberías poder ver la URL de tu función, que sigue una cierta convención de nomenclatura (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}). Por supuesto, también puedes encontrar esta URL del activador HTTP en la IU de Cloud Console, 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émosla en la variable de entorno BULK_IMPORT_URL para poder reutilizarla y probar nuestra 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 devolver el siguiente resultado:

{"status":"OK"}

Ahora que nuestra función de importación está implementada y lista, y que subimos nuestros datos de muestra, es 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 con, por ejemplo, la especificación de Open API, analizaremos los distintos extremos de nuestra API de REST.

La API intercambia objetos JSON de libros, que constan de lo siguiente:

  • isbn (opcional): Es un String de 13 caracteres que representa un código ISBN válido.
  • author: Un String no vacío que representa el nombre del autor del libro.
  • language: Un 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: Un 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 de libro:

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

GET /books

Obtén la lista de todos los libros, que se puede filtrar por autor o idioma, y se pagina en 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, valor predeterminado = 0): Indica la clasificación de la página de resultados que se devolverá.

Devuelve un array JSON de objetos de libros.

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

Parámetros de consulta: Ninguno

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

GET /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

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

Códigos de estado:

  • 200: Si se encuentra el libro 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 pasado como parámetro de ruta.

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

Parámetros de consulta: Ninguno

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

DELETE /books/{isbn}

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

Cuerpo de la carga útil: Ninguno.

Parámetros de consulta: Ninguno

Devuelve: 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 observar el Dockerfile, que será responsable de contenerizar el código de nuestra aplicación:

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

Usamos una imagen "slim" de Node.js 20. Estamos trabajando en el directorio /usr/src/app. Copiaremos el archivo package.json (con los detalles que se indican 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 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 en el caso de 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 controlar las solicitudes de CORS (uso compartido de recursos entre dominios), ya que se invocará nuestra API de REST desde el código del cliente del frontend de nuestra aplicación web de App Engine
  • El framework de Express, que será nuestro framework web para diseñar nuestra API
  • Y, luego, el módulo isbn3, que ayuda a validar los códigos ISBN de los libros.

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

index.js

Pasemos al núcleo 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 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'],
}));

Usamos Express como nuestro 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, lo que será necesario cuando creemos encabezados Link para la paginación (más información sobre esto más adelante).

Luego, configuramos el módulo cors. Explicitamos los encabezados que queremos que se pasen a través de CORS, ya que la mayoría suelen quitarse, pero 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;
}

Usaremos el módulo isbn3 de NPM para analizar y validar códigos ISBN, y 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}`});
    }
});

Estamos preparando una consulta para consultar la base de datos. Esta búsqueda dependerá de los parámetros de búsqueda opcionales para filtrar por autor o por idioma. También devolvemos la lista de libros en fragmentos de 10 libros.

Si se produce un error durante la recuperación de los libros, devolvemos un error con un código de estado 400.

Hagamos un acercamiento a 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, aplicamos filtros por author y language, pero en esta sección, ordenaremos la lista de libros por orden de fecha de última actualización (la última actualización aparece primero). También paginaremos el resultado definiendo un límite (la cantidad de elementos que se devolverán) y un desplazamiento (el punto de partida desde el cual se devolverá 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 devolverá al final de la función.

Terminemos las explicaciones de este extremo con una buena práctica: usar el encabezado Link para definir vínculos de URI a la primera, anterior, siguiente o última página de datos (en nuestro caso, solo proporcionaremos la 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);

La lógica puede parecer un poco compleja al principio, pero lo que hacemos 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á completa (es decir, contiene la cantidad máxima de libros según lo define la constante PAGE_SIZE, suponiendo que habrá otra con más datos). Luego, usamos la función resource#links() de Express para crear el encabezado correcto con la sintaxis adecuada.

Para tu información, el encabezado del vínculo se verá de la siguiente manera:

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

Ambos extremos se utilizan para crear un libro nuevo. Uno pasa el código ISBN en la carga útil del libro, mientras que el otro lo pasa como un parámetro de ruta. De cualquier manera, ambas 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, salimos de la función (y establecemos un código de estado 406). Recuperamos los campos del libro de la carga útil que se pasa en el cuerpo de la solicitud. Luego, almacenaremos los detalles del libro en Firestore. Devuelve 201 si la operación se realiza correctamente y 400 si falla.

Cuando se devuelve una respuesta correcta, también configuramos el encabezado de ubicación para darle pistas al cliente de la API sobre dónde se encuentra el recurso recién creado. El encabezado se verá de la siguiente manera:

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

Recuperemos un libro, identificado por 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, de hecho, se encontró un libro. De lo contrario, devolvemos un error y un código de estado 404 Not Found. Recuperamos los datos del libro y creamos un objeto JSON que lo representa para devolverlo.

  • PUT /books/:isbn

Usamos 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 por sus valores nuevos (de lo contrario, se quitan todos los campos y solo se guardan los campos nuevos en la carga útil, lo que borra los campos existentes de la actualización anterior o la creación inicial).

También establecemos 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. Devolvemos 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 de Express / Node

Por último, pero no menos importante, iniciamos el servidor, que escucha 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 forma local, primero instalaremos las dependencias con el siguiente comando:

$ 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 dentro de Docker también es una excelente manera de verificar que la contenedorización de nuestra aplicación se ejecutará correctamente a medida que 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 de Node o con una imagen de contenedor de Docker), ahora podemos ejecutar algunas consultas en ella.

  • 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
  • Crea 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
  • Recupera un libro por ISBN:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • Actualiza un libro existente cambiando solo su 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
  • Sigue estos pasos para encontrar los libros escritos por un autor específico:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • Enumera los libros escritos en inglés:
$ curl http://localhost:8080/books?language=English
  • Carga la 4ª página de 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 nuestra búsqueda.

Compila e implementa la API de REST en contenedores

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

Lo haremos en dos pasos:

  • Primero, compila la imagen del contenedor con Cloud Build, con 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 desde el registro y la implementa en la región de la nube.

Podemos verificar en la IU de la consola de Cloud que nuestro servicio de Cloud Run ahora aparece en la lista:

f62fbca02a8127c0.png

Un último paso que haremos aquí es recuperar la URL del servicio de Cloud Run recién implementado con el siguiente comando:

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

En la siguiente sección, necesitaremos la URL de nuestra API de REST de Cloud Run, 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 agregarle brillo a este proyecto es proporcionar un frontend web que interactúe con nuestra API de REST. Para ello, usaremos Google App Engine, con algo de código JavaScript del cliente que llamará a la API a través de solicitudes de AJAX (con la API de Fetch del cliente).

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

  • Un cuadro de selección para elegir el idioma del libro:

6fb9f741000a2dc1.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, con la biblioteca JsBarcode):

3aa21a9e16e3244e.png

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

3925ad81c91bbac9.png

Cuando se combinan 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 explorar 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 que permite configurar elementos como las variables de entorno, los distintos "controladores" de la aplicación o especificar que algunos recursos son activos estáticos que se publicarán a través de 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 de Node.js y que queremos usar la versión 14.

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

Después de eso, definimos varios controladores. Los primeros 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 apuntar a la página index.html. De esa manera, no veremos el sufijo index.html en la URL cuando accedamos a la raíz del sitio web. Y la última es la predeterminada que enrutará todas las demás URLs (/.*) a nuestra aplicación de Node.js (es decir, la parte "dinámica" de la aplicación, en contraste con los recursos estáticos que describimos).

Ahora actualizaremos 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 bien, cambia manualmente la cadena CHANGE_ME en app.yaml por 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"
    }
}

Volvemos a destacar que queremos ejecutar esta aplicación con Node.js 14. Dependemos del framework de Express, así como del módulo isbn3 de NPM para validar los códigos ISBN de los libros.

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 forma local con npm start, realizar algunos cambios en el código, detener la app con ^C y, luego, volver a iniciarla, 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 haya cambios:

$ npm run dev

El código de 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 de Express. Especificamos que el directorio público contiene recursos estáticos que el middleware static puede publicar (al menos cuando se ejecuta de forma local en modo de desarrollo). Por último, necesitamos body-parser para analizar nuestras cargas útiles de JSON.

Veamos las dos 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 primer elemento que coincida con / redireccionará a index.html en nuestro directorio public/html. Como en el modo de desarrollo no ejecutamos el entorno de ejecución de App Engine, no se realiza el enrutamiento de URLs de App Engine. En cambio, aquí, simplemente redireccionamos la URL raíz al archivo HTML.

El segundo extremo que definimos /webapi devolverá la URL de nuestra API de REST de Cloud Run. De esta manera, el código JavaScript del cliente sabrá 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, ejecutamos la app web de Express y escuchamos 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, destacaremos 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 diseño).

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 diseño CSS, que se encuentran en nuestros subdirectorios public/.

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

<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 completar espacios para representar un libro. Crearemos copias de esa plantilla para completar la lista de libros y reemplazaremos los valores de las ranuras por los detalles de los libros:

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

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

El código JavaScript del cliente 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 constantes y variables 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 devuelve la variable de entorno que establecimos inicialmente en app.yaml. Gracias a la variable de entorno, el extremo /webapi, llamado desde el código del cliente de JavaScript, no tuvimos que codificar de forma rígida la URL de la API de REST en nuestro código de frontend.

También definimos 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 al 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);
    });

Lo mismo sucede con el cuadro de selección: agregamos un controlador de eventos para recibir notificaciones sobre los cambios en la selección de idioma. 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 la 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();
    ... 
}

Arriba, creamos la URL exacta que se usará para llamar a la API de REST. Normalmente, podemos especificar tres parámetros de consulta, 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 cadena de idioma para filtrar por idioma escrito.

Luego, usamos 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';
    }

Según 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 aún hay más libros para cargar (habrá 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 devuelve la API de REST, clonaremos la plantilla con algunos componentes web que representan un libro y completaremos 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 agradable, como el de la contraportada de los libros reales.

Ejecuta y prueba la aplicación de forma local

Por ahora, ya hay suficiente código. Es momento de ver la aplicación en acción. Primero, lo haremos de forma local, dentro de Cloud Shell, antes de realizar la implementación real.

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

$ npm install

Luego, ejecutamos la app con el comando habitual:

$ npm start

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

$ npm run dev

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

Implementación de la aplicación de App Engine

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

Para implementar la aplicación, ejecutemos 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 el siguiente formato: 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 específico para ver libros solo en ese idioma.
  • Puedes borrar la selección con la pequeña cruz que aparece en el cuadro de selección para volver a la lista de todos los libros.

10. Realiza una limpieza (opcional)

Si no tienes la intención de 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!

Creamos un conjunto de servicios, gracias a Cloud Functions, App Engine y Cloud Run, para exponer varios extremos de la API web y el frontend web, para almacenar, actualizar y explorar una biblioteca de libros, siguiendo algunos buenos patrones de diseño para el desarrollo de la API de REST en el camino.

Temas abordados

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

Profundiza tus conocimientos

Si quieres explorar más este ejemplo concreto y expandirlo, aquí tienes una lista de cosas que puedes 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 control de claves de API para acceder a la API o definir límites de frecuencia para los consumidores de la API.
  • Implementa el módulo de nodo Swagger-UI en la aplicación de App Engine para documentar y ofrecer un entorno 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 crear nuevas entradas de libros. Además, como usamos la base de datos de Cloud Firestore, aprovecharemos su función de tiempo real para actualizar los datos del libro que se muestran a medida que se realizan los cambios.