Skip to content

Commit 785e6b9

Browse files
authored
Merge pull request #57 from ipinfo/silvano/eng-499-add-plus-bundle-support-in-ipinforuby-library
Add support for Plus bundle
2 parents 11c7272 + c494658 commit 785e6b9

File tree

4 files changed

+282
-0
lines changed

4 files changed

+282
-0
lines changed

lib/ipinfo/adapter.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,42 @@ def default_headers
130130
headers
131131
end
132132
end
133+
134+
class IPinfo::AdapterPlus
135+
HOST = 'https://api.ipinfo.io/lookup'
136+
137+
attr_reader :conn
138+
139+
def initialize(token = nil, adapter = :net_http)
140+
@token = token
141+
@conn = connection(adapter)
142+
end
143+
144+
def get(uri)
145+
@conn.get(HOST + uri) do |req|
146+
default_headers.each_pair do |key, value|
147+
req.headers[key] = value
148+
end
149+
req.params['token'] = CGI.escape(token) if token
150+
end
151+
end
152+
153+
private
154+
155+
attr_reader :token
156+
157+
def connection(adapter)
158+
Faraday.new() do |conn|
159+
conn.adapter(adapter)
160+
end
161+
end
162+
163+
def default_headers
164+
headers = {
165+
'User-Agent' => "IPinfoClient/Ruby/#{IPinfo::VERSION}",
166+
'Accept' => 'application/json'
167+
}
168+
headers['Authorization'] = "Bearer #{CGI.escape(token)}" if token
169+
headers
170+
end
171+
end

