Workshop de APIs da Web sem servidor

1. Visão geral

O objetivo deste codelab é adquirir experiência com a computação sem servidor serviços 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 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 de dispositivos móveis e recursos estáticos com recursos rápidos de aumentar e diminuir a escala,
  • 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 ver alguns bons princípios de design RESTful ao longo do caminho.

Neste workshop, criaremos um explorador de estante de livros que consiste em:

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

Veja como o front-end da Web vai ficar ao 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 diversos serviços que vamos precisar ao longo deste projeto, vamos ativar algumas APIs. Para fazer isso, inicie o comando a seguir no Cloud Shell:

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

Após algum tempo, a operação deverá 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 durante o processo: a região do Cloud em que vamos implantar a função, o app e o contêiner:

$ export REGION=europe-west3

Como vamos armazenar dados no banco de dados do Cloud Firestore, precisaremos 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, precisaremos classificar e filtrar os dados. Para isso, criaremos 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 ordem na coleção por meio de um campo atualizado.

4. Acessar o código

Receba o código do 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 que é 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: esta pasta contém dados de amostra de uma lista de cem livros.
  • function-import: oferece um endpoint para importar dados de amostra.
  • run-crud: esse contêiner vai expor uma API Web para acessar os dados de livros armazenados no Cloud Firestore.
  • appengine-frontend: este aplicativo da Web do App Engine exibe um front-end somente leitura simples para navegar pela lista de livros.

5. Dados de amostra da biblioteca de livros

Na pasta de dados, temos um arquivo books.json que contém uma lista de cem livros, provavelmente vale a pena ler. Esse documento JSON é uma matriz que contém objetos JSON. Vamos dar uma olhada no 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 no qual o livro é 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 amostra

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.

Conhecer o código

Vamos começar pelo 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 do ambiente de execução, só precisamos do módulo NPM @google-cloud/firestore para acessar o banco de dados e armazenar os dados dos nossos livros. Internamente, o ambiente de execução do Cloud Functions também oferece o Express para Web, portanto, 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, no Cloud Shell) para executar funções sem implantar toda vez que uma alteração for feita, melhorando o ciclo de feedback de 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

É possível usar o curl ou a visualização da Web do Cloud Shell para solicitações HTTP GET para interagir com a função.

Agora vamos dar uma olhada no arquivo index.js, que contém a lógica da função de importação de dados do livro:

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

Instanciamos o módulo do Firestore e apontamos 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 declararemos quando ela for implantada posteriormente.

As próximas instruções verificam se:

  • Aceitamos apenas solicitações HTTP POST. Caso contrário, retornamos um código de status 405 para indicar que os outros métodos HTTP não são permitidos.
  • Aceitamos apenas 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, é possível 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, que consiste nos detalhes do livro, nos campos isbn, title, author, language, pages e year. O código ISBN do livro servirá como sua 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, retornamos um código de status 400 para informar sobre a falha. 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.

Como executar e testar a função de importação

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

$ npm install

Para executar a função localmente, graças ao Functions Framework, usaremos o comando de script start definido 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 para sua função local, execute:

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

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

{"status":"OK"}

Também é possível acessar a IU do console do Cloud para verificar se os dados realmente 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, usaremos 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. Nós implantamos a função publicamente (de preferência, devemos proteger esse endpoint). Especificamos a região onde queremos que a função resida. Também indicamos as origens 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 é implantada na nuvem. Na IU do Console do Cloud, a função será exibida:

c910875d4dc0aaa8.png

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

380ffc46eb56441e.png

Também é possível recuperar o URL usando a 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 armazená-lo na variável de ambiente BULK_IMPORT_URL para que seja possível reutilizá-lo para testar a função implantada.

Como testar a função implantada

Vamos testar a função implantada com um comando curl semelhante que usamos anteriormente para testar a função executada localmente. A única alteração será o URL:

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

Novamente, se for bem-sucedido, ele retornará a seguinte saída:

{"status":"OK"}

Agora que nossa função de importação está implantada e pronta, depois de fazer o upload dos dados de amostra, é hora de desenvolvermos a API REST, que expõe esse conjunto de dados.

7. O contrato da API REST

Não estamos definindo um contrato de API usando, por exemplo, a especificação OpenAPI, vamos dar uma olhada nos vários endpoints da nossa API REST.

As trocas de API registram objetos JSON, 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 contendo 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 de Integer para o ano de publicação do livro.

Exemplo de payload do 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 serem retornados.

Retorna: uma matriz JSON de objetos de livro.

Códigos de status:

  • 200: quando a solicitação é bem-sucedida ao buscar a lista de livros,
  • 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 do livro) ou sem (nesse caso, o código isbn precisa estar presente no payload do livro).

Payload do corpo: um objeto de livro.

Parâmetros de consulta: nenhum.

Retorna: nada.

Códigos de status:

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

ADQUIRIR /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 puder ser 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. Somente os campos que precisam de 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 for atualizado,
  • 400: se ocorrer um erro,
  • 406: se o código isbn for inválido.

EXCLUIR /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 for excluído,
  • 400: se ocorrer um erro.

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

Conhecer o código

