ipwho 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE +21 -0
- data/README.md +206 -0
- data/lib/ipwho.rb +367 -0
- metadata +66 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6e879b527d7bf970cb0fd5cd763a753a1caacf26c39943712c69369850d811e3
|
|
4
|
+
data.tar.gz: fc7de3f5d4cd2c1b486923acd619a5a84ccf3efa1446b2c2dc8c885d36d9cb52
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f99e60ee24f0a0e9ae4fae090c77b1a4cc091d627febd96688b1d366652ce241faf272c7325b297b12e56afb46b0da180fa85c555c53c114ea21b00634039969
|
|
7
|
+
data.tar.gz: d55b91b8880d2758a77398f6aac7e07a37ac446187184e2da80c818785935a416cd888eb84ca3dc263107772e5c3359990d4c51099f0a16c19142baabbed1845
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 IPWho Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# IPWho ([ipwho.org](https://www.ipwho.org)) Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/ipwho) [](https://www.ruby-lang.org/) [](https://github.com/lavrox/SDK-IPWho-IP-Geolocation-Ruby/blob/main/LICENSE)
|
|
4
|
+
|
|
5
|
+
Official Ruby SDK for the [IPWho](https://www.ipwho.org) **IP geolocation API** — geoip lookup, IP location, IP to country / latitude / longitude, ASN/ISP, timezone, currency, flag, and proxy/VPN detection. Works as an IP lookup / ip-geolocation client for IPv4 and IPv6 (`lookup`, `me`, `bulk`).
|
|
6
|
+
|
|
7
|
+
- Product: [ipwho.org](https://www.ipwho.org)
|
|
8
|
+
- API docs: [ipwho.org/docs](https://www.ipwho.org/docs)
|
|
9
|
+
- Get an API key: [ipwho.org/free-plan](https://www.ipwho.org/free-plan) (free [Lavrox](https://lavrox.com) account)
|
|
10
|
+
- Live API host: `https://api.ipwho.org`
|
|
11
|
+
|
|
12
|
+
## API key
|
|
13
|
+
|
|
14
|
+
Open a free [Lavrox](https://lavrox.com) account to get an API key for [IPWho](https://www.ipwho.org). Create your key at [ipwho.org/free-plan](https://www.ipwho.org/free-plan) — no credit card required.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem install ipwho
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "ipwho"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires Ruby >= 2.5 and Faraday. Client class: `IpwhoApi::Client`.
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "ipwho"
|
|
32
|
+
|
|
33
|
+
client = IpwhoApi::Client.new(ENV.fetch("IPWHO_API_KEY"))
|
|
34
|
+
|
|
35
|
+
res = client.lookup("8.8.8.8") # GET /ip/{ip}
|
|
36
|
+
me = client.me # GET /me
|
|
37
|
+
bulk = client.bulk(["8.8.8.8", "1.1.1.1"]) # GET /bulk/{a,b,c}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Successful JSON:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
IpwhoApi::IpGeoResponse
|
|
44
|
+
├── success
|
|
45
|
+
├── message
|
|
46
|
+
└── data (GeoData, or OpenStruct on bulk)
|
|
47
|
+
├── ip
|
|
48
|
+
├── geoLocation
|
|
49
|
+
├── timezone
|
|
50
|
+
├── flag
|
|
51
|
+
├── currency
|
|
52
|
+
├── connection
|
|
53
|
+
├── security
|
|
54
|
+
├── userAgent
|
|
55
|
+
└── responseArray # bulk
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Reading the full response (8.8.8.8)
|
|
59
|
+
|
|
60
|
+
Live [IPWho](https://www.ipwho.org) values: United States, ASN 15169, America/Chicago, dial code +1.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
res = client.lookup("8.8.8.8")
|
|
64
|
+
data = res.data
|
|
65
|
+
|
|
66
|
+
puts data.ip # 8.8.8.8
|
|
67
|
+
|
|
68
|
+
geo = data.geoLocation
|
|
69
|
+
puts geo.continent, geo.continentCode # North America, NA
|
|
70
|
+
puts geo.country, geo.countryCode # United States, US
|
|
71
|
+
puts geo.capital, geo.region, geo.regionCode, geo.city
|
|
72
|
+
puts geo.postalCode, geo.dialCode # +1
|
|
73
|
+
puts geo.isInEu, geo.latitude, geo.longitude, geo.accuracyRadius
|
|
74
|
+
|
|
75
|
+
tz = data.timezone
|
|
76
|
+
puts tz.timeZone # America/Chicago
|
|
77
|
+
puts tz.abbr, tz.offset, tz.isDst, tz.utc, tz.currentTime
|
|
78
|
+
|
|
79
|
+
puts data.flag.flagIcon # 🇺🇸
|
|
80
|
+
puts data.flag.flagUnicode # U+1F1FA U+1F1F8
|
|
81
|
+
|
|
82
|
+
puts data.currency.code, data.currency.symbol, data.currency.name
|
|
83
|
+
puts data.currency.namePlural # US dollars
|
|
84
|
+
puts data.currency.hexUnicode
|
|
85
|
+
|
|
86
|
+
conn = data.connection
|
|
87
|
+
puts conn.asnNumber # 15169
|
|
88
|
+
puts conn.asnOrg # Google LLC
|
|
89
|
+
puts conn.isp, conn.org, conn.domain
|
|
90
|
+
puts conn.connectionType # Corporate
|
|
91
|
+
|
|
92
|
+
puts data.security.isVpn, data.security.isTor, data.security.isThreat
|
|
93
|
+
|
|
94
|
+
if data.userAgent
|
|
95
|
+
puts data.userAgent.browser.name, data.userAgent.os.name
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
me = client.me
|
|
99
|
+
puts me.data.ip
|
|
100
|
+
|
|
101
|
+
bulk = client.bulk(["8.8.8.8", "1.1.1.1"])
|
|
102
|
+
Array(bulk.data.responseArray).each do |item|
|
|
103
|
+
puts item.data.ip, item.data.geoLocation.country
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Example JSON (mapped fields)
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"success": true,
|
|
112
|
+
"data": {
|
|
113
|
+
"ip": "8.8.8.8",
|
|
114
|
+
"geoLocation": {
|
|
115
|
+
"country": "United States",
|
|
116
|
+
"countryCode": "US",
|
|
117
|
+
"dialCode": "+1",
|
|
118
|
+
"isInEu": false
|
|
119
|
+
},
|
|
120
|
+
"timezone": { "timeZone": "America/Chicago" },
|
|
121
|
+
"flag": { "flagIcon": "🇺🇸", "flagUnicode": "U+1F1FA U+1F1F8" },
|
|
122
|
+
"currency": { "code": "USD", "namePlural": "US dollars" },
|
|
123
|
+
"connection": {
|
|
124
|
+
"asnNumber": 15169,
|
|
125
|
+
"asnOrg": "Google LLC",
|
|
126
|
+
"connectionType": "Corporate"
|
|
127
|
+
},
|
|
128
|
+
"security": { "isVpn": false, "isTor": false, "isThreat": "low" }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## API Reference
|
|
134
|
+
|
|
135
|
+
### `IpwhoApi::Client.new(api_key, options = {})`
|
|
136
|
+
|
|
137
|
+
Required key (query `apiKey`). `options[:base_url]` default `https://api.ipwho.org`. `options[:timeout]` default `30`.
|
|
138
|
+
|
|
139
|
+
### `lookup(ip, options = {})`
|
|
140
|
+
|
|
141
|
+
`options[:format]` json|xml|csv. `options[:fields]` comma-separated filter.
|
|
142
|
+
|
|
143
|
+
### `me(options = {})`
|
|
144
|
+
|
|
145
|
+
### `bulk(ips)`
|
|
146
|
+
|
|
147
|
+
Per-IP rows on `data.responseArray`.
|
|
148
|
+
|
|
149
|
+
### Errors
|
|
150
|
+
|
|
151
|
+
`IpwhoApi::InvalidIPError` (404), `RateLimitError` (429), `APIResponseError`.
|
|
152
|
+
|
|
153
|
+
## Type Definitions
|
|
154
|
+
|
|
155
|
+
Structs (keyword init):
|
|
156
|
+
|
|
157
|
+
- **GeoLocation**: `continent`, `continentCode`, `country`, `countryCode`, `capital`, `region`, `regionCode`, `city`, `postalCode`, `dialCode`, `isInEu`, `latitude`, `longitude`, `accuracyRadius`
|
|
158
|
+
- **Timezone**: `timeZone`, `abbr`, `offset`, `isDst`, `utc`, `currentTime`
|
|
159
|
+
- **Flag**: `flagIcon`, `flagUnicode`
|
|
160
|
+
- **Currency**: `code`, `symbol`, `name`, `namePlural`, `hexUnicode`
|
|
161
|
+
- **Connection**: `asnNumber`, `asnOrg`, `isp`, `org`, `domain`, `connectionType`
|
|
162
|
+
- **Security**: `isVpn`, `isTor`, `isThreat`
|
|
163
|
+
- **UserAgent**: `browser`, `engine`, `os`, `device`, `cpu`
|
|
164
|
+
- **GeoData**: `ip` plus the objects above
|
|
165
|
+
- **IpGeoResponse**: `success`, `data`, `message`
|
|
166
|
+
|
|
167
|
+
Wire keys such as `postal_Code` and `flag_Icon` are mapped onto these members.
|
|
168
|
+
|
|
169
|
+
## Troubleshooting
|
|
170
|
+
|
|
171
|
+
- Empty key → `APIResponseError`. Key: [ipwho.org](https://www.ipwho.org).
|
|
172
|
+
- HTTP 403: SDK sends `ipwho-ruby-sdk/1.0.0`.
|
|
173
|
+
- HTTP 429 / 404: `RateLimitError` / `InvalidIPError`.
|
|
174
|
+
- Nil nested structs on some IPs.
|
|
175
|
+
|
|
176
|
+
## Testing
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
IPWHO_API_KEY=your_key ruby test_ipwho.rb
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The live check is `test_ipwho.rb`.
|
|
183
|
+
|
|
184
|
+
## Changelog
|
|
185
|
+
|
|
186
|
+
### v1.0.0
|
|
187
|
+
|
|
188
|
+
- `lookup`, `me`, `bulk` matching [api.ipwho.org](https://api.ipwho.org)
|
|
189
|
+
- Full typed Struct models
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT License — see [LICENSE](LICENSE).
|
|
194
|
+
|
|
195
|
+
## Support
|
|
196
|
+
|
|
197
|
+
- Documentation: [ipwho.org/docs](https://www.ipwho.org/docs)
|
|
198
|
+
- Contact: [ipwho.org/contact](https://www.ipwho.org/contact)
|
|
199
|
+
- GitHub Issues: [lavrox/SDK-IPWho-IP-Geolocation-Ruby](https://github.com/lavrox/SDK-IPWho-IP-Geolocation-Ruby/issues)
|
|
200
|
+
- Website: [ipwho.org](https://www.ipwho.org)
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
[IPWho](https://www.ipwho.org) — a [Lavrox](https://lavrox.com) network API.
|
|
205
|
+
|
|
206
|
+
[Lavrox](https://lavrox.com) — Independent API infrastructure. Lower latency, lower cost.
|
data/lib/ipwho.rb
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# IPWho Ruby SDK v1.0.0
|
|
4
|
+
# ======================
|
|
5
|
+
#
|
|
6
|
+
# Enterprise-grade client for the IPWho IP Geolocation API.
|
|
7
|
+
# Requires Ruby >= 2.5, faraday ~> 1.0, json.
|
|
8
|
+
#
|
|
9
|
+
# Endpoints:
|
|
10
|
+
# - lookup(ip) — geolocation for a specific IPv4/IPv6 address
|
|
11
|
+
# - me() — geolocation for the caller's IP
|
|
12
|
+
# - bulk(ips) — batch lookup for multiple IPs
|
|
13
|
+
#
|
|
14
|
+
# API docs: https://api.ipwho.org
|
|
15
|
+
|
|
16
|
+
require "json"
|
|
17
|
+
require "faraday"
|
|
18
|
+
require "ostruct"
|
|
19
|
+
require "erb"
|
|
20
|
+
|
|
21
|
+
module IpwhoApi
|
|
22
|
+
VERSION = "1.0.0".freeze
|
|
23
|
+
|
|
24
|
+
# ── Exceptions ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
class Error < StandardError; end
|
|
27
|
+
class InvalidIPError < Error; end
|
|
28
|
+
class RateLimitError < Error; end
|
|
29
|
+
class APIResponseError < Error; end
|
|
30
|
+
|
|
31
|
+
# ── Data objects (Struct-based, mirror OpenAPI schema) ────────────
|
|
32
|
+
|
|
33
|
+
# All data objects use camelCase keys matching the API response.
|
|
34
|
+
|
|
35
|
+
GeoLocation = Struct.new(
|
|
36
|
+
:continent, :continentCode, :country, :countryCode, :capital,
|
|
37
|
+
:region, :regionCode, :city, :postalCode, :dialCode,
|
|
38
|
+
:isInEu, :latitude, :longitude, :accuracyRadius,
|
|
39
|
+
keyword_init: true
|
|
40
|
+
) do
|
|
41
|
+
def self.from_hash(hash)
|
|
42
|
+
new(
|
|
43
|
+
continent: hash["continent"],
|
|
44
|
+
continentCode: hash["continentCode"],
|
|
45
|
+
country: hash["country"],
|
|
46
|
+
countryCode: hash["countryCode"],
|
|
47
|
+
capital: hash["capital"],
|
|
48
|
+
region: hash["region"],
|
|
49
|
+
regionCode: hash["regionCode"],
|
|
50
|
+
city: hash["city"],
|
|
51
|
+
postalCode: hash["postal_Code"],
|
|
52
|
+
dialCode: hash["dial_code"],
|
|
53
|
+
isInEu: hash["is_in_eu"],
|
|
54
|
+
latitude: hash["latitude"],
|
|
55
|
+
longitude: hash["longitude"],
|
|
56
|
+
accuracyRadius: hash["accuracy_radius"]
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Timezone = Struct.new(
|
|
62
|
+
:timeZone, :abbr, :offset, :isDst, :utc, :currentTime,
|
|
63
|
+
keyword_init: true
|
|
64
|
+
) do
|
|
65
|
+
def self.from_hash(hash)
|
|
66
|
+
new(
|
|
67
|
+
timeZone: hash["time_zone"],
|
|
68
|
+
abbr: hash["abbr"],
|
|
69
|
+
offset: hash["offset"],
|
|
70
|
+
isDst: hash["is_dst"],
|
|
71
|
+
utc: hash["utc"],
|
|
72
|
+
currentTime: hash["current_time"]
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Flag = Struct.new(:flagIcon, :flagUnicode, keyword_init: true) do
|
|
78
|
+
def self.from_hash(hash)
|
|
79
|
+
new(flagIcon: hash["flag_Icon"], flagUnicode: hash["flag_unicode"])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Currency = Struct.new(
|
|
84
|
+
:code, :symbol, :name, :namePlural, :hexUnicode,
|
|
85
|
+
keyword_init: true
|
|
86
|
+
) do
|
|
87
|
+
def self.from_hash(hash)
|
|
88
|
+
new(
|
|
89
|
+
code: hash["code"],
|
|
90
|
+
symbol: hash["symbol"],
|
|
91
|
+
name: hash["name"],
|
|
92
|
+
namePlural: hash["name_plural"],
|
|
93
|
+
hexUnicode: hash["hex_unicode"]
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Connection = Struct.new(
|
|
99
|
+
:asnNumber, :asnOrg, :isp, :org, :domain, :connectionType,
|
|
100
|
+
keyword_init: true
|
|
101
|
+
) do
|
|
102
|
+
def self.from_hash(hash)
|
|
103
|
+
new(
|
|
104
|
+
asnNumber: hash["asn_number"],
|
|
105
|
+
asnOrg: hash["asn_org"],
|
|
106
|
+
isp: hash["isp"],
|
|
107
|
+
org: hash["org"],
|
|
108
|
+
domain: hash["domain"],
|
|
109
|
+
connectionType: hash["connection_type"]
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Security = Struct.new(:isVpn, :isTor, :isThreat, keyword_init: true) do
|
|
115
|
+
def self.from_hash(hash)
|
|
116
|
+
new(
|
|
117
|
+
isVpn: hash["isVpn"],
|
|
118
|
+
isTor: hash["isTor"],
|
|
119
|
+
isThreat: hash["isThreat"]
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
UserAgentBrowser = Struct.new(:name, :version, keyword_init: true) do
|
|
125
|
+
def self.from_hash(hash)
|
|
126
|
+
new(name: hash["name"], version: hash["version"])
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
UserAgentEngine = Struct.new(:name, :version, keyword_init: true) do
|
|
131
|
+
def self.from_hash(hash)
|
|
132
|
+
new(name: hash["name"], version: hash["version"])
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
UserAgentOS = Struct.new(:name, :version, keyword_init: true) do
|
|
137
|
+
def self.from_hash(hash)
|
|
138
|
+
new(name: hash["name"], version: hash["version"])
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
UserAgentDevice = Struct.new(:type, :vendor, :model, keyword_init: true) do
|
|
143
|
+
def self.from_hash(hash)
|
|
144
|
+
new(type: hash["type"], vendor: hash["vendor"], model: hash["model"])
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
UserAgentCPU = Struct.new(:architecture, keyword_init: true) do
|
|
149
|
+
def self.from_hash(hash)
|
|
150
|
+
new(architecture: hash["architecture"])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
UserAgent = Struct.new(:browser, :engine, :os, :device, :cpu, keyword_init: true) do
|
|
155
|
+
def self.from_hash(hash)
|
|
156
|
+
new(
|
|
157
|
+
browser: hash["browser"] ? UserAgentBrowser.from_hash(hash["browser"]) : nil,
|
|
158
|
+
engine: hash["engine"] ? UserAgentEngine.from_hash(hash["engine"]) : nil,
|
|
159
|
+
os: hash["os"] ? UserAgentOS.from_hash(hash["os"]) : nil,
|
|
160
|
+
device: hash["device"] ? UserAgentDevice.from_hash(hash["device"]) : nil,
|
|
161
|
+
cpu: hash["cpu"] ? UserAgentCPU.from_hash(hash["cpu"]) : nil
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
GeoData = Struct.new(
|
|
167
|
+
:ip, :geoLocation, :timezone, :flag, :currency, :connection, :security, :userAgent,
|
|
168
|
+
keyword_init: true
|
|
169
|
+
) do
|
|
170
|
+
def self.from_hash(hash)
|
|
171
|
+
new(
|
|
172
|
+
ip: hash["ip"] || "",
|
|
173
|
+
geoLocation: hash["geoLocation"] ? GeoLocation.from_hash(hash["geoLocation"]) : nil,
|
|
174
|
+
timezone: hash["timezone"] ? Timezone.from_hash(hash["timezone"]) : nil,
|
|
175
|
+
flag: hash["flag"] ? Flag.from_hash(hash["flag"]) : nil,
|
|
176
|
+
currency: hash["currency"] ? Currency.from_hash(hash["currency"]) : nil,
|
|
177
|
+
connection: hash["connection"] ? Connection.from_hash(hash["connection"]) : nil,
|
|
178
|
+
security: hash["security"] ? Security.from_hash(hash["security"]) : nil,
|
|
179
|
+
userAgent: hash["userAgent"] ? UserAgent.from_hash(hash["userAgent"]) : nil
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
ErrorResponse = Struct.new(:success, :message, keyword_init: true) do
|
|
185
|
+
def self.from_hash(hash)
|
|
186
|
+
new(success: hash["success"], message: hash["message"])
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
IpGeoResponse = Struct.new(:success, :data, :message, keyword_init: true) do
|
|
191
|
+
def self.from_hash(hash)
|
|
192
|
+
data_obj = nil
|
|
193
|
+
if hash["data"].is_a?(Hash)
|
|
194
|
+
# Bulk: normalise responseArray
|
|
195
|
+
if hash["data"]["responseArray"].is_a?(Array)
|
|
196
|
+
data_obj = hash["data"].dup
|
|
197
|
+
data_obj["responseArray"] = hash["data"]["responseArray"].map { |item| IpGeoResponse.from_hash(item) }
|
|
198
|
+
data_obj = OpenStruct.new(data_obj)
|
|
199
|
+
else
|
|
200
|
+
data_obj = GeoData.from_hash(hash["data"])
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
new(success: hash["success"], data: data_obj, message: hash["message"])
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ── Client ──────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
class Client
|
|
210
|
+
DEFAULT_BASE_URL = "https://api.ipwho.org".freeze
|
|
211
|
+
|
|
212
|
+
# @param api_key [String] Your IPWho API key (required).
|
|
213
|
+
# @param options [Hash]
|
|
214
|
+
# @option options [String] :base_url Override base URL (default https://api.ipwho.org).
|
|
215
|
+
# @option options [Numeric] :timeout Request timeout in seconds (default 30).
|
|
216
|
+
def initialize(api_key, options = {})
|
|
217
|
+
raise APIResponseError, "api_key is required" if api_key.nil? || api_key.empty?
|
|
218
|
+
|
|
219
|
+
@api_key = api_key
|
|
220
|
+
@base_url = (options[:base_url] || DEFAULT_BASE_URL).sub(%r{/+$}, "")
|
|
221
|
+
@timeout = options[:timeout] || 30
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ── public API ──────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
# Look up geolocation for a specific IPv4 or IPv6 address.
|
|
227
|
+
#
|
|
228
|
+
# @param ip [String] The IP address.
|
|
229
|
+
# @param options [Hash]
|
|
230
|
+
# @option options [String] :format json|xml|csv (default json)
|
|
231
|
+
# @option options [String] :fields Comma-separated fields filter
|
|
232
|
+
# @return [IpwhoApi::IpGeoResponse]
|
|
233
|
+
# @raise [IpwhoApi::InvalidIPError] – 404
|
|
234
|
+
# @raise [IpwhoApi::RateLimitError] – 429
|
|
235
|
+
# @raise [IpwhoApi::APIResponseError] – other errors
|
|
236
|
+
def lookup(ip, options = {})
|
|
237
|
+
format = options[:format] || "json"
|
|
238
|
+
params = build_params(format, options[:fields])
|
|
239
|
+
get("/ip/#{ERB::Util.url_encode(ip)}", params, format)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Look up geolocation for the caller's own IP address.
|
|
243
|
+
#
|
|
244
|
+
# @param options [Hash]
|
|
245
|
+
# @option options [String] :format json|xml|csv (default json)
|
|
246
|
+
# @option options [String] :fields Comma-separated fields filter
|
|
247
|
+
# @return [IpwhoApi::IpGeoResponse]
|
|
248
|
+
# @raise [IpwhoApi::APIResponseError]
|
|
249
|
+
def me(options = {})
|
|
250
|
+
format = options[:format] || "json"
|
|
251
|
+
params = build_params(format, options[:fields])
|
|
252
|
+
get("/me", params, format)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Batch-lookup multiple IP addresses.
|
|
256
|
+
#
|
|
257
|
+
# @param ips [Array<String>] List of IPv4 or IPv6 addresses.
|
|
258
|
+
# @return [IpwhoApi::IpGeoResponse] Response with data.responseArray of IpGeoResponse.
|
|
259
|
+
# @raise [IpwhoApi::APIResponseError]
|
|
260
|
+
def bulk(ips)
|
|
261
|
+
raise APIResponseError, "ips must not be empty" if ips.nil? || ips.empty?
|
|
262
|
+
|
|
263
|
+
bulk_param = ips.join(",")
|
|
264
|
+
params = { apiKey: @api_key }
|
|
265
|
+
get("/bulk/#{ERB::Util.url_encode(bulk_param)}", params, "json")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
# Build query parameters.
|
|
271
|
+
def build_params(format, fields)
|
|
272
|
+
params = { apiKey: @api_key }
|
|
273
|
+
params[:format] = format if format != "json"
|
|
274
|
+
params[:get] = fields if fields && !fields.empty?
|
|
275
|
+
params
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Execute a GET request and return typed response.
|
|
279
|
+
def get(path, params, format)
|
|
280
|
+
conn = Faraday.new(url: @base_url) do |f|
|
|
281
|
+
f.headers["Accept"] = "application/json"
|
|
282
|
+
f.headers["User-Agent"] = "ipwho-ruby-sdk/#{VERSION}"
|
|
283
|
+
f.options.timeout = @timeout
|
|
284
|
+
f.options.open_timeout = @timeout
|
|
285
|
+
f.adapter Faraday.default_adapter
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
response = conn.get(path, params)
|
|
289
|
+
status = response.status
|
|
290
|
+
body = response.body
|
|
291
|
+
|
|
292
|
+
# Rate limit
|
|
293
|
+
raise RateLimitError, error_message(body, status) if status == 429
|
|
294
|
+
|
|
295
|
+
# Not found
|
|
296
|
+
raise InvalidIPError, error_message(body, status) if status == 404
|
|
297
|
+
|
|
298
|
+
# Other HTTP errors
|
|
299
|
+
raise APIResponseError, "HTTP #{status}: #{error_message(body, status)}" unless status >= 200 && status < 300
|
|
300
|
+
|
|
301
|
+
# Non-JSON formats: wrap raw text
|
|
302
|
+
unless format == "json"
|
|
303
|
+
return IpGeoResponse.new(
|
|
304
|
+
success: true,
|
|
305
|
+
data: GeoData.new(ip: body),
|
|
306
|
+
message: nil
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# JSON success path
|
|
311
|
+
payload = JSON.parse(body)
|
|
312
|
+
|
|
313
|
+
# Logical error in success=false
|
|
314
|
+
if payload["success"] == false
|
|
315
|
+
raise APIResponseError, payload["message"] || "API returned success=false"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
IpGeoResponse.from_hash(payload)
|
|
319
|
+
rescue Faraday::Error => e
|
|
320
|
+
raise APIResponseError, "Request failed: #{e.message}"
|
|
321
|
+
rescue JSON::ParserError
|
|
322
|
+
raise APIResponseError, "Invalid JSON response"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Best-effort extraction of error message.
|
|
326
|
+
def error_message(body, _status)
|
|
327
|
+
return "Unknown API error" if body.nil? || body.empty?
|
|
328
|
+
|
|
329
|
+
parsed = JSON.parse(body) rescue nil
|
|
330
|
+
return parsed["message"] if parsed.is_a?(Hash) && parsed["message"]
|
|
331
|
+
|
|
332
|
+
body
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
338
|
+
# Example usage
|
|
339
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
340
|
+
|
|
341
|
+
if __FILE__ == $PROGRAM_NAME
|
|
342
|
+
require "json"
|
|
343
|
+
require "faraday"
|
|
344
|
+
require "ostruct"
|
|
345
|
+
require "erb"
|
|
346
|
+
|
|
347
|
+
api_key = ENV["IPWHO_API_KEY"] || "sk.xxxx"
|
|
348
|
+
client = IpwhoApi::Client.new(api_key)
|
|
349
|
+
|
|
350
|
+
# Single lookup
|
|
351
|
+
resp = client.lookup("8.8.8.8")
|
|
352
|
+
if resp.data&.geoLocation
|
|
353
|
+
gl = resp.data.geoLocation
|
|
354
|
+
puts "IP: #{resp.data.ip} | #{gl.city}, #{gl.country} (#{gl.latitude}, #{gl.longitude})"
|
|
355
|
+
end
|
|
356
|
+
if resp.data&.currency
|
|
357
|
+
puts "Currency: #{resp.data.currency.code} (#{resp.data.currency.symbol})"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Self lookup
|
|
361
|
+
me = client.me
|
|
362
|
+
puts "\nMy IP: #{me.data&.ip || 'unknown'}"
|
|
363
|
+
|
|
364
|
+
# Bulk
|
|
365
|
+
bulk = client.bulk(["8.8.8.8", "1.1.1.1"])
|
|
366
|
+
puts "\nBulk: success=#{bulk.success}"
|
|
367
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ipwho
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- IPWho SDK Contributors
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: faraday
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.0'
|
|
26
|
+
description: Geoip lookup, IP location, ASN/ISP, timezone, currency, flag, and VPN/proxy
|
|
27
|
+
detection for IPv4 and IPv6 via api.ipwho.org.
|
|
28
|
+
email:
|
|
29
|
+
- info@ipwho.org
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- CHANGELOG.md
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- lib/ipwho.rb
|
|
38
|
+
homepage: https://www.ipwho.org
|
|
39
|
+
licenses:
|
|
40
|
+
- MIT
|
|
41
|
+
metadata:
|
|
42
|
+
homepage_uri: https://www.ipwho.org
|
|
43
|
+
source_code_uri: https://github.com/lavrox/SDK-IPWho-IP-Geolocation-Ruby
|
|
44
|
+
changelog_uri: https://github.com/lavrox/SDK-IPWho-IP-Geolocation-Ruby/blob/main/CHANGELOG.md
|
|
45
|
+
bug_tracker_uri: https://github.com/lavrox/SDK-IPWho-IP-Geolocation-Ruby/issues
|
|
46
|
+
documentation_uri: https://www.ipwho.org/docs
|
|
47
|
+
allowed_push_host: https://rubygems.org
|
|
48
|
+
rubygems_mfa_required: 'true'
|
|
49
|
+
rdoc_options: []
|
|
50
|
+
require_paths:
|
|
51
|
+
- lib
|
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: 2.5.0
|
|
57
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0'
|
|
62
|
+
requirements: []
|
|
63
|
+
rubygems_version: 4.0.16
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: Official Ruby SDK for the IPWho IP geolocation API (lookup, me, bulk).
|
|
66
|
+
test_files: []
|