Workshop de APIs da Web sem servidor

1. Visão geral

O objetivo deste codelab é ganhar experiência com os serviços "sem servidor" oferecidos pelo Google Cloud Platform:

  • Cloud Functions: para implantar pequenas unidades de lógica de negócios na forma de funções que reagem a vários eventos (mensagens do Pub/Sub, novos arquivos no Cloud Storage, solicitações HTTP e muito mais).
  • App Engine: para implantar e disponibilizar apps da Web, APIs da Web, back-ends para dispositivos móveis, recursos estáticos, com recursos rápidos de escalonamento vertical e horizontal.
  • Cloud Run: para implantar e escalonar contêineres que podem conter qualquer linguagem, ambiente de execução ou biblioteca.

E para descobrir como aproveitar esses serviços sem servidor para implantar e escalonar APIs da Web e REST, além de conhecer alguns bons princípios de design RESTful ao longo do caminho.

Neste workshop, vamos criar um explorador de estantes de livros com:

  • Uma função do Cloud: para importar o conjunto de dados inicial de livros disponíveis na biblioteca, no banco de dados de documentos do Cloud Firestore.
  • Um contêiner do Cloud Run: que vai expor uma API REST sobre o conteúdo do nosso banco de dados.
  • Um front-end da Web do App Engine: para navegar pela lista de livros, chamando nossa API REST.

Confira como será o front-end da Web no final deste codelab:

705e014da0ca5e90.png

O que você vai aprender

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

2. Configuração e requisitos

Configuração de ambiente autoguiada

  1. Faça login no Console do Google Cloud e crie um novo projeto ou reutilize um existente. Crie uma conta do Gmail ou do Google Workspace, se ainda não tiver uma.

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • O Nome do projeto é o nome de exibição para os participantes do projeto. É uma string de caracteres não usada pelas APIs do Google e pode ser atualizada quando você quiser.
  • O ID do projeto precisa ser exclusivo em todos os projetos do Google Cloud e não pode ser mudado após a definição. O console do Cloud gera automaticamente uma string exclusiva. Em geral, não importa o que seja. Na maioria dos codelabs, é necessário fazer referência ao ID do projeto, normalmente identificado como PROJECT_ID. Se você não gostar do ID gerado, crie outro aleatório. Se preferir, teste o seu e confira se ele está disponível. Ele não pode ser mudado após essa etapa e permanece durante o projeto.
  • Para sua informação, há um terceiro valor, um Número do projeto, que algumas APIs usam. Saiba mais sobre esses três valores na documentação.
  1. Em seguida, ative o faturamento no console do Cloud para usar os recursos/APIs do Cloud. A execução deste codelab não vai ser muito cara, se tiver algum custo. Para encerrar os recursos e evitar cobranças além deste tutorial, exclua os recursos criados ou exclua o projeto. Novos usuários do Google Cloud estão qualificados para o programa de US$ 300 de avaliação sem custos.

Inicie o Cloud Shell

Embora o Google Cloud e o Spanner possam ser operados remotamente do seu laptop, neste codelab usaremos o Google Cloud Shell, um ambiente de linha de comando executado no Cloud.

No Console do Google Cloud, clique no ícone do Cloud Shell na barra de ferramentas superior à direita:

84688aa223b1c3a2.png

O provisionamento e a conexão com o ambiente levarão apenas alguns instantes para serem concluídos: Quando o processamento for concluído, você verá algo como:

320e18fedb7fbe0.png

Essa máquina virtual contém todas as ferramentas de desenvolvimento necessárias. Ela oferece um diretório principal persistente de 5 GB, além de ser executada no Google Cloud. Isso aprimora o desempenho e a autenticação da rede. Neste codelab, todo o trabalho pode ser feito com um navegador. Você não precisa instalar nada.

3. Preparar o ambiente e ativar as APIs do Cloud

Para usar os vários serviços necessários ao longo deste projeto, vamos ativar algumas APIs. Para isso, execute o seguinte comando no Cloud Shell:

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

