Sunday, February 24, 2013

RestTemplate with Google Places API

My website, CheckTheCrowd.com, was initially using the Google Maps JavaScript API (Places Library) to fetch details on the various places submitted to the website.

Place details such as names, addresses, and photos are normally displayed as content. Because these contents were dynamically loaded via JavaScript, they won't be visible to web crawlers and hence cannot be read as keywords.

In order for web crawlers to access the place details, they needed to be included as part of the HTML generated from by the Servlet. This meant that rather than fetch the place details from the browser via JavaScript, I needed to fetch them from the web server.

Place Details - Google Places API

Under the hood, the JavaScript Places Library calls a REST service to fetch the details of a particular place. I needed to call the same service from the web server in order to deliver the place details as part of the Servlet content.

The Place Details REST service is a GET call to the following resource:
https://maps.googleapis.com/maps/api/place/details/output?parameters
Where output can either be JSON (json) or XML (xml), the resource requires 3 parameters: the API key (key), the place identifier (reference), and the sensor flag (sensor).

For example:
https://maps.googleapis.com/maps/api/place/details/json?reference=12345
   &sensor=false&key=54321

RestTemplate - Spring Web

Starting with version 3.0, the Spring Web module comes with a class called RestTemplate. Similar to other Spring templates, RestTemplate reduces boiler-plate code that is normally involved with calling REST services.

RestTemplate supports common HTTP methods such as GET, POST, DELETE, PUT, etc. Objects passed to and returned from these methods are converted by HttpMessageConverters. Default converters are registered against the MIME type and custom converters are also supported.

RestTemplate and Place Details

RestTemplate exposes a method called getForObject to support GET method calls. It accepts a String representing the URL template, a Class for the return type, and a variable String array to populate the template.

I started my implementation by creating a class called GooglePlaces. I then declared the URL template as a constant and declared RestTemplate as an instance member injected by the Spring container. My Google Places API key was also declared as a member instance, this time populated by Spring from a properties file:
private static final String PLACE_DETAILS_URL = 
    "https://maps.googleapis.com/maps/api/place/details/json?reference" 
        + "={searchId}&sensor=false&key={key}";

Value("${api.key}")
private String apiKey;

@Inject
private RestTemplate restTemplate;
The above code should be enough to call the Place Details service and get the response as JSON string:
String json = restTemplate.getForObject(PLACE_DETAILS_URL, 
   String.class, "12345", apiKey);
However, the JSON response needs to be converted to a Java object to be of practical use.

By default, RestTemplate supports JSON to Java conversion via MappingJacksonHttpMessageConverter. All I need is to do is create Java objects which map to the Place Details JSON response.

Java Mapping

I referred to Place Details reference guide for a sample of the JSON response that I needed to map to Java. Because the Place Details response includes other information that I didn't need for CheckTheCrowd, I added annotations to my classes which tells the converter to ignore unmapped properties:
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PlaceDetailsResponse {
    @JsonProperty("result")
    private PlaceDetails result;

    public PlaceDetails getResult() {
        return result;
    }

    public void setResult(PlaceDetails result) {
        this.result = result;
    }
}
The above class represents the top-level response object. It is simply a container for the result property.

The below class represents the result:
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PlaceDetails {
    @JsonProperty("name")
    private String name;

    @JsonProperty("icon")
    private String icon;

    @JsonProperty("url")
    private String url;

    @JsonProperty("formatted_address")
    private String address;

    @JsonProperty("geometry")
    private PlaceGeometry geometry;

    @JsonProperty("photos")
    private List<PlacePhoto> photos = Collections.emptyList();

    // Getters and setters...
}
I also needed the longitude and latitude information as well as the photos. Below are the classes for the geometry and photo properties which contain these information:
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PlaceGeometry {
    @JsonProperty("location")
    private PlaceLocation location;

    public PlaceLocation getLocation() {
        return location;
    }

    public void setLocation(PlaceLocation location) {
        this.location = location;
    }
}

@JsonIgnoreProperties(ignoreUnknown = true)
public static class PlaceLocation {
    @JsonProperty("lat")
    private String lat;

    @JsonProperty("lng")
    private String lng;
    
    // Getters and setters
}
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PlacePhoto {
    @JsonProperty("photo_reference")
    private String reference;

    public String getReference() {
        return reference;
    }

    public void setReference(String reference) {
        this.reference = reference;
    }
}
With the above Java mappings, I can now expose a method which returns an instance of PlaceDetails given a place reference:
public PlaceDetails getPlaceDetails(String searchId) {
    PlaceDetailsResponse response 
        = restTemplate.getForObject(PLACE_DETAILS_URL, 
            PlaceDetailsResponse.class, searchId, apiKey);
    if (response.getResult() != null) {
        return response.getResult();
    } else {
        return null;
    }
}

Caching

The moment I deployed my changes to Tomcat, I noticed a significant latency between server requests. This was expected because the server now has to make several calls to the Place Details service before returning a response.

This is exactly a scenario where a good caching strategy would help. It is worth noting however that Google Maps API Terms of Service (10.1.3.b) has strict rules regarding caching. It states that caching should only be done to improve performance and that data can only be cached up to 30 calendar days.

CheckTheCrowd uses Guava which includes a pretty good API for in-memory caching. Using a CacheLoader, I can seamlessly integrate a Guava cache to my code:
private LoadingCache<String, PlaceDetails> placeDetails 
        = CacheBuilder.newBuilder().maximumSize(1000).expireAfterAccess(24, TimeUnit.HOURS)
            .build(new CacheLoader<String, PlaceDetails>() {   
                public PlaceDetails load(String searchId) throws Exception {
                    PlaceDetailsResponse response = restTemplate.getForObject(PLACE_DETAILS_URL, 
                        PlaceDetailsResponse.class, searchId, apiKey);
                    if (response.getResult() != null) {
                        return response.getResult();
                    } else {
                        throw new PlacesException("Unable to find details for reference: " + searchId);
                    }
                }
            });
I set a cache size of 1000 and an expiry of 24 hours. The call to the Place Details service was then moved to the CacheLoader's load method. After which, I updated my method to refer to the cache instead:
public PlaceDetails getPlaceDetails(String searchId) {
    try {
        return placeDetails.get(searchId);
    } catch (ExecutionException e) {
        logger.warn("An exception occurred while "
            + "fetching place details!", e.getCause());
        return null;
    }
}
Unfortunately, I wasn't able to measure the exact latency prior to applying the cache. I was however, very pleased with the noticeable improvement I got after applying the cache.

The complete source is available from Google Code under Apache License 2.0.

1 comment :

  1. I realized that the load method of LoadingCache should not return null.

    I updated the code to return an exception instead.

    ReplyDelete