Workshop de APIs da Web sem servidor

1. Visão geral

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

  • Cloud Functions: para implantar pequenas unidades de lógica de negócios no formato de funções, que reagem a vários eventos, como 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 de escalonamento vertical e horizontal rápidos,
  • Cloud Run: para implantar e escalonar contêineres, que podem conter qualquer linguagem, ambiente de execução ou biblioteca.

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

Neste workshop, você criará um explorador de estante que contém as seguintes informações:

  • Uma função do Cloud para importar o conjunto de dados inicial dos livros disponíveis na nossa biblioteca, no banco de dados do documento do Cloud Firestore,
  • Contêiner do Cloud Run, que expõe 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.

Veja como ficará o front-end da Web no fim deste codelab:

b6964f26b9624565.png

O que você aprenderá

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

2. Configuração e requisitos

Configuração de ambiente personalizada

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

96a9c957bc475304.png

b9a10ebdf5b5a448.png

a1e3c01a38fa61c2.png

Lembre-se do código do projeto, um nome exclusivo em todos os projetos do Google Cloud. O nome acima já foi escolhido e não servirá para você. Faremos referência a ele mais adiante neste codelab como PROJECT_ID.

  1. Em seguida, será necessário ativar o faturamento no Console do Cloud para usar os recursos do Google Cloud.

A execução deste codelab não será muito cara, se for o caso. Siga todas as instruções na seção "Limpeza", que orienta você sobre como encerrar recursos para não incorrer em cobranças além deste tutorial. Novos usuários do Google Cloud estão qualificados para o programa de teste gratuito de US$300.

Iniciar Cloud Shell

Embora o Google Cloud possa ser operado remotamente do seu laptop, neste codelab você usará o Google Cloud Shell, um ambiente de linha de comando executado na nuvem.

No Console do GCP, clique no ícone do Cloud Shell na barra de ferramentas localizada no canto superior direito:

bce75f34b2c53987.png

O provisionamento e a conexão ao ambiente devem levar apenas alguns instantes. Quando o processamento for concluído, você verá algo como:

f6ef2b5f13479f3a.png

Essa máquina virtual contém todas as ferramentas de desenvolvimento necessárias. Ele oferece um diretório principal permanente de 5 GB e é executado no Google Cloud, melhorando muito o desempenho e a autenticação da rede. Todo o trabalho neste laboratório pode ser feito apenas com um navegador.

3. Preparar o ambiente e ativar as APIs de nuvem

Para usar os diversos serviços necessários ao longo deste projeto, serão ativadas algumas APIs. Para 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, você verá a operação concluída:

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

Também vamos configurar uma variável de ambiente que será necessária no caminho: a região da nuvem onde implantaremos a função, o app e o contêiner:

$ export REGION=europe-west3

Como armazenaremos os dados no banco de dados do Cloud Firestore, precisamos criar o banco de dados:

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

Mais adiante neste codelab, ao implementar a API REST, precisaremos classificar e filtrar os dados. Para isso, vamos criar três índices:

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

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

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

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. Buscar 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á as seguintes pastas relevantes 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: esta função oferecerá um endpoint para importar dados de amostra.
  • run-crud: este contêiner expõe uma API da 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. Amostras de dados da biblioteca de livros

Na pasta de dados, temos um arquivo books.json que contém uma lista de cem livros que provavelmente serão lidos. Este documento JSON é uma matriz que contém objetos JSON. Vamos dar uma olhada no formato dos dados que serão ingeridos por 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 é 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, implementaremos o endpoint que será usado para importar os dados do livro de amostra. Para isso, usaremos o Cloud Functions.

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": "^1.7.1"
    },
    "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 do nosso livro. Internamente, o ambiente de execução do Cloud Functions também fornece o framework da Web do Express. Por isso, não precisamos declará-lo como uma dependência.

Nas dependências de desenvolvimento, declaramos o Framework de funções (@google-cloud/functions-framework), que é o framework de tempo de execução usado para invocar suas funções. É um framework de código aberto que também pode ser usado localmente na máquina (neste caso, no Cloud Shell) para executar funções sem implantar cada vez que uma alteração é 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 curl ou a visualização da Web do Cloud Shell para solicitações HTTP GET para interagir com a função.

Agora, veja o 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).

exports.parseBooks = async (req, resp) => {
    if (req.method !== "POST") {
        resp.status(405).send({error: "Only method POST allowed"});
        return;
    }
    if (req.headers['content-type'] !== "application/json") {
        resp.status(406).send({error: "Only application/json accepted"});
        return;
    }
    ...
}