Dockerfile

Vamos começar examinando o Dockerfile, que será responsável pela conteinerização do código do nosso 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 executar o aplicativo com o comando node index.js.

package.json

Agora, podemos dar uma olhada no 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 do Dockerfile.

Nosso aplicativo de API da Web depende de:

  • O módulo do NPM do Firestore para acessar os dados do livro no banco de dados
  • A biblioteca cors para processar solicitações de compartilhamento de recursos entre origens (CORS, na sigla em inglês), já que a API REST será invocada usando o código do cliente do front-end do aplicativo da Web do App Engine.
  • O framework Express, que será o framework da Web para criar a API,
  • E o módulo isbn3, que ajuda a validar os 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 passar para a parte principal do código, analisando index.js em detalhes:

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

Exigimos o módulo do Firestore e fazemos referência à coleção books, em que os dados dos nossos livros 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 (falaremos mais sobre isso posteriormente).

Em seguida, vamos configurar o módulo cors. Explicamos os cabeçalhos que queremos transmitir via CORS, já que a maioria geralmente é removida. Mas aqui, queremos manter o comprimento e o tipo de conteúdo, 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;
}

Usaremos o módulo NPM isbn3 para analisar e validar códigos ISBN. Além disso, desenvolveremos uma pequena função utilitária que analisará os códigos ISBN e responderá com um código de status 406 na resposta, se eles forem inválidos.

  • GET /books

Vamos dar uma olhada no endpoint GET /books por partes:

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

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

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

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

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

Estamos preparando uma consulta para consultar o banco de dados. Esta consulta dependerá dos parâmetros de consulta opcionais, para filtrar por autor e/ou por idioma. Também estamos retornando a lista de livros em porções de 10 livros.

Se ocorrer um erro durante a busca dos livros, vamos retornar um erro com um código de status 400.

Vamos conferir a parte recortada 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 pela ordem da data da última atualização (a última atualização vem primeiro). Também paginamos o resultado definindo um limite (o número de elementos a serem retornados) e um deslocamento (o ponto inicial a partir do qual 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 desse endpoint com 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, forneceremos apenas "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 começo, mas o que estamos fazendo é adicionar um link anterior se não estivermos na primeira página de dados. Adicionamos um link next se a página de dados estiver cheia, ou seja, tiver o número máximo de livros definido pela constante PAGE_SIZE, supondo que haja outro com mais dados. Em seguida, usamos a função resource#links() do Express para criar o cabeçalho correto com a sintaxe correta.

Para suas informações, o cabeçalho do link será parecido com este:

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 o transmite como um parâmetro de caminho. De qualquer forma, ambos chamam a 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. 201 é retornado em caso de sucesso e 400 em caso de falha.

Ao retornar com sucesso, também definimos o cabeçalho do local para indicar ao cliente da API onde se encontra o recurso recém-criado. O cabeçalho será semelhante a este:

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. Vamos fazer uma consulta ao Firestore para recuperar o livro. A propriedade snapshot.exists é útil para saber se realmente um livro foi encontrado. Caso contrário, enviaremos 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 existente.

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

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

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

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 existentes por seus valores novos. Caso contrário, todos os campos serão removidos e somente os novos campos no payload serão salvos, apagando os campos existentes 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 o código de status 204 porque nenhum conteúdo está sendo retornado.

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, detectando na 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 instalaremos as dependências com:

$ npm install

E podemos começar com:

$ npm start

O servidor será iniciado em localhost e receberá detecções na porta 8080 por padrão.

Também é possível criar um contêiner do Docker e executar a imagem dele 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 novamente se a conteinerização do nosso aplicativo funcionará bem enquanto o criarmos na nuvem com o Cloud Build.

Como testar a API

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

  • Crie um novo 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 pelo ISBN:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • Atualizar um livro existente alterando somente 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 dos livros:
$ curl http://localhost:8080/books?page=3

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

Como criar e implantar a API REST conteinerizada

Estamos felizes que a API REST funcione de acordo com o planejado, então este é o momento certo para implantá-la no Cloud, no Cloud Run.

Vamos fazer isso em duas etapas:

  • Primeiro, criando 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 comando a seguir implanta a imagem de contêiner do registro e a implanta na região da nuvem.

Podemos verificar na interface do Console do Cloud se nosso serviço do Cloud Run agora aparece na lista:

f62fbca02a8127c0.png

A última etapa que faremos aqui é recuperar o URL do serviço do Cloud Run recém-implantado, usando 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 nossa API REST do Cloud Run na próxima seção, porque nosso código de front-end do App Engine vai interagir com a API.

9. Hospedar um app da Web para navegar pela biblioteca

A última peça para adicionar glitter ao projeto é criar um front-end da Web que interaja com a API REST. Para isso, usaremos o Google App Engine com um código JavaScript do cliente que chamará a API por meio de solicitações AJAX (usando a API Fetch do lado do cliente).

Nosso aplicativo, embora implantado no ambiente de execução do App Engine para 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 usaremos nenhum framework JavaScript de front-end avançado, apenas um JavaScript "vanilla", com alguns componentes da Web para a interface que usa a biblioteca de componentes da Web Shoelace:

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

6fb9f741000a2dc1.png

  • Um componente de cartão para exibir os detalhes sobre 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 em nossa biblioteca será semelhante a esta:

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 arquivo é específico do App Engine e permite configurar, por exemplo, variáveis de ambiente e os vários "gerenciadores" do aplicativo, ou especificando que alguns recursos são estáticos, que serão disponibilizados 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 é um 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. Precisaremos atualizar o marcador de posição CHANGE_ME com o URL correto (veja abaixo como alterar isso).

Depois disso, definimos vários gerenciadores. Os três primeiros apontam para o local do código HTML, CSS e JavaScript no 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. Dessa forma, o sufixo index.html não vai aparecer no URL ao acessar a raiz do site. O último é o padrão, que encaminha todos os outros URLs (/.*) para nosso aplicativo Node.JS (ou seja, a parte "dinâmica" do aplicativo, em contraste com os recursos estáticos que descrevemos).

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

No diretório appengine-frontend/, execute o seguinte comando 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 Node.JS package.json

{
    "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"
    }
}