Depois de algum tempo, a operação será concluída com êxito:

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

Também vamos configurar uma variável de ambiente que será necessária ao longo do processo: a região da nuvem em que vamos implantar nossa função, app e contêiner:

$ export REGION=europe-west3

Como vamos armazenar dados no banco de dados do Cloud Firestore, precisamos criar o banco de dados:

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

Mais adiante neste codelab, ao implementar a API REST, vamos precisar classificar e filtrar os dados. Para isso, vamos criar três í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 

Esses três índices correspondem às pesquisas que faremos por autor ou idioma, mantendo a ordenação na coleção por um campo atualizado.

4. Acessar o código

Acesse o código no seguinte repositório do GitHub:

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

O código do aplicativo é escrito usando Node.JS.

Você terá a seguinte estrutura de pastas relevante para este laboratório:

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 são as pastas relevantes:

  • data: contém uma amostra de dados de uma lista de 100 livros.
  • function-import: essa função vai oferecer um endpoint para importar dados de amostra.
  • run-crud: esse contêiner vai expor uma API da Web para acessar os dados de livros armazenados no Cloud Firestore.
  • appengine-frontend: esse aplicativo da Web do App Engine vai mostrar um front-end simples somente leitura para navegar pela lista de livros.

5. Dados de biblioteca de livros de amostra

Na pasta de dados, temos um arquivo books.json que contém uma lista de cem livros, provavelmente interessantes. Esse documento JSON é uma matriz que contém objetos JSON. Vamos analisar o formato dos dados que vamos ingerir usando uma função do Cloud:

[
  {
    "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 as entradas de livros nessa matriz contêm as seguintes informações:

  • isbn: o código ISBN-13 que identifica o livro.
  • author: o nome do autor do livro.
  • language: o idioma falado em que o livro foi escrito.
  • pages: o número de páginas do livro.
  • title: o título do livro.
  • year: o ano em que o livro foi publicado.

6. Um endpoint de função para importar dados de livros de exemplo

Nesta primeira seção, vamos implementar o endpoint que será usado para importar dados de livros de amostra. Vamos usar o Cloud Functions para essa finalidade.

Explorar o código

Vamos começar analisando o arquivo 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"
    }
}

Nas dependências de tempo de execução, só precisamos do módulo @google-cloud/firestore NPM para acessar o banco de dados e armazenar os dados do livro. Em segundo plano, o ambiente de execução do Cloud Functions também fornece o framework da Web Express, então não precisamos declará-lo como uma dependência.

Nas dependências de desenvolvimento, declaramos o Functions Framework (@google-cloud/functions-framework), que é o framework de ambiente de execução usado para invocar suas funções. É um framework de código aberto que também pode ser usado localmente na sua máquina (no nosso caso, dentro do Cloud Shell) para executar funções sem precisar implantar a cada mudança, melhorando assim o ciclo de feedback do desenvolvimento.

Para instalar as dependências, use o comando install:

$ npm install

O script start usa o Functions Framework para fornecer um comando que pode ser usado para executar a função localmente com a seguinte instrução:

$ npm start

Você pode usar curl ou a visualização na Web do Cloud Shell para solicitações HTTP GET e interagir com a função.

Agora vamos conferir o arquivo index.js, que contém a lógica da nossa função de importação de dados de livros:

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

Vamos instanciar o módulo do Firestore e apontar para a coleção de livros (semelhante a uma tabela em bancos de dados relacionais).

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 a função JavaScript parseBooks. Essa é a função que vamos declarar quando a implantarmos mais tarde.

As próximas instruções verificam se:

  • Só aceitamos solicitações HTTP POST e retornamos um código de status 405 para indicar que os outros métodos HTTP não são permitidos.
  • Só aceitamos payloads application/json. Caso contrário, enviamos um código de status 406 para indicar que esse não é um formato de payload aceitável.
    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()
        });
    }

Em seguida, podemos recuperar o payload JSON usando o body da solicitação. Estamos preparando uma operação em lote do Firestore para armazenar todos os livros em massa. Iteramos a matriz JSON com os detalhes do livro, passando pelos campos isbn, title, author, language, pages e year. O código ISBN do livro vai servir como chave primária ou 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"});

