In this code lab, you create a web frontend on Google App Engine, that will let users upload pictures from the web application, as well as browse the uploaded pictures and their thumbnails.

This web application will be using a CSS framework called Bulma, for having some good looking user interface, and also the Vue.JS JavaScript frontend framework to call the application's API you will build.

This application will consist of three tabs:

The resulting frontend looks as follows:

Those 3 pages are simple HTML pages:

What you'll learn

Codelab-at-a-conference setup

If you see a "request account button" at the top of the main Codelabs window, click it to obtain a temporary account. Otherwise ask one of the staff for a coupon with username/password.

These temporary accounts have existing projects that are set up with billing so that there are no costs associated for you with running this codelab.

Note that all these accounts will be disabled soon after the codelab is over.

Use these credentials to log into the machine or to open a new Google Cloud Console window https://console.cloud.google.com/. Accept the new account Terms of Service and any updates to Terms of Service.

Here's what you should see once logged in:

When presented with this console landing page, please select the only project available. Alternatively, from the console home page, click on "Select a Project" :

Start Cloud Shell

While Google Cloud can be operated remotely from your laptop, in this codelab you will be using Google Cloud Shell, a command line environment running in the Cloud.

From the GCP Console click the Cloud Shell icon on the top right toolbar:

It should only take a few moments to provision and connect to the environment. When it is finished, you should see something like this:

This virtual machine is loaded with all the development tools you'll need. It offers a persistent 5GB home directory, and runs on Google Cloud, greatly enhancing network performance and authentication. All of your work in this lab can be done with simply a browser.

Checkout the code, if you haven't already:

git clone https://github.com/GoogleCloudPlatform/serverless-photosharing-workshop

You will have the following file layout:

frontend
 |
 ├── index.js
 ├── package.json
 ├── app.yaml
 |
 ├── public
      |
      ├── index.html
      ├── collage.html
      ├── upload.html
      |
      ├── app.js
      ├── style.css

At the root of our project, you have 3 files:

A public folder contains static resources:

Dependencies

The package.json file defines the needed library dependencies:

{
  "name": "frontend",
  "version": "0.0.1",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "@google-cloud/firestore": "^3.4.1",
    "@google-cloud/storage": "^4.0.0",
    "express": "^4.16.4",
    "dayjs": "^1.8.22",
    "bluebird": "^3.5.0",
    "express-fileupload": "^1.1.6"
  }
}

Our application depends on:

Express frontend

At the beginning of the index.js controller, you will require all the dependencies defined in package.json earlier:

const express = require('express');
const fileUpload = require('express-fileupload');
const Firestore = require('@google-cloud/firestore');
const Promise = require("bluebird");
const {Storage} = require('@google-cloud/storage');
const storage = new Storage();
const path = require('path');
const dayjs = require('dayjs');
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)

Next, the Express application instance is created.

Two Express middleware are used:

const app = express();
app.use(express.static('public'));
app.use(fileUpload({
    limits: { fileSize: 10 * 1024 * 1024 },
    useTempFiles : true,
    tempFileDir : '/tmp/'
}))

Among the static resources, you have the HTML files for the home page, the collage page, and the upload page. Those pages will call the API backend. This API will have the following endpoints:

Picture upload

Before exploring the picture upload Node.js code, take a quick look at public/upload.html.

... 
<form method="POST" action="/api/pictures" enctype="multipart/form-data">
    ... 
    <input type="file" name="picture">
    <button>Submit</button>
    ... 
</form>
... 

The form element points at the /api/pictures endpoint, with an HTTP POST method, and a multi-part format. The index.js now has to respond to that endpoint and method, and extract the file content:

app.post('/api/pictures', async (req, res) => {
    if (!req.files || Object.keys(req.files).length === 0) {
        console.log("No file uploaded");
        return res.status(400).send('No file was uploaded.');
    }
    console.log(`Receiving file ${JSON.stringify(req.files.picture)}`);

    const newPicture = path.resolve('/tmp', req.files.picture.name);
    const mv = Promise.promisify(req.files.picture.mv);
    await mv(newPicture);
    console.log('File moved in temporary directory');

    const pictureBucket = storage.bucket(process.env.BUCKET_PICTURES);
    await pictureBucket.upload(newPicture, { resumable: false });
    console.log("Uploaded new picture into Cloud Storage");

    res.redirect('/');
});

First, you check that there is indeed a file being uploaded. Then you download the file locally via the mv method coming from our file upload Node module. Now that the file is available on the local filesystem, we upload the picture to the Cloud Storage bucket. Finally, we return back the user to the main screen of the application.

Listing the pictures

Time to display your beautiful pictures!

In the /api/pictures handler, you look into the Firestore database's pictures collection, to retrieve all the pictures, ordered by descending date of creation.

You push each picture in a JavaScript array, with its name, the labels describing it (coming from the Cloud Vision API), the dominant color, and a friendly date of creation (with dayjs, we relative time offsets like "3 days from now").

app.get('/api/pictures', async (req, res) => {
    console.log('Retrieving list of pictures');

    const thumbnails = [];
    const pictureStore = new Firestore().collection('pictures');
    const snapshot = await pictureStore.orderBy('created', 'desc').get();

    if (snapshot.empty) {
        console.log('No pictures found');
    } else {
        snapshot.forEach(doc => {
            const pic = doc.data();
            thumbnails.push({
                name: doc.id,
                labels: pic.labels,
                color: pic.color,
                created: dayjs(pic.created.toDate()).fromNow()
            });
        });
    }
    console.table(thumbnails);
    res.send(thumbnails);
});

This controller returns results of the following shape:

[
   {
      "name": "IMG_20180423_163745.jpg",
      "labels": [
         "Dish",
         "Food",
         "Cuisine",
         "Ingredient",
         "Orange chicken",
         "Produce",
         "Meat",
         "Staple food"
      ],
      "color": "#e78012",
      "created": "a day ago"
   },
   ...
]

This data structure is consumed by a small Vue.js snippet from the index.html page. Here's a simplified version of the markup from that page:

<div id="app">
   <div v-for="n in Math.ceil(pictures.length / 4)">
      <div v-for="pic in pictures.slice((n-1) * 4, n * 4)">
         <div :style="{ 'border-color': pic.color }">
            <a :href="'/api/pictures/' + pic.name">
               <img :src="'/api/thumbnails/' + pic.name">
                  </a>
         </div>
         <a v-for="label in pic.labels">
            {{ label }}
         </a>
      </div>
   </div>
</div>

The div's ID will indicate Vue.js that it's the part of the markup that will be dynamically rendered. This code is creating a 4-column view of the pictures, hence the bit of maths and array slicing to get 4 pictures per row. The iterations are done thanks to the v-for directives.

The pictures get a nice colored border corresponding to the dominant color in the picture, as found by the Cloud Vision API and we point at the thumbnails and the full-width pictures in the link and image sources.

Last, we list the labels describing the picture.

Here is the JavaScript code for the Vue.js snippet:

var app = new Vue({
  el: '#app',
  data() {
    return { pictures: [] }
  },
  mounted() {
    axios
      .get('/api/pictures')
      .then(response => { this.pictures = response.data })
  }
})

The Vue code is using the Axios library to make an AJAX call to our /api/pictures endpoint. The returned data is then bound to the view code in the markup you saw earlier.

Viewing the pictures

From the index.html our users can view the thumbnails of the pictures, click on them to view the full-size images, and fom collage.html, users view the collage.png image.

In the HTML markup of those pages, the image src and link href point at those 3 endpoints, which redirect to the Cloud Storage locations of the pictures, thumbnails, and collage. No need to hard-code the path in the HTML markup.

app.get('/api/pictures/:name', async (req, res) => {
    res.redirect(`https://storage.cloud.google.com/${process.env.BUCKET_PICTURES}/${req.params.name}`);
});

app.get('/api/thumbnails/:name', async (req, res) => {
    res.redirect(`https://storage.cloud.google.com/${process.env.BUCKET_THUMBNAILS}/${req.params.name}`);
});

app.get('/api/collage', async (req, res) => {
    res.redirect(`https://storage.cloud.google.com/${process.env.BUCKET_THUMBNAILS}/collage.png`);
});

Running the Node application

With all the endpoints defined, your Node.js application is ready to be launched. The Express application listens on port 8080 by default, and is ready to serve incoming requests.

const PORT = process.env.PORT || 8080;

app.listen(PORT, () => {
    console.log(`Started web frontend service on port ${PORT}`);
    console.log(`- Pictures bucket = ${process.env.BUCKET_PICTURES}`);
    console.log(`- Thumbnails bucket = ${process.env.BUCKET_THUMBNAILS}`);
});

Test the code locally to make sure it works before deploying to cloud.

You need two environment variables corresponding to the two Cloud Storage buckets:

export BUCKET_THUMBNAILS=thumbnails-${GOOGLE_CLOUD_PROJECT}
export BUCKET_PICTURES=pictures-${GOOGLE_CLOUD_PROJECT}

Inside frontend folder, install npm dependencies and start the server:

npm install; npm start

If everything went well, it should start the server on port 8080:

Started web frontend service on port 8080
- Pictures bucket = thumbnails-${GOOGLE_CLOUD_PROJECT}
- Thumbnails bucket = pictures-${GOOGLE_CLOUD_PROJECT}

The real names of your buckets will appear in those logs, which is helpful for debugging purposes.

From Cloud Shell, you can use the web preview feature, to browser the application running locally:

Your application is ready to be deployed.

Configure App Engine

Examine the app.yaml configuration file for App Engine:

runtime: nodejs10
env_variables:
  BUCKET_PICTURES: uploaded-pictures-GOOGLE_CLOUD_PROJECT
  BUCKET_THUMBNAILS: thumbnails-GOOGLE_CLOUD_PROJECT

The first line declares that the runtime is based on Node.js 10. Two environment variables are defined to point at the two buckets, for the original images and for the thumbnails.

To replace GOOGLE_CLOUD_PROJECT with your actual project id, you can run the following command:

sed -i -e "s/GOOGLE_CLOUD_PROJECT/${GOOGLE_CLOUD_PROJECT}/" app.yaml

Deploy

Set your preferred zone for App Engine, be sure to use the same region in the previous labs:

gcloud config set compute/zone europe-west1

And deploy:

gcloud app deploy

After a minute or two, you will be told that the application is serving traffic:

Beginning deployment of service [default]...
╔════════════════════════════════════════════════════════════╗
╠═ Uploading 8 files to Google Cloud Storage                ═╣
╚════════════════════════════════════════════════════════════╝
File upload done.
Updating service [default]...done.
Setting traffic split for service [default]...done.
Deployed service [default] to [https://GOOGLE_CLOUD_PROJECT.appspot.com]
You can stream logs from the command line by running:
  $ gcloud app logs tail -s default
To view your application in the web browser run:
  $ gcloud app browse

To test, go to the default App Engine url for the app (https://<YOUR_PROJECT_ID>.appspot.com/) app and you should see the frontend UI up and running!

Congratulations! This Node.js web application hosted on App Engine binds all your services together, and allows your users to upload and visualize pictures.

What we've covered