ip_geo_lookup 0.1.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: 52397b7053c8f2c5428972a08b16b488685a7e64da77ae439e818f0514d36edf
4
+ data.tar.gz: dcc0e4973ef2df48e0757c7aa5b283bae9a9f67806c3791f0a09145b67bbde3a
5
+ SHA512:
6
+ metadata.gz: f2a3490f5bd62a47a98774019dc7725701480bedf937ed5fb18f216cb64b8f52efcc1fbaf97dc894e5e9a051ff9a9bc882cfda04555d67ed561bdb0f8a7027e7
7
+ data.tar.gz: 0a8c4db162262c7f61be45d08987e41d71337c15dfd61d4fa2c90245028c3032ad9ea486ffeae2a8426bd32839aa9a274f440b49e42d6614c783611058d0a792
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project 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
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-03-30
11
+
12
+ ### Added
13
+
14
+ - Pure Ruby MMDB reader - reads MaxMind's native binary format with zero external dependencies
15
+ - IPv4 and IPv6 geolocation lookup via `IpGeoLookup.lookup(ip_string)`
16
+ - `Result` with 10 fields: `country_code`, `country_name`, `region`, `city`, `continent_code`, `continent_name`, `latitude`, `longitude`, `time_zone`, `postal_code` - plus `raw` for the full MMDB record
17
+ - `Result` supports object-style (`result.city`), hash-style (`result[:city]`), `to_h`, `to_s`, `Comparable`
18
+ - `IpAddress.parse` for single-pass IP validation and integer conversion; rejects leading-zero octets (`"08.8.8.8"` returns nil)
19
+ - `IpGeoLookup.configure` for custom database path and I/O mode
20
+ - `IpGeoLookup.reload!`, `.close`, `.reset!`, `.metadata`
21
+ - Thread safety via `Mutex` for all shared state
22
+ - Two I/O modes: `:file` (default, `pread`-based, fork-friendly) and `:memory` (loads entire DB into a String)
23
+ - Support for MMDB record sizes 24, 28, and 32
24
+ - IPv4 lookups in IPv6 databases (automatic 96-bit prefix walk)
25
+ - Use-after-close protection (`ClosedError`)
26
+ - GeoLite2-City.mmdb ships with the gem - no setup, no API keys, no downloads
27
+ - CI via GitHub Actions: test matrix (Ruby 2.6, 3.0, 4.0), StandardRB linting, automated gem publishing
28
+ - Dependabot for actions and bundler dependency updates
29
+
30
+ [Unreleased]: https://github.com/msuliq/ip_geo_lookup/compare/v0.1.0...HEAD
31
+ [0.1.0]: https://github.com/msuliq/ip_geo_lookup/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suleyman Musayev
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,156 @@
1
+ # IpGeoLookup
2
+
3
+ A lightweight, **zero-dependency** Ruby gem for resolving IPv4 and IPv6 addresses to geographic locations. Ships with an embedded [MaxMind GeoLite2](https://dev.maxmind.com/geoip/geolite2-free-geolite2-databases) database and a pure Ruby MMDB reader - no external gems, no API keys, no setup.
4
+
5
+ ## Features
6
+
7
+ - **Zero dependencies** - pure Ruby MMDB reader, no native extensions, no external gems
8
+ - **IPv4 and IPv6** - resolves to country, region, city, coordinates, timezone, and more
9
+ - **Batteries included** - GeoLite2 City database ships with the gem
10
+ - **Fast** - binary trie traversal over MaxMind's MMDB format
11
+ - **Secure** - no `Marshal.load`, rejects ambiguous IP octets with leading zeros
12
+ - **Thread-safe** - safe for Puma, Sidekiq, and other multi-threaded servers
13
+ - **Fork-friendly** - `:file` mode uses `pread` for shared OS page cache across workers
14
+ - **Configurable** - use the embedded database or point to your own `.mmdb` file
15
+
16
+ ## Installation
17
+
18
+ ```ruby
19
+ gem "ip_geo_lookup"
20
+ ```
21
+
22
+ ```sh
23
+ bundle install
24
+ ```
25
+
26
+ That's it - the database is included.
27
+
28
+ ## Usage
29
+
30
+ ### Basic lookup
31
+
32
+ ```ruby
33
+ require "ip_geo_lookup"
34
+
35
+ result = IpGeoLookup.lookup("8.8.8.8")
36
+ result = IpGeoLookup.lookup("2001:4860:4860::8888")
37
+ ```
38
+
39
+ ### Accessing results
40
+
41
+ ```ruby
42
+ result = IpGeoLookup.lookup("8.8.8.8")
43
+
44
+ # Core fields
45
+ result.country_code # => "US"
46
+ result.country_name # => "United States"
47
+ result.region # => "California"
48
+ result.city # => "Mountain View"
49
+
50
+ # Extended fields
51
+ result.continent_code # => "NA"
52
+ result.continent_name # => "North America"
53
+ result.latitude # => 37.386
54
+ result.longitude # => -122.0838
55
+ result.time_zone # => "America/Los_Angeles"
56
+ result.postal_code # => "94035"
57
+
58
+ # Hash-style access
59
+ result[:country_code] # => "US"
60
+
61
+ # Full MMDB record
62
+ result.raw # => {"country" => {"iso_code" => "US", ...}, ...}
63
+
64
+ # Conversions
65
+ result.to_h # => {country_code: "US", country_name: "United States", ...}
66
+ result.to_s # => "Mountain View, California, United States (US)"
67
+
68
+ # Sorting
69
+ results = ips.map { |ip| IpGeoLookup.lookup(ip) }.compact.sort
70
+ ```
71
+
72
+ ### Handling unknown IPs
73
+
74
+ Returns `nil` when the IP address is not found or is invalid:
75
+
76
+ ```ruby
77
+ IpGeoLookup.lookup("192.168.1.1") # => nil (private range)
78
+ IpGeoLookup.lookup("not_an_ip") # => nil (invalid format)
79
+ IpGeoLookup.lookup("08.8.8.8") # => nil (leading zeros rejected)
80
+ IpGeoLookup.lookup(nil) # => nil
81
+ ```
82
+
83
+ ### Configuration
84
+
85
+ ```ruby
86
+ IpGeoLookup.configure do |config|
87
+ config.database_path = "/path/to/GeoLite2-City.mmdb"
88
+ config.mode = :memory # optional: load entire DB into memory for single-process apps
89
+ end
90
+ ```
91
+
92
+ ### Lifecycle
93
+
94
+ ```ruby
95
+ IpGeoLookup.reload! # re-reads from disk, respects configured path
96
+ IpGeoLookup.close # releases file handle / memory
97
+ IpGeoLookup.metadata # => {"database_type" => "GeoLite2-City", ...}
98
+ ```
99
+
100
+ ## How It Works
101
+
102
+ The gem includes a pure Ruby reader for MaxMind's [MMDB binary format](https://maxmind.github.io/MaxMind-DB/). On lookup:
103
+
104
+ 1. The IP string is validated and converted to an integer in a single pass
105
+ 2. The MMDB binary trie is walked (32 bits for IPv4, 128 for IPv6)
106
+ 3. The matching record is decoded from the data section
107
+ 4. A `Result` object is returned (or `nil` if no match)
108
+
109
+ Two I/O modes are available:
110
+
111
+ - **`:file`** (default) - keeps the file open and uses `IO#pread` for reads; the OS page cache shares data across forked workers automatically
112
+ - **`:memory`** - reads the entire file into a Ruby String for fastest lookups in single-process environments
113
+
114
+ ## Compatibility
115
+
116
+ - Ruby >= 2.6.0
117
+ - Zero external dependencies
118
+ - Thread-safe
119
+ - Linux, macOS, Windows
120
+
121
+ ## Development
122
+
123
+ ```sh
124
+ bundle install
125
+ bundle exec rake spec
126
+ ```
127
+
128
+ Test fixtures are generated automatically during the test run - no external database file needed.
129
+
130
+ ### Updating the MaxMind database
131
+
132
+ The embedded GeoLite2 City database lives in `data/GeoLite2-City.mmdb` (gitignored due to size). To update it:
133
+
134
+ 1. Create a free account at [MaxMind](https://www.maxmind.com/en/geolite2/signup)
135
+ 2. Download the GeoLite2 City MMDB file
136
+ 3. Copy it into the gem:
137
+ ```sh
138
+ cp /path/to/GeoLite2-City.mmdb data/GeoLite2-City.mmdb
139
+ ```
140
+ 4. Rebuild the gem: `gem build ip_geo_lookup.gemspec`
141
+
142
+ ## Contributing
143
+
144
+ 1. Fork it
145
+ 2. Create your feature branch (`git checkout -b feature/my-feature`)
146
+ 3. Commit your changes
147
+ 4. Push to the branch
148
+ 5. Create a Pull Request
149
+
150
+ ## License
151
+
152
+ Available as open source under the [MIT License](LICENSE).
153
+
154
+ ## Attribution
155
+
156
+ This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com).
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IpGeoLookup
4
+ module IpAddress
5
+ module_function
6
+
7
+ # Returns [integer, version] or nil. Rejects leading-zero octets.
8
+ def parse(ip_string)
9
+ return nil unless ip_string.is_a?(String)
10
+
11
+ addr = ip_string.strip
12
+ if addr.include?(":")
13
+ int = to_i_v6(addr)
14
+ int ? [int, 6] : nil
15
+ elsif addr.include?(".")
16
+ int = to_i(addr)
17
+ int ? [int, 4] : nil
18
+ end
19
+ end
20
+
21
+ # IPv4 string to 32-bit integer. Rejects leading-zero octets.
22
+ def to_i(ip_string)
23
+ return nil unless ip_string.is_a?(String)
24
+
25
+ octets = ip_string.strip.split(".")
26
+ return nil unless octets.length == 4
27
+
28
+ result = 0
29
+ octets.each do |octet|
30
+ return nil unless octet.match?(/\A(0|[1-9]\d{0,2})\z/)
31
+ value = octet.to_i
32
+ return nil if value > 255
33
+ result = (result << 8) | value
34
+ end
35
+ result
36
+ end
37
+
38
+ # IPv6 string to 128-bit integer. Supports :: shorthand.
39
+ def to_i_v6(ip_string)
40
+ return nil unless ip_string.is_a?(String)
41
+
42
+ addr = ip_string.strip.downcase
43
+ return nil if addr.empty? || addr.include?("%")
44
+
45
+ if addr.include?("::")
46
+ return nil if addr.scan("::").length > 1
47
+
48
+ left, right = addr.split("::", -1)
49
+ left_groups = left.empty? ? [] : left.split(":")
50
+ right_groups = right.empty? ? [] : right.split(":")
51
+ fill_count = 8 - left_groups.length - right_groups.length
52
+ return nil if fill_count < 0
53
+
54
+ groups = left_groups + Array.new(fill_count, "0") + right_groups
55
+ else
56
+ groups = addr.split(":")
57
+ end
58
+
59
+ return nil unless groups.length == 8
60
+
61
+ result = 0
62
+ groups.each do |group|
63
+ return nil unless group.match?(/\A[0-9a-f]{1,4}\z/)
64
+ result = (result << 16) | group.to_i(16)
65
+ end
66
+ result
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IpGeoLookup
4
+ # Pure Ruby reader for MaxMind's MMDB binary format.
5
+ class MMDB
6
+ MODE_MEMORY = :memory
7
+ MODE_FILE = :file
8
+
9
+ METADATA_MARKER = "\xAB\xCD\xEFMaxMind.com".b.freeze
10
+
11
+ attr_reader :metadata
12
+
13
+ def initialize(path, mode: MODE_MEMORY)
14
+ raise DatabaseNotFoundError, "Database file not found: #{path}" unless File.exist?(path)
15
+
16
+ @closed = false
17
+
18
+ case mode
19
+ when MODE_MEMORY
20
+ @data = File.binread(path)
21
+ @use_file = false
22
+ when MODE_FILE
23
+ @io = File.open(path, "rb")
24
+ @file_size = @io.size
25
+ @use_file = true
26
+ @has_pread = @io.respond_to?(:pread)
27
+ @file_mutex = Mutex.new unless @has_pread
28
+ else
29
+ raise ArgumentError, "Unknown mode: #{mode}. Use :memory or :file"
30
+ end
31
+
32
+ load_metadata
33
+ end
34
+
35
+ # Returns the MMDB record Hash for the given IP integer, or nil.
36
+ def find(ip_int, bit_count)
37
+ raise ClosedError, "Reader is closed" if @closed
38
+
39
+ if bit_count == 32 && @ip_version == 6
40
+ start = ipv4_start_node
41
+ return nil if start >= @node_count
42
+ search_tree(ip_int, 32, start)
43
+ elsif bit_count == 128 && @ip_version == 4
44
+ nil
45
+ else
46
+ search_tree(ip_int, bit_count, 0)
47
+ end
48
+ end
49
+
50
+ def close
51
+ @closed = true
52
+ if @use_file
53
+ @io&.close
54
+ @io = nil
55
+ else
56
+ @data = nil
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def read_bytes(offset, length)
63
+ if @use_file
64
+ if @has_pread
65
+ @io.pread(length, offset)
66
+ else
67
+ @file_mutex.synchronize {
68
+ @io.seek(offset)
69
+ @io.read(length)
70
+ }
71
+ end
72
+ else
73
+ @data.byteslice(offset, length)
74
+ end
75
+ end
76
+
77
+ # --- Metadata ---
78
+
79
+ def load_metadata
80
+ size = @use_file ? @file_size : @data.bytesize
81
+ search_size = [size, 131_072].min
82
+ chunk = read_bytes(size - search_size, search_size)
83
+ pos = chunk.rindex(METADATA_MARKER)
84
+ raise DatabaseFormatError, "MMDB metadata marker not found" unless pos
85
+
86
+ meta_offset = (size - search_size) + pos + METADATA_MARKER.bytesize
87
+ @metadata, _ = decode(meta_offset)
88
+ raise DatabaseFormatError, "Invalid metadata" unless @metadata.is_a?(Hash)
89
+
90
+ @node_count = @metadata["node_count"] || raise(DatabaseFormatError, "Missing node_count")
91
+ @record_size = @metadata["record_size"] || raise(DatabaseFormatError, "Missing record_size")
92
+ @ip_version = @metadata["ip_version"] || raise(DatabaseFormatError, "Missing ip_version")
93
+
94
+ unless [24, 28, 32].include?(@record_size)
95
+ raise DatabaseFormatError, "Unsupported record size: #{@record_size}"
96
+ end
97
+
98
+ @node_byte_size = @record_size / 4
99
+ @search_tree_size = @node_count * @node_byte_size
100
+ @data_section_offset = @search_tree_size + 16
101
+ end
102
+
103
+ # --- Search tree ---
104
+
105
+ def ipv4_start_node
106
+ @ipv4_start_node ||= begin
107
+ node = 0
108
+ 96.times do
109
+ break if node >= @node_count
110
+ node, _ = read_node(node)
111
+ end
112
+ node
113
+ end
114
+ end
115
+
116
+ def search_tree(ip_int, bit_count, start_node)
117
+ node = start_node
118
+
119
+ bit_count.times do |i|
120
+ left, right = read_node(node)
121
+ record = (((ip_int >> (bit_count - 1 - i)) & 1) == 0) ? left : right
122
+
123
+ if record < @node_count
124
+ node = record
125
+ elsif record == @node_count
126
+ return nil
127
+ else
128
+ value, _ = decode(@search_tree_size + record - @node_count)
129
+ return value
130
+ end
131
+ end
132
+
133
+ nil
134
+ end
135
+
136
+ # Reads one node in a single I/O call, returns [left, right].
137
+ def read_node(node_number)
138
+ buf = read_bytes(node_number * @node_byte_size, @node_byte_size)
139
+
140
+ case @record_size
141
+ when 24
142
+ left = (buf.getbyte(0) << 16) | (buf.getbyte(1) << 8) | buf.getbyte(2)
143
+ right = (buf.getbyte(3) << 16) | (buf.getbyte(4) << 8) | buf.getbyte(5)
144
+ when 28
145
+ mid = buf.getbyte(3)
146
+ left = ((mid >> 4) << 24) |
147
+ (buf.getbyte(0) << 16) | (buf.getbyte(1) << 8) | buf.getbyte(2)
148
+ right = ((mid & 0x0F) << 24) |
149
+ (buf.getbyte(4) << 16) | (buf.getbyte(5) << 8) | buf.getbyte(6)
150
+ when 32
151
+ left, right = buf.unpack("NN")
152
+ end
153
+
154
+ [left, right]
155
+ end
156
+
157
+ # --- Data decoder ---
158
+
159
+ def decode(offset)
160
+ ctrl = read_bytes(offset, 1).getbyte(0)
161
+ offset += 1
162
+
163
+ type = (ctrl >> 5) & 7
164
+ if type == 0
165
+ type = read_bytes(offset, 1).getbyte(0) + 7
166
+ offset += 1
167
+ end
168
+
169
+ return decode_pointer(ctrl, offset) if type == 1
170
+
171
+ size, offset = read_payload_size(ctrl, offset)
172
+
173
+ case type
174
+ when 2 then [read_bytes(offset, size).force_encoding("UTF-8"), offset + size]
175
+ when 3 then [read_bytes(offset, 8).unpack1("G"), offset + 8]
176
+ when 4 then [read_bytes(offset, size), offset + size]
177
+ when 5, 6, 9, 10 then decode_uint(size, offset)
178
+ when 7 then decode_map(size, offset)
179
+ when 8 then decode_int32(size, offset)
180
+ when 11 then decode_array(size, offset)
181
+ when 14 then [size != 0, offset]
182
+ when 15 then [read_bytes(offset, 4).unpack1("g"), offset + 4]
183
+ else raise DatabaseFormatError, "Unknown MMDB data type: #{type}"
184
+ end
185
+ end
186
+
187
+ def read_payload_size(ctrl, offset)
188
+ size = ctrl & 0x1F
189
+ if size < 29
190
+ [size, offset]
191
+ elsif size == 29
192
+ [29 + read_bytes(offset, 1).getbyte(0), offset + 1]
193
+ elsif size == 30
194
+ buf = read_bytes(offset, 2)
195
+ [285 + ((buf.getbyte(0) << 8) | buf.getbyte(1)), offset + 2]
196
+ else
197
+ buf = read_bytes(offset, 3)
198
+ [65_821 + ((buf.getbyte(0) << 16) | (buf.getbyte(1) << 8) | buf.getbyte(2)), offset + 3]
199
+ end
200
+ end
201
+
202
+ def decode_pointer(ctrl, offset)
203
+ ptr_size = (ctrl >> 3) & 3
204
+
205
+ case ptr_size
206
+ when 0
207
+ ptr = ((ctrl & 7) << 8) | read_bytes(offset, 1).getbyte(0)
208
+ offset += 1
209
+ when 1
210
+ buf = read_bytes(offset, 2)
211
+ ptr = (((ctrl & 7) << 16) | (buf.getbyte(0) << 8) | buf.getbyte(1)) + 2048
212
+ offset += 2
213
+ when 2
214
+ buf = read_bytes(offset, 3)
215
+ ptr = (((ctrl & 7) << 24) | (buf.getbyte(0) << 16) | (buf.getbyte(1) << 8) | buf.getbyte(2)) + 526_336
216
+ offset += 3
217
+ when 3
218
+ buf = read_bytes(offset, 4)
219
+ ptr = (buf.getbyte(0) << 24) | (buf.getbyte(1) << 16) | (buf.getbyte(2) << 8) | buf.getbyte(3)
220
+ offset += 4
221
+ end
222
+
223
+ value, _ = decode(@data_section_offset + ptr)
224
+ [value, offset]
225
+ end
226
+
227
+ def decode_uint(size, offset)
228
+ return [0, offset] if size == 0
229
+ buf = read_bytes(offset, size)
230
+ val = 0
231
+ size.times { |i| val = (val << 8) | buf.getbyte(i) }
232
+ [val, offset + size]
233
+ end
234
+
235
+ def decode_map(size, offset)
236
+ map = {}
237
+ size.times do
238
+ key, offset = decode(offset)
239
+ val, offset = decode(offset)
240
+ map[key] = val
241
+ end
242
+ [map, offset]
243
+ end
244
+
245
+ def decode_array(size, offset)
246
+ arr = []
247
+ size.times do
248
+ val, offset = decode(offset)
249
+ arr << val
250
+ end
251
+ [arr, offset]
252
+ end
253
+
254
+ def decode_int32(size, offset)
255
+ val, offset = decode_uint(size, offset)
256
+ val -= (1 << 32) if val >= (1 << 31)
257
+ [val, offset]
258
+ end
259
+ end
260
+
261
+ class DatabaseNotFoundError < StandardError; end
262
+ class DatabaseFormatError < StandardError; end
263
+ class ClosedError < StandardError; end
264
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IpGeoLookup
4
+ class Result
5
+ include Comparable
6
+
7
+ FIELDS = [
8
+ :country_code, :country_name, :region, :city,
9
+ :continent_code, :continent_name,
10
+ :latitude, :longitude, :time_zone, :postal_code
11
+ ].freeze
12
+
13
+ attr_reader :country_code, :country_name, :region, :city,
14
+ :continent_code, :continent_name,
15
+ :latitude, :longitude, :time_zone, :postal_code,
16
+ :raw
17
+
18
+ def initialize(country_code:, country_name:, region:, city:,
19
+ continent_code: nil, continent_name: nil,
20
+ latitude: nil, longitude: nil,
21
+ time_zone: nil, postal_code: nil,
22
+ raw: nil)
23
+ @country_code = country_code
24
+ @country_name = country_name
25
+ @region = region
26
+ @city = city
27
+ @continent_code = continent_code
28
+ @continent_name = continent_name
29
+ @latitude = latitude
30
+ @longitude = longitude
31
+ @time_zone = time_zone
32
+ @postal_code = postal_code
33
+ @raw = raw
34
+ end
35
+
36
+ def [](key)
37
+ case key.to_sym
38
+ when :country_code then @country_code
39
+ when :country_name then @country_name
40
+ when :region then @region
41
+ when :city then @city
42
+ when :continent_code then @continent_code
43
+ when :continent_name then @continent_name
44
+ when :latitude then @latitude
45
+ when :longitude then @longitude
46
+ when :time_zone then @time_zone
47
+ when :postal_code then @postal_code
48
+ when :raw then @raw
49
+ end
50
+ end
51
+
52
+ def to_h
53
+ {
54
+ country_code: @country_code,
55
+ country_name: @country_name,
56
+ region: @region,
57
+ city: @city,
58
+ continent_code: @continent_code,
59
+ continent_name: @continent_name,
60
+ latitude: @latitude,
61
+ longitude: @longitude,
62
+ time_zone: @time_zone,
63
+ postal_code: @postal_code
64
+ }
65
+ end
66
+
67
+ def to_a
68
+ [@country_code, @country_name, @region, @city,
69
+ @continent_code, @continent_name,
70
+ @latitude, @longitude, @time_zone, @postal_code]
71
+ end
72
+
73
+ def members
74
+ FIELDS.dup
75
+ end
76
+
77
+ def to_s
78
+ location = [@city, @region, @country_name].reject { |s| s.nil? || s.empty? }.join(", ")
79
+ if @country_code && !@country_code.empty?
80
+ location.empty? ? @country_code : "#{location} (#{@country_code})"
81
+ else
82
+ location
83
+ end
84
+ end
85
+
86
+ def inspect
87
+ "#<IpGeoLookup::Result #{self}>"
88
+ end
89
+
90
+ def <=>(other)
91
+ return nil unless other.is_a?(Result)
92
+ [country_code.to_s, region.to_s, city.to_s] <=> [other.country_code.to_s, other.region.to_s, other.city.to_s]
93
+ end
94
+
95
+ # Equality based on to_h; raw is intentionally excluded.
96
+ def ==(other)
97
+ return false unless other.is_a?(Result)
98
+ to_h == other.to_h
99
+ end
100
+
101
+ alias_method :eql?, :==
102
+
103
+ def hash
104
+ to_h.hash
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IpGeoLookup
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ip_geo_lookup/version"
4
+ require_relative "ip_geo_lookup/result"
5
+ require_relative "ip_geo_lookup/ip_address"
6
+ require_relative "ip_geo_lookup/mmdb"
7
+
8
+ module IpGeoLookup
9
+ DEFAULT_DB_PATH = File.expand_path("../../data/GeoLite2-City.mmdb", __FILE__).freeze
10
+
11
+ @mutex = Mutex.new
12
+
13
+ class << self
14
+ # Returns a Result for the given IP string, or nil.
15
+ def lookup(ip_address)
16
+ return nil unless ip_address.is_a?(String)
17
+
18
+ parsed = IpAddress.parse(ip_address)
19
+ return nil unless parsed
20
+
21
+ ip_int, version = parsed
22
+ record = reader.find(ip_int, (version == 6) ? 128 : 32)
23
+ return nil unless record
24
+
25
+ build_result(record)
26
+ end
27
+
28
+ def metadata
29
+ reader.metadata
30
+ end
31
+
32
+ # Reloads from disk; respects configured path.
33
+ def reload!
34
+ @mutex.synchronize { @reader = nil }
35
+ end
36
+
37
+ def close
38
+ @mutex.synchronize do
39
+ @reader&.close
40
+ @reader = nil
41
+ end
42
+ end
43
+
44
+ def configure
45
+ @mutex.synchronize do
46
+ yield configuration
47
+ @reader = MMDB.new(configuration.database_path, mode: configuration.mode)
48
+ end
49
+ end
50
+
51
+ def configuration
52
+ @configuration ||= Configuration.new
53
+ end
54
+
55
+ def reset!
56
+ @mutex.synchronize do
57
+ @reader = nil
58
+ @configuration = nil
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def reader
65
+ @mutex.synchronize do
66
+ @reader ||= MMDB.new(configuration.database_path, mode: configuration.mode)
67
+ end
68
+ end
69
+
70
+ def build_result(record)
71
+ Result.new(
72
+ country_code: deep_fetch(record, "country", "iso_code"),
73
+ country_name: deep_fetch(record, "country", "names", "en"),
74
+ region: deep_fetch(record, "subdivisions", 0, "names", "en"),
75
+ city: deep_fetch(record, "city", "names", "en"),
76
+ continent_code: deep_fetch(record, "continent", "code"),
77
+ continent_name: deep_fetch(record, "continent", "names", "en"),
78
+ latitude: deep_fetch(record, "location", "latitude"),
79
+ longitude: deep_fetch(record, "location", "longitude"),
80
+ time_zone: deep_fetch(record, "location", "time_zone"),
81
+ postal_code: deep_fetch(record, "postal", "code"),
82
+ raw: record
83
+ )
84
+ end
85
+
86
+ def deep_fetch(obj, *keys)
87
+ keys.each do |key|
88
+ case obj
89
+ when Hash then obj = obj[key]
90
+ when Array then obj = obj[key]
91
+ else return nil
92
+ end
93
+ end
94
+ obj
95
+ end
96
+ end
97
+
98
+ class Configuration
99
+ attr_writer :database_path, :mode
100
+
101
+ def database_path
102
+ @database_path || DEFAULT_DB_PATH
103
+ end
104
+
105
+ def mode
106
+ @mode || MMDB::MODE_FILE
107
+ end
108
+ end
109
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ip_geo_lookup
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Suleyman Musayev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-30 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A lightweight, zero-dependency Ruby gem for resolving IPv4 and IPv6 addresses
14
+ to country, region, city, coordinates, and more. Ships with an embedded MaxMind
15
+ GeoLite2 MMDB database and a pure Ruby MMDB reader - no external gems, no API keys,
16
+ no setup.
17
+ email:
18
+ - slmusayev@gmail.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - LICENSE
25
+ - README.md
26
+ - lib/ip_geo_lookup.rb
27
+ - lib/ip_geo_lookup/ip_address.rb
28
+ - lib/ip_geo_lookup/mmdb.rb
29
+ - lib/ip_geo_lookup/result.rb
30
+ - lib/ip_geo_lookup/version.rb
31
+ homepage: https://github.com/msuliq/ip_geo_lookup
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ rubygems_mfa_required: 'true'
36
+ homepage_uri: https://github.com/msuliq/ip_geo_lookup
37
+ source_code_uri: https://github.com/msuliq/ip_geo_lookup
38
+ changelog_uri: https://github.com/msuliq/ip_geo_lookup/blob/main/CHANGELOG.md
39
+ bug_tracker_uri: https://github.com/msuliq/ip_geo_lookup/issues
40
+ post_install_message:
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 2.6.0
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.2.33
56
+ signing_key:
57
+ specification_version: 4
58
+ summary: Zero-dependency IPv4/IPv6 geolocation lookup with embedded MaxMind database
59
+ test_files: []