Agora que a maior parte dos dados está pronta, podemos confirmar a operação. Se a operação de armazenamento falhar, vamos retornar um código de status 400 para informar que ela falhou. Caso contrário, podemos retornar uma resposta "OK", com um código de status 202 indicando que a solicitação de salvamento em massa foi aceita.

Executar e testar a função de importação

Antes de executar o código, vamos instalar as dependências com:

$ npm install

Para executar a função localmente, graças ao Functions Framework, vamos usar o comando de script start que definimos em package.json:

$ npm start

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

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

Para enviar uma solicitação HTTP POST à função local, execute:

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

Ao executar esse comando, você vai ver a seguinte saída, confirmando que a função está sendo executada localmente:

{"status":"OK"}

Você também pode acessar a interface do Console do Cloud para verificar se os dados estão armazenados no Firestore:

409982568cebdbf8.png

Na captura de tela acima, podemos ver a coleção books criada, a lista de documentos de livros identificados pelo código ISBN do livro e os detalhes dessa entrada específica à direita.

Como implantar a função na nuvem

Para implantar a função no Cloud Functions, use o seguinte comando no diretório function-import:

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

Implantamos a função com um nome simbólico de bulk-import. Essa função é acionada por solicitações HTTP. Usamos o ambiente de execução do Node.JS 20. Implantamos a função publicamente. O ideal seria proteger esse endpoint. Especificamos a região em que queremos que a função fique. Apontamos para as fontes no diretório local e usamos parseBooks (a função JavaScript exportada) como ponto de entrada.

Depois de alguns minutos ou menos, a função será implantada na nuvem. Na interface do console do Cloud, a função vai aparecer:

c910875d4dc0aaa8.png

Na saída da implantação, você vai encontrar o URL da função, que segue uma determinada convenção de nomenclatura (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}). Também é possível encontrar esse URL de gatilho HTTP na interface do console do Cloud, na guia "Gatilho":

380ffc46eb56441e.png

Você também pode recuperar o URL pela linha de comando com gcloud:

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

Vamos armazenar isso na variável de ambiente BULK_IMPORT_URL para reutilizar no teste da função implantada.

Como testar a função implantada

Com um comando curl semelhante ao que usamos antes para testar a função em execução localmente, vamos testar a função implantada. A única mudança será no URL:

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

Se for bem-sucedido, ele vai retornar a seguinte saída:

{"status":"OK"}

Agora que a função de importação está implantada e pronta e que você já fez o upload dos dados de exemplo, é hora de desenvolver a API REST que expõe esse conjunto de dados.

7. O contrato da API REST

Embora não estejamos definindo um contrato de API usando, por exemplo, a especificação OpenAPI, vamos analisar os vários endpoints da nossa API REST.

A API troca objetos JSON de livros, que consistem em:

  • isbn (opcional): um String de 13 caracteres que representa um código ISBN válido.
  • author: um String não vazio que representa o nome do autor do livro.
  • language: um String não vazio que contém o idioma em que o livro foi escrito.
  • pages: um Integer positivo para a contagem de páginas do livro.
  • title: um String não vazio com o título do livro.
  • year: um valor Integer para o ano de publicação do livro.

Exemplo de payload de livro:

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

GET /books

Receba a lista de todos os livros, possivelmente filtrados por autor e/ou idioma, e paginados por janelas de 10 resultados por vez.

Payload do corpo: nenhum.

Parâmetros de consulta:

  • author (opcional): filtra a lista de livros por autor,
  • language (opcional): filtra a lista de livros por idioma.
  • page (opcional, padrão = 0): indica a classificação da página de resultados a ser retornada.

Retorna: uma matriz JSON de objetos de livro.

Códigos de status:

  • 200: quando a solicitação para buscar a lista de livros é bem-sucedida.
  • 400: se ocorrer um erro.

POST /books e POST /books/{isbn}