Enfatizamos novamente que queremos executar este aplicativo usando o Node.JS 14. Dependemos do framework Express, bem como do módulo NPM isbn3 para validar livros códigos ISBN.

Nas dependências de desenvolvimento, usaremos o módulo nodemon para monitorar as mudanças nos arquivos. Embora seja possível executar nosso aplicativo localmente com npm start, fazer algumas mudanças no código, interromper o app com ^C e reiniciá-lo, isso é um pouco tedioso. Em vez disso, use o seguinte comando para que o aplicativo seja recarregado / reiniciado automaticamente quando houver alterações:

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

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

Vamos dar uma olhada nos dois trajetos 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);
});

A primeira correspondência com / vai redirecionar para index.html no nosso diretório public/html. Como no modo de desenvolvimento não está em execução dentro do tempo de execução do App Engine, não temos o roteamento de URL do App Engine. Em vez disso, estamos apenas redirecionando o URL raiz para o arquivo HTML.

O segundo endpoint que definirmos para /webapi vai retornar o URL da nossa API REST do Cloud RUN. Dessa forma, o código JavaScript do lado do cliente saberá para onde chamar para conseguir 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 concluir, estamos executando o app da Web Express e escutando na porta 8080 por padrão.

A página index.html

Não analisaremos 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 de livros.

As últimas linhas estão importando 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 cadarço com as tags de elemento personalizado, por exemplo:

<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 seu recurso de preenchimento de slots para representar um livro. Criaremos cópias desse modelo para preencher a lista de livros e substituiremos os valores nos espaços pelos detalhes dos livros:

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

HTML suficiente, estamos quase terminando a revisão do código. Resta uma última parte importante: 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 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, buscaremos 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, chamada do código JavaScript do lado do cliente, não precisamos fixar o URL da API REST no código de front-end.

Também definimos as variáveis page e language, que serão usadas para acompanhar a paginação e a filtragem do 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 clicado, ele chama 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);
    });

Algo semelhante para a caixa de seleção, adicionamos um manipulador de eventos para ser notificado sobre alterações 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.

Então, vamos dar uma olhada nessa 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 nesta interface do usuário, especificamos apenas dois:

  • page: um número inteiro que indica a página atual para a 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 nosso 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, mostraremos ou ocultaremos o botão [More books...], já que o cabeçalho Link é uma dica que 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. Além disso, vamos 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 bom código de barras, como na contracapa de livros reais.

Como executar e testar o aplicativo localmente

Código suficiente por enquanto, é hora de ver o aplicativo em ação. Primeiro, faremos isso localmente, no Cloud Shell, antes da implantação de fato.

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

$ npm install

E executamos o app com o normal:

$ 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 pode ser acessado pelo navegador, em http://localhost:8080.

Como implantar o aplicativo do App Engine

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

Para implantar o aplicativo, inicie o seguinte comando:

$ gcloud app deploy -q

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

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

Como explorar a IU 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 apenas os livros nesse idioma.
  • Limpe a seleção com a pequena cruz na caixa de seleção para voltar à lista de todos os livros.

10. Limpar (opcional)

Se você não pretende manter o app, limpe 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!

Graças ao Cloud Functions, App Engine e Cloud Run, criamos um conjunto de serviços para expor vários endpoints de APIs da Web e front-ends da Web, além de armazenar, atualizar e navegar em uma biblioteca de livros, seguindo alguns bons padrões de design para o desenvolvimento da API REST.

O que vimos

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

Como ir mais longe

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

  • Use 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, adicionar recursos como gerenciamento de chaves de API para acessar a API ou definir limitações de taxa para os consumidores de API.
  • Implante o módulo de nó Swagger-UI no aplicativo do App Engine para documentar e oferecer um ambiente de testes para a API REST.
  • No front-end, além da capacidade de navegação atual, adicione telas extras para editar os dados e crie novas entradas de livro. Além disso, como estamos usando o banco de dados do Cloud Firestore, aproveite o recurso tempo real para atualizar os dados dos livros exibidos à medida que as alterações são feitas.