1. Présentation
L'objectif de cet atelier de programmation est de vous familiariser avec les services "sans serveur" proposés par Google Cloud Platform :
- Cloud Functions : pour déployer de petites unités de logique métier sous forme de fonctions qui réagissent à divers événements (messages Pub/Sub, nouveaux fichiers dans Cloud Storage, requêtes HTTP, etc.)
- App Engine : pour déployer et diffuser des applications Web, des API Web, des backends mobiles et des ressources statiques, avec des capacités de scaling rapide à la hausse et à la baisse.
- Cloud Run : pour déployer et mettre à l'échelle des conteneurs pouvant contenir n'importe quel langage, environnement d'exécution ou bibliothèque.
Vous découvrirez également comment tirer parti de ces services serverless pour déployer et mettre à l'échelle des API Web et REST, tout en découvrant de bons principes de conception RESTful.
Dans cet atelier, nous allons créer un explorateur de bibliothèque composé des éléments suivants :
- Une fonction Cloud : pour importer l'ensemble de données initial des livres disponibles dans notre bibliothèque, dans la base de données de documents Cloud Firestore.
- Un conteneur Cloud Run qui exposera une API REST sur le contenu de notre base de données.
- Un frontend Web App Engine : pour parcourir la liste des livres en appelant notre API REST.
Voici à quoi ressemblera l'interface Web à la fin de cet atelier de programmation :

Points abordés
- Cloud Functions
- Cloud Firestore
- Cloud Run
- App Engine
2. Préparation
Configuration de l'environnement au rythme de chacun
- Connectez-vous à la console Google Cloud, puis créez un projet ou réutilisez un projet existant. (Si vous ne possédez pas encore de compte Gmail ou Google Workspace, vous devez en créer un.)



- Le nom du projet est le nom à afficher pour les participants au projet. Il s'agit d'une chaîne de caractères non utilisée par les API Google. Vous pourrez toujours le modifier.
- L'ID du projet est unique parmi tous les projets Google Cloud et non modifiable une fois défini. La console Cloud génère automatiquement une chaîne unique (en général, vous n'y accordez d'importance particulière). Dans la plupart des ateliers de programmation, vous devrez indiquer l'ID de votre projet (généralement identifié par
PROJECT_ID). Si l'ID généré ne vous convient pas, vous pouvez en générer un autre de manière aléatoire. Vous pouvez également en spécifier un et voir s'il est disponible. Après cette étape, l'ID n'est plus modifiable et restera donc le même pour toute la durée du projet. - Pour information, il existe une troisième valeur (le numéro de projet) que certaines API utilisent. Pour en savoir plus sur ces trois valeurs, consultez la documentation.
- Vous devez ensuite activer la facturation dans la console Cloud pour utiliser les ressources/API Cloud. L'exécution de cet atelier de programmation est très peu coûteuse, voire sans frais. Pour désactiver les ressources et éviter ainsi que des frais ne vous soient facturés après ce tutoriel, vous pouvez supprimer le projet ou les ressources que vous avez créées. Les nouveaux utilisateurs de Google Cloud peuvent participer au programme d'essai sans frais pour bénéficier d'un crédit de 300 $.
Démarrer Cloud Shell
Bien que Google Cloud puisse être utilisé à distance depuis votre ordinateur portable, nous allons nous servir de Google Cloud Shell pour cet atelier de programmation, un environnement de ligne de commande exécuté dans le cloud.
Dans la console Google Cloud, cliquez sur l'icône Cloud Shell dans la barre d'outils supérieure :

Le provisionnement et la connexion à l'environnement prennent quelques instants seulement. Une fois l'opération terminée, le résultat devrait ressembler à ceci :

