Sunday, July 21, 2019

API Development with GCP


Summary

This post covers my use of Google Cloud Platform (GCP) to build a scalable piece of middleware, theoretically, infinitely scalable.  The notional use case here is a store locator microservice for a company that has thousands of locations.  The service will provide the closest store location for a given ZIP code or GPS coordinates.  The location will be returned as a URL representing the directions on Google Maps.

Overall Architecture

For this particular application, I utilized GCP-based services for the entirety of the core app.  I additionally leveraged the services of Auth0 for authentication services.  The API is realized in a REST structure - two GET-based services.

GCP Architecture

I used Google App Engine Flex (GAE) for the application core.  I provided front-end API support with Cloud Endpoints.  Caching of store and  ZIP coordinates utilizes Google's cloud implementation of Redis - Cloud Memorystore.  A combination of Cloud Functions and Cloud Storage provides the ability for real-time updating of the cache with simple configuration file modifications.

API Proxy + Authentication Layer

All requests into the webserver written in node.js on GAE Flexible are proxied by Cloud Endpoints.  Endpoints provides redirection to HTTPS and authentication proxying, among other things.  I chose utilize the OAuth services provided by Auth0.  They in turn proxy the JWT-based (RSA256) authentication that provides security for the API.



Configuration of the Cloud Endpoints is accomplished via an OpenAPI ver 2.0 YAML file.

swagger: "2.0"
info:
  title: "Store Locator"
  description: "Generate a Google maps directions URL to the nearest 
  store based on user's current ZIP code or coordinates"
  version: "1.0.0"
host: "youraccount.appspot.com"
consumes:
- "text/plain"
produces:
- "text/uri-list"
schemes:
  - "https"
securityDefinitions:
  auth0_jwk:
    authorizationUrl: ""
    flow: "implicit"
    type: "oauth2"
    x-google-issuer: "https://yourcaccount.auth0.com/"
    x-google-jwks_uri: "https://youraccount.auth0.com/.well-known/jwks.json"
    x-google-audiences: "https://locatorUser1"
security:
  - auth0_jwk: []
paths:
  /locator/zip:
    get:
      summary: "Find nearest store by ZIP code"
      operationId: "ZIP"
      description: "ZIP code"
      parameters:
        -
          name: zip
          in: query
          required: true
          type: string
      responses:
        200:
          description: "Google Maps URL with directions from input ZIP to nearest store"
          schema: 
            type: string
        404:
          description: "Error Message"
          schema:
            type: string
  /locator/coordinates:
    get:
      summary: "Find nearest store by coordinates (latitude, longitude)"
      operationId: "Coordinates"
      description: "Latitude, Longitude"
      parameters:
        -
          name: coordinates
          in: query
          required: true
          type: string
      responses:
        200:
          description: "Google Maps URL with directions from input coordinates to nearest store"
          schema: 
            type: string
        404:
          description: "Error Message"
          schema:
            type: string


Application Layer

The heart of this is a node.js application that realizes two HTTP GET paths in Express.  The paths allow for a search of the closest store based on the user's current ZIP code or GPS coordinates (latitude + longitude).  Cloud Memorystore keeps an updated set of coordinates for the ZIP codes and store locations.

Code Snippet - Get Closest Store by Coordinates

I use a filter based on the haversine formula to narrow down the list of closest store candidates.  That formula finds 'as the crow flies' distances.  Once those candidates have been found, I then send them into a Google Maps API service (Distance Matrix) for actual driving distances.  Ultimately, the API call returns a URL that corresponds to the actual driving directions between the origin and store coordinates.

/**
 * Performs the Haversine formula to generate the great circle distance between two coordinates.
 * @param {object} coord1 - latitude & longitude
 * @param {object} coord2 - latitude & longitude
 * @return {int} - great circle distance between the two coordinates
 */