lib/ipinfo_plus.rb

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# frozen_string_literal: true
2+
3+
require 'ipinfo/adapter'
4+
require 'ipinfo/cache/default_cache'
5+
require 'ipinfo/errors'
6+
require 'ipinfo/response'
7+
require_relative 'ipinfo/ipAddressMatcher'
8+
require_relative 'ipinfo/countriesData'
9+
require 'ipaddr'
10+
require 'cgi'
11+
12+
module IPinfoPlus
13+
include CountriesData
14+
DEFAULT_CACHE_MAXSIZE = 4096
15+
DEFAULT_CACHE_TTL = 60 * 60 * 24
16+
RATE_LIMIT_MESSAGE = 'To increase your limits, please review our ' \
17+
'paid plans at https://ipinfo.io/pricing'
18+
# Base URL to get country flag image link.
19+
# "PK" -> "https://cdn.ipinfo.io/static/images/countries-flags/PK.svg"
20+
COUNTRY_FLAGS_URL = 'https://cdn.ipinfo.io/static/images/countries-flags/'
21+
22+
class << self
23+
def create(access_token = nil, settings = {})
24+
IPinfo::IPinfoPlus.new(access_token, settings)
25+
end
26+
end
27+
end
28+
29+
class IPinfo::IPinfoPlus
30+
include IPinfoPlus
31+
attr_accessor :access_token, :countries, :httpc
32+
33+
def initialize(access_token = nil, settings = {})
34+
@access_token = access_token
35+
@httpc = IPinfo::AdapterPlus.new(access_token, httpc || :net_http)
36+
37+
maxsize = settings.fetch('maxsize', DEFAULT_CACHE_MAXSIZE)
38+
ttl = settings.fetch('ttl', DEFAULT_CACHE_TTL)
39+
@cache = settings.fetch('cache', IPinfo::DefaultCache.new(ttl, maxsize))
40+
@countries = settings.fetch('countries', DEFAULT_COUNTRY_LIST)
41+
@eu_countries = settings.fetch('eu_countries', DEFAULT_EU_COUNTRIES_LIST)
42+
@countries_flags = settings.fetch('countries_flags', DEFAULT_COUNTRIES_FLAG_LIST)
43+
@countries_currencies = settings.fetch('countries_currencies', DEFAULT_COUNTRIES_CURRENCIES_LIST)
44+
@continents = settings.fetch('continents', DEFAULT_CONTINENT_LIST)
45+
end
46+
47+
def details(ip_address = nil)
48+
details_base(ip_address)
49+
end
50+
51+
def request_details(ip_address = nil)
52+
if ip_address && isBogon(ip_address)
53+
details = {}
54+
details[:ip] = ip_address
55+
details[:bogon] = true
56+
details[:ip_address] = IPAddr.new(ip_address)
57+
return details
58+
end
59+
60+
res = @cache.get(cache_key(ip_address))
61+
return res unless res.nil?
62+
63+
response = @httpc.get(escape_path(ip_address))
64+
65+
if response.status.eql?(429)
66+
raise RateLimitError,
67+
RATE_LIMIT_MESSAGE
68+
end
69+
70+
details = JSON.parse(response.body, symbolize_names: true)
71+
@cache.set(cache_key(ip_address), details)
72+
details
73+
end
74+
75+
def details_base(ip_address)
76+
details = request_details(ip_address)
77+
78+
# Plus response has nested geo object (same structure as Core)
79+
if details.key?(:geo) && details[:geo].is_a?(Hash) && details[:geo].key?(:country_code)
80+
country_code = details[:geo][:country_code]
81+
details[:geo][:country_name] = @countries.fetch(country_code, nil)
82+
details[:geo][:is_eu] = @eu_countries.include?(country_code)
83+
details[:geo][:country_flag] = @countries_flags.fetch(country_code, nil)
84+
details[:geo][:country_currency] = @countries_currencies.fetch(country_code, nil)
85+
details[:geo][:continent] = @continents.fetch(country_code, nil)
86+
details[:geo][:country_flag_url] = "#{COUNTRY_FLAGS_URL}#{country_code}.svg"
87+
end
88+
89+
# Handle top-level country_code if present (for certain edge cases)
90+
if details.key?(:country_code)
91+
country_code = details[:country_code]
92+
details[:country_name] = @countries.fetch(country_code, nil)
93+
details[:is_eu] = @eu_countries.include?(country_code)
94+
details[:country_flag] = @countries_flags.fetch(country_code, nil)
95+
details[:country_currency] = @countries_currencies.fetch(country_code, nil)
96+
details[:continent] = @continents.fetch(country_code, nil)
97+
details[:country_flag_url] = "#{COUNTRY_FLAGS_URL}#{country_code}.svg"
98+
end
99+
100+
if details.key? :ip
101+
details[:ip_address] =
102+
IPAddr.new(details.fetch(:ip))
103+
end
104+
105+
IPinfo::Response.new(details)
106+
end
107+
108+
def isBogon(ip)
109+
if ip.nil?
110+
return false
111+
end
112+
113+
matcher_object = IPinfo::IpAddressMatcher.new(ip)
114+
matcher_object.matches
115+
end
116+
117+
def escape_path(ip)
118+
ip ? "/#{CGI.escape(ip)}" : '/'
119+
end
120+
121+
def cache_key(ip)
122+
"1:#{ip}"
123+
end
124+
end

