package jeffaschenk.commons.system.external.geocoding; import jeffaschenk.commons.container.security.constants.SecurityConstants; import jeffaschenk.commons.touchpoint.model.serviceprovider.GeoLocation; import jeffaschenk.commons.touchpoint.model.serviceprovider.GeoLocationCoordinates; import jeffaschenk.commons.types.GeoCodingServiceProviderResponseStatus; import jeffaschenk.commons.util.StringUtils; import net.iharder.Base64; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.http.MediaType; import org.springframework.http.converter.json.MappingJacksonHttpMessageConverter; import org.springframework.stereotype.Service; import org.springframework.web.client.RestOperations; import org.springframework.web.client.RestTemplate; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.io.IOException; import java.math.BigDecimal; import java.net.URISyntaxException; import java.net.URL; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Calendar; /** * * Provides Common GeoCoding Service Provider Implementation using the Google Maps API * for performing location lookup and validation. * <p/> * Online API Documentation: http://code.google.com/apis/maps/documentation/geocoding/ * * @author jeffaschenk@gmail.com */ @Service("geoCodingServiceProvider") public class GeoCodingServiceProviderImpl implements GeoCodingServiceProvider, ApplicationContextAware { /** * Global Constants */ private static final String ADDRESS = "address="; private static final String LATLNG = "latlng="; /** * Geocoding GeoLocation Provider Injected Properties */ @Value("#{systemEnvironmentProperties['geocoding.enabled']}") private String geoCodingEnabledStringValue; private boolean geoCodingEnabled; @Value("#{systemEnvironmentProperties['geocoding.service.providerUrl']}") private String geoCodingServiceProviderUrl; @Value("#{systemEnvironmentProperties['geocoding.service.provider.clientId']}") private String geoCodingServiceProviderClientId; @Value("#{systemEnvironmentProperties['geocoding.service.provider.clientSignature']}") private String geoCodingServiceProviderClientSignature; private byte[] binaryClientSignature; @Value("#{systemEnvironmentProperties['geocoding.service.provider.allowed.requests.per.day']}") private Long geoCodingServiceProviderAllowedRequestsPerDay; @Value("#{systemEnvironmentProperties['geocoding.service.provider.allowed.requests.per.second']}") private Long geoCodingServiceProviderAllowedRequestsPerSecond; @Value("#{systemEnvironmentProperties['geocoding.service.provider.throttle.seconds.wait.per.request']}") private Long geoCodingServiceProviderThrottleSecondsWaitPerRequest; // // TODO - Have Geo Coding service Provider use Memcached as Internal cache for Queries. // /** * Explicitly Set */ private static final String geoCodingServiceProviderOutput = "json"; // Using JSON as opposed to XML. private static final String geoCodingSensorForServer = "false"; // Our Server has no notion of GPS, Why Not? /** * Logging */ private final static Log log = LogFactory.getLog(GeoCodingServiceProviderImpl.class); /** * Initialization Indicator. */ private boolean initialized = false; /** * Statistically Counters and other Variables for * performing throttling of request to this service * provider. */ private Calendar currentTime = Calendar.getInstance(); private long totalNumberOfRequestsPerformed = 0; private long dailyNumberOfRequestsPerformed = 0; private long totalNumberOfRequestsBlocked = 0; private long dailyNumberOfRequestsBlocked = 0; private long numberOfRequestsPerSecond = 0; private long maximumNumberOfRequestsPerSecondReached = 0; private long whenMaximumNumberOfRequestsPerSecondReached = -1; private long lastRequestBlocked = -1; private long lastRequestPerformed = -1; private int dayInterval = 0; /** * Spring Application Context, * used to obtain access to Resources on * Classpath. */ private ApplicationContext applicationContext; public void setApplicationContext(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } /** * Initialize the Content Management System Interface */ @PostConstruct public void initialize() { this.geoCodingEnabled = StringUtils.toBoolean(this.geoCodingEnabledStringValue, false); if (this.geoCodingEnabled) { log.info("Activating GeoCoding Service Provider Interface - Google Maps Rest Services Implementation."); log.info("Configured Allowed Requests Per Day:[" + ((this.geoCodingServiceProviderAllowedRequestsPerDay != null) && (this.geoCodingServiceProviderAllowedRequestsPerDay > 0) ? this.geoCodingServiceProviderAllowedRequestsPerDay : "OFF") + "]"); log.info("Configured Allowed Requests Per Second:[" + ((this.geoCodingServiceProviderAllowedRequestsPerSecond != null) && (this.geoCodingServiceProviderAllowedRequestsPerSecond > 0) ? this.geoCodingServiceProviderAllowedRequestsPerSecond : "OFF") + "]"); log.info("Configured Throttle Wait:[" + ((this.geoCodingServiceProviderThrottleSecondsWaitPerRequest != null) && (this.geoCodingServiceProviderThrottleSecondsWaitPerRequest > 0) ? this.geoCodingServiceProviderThrottleSecondsWaitPerRequest + " seconds" : "OFF") + "]"); // Determine if I have a Signature or Not. if (StringUtils.isNotEmpty(this.geoCodingServiceProviderClientSignature)) { // Convert the key from 'web safe' base 64 to binary String keyString = this.geoCodingServiceProviderClientSignature.replace('-', '+'); keyString = keyString.replace('_', '/'); try { this.binaryClientSignature = Base64.decode(keyString); } catch (IOException ioe) { log.error("Base64 decoding Exception occurred for Client Signature, removing Client Information so GMap Requests will not be Signed!"); this.geoCodingServiceProviderClientSignature = null; this.geoCodingServiceProviderClientId = null; } } else { log.warn("No Client Signature Supplied, GMap Requests will not be Signed!"); } // *********************************************** // Now Prepare Internal Cache for all Geo // Queries // // TODO - Have Geo Coding service Provider use Memcached as Internal cache for Queries. // // Initialized. this.initialized = true; } else { log.info("GeoCoding Service Provider Interface has been Disabled."); this.initialized = false; } } /** * Destroy Service * Invoked during Termination of the Spring Container. */ @PreDestroy public void destroy() { if (this.initialized) { log.info("Deactivating GeoCoding Service Provider Interface"); // *************************** // Indicate not initialized! initialized = false; log.info("Deactivation of GeoCoding Service Provider Interface was Successful."); } } /** * getGeoCodingByAddress * * @param address - The textual Location Address for obtaining the GeoCoding. * @return String json representation */ @Override public GeoLocation getGeoCodingByAddress(String address) { if (StringUtils.isEmpty(address)) { return new GeoLocation(GeoCodingServiceProviderResponseStatus.INVALID_REQUEST); } return this.sendRequestToServiceProvider(ADDRESS + address.replace(" ","+")); } /** * getGeoCodingByCoordinates * <p/> * Additional optional parameters, which are we are not supporting at the moment. * o bounds (optional) — The bounding box of the viewport within which to bias geocode results more prominently. * (For more information see Viewport Biasing below.) * <p/> * o region (optional) — The region code, specified as a ccTLD ("top-level domain") two-character value. (For more information see Region Biasing below.) * <p/> * o language (optional) — The language in which to return results. See the supported list of domain languages. * Note that we often update supported languages so this list may not be exhaustive. If language is not supplied, * the geocoder will attempt to use the native language of the domain from which the request is sent wherever possible. * * @param coordinates - The latitude/longitude values for which you wish to obtain the closest, human-readable address. * @return String json representation */ @Override public GeoLocation getGeoCodingByCoordinates(GeoLocationCoordinates coordinates) { return this.sendRequestToServiceProvider(LATLNG + coordinates.getLatitude() + "," + coordinates.getLongitude()); } /** * getGeoCodingByCoordinates -- Aka Reverse Geo Lookup. * * @param latitude * @param longitude * @return GeoLocation */ @Override public GeoLocation getGeoCodingByCoordinates(BigDecimal latitude, BigDecimal longitude) { return this.sendRequestToServiceProvider(LATLNG + latitude + "," + longitude); } @Override public boolean isGeoCodingEnabled() { return geoCodingEnabled; } @Override public String getGeoCodingServiceProviderUrl() { return geoCodingServiceProviderUrl; } @Override public String getGeoCodingServiceProviderOutput() { return geoCodingServiceProviderOutput; } @Override public String getGeoCodingSensorForServer() { return geoCodingSensorForServer; } @Override public long getTotalNumberOfRequestsPerformed() { return totalNumberOfRequestsPerformed; } @Override public long getDailyNumberOfRequestsPerformed() { return dailyNumberOfRequestsPerformed; } @Override public long getTotalNumberOfRequestsBlocked() { return totalNumberOfRequestsBlocked; } @Override public long getDailyNumberOfRequestsBlocked() { return dailyNumberOfRequestsBlocked; } @Override public long getNumberOfRequestsPerSecond() { return numberOfRequestsPerSecond; } @Override public long getMaximumNumberOfRequestsPerSecondReached() { return maximumNumberOfRequestsPerSecondReached; } @Override public long getWhenMaximumNumberOfRequestsPerSecondReached() { return whenMaximumNumberOfRequestsPerSecondReached; } @Override public long getLastRequestBlocked() { return lastRequestBlocked; } @Override public long getLastRequestPerformed() { return lastRequestPerformed; } @Override public int getDayInterval() { return dayInterval; } /** * Provide Request to Geocoding Service Provider to Obtain the Location based upon * the specified parameter information. * * @param requestParameters * @return GeoLocation */ private GeoLocation sendRequestToServiceProvider(String requestParameters) { if (!this.geoCodingEnabled) { return new GeoLocation(GeoCodingServiceProviderResponseStatus.DISABLED); } // *********************************************** // Immediately check cache if available. // // TODO - Have Geo Coding service Provider use Memcached as Internal cache for Queries. // TODO - Jeff Schenk // // Throttle Requests if (!canRequestBeSentToProvider()) { return new GeoLocation(GeoCodingServiceProviderResponseStatus.OVER_QUERY_LIMIT); } if (log.isDebugEnabled()) { log.debug("GeoLocation Request being Sent:[" + requestParameters + "]"); } if (StringUtils.isEmpty(requestParameters)) { return new GeoLocation(GeoCodingServiceProviderResponseStatus.INVALID_REQUEST); } // Prepare for sending request RestTemplate restTemplate = new RestTemplate(); // // must be CommonsClientHttpRequestFactory or else the location header // in an HTTP 302 won't be followed // restTemplate.setRequestFactory(new CommonsClientHttpRequestFactory()); // TODO .. repair the refactoring... // MappingJacksonHttpMessageConverter json = new MappingJacksonHttpMessageConverter(); json.setSupportedMediaTypes(Arrays.asList(new MediaType("text", "javascript"))); restTemplate.getMessageConverters().add(json); RestOperations restOperations = restTemplate; // geoCodingServiceProviderClientId if ((StringUtils.isEmpty(this.geoCodingServiceProviderClientId)) || (StringUtils.isEmpty(this.geoCodingServiceProviderClientSignature))) { // Perform the Call, without a specified ClientID GeoLocation geoLocation = restOperations.getForObject(this.geoCodingServiceProviderUrl + "{output}" + "?{requestParameters}" + "&sensor={sensorSetting}", GeoLocation.class, this.geoCodingServiceProviderOutput, requestParameters, this.getGeoCodingSensorForServer()); // TODO Place in Cache.... return geoLocation; } else { // Sign the Call and Specify the clientId as Well. try { String signedRequest = this.signGMapRequest(this.geoCodingServiceProviderUrl + this.geoCodingServiceProviderOutput + "?" + requestParameters + "&client=" + this.geoCodingServiceProviderClientId + "&sensor=" + this.getGeoCodingSensorForServer()); if (log.isDebugEnabled()) { log.debug("Signed URL:["+signedRequest+"]"); } GeoLocation geoLocation = restOperations.getForObject(signedRequest, GeoLocation.class); // TODO Place in Cache.... return geoLocation; } catch (Exception e) { log.error("Exception encountered while attempting to sign GMap Request, Sending request without Client and Signature:[" + e.getMessage() + "]"); // Try Request without ClientID and being signed... GeoLocation geoLocation = restOperations.getForObject(this.geoCodingServiceProviderUrl + "{output}" + "?{requestParameters}" + "&sensor={sensorSetting}", GeoLocation.class, this.geoCodingServiceProviderOutput, requestParameters, this.getGeoCodingSensorForServer()); // TODO Place in Cache.... return geoLocation; } } } /** * Private helper method to determine if a Request can be sent to a Service provider * based upon contractual or specified usage so we do not create excessive requests * during a major registration event and cause a request storm to the service provider. * <p/> * F1- High Priority * - Google Maps API has a limit on the number of requests per second * that an application can make. * - That limit must be configurable and will either be 5 or 10 requests * per second. * - We have to implement a first-in/first-out queue for all map and * location requests to throttle at this configured limit. * <p/> * F2 TBD * - The throttling code will also have to limit the number of requests * per day * - Error handling so user does not get a generic good api error * - Some kind of audit on how many requests per day occur * * @return boolean */ private synchronized boolean canRequestBeSentToProvider() { // Initialize long now = System.currentTimeMillis(); // Check for Clearing Daily Totals if (this.currentTime.get(Calendar.DAY_OF_MONTH) != this.dayInterval) { log.warn("Rolling Daily Totals for Day:[" + this.dayInterval + "], Blocked:[" + this.dailyNumberOfRequestsBlocked + "]," + " Performed:[" + this.dailyNumberOfRequestsPerformed + "]"); // TODO - Perform Roll of Data to a Log for additional Auditing. this.dailyNumberOfRequestsBlocked = 0; this.dailyNumberOfRequestsPerformed = 0; this.dayInterval = this.currentTime.get(Calendar.DAY_OF_MONTH); } // Check for Requests allowed per Day if ((this.geoCodingServiceProviderAllowedRequestsPerDay != null) && (this.geoCodingServiceProviderAllowedRequestsPerDay > -1)) { if (this.dailyNumberOfRequestsPerformed > this.geoCodingServiceProviderAllowedRequestsPerDay) { this.lastRequestBlocked = now; this.dailyNumberOfRequestsBlocked++; this.totalNumberOfRequestsBlocked++; log.warn("GeoCoding Provider Request has been Blocked due to the Maximum Allowed Requests per Day have been Reached!"); if (log.isDebugEnabled()) { log.debug(this.toString()); } return false; } } // Check Interval of Time if ((now - this.lastRequestPerformed) <= 1000) { this.numberOfRequestsPerSecond++; if (this.maximumNumberOfRequestsPerSecondReached < this.numberOfRequestsPerSecond) { this.maximumNumberOfRequestsPerSecondReached = this.numberOfRequestsPerSecond; this.whenMaximumNumberOfRequestsPerSecondReached = now; } } else { this.numberOfRequestsPerSecond = 0; } // Check for Requests allowed per Second if ((this.geoCodingServiceProviderAllowedRequestsPerSecond != null) && (this.geoCodingServiceProviderAllowedRequestsPerSecond > -1)) { if (this.numberOfRequestsPerSecond > this.geoCodingServiceProviderAllowedRequestsPerSecond) { this.lastRequestBlocked = now; this.dailyNumberOfRequestsBlocked++; this.totalNumberOfRequestsBlocked++; log.warn("GeoCoding Provider Request has been Blocked due to the Maximum Allowed Requests per Second have been Reached!"); if (log.isDebugEnabled()) { log.debug(this.toString()); } return false; } } // Final Check and Sleep if Enabled. if ((this.geoCodingServiceProviderThrottleSecondsWaitPerRequest != null) && (this.geoCodingServiceProviderThrottleSecondsWaitPerRequest > 0)) { try { Thread.sleep(this.geoCodingServiceProviderThrottleSecondsWaitPerRequest * 1000); } catch (InterruptedException e) { } } // Increment Total Requests Performed this.totalNumberOfRequestsPerformed++; this.dailyNumberOfRequestsPerformed++; this.lastRequestPerformed = now; // Allow return true; } @Override public String toString() { return "GeoCodingServiceProviderImpl Current Statistic:{" + "currentTime=" + currentTime + ", totalNumberOfRequestsPerformed=" + totalNumberOfRequestsPerformed + ", dailyNumberOfRequestsPerformed=" + dailyNumberOfRequestsPerformed + ", totalNumberOfRequestsBlocked=" + totalNumberOfRequestsBlocked + ", dailyNumberOfRequestsBlocked=" + dailyNumberOfRequestsBlocked + ", numberOfRequestsPerSecond=" + numberOfRequestsPerSecond + ", maximumNumberOfRequestsPerSecondReached=" + maximumNumberOfRequestsPerSecondReached + ", whenMaximumNumberOfRequestsPerSecondReached=" + whenMaximumNumberOfRequestsPerSecondReached + ", lastRequestBlocked=" + lastRequestBlocked + ", lastRequestPerformed=" + lastRequestPerformed + ", dayInterval=" + dayInterval + '}'; } /** * Private Helper Method to Sign Requests. * * @param resource * @return String * @throws java.security.NoSuchAlgorithmException * @throws java.security.InvalidKeyException * @throws java.io.UnsupportedEncodingException * @throws java.net.URISyntaxException * @throws java.io.IOException */ private String signGMapRequest(String resource) throws NoSuchAlgorithmException, InvalidKeyException, URISyntaxException, IOException { URL url = new URL(resource); log.warn("Signing URL Path:["+url.getPath()+"], URL Query:["+url.getQuery()+"]"); // get an hmac_sha1 key from the raw key bytes SecretKeySpec signingKey = new SecretKeySpec(this.binaryClientSignature, SecurityConstants.HMAC_SHA1); // get an hmac_sha1 Mac instance and initialize with the signing key Mac mac = Mac.getInstance(SecurityConstants.HMAC_SHA1); mac.init(signingKey); // compute the hmac on input data bytes byte[] sigBytes = mac.doFinal((url.getPath() + "?" + url.getQuery()).getBytes()); // base 64 encode the binary signature String signature = Base64.encodeBytes(sigBytes); signature = signature.replace('+', '-'); signature = signature.replace('/', '_'); return resource + "&signature=" + signature; } }