Estamos exportando a função JavaScript parseBooks. Essa será a função que declararemos quando a implantarmos mais tarde.

As próximas instruções verificam se:

  • Aceitamos apenas solicitações HTTP POST. De outra forma, retornaremos um código de status 405 para indicar que os outros métodos HTTP não são permitidos.
  • Aceitamos apenas payloads application/json e enviamos um código de status 406 para indicar que esse não é um formato 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 por meio da body da solicitação. Estamos preparando uma operação em lote do Firestore para armazenar todos os livros em massa. Itera a matriz JSON que consiste nos detalhes do livro, passando pelos campos isbn, title, author, language, pages e year. O código ISBN do livro será usado como a chave ou identificador principal dele.

    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, um código de status 400 será retornado para informar que ela falhou. Caso contrário, podemos retornar uma resposta OK, com um código de status 202 que indica 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 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 iniciar esse comando, você 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 estão realmente armazenados no Firestore:

d6a2b31bfa3443f2.png

Na captura de tela acima, vemos a coleção books criada, a lista de documentos identificados pelo código ISBN do livro e os detalhes da 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 \
         --trigger-http \
         --runtime=nodejs12 \
         --allow-unauthenticated \
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

Implantamos a função com um nome simbólico de bulk-import. Esta função é acionada por solicitações HTTP. Usamos o ambiente de execução do Node.JS 12. Implante a função publicamente (o ideal é proteger esse endpoint). Especificamos a região em que a função reside. Também apontamos para as origens no diretório local e usamos o parseBooks (a função JavaScript exportada) como ponto de entrada.

Após alguns minutos, a função é implantada na nuvem. Na IU do Console do Cloud, você verá a função:

c3156d50ba917ddd.png

Na resposta da implantação, você verá o URL da função, que segue uma convenção de nomenclatura (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}). Além disso, também é possível encontrar o URL do gatilho HTTP na IU do Console do Cloud, na guia do acionador:

2d19539de3de98eb.png

Também é possível 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 armazená-la na variável de ambiente BULK_IMPORT_URL para que possamos reutilizá-la nos testes de funções implantadas.

Como testar a função implantada

Com um comando curl semelhante usado anteriormente para testar a função executada localmente, testaremos a função implantada. A única alteração será o URL:

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

Mais uma vez, se bem-sucedido, ele retornará a seguinte saída:

{"status":"OK"}

Agora que a função de importação está implantada e pronta, que fizemos o upload dos dados de amostra, é 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 da API aberta, vamos dar uma olhada nos vários endpoints da nossa API REST.

As trocas de API reservam objetos JSON que consistem em:

  • isbn (opcional): um String de 13 caracteres que representa um código ISBN válido,
  • author: uma String não vazia que representa o nome do autor do livro;
  • language: uma String não vazia com o idioma em que o livro foi escrito,
  • pages: uma Integer positiva para a contagem de páginas do livro;
  • title: uma String não vazia 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

Veja 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: none.

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 é bem-sucedida para 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 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: none.

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. Somente os campos que precisam de atualização podem ser transmitidos, e 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: none.

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

Explorar o código

Dockerfile

Vamos começar examinando o Dockerfile, que será responsável pela conteinerização do código do aplicativo:

FROM node:14-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 14. 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 esse aplicativo com o comando node index.js.

package.json

Em seguida, podemos ver o arquivo package.json:

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 14.0.0"
    },
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "scripts": {
        "start": "node index.js"
    }
}

Especificamos que queremos usar o Node.JS 14, como foi o caso do Dockerfile.

Nosso aplicativo de API da Web depende dos seguintes pontos:

  • O módulo 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 nossa API REST será invocada do código do cliente do front-end do aplicativo da Web do App Engine.
  • a estrutura do Google Express, que será nossa estrutura da Web para criar nossa API;
  • Em seguida, use o módulo isbn3 para ajudar na validação dos códigos ISBN do livro.

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, com uma visão mais aprofundada de index.js:

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

Exigimos o módulo Firestore e referenciamos a coleção books, onde 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 nossa biblioteca 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. Isso acontecerá quando criarmos cabeçalhos Link para fins de paginação (mais informações sobre isso mais adiante).

Em seguida, configuramos o módulo cors. Explicamos explicitamente os cabeçalhos que queremos transmitir por CORS, já que a maioria deles é normalmente removida, mas aqui, queremos manter o tamanho e o tipo de conteúdo habituais, 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 isbn3 do NPM para analisar e validar códigos ISBN e desenvolver uma pequena função de utilitário que analisará códigos ISBN e responderá com um código de status 406 na resposta, se os códigos ISBN forem: inválido.

  • GET /books

