ipdata 0.1.5 → 0.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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +32 -2
- data/lib/ipdata/client.rb +109 -21
- data/lib/ipdata/response.rb +20 -0
- data/lib/ipdata/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7127035fac487a80213a19c0076dd279854a26ed9409ebd33cfd99939472a3e
|
|
4
|
+
data.tar.gz: 61e8191443dc04b4d4cc5367c110d6afeeb2d1c3e121b2659078793978338d98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d3b43d466e48c1808adb3faf419ebc2e4dd3a0dd8f0074e2989ab3c74df264630c5fe5d6cf429c84e09ddc577b48e37f05580957c678e5a3ba92a53735d7485
|
|
7
|
+
data.tar.gz: 26db682141596dd33486a38c392a8e8cf408eb0346593c75cd44c3ccfca843993ac0f740cc089cb987dc9541155d93f76c165a67c898b970779119fc2f1a35f8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Connection reuse: an `IPData::Client` keeps one persistent HTTPS connection (HTTP/1.1 keep-alive) for its lifetime instead of opening a new TLS connection for every request. Create one client and reuse it. New option `keep_alive_timeout:` (seconds, default 60) for the client-side idle timeout, and new method `Client#close` to finish the connection at shutdown; the client reconnects on demand. A client is safe to share between threads (requests are serialised on its connection) and reconnects in a forked child without closing the parent's socket.
|
|
8
|
+
- `Response#is_satellite` and `Response#is_anycast`: the API sends these top-level booleans only when they are true; the readers return `false` when the key is absent, while `response["is_satellite"]`, `key?` and `to_h` reflect the raw response. Both names can be requested through `fields:` and `select_field:`.
|
|
9
|
+
- `threat.blocklists` entries carry `first_seen`, `last_seen` (`"YYYY-MM-DD"`, UTC) and `days_seen` when the feed reports them; some feeds omit all three.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `timeout:` now also applies to writes (previously only to connecting and reading).
|
|
14
|
+
- `bulk_lookup` (`POST /bulk`) is retried once, on a fresh connection, when a reused connection died before any response byte arrived; `lookup` (`GET`) was already retried once by `Net::HTTP`. Nothing is retried on an HTTP error status.
|
|
15
|
+
- The `User-Agent` header is now `ipdata-ruby/<version> Ruby/<ruby-version> (<platform>)` instead of `ruby-ipdata/...`.
|
|
16
|
+
- Gem metadata: the homepage is now https://ipdata.co; `source_code_uri` keeps pointing at this repository.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `use_ssl` follows the scheme of `base_url:`, so plain `http://` base URLs work; it used to be always on.
|
|
21
|
+
- `lookup(select_field: "city")` without an IP address requests `/city` instead of `//city`.
|
|
22
|
+
- A `base_url:` with a path prefix (for example `https://example.com/ipdata`) keeps that prefix in front of every request path instead of losing it.
|
|
23
|
+
|
|
24
|
+
## 0.1.5
|
|
25
|
+
|
|
26
|
+
Initial release on RubyGems: `lookup` for the caller's own IP or a given IP with `fields:` and `select_field:`, `bulk_lookup` for up to 100 addresses, `Client.eu` for the EU endpoint, an in-memory LRU cache with TTL, typed errors (`AuthenticationError`, `ForbiddenError`, `BadRequestError`) and no runtime dependencies.
|
data/README.md
CHANGED
|
@@ -118,9 +118,32 @@ client.lookup("8.8.8.8") # served from cache
|
|
|
118
118
|
### Custom Timeout
|
|
119
119
|
|
|
120
120
|
```ruby
|
|
121
|
-
client = IPData::Client.new("YOUR_API_KEY", timeout: 10) # 10 seconds
|
|
121
|
+
client = IPData::Client.new("YOUR_API_KEY", timeout: 10) # 10 seconds to connect, read and write
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
+
## Connection reuse
|
|
125
|
+
|
|
126
|
+
An `IPData::Client` keeps one persistent HTTPS connection (HTTP/1.1 keep-alive) for as long as it lives. Create one client per process (or one per API key or endpoint), keep it, and reuse it for every lookup: each new client pays a fresh TLS handshake. HTTP/2 is not available with Ruby's `Net::HTTP`, so the saving is the TLS handshake, not multiplexing.
|
|
127
|
+
|
|
128
|
+
A client is safe to share between threads, but it serialises requests on its single connection, so use one client per thread when you need lookups to run in parallel. Create clients after forking (for example in your web server's after-fork hook): a client inherited from the parent process notices the new process id and opens its own connection instead of reusing the parent's socket, which it never closes. Call `close` at shutdown to finish the connection; the client stays usable and reconnects on the next request.
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
client = IPData::Client.new("YOUR_API_KEY")
|
|
132
|
+
|
|
133
|
+
client.lookup("8.8.8.8") # opens the connection (TCP + TLS handshake)
|
|
134
|
+
client.lookup("1.1.1.1") # reuses it
|
|
135
|
+
|
|
136
|
+
at_exit { client.close }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The connection is opened on the first request. An idle connection is kept for `keep_alive_timeout:` seconds (default 60); after that the next request opens a new one. `timeout:` applies to connecting, reading and writing.
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
client = IPData::Client.new("YOUR_API_KEY", keep_alive_timeout: 30)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
If the server has silently dropped an idle connection, `lookup` (a `GET`) is retried once by `Net::HTTP` and `bulk_lookup` (a `POST`) once by the client, in both cases on a fresh connection and only when the failure happened before any response byte arrived. Nothing is retried on an HTTP error status.
|
|
146
|
+
|
|
124
147
|
## Response Fields
|
|
125
148
|
|
|
126
149
|
All responses are wrapped in `IPData::Response` objects that support both dot-notation and hash-style access:
|
|
@@ -138,6 +161,8 @@ response.continent_name
|
|
|
138
161
|
response.latitude
|
|
139
162
|
response.longitude
|
|
140
163
|
response.is_eu
|
|
164
|
+
response.is_satellite # satellite ISP; sent only when true, reads false otherwise
|
|
165
|
+
response.is_anycast # anycast address; sent only when true, reads false otherwise
|
|
141
166
|
response.postal
|
|
142
167
|
response.calling_code
|
|
143
168
|
response.flag
|
|
@@ -182,7 +207,10 @@ response.threat.is_known_attacker
|
|
|
182
207
|
response.threat.is_known_abuser
|
|
183
208
|
response.threat.is_threat
|
|
184
209
|
response.threat.is_bogon
|
|
185
|
-
response.threat.blocklists
|
|
210
|
+
response.threat.blocklists # array of entries: name, site, type and, when the feed reports them:
|
|
211
|
+
response.threat.blocklists.first.first_seen # first day listed, "YYYY-MM-DD" (UTC); absent for some feeds
|
|
212
|
+
response.threat.blocklists.first.last_seen # last day listed, "YYYY-MM-DD" (UTC); absent for some feeds
|
|
213
|
+
response.threat.blocklists.first.days_seen # number of days listed (integer); absent for some feeds
|
|
186
214
|
response.threat.scores
|
|
187
215
|
|
|
188
216
|
# Languages (array)
|
|
@@ -198,6 +226,8 @@ response[:country_code]
|
|
|
198
226
|
response.to_h
|
|
199
227
|
```
|
|
200
228
|
|
|
229
|
+
`is_satellite` and `is_anycast` are included in the API response only when they are true. `response.is_satellite` and `response.is_anycast` read `false` when the key is absent, while `response["is_satellite"]`, `response.key?("is_satellite")` and `response.to_h` reflect the raw response (`nil`, `false` and no key). Each `threat.blocklists` entry carries `first_seen` and `last_seen` (calendar dates as `"YYYY-MM-DD"` strings, UTC) and `days_seen` (an integer) when the feed reports them; some feeds, for example ipdata's own vpn feed, omit all three, so read them with `entry["first_seen"]` (`nil` when absent) or check `entry.key?("first_seen")`. The dates are kept as strings; use `Date.iso8601(entry.first_seen)` if you need `Date` objects.
|
|
230
|
+
|
|
201
231
|
## Error Handling
|
|
202
232
|
|
|
203
233
|
```ruby
|
data/lib/ipdata/client.rb
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "openssl"
|
|
4
5
|
require "json"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
7
8
|
module IPData
|
|
8
9
|
# HTTP client for the ipdata.co API.
|
|
9
10
|
#
|
|
11
|
+
# A client keeps one persistent HTTPS connection (HTTP/1.1 keep-alive) for
|
|
12
|
+
# as long as it lives: the connection is opened on the first request and
|
|
13
|
+
# reused for every later one, so only the first lookup pays the TCP and TLS
|
|
14
|
+
# handshake. Create one client per process (or per API key or endpoint),
|
|
15
|
+
# keep it, and call {#close} at shutdown.
|
|
16
|
+
#
|
|
17
|
+
# A client is safe to share between threads; requests are serialised on its
|
|
18
|
+
# single connection, so use one client per thread for parallel lookups.
|
|
19
|
+
# Create clients after forking: a client inherited from a parent process
|
|
20
|
+
# notices the new process id and opens its own connection instead of
|
|
21
|
+
# reusing (or closing) the parent's socket.
|
|
22
|
+
#
|
|
10
23
|
# @example Basic usage
|
|
11
24
|
# client = IPData::Client.new("YOUR_API_KEY")
|
|
12
25
|
# response = client.lookup("8.8.8.8")
|
|
@@ -22,17 +35,29 @@ module IPData
|
|
|
22
35
|
BASE_URL = "https://api.ipdata.co"
|
|
23
36
|
EU_BASE_URL = "https://eu-api.ipdata.co"
|
|
24
37
|
|
|
38
|
+
# Failures that mean a reused connection died before the server answered.
|
|
39
|
+
RETRYABLE_ERRORS = [EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError].freeze
|
|
40
|
+
private_constant :RETRYABLE_ERRORS
|
|
41
|
+
|
|
25
42
|
# @param api_key [String] Your ipdata API key
|
|
26
43
|
# @param cache [IPData::Cache, nil] Optional LRU cache instance
|
|
27
|
-
# @param timeout [Integer] HTTP timeout in seconds (default: 30)
|
|
28
|
-
# @param base_url [String, nil] Override the base URL (default: global endpoint)
|
|
29
|
-
|
|
44
|
+
# @param timeout [Integer] HTTP open, read and write timeout in seconds (default: 30)
|
|
45
|
+
# @param base_url [String, nil] Override the base URL (default: global endpoint).
|
|
46
|
+
# A path prefix, if the URL has one, is kept in front of every request path
|
|
47
|
+
# @param keep_alive_timeout [Integer] Seconds an idle connection is kept before
|
|
48
|
+
# the next request opens a new one (default: 60)
|
|
49
|
+
def initialize(api_key, cache: nil, timeout: 30, base_url: nil, keep_alive_timeout: 60)
|
|
30
50
|
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
|
|
31
51
|
|
|
32
52
|
@api_key = api_key
|
|
33
53
|
@cache = cache
|
|
34
54
|
@timeout = timeout
|
|
55
|
+
@keep_alive_timeout = keep_alive_timeout
|
|
35
56
|
@base_uri = URI.parse(base_url || BASE_URL)
|
|
57
|
+
@base_path = @base_uri.path.chomp("/")
|
|
58
|
+
@mutex = Mutex.new
|
|
59
|
+
@connection = nil
|
|
60
|
+
@connection_pid = nil
|
|
36
61
|
end
|
|
37
62
|
|
|
38
63
|
# Create a client configured for the EU endpoint.
|
|
@@ -51,11 +76,7 @@ module IPData
|
|
|
51
76
|
# @param select_field [String, nil] A single field to extract (returns raw value)
|
|
52
77
|
# @return [Response, Object] Response object, or raw value if select_field is used
|
|
53
78
|
def lookup(ip = nil, fields: nil, select_field: nil)
|
|
54
|
-
if select_field
|
|
55
|
-
path = "/#{ip}/#{select_field}"
|
|
56
|
-
data = get(path)
|
|
57
|
-
return data
|
|
58
|
-
end
|
|
79
|
+
return get(request_path(ip, select_field)) if select_field
|
|
59
80
|
|
|
60
81
|
cache_key = build_cache_key(ip, fields)
|
|
61
82
|
if @cache
|
|
@@ -63,11 +84,10 @@ module IPData
|
|
|
63
84
|
return cached if cached
|
|
64
85
|
end
|
|
65
86
|
|
|
66
|
-
path = ip ? "/#{ip}" : "/"
|
|
67
87
|
params = {}
|
|
68
88
|
params["fields"] = fields.join(",") if fields && !fields.empty?
|
|
69
89
|
|
|
70
|
-
data = get(
|
|
90
|
+
data = get(request_path(ip), params)
|
|
71
91
|
response = Response.new(data)
|
|
72
92
|
|
|
73
93
|
@cache&.set(cache_key, response)
|
|
@@ -87,14 +107,24 @@ module IPData
|
|
|
87
107
|
params = {}
|
|
88
108
|
params["fields"] = fields.join(",") if fields && !fields.empty?
|
|
89
109
|
|
|
90
|
-
data = post("
|
|
110
|
+
data = post(request_path("bulk"), ips, params)
|
|
91
111
|
data.map { |entry| Response.new(entry) }
|
|
92
112
|
end
|
|
93
113
|
|
|
114
|
+
# Close the persistent connection, if one is open.
|
|
115
|
+
#
|
|
116
|
+
# The client stays usable: the next request opens a new connection.
|
|
117
|
+
#
|
|
118
|
+
# @return [nil]
|
|
119
|
+
def close
|
|
120
|
+
@mutex.synchronize { discard_connection }
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
94
124
|
private
|
|
95
125
|
|
|
96
126
|
def user_agent
|
|
97
|
-
@user_agent ||= "ruby
|
|
127
|
+
@user_agent ||= "ipdata-ruby/#{VERSION} Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})"
|
|
98
128
|
end
|
|
99
129
|
|
|
100
130
|
def build_cache_key(ip, fields)
|
|
@@ -103,6 +133,13 @@ module IPData
|
|
|
103
133
|
key
|
|
104
134
|
end
|
|
105
135
|
|
|
136
|
+
# Path of a request made of +segments+, under the path prefix of the base
|
|
137
|
+
# URL if it has one. Nil segments are skipped, so a lookup of the caller's
|
|
138
|
+
# own IP uses "/" and its single-field form "/<field>", not "//<field>".
|
|
139
|
+
def request_path(*segments)
|
|
140
|
+
"#{@base_path}/#{segments.compact.join('/')}"
|
|
141
|
+
end
|
|
142
|
+
|
|
106
143
|
def build_uri(path, params = {})
|
|
107
144
|
uri = @base_uri.dup
|
|
108
145
|
uri.path = path
|
|
@@ -118,7 +155,7 @@ module IPData
|
|
|
118
155
|
request["Accept"] = "application/json"
|
|
119
156
|
request["User-Agent"] = user_agent
|
|
120
157
|
|
|
121
|
-
execute(
|
|
158
|
+
execute(request)
|
|
122
159
|
end
|
|
123
160
|
|
|
124
161
|
def post(path, body, params = {})
|
|
@@ -130,16 +167,11 @@ module IPData
|
|
|
130
167
|
request["User-Agent"] = user_agent
|
|
131
168
|
request.body = JSON.generate(body)
|
|
132
169
|
|
|
133
|
-
execute(
|
|
170
|
+
execute(request)
|
|
134
171
|
end
|
|
135
172
|
|
|
136
|
-
def execute(
|
|
137
|
-
|
|
138
|
-
http.use_ssl = true
|
|
139
|
-
http.open_timeout = @timeout
|
|
140
|
-
http.read_timeout = @timeout
|
|
141
|
-
|
|
142
|
-
response = http.request(request)
|
|
173
|
+
def execute(request)
|
|
174
|
+
response = @mutex.synchronize { perform(request) }
|
|
143
175
|
|
|
144
176
|
unless response.is_a?(Net::HTTPSuccess)
|
|
145
177
|
raise Error.from_response(response.code.to_i, response.body)
|
|
@@ -147,5 +179,61 @@ module IPData
|
|
|
147
179
|
|
|
148
180
|
JSON.parse(response.body)
|
|
149
181
|
end
|
|
182
|
+
|
|
183
|
+
# Send +request+ on the pooled connection. Must be called with @mutex held.
|
|
184
|
+
#
|
|
185
|
+
# The server may drop an idle connection without notice. Net::HTTP retries
|
|
186
|
+
# idempotent requests (GET) on such a failure itself (max_retries); POST
|
|
187
|
+
# /bulk is not idempotent, so Net::HTTP leaves it alone and it is retried
|
|
188
|
+
# here instead: exactly once, on a fresh connection, and only when the
|
|
189
|
+
# failure happened before any response byte arrived. An HTTP error status
|
|
190
|
+
# is a response and is never retried.
|
|
191
|
+
def perform(request)
|
|
192
|
+
retried = false
|
|
193
|
+
loop do
|
|
194
|
+
http = connection
|
|
195
|
+
responded = false
|
|
196
|
+
begin
|
|
197
|
+
return http.request(request) { responded = true }
|
|
198
|
+
rescue *RETRYABLE_ERRORS
|
|
199
|
+
discard_connection
|
|
200
|
+
raise if retried || responded || request.method != "POST"
|
|
201
|
+
|
|
202
|
+
retried = true
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The pooled Net::HTTP, opened on first use. A connection inherited from a
|
|
208
|
+
# parent process is dropped without finish (the socket is shared with the
|
|
209
|
+
# parent, so no TLS close_notify may be sent on it) and a new one opened.
|
|
210
|
+
def connection
|
|
211
|
+
discard_connection if @connection && @connection_pid != Process.pid
|
|
212
|
+
@connection ||= connect
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def connect
|
|
216
|
+
http = Net::HTTP.new(@base_uri.host, @base_uri.port)
|
|
217
|
+
http.use_ssl = @base_uri.scheme == "https"
|
|
218
|
+
http.open_timeout = @timeout
|
|
219
|
+
http.read_timeout = @timeout
|
|
220
|
+
http.write_timeout = @timeout
|
|
221
|
+
http.keep_alive_timeout = @keep_alive_timeout
|
|
222
|
+
http.max_retries = 1
|
|
223
|
+
http.start
|
|
224
|
+
@connection_pid = Process.pid
|
|
225
|
+
http
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Forget the pooled connection, finishing it only if this process opened it.
|
|
229
|
+
def discard_connection
|
|
230
|
+
http = @connection
|
|
231
|
+
@connection = nil
|
|
232
|
+
return unless http && @connection_pid == Process.pid
|
|
233
|
+
|
|
234
|
+
http.finish if http.started?
|
|
235
|
+
rescue IOError, SystemCallError
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
150
238
|
end
|
|
151
239
|
end
|
data/lib/ipdata/response.rb
CHANGED
|
@@ -14,6 +14,9 @@ module IPData
|
|
|
14
14
|
#
|
|
15
15
|
# Nested hashes are recursively wrapped as Response objects.
|
|
16
16
|
# Arrays of hashes (e.g. languages, blocklists) are mapped to Response arrays.
|
|
17
|
+
#
|
|
18
|
+
# Keys the API sends only when they are true (+is_satellite+, +is_anycast+)
|
|
19
|
+
# have explicit readers that return false when the key is absent.
|
|
17
20
|
class Response
|
|
18
21
|
def initialize(data)
|
|
19
22
|
@data = data || {}
|
|
@@ -24,6 +27,23 @@ module IPData
|
|
|
24
27
|
wrap(@data[key.to_s])
|
|
25
28
|
end
|
|
26
29
|
|
|
30
|
+
# Whether the address belongs to a satellite internet provider.
|
|
31
|
+
#
|
|
32
|
+
# The API includes the +is_satellite+ key only when it is true, so this
|
|
33
|
+
# reads +false+ when the key is absent instead of raising NoMethodError.
|
|
34
|
+
# The raw hash (+to_h+, +[]+, +key?+) is left untouched.
|
|
35
|
+
def is_satellite
|
|
36
|
+
@data.fetch("is_satellite", false)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Whether the address is anycast (announced from several locations at once).
|
|
40
|
+
#
|
|
41
|
+
# The API includes the +is_anycast+ key only when it is true, so this
|
|
42
|
+
# reads +false+ when the key is absent. The raw hash is left untouched.
|
|
43
|
+
def is_anycast
|
|
44
|
+
@data.fetch("is_anycast", false)
|
|
45
|
+
end
|
|
46
|
+
|
|
27
47
|
# Returns the underlying raw Hash.
|
|
28
48
|
def to_h
|
|
29
49
|
@data
|
data/lib/ipdata/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ipdata
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- IPData
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Official Ruby SDK for the ipdata.co API. Look up geolocation, threat
|
|
14
14
|
intelligence, ASN, company, carrier, currency, timezone and language data for any
|
|
@@ -19,6 +19,7 @@ executables: []
|
|
|
19
19
|
extensions: []
|
|
20
20
|
extra_rdoc_files: []
|
|
21
21
|
files:
|
|
22
|
+
- CHANGELOG.md
|
|
22
23
|
- LICENSE
|
|
23
24
|
- README.md
|
|
24
25
|
- lib/ipdata.rb
|
|
@@ -27,11 +28,11 @@ files:
|
|
|
27
28
|
- lib/ipdata/errors.rb
|
|
28
29
|
- lib/ipdata/response.rb
|
|
29
30
|
- lib/ipdata/version.rb
|
|
30
|
-
homepage: https://
|
|
31
|
+
homepage: https://ipdata.co
|
|
31
32
|
licenses:
|
|
32
33
|
- MIT
|
|
33
34
|
metadata:
|
|
34
|
-
homepage_uri: https://
|
|
35
|
+
homepage_uri: https://ipdata.co
|
|
35
36
|
source_code_uri: https://github.com/ipdata/ruby
|
|
36
37
|
changelog_uri: https://github.com/ipdata/ruby/blob/main/CHANGELOG.md
|
|
37
38
|
bug_tracker_uri: https://github.com/ipdata/ruby/issues
|