test/ipinfo_plus_test.rb

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'test_helper'
4+
5+
class IPinfoPlusTest < Minitest::Test
6+
TEST_IPV4 = '8.8.8.8'
7+
TEST_IPV6 = '2001:4860:4860::8888'
8+
9+
def test_that_it_has_a_version_number
10+
refute_nil ::IPinfo::VERSION
11+
end
12+
13+
def test_set_adapter
14+
ipinfo = IPinfoPlus.create(
15+
ENV.fetch('IPINFO_TOKEN', nil),
16+
{ 'http_client' => :excon }
17+
)
18+
19+
assert(ipinfo.httpc = :excon)
20+
end
21+
22+
def test_lookup_ip4
23+
ipinfo = IPinfoPlus.create(ENV.fetch('IPINFO_TOKEN', nil))
24+
25+
# multiple checks for cache
26+
(0...5).each do |_|
27+
resp = ipinfo.details(TEST_IPV4)
28+
29+
# Basic fields
30+
assert_equal(resp.ip, TEST_IPV4)
31+
assert_equal(resp.ip_address, IPAddr.new(TEST_IPV4))
32+
assert_equal(resp.hostname, 'dns.google')
33+
34+
# Geo object assertions
35+
assert(resp.geo.is_a?(Hash))
36+
refute_nil(resp.geo[:city])
37+
refute_nil(resp.geo[:region])
38+
refute_nil(resp.geo[:region_code])
39+
assert_equal(resp.geo[:country_code], 'US')
40+
assert_equal(resp.geo[:country], 'United States')
41+
assert_equal(resp.geo[:country_name], 'United States')
42+
assert_equal(resp.geo[:is_eu], false)
43+
refute_nil(resp.geo[:continent])
44+
refute_nil(resp.geo[:continent_code])
45+
refute_nil(resp.geo[:latitude])
46+
refute_nil(resp.geo[:longitude])
47+
refute_nil(resp.geo[:timezone])
48+
refute_nil(resp.geo[:postal_code])
49+
assert_equal(resp.geo[:country_flag]['emoji'], '🇺🇸')
50+
assert_equal(resp.geo[:country_flag]['unicode'], 'U+1F1FA U+1F1F8')
51+
assert_equal(resp.geo[:country_flag_url], 'https://cdn.ipinfo.io/static/images/countries-flags/US.svg')
52+
assert_equal(resp.geo[:country_currency]['code'], 'USD')
53+
assert_equal(resp.geo[:country_currency]['symbol'], '$')
54+
assert_equal(resp.geo[:continent]['code'], 'NA')
55+
assert_equal(resp.geo[:continent]['name'], 'North America')
56+
57+
# AS object assertions
58+
assert(resp.as.is_a?(Hash))
59+
assert_equal(resp.as[:asn], 'AS15169')
60+
assert(resp.as[:name].is_a?(String))
61+
assert(resp.as[:domain].is_a?(String))
62+
assert(resp.as[:type].is_a?(String))
63+
64+
# Network flags
65+
assert_equal(resp.is_anonymous, false)
66+
assert_equal(resp.is_anycast, true)
67+
assert_equal(resp.is_hosting, true)
68+
assert_equal(resp.is_mobile, false)
69+
assert_equal(resp.is_satellite, false)
70+
71+
# Plus-specific fields (may be present based on token tier)
72+
# abuse, company, domains, privacy fields
73+
# Just verify response structure
74+
end
75+
end
76+
77+
def test_lookup_ip6
78+
ipinfo = IPinfoPlus.create(ENV.fetch('IPINFO_TOKEN', nil))
79+
80+
# multiple checks for cache
81+
(0...5).each do |_|
82+
resp = ipinfo.details(TEST_IPV6)
83+
84+
# Basic fields
85+
assert_equal(resp.ip, TEST_IPV6)
86+
assert_equal(resp.ip_address, IPAddr.new(TEST_IPV6))
87+
88+
# Geo object assertions
89+
assert(resp.geo.is_a?(Hash))
90+
assert_equal(resp.geo[:country_code], 'US')
91+
assert(resp.geo[:country_code].is_a?(String))
92+
assert(resp.geo[:country].is_a?(String))
93+
refute_nil(resp.geo[:city])
94+
refute_nil(resp.geo[:region])
95+
96+
# AS object assertions
97+
assert(resp.as.is_a?(Hash))
98+
assert(resp.as[:asn].is_a?(String))
99+
assert(resp.as[:name].is_a?(String))
100+
assert(resp.as[:domain].is_a?(String))
101+
102+
# Network flags
103+
assert_equal(resp.is_anonymous, false)
104+
refute_nil(resp.is_anycast)
105+
refute_nil(resp.is_hosting)
106+
assert_equal(resp.is_mobile, false)
107+
assert_equal(resp.is_satellite, false)
108+
end
109+
end
110+
111+
def test_bogon_ip
112+
ipinfo = IPinfoPlus.create(ENV.fetch('IPINFO_TOKEN', nil))
113+
114+
resp = ipinfo.details('192.168.1.1')
115+
assert_equal(resp.bogon, true)
116+
assert_equal(resp.ip, '192.168.1.1')
117+
end
118+
end

test/test_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'ipinfo'
55
require 'ipinfo_lite'
66
require 'ipinfo_core'
7+
require 'ipinfo_plus'
78

89
require 'minitest/autorun'
910
require 'minitest/reporters'

0 commit comments

Comments
 (0)