Cette machine virtuelle contient tous les outils de développement nécessaires. Elle comprend un répertoire d'accueil persistant de 5 Go et s'exécute sur Google Cloud, ce qui améliore nettement les performances du réseau et l'authentification. Vous pouvez effectuer toutes les tâches de cet atelier de programmation dans un navigateur. Vous n'avez rien à installer.
3. Préparer l'environnement et activer les API Cloud
Pour utiliser les différents services dont nous aurons besoin tout au long de ce projet, nous allons activer quelques API. Pour ce faire, nous allons exécuter la commande suivante dans Cloud Shell :
$ gcloud services enable \
appengine.googleapis.com \
cloudbuild.googleapis.com \
cloudfunctions.googleapis.com \
compute.googleapis.com \
firestore.googleapis.com \
run.googleapis.com
Au bout de quelques minutes, l'opération devrait se terminer correctement :
Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.
Nous allons également configurer une variable d'environnement dont nous aurons besoin par la suite : la région cloud dans laquelle nous allons déployer notre fonction, notre application et notre conteneur :
$ export REGION=europe-west3
Comme nous allons stocker des données dans la base de données Cloud Firestore, nous devons la créer :
$ gcloud app create --region=${REGION}
$ gcloud firestore databases create --location=${REGION}
Plus tard dans cet atelier de programmation, lorsque nous implémenterons l'API REST, nous devrons trier et filtrer les données. Pour ce faire, nous allons créer trois index :
$ 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
Ces trois index correspondent aux recherches que nous effectuerons par auteur ou par langue, tout en conservant l'ordre dans la collection grâce à un champ mis à jour.
4. Obtenir le code
Récupérez le code à partir du dépôt GitHub suivant :
$ git clone https://github.com/glaforge/serverless-web-apis
Le code de l'application est écrit en Node.JS.
Vous disposerez de la structure de dossiers suivante, qui est pertinente pour cet atelier :
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
Voici les dossiers concernés :
data: ce dossier contient un exemple de données pour une liste de 100 livres.function-import: cette fonction proposera un point de terminaison pour importer des exemples de données.run-crud: ce conteneur exposera une API Web pour accéder aux données de livre stockées dans Cloud Firestore.appengine-frontend: cette application Web App Engine affichera une interface simple en lecture seule pour parcourir la liste des livres.
5. Exemple de données de bibliothèque de livres
Dans le dossier de données, nous avons un fichier books.json qui contient une liste de cent livres qui valent probablement la peine d'être lus. Ce document JSON est un tableau contenant des objets JSON. Examinons la forme des données que nous allons ingérer via une fonction Cloud Functions :
[
{
"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
},
...
]
Toutes nos entrées de livres dans ce tableau contiennent les informations suivantes :
isbn: code ISBN-13 identifiant le livre.author: nom de l'auteur du livre.language: langue parlée dans laquelle le livre est écrit.pages: nombre de pages du livre.title: titre du livre.year: année de publication du livre.
6. Point de terminaison de fonction permettant d'importer des exemples de données de livres
Dans cette première section, nous allons implémenter le point de terminaison qui sera utilisé pour importer des exemples de données de livres. Nous utiliserons Cloud Functions à cette fin.
Explorer le code
Commençons par examiner le fichier 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"
}
}
Dans les dépendances d'exécution, nous n'avons besoin que du module NPM @google-cloud/firestore pour accéder à la base de données et stocker nos données de livres. En arrière-plan, l'environnement d'exécution Cloud Functions fournit également le framework Web Express. Nous n'avons donc pas besoin de le déclarer comme dépendance.
Dans les dépendances de développement, nous déclarons le Functions Framework (@google-cloud/functions-framework), qui est le framework d'exécution utilisé pour appeler vos fonctions. Il s'agit d'un framework Open Source que vous pouvez également utiliser localement sur votre machine (dans notre cas, dans Cloud Shell) pour exécuter des fonctions sans les déployer à chaque fois que vous apportez une modification, ce qui améliore la boucle de rétroaction du développement.
Pour installer les dépendances, utilisez la commande install :
$ npm install
Le script start utilise le framework Functions pour vous fournir une commande que vous pouvez utiliser pour exécuter la fonction localement avec l'instruction suivante :
$ npm start
Vous pouvez utiliser curl ou éventuellement l'aperçu sur le Web de Cloud Shell pour les requêtes HTTP GET afin d'interagir avec la fonction.
Examinons maintenant le fichier index.js qui contient la logique de notre fonction d'importation de données de livres :
const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');
Nous instancions le module Firestore et pointons vers la collection de livres (semblable à une table dans les bases de données relationnelles).
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;
}
...
})
Nous exportons la fonction JavaScript parseBooks. Il s'agit de la fonction que nous déclarerons lors du déploiement ultérieur.
Les deux prochaines instructions permettent de vérifier les points suivants :
- Nous n'acceptons que les requêtes HTTP
POST. Dans le cas contraire, nous renvoyons un code d'état405pour indiquer que les autres méthodes HTTP ne sont pas autorisées. - Nous n'acceptons que les charges utiles
application/json. Dans le cas contraire, nous envoyons un code d'état406pour indiquer que le format de charge utile n'est pas acceptable.
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()
});
}
Nous pouvons ensuite récupérer la charge utile JSON via le body de la requête. Nous préparons une opération par lot Firestore pour stocker tous les livres en masse. Nous parcourons le tableau JSON contenant les détails du livre, en passant par les champs isbn, title, author, language, pages et year. Le code ISBN du livre servira de clé primaire ou d'identifiant.
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"});
Maintenant que la majeure partie des données est prête, nous pouvons valider l'opération. Si l'opération de stockage échoue, nous renvoyons un code d'état 400 pour indiquer l'échec. Sinon, nous pouvons renvoyer une réponse OK, avec un code d'état 202 indiquant que la requête d'enregistrement groupé a été acceptée.
Exécuter et tester la fonction d'importation
Avant d'exécuter le code, nous allons installer les dépendances avec :
$ npm install
Pour exécuter la fonction localement, grâce au framework Functions, nous allons utiliser la commande de script start que nous avons définie dans package.json :
$ npm start > start > npx @google-cloud/functions-framework --target=parseBooks Serving function... Function: parseBooks URL: http://localhost:8080/
Pour envoyer une requête POST HTTP à votre fonction locale, vous pouvez exécuter la commande suivante :
$ curl -d "@../data/books.json" \
-H "Content-Type: application/json" \
http://localhost:8080/
Lorsque vous lancez cette commande, le résultat suivant s'affiche, confirmant que la fonction s'exécute en local :
{"status":"OK"}
Vous pouvez également accéder à l'interface utilisateur de la console Cloud pour vérifier que les données sont bien stockées dans Firestore :