Poste um novo payload de livro com um parâmetro de caminho isbn (nesse caso, o código isbn não é necessário no payload de livro) ou sem ele (nesse caso, o código isbn precisa estar presente no payload de livro).

Payload do corpo: um objeto de livro.

Parâmetros de consulta: nenhum.

Retorna: nada.

Códigos de status:

  • 201: quando o livro é armazenado com êxito.
  • 406: se o código isbn for inválido.
  • 400: se ocorrer um erro.

GET /books/{isbn}

Recupera um livro da biblioteca, identificado pelo código isbn, transmitido como um parâmetro de caminho.

Payload do corpo: nenhum.

Parâmetros de consulta: nenhum.

Retorna: um objeto JSON de livro ou um objeto de erro se o livro não existir.

Códigos de status:

  • 200: se o livro for encontrado no banco de dados.
  • 400: se ocorrer um erro.
  • 404 — se o livro não foi encontrado;
  • 406: se o código isbn for inválido.

PUT /books/{isbn}

Atualiza um livro existente, identificado pelo isbn transmitido como parâmetro de caminho.

Payload do corpo: um objeto de livro. Apenas os campos que precisam de uma atualização podem ser transmitidos. Os outros são opcionais.

Parâmetros de consulta: nenhum.

Retorna: o livro atualizado.

Códigos de status:

  • 200: quando o livro é atualizado.
  • 400: se ocorrer um erro.
  • 406: se o código isbn for inválido.

DELETE /books/{isbn}

Exclui um livro existente, identificado pelo isbn transmitido como parâmetro de caminho.

Payload do corpo: nenhum.

Parâmetros de consulta: nenhum.

Retorna: nada.

Códigos de status:

  • 204: quando o livro é excluído com sucesso.
  • 400: se ocorrer um erro.

8. Implantar e expor uma API REST em um contêiner

Explorar o código

Dockerfile

Vamos começar analisando o Dockerfile, que será responsável por conteinerizar o código do aplicativo:

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

Estamos usando uma imagem "slim" do Node.JS 20. Estamos trabalhando no diretório /usr/src/app. Estamos copiando o arquivo package.json (detalhes abaixo) que define nossas dependências, entre outras coisas. Instalamos as dependências com npm install, copiando o código-fonte. Por fim, indicamos como esse aplicativo deve ser executado com o comando node index.js.

package.json

Em seguida, vamos analisar o arquivo 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 o Node.JS 14, como foi o caso com o Dockerfile.

Nosso aplicativo de API da Web depende de:

  • O módulo NPM do Firestore para acessar os dados de livros no banco de dados.
  • A biblioteca cors para processar solicitações de CORS (compartilhamento de recursos entre origens), já que nossa API REST será invocada no código do cliente do front-end do aplicativo da Web do App Engine.
  • O framework Express, que será nosso framework da Web para projetar a API.
  • E o módulo isbn3, que ajuda a validar códigos ISBN de livros.

Também especificamos o script start, que será útil para iniciar o aplicativo localmente, para fins de desenvolvimento e teste.

index.js

Vamos analisar o código em detalhes, com foco em index.js:

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

Precisamos do módulo do Firestore e fazemos referência à coleção books, em que os dados do livro são armazenados.

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'],
}));

Estamos usando o Express como nosso framework da Web para implementar a API REST. Estamos usando o módulo body-parser para analisar os payloads JSON trocados com nossa API.

O módulo querystring é útil para manipular URLs. Esse será o caso quando criarmos cabeçalhos Link para fins de paginação (mais sobre isso depois).

Em seguida, configuramos o módulo cors. Explicitamos os cabeçalhos que queremos transmitir via CORS, já que a maioria é geralmente removida. No entanto, aqui, queremos manter o comprimento e o tipo de conteúdo usuais, bem como o cabeçalho Link que especificaremos para paginação.

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

Vamos usar o módulo isbn3 do NPM para analisar e validar códigos ISBN, além de desenvolver uma pequena função utilitária que vai analisar códigos ISBN e responder com um código de status 406 na resposta se os códigos ISBN forem inválidos.

  • GET /books

