Contestants in the example programming contest create their solutions, called players, as web services that make one move at a time. Questioners are programs that have those players play the game. The manager accepts requests to play a new round of games, and displays current results.

Contest participants propose their solutions for scoring to the manager, which records the submission and sends a message to a Pub/Sub topic that results in all registered questioners exercising that player. Each questioner reports its result back to the manager, which updates its data about that submission with results. Any contest participant can ask the manager for the current results of all contest participants by viewing a web page.

The manager can be viewed as having two major parts: a web application that people use to submit their player information and view results, and a web service that questioners use to submit results of a contest round. The web application and the web service share data via Cloud Firestore.

We will create that web application with App Engine and make it available to authenticated users only, so contestants cannot impersonate other contestants. The web service will be an HTTP-triggered Cloud Function that accepts requests from any source, but will verify that requests are authorized by checking that they know the shared secret the manager gave them when they were triggered.

What you will build

In this tutorial, you will create:

Each of these parts will be built as a separate step in this tutorial

What you'll learn

What you'll need

All software deployed on Google Cloud Platform will be part of a project. The manager must be able to publish messages to the Pub/Sub topic used by the questioners, which is most easily enabled if they are part of the same project. You can use an existing project or you can create a new project at console.cloud.google.com.

You will work in the Cloud Shell command line environment. Start by opening that environment and fetching the sample code to it.

Launch the Console and Cloud Shell

Open the Cloud Console at console.cloud.google.com and select your project.

All commands in this codelab will be executed in the console UI or within a Cloud Shell. Open the Cloud Shell by clicking the Activate Cloud Shell icon found at the right side of the console page header. The lower half of the page will allow you to enter and run commands.

The commands could be run from your own PC, but you would have to install and configure needed development software first. The Cloud Shell already has all the software tools you need.

Fetch the source code

If you have not previously retrieved the code in another codelab, use the following command from the Cloud Shell command line:

git clone https://github.com/GoogleCloudPlatform/serverless-game-contest.git

You can explore the code in the built-in code editor by clicking the editor icon to launch it.

Cloud Firestore is a NoSQL database that the manager components will use for shared, persistent data. The database contains collections of documents. Each document can contain data and sub-collections. The structure this app will be using is shown below:

Each time a contestant submits a problem solution, a new document will be created in the rounds collection. The document will have a unique identifier (used as a contest_round identifier) assigned to it along with a random secret. The nickname that the contestant has chosen is also stored.

Whenever a questioner finishes a game against a player and submits the result, a new document will be added to the runs sub-collection of that document. This new runs document will specify the questioner, outcome, and how many moves it played.

Cloud Firestore must be enabled via the console or the gcloud CLI before it can be used. To enable Cloud Firestore from the console, use the following steps:

  1. From the menu icon in the upper left-hand corner of the console page, select Firestore > Data from the Storage section.
  2. The first time you visit this page you will be prompted to select a Cloud Firestore mode and specify where you want its data stored. Note that these selections are permanent for the project. If you later want to use a different mode or location you would have to create a new project to do so.
  3. Click SELECT NATIVE MODE.
  4. Select a location. Multi-region databases have a five nines SLA (99.999%) while regional ones have four nines SLAs. Since the free tier should be more than enough for this application (tens of thousands of reads, writes, and deletions per day), go ahead and select the nam5 (United States) multi-region option.
  5. Click CREATE DATABASE.

It will take a few minutes to initialize the database. You can go on to the next step while waiting.

You create a Cloud Function triggered by HTTP requests, which will share a Firestore database with the web app created in the next step.

  1. Using the menu in the top left corner of the console, select Cloud Functions from the Serverless section.
  2. If this is the first time you are using Cloud Functions in this project, you may see a message that the Cloud Functions API is not enabled. Click the Enable API button to proceed.
  3. The next message displays a Create function button. Click it to create a new Cloud Function.