Dans la capture d'écran ci-dessus, nous pouvons voir la collection books créée, la liste des documents de livre identifiés par le code ISBN du livre et les détails de cette entrée de livre spécifique à droite.
Déployer la fonction dans le cloud
Pour déployer la fonction dans Cloud Functions, nous allons utiliser la commande suivante dans le répertoire function-import :
$ gcloud functions deploy bulk-import \
--gen2 \
--trigger-http \
--runtime=nodejs20 \
--allow-unauthenticated \
--max-instances=30
--region=${REGION} \
--source=. \
--entry-point=parseBooks
Nous déployons la fonction avec le nom symbolique bulk-import. Cette fonction est déclenchée par des requêtes HTTP. Nous utilisons l'environnement d'exécution Node.JS 20. Nous déployons la fonction publiquement (idéalement, nous devrions sécuriser ce point de terminaison). Nous spécifions la région dans laquelle nous souhaitons que la fonction réside. Nous pointons vers les sources dans le répertoire local et utilisons parseBooks (la fonction JavaScript exportée) comme point d'entrée.
Au bout de quelques minutes ou moins, la fonction est déployée dans le cloud. Dans l'interface utilisateur de la console Cloud, la fonction devrait s'afficher :

Dans le résultat du déploiement, vous devriez pouvoir voir l'URL de votre fonction, qui suit une certaine convention de dénomination (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}). Bien sûr, vous pouvez également trouver cette URL de déclencheur HTTP dans l'interface utilisateur de la console Cloud, dans l'onglet "Déclencheur" :

