ColdFusion Gateway For IPInfo.io Geolocation Service
Yesterday, I wrote about my experimentation with the Circuit Breaker pattern in ColdFusion. In the experiment, I ended up using a simple test gateway; but, when I started the experiment over the weekend, I did so by writing a ColdFusion gateway for the IP geolocation service, IPInfo.io. I ended up not using it because I didn't want to flood their API with erroneous requests. But, I thought my ColdFusion client for their API might be worth sharing.
This certainly isn't the first time that I've looked at geolocation (or geocoding - not sure which term is really the correct term). In the past, I've looked at using the browser's geolocation API with Google Maps. I've also looked at using the IPInfoDB API in ColdFusion. But the IPInfo.io API is so simple, it's really quite a joy to work with (though their documentation is a bit lacking).
With IPInfo.io, you can either ask for fully structured geolocation data returned JavaScript Object Notation (JSON):
ipinfo.io/69.4.2.100/json
Or, you can get just a single string field from the geolocation data, such as "city":
ipinfo.io/69.4.2.100/city
The biggest hurdle I've had when dealing with the IPInfo.io API is that the structured data doesn't always come back with the same fields. As such, with my IPInfo.io gateway / client, one thing I wanted to make sure to do was normalize the API response so that the calling context could always depend on a known set of fields existing (even if those fields are empty).
Before we look at the implementation, though, let's take a quick look at how it can be used. In the following code, I'm using the IPInfoGateway.cfc to access both of the remote resources - the one for fully structured data and the one for a single field:
<cfscript>
ipInfoGateway = new IPInfoGateway();
// Get the entire geolocation data structure.
info = ipInfoGateway.getInfo( "69.4.2.100" );
// Pull back just the given field (uses a different API resource).
field = ipInfoGateway.getField( "69.4.2.100", "city" );
// Output results.
writeDump( info );
writeOutput( "<br />" );
writeDump( "City: #field#" );
</cfscript>
And, when we run this ColdFusion code, we get the following page output:
The IPInfo.io API returns the latitude and longitude as a single "loc" string. But since the vast majority of use-cases will likely want to break these values out and store them individually, I'm normalizing the response with individual "latitude" and "longitude" values. I'm also normalizing the existence of a "bogon" key, which is normally only returned when the IP address is outside the range of allocated addresses.
And, here is my implementation for the ColdFusion gateway to the IPInfo.io API:
component
output = false
hint = "I provide client access to the IPInfo.io API."
{
/**
* I initialize the IP Info gateway with the optional API key. If provided, the API
* key will be used to make all geolocation calls to the remote API.
*
* @apiKey I am the authorization token for paid IPInfo.io subscription plans.
* @output false
*/
public any function init( string apiKey = "" ) {
setApiKey( apiKey );
setHttpTimeout( 5 ); // Default timeout in seconds.
return( this );
}
// ---
// PUBLIC METHODS.
// ---
/**
* I return the sub-field of the geolocation information for the given IP address.
*
* NOTE: If the given field is not defined for the given IP address (assuming the
* IP address can be geolocated), an empty string will be returned.
*
* @ipAddress I am the address being geolocated.
* @field I am the sub-field being extracted.
* @output false
*/
public string function getField(
required string ipAddress,
required string field
) {
var content = executeRequest( ipAddress, field );
// If the given field is not defined for the given IP address, just return the
// empty string (rather than throwing an error).
if ( content == "undefined" ) {
return( "" );
}
return( content );
}
/**
* I return the full geolocation information for the given IP address.
*
* @ipAddress I am the address being geolocated.
* @output false
*/
public struct function getInfo( required string ipAddress ) {
var content = executeRequest( ipAddress, "" );
// CAUTION: At this point, we're assuming that if the request was executed
// successfully (ie, returned with a 2xx status code), the content contains
// valid JSON data - that is part of the API contract.
return( normalizeStructuredData( deserializeJson( content ) ) );
}
/**
* I set the API key used to make requests to the remove API service.
*
* From the documentation:
* --
* Free usage of our API is limited to 1,000 API requests per day. If you exceed
* 1,000 requests in a 24 hour period we'll return a 429 HTTP status code to you.
* If you need to make more requests or custom data, see our paid plans, which all
* have soft limits.
* --
*
* @newApiKey I am the token used to define a paid subscription.
* @output false
*/
public void function setApiKey( required string newApiKey ) {
apiKey = newApiKey;
}
/**
* I set the HTTP timeout (in seconds) to be used when calling the remote API service.
*
* CAUTION: This won't necessarily cut the request off if the given timeout is
* exceeded. Ultimately, releasing the request is the purview of the underlying Java
* layer, which may exceed the HTTP timeout because reasons.
*
* @newHttpTimeout I am the HTTP timeout in seconds.
* @output false
*/
public void function setHttpTimeout( required numeric newHttpTimeout ) {
httpTimeout = newHttpTimeout;
}
// ---
// PRIVATE METHODS.
// ---
/**
* I call the remote IPInfo.io API and return the response string. If the request
* cannot be fulfilled, an error is thrown.
*
* NOTE: The content of the request is not validated - only the HTTP status code.
*
* @ipAddress I am the address being geolocated.
* @field I am the sub-field of the geolocation data being extracted.
* @output false
*/
private string function executeRequest(
required string ipAddress,
required string field
) {
// Use the field value to determine which end-point to call.
var resource = len( field )
? "https://ipinfo.io/#ipAddress#/#field#" // Will always return a single string.
: "https://ipinfo.io/#ipAddress#/json" // Will always return JSON.
;
var apiRequest = new Http(
method = "get",
url = resource,
getAsBinary = "yes",
timeout = httpTimeout,
throwOnError = false // Do NOT throw errors for non-2xx status codes.
);
// Add the paid-subscription API key if it exists.
if ( len( apiKey ) ) {
apiRequest.addParam(
type = "url",
name = "token",
value = apiKey
);
}
var apiResponse = apiRequest.send().getPrefix();
// Even though we are asking the request to return a Binary value, the type is
// only guaranteed if the request comes back properly. If something goes terribly
// wrong (such as a "Connection Failure"), the fileContent will still be returned
// as a simple string (feels buggy, but what can you do?).
var content = isBinary( apiResponse.fileContent )
? charsetEncode( apiResponse.fileContent, "utf8" )
: apiResponse.fileContent
;
// Check for non-2xx status codes.
if ( ! reFind( "2\d\d", apiResponse.statusCode ) ) {
// If the error is due to rate-limiting, let's break that out as a different
// error type. This information may be valuable to the calling context that
// may be able to manage rate-limiting.
var errorType = find( "429", apiResponse.statusCode )
? "IPInfo.TooManyRequests"
: "IPInfo.HttpError"
;
throw(
type = errorType,
message = "IPInfo.io responded with a non-200 status code.",
detail = "The HTTP response came back with status code [#apiResponse.statusCode#].",
extendedInfo = content
);
}
return( content );
}
/**
* I safely access the given key on the given struct, returning either the value (if
* the key exists) or an empty string if the key doesn't exist.
*
* @data I am the data being accessed.
* @key I am the key being accessed.
* @output false
*/
private string function getValueSafely(
required struct data,
required string key
) {
if ( structKeyExists( data, key ) ) {
return( data[ key ] );
}
return( "" );
}
/**
* I return a normalized representation of the IP info. This way, the response
* structure will always be the same, even if IPInfo doesn't necessarily have all of
* the relevant information for the provided IP address.
*
* Any field not provided by IPInfo will be normalized as an empty string.
*
* @ipInfo I am the structured data returned from the ipinfo.io end-point.
* @output false
*/
private struct function normalizeStructuredData( required struct ipInfo ) {
var normalizedInfo = {
"ip": getValueSafely( ipInfo, "ip" ),
"city": getValueSafely( ipInfo, "city" ),
"postal": getValueSafely( ipInfo, "postal" ),
"region": getValueSafely( ipInfo, "region" ),
"country": getValueSafely( ipInfo, "country" ),
"hostname": getValueSafely( ipInfo, "hostname" ),
"org": getValueSafely( ipInfo, "org" ),
"loc": getValueSafely( ipInfo, "loc" ),
"latitude": "",
"longitude": ""
};
// IPInfo doesn't break out the "loc" field; but, the majority of use cases
// will certainly want to do that. As such, let's parse it into individual
// latitude and longitude values (when available).
if ( listLen( normalizedInfo.loc ) == 2 ) {
var coordinates = listToArray( normalizedInfo.loc );
normalizedInfo.latitude = coordinates[ 1 ];
normalizedInfo.longitude = coordinates[ 2 ];
}
// Sometimes, IPInfo includes the "bogon" flag:
// --
// (from Wikipedia) Bogon is also an informal name for an IP packet on the public
// Internet that claims to be from an area of the IP address space (or network
// prefix or network block) reserved, but not yet allocated or delegated by the
// Internet Assigned Numbers Authority (IANA) or a delegated Regional Internet
// Registry (RIR).
// --
// If that is the case, IPInfo will report "bogon: 1". Let's normalize that to a
// Boolean value.
normalizedInfo[ "bogon" ] = ( getValueSafely( ipInfo, "bogon" ) == "1" );
return( normalizedInfo );
}
}
Because I was originally going to use this gateway to test my ColdFusion Circuit Breaker implementation, I was trying to be very conscious about downstream behavior and how it may negatively affect the performance of the parent ColdFusion application. As such, I made sure to include an HTTP timeout, which defaults to 5-seconds (but can be overridden with the setHttpTimeout() method). The timeout is intended to prevent long-running requests from pilling up (though Charlie Arehart will warn you that this value is dubious).
I also thought it might be a good idea to throw a different error when IPInfo.io specifically returns a "429 Too Many Requests" HTTP status code. Normally, a failed API call results in an "IPInfo.HttpError" error. But, if the API returns a 429, I am throwing an "IPInfo.TooManyRequests" error. The thinking here was that if the calling context could target the "TooManyRequests" error specifically, it could immediately open the Circuit Breaker rather than waiting for errors to reach some threshold.
Of course, my Circuit Breaker eneded up being more generic and much less sophisticated. So, even if I ran my tests with this gateway, I wouldn't (yet) know how to take advantage of the more expressive error types.
If nothing else, this was a fun code kata in thinking about ColdFusion client design. Honestly, I just like any excuse that I can find for writing some ColdFusion code. And, both the ease of use and the simplicity of the IPInfo.io API is excuse enough for me.
Want to use code from this post? Check out the license.
Reader Comments
Good to see some ColdFusion again!. Also nice to learn about IPInfo. As always, thx!
I've been working with GetIPIntel.net & AbuseIPDB.com APIs. To reduce redundant calls, API limits and potential performance bottlenecks, I incorporated CacheGet() & CachePut() functions so that repeated calls using the same IP don't generate additional HTTP calls. The data shouldn't be different between page requests and the cached data will auto-expire at the time that it's set to (using CreateTimeSpan). Using CacheGet/Put is also beneficial if the third-party service is ever temporarily unavailable. The configured 5 second timeout will only occur once and then be temporarily cached for any immediate repeated API calls.
I just checked out IPInfo.io. They performed the correct reverse DNS lookup to get my Class C IP's hostname, but their lat/lng response indicates that I'm located near Wichita, Kansas in the middle of the Cheney Reservoir. It was off by about 1,600 miles. I checked using WhatisMyIP.com and they correct identify my city. (They offer an API, but it's not cheap.) I wonder why IPInfo.io is off by so much.
Sorry... it's $25/yr, but appears to may out at 60 API requests per hour (1000 (Per Day). This actually appears to be cheaper than the cheapest IPInfo.io plan, by $95/yr.
@James Moberg
IPInfo.io claims to be Fast, Reliable, and Up to Date. They make no claims about being accurate... LOL
@James, @Chris,
Full disclosure, we actually used IPInfo.io for a few years. But maybe a year ago (probably less), we switched over to another service called Net Acuity. We did so because some research by one of my team members demonstrated that Net Acuity is more accurate. I don't actually know where any of this geolocation data comes from to begin with - I would think that they ultimately all get it from the same place :D But, I guess that is not the case.
@James,
+1 for the caching! We actually store our values in MySQL with an `expiresAt` date. Then, we have a few key interactions which trigger the geolocation, which we do inside a thread. Our geo service has a method like:
geolocation.getIpInfoAsync()
... which spawns a CFThread internally and calls the:
thread {
. . . getIpInfo()
}
I really like the cacehGet() , cachePut() idea; but we don't read the location all that often - only for rendering a few different views.