Vamos analisar o endpoint 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 nos preparando para consultar o banco de dados, preparando uma consulta. Essa consulta dependerá dos parâmetros de consulta opcionais, para filtrar por autor e/ou idioma. Também estamos retornando a lista de livros em partes de 10 livros.

Se houver um erro ao longo do caminho, durante a busca dos livros, vamos retornar um erro com um código de status 400.

Vamos ampliar a parte cortada desse endpoint:

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

Na seção anterior, filtramos por author e language, mas nesta seção, vamos classificar a lista de livros por ordem de data da última atualização (a última atualização aparece primeiro). Também vamos paginar o resultado definindo um limite (o número de elementos a serem retornados) e um deslocamento (o ponto de partida para retornar o próximo lote de livros).

Executamos a consulta, recebemos o snapshot dos dados e colocamos esses resultados em uma matriz JavaScript que será retornada no final da função.

Vamos concluir as explicações sobre esse endpoint analisando uma prática recomendada: usar o cabeçalho Link para definir links de URI para a primeira, anterior, próxima ou última página de dados. No nosso caso, vamos fornecer apenas as páginas anterior e próxima.

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

A lógica pode parecer um pouco complexa no início, mas o que estamos fazendo é adicionar um link anterior se não estivermos na primeira página de dados. E adicionamos um link next se a página de dados estiver cheia (ou seja, contiver o número máximo de livros, conforme definido pela constante PAGE_SIZE, supondo que haja outra página com mais dados). Em seguida, usamos a função resource#links() do Express para criar o cabeçalho certo com a sintaxe correta.

Para sua informação, o cabeçalho do link vai ficar assim:

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

Os dois endpoints estão aqui para criar um novo livro. Um transmite o código ISBN no payload do livro, enquanto o outro transmite como um parâmetro de caminho. De qualquer forma, ambos chamam nossa função 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 se o código isbn é válido. Caso contrário, retornamos da função e definimos um código de status 406. Recuperamos os campos do livro do payload transmitido no corpo da solicitação. Em seguida, vamos armazenar os detalhes do livro no Firestore. Retornando 201 em caso de sucesso e 400 em caso de falha.

Quando a solicitação é concluída, também definimos o cabeçalho de local para dar pistas ao cliente da API sobre onde está o recurso recém-criado. O cabeçalho vai aparecer assim:

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

Vamos buscar um livro, identificado pelo ISBN, no 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 sempre, verificamos se o ISBN é válido. Fazemos uma consulta ao Firestore para recuperar o livro. A propriedade snapshot.exists é útil para saber se um livro foi encontrado. Caso contrário, enviamos um erro e um código de status 404 "Não encontrado". Recuperamos os dados do livro e criamos um objeto JSON que representa o livro a ser retornado.

  • PUT /books/:isbn

Estamos usando o método PUT para atualizar um livro.

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

Atualizamos o campo de data/hora updated para lembrar quando atualizamos esse registro pela última vez. Usamos a estratégia {merge:true}, que substitui os campos atuais pelos novos valores. Caso contrário, todos os campos são removidos, e apenas os novos campos no payload são salvos, apagando os campos atuais da atualização anterior ou da criação inicial.

Também definimos o cabeçalho Location para apontar para o URI do livro.

  • DELETE /books/:isbn

Excluir livros é bem simples. Basta chamar o método delete() na referência do documento. Retornamos um código de status 204 porque não estamos retornando nenhum conteúdo.

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

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

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

Iniciar o servidor Express / Node

Por último, mas não menos importante, iniciamos o servidor, que detecta a porta 8080 por padrão:

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

Como executar o aplicativo localmente

Para executar o aplicativo localmente, primeiro instale as dependências com:

$ npm install

Então, podemos começar com:

$ npm start

O servidor vai iniciar em localhost e detectar atividades na porta 8080 por padrão.

Também é possível criar um contêiner Docker e executar a imagem do contêiner com os seguintes comandos:

$ docker build -t crud-web-api .

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