Use the displayed Create function page to specify your new Cloud Function.

  1. Ensure the Environment is set to 1st gen.
  2. Fill in the Function name box with manager.
  3. Leave the default region selected.
  4. We will use an HTTP trigger. A URL for the function is displayed, in the form https://region-project_id.cloudfunctions.net/function_name.
  5. Ensure the checkbox Allow unauthenticated invocations is checked, so anyone can invoke the new function.
  6. Select Save in the Trigger section and then Next at the bottom of the screen.
  7. Change the Runtime to Python 3.9. You'll change the Entry point later.
  8. You may receive a message that Cloud Build API is required. Click Enable API to enable it.
  1. This will take you to the Cloud Build API page in another tab. Click Enable.
  2. Return to the tab with Create function.
  1. The function code to enter is in the manager/function/main.py file in the repository that was cloned at the beginning of this lab. Copy that code from the Cloud Shell code editor and paste it into the function body here.
  2. The requirements are in the manager/function/requirements.txt file in the repository that was cloned at the beginning of this lab. Copy that code from the Cloud Shell code editor and paste it into the requirements.txt here.
  3. Fill in the name of the Entry point as save_result, and click the Create button. A spinner icon will appear next to the function name near the top of the page. After a few minutes, it should change to a green check mark. Hovering over the icon will show the message Function is active.

If something goes wrong: the spinner icon will show a red exclamation point. Click the function name and look at the details of the Deployment failure messages in the General tab to figure out how to fix it and try again.

This function will be sent an HTTP POST request whose body is a JSON object with the result of a single contest round.

It will begin by extracting the information from the POST request:

import json
from google.cloud import firestore

def save_result(request):
    result = request.get_json()

    contest_round = result['contest_round']
    outcome = result['outcome']
    moves = result['moves']
    questioner = result['questioner']
    secret = result['secret']

A valid result must be for a contest_round that the manager created, and must know the random secret the manager assigned:

    rounds = firestore.Client().collection('rounds')
    this_round = rounds.document(contest_round).get()

    if not this_round.exists:
        return '404'  # Not found - no such contest_round was ever asked for
    if secret != this_round.to_dict().get('secret'):
        return '403'  # Forbidden - they don't know the shared secret

If everything is okay, it will save the result of this run:

    rounds.document(contest_round).collection('runs').add({
        'outcome': outcome,
        'moves': moves,
        'questioner': questioner,
    })

    return '201'  # Created (a new contest run entry)

Key steps in this function code are:

  1. Import the standard json module for interpreting the request data and formatting the response. Import the google.cloud.firestore module for managing the database. Since this is not a standard Python module, it must be added to requirements.txt.
  2. The request handler function is named save_result. Any valid Python name can be used, but it must be filled in the Function to execute field below the code editor.
  3. The body of the function retrieves the properties of the JSON request object and checks that they are for a contest round that was created by the manager, and know the secret that the manager specified for that round.
  4. If everything checks out, a document is added to the runs subcollection of that contest round, and status 201 is returned, indicating that new data was successfully saved.

We are now ready to test the function. Click the function name to open the Function details page, and then click the Testing tab to start. We will start with failing tests because the data is for a never requested run, or is missing the shared secret needed to authenticate the run. We will then set up the requested run information and retry the test, which should succeed.

  1. Fill in the Triggering Event box with a JSON object representing a request to make a move, as shown below.
{
  "contest_round": "one",
  "secret": "not-very",
  "questioner": "easy-questioner",
  "outcome": "won",
  "moves": 10
}
  1. Click Test the function.
  2. The Output should show 404. This is the status code for "Not found", because there is no entry for this contest_round in the database.
  3. In another console page, use the menu to navigate to Firestore/data. Click START COLLECTION. Enter rounds as the Collection ID. Enter one as the Document ID. Enter the first field, with name secret, type string, and value very secret.
  4. Click SAVE. You have created a contest round entry.
  5. Return to the Function details page and click Test the function with the same data as before.
  6. The Output should show 403. This is the status code for "Forbidden", because the secret provided does not match the one in the database.
  7. Switch to the Firestore/data page again. Click the edit icon next to the field named secret and enter a new field value: not-very. Click Update.
  8. Return to the Function details page and click Test the function with the same data as before.
  9. The Output should show 201. This is the status code for "Created", because function added new data to the database.
  10. Switch to the Firestore/data page again and refresh the page. Open the collection one. A sub-collection named runs should be visible on the right. Click the name runs. A list of documents in the sub-collection is shown. There should be one document with a randomly assigned name. Click that name. The document fields for moves, outcome, and questioner should display.

