Skip to content

Commit 4d1fca7

Browse files
committed
Add ip2geo IP geolocation lookup
Add support for the ip2geo.dev IP geolocation API as a new IP address lookup service. Supports IPv4 and IPv6 with city, country, continent, ASN, currency, flag, and timezone data. Configuration: Geocoder.configure(ip_lookup: :ip2geo, api_key: 'your-key') API docs: https://ip2geo.dev/docs
1 parent aa134e3 commit 4d1fca7

8 files changed

Lines changed: 376 additions & 1 deletion

File tree

README_API_GUIDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,19 @@ A free-tier API access plan, which includes unlimited country-level geolocation
770770
* **Documentation**: https://www.ip2location.io/ip2location-documentation
771771
* **Terms of Service**: https://www.ip2location.io/terms-of-service
772772

773+
### ip2geo (`:ip2geo`)
774+
775+
* **API key**: required
776+
* **Quota**: varies by plan
777+
* **Region**: world
778+
* **SSL support**: yes (required)
779+
* **Languages**: English
780+
* **Extra result fields**: continent, continent_code, phone_code, capital, tld, flag_emoji, flag_img, currency_name, currency_code, currency_symbol, asn_number, asn_name, accuracy_radius, city_geoname_id, time_now, registered_country, registered_country_code, metro_code
781+
* **Extra options**: `:host` — custom API host (default: api.ip2geo.dev)
782+
* **Notes**: API key is sent via X-Api-Key header. See https://ip2geo.dev for documentation.
783+
* **Terms of Service**: https://ip2geo.dev/terms
784+
* **Limitations**: IP address lookup only (no street addresses, no reverse geocoding)
785+
773786

774787
Local IP Address Lookups
775788
------------------------

lib/geocoder/lookup.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ def ip_services
9898
:ipqualityscore,
9999
:ipbase,
100100
:ip2location_io,
101-
:ip2location_lite
101+
:ip2location_lite,
102+
:ip2geo
102103
]
103104
end
104105

lib/geocoder/lookups/ip2geo.rb

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
require 'geocoder/lookups/base'
2+
require 'geocoder/results/ip2geo'
3+
4+
module Geocoder::Lookup
5+
class Ip2geo < Base
6+
7+
def name
8+
"ip2geo"
9+
end
10+
11+
def required_api_key_parts
12+
["api_key"]
13+
end
14+
15+
def supported_protocols
16+
[:https]
17+
end
18+
19+
def query_url(query)
20+
"#{protocol}://#{host}/convert?#{url_query_string(query)}"
21+
end
22+
23+
private # ---------------------------------------------------------------
24+
25+
def results(query)
26+
# Don't look up a loopback or private address, just return the stored result.
27+
return [reserved_result(query.text)] if query.internal_ip_address?
28+
29+
return [] unless (doc = fetch_data(query))
30+
return [] unless doc['success']
31+
return [] unless (data = doc['data'])
32+
33+
[data]
34+
end
35+
36+
def reserved_result(ip)
37+
{
38+
"ip" => ip,
39+
"type" => "reserved",
40+
"is_eu" => false,
41+
"continent" => {
42+
"name" => "",
43+
"code" => "",
44+
"country" => {
45+
"name" => "Reserved",
46+
"code" => "RD",
47+
"phone_code" => "",
48+
"capital" => "",
49+
"tld" => "",
50+
"flag" => { "emoji" => "", "img" => "" },
51+
"currency" => { "name" => "", "code" => "", "symbol" => "" },
52+
"subdivision" => { "name" => "", "code" => "" },
53+
"city" => {
54+
"name" => "",
55+
"latitude" => 0,
56+
"longitude" => 0,
57+
"postal_code" => "",
58+
"geoname_id" => nil,
59+
"accuracy_radius" => nil,
60+
"timezone" => { "name" => "", "time_now" => "" }
61+
}
62+
}
63+
},
64+
"asn" => { "number" => nil, "name" => "" },
65+
"registered_country" => { "name" => "", "code" => "" }
66+
}
67+
end
68+
69+
def query_url_params(query)
70+
{ ip: query.sanitized_text }.merge(super)
71+
end
72+
73+
# Inject the X-Api-Key header automatically so users only need to set
74+
# api_key in their Geocoder configuration.
75+
def make_api_request(query)
76+
configuration.http_headers["X-Api-Key"] ||= configuration.api_key
77+
super
78+
end
79+
80+
def host
81+
configuration[:host] || "api.ip2geo.dev"
82+
end
83+
84+
def cache_key(query)
85+
query_url(query)
86+
end
87+
88+
def check_response_for_errors!(response)
89+
case response.code.to_i
90+
when 401
91+
raise_error(Geocoder::InvalidApiKey) ||
92+
Geocoder.log(:warn, "ip2geo API error: invalid or missing API key")
93+
when 403
94+
raise_error(Geocoder::RequestDenied) ||
95+
Geocoder.log(:warn, "ip2geo API error: request denied")
96+
when 429
97+
raise_error(Geocoder::OverQueryLimitError) ||
98+
Geocoder.log(:warn, "ip2geo API error: rate limit exceeded")
99+
else
100+
super(response)
101+
end
102+
end
103+
end
104+
end