function haversine(coord1, coord2) {
 let lat1 = coord1.lat;
 let lon1 = coord1.long;
 let lat2 = coord2.lat;
 let lon2 = coord2.long;
 const R = 3961;  //miles
    const degRad = Math.PI/180;
    const dLat = (lat2-lat1)*degRad;
    const dLon = (lon2-lon1)*degRad;
 
 lat1 *= degRad;
    lat2 *= degRad;
    const a = Math.sin(dLat/2) * Math.sin(dLat/2) + 
        Math.sin(dLon/2) * Math.sin(dLon/2) * Math.cos(lat1) * Math.cos(lat2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
    return R * c;
}

/**
 * Fetches a configurable number of stores that are closest to a given coordinate.
 * @param {object} origin - latitude & longitude
 * @param {int} numVals - number of closest stores to return
 * @return {array} - array of the numVal closest stores
 */
function getStoresByCoord(origin, numVals) {
 console.log(`getStoresByCoord(JSON.stringify(origin), numVals)`);
 let distances = [];
 
 if (numVals > storeList.length) numVals = storeList.length;

 //performs a haversine dist calc between the origin and each of the stores
 for (let i=0; i < storeList.length; i++) {
  const dist = haversine(origin, {'lat' : storeList[i].lat, 'long' : storeList[i].long});
  const val = {'index': i, 'distance': dist};
  distances.push(val);
 }

 let stores = [];
 distances.sort(compareDist);
 for (let i = 0; i < numVals; i++) {
  stores.push(storeList[distances[i].index]);
 }
 
 return stores;
}

/**
 * Fetches the closest store location based on the user's lat/long
 * Performs an initial filtering based ZIP code only, then refines a configurable number of closest
 * stores using actual driving distance from the Google Maps Distance Matrix
 * API.
 * @param {string} origin - lat/long of origin location
 * @param {array of strings} stores - lat/long(s) of store locations
 * @return {string} - URL of Google map with nearest store
 */
function getClosestStore(origin, stores) {
 console.log(`getClosestStore(JSON.stringify(origin))`);
 
 let dests = [];
 for (const store of stores) {
  dests.push({lat: store.lat, lng: store.long});
 }
 return mapsClient.distanceMatrix({
  'origins': [{lat: origin.lat, lng: origin.long}],
  'destinations': dests
 })
 .asPromise()
 .then((response) => {
  if (response.status == 200 && response.json.status == 'OK') {
   let minIndex;
   let minDist;
   const elements = response.json.rows[0].elements
   for (let i=0; i < elements.length; i++) {
    if (minDist == null || elements[i].distance.value < minDist) {
     minIndex = i;
     minDist = elements[i].distance.value;
    }
   }
   return stores[minIndex];
  }
  else {
   throw new Error('invalid return status on Google distanceMatrix API call');
  }
 })
 .catch(err => {
  console.error(`getClosestStore(): ${JSON.stringify(err)}`);
  throw err;
 });
}

/**
 * Creates a google maps url with the directions from an origin to a store location.
 * @param {object} origin - latitude & longitude
 * @param {object} store - object containing store address info, to include latitude & longitude
 * @return {string} - Google Maps URL showing directions from origin to store location
 */
function getDirections(origin, store) {
 return MAPSURL + `&origin=origin.lat, origin.long` + `&destination=store.lat, store.long`;
}

/**
 * Fetches the closest store location based on the user's lat/long.
 * @param {string} coordinates - lat/long of user's current location
 * @return {string} - URL of Google map with nearest store
 */
app.get('/locator/coordinates', (request, response) => {
 const vals = request.query.coordinates.split(',');
 const origin = {'lat' : vals[0], 'long' : vals[1]};
 const stores = getStoresByCoord(origin, 3);
 getClosestStore(origin, stores)
 .then(store => {
  const url = getDirections(origin, store);
  response.status(200).send(url);
 })
 .catch(err => {
  response.status(404).send(err.message);
 });
});

Caching Layer

Google Cloud Memorystore is a cloud implementation of Redis.  I use this service to provide a real-time updatable set of ZIP code and store location coordinates.

Configuration + Persistence Layer

This app is able to get to real-time updates on stores and ZIP codes simply by reloading files into Google Cloud Storage.  This allows move/add/deletes of stores without any modification of code or restarts of the application.  I created a Cloud Function that is triggered on modification of either the stores or ZIP code files.  After being triggered, the Cloud Function updates the Memorystore cache with the latest information.

Code Snippet - Cloud Function (gcsMonitor.js)


/**
* Public function for reading the Store location file from Google Cloud Storage
* and loading into Google CloudMemory(redis).  
* Will propagate exceptions.
*/
function loadStoreCache() {
 console.log(`loadStoreCache() executing`);
 const bucket = storage.bucket(gcpBucket);
 const stream = bucket.file(gcpStoreFile).createReadStream();
 const client = redis.createClient(REDISPORT, REDISHOST);
 client.on("error", function (err) {
  console.log("loadStoreCache() Redis error:" + err);
 });

 csv()
 .fromStream(stream)
 .subscribe((json) => {
  let hashKey;
  for (let [key, value] of Object.entries(json)) {
   if (key === 'storeNum') {
    hashKey = 'store:' + value;
   }
   else {
    console.log(`loadStoreCache() inserting hashKey`);
    client.hset(hashKey, key, value, (err, reply) => {
     if (err) {
      console.error(`loadStoreCache() Error: ${err}`);
     }
     
    });
   }
  }
 })
 .on('done', (err) => {
  client.quit();
  console.log(`loadStoreCache() complete`);
 });
};

Test Client

As discussed, the API uses Auth0 (proxied by Endpoints) for authentication.  The code below fetches a JWT token from Auth0 and then performs an API call with that token in the header.
function fetchToken() {

    const body = {
        'client_id': clientId,
        'client_secret': clientSecret,
        'audience': audience,
        'grant_type': 'client_credentials'
    };

    return fetch(tokenUrl, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(body)
    })
    .then(response => {
        if (response.ok) {
            return response.json();
        }
        else {
            console.error('fetchToken Error: ' + response.status);
        }
    }) 
    .then(json => {
        return json.access_token;
    })   
}

const apiUrl = 'https://yourapp.appspot.com/locator/coordinates/?coordinates=37.1464,-94.4630'
function authTest() {
    return fetchToken()
    .then(token => {
        fetch(apiUrl, {
            method: 'GET',
            headers: {
                'Authorization': 'Bearer ' + token
            }
        })
        .then(response => {
            if (response.ok) {
                return response.text();
            }
            else {
                console.error(response.status);
            }
        })
        .then(text => {
            console.log('Response: ' + text);
        })
    });
}

authTest();


Results

$ node testClient.js
Response: https://www.google.com/maps/dir/?api=1&origin=37.1464, -94.4630&destination=37.0885389, -94.5144897

Source


Copyright ©1993-2024 Joey E Whelan, All rights reserved.