Executar no Docker também é uma ótima maneira de verificar se a contêinerização do aplicativo vai funcionar bem à medida que o criamos na nuvem com o Cloud Build.

Como testar a API

Independente de como executamos o código da API REST (diretamente via Node ou por uma imagem de contêiner do Docker), agora podemos executar algumas consultas nele.

  • Crie um livro (ISBN no payload do corpo):
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • Crie um novo livro (ISBN em um parâmetro de caminho):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • Exclua um livro (o que criamos):
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • Recuperar um livro por ISBN:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • Atualize um livro mudando apenas o título:
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • Recupere a lista de livros (os 10 primeiros):
$ curl http://localhost:8080/books
  • Encontre os livros escritos por um autor específico:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • Liste os livros escritos em inglês:
$ curl http://localhost:8080/books?language=English
  • Carregue a quarta página de livros:
$ curl http://localhost:8080/books?page=3

Também é possível combinar os parâmetros de consulta author, language e books para refinar a pesquisa.

Como criar e implantar a API REST em contêineres

Como a API REST funciona conforme o planejado, é o momento certo para implantá-la na nuvem, no Cloud Run.

Vamos fazer isso em duas etapas:

  • Primeiro, crie a imagem do contêiner com o Cloud Build usando o seguinte comando:
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • Em seguida, implante o serviço com este segundo comando:
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

Com o primeiro comando, o Cloud Build cria a imagem do contêiner e a hospeda no Container Registry. O próximo comando implanta a imagem do contêiner do registro na região da nuvem.

Podemos verificar na interface do console do Cloud se o serviço do Cloud Run aparece na lista:

f62fbca02a8127c0.png

Uma última etapa que vamos fazer aqui é recuperar o URL do serviço do Cloud Run recém-implantado com o seguinte comando:

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

Vamos precisar do URL da API REST do Cloud Run na próxima seção, já que o código do front-end do App Engine vai interagir com a API.

9. Hospedar um app da Web para navegar pela biblioteca

A última peça do quebra-cabeça para dar um toque especial a este projeto é fornecer um front-end da Web que interaja com nossa API REST. Para isso, vamos usar o Google App Engine com um código JavaScript do cliente que vai chamar a API por solicitações AJAX (usando a API Fetch do lado do cliente).

Nosso aplicativo, embora implantado no ambiente de execução do App Engine Node.JS, é composto principalmente de recursos estáticos. Não há muito código de back-end, já que a maior parte da interação do usuário será no navegador via JavaScript do lado do cliente. Não vamos usar nenhum framework de JavaScript sofisticado de front-end. Vamos usar apenas um pouco de JavaScript "vanilla", com alguns componentes da Web para a interface usando a biblioteca de componentes da Web Shoelace:

  • uma caixa de seleção para escolher o idioma do livro:

6fb9f741000a2dc1.png

  • um componente de card para mostrar os detalhes de um livro específico (incluindo um código de barras para representar o ISBN do livro, usando a biblioteca JsBarcode):

3aa21a9e16e3244e.png

  • e um botão para carregar mais livros do banco de dados:

3925ad81c91bbac9.png

Ao combinar todos esses componentes visuais, a página da Web resultante para navegar na biblioteca será assim:

18a5117150977d6.png

O arquivo de configuração app.yaml

Vamos começar a analisar a base de código desse aplicativo do App Engine, examinando o arquivo de configuração app.yaml. Esse é um arquivo específico do App Engine que permite configurar variáveis de ambiente, os vários "handlers" do aplicativo ou especificar que alguns recursos são ativos estáticos, que serão veiculados pela CDN integrada do 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 nosso aplicativo é Node.JS e que queremos usar a versão 14.

Em seguida, definimos uma variável de ambiente que aponta para o URL do serviço do Cloud Run. Precisamos atualizar o marcador CHANGE_ME com o URL correto. Veja abaixo como fazer isso.