Vous pouvez également récupérer l'URL via la ligne de commande avec gcloud :
$ export BULK_IMPORT_URL=$(gcloud functions describe bulk-import \
--region=$REGION \
--format 'value(httpsTrigger.url)')
$ echo $BULK_IMPORT_URL
Stockons-le dans la variable d'environnement BULK_IMPORT_URL afin de pouvoir le réutiliser pour tester notre fonction déployée.
Tester la fonction déployée
Nous allons tester la fonction déployée à l'aide d'une commande curl semblable à celle que nous avons utilisée précédemment pour tester la fonction exécutée en local. La seule modification concernera l'URL :
$ curl -d "@../data/books.json" \
-H "Content-Type: application/json" \
$BULK_IMPORT_URL
Si l'opération réussit, le résultat suivant doit s'afficher :
{"status":"OK"}
Maintenant que notre fonction d'importation est déployée et prête, et que nous avons importé nos exemples de données, il est temps de développer l'API REST qui expose cet ensemble de données.
7. Contrat de l'API REST
Bien que nous ne définissions pas de contrat d'API à l'aide, par exemple, de la spécification OpenAPI, nous allons examiner les différents points de terminaison de notre API REST.
L'API échange des objets JSON de livres, qui se composent des éléments suivants :
isbn(facultatif) :Stringà 13 caractères représentant un code ISBN valide.author:Stringnon vide représentant le nom de l'auteur du livre.language:Stringnon vide contenant la langue dans laquelle le livre a été écrit.pages: unIntegerpositif pour le nombre de pages du livre.title:Stringnon vide avec le titre du livre.year: valeurIntegerpour l'année de publication du livre.
Exemple de charge utile de livre :
{
"isbn": "9780435272463",
"author": "Chinua Achebe",
"language": "English",
"pages": 209,
"title": "Things Fall Apart",
"year": 1958
}
GET /books
Obtenez la liste de tous les livres, potentiellement filtrée par auteur et/ou langue, et paginée par groupes de 10 résultats à la fois.
Charge utile du corps : aucune.
Paramètres de requête :
author(facultatif) : filtre la liste des livres par auteur.language(facultatif) : filtre la liste des livres par langue.page(facultatif, valeur par défaut : 0) : indique le rang de la page de résultats à renvoyer.
Renvoie un tableau JSON d'objets livre.
Codes d'état :
200: lorsque la requête réussit à récupérer la liste des livres,400: si une erreur se produit.
POST /books et POST /books/{isbn}
Publiez une nouvelle charge utile de livre, soit avec un paramètre de chemin d'accès isbn (dans ce cas, le code isbn n'est pas nécessaire dans la charge utile du livre), soit sans (dans ce cas, le code isbn doit être présent dans la charge utile du livre).
Charge utile du corps : un objet livre.
Paramètres de requête : aucun.
Ne renvoie rien.
Codes d'état :
201: lorsque le livre est stocké correctement.406: si le codeisbnn'est pas valide.400: si une erreur se produit.
GET /books/{isbn}
Récupère un livre de la bibliothèque, identifié par son code isbn, transmis en tant que paramètre de chemin d'accès.
Charge utile du corps : aucune.
Paramètres de requête : aucun.
Renvoie un objet JSON de livre ou un objet d'erreur si le livre n'existe pas.
Codes d'état :
200: si le livre est trouvé dans la base de données,400: si une erreur se produit,404: si le livre est introuvable,406: si le codeisbnn'est pas valide.
PUT /books/{isbn}
Met à jour un livre existant, identifié par son isbn transmis en tant que paramètre de chemin d'accès.
Charge utile du corps : un objet livre. Seuls les champs nécessitant une mise à jour peuvent être transmis, les autres étant facultatifs.
Paramètres de requête : aucun.
Renvoie le livre mis à jour.
Codes d'état :
200: lorsque le livre est mis à jour,400: si une erreur se produit,406: si le codeisbnn'est pas valide.
DELETE /books/{isbn}
Supprime un livre existant, identifié par son isbn transmis en tant que paramètre de chemin.
Charge utile du corps : aucune.
Paramètres de requête : aucun.
Ne renvoie rien.
Codes d'état :
204: lorsque le livre est supprimé400: si une erreur se produit.
8. Déployer et exposer une API REST dans un conteneur
Explorer le code
Dockerfile
Commençons par examiner le Dockerfile, qui sera responsable de la conteneurisation du code de notre application :
FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . ./
CMD [ "node", "index.js" ]
Nous utilisons une image "slim" Node.JS 20. Nous allons travailler dans le répertoire /usr/src/app. Nous copions le fichier package.json (voir les détails ci-dessous) qui définit, entre autres, nos dépendances. Nous installons les dépendances avec npm install en copiant le code source. Enfin, nous indiquons comment cette application doit être exécutée, avec la commande node index.js.
package.json
Ensuite, nous pouvons examiner le fichier 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"
}
}
Nous spécifions que nous voulons utiliser Node.JS 14, comme c'était le cas avec Dockerfile.
Notre application d'API Web dépend des éléments suivants :
- Le module Firestore NPM pour accéder aux données des livres dans la base de données
- La bibliothèque
corspour gérer les requêtes CORS (Cross-Origin Resource Sharing), car notre API REST sera appelée à partir du code client du frontend de notre application Web App Engine. - Le framework Express, qui sera notre framework Web pour concevoir notre API,
- Ensuite, le module
isbn3permet de valider les codes ISBN des livres.
Nous spécifions également le script start, qui sera utile pour démarrer l'application en local à des fins de développement et de test.
index.js
Passons maintenant au cœur du code, en examinant en détail index.js :
const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');
Nous avons besoin du module Firestore et nous faisons référence à la collection books, où sont stockées les données de nos livres.
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'],
}));
Nous utilisons Express comme framework Web pour implémenter notre API REST. Nous utilisons le module body-parser pour analyser les charges utiles JSON échangées avec notre API.
Le module querystring est utile pour manipuler les URL. Ce sera le cas lorsque nous créerons des en-têtes Link à des fins de pagination (nous y reviendrons plus tard).
Nous configurons ensuite le module cors. Nous spécifions les en-têtes que nous voulons transmettre via CORS, car la plupart sont généralement supprimés. Ici, nous voulons conserver la longueur et le type de contenu habituels, ainsi que l'en-tête Link que nous spécifierons pour la pagination.
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;
}
Nous utiliserons le module NPM isbn3 pour analyser et valider les codes ISBN. Nous développerons une petite fonction utilitaire qui analysera les codes ISBN et répondra avec un code d'état 406 dans la réponse si les codes ISBN ne sont pas valides.
GET /books
Examinons le point de terminaison GET /books, élément par élément :
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}`});
}
});
Nous allons interroger la base de données en préparant une requête. Cette requête dépendra des paramètres de requête facultatifs, pour filtrer par auteur et/ou par langue. Nous renvoyons également la liste des livres par blocs de 10 livres.
Si une erreur se produit lors de la récupération des livres, nous renvoyons une erreur avec un code d'état 400.
Zoomons sur la partie tronquée de ce point de terminaison :
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);
});
}
Dans la section précédente, nous avons filtré les résultats par author et language. Dans cette section, nous allons trier la liste des livres par date de dernière mise à jour (les livres mis à jour le plus récemment apparaissant en premier). Nous allons également paginer le résultat en définissant une limite (le nombre d'éléments à renvoyer) et un décalage (le point de départ à partir duquel renvoyer le prochain lot de livres).
Nous exécutons la requête, obtenons l'instantané des données et plaçons ces résultats dans un tableau JavaScript qui sera renvoyé à la fin de la fonction.
Terminons les explications sur ce point de terminaison en examinant une bonne pratique : utiliser l'en-tête Link pour définir des liens URI vers les première, précédente, suivante ou dernière pages de données (dans notre cas, nous ne fournirons que les pages précédente et suivante).
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 logique peut sembler un peu complexe au premier abord, mais ce que nous faisons, c'est ajouter un lien previous (précédent) si nous ne sommes pas sur la première page de données. Nous ajoutons un lien next si la page de données est pleine (c'est-à-dire qu'elle contient le nombre maximal de livres défini par la constante PAGE_SIZE, en supposant qu'une autre page arrive avec plus de données). Nous utilisons ensuite la fonction resource#links() d'Express pour créer le bon en-tête avec la bonne syntaxe.
Pour information, l'en-tête du lien se présentera comme suit :
link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
POST /booksetPOST /books/:isbn
Les deux points de terminaison permettent de créer un livre. L'un transmet le code ISBN dans la charge utile du livre, tandis que l'autre le transmet en tant que paramètre de chemin d'accès. Dans les deux cas, les deux appels à notre fonction 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}`});
}
}
Nous vérifions que le code isbn est valide. Dans le cas contraire, nous quittons la fonction (et définissons un code d'état 406). Nous récupérons les champs du livre à partir de la charge utile transmise dans le corps de la requête. Nous allons ensuite stocker les détails du livre dans Firestore. Renvoie 201 en cas de réussite et 400 en cas d'échec.
En cas de réussite, nous définissons également l'en-tête de localisation afin de fournir des indications au client de l'API sur l'emplacement de la ressource nouvellement créée. L'en-tête se présente comme suit :
Location: /books/9781234567898
GET /books/:isbn
Récupérons un livre, identifié par son ISBN, depuis 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}`});
}
});
Comme toujours, nous vérifions si l'ISBN est valide. Nous envoyons une requête à Firestore pour récupérer le livre. La propriété snapshot.exists est utile pour savoir si un livre a été trouvé. Dans le cas contraire, nous renvoyons une erreur et un code d'état 404 (Introuvable). Nous récupérons les données du livre et créons un objet JSON représentant le livre à renvoyer.
PUT /books/:isbn
Nous utilisons la méthode PUT pour mettre à jour un livre existant.
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}`});
}
});
Nous mettons à jour le champ de date/heure updated pour nous souvenir de la dernière mise à jour de cet enregistrement. Nous utilisons la stratégie {merge:true}, qui remplace les champs existants par leurs nouvelles valeurs (sinon, tous les champs sont supprimés et seuls les nouveaux champs du payload sont enregistrés, ce qui efface les champs existants de la mise à jour précédente ou de la création initiale).
Nous définissons également l'en-tête Location pour qu'il pointe vers l'URI du livre.
DELETE /books/:isbn
La suppression de livres est assez simple. Nous appelons simplement la méthode delete() sur la référence du document. Nous renvoyons un code d'état 204, car nous ne renvoyons aucun contenu.
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}`});
}
});
Démarrer le serveur Express / Node
Enfin, nous démarrons le serveur, qui écoute sur le port 8080 par défaut :
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Books Web API service: listening on port ${port}`);
console.log(`Node ${process.version}`);
});
Exécuter votre application en local
Pour exécuter l'application en local, nous allons d'abord installer les dépendances avec :
$ npm install
Nous pouvons ensuite commencer par :
$ npm start
Le serveur démarre sur localhost et écoute sur le port 8080 par défaut.
Il est également possible de créer un conteneur Docker et d'exécuter l'image du conteneur à l'aide des commandes suivantes :
$ docker build -t crud-web-api . $ docker run --rm -p 8080:8080 -it crud-web-api
L'exécution dans Docker est également un excellent moyen de vérifier que la conteneurisation de notre application fonctionnera correctement lorsque nous la créerons dans le cloud avec Cloud Build.
Tester l'API
Quelle que soit la manière dont nous exécutons le code de l'API REST (directement via Node ou via une image de conteneur Docker), nous pouvons désormais exécuter quelques requêtes.
- Créez un livre (ISBN dans la charge utile du corps) :
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
-H "Content-Type: application/json" \
http://localhost:8080/books
- Créez un livre (ISBN dans un paramètre de chemin d'accès) :
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
-H "Content-Type: application/json" \
http://localhost:8080/books/9782070368228
- Supprimez un livre (celui que nous avons créé) :
$ curl -XDELETE http://localhost:8080/books/9782070368228
- Récupérer un livre par son ISBN :
$ curl http://localhost:8080/books/9780140449136 $ curl http://localhost:8080/books/9782070360536
- Mettez à jour un livre existant en modifiant uniquement son titre :
$ curl -XPUT \
-d '{"title":"Book"}' \
-H "Content-Type: application/json" \
http://localhost:8080/books/9780003701203
- Récupérez la liste des livres (les 10 premiers) :
$ curl http://localhost:8080/books
- Rechercher les livres écrits par un auteur en particulier :
$ curl http://localhost:8080/books?author=Virginia+Woolf
- Liste les livres écrits en anglais :
$ curl http://localhost:8080/books?language=English
- Charger la quatrième page de livres :
$ curl http://localhost:8080/books?page=3
Nous pouvons également combiner les paramètres de requête author, language et books pour affiner notre recherche.
Créer et déployer l'API REST conteneurisée
L'API REST fonctionnant comme prévu, il est temps de la déployer dans le cloud, sur Cloud Run.
Nous allons procéder en deux étapes :
- Tout d'abord, créez l'image de conteneur avec Cloud Build à l'aide de la commande suivante :
$ gcloud builds submit \
--tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
- Déployez ensuite le service à l'aide de cette deuxième commande :
$ gcloud run deploy run-crud \
--image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
--allow-unauthenticated \
--region=${REGION} \
--platform=managed
Avec la première commande, Cloud Build crée l'image de conteneur et l'héberge dans Container Registry. La commande suivante déploie l'image de conteneur à partir du registre et la déploie dans la région cloud.
Nous pouvons vérifier dans l'interface utilisateur de la console Cloud que notre service Cloud Run apparaît désormais dans la liste :

La dernière étape consiste à récupérer l'URL du service Cloud Run que nous venons de déployer, grâce à la commande suivante :
$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \
--region=${REGION} \
--platform=managed \
--format='value(status.url)')
Nous aurons besoin de l'URL de notre API REST Cloud Run dans la section suivante, car le code frontend App Engine interagira avec l'API.
9. Héberger une application Web pour parcourir la bibliothèque
Pour ajouter une touche d'éclat à ce projet, il ne reste plus qu'à fournir une interface Web qui interagira avec notre API REST. Pour ce faire, nous allons utiliser Google App Engine, avec du code JavaScript client qui appellera l'API via des requêtes AJAX (à l'aide de l'API Fetch côté client).
Notre application, bien que déployée sur l'environnement d'exécution Node.JS App Engine, est principalement constituée de ressources statiques. Il n'y a pas beaucoup de code de backend, car la plupart des interactions utilisateur se feront dans le navigateur via JavaScript côté client. Nous n'utiliserons aucun framework JavaScript frontend sophistiqué. Nous utiliserons simplement du JavaScript "vanilla", avec quelques composants Web pour l'UI à l'aide de la bibliothèque de composants Web Shoelace :
- une zone de sélection pour choisir la langue du livre :

