Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions lib/ipinfo/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,42 @@ def default_headers
headers
end
end

class IPinfo::AdapterPlus
HOST = 'https://api.ipinfo.io/lookup'

attr_reader :conn

def initialize(token = nil, adapter = :net_http)
@token = token
@conn = connection(adapter)
end

def get(uri)
@conn.get(HOST + uri) do |req|
default_headers.each_pair do |key, value|
req.headers[key] = value
end
req.params['token'] = CGI.escape(token) if token
end
end

private

attr_reader :token

def connection(adapter)
Faraday.new() do |conn|
conn.adapter(adapter)
end
end

def default_headers
headers = {
'User-Agent' => "IPinfoClient/Ruby/#{IPinfo::VERSION}",
'Accept' => 'application/json'
}
headers['Authorization'] = "Bearer #{CGI.escape(token)}" if token
headers
end
end
124 changes: 124 additions & 0 deletions lib/ipinfo_plus.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# frozen_string_literal: true

require 'ipinfo/adapter'
require 'ipinfo/cache/default_cache'
require 'ipinfo/errors'
require 'ipinfo/response'
require_relative 'ipinfo/ipAddressMatcher'
require_relative 'ipinfo/countriesData'
require 'ipaddr'
require 'cgi'

module IPinfoPlus
include CountriesData
DEFAULT_CACHE_MAXSIZE = 4096
DEFAULT_CACHE_TTL = 60 * 60 * 24
RATE_LIMIT_MESSAGE = 'To increase your limits, please review our ' \
'paid plans at https://ipinfo.io/pricing'
# Base URL to get country flag image link.
# "PK" -> "https://cdn.ipinfo.io/static/images/countries-flags/PK.svg"
COUNTRY_FLAGS_URL = 'https://cdn.ipinfo.io/static/images/countries-flags/'

class << self
def create(access_token = nil, settings = {})
IPinfo::IPinfoPlus.new(access_token, settings)
end
end
end

class IPinfo::IPinfoPlus
include IPinfoPlus
attr_accessor :access_token, :countries, :httpc

def initialize(access_token = nil, settings = {})
@access_token = access_token
@httpc = IPinfo::AdapterPlus.new(access_token, httpc || :net_http)

maxsize = settings.fetch('maxsize', DEFAULT_CACHE_MAXSIZE)
ttl = settings.fetch('ttl', DEFAULT_CACHE_TTL)
@cache = settings.fetch('cache', IPinfo::DefaultCache.new(ttl, maxsize))
@countries = settings.fetch('countries', DEFAULT_COUNTRY_LIST)
@eu_countries = settings.fetch('eu_countries', DEFAULT_EU_COUNTRIES_LIST)
@countries_flags = settings.fetch('countries_flags', DEFAULT_COUNTRIES_FLAG_LIST)
@countries_currencies = settings.fetch('countries_currencies', DEFAULT_COUNTRIES_CURRENCIES_LIST)
@continents = settings.fetch('continents', DEFAULT_CONTINENT_LIST)
end

def details(ip_address = nil)
details_base(ip_address)
end

def request_details(ip_address = nil)
if ip_address && isBogon(ip_address)
details = {}
details[:ip] = ip_address
details[:bogon] = true
details[:ip_address] = IPAddr.new(ip_address)
return details
end

res = @cache.get(cache_key(ip_address))
return res unless res.nil?

response = @httpc.get(escape_path(ip_address))

if response.status.eql?(429)
raise RateLimitError,
RATE_LIMIT_MESSAGE
end

details = JSON.parse(response.body, symbolize_names: true)
@cache.set(cache_key(ip_address), details)
details
end

def details_base(ip_address)
details = request_details(ip_address)

# Plus response has nested geo object (same structure as Core)
if details.key?(:geo) && details[:geo].is_a?(Hash) && details[:geo].key?(:country_code)
country_code = details[:geo][:country_code]
details[:geo][:country_name] = @countries.fetch(country_code, nil)
details[:geo][:is_eu] = @eu_countries.include?(country_code)
details[:geo][:country_flag] = @countries_flags.fetch(country_code, nil)
details[:geo][:country_currency] = @countries_currencies.fetch(country_code, nil)
details[:geo][:continent] = @continents.fetch(country_code, nil)
details[:geo][:country_flag_url] = "#{COUNTRY_FLAGS_URL}#{country_code}.svg"
end

# Handle top-level country_code if present (for certain edge cases)
if details.key?(:country_code)
country_code = details[:country_code]
details[:country_name] = @countries.fetch(country_code, nil)
details[:is_eu] = @eu_countries.include?(country_code)
details[:country_flag] = @countries_flags.fetch(country_code, nil)
details[:country_currency] = @countries_currencies.fetch(country_code, nil)
details[:continent] = @continents.fetch(country_code, nil)
details[:country_flag_url] = "#{COUNTRY_FLAGS_URL}#{country_code}.svg"
end

if details.key? :ip
details[:ip_address] =
IPAddr.new(details.fetch(:ip))
end