Depois disso, definimos vários manipuladores. Os três primeiros apontam para o local do código HTML, CSS e JavaScript do lado do cliente, na pasta public/ e nas subpastas dela. O quarto indica que o URL raiz do aplicativo do App Engine deve apontar para a página index.html. Assim, não vamos ver o sufixo index.html no URL ao acessar a raiz do site. E o último é o padrão, que vai rotear todos os outros URLs (/.*) para nosso aplicativo Node.JS (ou seja, a parte "dynamic" do aplicativo, em contraste com os recursos estáticos que descrevemos).

Vamos atualizar o URL da API da Web do serviço do Cloud Run agora.

No diretório appengine-frontend/, execute o comando a seguir para atualizar a variável de ambiente que aponta para o URL da nossa API REST baseada no Cloud Run:

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

Ou mude manualmente a string CHANGE_ME em app.yaml com o URL correto:

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

O arquivo package.json do 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"
    }
}

Reforçamos que queremos executar esse aplicativo usando o Node.JS 14. Dependemos do framework Express e do módulo isbn3 NPM para validar os códigos ISBN dos livros.

Nas dependências de desenvolvimento, vamos usar o módulo nodemon para monitorar mudanças nos arquivos. Embora seja possível executar o aplicativo localmente com npm start, fazer algumas mudanças no código, parar o app com ^C e reiniciá-lo, isso é um pouco cansativo. Em vez disso, podemos usar o seguinte comando para que o aplicativo seja recarregado / reiniciado automaticamente após as mudanças:

$ npm run dev

O código Node.JS index.js

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

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

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

É necessário ter o framework da Web Express. Especificamos que o diretório público contém recursos estáticos que podem ser veiculados (pelo menos ao executar localmente no modo de desenvolvimento) pelo middleware static. Por fim, precisamos de body-parser para analisar nossos payloads JSON.

Vamos conferir as duas rotas 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);
});

O primeiro que corresponder a / vai redirecionar para index.html no diretório public/html. Como no modo de desenvolvimento não estamos executando no ambiente de execução do App Engine, não temos o roteamento de URL do App Engine. Em vez disso, aqui estamos apenas redirecionando o URL raiz para o arquivo HTML.

O segundo endpoint que definimos, /webapi, vai retornar o URL da nossa API REST do Cloud Run. Assim, o código JavaScript do lado do cliente saberá onde chamar para receber a lista de livros.

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 terminar, estamos executando o web app Express e detectando a porta 8080 por padrão.

A página index.html

Não vamos analisar todas as linhas desta longa página HTML. Em vez disso, vamos destacar algumas linhas 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">

As duas primeiras linhas importam a biblioteca de componentes da Web do Shoelace (um script e uma folha de estilo).

A próxima linha importa a biblioteca JsBarcode para criar os códigos de barras dos códigos ISBN dos livros.

As últimas linhas importam nosso próprio código JavaScript e folha de estilo CSS, que estão localizados nos subdiretórios public/.

No body da página HTML, usamos os componentes do Shoelace com as tags de elementos personalizados, como:

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

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

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

Também usamos modelos HTML e a capacidade de preenchimento de slots para representar um livro. Vamos criar cópias desse modelo para preencher a lista de livros e substituir os valores nos slots pelos detalhes dos livros:

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

Já chega de HTML. Estamos quase terminando a revisão do código. Uma última parte importante restante: o código JavaScript do lado do cliente app.js que interage com nossa API REST.

O código JavaScript do lado do cliente app.js

Começamos com um listener de eventos de nível superior que aguarda o carregamento do conteúdo do DOM:

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

Quando estiver tudo pronto, podemos configurar algumas constantes e variáveis importantes:

    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 = '';

Primeiro, vamos buscar o URL da nossa API REST, graças ao código do nó do App Engine que retorna a variável de ambiente definida inicialmente em app.yaml. Graças à variável de ambiente, o endpoint /webapi, chamado do código JavaScript do lado do cliente, não precisou codificar o URL da API REST no código do front-end.

Também definimos variáveis page e language, que usaremos para acompanhar a paginação e a filtragem de idioma.

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

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

