ipwhois 1.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 439b7008b4082c53b21db0360b7d1b1914e890d069e43cbd451fae937a6768d6
4
+ data.tar.gz: f410bbc7ba36f0f3f0cecb0744ec1bbc3be19e98737bf064acee1b6be6f69395
5
+ SHA512:
6
+ metadata.gz: 6fc2bb733df8f1a9fdee24b6cdb63e03aa6881e0a92b54ee33b894ed96a5ff547222ef8c5b30c12da39247df4226e21aba33a11a989f2ee54ce2d56680c6c529
7
+ data.tar.gz: 4188276ad8f840c73ef56b8a4623fc8fbb845b2f20af7961fb7ad2232a5457e047c20318c98baf5f0321352ba1157cccd22c5a35cc477149527893ce95624c13
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `ipwhois` gem will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.2.0] - 2026-05-12
9
+
10
+ ### Added
11
+
12
+ - Initial public release of the `ipwhois` gem.
13
+ - `Ipwhois::Client#lookup` — single IP lookup (IPv4 / IPv6, or current IP).
14
+ - `Ipwhois::Client#bulk_lookup` — up to 100 IPs in a single GET request
15
+ (paid plan).
16
+ - Free plan (no API key, `ipwho.is`) and paid plan (with API key,
17
+ `ipwhois.pro`) served by the same `Client` class.
18
+ - HTTPS by default; `ssl: false` constructor option to fall back to HTTP.
19
+ - Localisation (`:lang`), field filtering (`:fields`), threat detection
20
+ (`:security`), rate-limit info (`:rate`).
21
+ - Fluent client-wide defaults: `set_language`, `set_fields`, `set_security`,
22
+ `set_rate`, `set_timeout`, `set_connect_timeout`, `set_user_agent` — each
23
+ returns `self` and is chainable.
24
+ - **The library never raises.** All errors — invalid input, network failure,
25
+ API-level errors (bad IP, bad key, rate limit, …) — are returned in the
26
+ response hash with `'success' => false` and a `'message'`. HTTP error
27
+ responses are additionally enriched with `'http_status'`, and HTTP 429
28
+ responses on the free plan with `'retry_after'`.
29
+ - Every error response carries an `'error_type'` field: one of `'api'`,
30
+ `'network'`, or `'invalid_argument'`, so callers can branch on the failure
31
+ category with a single `info['error_type']` check.
32
+ - Minitest test suite covering URL construction, IPv6 path encoding, and
33
+ input validation. No real HTTP request is sent — the suite runs anywhere
34
+ without an API key or network access.
35
+ - No runtime dependencies — uses only the Ruby stdlib (`net/http`, `uri`,
36
+ `json`, `openssl`).
37
+ - Ruby `>= 3.0` requirement.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ipwhois.io
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,351 @@
1
+ # ipwhois
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/ipwhois.svg)](https://rubygems.org/gems/ipwhois)
4
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.0-blue.svg)](https://www.ruby-lang.org)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Official, dependency-free Ruby client for the [ipwhois.io](https://ipwhois.io)
8
+ IP Geolocation API.
9
+
10
+ - ✅ Single and bulk IP lookups (IPv4 and IPv6)
11
+ - ✅ Works with both the **Free** and **Paid** plans
12
+ - ✅ HTTPS by default
13
+ - ✅ Localisation, field selection, threat detection, rate info
14
+ - ✅ Never raises — all errors returned as `'success' => false` hashes
15
+ - ✅ No runtime dependencies — Ruby stdlib only (`net/http`, `uri`, `json`, `openssl`)
16
+ - ✅ Ruby 3.0+
17
+
18
+ ## Installation
19
+
20
+ Add to your `Gemfile`:
21
+
22
+ ```ruby
23
+ gem 'ipwhois'
24
+ ```
25
+
26
+ Then run:
27
+
28
+ ```bash
29
+ bundle install
30
+ ```
31
+
32
+ Or install directly:
33
+
34
+ ```bash
35
+ gem install ipwhois
36
+ ```
37
+
38
+ ## Free vs Paid plan
39
+
40
+ The same `Ipwhois::Client` class is used for both plans. The only difference is
41
+ whether you pass an API key:
42
+
43
+ - **Free plan** — create the client **without arguments**. No API key, no
44
+ signup required. Suitable for low-traffic and non-commercial use.
45
+ - **Paid plan** — create the client **with your API key** from
46
+ <https://ipwhois.io>. Higher limits, plus access to bulk lookups and
47
+ threat-detection data.
48
+
49
+ ```ruby
50
+ free = Ipwhois::Client.new # Free plan — no API key
51
+ paid = Ipwhois::Client.new('YOUR_API_KEY') # Paid plan — with API key
52
+ ```
53
+
54
+ Everything else (`lookup`, options, error handling) is identical.
55
+
56
+ ## Quick start — Free plan (no API key)
57
+
58
+ ```ruby
59
+ require 'ipwhois'
60
+
61
+ client = Ipwhois::Client.new # no API key
62
+
63
+ info = client.lookup('8.8.8.8')
64
+
65
+ puts "#{info['country']} #{info.dig('flag', 'emoji')}"
66
+ # → United States 🇺🇸
67
+
68
+ puts "#{info['city']}, #{info['region']}"
69
+ # → Mountain View, California
70
+ ```
71
+
72
+ ## Quick start — Paid plan (with API key)
73
+
74
+ Get an API key at <https://ipwhois.io> and pass it to the constructor:
75
+
76
+ ```ruby
77
+ require 'ipwhois'
78
+
79
+ client = Ipwhois::Client.new('YOUR_API_KEY') # with API key
80
+
81
+ info = client.lookup('8.8.8.8')
82
+
83
+ puts "#{info['country']} #{info.dig('flag', 'emoji')}"
84
+ # → United States 🇺🇸
85
+
86
+ puts "#{info['city']}, #{info['region']}"
87
+ # → Mountain View, California
88
+ ```
89
+
90
+ > ℹ️ Pass nothing to look up your own public IP: `client.lookup` — works
91
+ > on both plans.
92
+
93
+ ## Lookup options
94
+
95
+ Every option below can be passed per call, or set once on the client as a
96
+ default.
97
+
98
+ | Option | Type | Plans needed | Description |
99
+ | ------------------ | -------- | -------------------- | -------------------------------------------------------------------------- |
100
+ | `:lang` | String | Free + Paid | One of: `'en'`, `'ru'`, `'de'`, `'es'`, `'pt-BR'`, `'fr'`, `'zh-CN'`, `'ja'` |
101
+ | `:fields` | Array | Free + Paid | Restrict the response to specific fields (e.g. `%w[country city]`) |
102
+ | `:rate` | Boolean | Basic and above | Include the `rate` block (`limit`, `remaining`) |
103
+ | `:security` | Boolean | Business and above | Include the `security` block (proxy/vpn/tor/hosting) |
104
+
105
+ ### Setting defaults once
106
+
107
+ Every option can be passed two ways: **per call** (as keyword arguments to
108
+ `lookup` / `bulk_lookup`) or **once as a default** on the client. Per-call
109
+ options always override the defaults, so it's safe to set sensible defaults
110
+ and only override what differs for a specific call.
111
+
112
+ Defaults are set with fluent setters — `set_language`, `set_fields`,
113
+ `set_security`, `set_rate`, `set_timeout`, `set_connect_timeout`,
114
+ `set_user_agent` — and can be chained:
115
+
116
+ ```ruby
117
+ # Free plan
118
+ client = Ipwhois::Client.new
119
+ .set_language('en')
120
+ .set_fields(%w[success country city flag.emoji])
121
+ .set_timeout(8)
122
+ ```
123
+
124
+ ```ruby
125
+ # Paid plan
126
+ client = Ipwhois::Client.new('YOUR_API_KEY')
127
+ .set_language('en')
128
+ .set_fields(%w[success country city flag.emoji])
129
+ .set_timeout(8)
130
+ ```
131
+
132
+ Either client behaves the same way at call time — per-call options always
133
+ win over the defaults:
134
+
135
+ ```ruby
136
+ client.lookup('8.8.8.8') # uses lang=en, the field whitelist, and timeout=8
137
+ client.lookup('1.1.1.1', lang: 'de') # overrides lang for this single call only
138
+ ```
139
+
140
+ > ⚠️ When you restrict fields with `set_fields` (or the per-call `:fields`
141
+ > option), the API only returns the fields you ask for. Always include
142
+ > `'success'` in the list if you rely on `info['success']` for error
143
+ > checking — otherwise the field will be missing on responses.
144
+
145
+ > ℹ️ `set_security(true)` requires Business+ and `set_rate(true)` requires
146
+ > Basic+. See the table above for what's available where.
147
+
148
+ ## HTTPS Encryption
149
+
150
+ By default, all requests are sent over HTTPS. If you need to disable it (for
151
+ example, in environments without an up-to-date CA bundle), pass `ssl: false`
152
+ to the constructor:
153
+
154
+ ```ruby
155
+ # Free plan
156
+ client = Ipwhois::Client.new(nil, ssl: false)
157
+ ```
158
+
159
+ ```ruby
160
+ # Paid plan
161
+ client = Ipwhois::Client.new('YOUR_API_KEY', ssl: false)
162
+ ```
163
+
164
+ > ℹ️ HTTPS is strongly recommended for production traffic — your API key is
165
+ > sent in the query string and would otherwise travel in clear text.
166
+
167
+ ## Bulk lookup (Paid plan only)
168
+
169
+ The bulk endpoint sends **up to 100 IPs** in a single GET request. Each
170
+ address counts as one credit. Available on the **Business** and **Unlimited**
171
+ plans.
172
+
173
+ ```ruby
174
+ client = Ipwhois::Client.new('YOUR_API_KEY')
175
+
176
+ results = client.bulk_lookup([
177
+ '8.8.8.8',
178
+ '1.1.1.1',
179
+ '208.67.222.222',
180
+ '2c0f:fb50:4003::' # IPv6 is fine — mix freely
181
+ ])
182
+
183
+ results.each do |row|
184
+ if row['success'] == false
185
+ # Per-IP errors (e.g. "Invalid IP address") are returned inline,
186
+ # they do NOT raise — the rest of the batch is still usable.
187
+ puts "skip #{row['ip']}: #{row['message']}"
188
+ next
189
+ end
190
+ puts "#{row['ip']} → #{row['country']}"
191
+ end
192
+ ```
193
+
194
+ > ℹ️ Bulk requires an API key. Calling `bulk_lookup` without one will fail
195
+ > at the API level.
196
+
197
+ ## Error handling
198
+
199
+ **The library never raises.** Every failure — invalid IP, bad API key, rate
200
+ limit, network outage, bad options — comes back inside the response hash
201
+ with `'success' => false` and a `'message'`. Just check `info['success']`
202
+ after every call:
203
+
204
+ ```ruby
205
+ info = client.lookup('8.8.8.8')
206
+
207
+ unless info['success']
208
+ warn "Lookup failed: #{info['message']}"
209
+ return
210
+ end
211
+
212
+ puts info['country']
213
+ ```
214
+
215
+ This means an outage of the ipwhois.io API (or of your server's DNS,
216
+ connection, etc.) will never surface as a fatal error in your application —
217
+ you decide how to react.
218
+
219
+ ### Error response fields
220
+
221
+ Every error response contains `'success' => false`, a human-readable
222
+ `'message'`, and an `'error_type'` so you can branch on the category of the
223
+ failure. Some errors include extra fields you can branch on:
224
+
225
+ | Field | When it's present |
226
+ | --------------- | -------------------------------------------------------------------------------------------- |
227
+ | `'success'` | Always — false for error responses (true for successful responses) |
228
+ | `'message'` | Always — human-readable description of what went wrong |
229
+ | `'error_type'` | Always — one of `'api'`, `'network'`, or `'invalid_argument'` |
230
+ | `'http_status'` | On HTTP 4xx / 5xx responses |
231
+ | `'retry_after'` | On HTTP 429 — **free plan only** (the paid endpoint does not send a `Retry-After` header) |
232
+
233
+ ```ruby
234
+ info = client.lookup('8.8.8.8')
235
+
236
+ unless info['success']
237
+ if info['http_status'] == 429
238
+ sleep(info['retry_after'] || 60)
239
+ # …retry
240
+ end
241
+ if info['error_type'] == 'network'
242
+ # DNS failure, connection refused, timeout, …
243
+ end
244
+ warn "Error: #{info['message']}"
245
+ return
246
+ end
247
+ ```
248
+
249
+ ## Response shape
250
+
251
+ A successful response includes (depending on your plan and selected options):
252
+
253
+ ```jsonc
254
+ {
255
+ "ip": "8.8.4.4",
256
+ "success": true,
257
+ "type": "IPv4",
258
+ "continent": "North America",
259
+ "continent_code": "NA",
260
+ "country": "United States",
261
+ "country_code": "US",
262
+ "region": "California",
263
+ "region_code": "CA",
264
+ "city": "Mountain View",
265
+ "latitude": 37.3860517,
266
+ "longitude": -122.0838511,
267
+ "is_eu": false,
268
+ "postal": "94039",
269
+ "calling_code": "1",
270
+ "capital": "Washington D.C.",
271
+ "borders": "CA,MX",
272
+ "flag": {
273
+ "img": "https://cdn.ipwhois.io/flags/us.svg",
274
+ "emoji": "🇺🇸",
275
+ "emoji_unicode": "U+1F1FA U+1F1F8"
276
+ },
277
+ "connection": {
278
+ "asn": 15169,
279
+ "org": "Google LLC",
280
+ "isp": "Google LLC",
281
+ "domain": "google.com"
282
+ },
283
+ "timezone": {
284
+ "id": "America/Los_Angeles",
285
+ "abbr": "PDT",
286
+ "is_dst": true,
287
+ "offset": -25200,
288
+ "utc": "-07:00",
289
+ "current_time": "2026-05-08T14:31:48-07:00"
290
+ },
291
+ "currency": {
292
+ "name": "US Dollar",
293
+ "code": "USD",
294
+ "symbol": "$",
295
+ "plural": "US dollars",
296
+ "exchange_rate": 1
297
+ },
298
+ "security": {
299
+ "anonymous": false,
300
+ "proxy": false,
301
+ "vpn": false,
302
+ "tor": false,
303
+ "hosting": false
304
+ },
305
+ "rate": {
306
+ "limit": 250000,
307
+ "remaining": 50155
308
+ }
309
+ }
310
+ ```
311
+
312
+ Responses are parsed with the stdlib `JSON` module, so all hashes use **string
313
+ keys** (`info['country']`, not `info[:country]`). Use `Hash#dig` for safe
314
+ access to nested keys: `info.dig('flag', 'emoji')`.
315
+
316
+ For the full field reference, see the [official documentation](https://ipwhois.io/documentation).
317
+
318
+ An **error** response looks like:
319
+
320
+ ```jsonc
321
+ {
322
+ "success": false,
323
+ "message": "Rate limit exceeded",
324
+ "error_type": "api", // 'api' / 'network' / 'invalid_argument'
325
+ "http_status": 429, // present for HTTP 4xx / 5xx
326
+ "retry_after": 60 // additionally present on HTTP 429 — free plan only
327
+ }
328
+ ```
329
+
330
+ ## Requirements
331
+
332
+ - Ruby **3.0** or newer
333
+ - No runtime gem dependencies (uses stdlib only)
334
+
335
+ ## Development
336
+
337
+ ```bash
338
+ git clone https://github.com/IPWhois/ipwhois-ruby.git
339
+ cd ipwhois-ruby
340
+ bundle install
341
+ rake test
342
+ ```
343
+
344
+ ## Contributing
345
+
346
+ Issues and pull requests are welcome on
347
+ [GitHub](https://github.com/IPWhois/ipwhois-ruby).
348
+
349
+ ## License
350
+
351
+ [MIT](LICENSE) © ipwhois.io
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'openssl'
7
+
8
+ module Ipwhois
9
+ # Ruby client for the ipwhois.io IP Geolocation API.
10
+ #
11
+ # Quick start
12
+ # -----------
13
+ # # Free plan (no API key, ~1 request/second per client IP)
14
+ # client = Ipwhois::Client.new
15
+ # info = client.lookup('8.8.8.8')
16
+ #
17
+ # # Paid plan (with API key, higher limits, bulk, security data, …)
18
+ # client = Ipwhois::Client.new('YOUR_API_KEY')
19
+ # info = client.lookup('8.8.8.8', lang: 'en', security: true)
20
+ #
21
+ # # Bulk lookup — up to 100 IPs in one call (paid only)
22
+ # list = client.bulk_lookup(['8.8.8.8', '1.1.1.1', '208.67.222.222'])
23
+ #
24
+ # # HTTPS is enabled by default. Pass `ssl: false` to fall back to HTTP.
25
+ #
26
+ # Error handling
27
+ # --------------
28
+ # The library never raises. All errors — invalid input, network failure,
29
+ # API-level errors (bad IP, bad key, rate limit, …) — are returned in the
30
+ # response hash with `'success' => false` and a `'message'`. Just check
31
+ # `info['success']` after every call.
32
+ class Client
33
+ # Free-plan endpoint host (used when no API key is provided).
34
+ HOST_FREE = 'ipwho.is'
35
+
36
+ # Paid-plan endpoint host (used when an API key is provided).
37
+ HOST_PAID = 'ipwhois.pro'
38
+
39
+ # Maximum number of IP addresses allowed in a single bulk request.
40
+ BULK_LIMIT = 100
41
+
42
+ # Languages supported by the `lang` option.
43
+ SUPPORTED_LANGUAGES = %w[en ru de es pt-BR fr zh-CN ja].freeze
44
+
45
+ DEFAULT_TIMEOUT = 10
46
+ DEFAULT_CONNECT_TIMEOUT = 5
47
+ MAX_REDIRECTS = 3
48
+
49
+ # @param api_key [String, nil] Your ipwhois.io API key. Omit (or pass nil)
50
+ # for the free plan.
51
+ # @param options [Hash] Optional defaults applied to every request.
52
+ # Recognised keys: `:lang`, `:fields`, `:security`, `:rate`, `:ssl`,
53
+ # `:timeout`, `:connect_timeout`, `:user_agent`.
54
+ def initialize(api_key = nil, **options)
55
+ @api_key = api_key
56
+ @timeout = options.delete(:timeout) || DEFAULT_TIMEOUT
57
+ @connect_timeout = options.delete(:connect_timeout) || DEFAULT_CONNECT_TIMEOUT
58
+ @user_agent = options.delete(:user_agent) || "ipwhois-ruby/#{Ipwhois::VERSION}"
59
+ @ssl = options.key?(:ssl) ? options.delete(:ssl) != false : true
60
+ @defaults = options
61
+ end
62
+
63
+ # Look up information for a single IP address.
64
+ #
65
+ # Pass `nil` (or call without arguments) to look up the caller's own
66
+ # public IP, as documented at https://ipwhois.io/documentation.
67
+ #
68
+ # The library never raises — check `result['success']` after every call.
69
+ #
70
+ # @param ip [String, nil] IPv4 or IPv6 address. nil = current IP.
71
+ # @param options [Hash] Per-call options: `:lang`, `:fields`, `:security`,
72
+ # `:rate`.
73
+ # @return [Hash] Decoded JSON response. On any error (API, network, bad
74
+ # input) the hash contains `'success' => false` and `'message'`. The
75
+ # library never raises.
76
+ def lookup(ip = nil, **options)
77
+ error = validate_options(options)
78
+ return error if error
79
+
80
+ path = ip.nil? ? '/' : "/#{escape_path_segment(ip.to_s)}"
81
+ url = build_url(path, options)
82
+
83
+ request(url)
84
+ end
85
+
86
+ # Look up information for multiple IP addresses in a single request.
87
+ #
88
+ # Uses the GET / comma-separated form documented at
89
+ # https://ipwhois.io/documentation/bulk — up to 100 addresses per call.
90
+ # Each address counts as one credit.
91
+ #
92
+ # Available on the Business and Unlimited plans only.
93
+ #
94
+ # Per-IP errors are returned inline with `'success' => false` for the
95
+ # affected entry; the rest of the batch is still usable. If the whole
96
+ # call fails, the response is a single error hash with `'success' => false`
97
+ # instead of an array.
98
+ #
99
+ # @param ips [Array<String>] Up to 100 IPv4/IPv6 addresses (mixable).
100
+ # @param options [Hash] Per-call options (same keys as {#lookup}).
101
+ # @return [Array<Hash>, Hash] Array of per-IP results on success; a single
102
+ # error hash on whole-batch failure. The library never raises.
103
+ def bulk_lookup(ips, **options)
104
+ unless ips.is_a?(Array)
105
+ return {
106
+ 'success' => false,
107
+ 'message' => 'Bulk lookup requires an Array of IP addresses.',
108
+ 'error_type' => 'invalid_argument'
109
+ }
110
+ end
111
+
112
+ if ips.empty?
113
+ return {
114
+ 'success' => false,
115
+ 'message' => 'Bulk lookup requires at least one IP address.',
116
+ 'error_type' => 'invalid_argument'
117
+ }
118
+ end
119
+
120
+ if ips.size > BULK_LIMIT
121
+ return {
122
+ 'success' => false,
123
+ 'message' => "Bulk lookup accepts at most #{BULK_LIMIT} IP addresses per call, got #{ips.size}.",
124
+ 'error_type' => 'invalid_argument'
125
+ }
126
+ end
127
+
128
+ error = validate_options(options)
129
+ return error if error
130
+
131
+ # The API accepts addresses joined by commas — no URL-encoding of the
132
+ # commas themselves, otherwise the path is misinterpreted.
133
+ joined = ips.map { |ip| escape_path_segment(ip.to_s) }.join(',')
134
+ url = build_url("/bulk/#{joined}", options)
135
+
136
+ request(url)
137
+ end
138
+
139
+ # Set the default language used when none is supplied per call.
140
+ #
141
+ # @param lang [String] One of {SUPPORTED_LANGUAGES}.
142
+ # @return [self]
143
+ def set_language(lang)
144
+ @defaults[:lang] = lang
145
+ self
146
+ end
147
+
148
+ # Restrict every response to a fixed set of fields by default.
149
+ #
150
+ # Include `'success'` in the list if you rely on `info['success']` for
151
+ # error checking — when `:fields` is set, the API only returns the fields
152
+ # you ask for.
153
+ #
154
+ # @param fields [Array<String>] For example: %w[success country city flag.emoji].
155
+ # @return [self]
156
+ def set_fields(fields)
157
+ @defaults[:fields] = fields
158
+ self
159
+ end
160
+
161
+ # Enable or disable threat-detection data on every call by default.
162
+ # @return [self]
163
+ def set_security(enabled)
164
+ @defaults[:security] = enabled
165
+ self
166
+ end
167
+
168
+ # Enable or disable the `rate` block in responses by default.
169
+ # @return [self]
170
+ def set_rate(enabled)
171
+ @defaults[:rate] = enabled
172
+ self
173
+ end
174
+
175
+ # Set the per-request total timeout in seconds (default: 10).
176
+ # @return [self]
177
+ def set_timeout(seconds)
178
+ @timeout = seconds
179
+ self
180
+ end
181
+
182
+ # Set the connection timeout in seconds (default: 5).
183
+ # @return [self]
184
+ def set_connect_timeout(seconds)
185
+ @connect_timeout = seconds
186
+ self
187
+ end
188
+
189
+ # Override the User-Agent header sent with every request.
190
+ # @return [self]
191
+ def set_user_agent(user_agent)
192
+ @user_agent = user_agent
193
+ self
194
+ end
195
+
196
+ # ------------------------------------------------------------------
197
+ # Internals
198
+ # ------------------------------------------------------------------
199
+
200
+ private
201
+
202
+ # Validate per-call options. Returns an error hash on the first invalid
203
+ # option, or nil if everything looks OK.
204
+ def validate_options(options)
205
+ merged = @defaults.merge(options)
206
+
207
+ if merged[:lang]
208
+ lang = merged[:lang].to_s
209
+ unless SUPPORTED_LANGUAGES.include?(lang)
210
+ return {
211
+ 'success' => false,
212
+ 'message' => "Unsupported language \"#{lang}\". Supported: #{SUPPORTED_LANGUAGES.join(', ')}.",
213
+ 'error_type' => 'invalid_argument'
214
+ }
215
+ end
216
+ end
217
+
218
+ nil
219
+ end
220
+
221
+ # Build the full URL for a given path + options.
222
+ def build_url(path, options)
223
+ host = @api_key ? HOST_PAID : HOST_FREE
224
+
225
+ # Per-call options win over defaults.
226
+ merged = @defaults.merge(options)
227
+
228
+ query = []
229
+ query << ['key', @api_key] if @api_key
230
+ query << ['lang', merged[:lang].to_s] if merged[:lang]
231
+
232
+ if merged[:fields]
233
+ fields_value = merged[:fields].is_a?(Array) ? merged[:fields].join(',') : merged[:fields].to_s
234
+ query << ['fields', fields_value]
235
+ end
236
+
237
+ query << ['security', '1'] if merged[:security]
238
+ query << ['rate', '1'] if merged[:rate]
239
+
240
+ scheme = @ssl ? 'https' : 'http'
241
+ url = "#{scheme}://#{host}#{path}"
242
+ url += "?#{URI.encode_www_form(query)}" unless query.empty?
243
+ url
244
+ end
245
+
246
+ # RFC 3986 percent-encoding for a single path segment — encodes
247
+ # everything outside unreserved chars, in particular the `:` characters
248
+ # in IPv6 addresses, which would otherwise be interpreted as a port
249
+ # separator by URI parsers.
250
+ def escape_path_segment(str)
251
+ str.gsub(/([^A-Za-z0-9\-._~])/) { |c| format('%%%02X', c.ord) }
252
+ end
253
+
254
+ # Perform a GET request and return the decoded JSON body.
255
+ def request(url)
256
+ uri = URI.parse(url)
257
+
258
+ http_response = perform_http_get(uri, MAX_REDIRECTS)
259
+ # On network failure perform_http_get returns a pre-shaped error hash.
260
+ return http_response if http_response.is_a?(Hash)
261
+
262
+ status_code = http_response.code.to_i
263
+ body = http_response.body.to_s
264
+
265
+ decoded =
266
+ if body.empty?
267
+ nil
268
+ else
269
+ begin
270
+ JSON.parse(body)
271
+ rescue JSON::ParserError
272
+ # The ipwhois API always returns JSON. A non-JSON body means
273
+ # something went wrong upstream (gateway error page, captive
274
+ # portal, hijacked response, …) — synthesise an error hash so
275
+ # the caller can handle it the same way as a normal API error.
276
+ snippet = body.gsub(/\s+/, ' ').strip
277
+ snippet = "#{snippet[0, 200]}…" if snippet.length > 200
278
+ return {
279
+ 'success' => false,
280
+ 'message' => "Invalid JSON returned by ipwhois API (HTTP #{status_code}): #{snippet}",
281
+ 'http_status' => status_code,
282
+ 'error_type' => 'api'
283
+ }
284
+ end
285
+ end
286
+
287
+ # Normalise plain values into a hash so the rest of the pipeline can
288
+ # treat the result uniformly. Arrays (bulk responses) are passed
289
+ # through as-is.
290
+ decoded = {} if decoded.nil?
291
+ decoded = { 'value' => decoded } unless decoded.is_a?(Hash) || decoded.is_a?(Array)
292
+
293
+ # For HTTP errors, normalise into a `success => false` hash so the
294
+ # caller doesn't have to inspect HTTP status separately.
295
+ if status_code >= 400
296
+ if decoded.is_a?(Hash) && decoded['success'] == false
297
+ # The API already shaped the error correctly — just enrich it.
298
+ decoded['http_status'] = status_code
299
+ else
300
+ message = (decoded.is_a?(Hash) && decoded['message']) ||
301
+ "HTTP #{status_code} returned by ipwhois API"
302
+ decoded = {
303
+ 'success' => false,
304
+ 'message' => message,
305
+ 'http_status' => status_code
306
+ }
307
+ end
308
+
309
+ # `Retry-After` is only emitted by the free-plan endpoint
310
+ # (ipwho.is); the paid endpoint (ipwhois.pro) does not send the
311
+ # header, so don't try to read it there.
312
+ if status_code == 429 && @api_key.nil?
313
+ retry_after = http_response['retry-after']
314
+ decoded['retry_after'] = retry_after.to_i if retry_after
315
+ end
316
+ end
317
+
318
+ # Tag every API-shaped error (`success => false` returned by the API,
319
+ # on any HTTP status) with `error_type => 'api'` so callers can branch
320
+ # on the category alongside the non-API codes ('network',
321
+ # 'invalid_argument'). HTTP 2xx + success=false bodies (e.g. "Invalid
322
+ # IP address", "Reserved range") are otherwise passed through
323
+ # untouched.
324
+ if decoded.is_a?(Hash) && decoded['success'] == false && !decoded.key?('error_type')
325
+ decoded['error_type'] = 'api'
326
+ end
327
+
328
+ decoded
329
+ end
330
+
331
+ # Perform the actual HTTP GET. Follows up to `redirects_left` redirects.
332
+ # Returns either a Net::HTTPResponse or a pre-shaped error hash on
333
+ # network/transport failure.
334
+ def perform_http_get(uri, redirects_left)
335
+ use_ssl = (uri.scheme == 'https')
336
+ http = Net::HTTP.new(uri.host, uri.port)
337
+ http.use_ssl = use_ssl
338
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if use_ssl
339
+ http.open_timeout = @connect_timeout
340
+ http.read_timeout = @timeout
341
+
342
+ req = Net::HTTP::Get.new(uri.request_uri)
343
+ req['User-Agent'] = @user_agent
344
+ req['Accept'] = 'application/json'
345
+
346
+ response = http.request(req)
347
+
348
+ if response.is_a?(Net::HTTPRedirection) && redirects_left.positive?
349
+ location = response['location']
350
+ return network_error('redirect without Location header') if location.nil? || location.empty?
351
+
352
+ new_uri = URI.parse(location)
353
+ # Location headers may be relative — resolve against the current URI.
354
+ new_uri = uri + location unless new_uri.absolute?
355
+ return perform_http_get(new_uri, redirects_left - 1)
356
+ end
357
+
358
+ response
359
+ rescue Net::OpenTimeout => e
360
+ network_error("connection timeout: #{e.message}")
361
+ rescue Net::ReadTimeout => e
362
+ network_error("read timeout: #{e.message}")
363
+ rescue SocketError => e
364
+ network_error(e.message)
365
+ rescue OpenSSL::SSL::SSLError => e
366
+ network_error("SSL error: #{e.message}")
367
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
368
+ Errno::ENETUNREACH, Errno::ETIMEDOUT, EOFError, IOError => e
369
+ network_error(e.message)
370
+ end
371
+
372
+ def network_error(message)
373
+ {
374
+ 'success' => false,
375
+ 'message' => "Network error: #{message}",
376
+ 'error_type' => 'network'
377
+ }
378
+ end
379
+ end
380
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ipwhois
4
+ # Gem version, used in the default User-Agent header.
5
+ VERSION = '1.2.0'
6
+ end
data/lib/ipwhois.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ipwhois/version'
4
+ require_relative 'ipwhois/client'
5
+
6
+ # Top-level namespace for the ipwhois.io IP Geolocation API client.
7
+ #
8
+ # Quick start
9
+ # -----------
10
+ # # Free plan (no API key, ~1 request/second per client IP)
11
+ # client = Ipwhois::Client.new
12
+ # info = client.lookup('8.8.8.8')
13
+ #
14
+ # # Paid plan (with API key, higher limits, bulk, security data, …)
15
+ # client = Ipwhois::Client.new('YOUR_API_KEY')
16
+ # info = client.lookup('8.8.8.8', lang: 'en', security: true)
17
+ #
18
+ # # Bulk lookup — up to 100 IPs in one call (paid only)
19
+ # list = client.bulk_lookup(['8.8.8.8', '1.1.1.1', '208.67.222.222'])
20
+ #
21
+ # # HTTPS is enabled by default. Pass `ssl: false` to fall back to HTTP.
22
+ #
23
+ # Error handling
24
+ # --------------
25
+ # The library never raises. All errors — invalid input, network failure,
26
+ # API-level errors (bad IP, bad key, rate limit, …) — are returned in the
27
+ # response hash with `'success' => false` and a `'message'`. Just check
28
+ # `info['success']` after every call.
29
+ module Ipwhois
30
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ipwhois
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.2.0
5
+ platform: ruby
6
+ authors:
7
+ - ipwhois.io
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Simple, dependency-free Ruby client for the ipwhois.io IP Geolocation
42
+ API. Supports single and bulk IP lookups (IPv4 and IPv6), free and paid plans, localisation,
43
+ field selection, threat detection and rate-limit info.
44
+ email:
45
+ - support@ipwhois.io
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - lib/ipwhois.rb
54
+ - lib/ipwhois/client.rb
55
+ - lib/ipwhois/version.rb
56
+ homepage: https://ipwhois.io
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://ipwhois.io
61
+ source_code_uri: https://github.com/IPWhois/ipwhois-ruby
62
+ bug_tracker_uri: https://github.com/IPWhois/ipwhois-ruby/issues
63
+ documentation_uri: https://ipwhois.io/documentation
64
+ changelog_uri: https://github.com/IPWhois/ipwhois-ruby/blob/main/CHANGELOG.md
65
+ allowed_push_host: https://rubygems.org
66
+ rubygems_mfa_required: 'true'
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 3.0.0
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.0.3.1
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: Official Ruby client for the ipwhois.io IP Geolocation API.
86
+ test_files: []