Vamos analisar o endpoint GET /books, peça 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}`});
    }
});

Para preparar a consulta, estamos preparando uma consulta no banco de dados. Esta consulta depende dos parâmetros opcionais de consulta para filtrar por autor e/ou idioma. Também retornamos a lista de livros por blocos de 10 livros.

Se houver um erro no caminho, ao buscar os livros, retornaremos um erro com um código de status 400.

Vamos aumentar o zoom na 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 da data da última atualização (a última atualização vem primeiro). Também paginaremos o resultado definindo um limite (o número de elementos a serem retornados) e um deslocamento (o ponto inicial de onde 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.

Para concluir as explicações desse endpoint, veja uma prática recomendada: usar o cabeçalho Link para definir links de URI para a primeira, a anterior, a próxima ou a última página de dados. No nosso caso, fornecemos apenas a seguir.

        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 aqui no início, mas o que estamos fazendo é adicionar um link anterior se não estivermos na primeira página de dados. Além disso, adicionaremos 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 outro tenha 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 será semelhante a este:

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

Os dois endpoints estão aqui para criar um 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 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, retorna da função (e define um código de status 406). Recuperamos os campos de livro do payload transmitido no corpo da solicitação. Em seguida, armazenaremos os detalhes do livro no Firestore. Retornando 201 em caso de sucesso e 400 em caso de falha.

Ao retornar com sucesso, também definimos o cabeçalho do local para dar sinais ao cliente da API onde está o recurso recém-criado. O cabeçalho ficará assim:

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

Vamos buscar um livro identificado pelo ISBN pelo livro 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. Faremos uma consulta para o Firestore 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}`});
    }
});

Nós atualizamos o campo de data/hora do updated para lembrar quando atualizamos esse registro pela última vez. Usamos a estratégia {merge:true} que substitui os campos existentes pelos novos valores. Caso contrário, todos os campos são removidos, e apenas os novos 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. Como não retornamos conteúdo, retornamos um código de status 204.

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 do Express / Node

Por último, mas não menos importante, iniciamos o servidor, escutando 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

Podemos começar com:

$ npm start

Por padrão, o servidor iniciará em localhost e fará a detecção na porta 8080.

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

A execução no Docker também é uma ótima maneira de verificar se a conteinerização do aplicativo será executada corretamente à medida que o criamos na nuvem com o Cloud Build.

Como testar a API

Independentemente de como executamos o código da API REST (diretamente por meio de um nó ou de 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
  • Excluir 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
  • Para atualizar um livro existente, altere somente seu 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 livros):
$ curl http://localhost:8080/books
  • Encontrar 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 em contêiner

Ficamos felizes em saber que a API REST funciona de acordo com o plano. Agora é o momento certo para implantá-la no Cloud, no Cloud Run.

Faremos 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 e a implanta na região da nuvem.

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

4ca13b0a703b2126.png

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

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

Você precisará do URL da API REST do Cloud Run na próxima seção, porque nosso código de front-end do App Engine interagirá com a API.

9. Hospedar um app da Web para navegar na biblioteca

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

Nosso aplicativo, apesar de implantado no ambiente de execução do App Engine para Node.JS, é composto principalmente por recursos estáticos. Não há muito código de back-end, já que a maior parte da interação do usuário ocorrerá no navegador por meio do JavaScript do lado do cliente. Não usaremos nenhum framework JavaScript de front-end sofisticado. Usaremos apenas JavaScript "vanilla", com alguns componentes da Web para a IU usando a biblioteca de componentes da Web Shoelace:

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

1b7bf64bd327b1ee.png.

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

4dd54e4d5ee53367.png

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

4766c796a9d87475.png

Ao combinar todos esses componentes visuais, a página da Web resultante para navegar em nossa biblioteca ficará assim:

fb6eae65811c8ac2.png

O arquivo de configuração app.yaml

Vamos começar a analisar a base de código desse aplicativo do App Engine analisando o arquivo de configuração app.yaml. Esse é um arquivo específico do App Engine que permite a configuração de variáveis de ambiente, os diversos gerenciadores do aplicativo ou a especificação de que alguns recursos são estáticos, que serão exibido 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 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. Será necessário atualizar o marcador de posição CHANGE_ME com o URL correto (veja abaixo como alterar isso).