The function seems to be working as designed.

You will create an App Engine web app that users will interact with, and which will share a Firestore database with the cloud function above, and share a Pub/Sub topic with the questioners.

  1. Using the menu in the top left corner of the console, select App Engine from the Serverless section. You may have to wait a few minutes for the App Engine environment to be initialized.
  2. Switch to the Cloud Shell tab you opened at the start of this tutorial, and navigate to the manager app's folder:
cd ~/serverless-game-contest/manager/appengine
  1. You can examine the app code in the code editor in the Cloud Shell tab.

The application consists of the following files:

Prepare for deployment

We will take a look at these files soon, but first let's deploy the app, which can take a few minutes. Before deploying, we need to change one line in the code to have the URL of the manager Cloud Function created in Step 4:

  1. Navigate to a browser tab for Cloud Functions, and click on the manager function. In the page that opens, click on the Trigger heading and copy the function's URL displayed there.
  2. Now navigate to the Cloud Shell tab, which should also have the Code Editor open. Navigate to the main.py file in manager/appengine.
  3. Use the code editor to change the line
RESULT_URL = 'https://your-manager-function-url-goes-here'

by replacing the URL in quotes with the actual URL of the manager function you just copied.

  1. The file will save the change automatically within a few seconds.

You also need to change the line in requirements.txt from:

Flask=1.0.2

to

Flask>1.4.1

Deploy the app

Now deploy the edited app:

  1. Click on the command window in the Cloud Shell page and navigate to the App Engine folder:
cd ~/serverless-game-contest/manager/appengine
  1. Deploy the app with the following command:
gcloud app deploy
  1. You may be asked to choose a region. If so, select one near you.
  2. A summary of what is being deployed will be displayed, and you will be asked whether you want to continue. Type Y for yes and press the Enter key.
  3. The progress of uploading files and creating the app will take a few minutes.

Understand the code

While waiting for the app to finish deploying, examine the application's files in the App Engine folder. All of the app's code is in main.py. It is a Flask app. When it starts it creates a global app object:

app = Flask(__name__)

The actions to take in response to web requests are decorated with @app.route giving the request path and HTTP method. For example, the home page has request path /, and is fetched by a browser with the HTTP GET method. Its code is below:

@app.route('/', methods=['GET'])
def echo_recent_results():
   results = []

   rounds = firestore.Client().collection('rounds')
   for contest_round in rounds.order_by(
           'timestamp', direction=firestore.Query.DESCENDING
       ).stream():
       round = contest_round.to_dict()
       round['contest_round'] = contest_round.id
       round['runs'] = []

       for run in contest_round.reference.collection('runs').order_by('questioner').stream():
           round['runs'].append(run.to_dict())

       results.append(round)

   page = render_template('index.html', rounds=results)
   return page

When a GET request arrives for the home page, the Flask framework calls the echo_recent_results function. That function builds a data structure called results, representing the information in the Firestore database, and then returns the index.html template with data from results filled in.

The returned page is very bare bones in design, but it has the data from the contest rounds. It also includes a link to a page at /request-round, which is served by the following code:

@app.route('/request-round', methods=['GET'])
def round_form():
    nickname = get_nickname()
    if nickname is None:
        value = ''
        attributes = ''
    else:
        value = nickname
        attributes = "readonly disabled"

    return render_template(
        'round_form.html',
        value=value, attributes=attributes
    )

This code returns a web page with a form the user can fill out with their chosen nickname and the URL of the player they wrote. However, if the user is one that has already made a prior submission and given a nickname then, this code looks up the already set nickname with get_nickname() and fills it in the form for them. Finding and tracking the previously set nickname requires the app to be protected by Identity-Aware Proxy. How to do that, and how the code uses that environment to track nicknames, is described in the next step.

When the user submits this form, the browser sends the data via a POST request to the same URL, and it is handled by the start_round function. It begins by getting the data from the form:

@app.route('/request-round', methods=['POST'])
def start_round():
    player_url = request.form.get('player_url')
    if player_url is None:
        return 'Bad Request: missing player_url', 400

It then gets the user's nickname, from the previously saved value if there is one, otherwise from the submitted form. Notice that, even if the user manages to enter a different nickname than used before, the new one is ignored in favor of the earlier one:

    nickname = get_nickname()

    if nickname is None:  # Never seen this user, accept submitted nickname
        nickname = request.form.get('nickname')
        if nickname is not None:
            set_nickname(nickname)
        else:
            return 'Bad Request: missing nickname', 400

Again, tracking a user's previously selected nickname will be covered in the next step.

The system keeps tracks of each contest submission via a randomly assigned contest_round identifier. A universally unique identifier (UUID) will avoid accidental reuse of identifiers, and version 4 UUIDs are random, so can't be guessed by looking for patterns. The system wants to be sure that reported results for this round come only from questioners that it triggered, so a random secret is also created. This information is saved in the database so it can be checked when results are reported.

    contest_round = str(uuid.uuid4())
    secret = str(uuid.uuid4())
    timestamp = datetime.utcnow()

    firestore.Client().collection('rounds').add({
        'nickname': nickname,
        'secret': secret,
        'timestamp': timestamp,
        }, document_id=contest_round)

Finally, the code publishes a message to the topic that the questioners subscribe to, so that each of them will play a game against the newly submitted player:

    publisher = pubsub.PublisherClient()
    topic_name = 'projects/{project_id}/topics/{topic}'.format(
        project_id=os.getenv('GOOGLE_CLOUD_PROJECT'),
        topic='play-a-game'
    )
    payload = {
        'contest_round': contest_round,
        'player_url': player_url,
        'result_url': RESULT_URL,
        'secret': secret
    }
    publisher.publish(topic_name, json.dumps(payload).encode())

    return redirect('/', code=302)

Once the message has been published, the code sends the user back to the home page. That page may or may not show some or all of the contest results for the submission, depending on how fast they were developed and saved. In most cases the user would have to refresh the page a few times before every contest run for the submission is finished and displayed.

Run the app

Your app should be finished deploying by now. In the Cloud Shell command line, type the command gcloud app browse to see its URL, and open that page to try it out. You should see an empty page of results, be able to navigate to the form for submitting a new player, and after submitting your own player's URL, see the results show up, eventually, on the home page.

If something goes wrong: Open a new browser tab in the cloud console and navigate to Logging/Logs Explorer in the Operations section. You can look for error messages and tracebacks here, usually delayed up to a minute or two. You can debug your code by adding print statements that will show up in the logs, and redeploying the app.

You have a complete working system. But bad actors could impersonate others, submitting bad solutions under their nicknames. That's addressed in the next, and final, step.

Anyone can connect to the app deployed in step 4, above, and submit new players under a selected nickname. In fact, someone could use a nickname already selected by another player to make lower scores appear under that name. We would like to prevent contestants impersonating others, and would like to be able to find and block bad actors if needed.

There are many ways to authenticate users, even using third parties to verify their identity, that require somewhat complicated programming to implement. Google Cloud Identity-Aware Proxy (IAP) is an easy way to achieve the same end, requiring no change in an application to restrict access to it to only selected, authenticated users. Minimal coding is needed if the application needs to know those users' identities for some purpose. This project does access users' verified identities (a unique ID number) in order to prevent impersonation and, if needed, block bad actors.

Enable IAP

Enable IAP to restrict access to the App Engine app:

  1. Open the cloud console in a new tab, and select IAM & Admin/Identity-Aware Proxy from the menu on the left side of the page. You may have to click a button to enable this API, then another button to go to the IAP console page.
  2. Since IAP will authenticate users connecting to your app, it requires that you provide a consent screen to those users. The message Before you can use IAP, you need to configure your OAuth consent screen will display. Click CONFIGURE CONSENT SCREEN. Select the External user type so any user with a Google account can use the app.
  3. Fill in the required blanks on the OAuth consent screen form:
  1. Click Save.
  2. You'll be taken to a summary page. Use the menu on the left to return to IAM & Admin/Identity-Aware Proxy.
  3. Click HTTP RESOURCES if necessary and click the toggle button on the line for your App Engine app:

Return the tab for your app's home page (or open a new tab and enter the home page's URL) and refresh the screen. You will see a Sign in with Google prompt. Go ahead and sign in with any account.

Instead of your app's home page, you will see an error message:

You have successfully restricted access to your app. In fact, you have not specified any authorized users, so nobody, including you, can access it.

Authorize users

Return to the browser tab you used to enable Identity-Aware Proxy to add authorized users.

  1. Click the check box for the HTTPS App Engine app resource.
  2. The right side of the screen will show authorized users:
  3. Click ADD MEMBER.
  4. In the Add members to "App Engine app" form that opens, enter allAuthenticatedUsers as a new member.
  5. Select the role Cloud IAP/IAP-secured Web App User:
  6. Click SAVE.

Return the tab for your app's home page (or open a new tab and enter the home page's URL) and refresh the screen. You may see a Sign in with Google prompt, or you may already be signed in from your last attempt. Go ahead and sign in with any account if needed.

Refresh the page. You should see your app's home page. It may take a few minutes for the service to update. If you still see the error message, wait a minute and refresh the page.

Understand the app's behavior for authenticated users

Open your app's home page, logging in if prompted. You should see the home page. Click the link to Request a new contest round and enter your preferred nickname and the URL of your player. Click Request round. You will be redirected to the home page and you will see your most recent request at the top of the standings. You may need to refresh the page if the request is not yet showing.

Now click the link to Request a new contest round a second time. Notice that the nickname you used before is already filled in and editing of that field has been disabled. If you know how to use your browser's developer tools, go ahead and override the disabling, fill in a different nickname (and your player's URL) and click Request round.

You will be redirected to the home page and see your most recent request listed first. Note that the new nickname you entered has been ignored, and the first one you used after logging in is shown instead.

Recall that the handler to deliver the Request a new contest round form calls the get_nickname function and, if it returns a value, inserts that value in the form. And the handler that deals with that submitted form also calls get_nickname and uses the returned value, regardless of what is entered in the form, if it does not return None. Only when get_nickname returns None is the nickname field in the form actually used, in which case set_nickname is called to remember that value.

Both get_nickname and set_nickname rely on a unique ID that Identity-Aware Proxy provides for the logged in user to work. When IAP is used to protect a web app, it intercepts all requests to that app and adds three HTTP headers to the request:

This app saves the user ID, matched to the first nickname submitted with it, in a Firestore collection, and then uses it to look up the nickname on future requests. The user's email could have been used instead, but we avoid storing personal information such as email address if we don't have to.

The JWT assertion is a digitally signed object that contains the email and user ID in it. The benefit of using that assertion instead of one of the other headers would be to avoid spoofing if the architecture had some way for requests to arrive from an internal location, possibly bypassing IAP. The negative of using that assertion is the extra complexity involved in validating the signature. This example is not using the JWT assertion for that reason. If you are interested in looking into this further, there is a User Authentication with Identity-Aware Proxy codelab available with full details.

The set_nickname function checks for the existence of the IAP supplied header. If there is none, it does nothing. Otherwise it saves the nickname in a document that uses the IAP user ID value as the document ID. Note that if you call set_nickname multiple times, it will replace any earlier value in the database:

def set_nickname(nickname):
    user_id = request.headers.get('x-goog-authenticated-user-id')
    if user_id is None:
        return

    firestore.Client().collection('nicknames').add({
        'nickname': nickname
        }, document_id=user_id)

The get_nickname function checks to see if there is an IAP supplied header with a unique user ID. If not, it returns None. Otherwise, it looks up the nickname that saved using the user-ID as a document ID and returns that, if found:

def get_nickname():
    user_id = request.headers.get('x-goog-authenticated-user-id')
    if user_id is None:
        return None

    nicknames = firestore.Client().collection('nicknames')
    user = nicknames.document(user_id).get()

    if user.exists:
        return user.to_dict().get('nickname')
    else:
        return None