- un composant de carte pour afficher les détails d'un livre spécifique (y compris un code-barres pour représenter l'ISBN du livre, à l'aide de la bibliothèque JsBarcode) :

- et un bouton permettant de charger plus de livres à partir de la base de données :

Lorsque vous combinez tous ces composants visuels, la page Web résultante pour parcourir notre bibliothèque se présente comme suit :

Le fichier de configuration app.yaml
Commençons à explorer le code de cette application App Engine en examinant son fichier de configuration app.yaml. Il s'agit d'un fichier spécifique à App Engine, qui permet de configurer des éléments tels que les variables d'environnement, les différents gestionnaires de l'application ou de spécifier que certaines ressources sont des éléments statiques, qui seront diffusés par le CDN intégré d'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
Nous spécifions que notre application est une application Node.JS et que nous souhaitons utiliser la version 14.
Nous définissons ensuite une variable d'environnement qui pointe vers l'URL de notre service Cloud Run. Nous devrons remplacer l'espace réservé CHANGE_ME par l'URL appropriée (voir ci-dessous comment la modifier).
Ensuite, nous définissons différents gestionnaires. Les trois premiers points indiquent l'emplacement du code côté client HTML, CSS et JavaScript, sous le dossier public/ et ses sous-dossiers. La quatrième indique que l'URL racine de notre application App Engine doit pointer vers la page index.html. Ainsi, nous ne verrons pas le suffixe index.html dans l'URL lorsque nous accéderons à la racine du site Web. La dernière est celle par défaut, qui redirigera toutes les autres URL (/.*) vers notre application Node.JS (c'est-à-dire la partie dynamique de l'application, par opposition aux éléments statiques que nous avons décrits).
Mettons à jour l'URL de l'API Web du service Cloud Run.
Dans le répertoire appengine-frontend/, exécutez la commande suivante pour mettre à jour la variable d'environnement pointant vers l'URL de notre API REST basée sur Cloud Run :
$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml
Vous pouvez également modifier manuellement la chaîne CHANGE_ME dans app.yaml en remplaçant l'URL par la bonne :
env_variables:
RUN_CRUD_SERVICE_URL: CHANGE_ME
Le fichier package.json 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"
}
}
Nous insistons à nouveau sur le fait que nous souhaitons exécuter cette application à l'aide de Node.JS 14. Nous dépendons du framework Express, ainsi que du module NPM isbn3 pour valider les codes ISBN des livres.
Dans les dépendances de développement, nous allons utiliser le module nodemon pour surveiller les modifications apportées aux fichiers. Bien que nous puissions exécuter notre application localement avec npm start, apporter des modifications au code, arrêter l'application avec ^C, puis la relancer, c'est un peu fastidieux. Nous pouvons utiliser la commande suivante pour que l'application soit automatiquement rechargée / redémarrée en cas de modification :
$ npm run dev
Le code 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());
Nous exigeons le framework Web Express. Nous précisons que le répertoire public contient des éléments statiques qui peuvent être diffusés (au moins lors de l'exécution locale en mode développement) par le middleware static. Enfin, nous avons besoin de body-parser pour analyser nos charges utiles JSON.
Examinons les deux routes que nous avons définies :
app.get('/', async (req, res) => {
res.redirect('/html/index.html');
});
app.get('/webapi', async (req, res) => {
res.send(process.env.RUN_CRUD_SERVICE_URL);
});
La première correspondance / redirigera vers le index.html dans notre répertoire public/html. Comme nous ne sommes pas en mode développement dans l'environnement d'exécution App Engine, le routage d'URL d'App Engine n'a pas lieu. Ici, nous redirigeons simplement l'URL racine vers le fichier HTML.
Le deuxième point de terminaison que nous définissons /webapi renverra l'URL de notre API REST Cloud Run. De cette façon, le code JavaScript côté client saura où appeler pour obtenir la liste des livres.
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}`);
});
Pour finir, nous exécutons l'application Web Express et écoutons le port 8080 par défaut.
La page index.html
Nous n'allons pas examiner chaque ligne de cette longue page HTML. Concentrons-nous plutôt sur les lignes importantes.
<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">
Les deux premières lignes importent la bibliothèque de composants Web Shoelace (un script et une feuille de style).
La ligne suivante importe la bibliothèque JsBarcode pour créer les codes-barres des codes ISBN des livres.
Les dernières lignes importent notre propre code JavaScript et notre propre feuille de style CSS, qui se trouvent dans nos sous-répertoires public/.
Dans le body de la page HTML, nous utilisons les composants Shoelace avec leurs balises d'éléments personnalisés, comme suit :
<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>
...
Nous utilisons également des modèles HTML et leur capacité de remplissage d'emplacement pour représenter un livre. Nous allons créer des copies de ce modèle pour remplir la liste des livres et remplacer les valeurs des emplacements par les détails des livres :
<template id="book-card">
<sl-card class="card-overview">
...
<slot name="author">Author</slot>
...
</sl-card>
</template>
Assez de HTML, nous avons presque terminé d'examiner le code. Il ne reste plus qu'une partie importante : le code JavaScript côté client app.js qui interagit avec notre API REST.
Code JavaScript côté client app.js
Nous commençons par un écouteur d'événements de premier niveau qui attend que le contenu du DOM soit chargé :
document.addEventListener("DOMContentLoaded", async function(event) {
...
}
Une fois qu'il est prêt, nous pouvons configurer certaines constantes et variables clés :
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 = '';
Tout d'abord, nous allons récupérer l'URL de notre API REST grâce à notre code de nœud App Engine qui renvoie la variable d'environnement que nous avons définie initialement dans app.yaml. Grâce à la variable d'environnement, le point de terminaison /webapi, appelé à partir du code JavaScript côté client, nous n'avons pas eu à coder en dur l'URL de l'API REST dans notre code frontend.
Nous définissons également des variables page et language, que nous utiliserons pour suivre la pagination et le filtrage par langue.
const moreButton = document.getElementById('more-button');
moreButton.addEventListener('sl-focus', event => {
console.log('Button clicked');
moreButton.blur();
appendMoreBooks(server, page++, language);
});
Nous ajoutons un gestionnaire d'événements au bouton pour charger les livres. Lorsque l'utilisateur cliquera dessus, la fonction appendMoreBooks() sera appelée.
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);
});
Nous faisons la même chose pour la zone de sélection : nous ajoutons un gestionnaire d'événements pour être avertis des modifications apportées à la sélection de la langue. Comme pour le bouton, nous appelons également la fonction appendMoreBooks() en transmettant l'URL de l'API REST, la page actuelle et la sélection de la langue.
Examinons la fonction qui récupère et ajoute des livres :
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();
...
}
Ci-dessus, nous créons l'URL exacte à utiliser pour appeler l'API REST. Nous pouvons normalement spécifier trois paramètres de requête, mais dans cette UI, nous n'en spécifions que deux :
page: entier indiquant la page actuelle pour la pagination des livres.language: chaîne de langue permettant de filtrer les résultats par langue écrite.
Nous utilisons ensuite l'API Fetch pour récupérer le tableau JSON contenant les informations sur nos livres.
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';
}
Selon que l'en-tête Link est présent ou non dans la réponse, nous affichons ou masquons le bouton [More books...]. En effet, l'en-tête Link est une indication nous informant s'il reste des livres à charger (une URL next sera présente dans l'en-tête 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);
...
}
}
Dans la section ci-dessus de la fonction, pour chaque livre renvoyé par l'API REST, nous allons cloner le modèle avec des composants Web représentant un livre et remplir les emplacements du modèle avec les détails du livre.
JsBarcode('.img-barcode-' + book.isbn).EAN13(book.isbn, {fontSize: 18, textMargin: 0, height: 60}).render();
Pour rendre le code ISBN un peu plus joli, nous utilisons la bibliothèque JsBarcode pour créer un code-barres esthétique, comme celui qui figure au dos des vrais livres.
Exécuter et tester l'application en local
C'est suffisant pour le moment. Il est temps de voir l'application en action. Nous allons d'abord le faire en local, dans Cloud Shell, avant de le déployer réellement.
Nous installons les modules NPM nécessaires à notre application avec :
$ npm install
Ensuite, nous exécutons l'application avec la commande habituelle :
$ npm start
Ou avec le rechargement automatique des modifications grâce à nodemon, avec :
$ npm run dev
L'application s'exécute localement et nous pouvons y accéder depuis le navigateur, à l'adresse http://localhost:8080.
Déployer l'application App Engine
Maintenant que nous sommes sûrs que notre application fonctionne correctement en local, il est temps de la déployer sur App Engine.
Pour déployer l'application, exécutons la commande suivante :
$ gcloud app deploy -q
Au bout d'environ une minute, l'application devrait être déployée.
L'application sera disponible à l'URL suivante : https://${GOOGLE_CLOUD_PROJECT}.appspot.com.
Explorer l'interface utilisateur de notre application Web App Engine
Désormais, vous pouvez :
- Cliquez sur le bouton
[More books...]pour charger d'autres livres. - Sélectionnez une langue spécifique pour n'afficher que les livres dans cette langue.
- Pour revenir à la liste de tous les livres, vous pouvez effacer la sélection en cliquant sur la petite croix dans la zone de sélection.
10. Effectuer un nettoyage (facultatif)
Si vous ne comptez pas conserver l'application, vous pouvez nettoyer les ressources pour limiter les coûts et utiliser le cloud de manière raisonnée en supprimant l'intégralité du projet :
gcloud projects delete ${GOOGLE_CLOUD_PROJECT}
11. Félicitations !
Nous avons créé un ensemble de services, grâce à Cloud Functions, App Engine et Cloud Run, pour exposer différents points de terminaison d'API Web et un frontend Web, afin de stocker, mettre à jour et parcourir une bibliothèque de livres, en suivant au passage de bons modèles de conception pour le développement d'API REST.
Points abordés
- Cloud Functions
- Cloud Firestore
- Cloud Run
- App Engine
Aller plus loin
Si vous souhaitez explorer plus en détail cet exemple concret et l'étendre, voici une liste d'éléments que vous pouvez examiner :
- Profitez d'API Gateway pour fournir une façade d'API commune à la fonction d'importation de données et au conteneur d'API REST, afin d'ajouter des fonctionnalités telles que la gestion des clés API pour accéder à l'API ou définir des limites de débit pour les consommateurs d'API.
- Déployez le module de nœud Swagger-UI dans l'application App Engine pour documenter et proposer un bac à sable de test pour l'API REST.
- Sur le frontend, en plus de la fonctionnalité de navigation existante, ajoutez des écrans supplémentaires pour modifier les données et créer des entrées de livres. De plus, comme nous utilisons la base de données Cloud Firestore, nous allons tirer parti de sa fonctionnalité en temps réel pour mettre à jour les données des livres affichées à mesure que des modifications sont apportées.