Depois, definimos vários gerenciadores. Os três primeiros apontam para o local do código do cliente HTML, CSS e JavaScript, na pasta public/ e nas subpastas. O quarto indica que o URL raiz do aplicativo do App Engine deve apontar para a página index.html. Assim, não veremos o sufixo index.html no URL ao acessar a raiz do site. E o último é o padrão que encaminhará todos os outros URLs (/.*) para o nosso aplicativo Node.JS, ou seja, a parte "dinâmica" do aplicativo, ao contrário da estática. recursos que descrevemos).

Agora, 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 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"
    }
}

Mais uma vez, queremos executar este aplicativo usando Node.JS 14. Dependemos do framework do Express e do módulo NPM do isbn3 para validar códigos ISBN de livros.

Nas dependências de desenvolvimento, usaremos o módulo nodemon para monitorar as alterações no arquivo. Embora seja possível executar nosso app localmente com npm start, fazer algumas mudanças no código, interrompê-lo com ^C e reiniciá-lo, o processo é tedioso. Em vez disso, podemos usar o comando a seguir para que o aplicativo seja recarregado / reiniciado automaticamente após alterações:

$ npm run dev

O código Node.JS de index.js

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

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

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

Exigimos o framework da Web do Express. Especificamos que o diretório público contém recursos estáticos que podem ser veiculados pelo menos durante a execução local no modo de desenvolvimento pelo middleware static. Por fim, exigimos que body-parser analise nossos payloads JSON.

Vamos dar uma olhada em alguns 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);
});

O primeiro correspondente a / será redirecionado para o index.html no nosso diretório public/html. Como no modo de desenvolvimento, não executamos no ambiente de execução do App Engine, não ocorremos o roteamento de URL do App Engine. Portanto, aqui estamos apenas redirecionando o URL raiz para o arquivo HTML.

O segundo endpoint definido como /webapi retornará o URL da API REST do Cloud RUN. Assim, o código JavaScript do lado do cliente saberá onde chamar para ver 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 finalizar, estamos executando o app da Web Express e ouvindo 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, destacaremos algumas linhas-chave.

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

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

Na body da página HTML, usamos os componentes da Shoelace com as tags de elemento personalizado, 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>
...

Além disso, usamos modelos HTML e a capacidade de preenchimento de slots para representar um livro. Criaremos cópias desse modelo para preencher a lista de livros e substituiremos 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>

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

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

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

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

Quando ela estiver pronta, poderemos 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 API REST, graças ao código de 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 no código do lado do cliente JavaScript, não tivemos que fixar o URL da API REST no código de front-end.

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

    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. Ao receber um clique, ele 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);
    });

Semelhante à caixa de seleção, adicionamos um manipulador de eventos para ser notificado sobre alterações na seleção de idiomas. 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 dar uma olhada na 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, criamos o URL exato a ser usado para chamar a API REST. Há três parâmetros de consulta que normalmente podemos especificar, mas, nesta IU, 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 idioma escrito.

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

Se o cabeçalho Link estiver presente na resposta, exibiremos ou ocultaremos o botão [More books...], já que o cabeçalho Link é uma dica que indica se há mais livros a serem carregados. ser 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 preencheremos 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 mais agradável, usamos a biblioteca JsBarcode para criar um bom código de barras, como na capa de livros reais.

Como executar e testar o aplicativo localmente

Por tempo suficiente, veja o aplicativo em ação. Primeiro, faremos isso localmente no Cloud Shell antes de fazer a implantação.

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

$ npm install

E executamos o app normalmente:

$ npm start

Com a atualização automática das alterações graças ao nodemon, com:

$ npm run dev

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

Como implantar o aplicativo do App Engine

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

Para implantar o aplicativo, vamos iniciar 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 com o formato: https://${GOOGLE_CLOUD_PROJECT}.appspot.com.

Conheça a IU do 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 os livros somente nesse idioma.
  • Você pode limpar 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, é possível limpar os recursos para economizar custos e ser um bom usuário 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, App Engine e Cloud Run, para expor vários endpoints da API Web e front-end da Web, além de armazenar, atualizar e navegar por uma biblioteca de livros, seguindo alguns bons padrões de design para o desenvolvimento da API REST. o caminho.

O que vimos

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

Vá além

Se você quiser explorar esse exemplo concreto e expandi-lo, veja esta lista de itens a serem investigados:

  • 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, adicionar recursos como gerenciamento 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 playground de teste para a API REST.
  • No front-end, além do recurso 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 à medida que as alterações são feitas.