API Gateway is one of several platforms on Google Cloud for building and hosting REST APIs. It provides common API Gateway features such as request validation, API key management,and usage reporting along with support for integrations with a variety of forms of backend infrastructure for processing API requests.
Previously, rendering of the Guestbook was done in Flask with Jinja templates returning HTML and CSS back to the browser upon each update. Modern, client-side rendering approaches, however, perform the rendering task on the front-end browser, leaving only the Model function to be implemented by the backend server. When split into this architecture, after the browser downloads the initial web content, the backend is then only accessed via a REST API to update Model state. Serverless functions are often used to implement REST APIs. As our Guestbook application is quite minimal, having only two functions, it can easily be adapted to this style of architecture.
To demonstrate this, we will now take our guestbook application and break it into a frontend and a backend and then create an API for directly accessing the Guestbook model backend. Our backend REST API will be deployed on GCP's API Gateway with an integration with Cloud Functions to perform the backend operations themselves. Note that because our Guestbook is a public API, so we will be skipping the use of API keys and allowing arbitrary cross-site requests for it.
To begin with, visit the code in Cloud Shell
cd cs430-src/06_gcp_restapi_cloudfunctions
The gbmodel
code is the same.
# Update YOUR_PROJECT_ID
class model(Model):
def __init__(self):
self.client = datastore.Client('YOUR_PROJECT_ID')
The main code change in this version is the removal of the server-side rendering code that generates HTML for the site. This has been replaced with a single file (main.py
) that Cloud Function looks for to implement our two endpoints. The first endpoint is named entries
and supports code for handling GET requests as shown below. It calls our Model's select()
function to retrieve all of the entries in the Guestbook model. In previous instantiations, the web application took the results returned from the model (as a list of lists) and then rendered them in HTML using Jinja2. For the REST API, rather than return HTML, we instead need to return an object in Javascript Object Notation (JSON). The code takes the list of lists and creates a list of dictionaries (entries
) that is then converted into JSON (json.dumps
) and sent back to the client (make_response
) with an appropriate response type (application/json
).
from flask import make_response, abort
import gbmodel
import json
def entries(request):
""" Guestbook API endpoint
:param request: flask.Request object
:return: flask.Response object (in JSON), HTTP status code
"""
model = gbmodel.get_model()
if request.method == 'GET':
entries = [dict(name=row[0], email=row[1], date=str(row[2]), message=row[3] )
for row in model.select()]
response = make_response(json.dumps(entries))
response.headers['Content-Type'] = 'application/json'
return response, 200
The entries
function also contains code to handle HTTP OPTIONS request methods.
if request.method == 'OPTIONS':
return handle_cors('GET'), 200
This is to support CORS (cross-origin resource sharing). CORS allows us (as a REST API provider) to restrict which web sites can access our APIs from Javascript. If your Guestbook front-end is hosted by foo.com
, the web browser will query our REST API endpoint with a "pre-flight" OPTIONS request to check to see if Javascript code from foo.com
is able to access the endpoint. The code for handling the CORS request is below. As the code shows, it simply sets the HTTP response headers to allow all origins '*'
to perform the specified method request (in this case, GET
). We use the wildcard since we don't know (in this codelab) where the frontend will be served from. If the front-end were served from foo.com
, then we could specify 'https://foo.com
' for the 'Access-Control-Allow-Origin
' header value.
def handle_cors(method):
response = make_response()
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = method
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
response.headers['Access-Control-Max-Age'] = '3600'
return response
The other endpoint implemented within main.py
is named entry
. As shown below, the endpoint only supports HTTP POST requests and checks to ensure that a JSON object specifying a Guestbook entry is included in the POST. It then gets the dictionary representation of the JSON object (request.get_json()
), validating that all parts of the entry are included (via the all()
), before calling the Model's insert()
method to insert the entry into the Guestbook. As with the entries
endpoint, it then returns the entire Guestbook back to the client so the UI can be updated with the new content.
def entry(request):
""" Guestbook API endpoint
:param request: flask.Request object
:return: flask.Response object (in JSON), HTTP status code
"""
model = gbmodel.get_model()
if request.method == 'POST' and request.headers['content-type'] == 'application/json':
request_json = request.get_json(silent=True)
if all(key in request_json for key in ('name', 'email', 'message')):
model.insert(request_json['name'], request_json['email'], request_json['message'])
else:
raise ValueError("JSON missing name, email, or message property")
Note that, similar to the prior CORS support in the entries
endpoint, we include similar code to handle the HTTP OPTIONS requests on this one.
We will now deploy our Cloud Functions. Go back to Cloud Shell and the source directory containing main.py
. The command below deploys the function that has been implemented specifying a Python 3.7 environment. We'll be invoking these Cloud Functions via HTTP requests so specify an HTTP trigger for them. In addition, our two functions only require access to Cloud Datastore so we will deploy both using the service account created previously in order to practice least-privileges.
gcloud functions deploy entries \ --runtime python311 \ --trigger-http \ --service-account guestbook@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com gcloud functions deploy entry \ --runtime python311 \ --trigger-http \ --service-account guestbook@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com
As we only want invocations of our Cloud Functions to come from API Gateway, disallow unauthenticated access to these functions when prompted. Then, wait for each function to finish deploying. Go to the Cloud Functions console to view the functions.
Then, use the commands below to show the functions' settings along with the URL to access each one (httpsTrigger
).
gcloud functions describe entries gcloud functions describe entry
In Cloud Shell, use curl
to access the entries
HTTP end-point trigger.
curl https://...cloudfunctions.net/entries
The command should yield an error since the function disallows unauthenticated access.
We can send an authenticated request by attaching an identity token for ourselves from Google Shell. To do so, fill in the URL and attach an identity token for the Cloud Shell user (e.g. the project owner by invoking gcloud auth print-identity-token
and sending it along with the authentication header for the request).
curl https://...cloudfunctions.net/entries -H "Authorization: Bearer $(gcloud auth print-identity-token)"
The request will result in the entire contents of the Guestbook being returned to you as a JSON object by the function. Ensure that this is the case before continuing.
API Gateways are used to manage collections of APIs that an organization might provide. We will now deploy a gateway which will integrate with our backend Cloud Functions to implement the Guestbook API.
To begin with, enable the required services:
gcloud services enable apigateway.googleapis.com
gcloud services enable servicemanagement.googleapis.com
gcloud services enable servicecontrol.googleapis.com
Then create the API.
gcloud api-gateway apis create gbapi --project=$GOOGLE_CLOUD_PROJECT
OpenAPI specifications are a standard way of specifying what each API accepts as parameters and what each delivers as a response. One can think of this as a typing system to ensure the client and server are sending data as expected to each other. A template specification for the Guestbook API is included in the repository. It contains everything required for specifying the API except for the URL for the backend integrations to Cloud Functions. Specifically, the snippet below is for the /entries
end-point. It specifies the methods supported by each API path and the expected responses. In this case, there is a GET method for obtaining the entries and an OPTIONS method for handling any CORS preflight requests. For the /entries
path, we must fill in the backend URL that will service API requests to the path.
Edit the file and fill in the HTTP trigger for the Cloud Function entries
in the backend URL for both methods.
swagger: '2.0'
info:
title: Guestbook API
description: Guestbook API on API Gateway with a Google Cloud Functions backend
version: 1.0.0
schemes:
- https
produces:
- application/json
paths:
/entries:
get:
summary: Grab all entries
operationId: entries
x-google-backend:
address: https://.../entries
responses:
'200':
description: A successful response
schema:
type: string
options:
operationId: corsentries
x-google-backend:
address: https://.../entries
responses:
'200':
description: Allow CORS
The rest of the OpenAPI specification is shown below for the specification of the /entry
endpoint. It includes the format of the expected JSON object for submitting a Guestbook entry and the handling of the OPTIONS method for supporting the CORS preflight request. We only need to provide the backend URL for handling the API requests. Go back to the Cloud Function entry
and get its HTTP trigger.
Edit the file again and fill in the HTTP trigger for the Cloud Function entry
in the backend URL for both methods.
/entry:
post:
summary: Add an entry
operationId: entry
x-google-backend:
address: https://.../entry
consumes:
- application/json
parameters:
- in: body
name: entry
description: Add entry to guestbook
schema:
type: object
properties:
name:
type: string
email:
type: string
message:
type: string
responses:
'200':
description: A successful response
schema:
type: string
options:
operationId: corsentry
x-google-backend:
address: https://.../entry
responses:
'200':
description: Allow CORS
As with any cloud resource, a service account with a set of permissions needs to be associated with any API we create. We have defined a Cloud Function that only allows authenticated access to it and our access to the Cloud Function via curl
in Cloud Shell was only possible by attaching the identity token of the project owner.
In this step, we will set up the permissions to allow API Gateway to authenticate its requests to the Cloud Function backend. First, create a service account.
gcloud iam service-accounts create gbapisa
Then, attach the permissions to allow the service account to invoke Cloud Functions.
gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
--member serviceAccount:gbapisa@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com \
--role roles/cloudfunctions.invoker
With our OpenAPI specification and our properly configured service account, we can create an API Gateway configuration for our API using the OpenAPI specification and attaching the service account we wish to use to access the backend Cloud Function.
gcloud api-gateway api-configs create gbapiconfig \
--api=gbapi --openapi-spec=openapi.yaml \
--project=${GOOGLE_CLOUD_PROJECT} --backend-auth-service-account=gbapisa@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com
From this configuration, we can then instantiate the gateway itself, specifying the API we created previously and the configuration.
gcloud api-gateway gateways create gbapigw \
--api=gbapi --api-config=gbapiconfig \
--location=us-central1 --project=${GOOGLE_CLOUD_PROJECT}
When the gateway finishes deploying, find the DNS name of it. One can also list the hostname via the command below.
gcloud api-gateway gateways describe gbapigw --location=us-central1
We will be using this hostname to access our API endpoints from web clients by appending the paths of /entries
and /entry
as defined in the OpenAPI specification.
REST APIs can be programmatically accessed via any popular language. As we have been using Python for the class, we can do so using its interpreter. On your Ubuntu VM or in Cloud Shell, activate a virtual environment with Python requests installed and then launch the Python interpreter:
python3
Within the interpreter, import the requests package and access the REST API's entries
endpoint, saving the response.
import requests
resp = requests.get('https://.../entries')
Using the interpreter and the resp
object, use Python's print()
function to show the following for the response:
resp.status_code
)resp.headers
)resp.text
)resp.json()
)Then, assign the response JSON to a variable and use the Python interpreter to write a loop that prints the name
, email
, date
, and message
values of the first Guestbook entry returned.
Refer back to this sequence as needed when integrating a REST API into applications you write.
We can also use Python to submit a new Guestbook entry via the REST API. Within the same interpreter session, import the JSON package and create a dictionary containing a Guestbook entry with your name
, email
, and message
of "Hello Cloud Functions from Python Requests!". Note that, in Python, you can create a dictionary using syntax similar to JSON. For example, the snippet my_dict = {'foo':'bar'}
creates a dictionary with a single entry with key 'foo
' and value 'bar
'.
import json
mydict = {
'name' : 'Wu-chang',
'email' : 'wuchang@pdx.edu',
...
}
Then, submit a POST request to the API's entry
endpoint, passing the dictionary containing the Guestbook entry into the json
keyword parameter to the post()
method. The request
package will convert the dictionary into the JSON format as part of the request.
resp = requests.post('https://.../entry', json=mydict)
Print the response status, the response headers, and the response text that indicates a successful insertion within the Python interpreter as before.
With a single page application (SPA), you download the entire site as a static bundle and then, just like an application, interact with it seamlessly. As the application needs to send and retrieve data to the backend server, it does so asynchronously using Javascript and HTTP requests to and from the APIs it is programmed to access. There are many examples of single-page applications such as GMail and Google Docs.
This version of the Guestbook is implemented in a similar manner. Since we designed both the AWS and GCP versions to supply an identical REST API interface, the code for our local client-side version is the same for both. This recalls the exact approach we took for our Model abstract class in previous implementations. With a standard interface to interface with the backend model, our controller and presenter code is able to work with any new models without modification. In this case, one can consider the REST API specification as our new model interface as it standardizes our HTTP queries so that our front-end code does not have to change if we shift backends between AWS and GCP.
On your Ubuntu VM that your are able to run a browser on, visit the source directory containing the application.
cd cs430-src/06_gcp_restapi_cloudfunctions/frontend-src
View index.html
which contains the application. As the file shows, it is similar to prior versions with two exceptions. The first exception is that it now includes a Javascript file
<script src="./static/guestbook.js"></script>
This file will execute code when we submit a new entry and update our page with the response. By operating on the DOM directly, it will allow us to view the results of our submission immediately without reloading the page.
The base page also implements the form used to sign the Guestbook. As the code below shows, each input field is labeled (name
, email
, message
) so that it can be accessed by our Javascript code . In addition, when the "Sign
" button is clicked, the sign()
function that is defined in guestbook.js
will be called.
<h2>Guestbook</h2>
<div>
<div>
<label for="name">Name: </label>
<input id='name' type='text' name='name'>
</div>
<div>
<label for="email">Email: </label>
<input id='email' type='text' name='email'>
</div>
<div>
<label for="message">Message: </label><br>
<textarea id="message" rows=5 cols=50 name="message"></textarea>
</div>
<button onclick="sign()">Sign</button>
</div>
Finally, at the bottom of the page is the definition of a element named "
entries
". This element will be used by our Javascript code to automatically update what is rendered for the based on the entries returned by the backend.
<h2>Entries</h2>
<div id="entries"></div>
The main file which implements our application is located at static/guestbook.js
. In the file, we define a baseApiUrl
for the REST API. This URL will support two endpoints: the entries
endpoint supporting a GET request to obtain all of the entries in the Guestbook in JSON and the entry
endpoint supporting a POST request to add an entry to the Guestbook using JSON. Begin by changing this URL to point to the base URL returned by the API Gateway deployment (e.g. https://...gateway.dev/
). Include the ending '/
'.
const baseApiUrl = "<FMI>";
There are three main functions in this file: getEntries
, sign
, and viewEntries
. The code for getEntries
is shown below. It simply uses the browser's fetch()
interface to access the entries
endpoint asynchronously. It then parses the returned string into the gbentries
array, before calling viewEntries
. viewEntries
will then update the page's DOM elements directly with the data obtained.
const getEntries = async () => {
const response = await fetch(baseApiUrl + "entries", {
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
method: "GET"
});
const gbentries = await response.json();
viewEntries(gbentries);
};
The sign
function collects form values via their label names and formats a JSON object with them. It then issues a POST request using the browser's fetch()
interface to the entry
endpoint asynchronously. The response to the request contains all of the entries in the Guestbook. As with the prior getEntries
, once the JSON response is parsed, viewEntries
is called to update the application.
const sign = async () => {
const name = document.getElementById("name").value;
const email = document.getElementById("email").value;
const message = document.getElementById("message").value;
const response = await fetch(baseApiUrl + "entry", {
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify({ name: name, email: email, message: message })
});
const gbentries = await response.json();
viewEntries(gbentries);
};
Both getEntries
and sign
, call viewEntries
to update the UI with the gbentries
returned by the API calls. As the code below shows, the viewEntries
first clears out the DOM of previous guestbook entries. It then performs a map of a function across all of the gbentries
it has been passed. The function mapped, appends the DOM elements that construct a single entry in the guestbook to the node.
const viewEntries = entries => {
const entriesNode = document.getElementById("entries");
while (entriesNode.firstChild) {
entriesNode.firstChild.remove();
}
entries.map(entry => {
const nameAndEmail = document.createTextNode(
entry.name + " <" + entry.email + ">"
);
const signedOn = document.createTextNode("signed on " + entry.date);
const message = document.createTextNode(entry.message);
const br = document.createElement("br");
const br2 = document.createElement("br");
const p = document.createElement("p");
p.classList.add("entry");
p.appendChild(nameAndEmail);
p.appendChild(br);
p.appendChild(signedOn);
p.appendChild(br2);
p.appendChild(message);
entriesNode.appendChild(p);
});
};
Ensure that you have modified the static/guestbook.js
file with the base URL of your endpoints. Bring up a web browser window with the Developer Tools open.
Then go to File=>Open File to open a local HTML file. Navigate to the directory containing the index.html
file and view it. Click on the "Network" tab in Developer Tools.
Then, enter a message using your name, PSU e-mail address, and the message "Hello API Gateway from local SPA!".
Storage buckets are often used to serve static web sites. When configured as multi-region buckets, the content within them can be automatically forward deployed to locations near to where clients are requesting the content from. Distributing our client application can be done via Cloud Shell. To begin with, bring up Cloud Shell and change directories to the frontend client source code.
cd cs430-src/06_gcp_restapi_cloudfunctions/frontend-src
Begin by changing the baseApiUrl
to point to the API Gateway endpoint (e.g. https://...gateway.dev/
)
const baseApiUrl = "<FMI>";
Then, create a bucket with your name formatted as below:
gsutil mb gs://gbapi-<OdinId>
(e.g. gs://gbapi-wuchang
)
Since this will be a publicly accessible web site, we will assign an access policy allowing all users to access its content via IAM. In this case, the special identifier allUsers
specifies everyone and objectViewer
assigns read-access permissions.
gsutil iam ch allUsers:objectViewer gs://gbapi-<OdinId>
Finally, copy the entire contents of the directory over to the bucket.
gsutil cp -r . gs://gbapi-<OdinId>
Storage buckets by default are web accessible via the following URL
https://storage.googleapis.com/<BucketName>
Bring up a web browser with Developer Tools up and visit the index.html
file in this bucket:
https://storage.googleapis.com/<BucketName>/index.html
As with the prior version, both a preflight request and a fetch request to the API endpoint are successful, allowing the application to load.
Enter a message using your name, PSU e-mail address, and the message "Hello API Gateway from SPA in GCS!". Scroll down to find the message posted.
Congratulations. You have deployed a serverless API supporting a single-page application.
To clean up, delete the storage bucket, the API Gateway, and the Cloud Functions either via the web console UI or from Cloud Shell via the CLI.
gsutil rm -r gs://gbapi-<OdinId> gcloud api-gateway gateways delete gbapigw --location us-central1 gcloud api-gateway api-configs delete gbapiconfig --api gbapi gcloud api-gateway apis delete gbapi gcloud iam service-accounts delete gbapisa@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com gcloud functions delete entries gcloud functions delete entry