diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4e144..a1ad2c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,30 @@ # Changelog +## Version 2 +### Version 2.0.0 +* Complete rewrite of the gem. Includes the following changes: + * SimpleGeolocator is no longer a helper module containing methods for every type of data. Now, you call the + `SimpleGeolocator#get` function, which returns an IPAPIResponse object. This object has instance attributes to + replace almost all of the old SimpleGeolocator module functions. The exception to this is connection, which has + been replaced by the `IPAPIResponse#mobile?` and `IPAPIResponse#proxy?` functions. + * Proper error handling has been introduced. Functions will no longer return error strings. Now, + `IPAPIResponse#initialize` fails with the according errors, to quickly alert the developer or user that something + has gone wrong. + * `zip` is no longer an Integer, because that implies that some math should be done on it. It is a String now. You + can call `#to_i` if you for some reason want it to be an Integer. + * Region (`#region`) and country (`#country`) are now represented by a LOCATION_STRUCT Struct, which has 2 instance + attributes: name and code. + * Longitude and latitude (`#ll`) are no longer represented as an array, but a Pair from the data_types gem. This + makes significantly more conceptual sense. + * `#isp_name` and `#organization_name` are now represented by the new named `isp` and `organization` instance + attributes. +* Changes to the libraries and requirements: + * HTTPClient is no longer used. It had largely too much overhead for a gem this simple. When we do not need + keepalive, complicated requests, or even just a "full" client, I found it didn't make sense to use it. Now, Curb + is used. + * The stdlib JSON library is not used anymore. It has been replaced by Optimized JSON (Oj) for performance reasons. + * data_types is now a library used by SimpleGeolocator. + * Pessimistic version requirements are now used. + ## Version 1 ### Version 1.3.2 * License as MIT diff --git a/Gemfile b/Gemfile index a5107ea..0ba6e0f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,9 @@ source 'https://rubygems.org' -gem('httpclient', '2.7.1') -gem('rainbow', '2.0.0') -gem('string-utility', '2.5.0') +gem('curb', '~> 0.9.x') +gem('data_types', '~> 1.x') +gem('oj', '~> 2.x') +gem('rainbow', '~> 2.x') +gem('string-utility', '~> 2.x') ruby '2.3.0' diff --git a/Gemfile.lock b/Gemfile.lock index 2b2d0ce..b0f6310 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,9 @@ GEM remote: https://rubygems.org/ specs: - httpclient (2.7.1) + curb (0.9.1) + data_types (1.1.0) + oj (2.15.0) rainbow (2.0.0) string-utility (2.5.0) @@ -9,9 +11,11 @@ PLATFORMS ruby DEPENDENCIES - httpclient (= 2.7.1) - rainbow (= 2.0.0) - string-utility (= 2.5.0) + curb (~> 0.9.x) + data_types (~> 1.x) + oj (~> 2.x) + rainbow (~> 2.x) + string-utility (~> 2.5.x) BUNDLED WITH 1.11.2 diff --git a/README.md b/README.md index 67da1dd..4115308 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # simple_geolocator [![Gem Version](https://badge.fury.io/rb/simple_geolocator.svg)](https://badge.fury.io/rb/simple_geolocator) +[![Gem Downloads](https://img.shields.io/gem/dt/simple_geolocator.svg?maxAge=2592000)]() +[![Gemnasium](https://img.shields.io/gemnasium/elifoster/simple_geolocator.svg?maxAge=2592000)]() A simple Geolocating Ruby Gem utilizing IP-API.com. @@ -22,7 +24,7 @@ $ bundle ``` ## Usage -You can use it in your code or via IRB as described in the documentation. Or, if you wanna have some real fun, you can use the fancy command line interface added in version 1.3.0! +You can use it in your code as described in the documentation. Or, if you wanna have some real fun, you can use the fancy command line interface added in version 1.3.0! ``` $ simplegeo diff --git a/bin/simplegeo b/bin/simplegeo index f46a245..7396864 100755 --- a/bin/simplegeo +++ b/bin/simplegeo @@ -9,30 +9,27 @@ require 'string-utility' require_relative '../lib/simple_geolocator' if ARGV.empty? - puts 'You must provide an IP.' - exit + fail 'You must provide an IP.' end ip = ARGV[0] -city = SimpleGeolocator.city(ip) -region = SimpleGeolocator.region(ip)[:name] -country = SimpleGeolocator.country(ip)[:name] -zip = SimpleGeolocator.zip(ip) -ll = SimpleGeolocator.ll(ip) -timezone = SimpleGeolocator.timezone(ip) -isp = SimpleGeolocator.isp_name(ip) -org = SimpleGeolocator.organization_name(ip) -connection_attributes = SimpleGeolocator.connection(ip) +response = SimpleGeolocator.get(ip) +city = response.city +region = response.region.name +country = response.country.name +zip = response.zip +ll = response.ll +timezone = response.timezone +isp = response.isp +org = response.organization +mobile = response.mobile? +proxy = response.proxy? puts "Here is the data for #{ip}:" puts Rainbow("ISP: #{isp}").color(StringUtility.random_color_six) puts Rainbow("Organization: #{org}").color(StringUtility.random_color_six) puts Rainbow("Timezone: #{timezone}").color(StringUtility.random_color_six) -puts Rainbow("Location: #{city}, #{region}, #{country}, #{zip}") - .color(StringUtility.random_color_six) -puts Rainbow("Exact location: #{ll[0]}, #{ll[1]}") - .color(StringUtility.random_color_six) -puts Rainbow('They are using a mobile connection.') - .color(StringUtility.random_color_six) if connection_attributes[:mobile] -puts Rainbow('They are using a proxy.') - .color(StringUtility.random_color_six) if connection_attributes[:proxy] +puts Rainbow("Location: #{city}, #{region}, #{country}, #{zip}").color(StringUtility.random_color_six) +puts Rainbow("Exact location: #{ll.left}, #{ll.right}").color(StringUtility.random_color_six) +puts Rainbow('They are using a mobile connection.').color(StringUtility.random_color_six) if mobile +puts Rainbow('They are using a proxy.').color(StringUtility.random_color_six) if proxy diff --git a/lib/simple_geolocator.rb b/lib/simple_geolocator.rb index bd569a8..3658f9f 100644 --- a/lib/simple_geolocator.rb +++ b/lib/simple_geolocator.rb @@ -1,188 +1,24 @@ -require 'httpclient' -require 'json' +require 'curb' +require 'oj' +require 'data_types/pair' +require_relative 'simple_geolocator/ipapi_response' module SimpleGeolocator - extend self + module_function - @client = HTTPClient.new @cache = {} - # Gets the full JSON response, useful for getting multiple pieces of data in - # a single request. - # @param ip [String] The IP to get data for. - # @return [JSON] A parsed JSON object containing the response. - def get_full_response(ip) - return @cache[ip] unless @cache[ip].nil? - url = "http://ip-api.com/json/#{ip}?fields=258047" - uri = URI.parse(url) - response = @client.get(uri) - @cache[ip] = JSON.parse(response.body) - end - - # Gets whether the request failed or not. - # @param response [JSON] The parsed response body (#get_full_response) to - # check. - # @return [Boolean] True if successful, false if errored. - def request_successful?(response) - case response['status'] - when 'success' - true - when 'fail' - false - end - end - - # Gets the country data for the IP. - # @param ip [String] See #get_full_response - # @return [Hash] A hash containing data formatted as - # { :name => 'United States', :code => 'US' } - # @return [String] A string containing the error message. - def country(ip) - response = get_full_response(ip) - err = error(response) - return err unless err.nil? - ret = { - name: response['country'], - code: response['countryCode'] - } - ret - end - - # Gets the region data for the IP. - # @param ip [String] See #get_full_response - # @return [Hash] A hash containing data formatted as - # { :name => 'Oregon', :code => 'OR'} - # @return [String] A string containing the error message. - def region(ip) - response = get_full_response(ip) - err = error(response) - return err unless err.nil? - ret = { - name: response['regionName'], - code: response['region'] - } - ret - end - - # Gets the city name for the IP. - # @param ip [String] See #get_full_response - # @return [String] The name of the city that the IP is located in. - # @return [String] A string containing the error message. - def city(ip) - response = get_full_response(ip) - err = error(response) - return err unless err.nil? - - response['city'] - end - - # Gets the zip code for the IP. - # @param ip [String] See #get_full_response - # @return [Int] The zip code that the IP is located in. - # @return [String] A string containing the error message. - def zip(ip) - response = get_full_response(ip) - err = error(response) - return err unless err.nil? - - response['zip'].to_i - end - - # Gets the latitude, longitude for the IP. - # @param ip [String] See #get_full_response - # @return [Array] An array of Floats formatted as lat, lon - # @return [String] A string containing the error message. - def ll(ip) - response = get_full_response(ip) - err = error(response) - return err unless err.nil? - - ret = [response['lat'], response['lon']] - ret - end - - # Gets the timezone for the IP. - # @param ip [String] See #get_full_response - # @return [String] The timezone (UTC, PST, etc.) that the IP is in. - # @return [String] A string containing the error message. - def timezone(ip) - response = get_full_response(ip) - err = error(response) - return err unless err.nil? - - response['timezone'] - end - - # Gets the name of the IP's Internet Service Provider. - # @param ip [String] See #get_full_response - # @return [String] The ISP name, such as Comcast Cable. - # @return [String] A string containing the error message. - def isp_name(ip) - response = get_full_response(ip) - err = error(response) - return err unless err.nil? - - response['isp'] - end - - # Gets the name of the IP's organization. For most people, this is identical - # to their ISP name. - # @param ip [String] See #get_full_response - # @return [String] The organization name, such as Google. - # @return [String] A string containing the error message. - def organization_name(ip) - response = get_full_response(ip) - err = error(response) - return err unless err.nil? + URL_FORMAT = 'http://ip-api.com/json/%s?fields=258047'.freeze - response['org'] - end - - # Gets the IP connection attributes - if it's a mobile and/or a proxy - # connection. - # @param ip [String] See #get_full_response - # @return [Hash] A hash containing data formatted as - # { :mobile => true, :proxy => true} - # @return [String] A string containing the error message. - def connection(ip) - response = get_full_response(ip) - err = error(response) - return err unless err.nil? - ret = { - mobile: response['mobile'], - proxy: response['proxy'] - } - ret - end - - # Gets the according description for the semi-ambiguous error returned by the - # API. - # @param error [String] The error message returned by #error - # @return [String] The error description. - # @return [Nil] If you provided an invalid error message. - def get_error_description(error) - case error - when 'private range' - return 'The IP address is part of a private range.' - when 'reserved range' - return 'The IP address is part of a reserved range.' - when 'invalid query' - return 'The IP address or domain name is invalid.' - when 'quota' - return 'You have reached the quota.' - else - return nil - end - end - - private - - # Gets the error message from a response. - # @param response [JSON] See #request_successful? - # @return [String] The error message. - # @return [Nil] If there was no error message to begin with. - def error(response) - return response['message'] unless request_successful?(response) - nil + # Gets the full response. From here, all the data related to the IP can be accessed. Caches the result in order to + # prevent reaching the rate limit. + # @param ip [String] The IP to get data for. + # @return [IPAPIResponse] The full parsed response object. + def get(ip) + return @cache[ip] if @cache.key?(ip) + url = format(URL_FORMAT, ip) + response = Curl.get(url).body_str + ipapi = SimpleGeolocator::IPAPIResponse.new(Oj.load(response)) + @cache[ip] = ipapi end end diff --git a/lib/simple_geolocator/ipapi_response.rb b/lib/simple_geolocator/ipapi_response.rb new file mode 100644 index 0000000..4d80d54 --- /dev/null +++ b/lib/simple_geolocator/ipapi_response.rb @@ -0,0 +1,83 @@ +# noinspection RubyTooManyInstanceVariablesInspection +module SimpleGeolocator + class IPAPIResponse + # @return [Hash] The full parsed response given by the API. + attr_reader :full_response + + # @return [LOCATION_STRUCT] The country name and code. + attr_reader :country + + # @return [LOCATION_STRUCT] The region name and code. + attr_reader :region + + # @return [String] The name of the city. + attr_reader :city + + # @return [String] The zip code. + attr_reader :zip + + # @return [Pair] The pair of the longitude and latitude. + attr_reader :ll + + # @return [String] The name of the timezone, e.g., America/Los Angeles. + attr_reader :timezone + + # @return [String] The name of the ISP that the IP is using. + attr_reader :isp + + # @return [String] The name of the organization that the IP is within, or their ISP name. + attr_reader :organization + + # A simple struct that stores the name and code for the location. + # @param name [String] The name of the location. + # @param code [String] The location code. + LOCATION_STRUCT = Struct.new(:name, :code) + + # Creates a new IPAPIResponse object. + # @param response [Hash] The response given by the IP API. + # @raise [RuntimeError] When the request fails, raises a RuntimeError depending on the error message. + def initialize(response = {}) + @full_response = response + @status = response['status'] + if successful? + @country = LOCATION_STRUCT.new(response['country'], response['countryCode']) + @region = LOCATION_STRUCT.new(response['regionName'], response['region']) + @city = response['city'] + @zip = response['zip'] + @ll = Pair.new(response['lat'], response['lon']) + @isp = response['isp'] + @timezone = response['timezone'] + @organization = response['org'] + @mobile = response['mobile'] + @proxy = response['proxy'] + else + case response['message'] + when 'private range' + fail 'The IP address is part of a private range.' + when 'reserved range' + fail 'The IP address is part of a reserved range.' + when 'invalid query' + fail 'The IP address or domain name is invalid.' + when 'quota' + fail 'You have reached the IP API rate limit.' + else + end + end + end + + # @return [Boolean] Whether the request was successful. + def successful? + @status == 'success' + end + + # @return [Boolean] Whether the IP is on a mobile device. + def mobile? + @mobile + end + + # @return [Boolean] Whether the IP is on a proxy. + def proxy? + @proxy + end + end +end diff --git a/simple_geolocator.gemspec b/simple_geolocator.gemspec index c6a72e0..0108a36 100644 --- a/simple_geolocator.gemspec +++ b/simple_geolocator.gemspec @@ -1,18 +1,13 @@ Gem::Specification.new do |s| s.authors = ['Eli Foster'] s.name = 'simple_geolocator' - s.summary = 'A Ruby gem for easily using the IP-API.com API to perform ' \ - 'IP geolocation.' - s.version = '1.3.2' + s.summary = 'A Ruby gem for easily using the IP-API.com API to perform IP geolocation.' + s.version = '2.0.0' s.license = 'MIT' - s.description = 'Accessing the IP API through HTTPClient. I found that many' \ - ', if not all, Geolocation gems were very annoying and ' \ - 'overly-complex to use. Thus, this gem was born. It ' \ - 'does not use anything like Google or Yahoo! Geolocation ' \ - 'because I have found that those APIs are unpredictable ' \ - 'and often-times broken. This Gem has been made to be as ' \ - 'simple to use as possible. It also includes a CLI that can' \ - ' be called simple as \'simplegeo\' followed by the IP.' + s.description = <`. +EOF s.email = 'elifosterwy@gmail.com' s.homepage = 'https://github.com/elifoster/simple_geolocator' s.metadata = { @@ -21,10 +16,13 @@ Gem::Specification.new do |s| s.files = [ 'CHANGELOG.md', 'lib/simple_geolocator.rb', + 'lib/simple_geolocator/ipapi_response.rb', 'bin/simplegeo' ] s.executables = 'simplegeo' - s.add_runtime_dependency('rainbow', '2.0.0') - s.add_runtime_dependency('string-utility', '>= 2.5.0') - s.add_runtime_dependency('httpclient', '~> 2.6', '>= 2.6.0.1') + s.add_runtime_dependency('rainbow', '~> 2') + s.add_runtime_dependency('string-utility', '~> 2') + s.add_runtime_dependency('curb', '~> 0.9') + s.add_runtime_dependency('oj', '~> 2') + s.add_runtime_dependency('data_types', '~> 1') end