lib/geocoder/results/ip2geo.rb

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
require 'geocoder/results/base'
2+
3+
module Geocoder::Result
4+
class Ip2geo < Base
5+
6+
def ip
7+
@data['ip']
8+
end
9+
10+
def city
11+
@data.dig('continent', 'country', 'city', 'name') || ''
12+
end
13+
14+
def state
15+
@data.dig('continent', 'country', 'subdivision', 'name') || ''
16+
end
17+
18+
def state_code
19+
@data.dig('continent', 'country', 'subdivision', 'code') || ''
20+
end
21+
22+
def country
23+
@data.dig('continent', 'country', 'name') || ''
24+
end
25+
26+
def country_code
27+
@data.dig('continent', 'country', 'code') || ''
28+
end
29+
30+
def postal_code
31+
@data.dig('continent', 'country', 'city', 'postal_code') || ''
32+
end
33+
34+
def coordinates
35+
[
36+
@data.dig('continent', 'country', 'city', 'latitude').to_f,
37+
@data.dig('continent', 'country', 'city', 'longitude').to_f
38+
]
39+
end
40+
41+
def continent
42+
@data.dig('continent', 'name') || ''
43+
end
44+
45+
def continent_code
46+
@data.dig('continent', 'code') || ''
47+
end
48+
49+
def timezone
50+
@data.dig('continent', 'country', 'city', 'timezone', 'name') || ''
51+
end
52+
53+
def phone_code
54+
@data.dig('continent', 'country', 'phone_code') || ''
55+
end
56+
57+
def capital
58+
@data.dig('continent', 'country', 'capital') || ''
59+
end
60+
61+
def tld
62+
@data.dig('continent', 'country', 'tld') || ''
63+
end
64+
65+
def accuracy_radius
66+
@data.dig('continent', 'country', 'city', 'accuracy_radius')
67+
end
68+
69+
def city_geoname_id
70+
@data.dig('continent', 'country', 'city', 'geoname_id')
71+
end
72+
73+
def time_now
74+
@data.dig('continent', 'country', 'city', 'timezone', 'time_now') || ''
75+
end
76+
77+
def flag_emoji
78+
@data.dig('continent', 'country', 'flag', 'emoji') || ''
79+
end
80+
81+
def flag_img
82+
@data.dig('continent', 'country', 'flag', 'img') || ''
83+
end
84+
85+
def currency_name
86+
@data.dig('continent', 'country', 'currency', 'name') || ''
87+
end
88+
89+
def currency_code
90+
@data.dig('continent', 'country', 'currency', 'code') || ''
91+
end
92+
93+
def currency_symbol
94+
@data.dig('continent', 'country', 'currency', 'symbol') || ''
95+
end
96+
97+
def asn_number
98+
@data.dig('asn', 'number')
99+
end
100+
101+
def asn_name
102+
@data.dig('asn', 'name') || ''
103+
end
104+
105+
def registered_country
106+
@data.dig('registered_country', 'name') || ''
107+
end
108+
109+
def registered_country_code
110+
@data.dig('registered_country', 'code') || ''
111+
end
112+
113+
def continent_geoname_id
114+
@data.dig('continent', 'geoname_id')
115+
end
116+
117+
def country_geoname_id
118+
@data.dig('continent', 'country', 'geoname_id')
119+
end
120+
121+
def metro_code
122+
@data.dig('continent', 'country', 'city', 'metro_code')
123+
end
124+
125+
def flag_emoji_unicode
126+
@data.dig('continent', 'country', 'flag', 'emoji_unicode') || ''
127+
end
128+
129+
def registered_country_geoname_id
130+
@data.dig('registered_country', 'geoname_id')
131+
end
132+
133+
def self.response_attributes
134+
%w[type is_eu]
135+
end
136+
137+
response_attributes.each do |a|
138+
define_method a do
139+
@data[a]
140+
end
141+
end
142+
end
143+
end

test/fixtures/ip2geo_8_8_8_8

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"success": true,
3+
"data": {
4+
"ip": "8.8.8.8",
5+
"type": "IPv4",
6+
"is_eu": false,
7+
"continent": {
8+
"name": "North America",
9+
"code": "NA",
10+
"geoname_id": 6255149,
11+
"country": {
12+
"name": "United States",
13+
"code": "US",
14+
"geoname_id": 6252001,
15+
"phone_code": "+1",
16+
"capital": "Washington D.C.",
17+
"tld": ".us",
18+
"flag": {
19+
"emoji": "",
20+
"emoji_unicode": "U+1F1FA U+1F1F8",
21+
"img": "https://flagcdn.com/us.svg"
22+
},
23+
"currency": {
24+
"name": "United States Dollar",
25+
"code": "USD",
26+
"symbol": "$"
27+
},
28+
"subdivision": {
29+
"name": "California",
30+
"code": "CA"
31+
},
32+
"city": {
33+
"name": "Mountain View",
34+
"latitude": 37.386,
35+
"longitude": -122.0838,
36+
"postal_code": "94035",
37+
"geoname_id": 5375480,
38+
"metro_code": 807,
39+
"accuracy_radius": 1000,
40+
"timezone": {
41+
"name": "America/Los_Angeles",
42+
"time_now": "2026-04-05T10:30:00-07:00"
43+
}
44+
}
45+
}
46+
},
47+
"asn": {
48+
"number": 15169,
49+
"name": "Google LLC"
50+
},
51+
"registered_country": {
52+
"name": "United States",
53+
"code": "US",
54+
"geoname_id": 6252001
55+
}
56+
}
57+
}

test/fixtures/ip2geo_no_results

Whitespace-only changes.

test/test_helper.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,14 @@ def read_fixture(file)
625625
MockHttpResponse.new(options)
626626
end
627627
end
628+
629+
require 'geocoder/lookups/ip2geo'
630+
class Ip2geo
631+
private
632+
def default_fixture_filename
633+
"ip2geo_8_8_8_8"
634+
end
635+
end
628636
end
629637
end
630638

0 commit comments

Comments
 (0)