Adicionamos um manipulador de eventos ao botão para carregar livros. Quando ele for clicado, vai chamar a função 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);
    });

O mesmo vale para a caixa de seleção. Adicionamos um manipulador de eventos para receber notificações sobre mudanças na seleção de idioma. Assim como no botão, também chamamos a função appendMoreBooks(), transmitindo o URL da API REST, a página atual e a seleção de idioma.

Vamos analisar a função que busca e anexa livros:

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

Acima, estamos criando o URL exato a ser usado para chamar a API REST. Normalmente, podemos especificar três parâmetros de consulta, mas aqui na interface, especificamos apenas dois:

  • page: um número inteiro que indica a página atual da paginação dos livros.
  • language: uma string de idioma para filtrar por linguagem escrita.

Em seguida, usamos a API Fetch para recuperar a matriz JSON que contém os detalhes do livro.

    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';
    }

Dependendo da presença do cabeçalho Link na resposta, vamos mostrar ou ocultar o botão [More books...]. O cabeçalho Link é uma dica que nos informa se há mais livros para carregar (haverá um URL next no cabeçalho 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);
        ... 
    }
}

Na seção acima da função, para cada livro retornado pela API REST, vamos clonar o modelo com alguns componentes da Web que representam um livro e preencher os slots do modelo com os detalhes do livro.

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

Para deixar o código ISBN um pouco mais bonito, usamos a biblioteca JsBarcode para criar um código de barras semelhante ao da contracapa de livros reais.

Executar e testar o aplicativo localmente

Por enquanto, chega de código. É hora de ver o aplicativo em ação. Primeiro, vamos fazer isso localmente, no Cloud Shell, antes de implantar de verdade.

Instalamos os módulos NPM necessários para nosso aplicativo com:

$ npm install

E executamos o app com o comando usual:

$ npm start

Ou com a atualização automática de mudanças graças ao nodemon, com:

$ npm run dev

O aplicativo está sendo executado localmente, e podemos acessá-lo no navegador em http://localhost:8080.

Como implantar o aplicativo do App Engine

Agora que temos certeza de que o aplicativo funciona bem localmente, é hora de implantá-lo no App Engine.

Para implantar o aplicativo, execute o seguinte comando:

$ gcloud app deploy -q

Após cerca de um minuto, o aplicativo será implantado.

O aplicativo vai estar disponível em um URL no formato: https://${GOOGLE_CLOUD_PROJECT}.appspot.com.

Como explorar a interface do nosso aplicativo da Web do App Engine

Agora você pode:

  • Clique no botão [More books...] para carregar mais livros.
  • Selecione um idioma específico para ver livros apenas nesse idioma.
  • Para voltar à lista de todos os livros, clique no pequeno X na caixa de seleção.

10. Limpar (opcional)

Se você não pretende manter o app, libere os recursos para economizar custos e ser um bom cidadão da nuvem excluindo todo o projeto:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. Parabéns!

Criamos um conjunto de serviços, graças ao Cloud Functions, ao App Engine e ao Cloud Run, para expor vários endpoints de API e front-end da Web, armazenar, atualizar e navegar em uma biblioteca de livros, seguindo alguns bons padrões de design para o desenvolvimento de APIs REST ao longo do caminho.

O que vimos

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

Indo mais longe

Se quiser explorar mais esse exemplo concreto e expandi-lo, confira uma lista de coisas que você pode investigar:

  • Aproveite o gateway de API para fornecer uma fachada de API comum à função de importação de dados e ao contêiner da API REST, além de adicionar recursos como o processamento de chaves de API para acessar a API ou definir limitações de taxa para consumidores de API.
  • Implante o módulo de nó Swagger-UI no aplicativo do App Engine para documentar e oferecer um ambiente de teste para a API REST.
  • No front-end, além da capacidade de navegação atual, adicione telas extras para editar os dados e criar novas entradas de livros. Além disso, como estamos usando o banco de dados do Cloud Firestore, aproveite o recurso em tempo real para atualizar os dados do livro exibidos conforme as mudanças são feitas.