IPinfo::Response.new(details)
end

def isBogon(ip)
if ip.nil?
return false
end

matcher_object = IPinfo::IpAddressMatcher.new(ip)
matcher_object.matches
end

def escape_path(ip)
ip ? "/#{CGI.escape(ip)}" : '/'
end

def cache_key(ip)
"1:#{ip}"
end
end
118 changes: 118 additions & 0 deletions test/ipinfo_plus_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# frozen_string_literal: true

require_relative 'test_helper'

class IPinfoPlusTest < Minitest::Test
TEST_IPV4 = '8.8.8.8'
TEST_IPV6 = '2001:4860:4860::8888'

def test_that_it_has_a_version_number
refute_nil ::IPinfo::VERSION
end

def test_set_adapter
ipinfo = IPinfoPlus.create(
ENV.fetch('IPINFO_TOKEN', nil),
{ 'http_client' => :excon }
)

assert(ipinfo.httpc = :excon)
end

def test_lookup_ip4
ipinfo = IPinfoPlus.create(ENV.fetch('IPINFO_TOKEN', nil))

# multiple checks for cache
(0...5).each do |_|
resp = ipinfo.details(TEST_IPV4)

# Basic fields
assert_equal(resp.ip, TEST_IPV4)
assert_equal(resp.ip_address, IPAddr.new(TEST_IPV4))
assert_equal(resp.hostname, 'dns.google')

# Geo object assertions
assert(resp.geo.is_a?(Hash))
refute_nil(resp.geo[:city])
refute_nil(resp.geo[:region])
refute_nil(resp.geo[:region_code])
assert_equal(resp.geo[:country_code], 'US')
assert_equal(resp.geo[:country], 'United States')
assert_equal(resp.geo[:country_name], 'United States')
assert_equal(resp.geo[:is_eu], false)
refute_nil(resp.geo[:continent])
refute_nil(resp.geo[:continent_code])
refute_nil(resp.geo[:latitude])
refute_nil(resp.geo[:longitude])
refute_nil(resp.geo[:timezone])
refute_nil(resp.geo[:postal_code])
assert_equal(resp.geo[:country_flag]['emoji'], '🇺🇸')
assert_equal(resp.geo[:country_flag]['unicode'], 'U+1F1FA U+1F1F8')
assert_equal(resp.geo[:country_flag_url], 'https://cdn.ipinfo.io/static/images/countries-flags/US.svg')
assert_equal(resp.geo[:country_currency]['code'], 'USD')
assert_equal(resp.geo[:country_currency]['symbol'], '$')
assert_equal(resp.geo[:continent]['code'], 'NA')
assert_equal(resp.geo[:continent]['name'], 'North America')

# AS object assertions
assert(resp.as.is_a?(Hash))
assert_equal(resp.as[:asn], 'AS15169')
assert(resp.as[:name].is_a?(String))
assert(resp.as[:domain].is_a?(String))
assert(resp.as[:type].is_a?(String))

# Network flags
assert_equal(resp.is_anonymous, false)
assert_equal(resp.is_anycast, true)
assert_equal(resp.is_hosting, true)
assert_equal(resp.is_mobile, false)
assert_equal(resp.is_satellite, false)

# Plus-specific fields (may be present based on token tier)
# abuse, company, domains, privacy fields
# Just verify response structure
end
end

def test_lookup_ip6
ipinfo = IPinfoPlus.create(ENV.fetch('IPINFO_TOKEN', nil))

# multiple checks for cache
(0...5).each do |_|
resp = ipinfo.details(TEST_IPV6)

# Basic fields
assert_equal(resp.ip, TEST_IPV6)
assert_equal(resp.ip_address, IPAddr.new(TEST_IPV6))

# Geo object assertions
assert(resp.geo.is_a?(Hash))
assert_equal(resp.geo[:country_code], 'US')
assert(resp.geo[:country_code].is_a?(String))
assert(resp.geo[:country].is_a?(String))
refute_nil(resp.geo[:city])
refute_nil(resp.geo[:region])

# AS object assertions
assert(resp.as.is_a?(Hash))
assert(resp.as[:asn].is_a?(String))
assert(resp.as[:name].is_a?(String))
assert(resp.as[:domain].is_a?(String))

# Network flags
assert_equal(resp.is_anonymous, false)
refute_nil(resp.is_anycast)
refute_nil(resp.is_hosting)
assert_equal(resp.is_mobile, false)
assert_equal(resp.is_satellite, false)
end
end

def test_bogon_ip
ipinfo = IPinfoPlus.create(ENV.fetch('IPINFO_TOKEN', nil))

resp = ipinfo.details('192.168.1.1')
assert_equal(resp.bogon, true)
assert_equal(resp.ip, '192.168.1.1')
end
end
1 change: 1 addition & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require 'ipinfo'
require 'ipinfo_lite'
require 'ipinfo_core'
require 'ipinfo_plus'

require 'minitest/autorun'
require 'minitest/reporters'
Expand Down