├── Gemfile ├── .gitmodules ├── README.dev.md ├── lib └── maxmind │ ├── geoip2.rb │ └── geoip2 │ ├── version.rb │ ├── model │ ├── enterprise.rb │ ├── abstract.rb │ ├── insights.rb │ ├── domain.rb │ ├── connection_type.rb │ ├── asn.rb │ ├── anonymous_plus.rb │ ├── isp.rb │ ├── anonymous_ip.rb │ ├── country.rb │ └── city.rb │ ├── record │ ├── abstract.rb │ ├── maxmind.rb │ ├── place.rb │ ├── represented_country.rb │ ├── postal.rb │ ├── continent.rb │ ├── city.rb │ ├── subdivision.rb │ ├── country.rb │ ├── location.rb │ ├── anonymizer.rb │ └── traits.rb │ ├── errors.rb │ ├── reader.rb │ └── client.rb ├── Rakefile ├── .github ├── dependabot.yml └── workflows │ ├── zizmor.yml │ ├── rubocop.yml │ ├── test.yml │ └── release.yml ├── .gitignore ├── LICENSE-MIT ├── test ├── test_model_names.rb ├── test_model_country.rb ├── test_client.rb └── test_reader.rb ├── .rubocop.yml ├── maxmind-geoip2.gemspec ├── dev-bin └── release.sh ├── Gemfile.lock ├── CHANGELOG.md ├── LICENSE-APACHE ├── README.md └── CLAUDE.md /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/MaxMind-DB"] 2 | path = test/data 3 | url = https://github.com/maxmind/MaxMind-DB 4 | -------------------------------------------------------------------------------- /README.dev.md: -------------------------------------------------------------------------------- 1 | # How to release 2 | 3 | See 4 | [here](https://github.com/maxmind/minfraud-api-ruby/blob/main/README.dev.md). 5 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/client' 4 | require 'maxmind/geoip2/reader' 5 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MaxMind 4 | module GeoIP2 5 | # The Gem version. 6 | VERSION = '1.4.0' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rake/testtask' 5 | require 'rubocop/rake_task' 6 | 7 | Rake::TestTask.new do |t| 8 | t.libs << 'test' 9 | end 10 | 11 | RuboCop::RakeTask.new 12 | 13 | desc 'Run tests and RuboCop' 14 | task default: %i[test rubocop] 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: / 5 | schedule: 6 | interval: daily 7 | time: '14:00' 8 | open-pull-requests-limit: 10 9 | cooldown: 10 | default-days: 7 11 | - package-ecosystem: github-actions 12 | directory: / 13 | schedule: 14 | interval: daily 15 | time: '14:00' 16 | cooldown: 17 | default-days: 7 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor files 2 | *.swp 3 | *~ 4 | .DS_Store 5 | 6 | # YARD documentation 7 | /doc 8 | /.yardoc 9 | 10 | # Bundler 11 | /.bundle 12 | /vendor/bundle 13 | 14 | # Gem build artifacts 15 | *.gem 16 | /pkg 17 | 18 | # Test coverage 19 | /coverage 20 | 21 | # RuboCop cache 22 | /.rubocop-* 23 | 24 | # IDE/Editor specific 25 | /.idea 26 | /.vscode 27 | *.iml 28 | 29 | # Temporary files 30 | /tmp 31 | 32 | # Claude Code 33 | .claude 34 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/enterprise.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/model/city' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Model 8 | # Model class for the data returned by GeoIP2 Enterprise database lookups. 9 | # 10 | # See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more 11 | # details. 12 | # 13 | # See {MaxMind::GeoIP2::Model::City} for inherited methods. 14 | class Enterprise < City 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/abstract.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MaxMind 4 | module GeoIP2 5 | module Record 6 | # @!visibility private 7 | class Abstract 8 | def initialize(record) 9 | @record = record 10 | end 11 | 12 | protected 13 | 14 | def get(key) 15 | if @record.nil? || !@record.key?(key) 16 | return false if key.start_with?('is_') 17 | 18 | return nil 19 | end 20 | 21 | @record[key] 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/workflows/zizmor.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Actions Security Analysis with zizmor 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["**"] 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | zizmor: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | security-events: write 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v6 19 | with: 20 | persist-credentials: false 21 | 22 | - name: Run zizmor 23 | uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0 24 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/maxmind.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/abstract' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Record 8 | # Contains data about your account. 9 | # 10 | # This record is returned by all location services. 11 | class MaxMind < Abstract 12 | # The number of remaining queries you have for the service you are calling. 13 | # 14 | # @return [Integer, nil] 15 | def queries_remaining 16 | get('queries_remaining') 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Run rubocop 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '3 22 * * SUN' 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | rubocop: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | with: 17 | persist-credentials: false 18 | 19 | # zizmor complains that 'v1' is a ref that can be provided by both the branch and tag namespaces. 20 | # specify that we want the v1 branch. 21 | - uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # 1.269.0 22 | with: 23 | ruby-version: 3.4 24 | 25 | - run: bundle install 26 | - run: bundle exec rake -t rubocop 27 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/abstract.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ipaddr' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Model 8 | # @!visibility private 9 | class Abstract 10 | def initialize(record) 11 | @record = record 12 | 13 | ip = IPAddr.new(record['ip_address']).mask(record['prefix_length']) 14 | record['network'] = format('%s/%d', ip.to_s, record['prefix_length']) 15 | end 16 | 17 | protected 18 | 19 | def get(key) 20 | if @record.nil? || !@record.key?(key) 21 | return false if key.start_with?('is_') 22 | 23 | return nil 24 | end 25 | 26 | @record[key] 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/place.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/abstract' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Record 8 | # Location data common to different location types. 9 | class Place < Abstract 10 | # @!visibility private 11 | def initialize(record, locales) 12 | super(record) 13 | @locales = locales 14 | end 15 | 16 | # The first available localized name in order of preference. 17 | # 18 | # @return [String, nil] 19 | def name 20 | n = names 21 | return nil if n.nil? 22 | 23 | @locales.each do |locale| 24 | return n[locale] if n.key?(locale) 25 | end 26 | 27 | nil 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/insights.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/model/city' 4 | require 'maxmind/geoip2/record/anonymizer' 5 | 6 | module MaxMind 7 | module GeoIP2 8 | module Model 9 | # Model class for the data returned by the GeoIP2 Insights web service. 10 | # 11 | # See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more 12 | # details. 13 | class Insights < City 14 | # Data indicating whether the IP address is part of an anonymizing 15 | # network. 16 | # 17 | # @return [MaxMind::GeoIP2::Record::Anonymizer] 18 | attr_reader :anonymizer 19 | 20 | # @!visibility private 21 | def initialize(record, locales) 22 | super 23 | @anonymizer = MaxMind::GeoIP2::Record::Anonymizer.new( 24 | record['anonymizer'], 25 | ) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/represented_country.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/country' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Record 8 | # Contains data for the represented country associated with an IP address. 9 | # 10 | # This class contains the country-level data associated with an IP address 11 | # for the IP's represented country. The represented country is the country 12 | # represented by something like a military base. 13 | # 14 | # See {MaxMind::GeoIP2::Record::Country} for inherited methods. 15 | class RepresentedCountry < Country 16 | # A string indicating the type of entity that is representing the country. 17 | # Currently we only return +military+ but this could expand to include 18 | # other types in the future. 19 | # 20 | # @return [String, nil] 21 | def type 22 | get('type') 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy of 2 | this software and associated documentation files (the "Software"), to deal in 3 | the Software without restriction, including without limitation the rights to 4 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '3 23 * * SUN' 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest, windows-latest, macos-latest] 18 | version: 19 | [ 20 | 3.2, 21 | 3.3, 22 | 3.4, 23 | jruby, 24 | ] 25 | exclude: 26 | - os: windows-latest 27 | version: jruby 28 | steps: 29 | - uses: actions/checkout@v6 30 | with: 31 | submodules: true 32 | persist-credentials: false 33 | 34 | # zizmor complains that 'v1' is a ref that can be provided by both the branch and tag namespaces. 35 | # specify that we want the v1 branch. 36 | - uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # 1.269.0 37 | with: 38 | ruby-version: ${{ matrix.version }} 39 | 40 | - run: bundle install 41 | - run: bundle exec rake -t test 42 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/domain.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/model/abstract' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Model 8 | # Model class for the GeoIP2 Domain database. 9 | class Domain < Abstract 10 | # The second level domain associated with the IP address. This will be 11 | # something like "example.com" or "example.co.uk", not "foo.example.com". 12 | # 13 | # @return [String, nil] 14 | def domain 15 | get('domain') 16 | end 17 | 18 | # The IP address that the data in the model is for. 19 | # 20 | # @return [String] 21 | def ip_address 22 | get('ip_address') 23 | end 24 | 25 | # The network in CIDR notation associated with the record. In particular, 26 | # this is the largest network where all of the fields besides ip_address 27 | # have the same value. 28 | # 29 | # @return [String] 30 | def network 31 | get('network') 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | release: 10 | types: 11 | - published 12 | 13 | jobs: 14 | push: 15 | if: github.event_name == 'release' && github.event.action == 'published' 16 | runs-on: ubuntu-latest 17 | environment: release 18 | permissions: 19 | id-token: write 20 | steps: 21 | - uses: actions/checkout@v6 22 | with: 23 | submodules: true 24 | persist-credentials: false 25 | 26 | # zizmor complains that 'v1' is a ref that can be provided by both the branch and tag namespaces. 27 | # specify that we want the v1 branch. 28 | - name: Set up Ruby 29 | uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # 1.269.0 30 | with: 31 | ruby-version: ruby 32 | 33 | - run: bundle install 34 | 35 | # zizmor complains that 'v1' is a ref that can be provided by both the branch and tag namespaces. 36 | # specify that we want the v1 branch. 37 | - uses: rubygems/release-gem@1c162a739e8b4cb21a676e97b087e8268d8fc40b # 1.1.2 38 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/connection_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/model/abstract' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Model 8 | # Model class for the GeoIP2 Connection Type database. 9 | class ConnectionType < Abstract 10 | # The connection type may take the following values: "Dialup", 11 | # "Cable/DSL", "Corporate", "Cellular", and "Satellite". Additional 12 | # values may be added in the future. 13 | # 14 | # @return [String, nil] 15 | def connection_type 16 | get('connection_type') 17 | end 18 | 19 | # The IP address that the data in the model is for. 20 | # 21 | # @return [String] 22 | def ip_address 23 | get('ip_address') 24 | end 25 | 26 | # The network in CIDR notation associated with the record. In particular, 27 | # this is the largest network where all of the fields besides ip_address 28 | # have the same value. 29 | # 30 | # @return [String] 31 | def network 32 | get('network') 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/postal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/abstract' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Record 8 | # Contains data for the postal record associated with an IP address. 9 | # 10 | # This record is returned by all location services and databases besides 11 | # Country. 12 | class Postal < Abstract 13 | # The postal code of the location. Postal codes are not available for all 14 | # countries. In some countries, this will only contain part of the postal 15 | # code. This attribute is returned by all location databases and services 16 | # besides Country. 17 | # 18 | # @return [String, nil] 19 | def code 20 | get('code') 21 | end 22 | 23 | # A value from 0-100 indicating MaxMind's confidence that the postal code 24 | # is correct. This attribute is only available from the Insights service 25 | # and the GeoIP2 Enterprise database. 26 | # 27 | # @return [Integer, nil] 28 | def confidence 29 | get('confidence') 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/asn.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/model/abstract' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Model 8 | # Model class for the GeoLite2 ASN database. 9 | class ASN < Abstract 10 | # The autonomous system number associated with the IP address. 11 | # 12 | # @return [Integer, nil] 13 | def autonomous_system_number 14 | get('autonomous_system_number') 15 | end 16 | 17 | # The organization associated with the registered autonomous system number 18 | # for the IP address. 19 | # 20 | # @return [String, nil] 21 | def autonomous_system_organization 22 | get('autonomous_system_organization') 23 | end 24 | 25 | # The IP address that the data in the model is for. 26 | # 27 | # @return [String] 28 | def ip_address 29 | get('ip_address') 30 | end 31 | 32 | # The network in CIDR notation associated with the record. In particular, 33 | # this is the largest network where all of the fields besides ip_address 34 | # have the same value. 35 | # 36 | # @return [String] 37 | def network 38 | get('network') 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/continent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/place' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Record 8 | # Contains data for the continent record associated with an IP address. 9 | # 10 | # This record is returned by all location services and databases. 11 | # 12 | # See {MaxMind::GeoIP2::Record::Place} for inherited methods. 13 | class Continent < Place 14 | # A two character continent code like "NA" (North America) or "OC" 15 | # (Oceania). This attribute is returned by all location services and 16 | # databases. 17 | # 18 | # @return [String, nil] 19 | def code 20 | get('code') 21 | end 22 | 23 | # The GeoName ID for the continent. This attribute is returned by all 24 | # location services and databases. 25 | # 26 | # @return [String, nil] 27 | def geoname_id 28 | get('geoname_id') 29 | end 30 | 31 | # A Hash where the keys are locale codes and the values are names. This 32 | # attribute is returned by all location services and databases. 33 | # 34 | # @return [Hash, nil] 35 | def names 36 | get('names') 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/test_model_names.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2' 4 | require 'minitest/autorun' 5 | 6 | class ModelNameTest < Minitest::Test 7 | RAW = { 8 | 'continent' => { 9 | 'code' => 'NA', 10 | 'geoname_id' => 42, 11 | 'names' => { 12 | 'en' => 'North America', 13 | 'zh-CN' => '北美洲', 14 | }, 15 | }, 16 | 'country' => { 17 | 'geoname_id' => 1, 18 | 'iso_code' => 'US', 19 | 'names' => { 20 | 'en' => 'United States of America', 21 | 'ru' => 'объединяет государства', 22 | 'zh-CN' => '美国', 23 | }, 24 | }, 25 | 'traits' => { 26 | 'ip_address' => '1.2.3.4', 27 | }, 28 | }.freeze 29 | 30 | def test_fallback 31 | model = MaxMind::GeoIP2::Model::Country.new(RAW, %w[ru zh-CN en]) 32 | 33 | assert_equal('北美洲', model.continent.name) 34 | assert_equal('объединяет государства', model.country.name) 35 | end 36 | 37 | def test_two_fallbacks 38 | model = MaxMind::GeoIP2::Model::Country.new(RAW, %w[ru jp]) 39 | 40 | assert_nil(model.continent.name) 41 | assert_equal('объединяет государства', model.country.name) 42 | end 43 | 44 | def test_no_fallbacks 45 | model = MaxMind::GeoIP2::Model::Country.new(RAW, %w[jp]) 46 | 47 | assert_nil(model.continent.name) 48 | assert_nil(model.country.name) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-minitest 3 | - rubocop-performance 4 | - rubocop-rake 5 | - rubocop-thread_safety 6 | 7 | AllCops: 8 | TargetRubyVersion: '3.2' 9 | NewCops: enable 10 | 11 | # Metrics are too arbitrary. 12 | Metrics/AbcSize: 13 | Enabled: false 14 | Metrics/ClassLength: 15 | Enabled: false 16 | Layout/LineLength: 17 | Enabled: false 18 | Metrics/MethodLength: 19 | Enabled: false 20 | 21 | # Weird. 22 | Style/FormatStringToken: 23 | Enabled: false 24 | Style/NumericPredicate: 25 | Enabled: false 26 | 27 | # Trailing commas are good. 28 | Style/TrailingCommaInArguments: 29 | Enabled: false 30 | Style/TrailingCommaInArrayLiteral: 31 | Enabled: false 32 | Style/TrailingCommaInHashLiteral: 33 | Enabled: false 34 | 35 | # Use unless as much as possible? I disagree! 36 | Style/NegatedIf: 37 | Enabled: false 38 | 39 | # This doesn't always make sense. 40 | Style/IfUnlessModifier: 41 | Enabled: false 42 | 43 | Style/HashTransformKeys: 44 | Enabled: true 45 | 46 | Gemspec/DevelopmentDependencies: 47 | Enabled: false 48 | 49 | # Sometimes it makes sense to have lots of assertions. 50 | Minitest/MultipleAssertions: 51 | Enabled: false 52 | # This seems less clear to me. 53 | Minitest/RefutePredicate: 54 | Enabled: false 55 | # This seems less clear to me. 56 | Minitest/AssertPredicate: 57 | Enabled: false 58 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/city.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/place' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Record 8 | # City-level data associated with an IP address. 9 | # 10 | # This record is returned by all location services and databases besides 11 | # Country. 12 | # 13 | # See {MaxMind::GeoIP2::Record::Place} for inherited methods. 14 | class City < Place 15 | # A value from 0-100 indicating MaxMind's confidence that the city is 16 | # correct. This attribute is only available from the Insights service and 17 | # the GeoIP2 Enterprise database. 18 | # 19 | # @return [Integer, nil] 20 | def confidence 21 | get('confidence') 22 | end 23 | 24 | # The GeoName ID for the city. This attribute is returned by all location 25 | # services and databases. 26 | # 27 | # @return [Integer, nil] 28 | def geoname_id 29 | get('geoname_id') 30 | end 31 | 32 | # A Hash where the keys are locale codes and the values are names. This 33 | # attribute is returned by all location services and databases. 34 | # 35 | # @return [Hash, nil] 36 | def names 37 | get('names') 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/anonymous_plus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | require 'maxmind/geoip2/model/anonymous_ip' 5 | 6 | module MaxMind 7 | module GeoIP2 8 | module Model 9 | # Model class for the Anonymous Plus database. 10 | class AnonymousPlus < AnonymousIP 11 | # A score ranging from 1 to 99 that is our percent confidence that the 12 | # network is currently part of an actively used VPN service. 13 | # 14 | # @return [Integer, nil] 15 | def anonymizer_confidence 16 | get('anonymizer_confidence') 17 | end 18 | 19 | # The last day that the network was sighted in our analysis of 20 | # anonymized networks. This value is parsed lazily. 21 | # 22 | # @return [Date, nil] A Date object representing the last seen date, 23 | # or nil if the date is not available. 24 | def network_last_seen 25 | return @network_last_seen if defined?(@network_last_seen) 26 | 27 | date_string = get('network_last_seen') 28 | 29 | if !date_string 30 | return nil 31 | end 32 | 33 | @network_last_seen = Date.parse(date_string) 34 | end 35 | 36 | # The name of the VPN provider (e.g., NordVPN, SurfShark, etc.) 37 | # associated with the network. 38 | # 39 | # @return [String, nil] 40 | def provider_name 41 | get('provider_name') 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MaxMind 4 | module GeoIP2 5 | # Module's base error class 6 | class Error < StandardError 7 | end 8 | 9 | # Base error class for all errors that originate from the IP address 10 | # itself and will not change when retried. 11 | class AddressError < Error 12 | end 13 | 14 | # An AddressNotFoundError means the IP address was not found in the 15 | # database or the web service said the IP address was not found. 16 | class AddressNotFoundError < AddressError 17 | end 18 | 19 | # An HTTPError means there was an unexpected HTTP status or response. 20 | class HTTPError < Error 21 | end 22 | 23 | # An AddressInvalidError means the IP address was invalid. 24 | class AddressInvalidError < AddressError 25 | end 26 | 27 | # An AddressReservedError means the IP address is reserved. 28 | class AddressReservedError < AddressError 29 | end 30 | 31 | # An AuthenticationError means there was a problem authenticating to the 32 | # web service. 33 | class AuthenticationError < Error 34 | end 35 | 36 | # An InsufficientFundsError means the account is out of credits. 37 | class InsufficientFundsError < Error 38 | end 39 | 40 | # A PermissionRequiredError means the account does not have permission to 41 | # use the requested service. 42 | class PermissionRequiredError < Error 43 | end 44 | 45 | # An InvalidRequestError means the web service returned an error and there 46 | # is no more specific error class. 47 | class InvalidRequestError < Error 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/subdivision.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/place' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Record 8 | # Contains data for the subdivisions associated with an IP address. 9 | # 10 | # This record is returned by all location databases and services besides 11 | # Country. 12 | # 13 | # See {MaxMind::GeoIP2::Record::Place} for inherited methods. 14 | class Subdivision < Place 15 | # This is a value from 0-100 indicating MaxMind's confidence that the 16 | # subdivision is correct. This attribute is only available from the 17 | # Insights service and the GeoIP2 Enterprise database. 18 | # 19 | # @return [Integer, nil] 20 | def confidence 21 | get('confidence') 22 | end 23 | 24 | # This is a GeoName ID for the subdivision. This attribute is returned by 25 | # all location databases and services besides Country. 26 | # 27 | # @return [Integer, nil] 28 | def geoname_id 29 | get('geoname_id') 30 | end 31 | 32 | # This is a string up to three characters long contain the subdivision 33 | # portion of the ISO 3166-2 code. See 34 | # https://en.wikipedia.org/wiki/ISO_3166-2. This attribute is returned by 35 | # all location databases and services except Country. 36 | # 37 | # @return [String, nil] 38 | def iso_code 39 | get('iso_code') 40 | end 41 | 42 | # A Hash where the keys are locale codes and the values are names. This attribute is returned by all location services and 43 | # databases besides country. 44 | # 45 | # @return [Hash, nil] 46 | def names 47 | get('names') 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /maxmind-geoip2.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | require 'maxmind/geoip2/version' 7 | 8 | Gem::Specification.new do |s| 9 | s.authors = ['William Storey'] 10 | s.files = Dir['**/*'].difference(Dir['.github/**/*', 'dev-bin/**/*', 'test/**/*', 'CLAUDE.md', 'Gemfile*', 'Rakefile', '*.gemspec', 'README.dev.md']) 11 | s.name = 'maxmind-geoip2' 12 | s.summary = 'A gem for interacting with the GeoIP2 webservices and databases.' 13 | s.version = MaxMind::GeoIP2::VERSION 14 | 15 | s.description = 'A gem for interacting with the GeoIP2 webservices and databases. MaxMind provides geolocation data as downloadable databases as well as through a webservice.' 16 | s.email = 'support@maxmind.com' 17 | s.homepage = 'https://github.com/maxmind/GeoIP2-ruby' 18 | s.licenses = ['Apache-2.0', 'MIT'] 19 | s.metadata = { 20 | 'bug_tracker_uri' => 'https://github.com/maxmind/GeoIP2-ruby/issues', 21 | 'changelog_uri' => 'https://github.com/maxmind/GeoIP2-ruby/blob/main/CHANGELOG.md', 22 | 'documentation_uri' => 'https://www.rubydoc.info/gems/maxmind-geoip2', 23 | 'homepage_uri' => 'https://github.com/maxmind/GeoIP2-ruby', 24 | 'rubygems_mfa_required' => 'true', 25 | 'source_code_uri' => 'https://github.com/maxmind/GeoIP2-ruby', 26 | } 27 | s.required_ruby_version = '>= 3.2' 28 | 29 | s.add_dependency 'connection_pool', '>= 2.2', '< 4.0' 30 | s.add_dependency 'http', '>= 4.3', '< 6.0' 31 | s.add_dependency 'maxmind-db', ['~> 1.4'] 32 | 33 | s.add_development_dependency 'minitest' 34 | s.add_development_dependency 'rake' 35 | s.add_development_dependency 'rubocop' 36 | s.add_development_dependency 'rubocop-minitest' 37 | s.add_development_dependency 'rubocop-performance' 38 | s.add_development_dependency 'rubocop-rake' 39 | s.add_development_dependency 'rubocop-thread_safety' 40 | s.add_development_dependency 'webmock' 41 | end 42 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/country.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/place' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Record 8 | # Contains data for the country record associated with an IP address. 9 | # 10 | # This record is returned by all location services and databases. 11 | # 12 | # See {MaxMind::GeoIP2::Record::Place} for inherited methods. 13 | class Country < Place 14 | # A value from 0-100 indicating MaxMind's confidence that the country is 15 | # correct. This attribute is only available from the Insights service and 16 | # the GeoIP2 Enterprise database. 17 | # 18 | # @return [Integer, nil] 19 | def confidence 20 | get('confidence') 21 | end 22 | 23 | # The GeoName ID for the country. This attribute is returned by all 24 | # location services and databases. 25 | # 26 | # @return [Integer, nil] 27 | def geoname_id 28 | get('geoname_id') 29 | end 30 | 31 | # This is true if the country is a member state of the European Union. This 32 | # attribute is returned by all location services and databases. 33 | # 34 | # @return [Boolean] 35 | def in_european_union? 36 | get('is_in_european_union') 37 | end 38 | 39 | # The two-character ISO 3166-1 alpha code for the country. See 40 | # https://en.wikipedia.org/wiki/ISO_3166-1. This attribute is returned by 41 | # all location services and databases. 42 | # 43 | # @return [String, nil] 44 | def iso_code 45 | get('iso_code') 46 | end 47 | 48 | # A Hash where the keys are locale codes and the values are names. This 49 | # attribute is returned by all location services and databases. 50 | # 51 | # @return [Hash, nil] 52 | def names 53 | get('names') 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/isp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/model/abstract' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Model 8 | # Model class for the GeoIP2 ISP database. 9 | class ISP < Abstract 10 | # The autonomous system number associated with the IP address. 11 | # 12 | # @return [Integer, nil] 13 | def autonomous_system_number 14 | get('autonomous_system_number') 15 | end 16 | 17 | # The organization associated with the registered autonomous system number 18 | # for the IP address. 19 | # 20 | # @return [String, nil] 21 | def autonomous_system_organization 22 | get('autonomous_system_organization') 23 | end 24 | 25 | # The IP address that the data in the model is for. 26 | # 27 | # @return [String] 28 | def ip_address 29 | get('ip_address') 30 | end 31 | 32 | # The name of the ISP associated with the IP address. 33 | # 34 | # @return [String, nil] 35 | def isp 36 | get('isp') 37 | end 38 | 39 | # The {https://en.wikipedia.org/wiki/Mobile_country_code mobile country 40 | # code (MCC)} associated with the IP address and ISP. 41 | # 42 | # @return [String, nil] 43 | def mobile_country_code 44 | get('mobile_country_code') 45 | end 46 | 47 | # The {https://en.wikipedia.org/wiki/Mobile_country_code mobile network 48 | # code (MNC)} associated with the IP address and ISP. 49 | # 50 | # @return [String, nil] 51 | def mobile_network_code 52 | get('mobile_network_code') 53 | end 54 | 55 | # The network in CIDR notation associated with the record. In particular, 56 | # this is the largest network where all of the fields besides ip_address 57 | # have the same value. 58 | # 59 | # @return [String] 60 | def network 61 | get('network') 62 | end 63 | 64 | # The name of the organization associated with the IP address. 65 | # 66 | # @return [String, nil] 67 | def organization 68 | get('organization') 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/anonymous_ip.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/model/abstract' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Model 8 | # Model class for the Anonymous IP database. 9 | class AnonymousIP < Abstract 10 | # This is true if the IP address belongs to any sort of anonymous network. 11 | # 12 | # @return [Boolean] 13 | def anonymous? 14 | get('is_anonymous') 15 | end 16 | 17 | # This is true if the IP address is registered to an anonymous VPN 18 | # provider. If a VPN provider does not register subnets under names 19 | # associated with them, we will likely only flag their IP ranges using the 20 | # hosting_provider? method. 21 | # 22 | # @return [Boolean] 23 | def anonymous_vpn? 24 | get('is_anonymous_vpn') 25 | end 26 | 27 | # This is true if the IP address belongs to a hosting or VPN provider (see 28 | # description of the anonymous_vpn? method). 29 | # 30 | # @return [Boolean] 31 | def hosting_provider? 32 | get('is_hosting_provider') 33 | end 34 | 35 | # The IP address that the data in the model is for. 36 | # 37 | # @return [String] 38 | def ip_address 39 | get('ip_address') 40 | end 41 | 42 | # The network in CIDR notation associated with the record. In particular, 43 | # this is the largest network where all of the fields besides ip_address 44 | # have the same value. 45 | # 46 | # @return [String] 47 | def network 48 | get('network') 49 | end 50 | 51 | # This is true if the IP address belongs to a public proxy. 52 | # 53 | # @return [Boolean] 54 | def public_proxy? 55 | get('is_public_proxy') 56 | end 57 | 58 | # This is true if the IP address is on a suspected anonymizing network 59 | # and belongs to a residential ISP. 60 | # 61 | # @return [Boolean] 62 | def residential_proxy? 63 | get('is_residential_proxy') 64 | end 65 | 66 | # This is true if the IP address is a Tor exit node. 67 | # 68 | # @return [Boolean] 69 | def tor_exit_node? 70 | get('is_tor_exit_node') 71 | end 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/location.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/abstract' 4 | 5 | module MaxMind 6 | module GeoIP2 7 | module Record 8 | # Contains data for the location record associated with an IP address. 9 | # 10 | # This record is returned by all location services and databases besides 11 | # Country. 12 | class Location < Abstract 13 | # The approximate accuracy radius in kilometers around the latitude and 14 | # longitude for the IP address. This is the radius where we have a 67% 15 | # confidence that the device using the IP address resides within the circle 16 | # centered at the latitude and longitude with the provided radius. 17 | # 18 | # @return [Integer, nil] 19 | def accuracy_radius 20 | get('accuracy_radius') 21 | end 22 | 23 | # The average income in US dollars associated with the requested IP 24 | # address. This attribute is only available from the Insights service. 25 | # 26 | # @return [Integer, nil] 27 | def average_income 28 | get('average_income') 29 | end 30 | 31 | # The approximate latitude of the location associated with the IP address. 32 | # This value is not precise and should not be used to identify a particular 33 | # address or household. 34 | # 35 | # @return [Float, nil] 36 | def latitude 37 | get('latitude') 38 | end 39 | 40 | # The approximate longitude of the location associated with the IP address. 41 | # This value is not precise and should not be used to identify a particular 42 | # address or household. 43 | # 44 | # @return [Float, nil] 45 | def longitude 46 | get('longitude') 47 | end 48 | 49 | # The metro code is a no-longer-maintained code for targeting 50 | # advertisements in Google. 51 | # 52 | # @return [Integer, nil] 53 | # @deprecated Code values are no longer maintained. 54 | def metro_code 55 | get('metro_code') 56 | end 57 | 58 | # The estimated population per square kilometer associated with the IP 59 | # address. This attribute is only available from the Insights service. 60 | # 61 | # @return [Integer, nil] 62 | def population_density 63 | get('population_density') 64 | end 65 | 66 | # The time zone associated with location, as specified by the IANA Time 67 | # Zone Database, e.g., "America/New_York". See 68 | # https://www.iana.org/time-zones. 69 | # 70 | # @return [String, nil] 71 | def time_zone 72 | get('time_zone') 73 | end 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/country.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/record/continent' 4 | require 'maxmind/geoip2/record/country' 5 | require 'maxmind/geoip2/record/maxmind' 6 | require 'maxmind/geoip2/record/represented_country' 7 | require 'maxmind/geoip2/record/traits' 8 | 9 | module MaxMind 10 | module GeoIP2 11 | module Model 12 | # Model class for the data returned by the GeoIP2 Country web service and 13 | # database. It is also used for GeoLite2 Country lookups. 14 | class Country 15 | # Continent data for the IP address. 16 | # 17 | # @return [MaxMind::GeoIP2::Record::Continent] 18 | attr_reader :continent 19 | 20 | # Country data for the IP address. This object represents the country where 21 | # MaxMind believes the end user is located. 22 | # 23 | # @return [MaxMind::GeoIP2::Record::Country] 24 | attr_reader :country 25 | 26 | # Data related to your MaxMind account. 27 | # 28 | # @return [MaxMind::GeoIP2::Record::MaxMind] 29 | attr_reader :maxmind 30 | 31 | # Registered country data for the IP address. This record represents the 32 | # country where the ISP has registered a given IP block and may differ from 33 | # the user's country. 34 | # 35 | # @return [MaxMind::GeoIP2::Record::Country] 36 | attr_reader :registered_country 37 | 38 | # Represented country data for the IP address. The represented country is 39 | # used for things like military bases. It is only present when the 40 | # represented country differs from the country. 41 | # 42 | # @return [MaxMind::GeoIP2::Record::RepresentedCountry] 43 | attr_reader :represented_country 44 | 45 | # Data for the traits of the IP address. 46 | # 47 | # @return [MaxMind::GeoIP2::Record::Traits] 48 | attr_reader :traits 49 | 50 | # @!visibility private 51 | def initialize(record, locales) 52 | @continent = MaxMind::GeoIP2::Record::Continent.new( 53 | record['continent'], 54 | locales, 55 | ) 56 | @country = MaxMind::GeoIP2::Record::Country.new( 57 | record['country'], 58 | locales, 59 | ) 60 | @maxmind = MaxMind::GeoIP2::Record::MaxMind.new(record['maxmind']) 61 | @registered_country = MaxMind::GeoIP2::Record::Country.new( 62 | record['registered_country'], 63 | locales, 64 | ) 65 | @represented_country = MaxMind::GeoIP2::Record::RepresentedCountry.new( 66 | record['represented_country'], 67 | locales, 68 | ) 69 | @traits = MaxMind::GeoIP2::Record::Traits.new(record['traits']) 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/model/city.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2/model/country' 4 | require 'maxmind/geoip2/record/city' 5 | require 'maxmind/geoip2/record/location' 6 | require 'maxmind/geoip2/record/postal' 7 | require 'maxmind/geoip2/record/subdivision' 8 | 9 | module MaxMind 10 | module GeoIP2 11 | module Model 12 | # Model class for the data returned by the GeoIP2 City Plus web service 13 | # and the City database. It is also used for GeoLite2 City lookups. 14 | # 15 | # See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more 16 | # details. 17 | # 18 | # See {MaxMind::GeoIP2::Model::Country} for inherited methods. 19 | class City < Country 20 | # City data for the IP address. 21 | # 22 | # @return [MaxMind::GeoIP2::Record::City] 23 | attr_reader :city 24 | 25 | # Location data for the IP address. 26 | # 27 | # @return [MaxMind::GeoIP2::Record::Location] 28 | attr_reader :location 29 | 30 | # Postal data for the IP address. 31 | # 32 | # @return [MaxMind::GeoIP2::Record::Postal] 33 | attr_reader :postal 34 | 35 | # The country subdivisions for the IP address. 36 | # 37 | # The number and type of subdivisions varies by country, but a subdivision 38 | # is typically a state, province, country, etc. Subdivisions are ordered 39 | # from most general (largest) to most specific (smallest). 40 | # 41 | # If the response did not contain any subdivisions, this attribute will be 42 | # an empty array. 43 | # 44 | # @return [Array] 45 | attr_reader :subdivisions 46 | 47 | # @!visibility private 48 | def initialize(record, locales) 49 | super 50 | @city = MaxMind::GeoIP2::Record::City.new(record['city'], locales) 51 | @location = MaxMind::GeoIP2::Record::Location.new(record['location']) 52 | @postal = MaxMind::GeoIP2::Record::Postal.new(record['postal']) 53 | @subdivisions = create_subdivisions(record['subdivisions'], locales) 54 | end 55 | 56 | # The most specific subdivision returned. 57 | # 58 | # If the response did not contain any subdivisions, this method returns 59 | # nil. 60 | # 61 | # @return [MaxMind::GeoIP2::Record::Subdivision, nil] 62 | def most_specific_subdivision 63 | @subdivisions.last 64 | end 65 | 66 | private 67 | 68 | def create_subdivisions(subdivisions, locales) 69 | return [] if subdivisions.nil? 70 | 71 | subdivisions.map do |s| 72 | MaxMind::GeoIP2::Record::Subdivision.new(s, locales) 73 | end 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /dev-bin/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu -o pipefail 4 | 5 | # Pre-flight checks - verify all required tools are available and configured 6 | # before making any changes to the repository 7 | 8 | check_command() { 9 | if ! command -v "$1" &>/dev/null; then 10 | echo "Error: $1 is not installed or not in PATH" 11 | exit 1 12 | fi 13 | } 14 | 15 | # Verify gh CLI is authenticated 16 | if ! gh auth status &>/dev/null; then 17 | echo "Error: gh CLI is not authenticated. Run 'gh auth login' first." 18 | exit 1 19 | fi 20 | 21 | # Verify we can access this repository via gh 22 | if ! gh repo view --json name &>/dev/null; then 23 | echo "Error: Cannot access repository via gh. Check your authentication and repository access." 24 | exit 1 25 | fi 26 | 27 | # Verify git can connect to the remote (catches SSH key issues, etc.) 28 | if ! git ls-remote origin &>/dev/null; then 29 | echo "Error: Cannot connect to git remote. Check your git credentials/SSH keys." 30 | exit 1 31 | fi 32 | 33 | check_command perl 34 | check_command rake 35 | 36 | # Check that we're not on the main branch 37 | current_branch=$(git branch --show-current) 38 | if [ "$current_branch" = "main" ]; then 39 | echo "Error: Releases should not be done directly on the main branch." 40 | echo "Please create a release branch and run this script from there." 41 | exit 1 42 | fi 43 | 44 | # Fetch latest changes and check that we're not behind origin/main 45 | echo "Fetching from origin..." 46 | git fetch origin 47 | 48 | if ! git merge-base --is-ancestor origin/main HEAD; then 49 | echo "Error: Current branch is behind origin/main." 50 | echo "Please merge or rebase with origin/main before releasing." 51 | exit 1 52 | fi 53 | 54 | changelog=$(cat CHANGELOG.md) 55 | 56 | regex=' 57 | ## ([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?) \(([0-9]{4}-[0-9]{2}-[0-9]{2})\) 58 | 59 | ((.| 60 | )*) 61 | ' 62 | 63 | if [[ ! $changelog =~ $regex ]]; then 64 | echo "Could not find date line in change log!" 65 | exit 1 66 | fi 67 | 68 | version="${BASH_REMATCH[1]}" 69 | date="${BASH_REMATCH[3]}" 70 | notes="$(echo "${BASH_REMATCH[4]}" | sed -n -E '/^## [0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?/,$!p')" 71 | 72 | echo "$notes" 73 | if [[ "$date" != "$(date +"%Y-%m-%d")" ]]; then 74 | echo "$date is not today!" 75 | exit 1 76 | fi 77 | 78 | tag="v$version" 79 | 80 | if [ -n "$(git status --porcelain)" ]; then 81 | echo ". is not clean." >&2 82 | exit 1 83 | fi 84 | 85 | perl -pi -e "s/(?<=VERSION = \').+?(?=\')/$version/g" lib/maxmind/geoip2/version.rb 86 | 87 | echo $"Test results:" 88 | 89 | rake 90 | 91 | echo $'\nDiff:' 92 | git diff 93 | 94 | echo $'\nRelease notes:' 95 | echo "$notes" 96 | 97 | read -r -e -p "Commit changes and push to origin? " should_push 98 | 99 | if [ "$should_push" != "y" ]; then 100 | echo "Aborting" 101 | exit 1 102 | fi 103 | 104 | git commit -m "Update for $tag" -a 105 | 106 | git push 107 | 108 | gh release create --target "$(git branch --show-current)" -t "$version" -n "$notes" "$tag" 109 | -------------------------------------------------------------------------------- /test/test_model_country.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/geoip2' 4 | require 'minitest/autorun' 5 | 6 | class CountryModelTest < Minitest::Test 7 | RAW = { 8 | 'continent' => { 9 | 'code' => 'NA', 10 | 'geoname_id' => 42, 11 | 'names' => { 'en' => 'North America' }, 12 | }, 13 | 'country' => { 14 | 'geoname_id' => 1, 15 | 'iso_code' => 'US', 16 | 'names' => { 'en' => 'United States of America' }, 17 | }, 18 | 'registered_country' => { 19 | 'geoname_id' => 2, 20 | 'is_in_european_union' => true, 21 | 'iso_code' => 'DE', 22 | 'names' => { 'en' => 'Germany' }, 23 | }, 24 | 'traits' => { 25 | 'ip_address' => '1.2.3.4', 26 | 'prefix_length' => 24, 27 | }, 28 | }.freeze 29 | 30 | def test_objects 31 | model = MaxMind::GeoIP2::Model::Country.new(RAW, ['en']) 32 | 33 | assert_instance_of(MaxMind::GeoIP2::Model::Country, model) 34 | assert_instance_of(MaxMind::GeoIP2::Record::Continent, model.continent) 35 | assert_instance_of(MaxMind::GeoIP2::Record::Country, model.country) 36 | assert_instance_of( 37 | MaxMind::GeoIP2::Record::Country, model.registered_country, 38 | ) 39 | assert_instance_of( 40 | MaxMind::GeoIP2::Record::RepresentedCountry, model.represented_country, 41 | ) 42 | assert_instance_of( 43 | MaxMind::GeoIP2::Record::Traits, model.traits, 44 | ) 45 | end 46 | 47 | def test_values 48 | model = MaxMind::GeoIP2::Model::Country.new(RAW, ['en']) 49 | 50 | assert_equal(42, model.continent.geoname_id) 51 | assert_equal('NA', model.continent.code) 52 | assert_equal({ 'en' => 'North America' }, model.continent.names) 53 | assert_equal('North America', model.continent.name) 54 | 55 | assert_equal(1, model.country.geoname_id) 56 | refute(model.country.in_european_union?) 57 | assert_equal('US', model.country.iso_code) 58 | assert_equal({ 'en' => 'United States of America' }, model.country.names) 59 | assert_equal('United States of America', model.country.name) 60 | assert_nil(model.country.confidence) 61 | 62 | assert_equal(2, model.registered_country.geoname_id) 63 | assert(model.registered_country.in_european_union?) 64 | assert_equal('DE', model.registered_country.iso_code) 65 | assert_equal({ 'en' => 'Germany' }, model.registered_country.names) 66 | assert_equal('Germany', model.registered_country.name) 67 | end 68 | 69 | def test_unknown_record 70 | model = MaxMind::GeoIP2::Model::Country.new(RAW, ['en']) 71 | assert_raises(NoMethodError) { model.unknown_record } 72 | end 73 | 74 | def test_unknown_trait 75 | model = MaxMind::GeoIP2::Model::Country.new(RAW, ['en']) 76 | assert_raises(NoMethodError) { model.traits.unknown } 77 | end 78 | 79 | # This can happen if we're being created from a not fully populated response 80 | # when used by minFraud. It shouldn't ever happen from GeoIP2 though. 81 | def test_no_traits 82 | model = MaxMind::GeoIP2::Model::Country.new( 83 | { 84 | 'continent' => { 85 | 'code' => 'NA', 86 | 'geoname_id' => 42, 87 | 'names' => { 'en' => 'North America' }, 88 | }, 89 | }, 90 | ['en'], 91 | ) 92 | 93 | assert_equal(42, model.continent.geoname_id) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | maxmind-geoip2 (1.4.0) 5 | connection_pool (>= 2.2, < 4.0) 6 | http (>= 4.3, < 6.0) 7 | maxmind-db (~> 1.4) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | addressable (2.8.8) 13 | public_suffix (>= 2.0.2, < 8.0) 14 | ast (2.4.3) 15 | bigdecimal (3.3.1) 16 | connection_pool (3.0.2) 17 | crack (1.0.1) 18 | bigdecimal 19 | rexml 20 | domain_name (0.6.20240107) 21 | ffi (1.17.2) 22 | ffi (1.17.2-aarch64-linux-gnu) 23 | ffi (1.17.2-aarch64-linux-musl) 24 | ffi (1.17.2-arm-linux-gnu) 25 | ffi (1.17.2-arm-linux-musl) 26 | ffi (1.17.2-arm64-darwin) 27 | ffi (1.17.2-x86-linux-gnu) 28 | ffi (1.17.2-x86-linux-musl) 29 | ffi (1.17.2-x86_64-darwin) 30 | ffi (1.17.2-x86_64-linux-gnu) 31 | ffi (1.17.2-x86_64-linux-musl) 32 | ffi-compiler (1.3.2) 33 | ffi (>= 1.15.5) 34 | rake 35 | hashdiff (1.2.1) 36 | http (5.3.1) 37 | addressable (~> 2.8) 38 | http-cookie (~> 1.0) 39 | http-form_data (~> 2.2) 40 | llhttp-ffi (~> 0.5.0) 41 | http-cookie (1.1.0) 42 | domain_name (~> 0.5) 43 | http-form_data (2.3.0) 44 | json (2.16.0) 45 | language_server-protocol (3.17.0.5) 46 | lint_roller (1.1.0) 47 | llhttp-ffi (0.5.1) 48 | ffi-compiler (~> 1.0) 49 | rake (~> 13.0) 50 | maxmind-db (1.4.0) 51 | minitest (5.26.2) 52 | parallel (1.27.0) 53 | parser (3.3.10.0) 54 | ast (~> 2.4.1) 55 | racc 56 | prism (1.6.0) 57 | public_suffix (7.0.0) 58 | racc (1.8.1) 59 | rainbow (3.1.1) 60 | rake (13.3.1) 61 | regexp_parser (2.11.3) 62 | rexml (3.4.4) 63 | rubocop (1.81.7) 64 | json (~> 2.3) 65 | language_server-protocol (~> 3.17.0.2) 66 | lint_roller (~> 1.1.0) 67 | parallel (~> 1.10) 68 | parser (>= 3.3.0.2) 69 | rainbow (>= 2.2.2, < 4.0) 70 | regexp_parser (>= 2.9.3, < 3.0) 71 | rubocop-ast (>= 1.47.1, < 2.0) 72 | ruby-progressbar (~> 1.7) 73 | unicode-display_width (>= 2.4.0, < 4.0) 74 | rubocop-ast (1.48.0) 75 | parser (>= 3.3.7.2) 76 | prism (~> 1.4) 77 | rubocop-minitest (0.38.2) 78 | lint_roller (~> 1.1) 79 | rubocop (>= 1.75.0, < 2.0) 80 | rubocop-ast (>= 1.38.0, < 2.0) 81 | rubocop-performance (1.26.1) 82 | lint_roller (~> 1.1) 83 | rubocop (>= 1.75.0, < 2.0) 84 | rubocop-ast (>= 1.47.1, < 2.0) 85 | rubocop-rake (0.7.1) 86 | lint_roller (~> 1.1) 87 | rubocop (>= 1.72.1) 88 | rubocop-thread_safety (0.7.3) 89 | lint_roller (~> 1.1) 90 | rubocop (~> 1.72, >= 1.72.1) 91 | rubocop-ast (>= 1.44.0, < 2.0) 92 | ruby-progressbar (1.13.0) 93 | unicode-display_width (3.2.0) 94 | unicode-emoji (~> 4.1) 95 | unicode-emoji (4.1.0) 96 | webmock (3.26.1) 97 | addressable (>= 2.8.0) 98 | crack (>= 0.3.2) 99 | hashdiff (>= 0.4.0, < 2.0.0) 100 | 101 | PLATFORMS 102 | aarch64-linux-gnu 103 | aarch64-linux-musl 104 | arm-linux-gnu 105 | arm-linux-musl 106 | arm64-darwin 107 | ruby 108 | x86-linux-gnu 109 | x86-linux-musl 110 | x86_64-darwin 111 | x86_64-linux-gnu 112 | x86_64-linux-musl 113 | 114 | DEPENDENCIES 115 | maxmind-geoip2! 116 | minitest 117 | rake 118 | rubocop 119 | rubocop-minitest 120 | rubocop-performance 121 | rubocop-rake 122 | rubocop-thread_safety 123 | webmock 124 | 125 | BUNDLED WITH 126 | 2.6.9 127 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/anonymizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | require 'maxmind/geoip2/record/abstract' 5 | 6 | module MaxMind 7 | module GeoIP2 8 | module Record 9 | # Contains data indicating whether an IP address is part of an 10 | # anonymizing network. 11 | # 12 | # This record is returned by the Insights web service. 13 | class Anonymizer < Abstract 14 | # A score ranging from 1 to 99 that represents our percent confidence 15 | # that the network is currently part of an actively used VPN service. 16 | # This property is only available from Insights. 17 | # 18 | # @return [Integer, nil] 19 | def confidence 20 | get('confidence') 21 | end 22 | 23 | # This is true if the IP address belongs to any sort of anonymous 24 | # network. This property is only available from Insights. 25 | # 26 | # @return [Boolean] 27 | def anonymous? 28 | get('is_anonymous') 29 | end 30 | 31 | # This is true if the IP address is registered to an anonymous VPN 32 | # provider. If a VPN provider does not register subnets under names 33 | # associated with them, we will likely only flag their IP ranges using 34 | # the hosting_provider? property. This property is only available from 35 | # Insights. 36 | # 37 | # @return [Boolean] 38 | def anonymous_vpn? 39 | get('is_anonymous_vpn') 40 | end 41 | 42 | # This is true if the IP address belongs to a hosting or VPN provider 43 | # (see description of the anonymous_vpn? property). This property is 44 | # only available from Insights. 45 | # 46 | # @return [Boolean] 47 | def hosting_provider? 48 | get('is_hosting_provider') 49 | end 50 | 51 | # This is true if the IP address belongs to a public proxy. This 52 | # property is only available from Insights. 53 | # 54 | # @return [Boolean] 55 | def public_proxy? 56 | get('is_public_proxy') 57 | end 58 | 59 | # This is true if the IP address is on a suspected anonymizing network 60 | # and belongs to a residential ISP. This property is only available from 61 | # Insights. 62 | # 63 | # @return [Boolean] 64 | def residential_proxy? 65 | get('is_residential_proxy') 66 | end 67 | 68 | # This is true if the IP address is a Tor exit node. This property is 69 | # only available from Insights. 70 | # 71 | # @return [Boolean] 72 | def tor_exit_node? 73 | get('is_tor_exit_node') 74 | end 75 | 76 | # The last day that the network was sighted in our analysis of 77 | # anonymized networks. This value is parsed lazily. This property is 78 | # only available from Insights. 79 | # 80 | # @return [Date, nil] A Date object representing the last seen date, 81 | # or nil if the date is not available. 82 | def network_last_seen 83 | return @network_last_seen if defined?(@network_last_seen) 84 | 85 | date_string = get('network_last_seen') 86 | 87 | if !date_string 88 | return nil 89 | end 90 | 91 | @network_last_seen = Date.parse(date_string) 92 | end 93 | 94 | # The name of the VPN provider (e.g., NordVPN, SurfShark, etc.) 95 | # associated with the network. This property is only available from 96 | # Insights. 97 | # 98 | # @return [String, nil] 99 | def provider_name 100 | get('provider_name') 101 | end 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.5.0 4 | 5 | * Unnecessary files were removed from the published .gem. Pull request by 6 | Orien Madgwick. GitHub #131. 7 | 8 | ## 1.4.0 (2025-11-20) 9 | 10 | * Ruby 3.2+ is now required. If you're using Ruby 3.0 or 3.1, please use 11 | version 1.3.0 of this gem. 12 | * A new `anonymizer` object has been added to the `MaxMind::GeoIP2::Model::Insights` 13 | model. This object indicates whether the IP address is part of an anonymizing 14 | network, including VPN confidence scoring, provider name detection, and network 15 | last seen date. This is only available from the GeoIP2 Insights web service. 16 | * A new `ip_risk_snapshot` method has been added to `MaxMind::GeoIP2::Record::Traits`. 17 | This field contains the risk associated with the IP address, ranging from 0.01 to 18 | 99 (a higher score indicates a higher risk). This is only available from the GeoIP2 19 | Insights web service. 20 | * The `anonymous?`, `anonymous_vpn?`, `hosting_provider?`, `public_proxy?`, 21 | `residential_proxy?`, and `tor_exit_node?` methods in 22 | `MaxMind::GeoIP2::Record::Traits` have been deprecated. Please use the 23 | corresponding methods in the `anonymizer` object from the GeoIP2 Insights 24 | response instead. 25 | 26 | 27 | ## 1.3.0 (2025-05-06) 28 | 29 | * Support for the GeoIP Anonymous Plus database has been added. To do a 30 | lookup in this database, use the `anonymous_plus` method on 31 | `MaxMind::GeoIP2::Reader`. 32 | * Ruby 3.0+ is now required. If you're using Ruby 2.5, 2.6, or 2.7, please 33 | use version 1.2.0 of this gem. 34 | * Deprecated `metro_code` on `MaxMind::GeoIP2::Record::Location`. The code 35 | values are no longer being maintained. 36 | 37 | ## 1.2.0 (2023-12-04) 38 | 39 | * `MaxMind::GeoIP2::Client` now validates the IP address before making a 40 | request to the web service. 41 | * `MaxMind::GeoIP2::Client` now includes the version of Ruby, the version 42 | of the HTTP client library, and its own version in the User-Agent header. 43 | * The `anycast?` method was added to `MaxMind::GeoIP2::Record::Traits`. 44 | This returns `true` if the IP address belongs to an [anycast 45 | network](https://en.wikipedia.org/wiki/Anycast). This is available for 46 | the GeoIP2 Country, City Plus, and Insights web services and the GeoIP2 47 | Country, City, and Enterprise databases. 48 | 49 | ## 1.1.0 (2021-11-18) 50 | 51 | * Exceptions from this gem now inherit from `MaxMind::GeoIP2::Error`. IP 52 | address related exceptions now inherit from 53 | `MaxMind::GeoIP2::AddressError`, which itself inherits from 54 | `MaxMind::GeoIP2::Error`. Pull Request by gr8bit. GitHub #35. 55 | * Support for mobile country code (MCC) and mobile network codes (MNC) was 56 | added for the GeoIP2 ISP and Enterprise databases as well as the GeoIP2 57 | City and Insights web services. `mobile_country_code` and 58 | `mobile_network_code` attributes were added to 59 | `MaxMind::GeoIP2::Model::ISP` for the GeoIP2 ISP database and 60 | `MaxMind::GeoIP2::Record::Traits` for the Enterprise database and the 61 | GeoIP2 City and Insights web services. We expect this data to be 62 | available by late January, 2022. 63 | 64 | ## 1.0.0 (2021-05-14) 65 | 66 | * Ruby 2.4 is no longer supported. If you're using Ruby 2.4, please use 67 | version 0.7.0 of this gem. 68 | * Expand accepted versions of the `http` gem to include 5.0+. 69 | * Bump version to 1.0.0 since we have been at 0.x for over a year. There is 70 | no breaking change. 71 | 72 | ## 0.7.0 (2021-03-24) 73 | 74 | * Ensured defaults are set when creating a `MaxMind::GeoIP2::Client` in the 75 | case when args are explicitly passed in as `nil`. Pull Request by Manoj 76 | Dayaram. GitHub #31. 77 | 78 | ## 0.6.0 (2021-03-23) 79 | 80 | * Updated the `MaxMind::GeoIP2::Reader` constructor to support being called 81 | using keyword arguments. For example, you may now create a `Reader` using 82 | `MaxMind::GeoIP2::Reader.new(database: 'GeoIP2-Country.mmdb')` instead of 83 | using positional arguments. This is intended to make it easier to pass in 84 | optional arguments. Positional argument calling is still supported. 85 | * Proxy support was fixed. Pull request by Manoj Dayaram. GitHub #30. 86 | 87 | ## 0.5.0 (2020-09-25) 88 | 89 | * Added the `residential_proxy?` method to 90 | `MaxMind::GeoIP2::Model::AnonymousIP` and 91 | `MaxMind::GeoIP2::Record::Traits` for use with the Anonymous IP database 92 | and GeoIP2 Precision Insights. 93 | 94 | ## 0.4.0 (2020-03-06) 95 | 96 | * HTTP connections are now persistent. There is a new parameter that 97 | controls the maximum number of connections the client will use. 98 | 99 | ## 0.3.0 (2020-03-04) 100 | 101 | * Modules are now always be defined. Previously we used a shorthand syntax 102 | which meant including individual classes could leave module constants 103 | undefined. 104 | 105 | ## 0.2.0 (2020-02-26) 106 | 107 | * Added support for the GeoIP2 Precision web services: Country, City, and 108 | Insights. 109 | 110 | ## 0.1.0 (2020-02-20) 111 | 112 | * Added support for the Anonymous IP, ASN, Connection Type, Domain, and ISP 113 | databases. 114 | * Added missing dependency on maxmind-db to the gemspec. Reported by Sean 115 | Dilda. GitHub #4. 116 | 117 | ## 0.0.1 (2020-01-09) 118 | 119 | * Initial implementation with support for location databases. 120 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/record/traits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ipaddr' 4 | require 'maxmind/geoip2/record/abstract' 5 | 6 | module MaxMind 7 | module GeoIP2 8 | module Record 9 | # Contains data for the traits record associated with an IP address. 10 | # 11 | # This record is returned by all location services and databases. 12 | class Traits < Abstract 13 | # @!visibility private 14 | def initialize(record) 15 | super 16 | if record && !record.key?('network') && record.key?('ip_address') && 17 | record.key?('prefix_length') 18 | ip = IPAddr.new(record['ip_address']).mask(record['prefix_length']) 19 | record['network'] = format('%s/%d', ip.to_s, ip.prefix) 20 | end 21 | end 22 | 23 | # The autonomous system number associated with the IP address. See 24 | # Wikipedia[https://en.wikipedia.org/wiki/Autonomous_system_(Internet)]. 25 | # This attribute is only available from the City Plus and Insights web 26 | # services and the Enterprise database. 27 | # 28 | # @return [Integer, nil] 29 | def autonomous_system_number 30 | get('autonomous_system_number') 31 | end 32 | 33 | # The organization associated with the registered autonomous system number 34 | # for the IP address. See 35 | # Wikipedia[https://en.wikipedia.org/wiki/Autonomous_system_(Internet)]. 36 | # This attribute is only available from the City Plus and Insights web 37 | # services and the Enterprise database. 38 | # 39 | # @return [String, nil] 40 | def autonomous_system_organization 41 | get('autonomous_system_organization') 42 | end 43 | 44 | # The connection type may take the following values: "Dialup", 45 | # "Cable/DSL", "Corporate", "Cellular", and "Satellite". Additional 46 | # values may be added in the future. This attribute is only available 47 | # from the City Plus and Insights web services and the Enterprise 48 | # database. 49 | # 50 | # @return [String, nil] 51 | def connection_type 52 | get('connection_type') 53 | end 54 | 55 | # The second level domain associated with the IP address. This will be 56 | # something like "example.com" or "example.co.uk", not "foo.example.com". 57 | # This attribute is only available from the City Plus and Insights web 58 | # services and the Enterprise database. 59 | # 60 | # @return [String, nil] 61 | def domain 62 | get('domain') 63 | end 64 | 65 | # The IP address that the data in the model is for. If you performed a "me" 66 | # lookup against the web service, this will be the externally routable IP 67 | # address for the system the code is running on. If the system is behind a 68 | # NAT, this may differ from the IP address locally assigned to it. This 69 | # attribute is returned by all end points. 70 | # 71 | # @return [String, nil] 72 | def ip_address 73 | get('ip_address') 74 | end 75 | 76 | # This is true if the IP address belongs to any sort of anonymous network. 77 | # This property is only available from Insights. 78 | # 79 | # This method is deprecated as of version 1.4.0. Use the anonymizer object 80 | # from the Insights response instead. 81 | # 82 | # @return [Boolean] 83 | # @deprecated since 1.4.0 84 | def anonymous? 85 | get('is_anonymous') 86 | end 87 | 88 | # This is true if the IP address is registered to an anonymous VPN 89 | # provider. If a VPN provider does not register subnets under names 90 | # associated with them, we will likely only flag their IP ranges using the 91 | # hosting_provider? property. This property is only available from Insights. 92 | # 93 | # This method is deprecated as of version 1.4.0. Use the anonymizer object 94 | # from the Insights response instead. 95 | # 96 | # @return [Boolean] 97 | # @deprecated since 1.4.0 98 | def anonymous_vpn? 99 | get('is_anonymous_vpn') 100 | end 101 | 102 | # This is true if the IP address belongs to an 103 | # {https://en.wikipedia.org/wiki/Anycast anycast network}. 104 | # 105 | # This property is only available from the Country, City Plus, and 106 | # Insights web services and the GeoIP2 Country, City, and Enterprise 107 | # databases. 108 | # 109 | # @return [Boolean] 110 | def anycast? 111 | get('is_anycast') 112 | end 113 | 114 | # This is true if the IP address belongs to a hosting or VPN provider (see 115 | # description of the anonymous_vpn? property). This property is only 116 | # available from Insights. 117 | # 118 | # This method is deprecated as of version 1.4.0. Use the anonymizer object 119 | # from the Insights response instead. 120 | # 121 | # @return [Boolean] 122 | # @deprecated since 1.4.0 123 | def hosting_provider? 124 | get('is_hosting_provider') 125 | end 126 | 127 | # This attribute is true if MaxMind believes this IP address to be a 128 | # legitimate proxy, such as an internal VPN used by a corporation. This 129 | # attribute is only available in the Enterprise database. 130 | # 131 | # @return [Boolean] 132 | def legitimate_proxy? 133 | get('is_legitimate_proxy') 134 | end 135 | 136 | # The {https://en.wikipedia.org/wiki/Mobile_country_code mobile country 137 | # code (MCC)} associated with the IP address and ISP. 138 | # 139 | # This attribute is only available from the City Plus and Insights web 140 | # services and the Enterprise database. 141 | # 142 | # @return [String, nil] 143 | def mobile_country_code 144 | get('mobile_country_code') 145 | end 146 | 147 | # The {https://en.wikipedia.org/wiki/Mobile_country_code mobile network 148 | # code (MNC)} associated with the IP address and ISP. 149 | # 150 | # This attribute is only available from the City Plus and Insights web 151 | # services and the Enterprise database. 152 | # 153 | # @return [String, nil] 154 | def mobile_network_code 155 | get('mobile_network_code') 156 | end 157 | 158 | # This is true if the IP address belongs to a public proxy. This property 159 | # is only available from Insights. 160 | # 161 | # This method is deprecated as of version 1.4.0. Use the anonymizer object 162 | # from the Insights response instead. 163 | # 164 | # @return [Boolean] 165 | # @deprecated since 1.4.0 166 | def public_proxy? 167 | get('is_public_proxy') 168 | end 169 | 170 | # This is true if the IP address is on a suspected anonymizing network 171 | # and belongs to a residential ISP. This property is only available 172 | # from Insights. 173 | # 174 | # This method is deprecated as of version 1.4.0. Use the anonymizer object 175 | # from the Insights response instead. 176 | # 177 | # @return [Boolean] 178 | # @deprecated since 1.4.0 179 | def residential_proxy? 180 | get('is_residential_proxy') 181 | end 182 | 183 | # This is true if the IP address is a Tor exit node. This property is only 184 | # available from Insights. 185 | # 186 | # This method is deprecated as of version 1.4.0. Use the anonymizer object 187 | # from the Insights response instead. 188 | # 189 | # @return [Boolean] 190 | # @deprecated since 1.4.0 191 | def tor_exit_node? 192 | get('is_tor_exit_node') 193 | end 194 | 195 | # The name of the ISP associated with the IP address. This attribute is 196 | # only available from the City Plus and Insights web services and the 197 | # Enterprise database. 198 | # 199 | # @return [String, nil] 200 | def isp 201 | get('isp') 202 | end 203 | 204 | # The network in CIDR notation associated with the record. In particular, 205 | # this is the largest network where all of the fields besides ip_address 206 | # have the same value. 207 | # 208 | # @return [String, nil] 209 | def network 210 | get('network') 211 | end 212 | 213 | # The name of the organization associated with the IP address. This 214 | # attribute is only available from the City Plus and Insights web services 215 | # and the Enterprise database. 216 | # 217 | # @return [String, nil] 218 | def organization 219 | get('organization') 220 | end 221 | 222 | # An indicator of how static or dynamic an IP address is. This property is 223 | # only available from Insights. 224 | # 225 | # @return [Float, nil] 226 | def static_ip_score 227 | get('static_ip_score') 228 | end 229 | 230 | # The estimated number of users sharing the IP/network during the past 24 231 | # hours. For IPv4, the count is for the individual IP. For IPv6, the count 232 | # is for the /64 network. This property is only available from Insights. 233 | # 234 | # @return [Integer, nil] 235 | def user_count 236 | get('user_count') 237 | end 238 | 239 | # This field contains the risk associated with the IP address. The value 240 | # ranges from 0.01 to 99. A higher score indicates a higher risk. 241 | # Please note that the IP risk score provided in GeoIP products and 242 | # services is more static than the IP risk score provided in minFraud 243 | # and is not responsive to traffic on your network. If you need realtime 244 | # IP risk scoring based on behavioral signals on your own network, please 245 | # use minFraud. 246 | # 247 | # We do not provide an IP risk snapshot for low-risk networks. If this 248 | # field is not populated, we either do not have signals for the network 249 | # or the signals we have show that the network is low-risk. If you would 250 | # like to get signals for low-risk networks, please use the minFraud web 251 | # services. 252 | # 253 | # This property is only available from Insights. 254 | # 255 | # @return [Float, nil] 256 | def ip_risk_snapshot 257 | get('ip_risk_snapshot') 258 | end 259 | 260 | # The user type associated with the IP address. This can be one of the 261 | # following values: 262 | # 263 | # * business 264 | # * cafe 265 | # * cellular 266 | # * college 267 | # * consumer_privacy_network 268 | # * content_delivery_network 269 | # * dialup 270 | # * government 271 | # * hosting 272 | # * library 273 | # * military 274 | # * residential 275 | # * router 276 | # * school 277 | # * search_engine_spider 278 | # * traveler 279 | # 280 | # This attribute is only available from the Insights web service and the 281 | # Enterprise database. 282 | # 283 | # @return [String, nil] 284 | def user_type 285 | get('user_type') 286 | end 287 | end 288 | end 289 | end 290 | end 291 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GeoIP2 Ruby API 2 | 3 | ## Description 4 | 5 | This is the Ruby API for the GeoIP2 and GeoLite2 6 | [webservices](https://dev.maxmind.com/geoip/docs/web-services?lang=en) 7 | and [databases](https://dev.maxmind.com/geoip/docs/databases?lang=en). 8 | 9 | ## Installation 10 | 11 | ``` 12 | gem install maxmind-geoip2 13 | ``` 14 | 15 | ## IP Geolocation Usage 16 | 17 | IP geolocation is inherently imprecise. Locations are often near the center 18 | of the population. Any location provided by a GeoIP2 database or web 19 | service should not be used to identify a particular address or household. 20 | 21 | ## Database Reader 22 | 23 | ### Usage 24 | 25 | To use this API, you must create a new `MaxMind::GeoIP2::Reader` object 26 | with the path to the database file as the first argument to the 27 | constructor. You may then call the method corresponding to the database you 28 | are using. 29 | 30 | If the lookup succeeds, the method call will return a model class for the 31 | record in the database. This model in turn contains multiple container 32 | objects for the different parts of the data such as the city in which the 33 | IP address is located. 34 | 35 | If the record is not found, a `MaxMind::GeoIP2::AddressNotFoundError` 36 | exception is thrown. If the database is invalid or corrupt, a 37 | `MaxMind::DB::InvalidDatabaseError` exception will be thrown. 38 | 39 | See the [API documentation](https://www.rubydoc.info/gems/maxmind-geoip2) 40 | for more details. 41 | 42 | ### City Example 43 | 44 | ```ruby 45 | require 'maxmind/geoip2' 46 | 47 | # This creates the Reader object which should be reused across lookups. 48 | reader = MaxMind::GeoIP2::Reader.new( 49 | database: '/usr/share/GeoIP/GeoIP2-City.mmdb', 50 | ) 51 | 52 | record = reader.city('128.101.101.101') 53 | 54 | puts record.country.iso_code # US 55 | puts record.country.name # United States 56 | puts record.country.names['zh-CN'] # '美国' 57 | 58 | puts record.most_specific_subdivision.name # Minnesota 59 | puts record.most_specific_subdivision.iso_code # MN 60 | 61 | puts record.city.name # Minneapolis 62 | 63 | puts record.postal.code # 55455 64 | 65 | puts record.location.latitude # 44.9733 66 | puts record.location.longitude # -93.2323 67 | 68 | puts record.traits.network # 128.101.101.101/32 69 | ``` 70 | 71 | ### Country Example 72 | 73 | ```ruby 74 | require 'maxmind/geoip2' 75 | 76 | # This creates the Reader object which should be reused across lookups. 77 | reader = MaxMind::GeoIP2::Reader.new( 78 | database: '/usr/share/GeoIP/GeoIP2-Country.mmdb', 79 | ) 80 | 81 | record = reader.country('128.101.101.101') 82 | 83 | puts record.country.iso_code # US 84 | puts record.country.name # United States 85 | puts record.country.names['zh-CN'] # '美国' 86 | ``` 87 | 88 | ### Enterprise Example 89 | 90 | ```ruby 91 | require 'maxmind/geoip2' 92 | 93 | # This creates the Reader object which should be reused across lookups. 94 | reader = MaxMind::GeoIP2::Reader.new( 95 | database: '/usr/share/GeoIP/GeoIP2-Enterprise.mmdb', 96 | ) 97 | 98 | record = reader.enterprise('128.101.101.101') 99 | 100 | puts record.country.confidence # 99 101 | puts record.country.iso_code # US 102 | puts record.country.name # United States 103 | puts record.country.names['zh-CN'] # '美国' 104 | 105 | puts record.most_specific_subdivision.confidence # 77 106 | puts record.most_specific_subdivision.name # Minnesota 107 | puts record.most_specific_subdivision.iso_code # MN 108 | 109 | puts record.city.confidence # 60 110 | puts record.city.name # Minneapolis 111 | 112 | puts record.postal.code # 55455 113 | 114 | puts record.location.accuracy_radius # 50 115 | puts record.location.latitude # 44.9733 116 | puts record.location.longitude # -93.2323 117 | 118 | puts record.traits.network # 128.101.101.101/32 119 | ``` 120 | 121 | ### Anonymous IP Example 122 | 123 | ```ruby 124 | require 'maxmind/geoip2' 125 | 126 | # This creates the Reader object which should be reused across lookups. 127 | reader = MaxMind::GeoIP2::Reader.new( 128 | database: '/usr/share/GeoIP/GeoIP2-Anonymous-IP.mmdb', 129 | ) 130 | 131 | record = reader.anonymous_ip('128.101.101.101') 132 | 133 | puts "Anonymous" if record.is_anonymous 134 | ``` 135 | 136 | ### Anonymous Plus Example 137 | 138 | ```ruby 139 | require 'maxmind/geoip2' 140 | 141 | # This creates the Reader object which should be reused across lookups. 142 | reader = MaxMind::GeoIP2::Reader.new( 143 | database: '/usr/share/GeoIP/GeoIP-Anonymous-Plus.mmdb', 144 | ) 145 | 146 | record = reader.anonymous_plus('128.101.101.101') 147 | 148 | puts record.anonymizer_confidence # 30 149 | ``` 150 | 151 | ### ASN Example 152 | 153 | ```ruby 154 | require 'maxmind/geoip2' 155 | 156 | # This creates the Reader object which should be reused across lookups. 157 | reader = MaxMind::GeoIP2::Reader.new( 158 | database: '/usr/share/GeoIP/GeoLite2-ASN.mmdb', 159 | ) 160 | 161 | record = reader.asn('128.101.101.101') 162 | 163 | puts record.autonomous_system_number # 1234 164 | puts record.autonomous_system_organization # Example Ltd 165 | ``` 166 | 167 | ### Connection Type Example 168 | 169 | ```ruby 170 | require 'maxmind/geoip2' 171 | 172 | # This creates the Reader object which should be reused across lookups. 173 | reader = MaxMind::GeoIP2::Reader.new( 174 | database: '/usr/share/GeoIP/GeoIP2-Connection-Type.mmdb', 175 | ) 176 | 177 | record = reader.connection_type('128.101.101.101') 178 | 179 | puts record.connection_type # Cable/DSL 180 | ``` 181 | 182 | ### Domain Example 183 | 184 | ```ruby 185 | require 'maxmind/geoip2' 186 | 187 | # This creates the Reader object which should be reused across lookups. 188 | reader = MaxMind::GeoIP2::Reader.new( 189 | database: '/usr/share/GeoIP/GeoIP2-Domain.mmdb', 190 | ) 191 | 192 | record = reader.domain('128.101.101.101') 193 | 194 | puts record.domain # example.com 195 | ``` 196 | 197 | ### ISP Example 198 | 199 | ```ruby 200 | require 'maxmind/geoip2' 201 | 202 | # This creates the Reader object which should be reused across lookups. 203 | reader = MaxMind::GeoIP2::Reader.new( 204 | database: '/usr/share/GeoIP/GeoIP2-ISP.mmdb', 205 | ) 206 | 207 | record = reader.isp('128.101.101.101') 208 | 209 | puts record.autonomous_system_number # 217 210 | puts record.autonomous_system_organization # University of Minnesota 211 | puts record.isp # University of Minnesota 212 | puts record.organization # University of Minnesota 213 | ``` 214 | 215 | ## Web Service Client 216 | 217 | ### Usage 218 | 219 | To use this API, you must create a new `MaxMind::GeoIP2::Client` object 220 | with your account ID and license key. To use the GeoLite2 web service, you 221 | may also set the `host` parameter to `geolite.info`. You may then you call 222 | the method corresponding to a specific end point, passing it the IP address 223 | you want to look up. 224 | 225 | If the request succeeds, the method call will return a model class for the end 226 | point you called. This model in turn contains multiple record classes, each of 227 | which represents part of the data returned by the web service. 228 | 229 | If there is an error, a structured exception is thrown. 230 | 231 | See the [API documentation](https://www.rubydoc.info/gems/maxmind-geoip2) 232 | for more details. 233 | 234 | ### Example 235 | 236 | ```ruby 237 | require 'maxmind/geoip2' 238 | 239 | # This creates a Client object that can be reused across requests. 240 | # Replace "42" with your account ID and "license_key" with your license 241 | # key. 242 | client = MaxMind::GeoIP2::Client.new( 243 | account_id: 42, 244 | license_key: 'license_key', 245 | 246 | # To use the GeoLite2 web service instead of the GeoIP2 web service, set 247 | # the host parameter to "geolite.info": 248 | # host: 'geolite.info', 249 | 250 | # To use the Sandbox GeoIP2 web service instead of the production GeoIP2 251 | # web service, set the host parameter to "sandbox.maxmind.com": 252 | # host: 'sandbox.maxmind.com', 253 | ) 254 | 255 | # Replace "city" with the method corresponding to the web service that 256 | # you are using, e.g., "country", "insights". Please note that Insights 257 | # is only supported by the GeoIP2 web service and not the GeoLite2 web 258 | # service. 259 | record = client.city('128.101.101.101') 260 | 261 | puts record.country.iso_code # US 262 | puts record.country.name # United States 263 | puts record.country.names['zh-CN'] # 美国 264 | 265 | puts record.most_specific_subdivision.name # Minnesota 266 | puts record.most_specific_subdivision.iso_code # MN 267 | 268 | puts record.city.name # Minneapolis 269 | 270 | puts record.postal.code # 55455 271 | 272 | puts record.location.latitude # 44.9733 273 | puts record.location.longitude # -93.2323 274 | 275 | puts record.traits.network # 128.101.101.101/32 276 | ``` 277 | 278 | ## Values to use for Database or Array Keys 279 | 280 | **We strongly discourage you from using a value from any `names` property 281 | as a key in a database or array.** 282 | 283 | These names may change between releases. Instead we recommend using one of 284 | the following: 285 | 286 | * `MaxMind::GeoIP2::Record::City` - `city.geoname_id` 287 | * `MaxMind::GeoIP2::Record::Continent` - `continent.code` or 288 | `continent.geoname_id` 289 | * `MaxMind::GeoIP2::Record::Country` and 290 | `MaxMind::GeoIP2::Record::RepresentedCountry` - `country.iso_code` or 291 | `country.geoname_id` 292 | * `MaxMind::GeoIP2::Record::Subdivision` - `subdivision.iso_code` or 293 | `subdivision.geoname_id` 294 | 295 | ### What data is returned? 296 | 297 | While many of the end points return the same basic records, the attributes 298 | which can be populated vary between end points. In addition, while an end 299 | point may offer a particular piece of data, MaxMind does not always have 300 | every piece of data for any given IP address. 301 | 302 | See the [GeoIP2 web service 303 | documentation](https://dev.maxmind.com/geoip/docs/web-services?lang=en) for details on 304 | what data each end point may return. 305 | 306 | The only piece of data which is always returned is the `ip_address` 307 | attribute in the `MaxMind::GeoIP2::Record::Traits` record. 308 | 309 | ## Integration with GeoNames 310 | 311 | [GeoNames](https://www.geonames.org/) offers web services and downloadable 312 | databases with data on geographical features around the world, including 313 | populated places. They offer both free and paid premium data. Each feature 314 | is unique identified by a `geoname_id`, which is an integer. 315 | 316 | Many of the records returned by the GeoIP2 web services and databases 317 | include a `geoname_id` property. This is the ID of a geographical feature 318 | (city, region, country, etc.) in the GeoNames database. 319 | 320 | Some of the data that MaxMind provides is also sourced from GeoNames. We 321 | source things like place names, ISO codes, and other similar data from the 322 | GeoNames premium data set. 323 | 324 | ## Reporting data problems 325 | 326 | If the problem you find is that an IP address is incorrectly mapped, please 327 | [submit your correction to MaxMind](https://www.maxmind.com/en/correction). 328 | 329 | If you find some other sort of mistake, like an incorrect spelling, please 330 | check the [GeoNames site](https://www.geonames.org/) first. Once you've 331 | searched for a place and found it on the GeoNames map view, there are a 332 | number of links you can use to correct data ("move", "edit", "alternate 333 | names", etc.). Once the correction is part of the GeoNames data set, it 334 | will be automatically incorporated into future MaxMind releases. 335 | 336 | If you are a paying MaxMind customer and you're not sure where to submit a 337 | correction, please [contact MaxMind 338 | support](https://www.maxmind.com/en/support) for help. 339 | 340 | ## Support 341 | 342 | Please report all issues with this code using the [GitHub issue 343 | tracker](https://github.com/maxmind/GeoIP2-ruby/issues). 344 | 345 | If you are having an issue with a MaxMind service that is not specific to the 346 | client API, please see [our support page](https://www.maxmind.com/en/support). 347 | 348 | ## Requirements 349 | 350 | This code requires Ruby version 3.2 or higher. 351 | 352 | ## Contributing 353 | 354 | Patches and pull requests are encouraged. Please include unit tests 355 | whenever possible. 356 | 357 | ## Versioning 358 | 359 | This library uses [Semantic Versioning](https://semver.org/). 360 | 361 | ## Copyright and License 362 | 363 | This software is Copyright (c) 2020-2025 by MaxMind, Inc. 364 | 365 | This is free software, licensed under the [Apache License, Version 366 | 2.0](LICENSE-APACHE) or the [MIT License](LICENSE-MIT), at your option. 367 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/reader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'maxmind/db' 4 | require 'maxmind/geoip2/errors' 5 | require 'maxmind/geoip2/model/anonymous_ip' 6 | require 'maxmind/geoip2/model/anonymous_plus' 7 | require 'maxmind/geoip2/model/asn' 8 | require 'maxmind/geoip2/model/city' 9 | require 'maxmind/geoip2/model/connection_type' 10 | require 'maxmind/geoip2/model/country' 11 | require 'maxmind/geoip2/model/domain' 12 | require 'maxmind/geoip2/model/enterprise' 13 | require 'maxmind/geoip2/model/isp' 14 | 15 | module MaxMind 16 | module GeoIP2 17 | # Reader is a reader for the GeoIP2/GeoLite2 database format. IP addresses 18 | # can be looked up using the database specific methods. 19 | # 20 | # == Example 21 | # 22 | # require 'maxmind/geoip2' 23 | # 24 | # reader = MaxMind::GeoIP2::Reader.new(database: 'GeoIP2-Country.mmdb') 25 | # 26 | # record = reader.country('1.2.3.4') 27 | # puts record.country.iso_code 28 | # 29 | # reader.close 30 | class Reader 31 | # rubocop:disable Metrics/CyclomaticComplexity 32 | # rubocop:disable Metrics/PerceivedComplexity 33 | 34 | # Create a Reader for looking up IP addresses in a GeoIP2/GeoLite2 database 35 | # file. 36 | # 37 | # If you're performing multiple lookups, it's most efficient to create one 38 | # Reader and reuse it. 39 | # 40 | # Once created, the Reader is safe to use for lookups from multiple 41 | # threads. It is safe to use after forking. 42 | # 43 | # @overload initialize(database:, locales: ['en'], mode: MaxMind::DB::MODE_AUTO) 44 | # @param database [String] a path to a GeoIP2/GeoLite2 database file. 45 | # @param locales [Array] a list of locale codes to use in the name 46 | # property from most preferred to least preferred. 47 | # @param mode [Symbol] Defines how to open the database. It may be one of 48 | # MaxMind::DB::MODE_AUTO, MaxMind::DB::MODE_FILE, or 49 | # MaxMind::DB::MODE_MEMORY. If you don't provide one, the Reader uses 50 | # MaxMind::DB::MODE_AUTO. Refer to the definition of those constants in 51 | # MaxMind::DB for an explanation of their meaning. 52 | # 53 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database is corrupt 54 | # or invalid. 55 | # 56 | # @raise [ArgumentError] if the mode is invalid. 57 | def initialize(*args) 58 | # This if statement is to let us support calling as though we are using 59 | # Ruby 2.0 keyword arguments. We can't use keyword argument syntax as 60 | # we want to be backwards compatible with the old way we accepted 61 | # parameters, which looked like: 62 | # def initialize(database, locales = ['en'], options = {}) 63 | if args.length == 1 && args[0].instance_of?(Hash) 64 | database = args[0][:database] 65 | locales = args[0][:locales] 66 | mode = args[0][:mode] 67 | else 68 | database = args[0] 69 | locales = args[1] 70 | mode = args[2].instance_of?(Hash) ? args[2][:mode] : nil 71 | end 72 | 73 | if !database.instance_of?(String) 74 | raise ArgumentError, 'Invalid database parameter' 75 | end 76 | 77 | locales = ['en'] if locales.nil? || locales.empty? 78 | 79 | options = {} 80 | options[:mode] = mode if !mode.nil? 81 | @reader = MaxMind::DB.new(database, options) 82 | 83 | @type = @reader.metadata.database_type 84 | 85 | @locales = locales 86 | end 87 | # rubocop:enable Metrics/CyclomaticComplexity 88 | # rubocop:enable Metrics/PerceivedComplexity 89 | 90 | # Look up the IP address in the Anonymous IP database. 91 | # 92 | # @param ip_address [String] a string in the standard notation. It may be 93 | # IPv4 or IPv6. 94 | # 95 | # @return [MaxMind::GeoIP2::Model::AnonymousIP] 96 | # 97 | # @raise [ArgumentError] if used against a non-Anonymous IP database or if 98 | # you attempt to look up an IPv6 address in an IPv4 only database. 99 | # 100 | # @raise [AddressNotFoundError] if the IP address is not found in the 101 | # database. 102 | # 103 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database appears 104 | # corrupt. 105 | def anonymous_ip(ip_address) 106 | flat_model_for( 107 | Model::AnonymousIP, 108 | 'anonymous_ip', 109 | 'GeoIP2-Anonymous-IP', 110 | ip_address, 111 | ) 112 | end 113 | 114 | # Look up the IP address in the Anonymous Plus database. 115 | # 116 | # @param ip_address [String] a string in the standard notation. It may be 117 | # IPv4 or IPv6. 118 | # 119 | # @return [MaxMind::GeoIP2::Model::AnonymousPlus] 120 | # 121 | # @raise [ArgumentError] if used against a non-Anonymous Plus database 122 | # or if you attempt to look up an IPv6 address in an IPv4 only database. 123 | # 124 | # @raise [AddressNotFoundError] if the IP address is not found in the 125 | # database. 126 | # 127 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database appears 128 | # corrupt. 129 | def anonymous_plus(ip_address) 130 | flat_model_for( 131 | Model::AnonymousPlus, 132 | 'anonymous_plus', 133 | 'GeoIP-Anonymous-Plus', 134 | ip_address, 135 | ) 136 | end 137 | 138 | # Look up the IP address in an ASN database. 139 | # 140 | # @param ip_address [String] a string in the standard notation. It may be 141 | # IPv4 or IPv6. 142 | # 143 | # @return [MaxMind::GeoIP2::Model::ASN] 144 | # 145 | # @raise [ArgumentError] if used against a non-ASN database or if you 146 | # attempt to look up an IPv6 address in an IPv4 only database. 147 | # 148 | # @raise [AddressNotFoundError] if the IP address is not found in the 149 | # database. 150 | # 151 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database appears 152 | # corrupt. 153 | def asn(ip_address) 154 | flat_model_for(Model::ASN, 'asn', 'GeoLite2-ASN', ip_address) 155 | end 156 | 157 | # Look up the IP address in a City database. 158 | # 159 | # @param ip_address [String] a string in the standard notation. It may be 160 | # IPv4 or IPv6. 161 | # 162 | # @return [MaxMind::GeoIP2::Model::City] 163 | # 164 | # @raise [ArgumentError] if used against a non-City database or if you 165 | # attempt to look up an IPv6 address in an IPv4 only database. 166 | # 167 | # @raise [AddressNotFoundError] if the IP address is not found in the 168 | # database. 169 | # 170 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database appears 171 | # corrupt. 172 | def city(ip_address) 173 | model_for(Model::City, 'city', 'City', ip_address) 174 | end 175 | 176 | # Look up the IP address in a Connection Type database. 177 | # 178 | # @param ip_address [String] a string in the standard notation. It may be 179 | # IPv4 or IPv6. 180 | # 181 | # @return [MaxMind::GeoIP2::Model::ConnectionType] 182 | # 183 | # @raise [ArgumentError] if used against a non-Connection Type database or if 184 | # you attempt to look up an IPv6 address in an IPv4 only database. 185 | # 186 | # @raise [AddressNotFoundError] if the IP address is not found in the 187 | # database. 188 | # 189 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database appears 190 | # corrupt. 191 | def connection_type(ip_address) 192 | flat_model_for( 193 | Model::ConnectionType, 194 | 'connection_type', 195 | 'GeoIP2-Connection-Type', 196 | ip_address, 197 | ) 198 | end 199 | 200 | # Look up the IP address in a Country database. 201 | # 202 | # @param ip_address [String] a string in the standard notation. It may be 203 | # IPv4 or IPv6. 204 | # 205 | # @return [MaxMind::GeoIP2::Model::Country] 206 | # 207 | # @raise [ArgumentError] if used against a non-Country database or if you 208 | # attempt to look up an IPv6 address in an IPv4 only database. 209 | # 210 | # @raise [AddressNotFoundError] if the IP address is not found in the 211 | # database. 212 | # 213 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database appears 214 | # corrupt. 215 | def country(ip_address) 216 | model_for(Model::Country, 'country', 'Country', ip_address) 217 | end 218 | 219 | # Look up the IP address in a Domain database. 220 | # 221 | # @param ip_address [String] a string in the standard notation. It may be 222 | # IPv4 or IPv6. 223 | # 224 | # @return [MaxMind::GeoIP2::Model::Domain] 225 | # 226 | # @raise [ArgumentError] if used against a non-Domain database or if you 227 | # attempt to look up an IPv6 address in an IPv4 only database. 228 | # 229 | # @raise [AddressNotFoundError] if the IP address is not found in the 230 | # database. 231 | # 232 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database appears 233 | # corrupt. 234 | def domain(ip_address) 235 | flat_model_for(Model::Domain, 'domain', 'GeoIP2-Domain', ip_address) 236 | end 237 | 238 | # Look up the IP address in an Enterprise database. 239 | # 240 | # @param ip_address [String] a string in the standard notation. It may be 241 | # IPv4 or IPv6. 242 | # 243 | # @return [MaxMind::GeoIP2::Model::Enterprise] 244 | # 245 | # @raise [ArgumentError] if used against a non-Enterprise database or if 246 | # you attempt to look up an IPv6 address in an IPv4 only database. 247 | # 248 | # @raise [AddressNotFoundError] if the IP address is not found in the 249 | # database. 250 | # 251 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database appears 252 | # corrupt. 253 | def enterprise(ip_address) 254 | model_for(Model::Enterprise, 'enterprise', 'Enterprise', ip_address) 255 | end 256 | 257 | # Look up the IP address in an ISP database. 258 | # 259 | # @param ip_address [String] a string in the standard notation. It may be 260 | # IPv4 or IPv6. 261 | # 262 | # @return [MaxMind::GeoIP2::Model::ISP] 263 | # 264 | # @raise [ArgumentError] if used against a non-ISP database or if you 265 | # attempt to look up an IPv6 address in an IPv4 only database. 266 | # 267 | # @raise [AddressNotFoundError] if the IP address is not found in the 268 | # database. 269 | # 270 | # @raise [MaxMind::DB::InvalidDatabaseError] if the database appears 271 | # corrupt. 272 | def isp(ip_address) 273 | flat_model_for(Model::ISP, 'isp', 'GeoIP2-ISP', ip_address) 274 | end 275 | 276 | # Return the metadata associated with the database. 277 | # 278 | # @return [MaxMind::DB::Metadata] 279 | def metadata 280 | @reader.metadata 281 | end 282 | 283 | # Close the Reader and return resources to the system. 284 | # 285 | # @return [void] 286 | def close 287 | @reader.close 288 | end 289 | 290 | private 291 | 292 | def model_for(model_class, method, type, ip_address) 293 | record, prefix_length = get_record(method, type, ip_address) 294 | 295 | record['traits'] = {} if !record.key?('traits') 296 | record['traits']['ip_address'] = ip_address 297 | record['traits']['prefix_length'] = prefix_length 298 | 299 | model_class.new(record, @locales) 300 | end 301 | 302 | def get_record(method, type, ip_address) 303 | if !@type.include?(type) 304 | raise ArgumentError, 305 | "The #{method} method cannot be used with the #{@type} database." 306 | end 307 | 308 | record, prefix_length = @reader.get_with_prefix_length(ip_address) 309 | 310 | if record.nil? 311 | raise AddressNotFoundError, 312 | "The address #{ip_address} is not in the database." 313 | end 314 | 315 | [record, prefix_length] 316 | end 317 | 318 | def flat_model_for(model_class, method, type, ip_address) 319 | record, prefix_length = get_record(method, type, ip_address) 320 | 321 | record['ip_address'] = ip_address 322 | record['prefix_length'] = prefix_length 323 | 324 | model_class.new(record) 325 | end 326 | end 327 | end 328 | end 329 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | **GeoIP2-ruby** is MaxMind's official Ruby client library for: 8 | - **GeoIP2/GeoLite2 Web Services**: Country, City, and Insights endpoints 9 | - **GeoIP2/GeoLite2 Databases**: Local MMDB file reading for various database types (City, Country, ASN, Anonymous IP, Anonymous Plus, ISP, etc.) 10 | 11 | The library provides both web service clients and database readers that return strongly-typed model objects containing geographic, ISP, anonymizer, and other IP-related data. 12 | 13 | **Key Technologies:** 14 | - Ruby 3.2+ (uses frozen string literals and modern Ruby features) 15 | - MaxMind DB Reader for binary database files 16 | - HTTP gem for web service client functionality 17 | - Minitest for testing 18 | - RuboCop with multiple plugins for code quality 19 | 20 | ## Code Architecture 21 | 22 | ### Package Structure 23 | 24 | ``` 25 | lib/maxmind/geoip2/ 26 | ├── model/ # Response models (City, Insights, AnonymousIP, etc.) 27 | ├── record/ # Data records (City, Location, Traits, etc.) 28 | ├── client.rb # HTTP client for MaxMind web services 29 | ├── reader.rb # Local MMDB file reader 30 | ├── errors.rb # Custom exceptions for error handling 31 | └── version.rb # Version constant 32 | ``` 33 | 34 | ### Key Design Patterns 35 | 36 | #### 1. **Attr Reader Pattern for Immutable Data** 37 | 38 | Models expose data through `attr_reader` attributes that are initialized in the constructor. Unlike PHP's readonly properties, Ruby uses instance variables with attr_reader: 39 | 40 | ```ruby 41 | class City < Country 42 | attr_reader :city 43 | attr_reader :location 44 | attr_reader :postal 45 | attr_reader :subdivisions 46 | 47 | def initialize(record, locales) 48 | super 49 | @city = MaxMind::GeoIP2::Record::City.new(record['city'], locales) 50 | @location = MaxMind::GeoIP2::Record::Location.new(record['location']) 51 | @postal = MaxMind::GeoIP2::Record::Postal.new(record['postal']) 52 | @subdivisions = create_subdivisions(record['subdivisions'], locales) 53 | end 54 | end 55 | ``` 56 | 57 | **Key Points:** 58 | - Instance variables are set in the constructor 59 | - Use `attr_reader` to expose them 60 | - Models and records are initialized from hash data (from JSON/DB) 61 | - Records are composed objects (City contains City record, Location record, etc.) 62 | 63 | #### 2. **Inheritance Hierarchies** 64 | 65 | Models follow clear inheritance patterns: 66 | - `Country` → base model with country/continent data 67 | - `City` extends `Country` → adds city, location, postal, subdivisions 68 | - `Insights` extends `City` → adds additional web service fields (web service only) 69 | - `Enterprise` extends `City` → adds enterprise-specific fields 70 | 71 | Records have similar patterns: 72 | - `Abstract` → base with `get` method for accessing hash data 73 | - `Place` extends `Abstract` → adds names/locales handling 74 | - Specific records (`City`, `Country`, etc.) extend `Place` or `Abstract` 75 | 76 | #### 3. **Get Method Pattern for Data Access** 77 | 78 | Both models and records use a protected `get` method to safely access hash data: 79 | 80 | ```ruby 81 | def get(key) 82 | if @record.nil? || !@record.key?(key) 83 | return false if key.start_with?('is_') 84 | return nil 85 | end 86 | 87 | @record[key] 88 | end 89 | ``` 90 | 91 | - Returns `false` for missing boolean fields (starting with `is_`) 92 | - Returns `nil` for missing optional fields 93 | - Records store the raw hash in `@record` instance variable 94 | 95 | Public methods expose data through the `get` method: 96 | 97 | ```ruby 98 | def anonymizer_confidence 99 | get('anonymizer_confidence') 100 | end 101 | 102 | def provider_name 103 | get('provider_name') 104 | end 105 | ``` 106 | 107 | #### 4. **Lazy Parsing for Special Types** 108 | 109 | Some fields require parsing and are computed lazily: 110 | 111 | ```ruby 112 | def network_last_seen 113 | return @network_last_seen if defined?(@network_last_seen) 114 | 115 | date_string = get('network_last_seen') 116 | 117 | if !date_string 118 | return nil 119 | end 120 | 121 | @network_last_seen = Date.parse(date_string) 122 | end 123 | ``` 124 | 125 | - Use `defined?(@variable)` to check if already parsed 126 | - Parse only once and cache in instance variable 127 | - Handle nil cases before parsing 128 | 129 | #### 5. **Web Service Only vs Database Models** 130 | 131 | Some models are only used by web services and do **not** need MaxMind DB support: 132 | 133 | **Web Service Only Models**: 134 | - Models that are exclusive to web service responses 135 | - Simpler implementation, just inherit and define in model hierarchy 136 | - Example: `Insights` (extends City but used only for web service) 137 | 138 | **Database-Supported Models**: 139 | - Models used by both web services and database files 140 | - Reader has specific methods (e.g., `anonymous_ip`, `anonymous_plus`, `city`) 141 | - Must handle MaxMind DB format data structures 142 | - Example: `City`, `Country`, `AnonymousIP`, `AnonymousPlus` 143 | 144 | ## Testing Conventions 145 | 146 | ### Running Tests 147 | 148 | ```bash 149 | # Install dependencies 150 | bundle install 151 | 152 | # Run all tests 153 | bundle exec rake test 154 | 155 | # Run tests and RuboCop 156 | bundle exec rake # default task 157 | 158 | # Run RuboCop only 159 | bundle exec rake rubocop 160 | 161 | # Run specific test file 162 | ruby -Ilib:test test/test_reader.rb 163 | ``` 164 | 165 | ### Test Structure 166 | 167 | Tests are organized by functionality: 168 | - `test/test_reader.rb` - Database reader tests 169 | - `test/test_client.rb` - Web service client tests 170 | - `test/test_model_*.rb` - Model-specific tests 171 | - `test/data/` - Test fixtures and sample database files 172 | 173 | ### Test Patterns 174 | 175 | Tests use Minitest with a constant for test data: 176 | 177 | ```ruby 178 | class CountryModelTest < Minitest::Test 179 | RAW = { 180 | 'continent' => { 181 | 'code' => 'NA', 182 | 'geoname_id' => 42, 183 | 'names' => { 'en' => 'North America' }, 184 | }, 185 | 'country' => { 186 | 'geoname_id' => 1, 187 | 'iso_code' => 'US', 188 | 'names' => { 'en' => 'United States of America' }, 189 | }, 190 | 'traits' => { 191 | 'ip_address' => '1.2.3.4', 192 | 'prefix_length' => 24, 193 | }, 194 | }.freeze 195 | 196 | def test_values 197 | model = MaxMind::GeoIP2::Model::Country.new(RAW, ['en']) 198 | 199 | assert_equal(42, model.continent.geoname_id) 200 | assert_equal('NA', model.continent.code) 201 | assert_equal('United States of America', model.country.name) 202 | end 203 | end 204 | ``` 205 | 206 | When adding new fields to models: 207 | 1. Update the `RAW` constant to include the new field 208 | 2. Add assertions to verify the field is properly populated 209 | 3. Test both presence and absence of the field (nil handling) 210 | 4. Test with different values if applicable 211 | 212 | ## Working with This Codebase 213 | 214 | ### Adding New Fields to Existing Models 215 | 216 | For database models (like AnonymousPlus): 217 | 218 | 1. **Add a public method** that calls `get`: 219 | ```ruby 220 | # A description of the field. 221 | # 222 | # @return [Type, nil] 223 | def field_name 224 | get('field_name') 225 | end 226 | ``` 227 | 228 | 2. **For fields requiring parsing** (dates, complex types), use lazy loading: 229 | ```ruby 230 | def network_last_seen 231 | return @network_last_seen if defined?(@network_last_seen) 232 | 233 | date_string = get('network_last_seen') 234 | 235 | if !date_string 236 | return nil 237 | end 238 | 239 | @network_last_seen = Date.parse(date_string) 240 | end 241 | ``` 242 | 243 | For composed models (like City, Country): 244 | 245 | 1. **Add `attr_reader`** for the new record/field: 246 | ```ruby 247 | attr_reader :new_field 248 | ``` 249 | 250 | 2. **Initialize in constructor**: 251 | ```ruby 252 | def initialize(record, locales) 253 | super 254 | @new_field = record['new_field'] 255 | end 256 | ``` 257 | 258 | 3. **Provide comprehensive YARD documentation** (`@return` tags) 259 | 4. **Update tests** to include the new field in test data and assertions 260 | 5. **Update CHANGELOG.md** with the change 261 | 262 | ### Adding New Models 263 | 264 | When creating a new model class: 265 | 266 | 1. **Determine if web service only or database-supported** 267 | 2. **Follow the pattern** from existing similar models 268 | 3. **Extend the appropriate base class** (e.g., `Country`, `City`, or standalone) 269 | 4. **Use `attr_reader`** for composed record objects 270 | 5. **Provide comprehensive YARD documentation** for all public methods 271 | 6. **Add corresponding tests** with full coverage 272 | 7. **If database-supported**, add a method to `Reader` class 273 | 274 | ### Deprecation Guidelines 275 | 276 | When deprecating fields: 277 | 278 | 1. **Use `@deprecated` in YARD doc** with version and alternative: 279 | ```ruby 280 | # This field is deprecated as of version 2.0.0. 281 | # Use the anonymizer object from the Insights response instead. 282 | # 283 | # @return [Boolean] 284 | # @deprecated since 2.0.0 285 | def is_anonymous 286 | get('is_anonymous') 287 | end 288 | ``` 289 | 290 | 2. **Keep deprecated fields functional** - don't break existing code 291 | 3. **Update CHANGELOG.md** with deprecation notices 292 | 4. **Document alternatives** in the deprecation message 293 | 294 | ### CHANGELOG.md Format 295 | 296 | Always update `CHANGELOG.md` for user-facing changes. 297 | 298 | **Important**: Do not add a date to changelog entries until release time. 299 | 300 | - If there's an existing version entry without a date (e.g., `1.5.0`), add your changes there 301 | - If creating a new version entry, don't include a date - it will be added at release time 302 | - Use past tense for descriptions 303 | 304 | ```markdown 305 | ## 1.5.0 306 | 307 | * A new `field_name` method has been added to `MaxMind::GeoIP2::Model::ModelName`. 308 | This method provides information about... 309 | * The `old_field` method in `MaxMind::GeoIP2::Model::ModelName` has been deprecated. 310 | Please use `new_field` instead. 311 | ``` 312 | 313 | ## Common Pitfalls and Solutions 314 | 315 | ### Problem: Incorrect Nil Handling 316 | 317 | Using the wrong nil check can cause unexpected behavior. 318 | 319 | **Solution**: Follow these patterns: 320 | - Use `if !variable` or `if variable.nil?` to check for nil 321 | - The `get` method returns `nil` for missing keys (except `is_*` keys which return `false`) 322 | - Use `defined?(@variable)` to check if an instance variable has been set (for lazy loading) 323 | 324 | ### Problem: Missing YARD Documentation 325 | 326 | New methods without documentation make the API harder to use. 327 | 328 | **Solution**: Always add YARD documentation: 329 | - Use `@return [Type, nil]` for the return type 330 | - Add a description of what the method returns 331 | - Use `@deprecated since X.Y.Z` for deprecated methods 332 | - Include examples in model class documentation if helpful 333 | 334 | ### Problem: Test Failures After Adding Fields 335 | 336 | Tests fail because fixtures don't include new fields. 337 | 338 | **Solution**: Update all related tests: 339 | 1. Add field to test `RAW` constant or test data hash 340 | 2. Add assertions for the new field 341 | 3. Test nil case if field is optional 342 | 4. Test different data types if applicable 343 | 344 | ## Code Style Requirements 345 | 346 | - **RuboCop enforced** with multiple plugins (minitest, performance, rake, thread_safety) 347 | - **Frozen string literals** (`# frozen_string_literal: true`) in all files 348 | - **Target Ruby 3.2+** 349 | - **No metrics cops** - AbcSize, ClassLength, MethodLength disabled 350 | - **Trailing commas allowed** in arrays, hashes, and arguments 351 | - **Use `if !condition`** instead of `unless condition` (NegatedIf disabled) 352 | 353 | Key RuboCop configurations: 354 | - Line length not enforced 355 | - Format string token checks disabled 356 | - Numeric predicates allowed in any style 357 | - Multiple assertions allowed in tests 358 | 359 | ## Development Workflow 360 | 361 | ### Setup 362 | ```bash 363 | bundle install 364 | ``` 365 | 366 | ### Before Committing 367 | ```bash 368 | # Run tests and linting 369 | bundle exec rake 370 | 371 | # Or run separately 372 | bundle exec rake test 373 | bundle exec rake rubocop 374 | ``` 375 | 376 | ### Running Single Test 377 | ```bash 378 | ruby -Ilib:test test/test_reader.rb 379 | ``` 380 | 381 | ### Version Requirements 382 | - **Ruby 3.2+** required 383 | - Target compatibility should match current supported Ruby versions (3.2-3.4) 384 | 385 | ## Additional Resources 386 | 387 | - [API Documentation](https://www.rubydoc.info/gems/maxmind-geoip2) 388 | - [GeoIP2 Web Services Docs](https://dev.maxmind.com/geoip/docs/web-services) 389 | - [MaxMind DB Format](https://maxmind.github.io/MaxMind-DB/) 390 | - GitHub Issues: https://github.com/maxmind/GeoIP2-ruby/issues 391 | -------------------------------------------------------------------------------- /lib/maxmind/geoip2/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'connection_pool' 4 | require 'http' 5 | require 'json' 6 | require 'maxmind/geoip2/errors' 7 | require 'maxmind/geoip2/version' 8 | require 'maxmind/geoip2/model/city' 9 | require 'maxmind/geoip2/model/country' 10 | require 'maxmind/geoip2/model/insights' 11 | require 'resolv' 12 | 13 | module MaxMind 14 | module GeoIP2 15 | # This class provides a client API for all the 16 | # {https://dev.maxmind.com/geoip/docs/web-services?lang=en GeoIP2 web 17 | # services}. The services are Country, City Plus, and Insights. Each service 18 | # returns a different set of data about an IP address, with Country returning 19 | # the least data and Insights the most. 20 | # 21 | # Each web service is represented by a different model class, and these model 22 | # classes in turn contain multiple record classes. The record classes have 23 | # attributes which contain data about the IP address. 24 | # 25 | # If the web service does not return a particular piece of data for an IP 26 | # address, the associated attribute is not populated. 27 | # 28 | # The web service may not return any information for an entire record, in 29 | # which case all of the attributes for that record class will be empty. 30 | # 31 | # == Usage 32 | # 33 | # The basic API for this class is the same for all of the web service end 34 | # points. First you create a web service client object with your MaxMind 35 | # account ID and license key, then you call the method corresponding to a 36 | # specific end point, passing it the IP address you want to look up. 37 | # 38 | # If the request succeeds, the method call will return a model class for the 39 | # service you called. This model in turn contains multiple record classes, 40 | # each of which represents part of the data returned by the web service. 41 | # 42 | # If the request fails, the client class throws an exception. 43 | # 44 | # == Example 45 | # 46 | # require 'maxmind/geoip2' 47 | # 48 | # client = MaxMind::GeoIP2::Client.new( 49 | # account_id: 42, 50 | # license_key: 'abcdef123456', 51 | # ) 52 | # 53 | # # Replace 'city' with the method corresponding to the web service you 54 | # # are using, e.g., 'country', 'insights'. 55 | # record = client.city('128.101.101.101') 56 | # 57 | # puts record.country.iso_code 58 | class Client 59 | # rubocop:disable Metrics/ParameterLists 60 | # rubocop:disable Metrics/CyclomaticComplexity 61 | # rubocop:disable Metrics/PerceivedComplexity 62 | 63 | # Create a Client that may be used to query a GeoIP2 web service. 64 | # 65 | # Once created, the Client is safe to use for lookups from multiple 66 | # threads. 67 | # 68 | # @param account_id [Integer] your MaxMind account ID. 69 | # 70 | # @param license_key [String] your MaxMind license key. 71 | # 72 | # @param locales [Array] a list of locale codes to use in the name 73 | # property from most preferred to least preferred. 74 | # 75 | # @param host [String] the host to use when querying the web service. Set 76 | # this to "geolite.info" to use the GeoLite2 web service instead of the 77 | # GeoIP2 web service. Set this to "sandbox.maxmind.com" to use the 78 | # Sandbox environment. The sandbox allows you to experiment with the 79 | # API without affecting your production data. 80 | # 81 | # @param timeout [Integer] the number of seconds to wait for a request 82 | # before timing out. If 0, no timeout is set. 83 | # 84 | # @param proxy_address [String] proxy address to use, if any. 85 | # 86 | # @param proxy_port [Integer] proxy port to use, if any. 87 | # 88 | # @param proxy_username [String] proxy username to use, if any. 89 | # 90 | # @param proxy_password [String] proxy password to use, if any. 91 | # 92 | # @param pool_size [Integer] HTTP connection pool size 93 | def initialize( 94 | account_id:, 95 | license_key:, 96 | locales: ['en'], 97 | host: 'geoip.maxmind.com', 98 | timeout: 0, 99 | proxy_address: '', 100 | proxy_port: 0, 101 | proxy_username: '', 102 | proxy_password: '', 103 | pool_size: 5 104 | ) 105 | @account_id = account_id 106 | @license_key = license_key 107 | @locales = locales || ['en'] 108 | @host = host || 'geoip.maxmind.com' 109 | @timeout = timeout || 0 110 | @proxy_address = proxy_address || '' 111 | @proxy_port = proxy_port || 0 112 | @proxy_username = proxy_username || '' 113 | @proxy_password = proxy_password || '' 114 | @pool_size = pool_size || 5 115 | 116 | @connection_pool = ConnectionPool.new(size: @pool_size) do 117 | make_http_client.persistent("https://#{@host}") 118 | end 119 | end 120 | # rubocop:enable Metrics/PerceivedComplexity 121 | # rubocop:enable Metrics/CyclomaticComplexity 122 | # rubocop:enable Metrics/ParameterLists 123 | 124 | # This method calls the City Plus web service. 125 | # 126 | # @param ip_address [String] IPv4 or IPv6 address as a string. If no 127 | # address is provided, the address that the web service is called from is 128 | # used. 129 | # 130 | # @raise [HTTP::Error] if there was an error performing the HTTP request, 131 | # such as an error connecting. 132 | # 133 | # @raise [JSON::ParserError] if there was invalid JSON in the response. 134 | # 135 | # @raise [HTTPError] if there was a problem with the HTTP response, such as 136 | # an unexpected HTTP status code. 137 | # 138 | # @raise [AddressInvalidError] if the web service believes the IP address 139 | # to be invalid or missing. 140 | # 141 | # @raise [AddressNotFoundError] if the IP address was not found. 142 | # 143 | # @raise [AddressReservedError] if the IP address is reserved. 144 | # 145 | # @raise [AuthenticationError] if there was a problem authenticating to the 146 | # web service, such as an invalid or missing license key. 147 | # 148 | # @raise [InsufficientFundsError] if your account is out of credit. 149 | # 150 | # @raise [PermissionRequiredError] if your account does not have permission 151 | # to use the web service. 152 | # 153 | # @raise [InvalidRequestError] if the web service responded with an error 154 | # and there is no more specific error to raise. 155 | # 156 | # @return [MaxMind::GeoIP2::Model::City] 157 | def city(ip_address = 'me') 158 | response_for('city', MaxMind::GeoIP2::Model::City, ip_address) 159 | end 160 | 161 | # This method calls the Country web service. 162 | # 163 | # @param ip_address [String] IPv4 or IPv6 address as a string. If no 164 | # address is provided, the address that the web service is called from is 165 | # used. 166 | # 167 | # @raise [HTTP::Error] if there was an error performing the HTTP request, 168 | # such as an error connecting. 169 | # 170 | # @raise [JSON::ParserError] if there was invalid JSON in the response. 171 | # 172 | # @raise [HTTPError] if there was a problem with the HTTP response, such as 173 | # an unexpected HTTP status code. 174 | # 175 | # @raise [AddressInvalidError] if the web service believes the IP address 176 | # to be invalid or missing. 177 | # 178 | # @raise [AddressNotFoundError] if the IP address was not found. 179 | # 180 | # @raise [AddressReservedError] if the IP address is reserved. 181 | # 182 | # @raise [AuthenticationError] if there was a problem authenticating to the 183 | # web service, such as an invalid or missing license key. 184 | # 185 | # @raise [InsufficientFundsError] if your account is out of credit. 186 | # 187 | # @raise [PermissionRequiredError] if your account does not have permission 188 | # to use the web service. 189 | # 190 | # @raise [InvalidRequestError] if the web service responded with an error 191 | # and there is no more specific error to raise. 192 | # 193 | # @return [MaxMind::GeoIP2::Model::Country] 194 | def country(ip_address = 'me') 195 | response_for('country', MaxMind::GeoIP2::Model::Country, ip_address) 196 | end 197 | 198 | # This method calls the Insights web service. 199 | # 200 | # Insights is only supported by the GeoIP2 web service. The GeoLite2 web 201 | # service does not support it. 202 | # 203 | # @param ip_address [String] IPv4 or IPv6 address as a string. If no 204 | # address is provided, the address that the web service is called from is 205 | # used. 206 | # 207 | # @raise [HTTP::Error] if there was an error performing the HTTP request, 208 | # such as an error connecting. 209 | # 210 | # @raise [JSON::ParserError] if there was invalid JSON in the response. 211 | # 212 | # @raise [HTTPError] if there was a problem with the HTTP response, such as 213 | # an unexpected HTTP status code. 214 | # 215 | # @raise [AddressInvalidError] if the web service believes the IP address 216 | # to be invalid or missing. 217 | # 218 | # @raise [AddressNotFoundError] if the IP address was not found. 219 | # 220 | # @raise [AddressReservedError] if the IP address is reserved. 221 | # 222 | # @raise [AuthenticationError] if there was a problem authenticating to the 223 | # web service, such as an invalid or missing license key. 224 | # 225 | # @raise [InsufficientFundsError] if your account is out of credit. 226 | # 227 | # @raise [PermissionRequiredError] if your account does not have permission 228 | # to use the web service. 229 | # 230 | # @raise [InvalidRequestError] if the web service responded with an error 231 | # and there is no more specific error to raise. 232 | # 233 | # @return [MaxMind::GeoIP2::Model::Insights] 234 | def insights(ip_address = 'me') 235 | response_for('insights', MaxMind::GeoIP2::Model::Insights, ip_address) 236 | end 237 | 238 | private 239 | 240 | def response_for(endpoint, model_class, ip_address) 241 | if ip_address != 'me' && ip_address !~ Resolv::AddressRegex 242 | raise AddressInvalidError, "The value \"#{ip_address}\" is not a valid IP address" 243 | end 244 | 245 | record = get(endpoint, ip_address) 246 | 247 | model_class.new(record, @locales) 248 | end 249 | 250 | def make_http_client 251 | headers = HTTP.basic_auth(user: @account_id, pass: @license_key) 252 | .headers( 253 | accept: 'application/json', 254 | user_agent: "MaxMind-GeoIP2-ruby/#{VERSION} ruby/#{RUBY_VERSION} http/#{HTTP::VERSION}" 255 | ) 256 | 257 | timeout = @timeout > 0 ? headers.timeout(@timeout) : headers 258 | 259 | proxy = timeout 260 | if @proxy_address != '' 261 | proxy_params = [@proxy_address] 262 | proxy_params << (@proxy_port == 0 ? nil : @proxy_port) 263 | proxy_params << (@proxy_username == '' ? nil : @proxy_username) 264 | proxy_params << (@proxy_password == '' ? nil : @proxy_password) 265 | proxy = timeout.via(*proxy_params) 266 | end 267 | 268 | proxy 269 | end 270 | 271 | def get(endpoint, ip_address) 272 | url = "/geoip/v2.1/#{endpoint}/#{ip_address}" 273 | 274 | response = nil 275 | body = nil 276 | @connection_pool.with do |client| 277 | response = client.get(url) 278 | body = response.to_s 279 | end 280 | 281 | is_json = response.headers[:content_type]&.include?('json') 282 | 283 | if response.status.client_error? 284 | return handle_client_error(endpoint, response.code, body, is_json) 285 | end 286 | 287 | if response.status.server_error? 288 | raise HTTPError, 289 | "Received server error response (#{response.code}) for #{endpoint} with body #{body}" 290 | end 291 | 292 | if response.code != 200 293 | raise HTTPError, 294 | "Received unexpected response (#{response.code}) for #{endpoint} with body #{body}" 295 | end 296 | 297 | handle_success(endpoint, body, is_json) 298 | end 299 | 300 | # rubocop:disable Metrics/CyclomaticComplexity 301 | def handle_client_error(endpoint, status, body, is_json) 302 | if !is_json 303 | raise HTTPError, 304 | "Received client error response (#{status}) for #{endpoint} but it is not JSON: #{body}" 305 | end 306 | 307 | error = JSON.parse(body) 308 | 309 | if !error.key?('code') || !error.key?('error') 310 | raise HTTPError, 311 | "Received client error response (#{status}) that is JSON but does not specify code or error keys: #{body}" 312 | end 313 | 314 | case error['code'] 315 | when 'IP_ADDRESS_INVALID', 'IP_ADDRESS_REQUIRED' 316 | raise AddressInvalidError, error['error'] 317 | when 'IP_ADDRESS_NOT_FOUND' 318 | raise AddressNotFoundError, error['error'] 319 | when 'IP_ADDRESS_RESERVED' 320 | raise AddressReservedError, error['error'] 321 | when 'ACCOUNT_ID_REQUIRED', 322 | 'ACCOUNT_ID_UNKNOWN', 323 | 'AUTHORIZATION_INVALID', 324 | 'LICENSE_KEY_REQUIRED' 325 | raise AuthenticationError, error['error'] 326 | when 'INSUFFICIENT_FUNDS' 327 | raise InsufficientFundsError, error['error'] 328 | when 'PERMISSION_REQUIRED' 329 | raise PermissionRequiredError, error['error'] 330 | else 331 | raise InvalidRequestError, error['error'] 332 | end 333 | end 334 | # rubocop:enable Metrics/CyclomaticComplexity 335 | 336 | def handle_success(endpoint, body, is_json) 337 | if !is_json 338 | raise HTTPError, 339 | "Received a success response for #{endpoint} but it is not JSON: #{body}" 340 | end 341 | 342 | JSON.parse(body) 343 | end 344 | end 345 | end 346 | end 347 | -------------------------------------------------------------------------------- /test/test_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'maxmind/geoip2' 5 | require 'minitest/autorun' 6 | require 'webmock/minitest' 7 | 8 | class ClientTest < Minitest::Test 9 | COUNTRY = { 10 | 'continent' => { 11 | 'code' => 'NA', 12 | 'geoname_id' => 42, 13 | 'names' => { 'en' => 'North America' }, 14 | }, 15 | 'country' => { 16 | 'geoname_id' => 1, 17 | 'iso_code' => 'US', 18 | 'names' => { 'en' => 'United States of America' }, 19 | }, 20 | 'maxmind' => { 21 | 'queries_remaining' => 11, 22 | }, 23 | 'traits' => { 24 | 'ip_address' => '1.2.3.4', 25 | 'is_anycast' => true, 26 | 'network' => '1.2.3.0/24', 27 | }, 28 | }.freeze 29 | 30 | INSIGHTS = { 31 | 'anonymizer' => { 32 | 'confidence' => 85, 33 | 'is_anonymous' => true, 34 | 'is_anonymous_vpn' => true, 35 | 'is_hosting_provider' => false, 36 | 'is_public_proxy' => false, 37 | 'is_residential_proxy' => true, 38 | 'is_tor_exit_node' => false, 39 | 'network_last_seen' => '2025-10-15', 40 | 'provider_name' => 'NordVPN', 41 | }, 42 | 'continent' => { 43 | 'code' => 'NA', 44 | 'geoname_id' => 42, 45 | 'names' => { 'en' => 'North America' }, 46 | }, 47 | 'country' => { 48 | 'geoname_id' => 1, 49 | 'iso_code' => 'US', 50 | 'names' => { 'en' => 'United States of America' }, 51 | }, 52 | 'maxmind' => { 53 | 'queries_remaining' => 11, 54 | }, 55 | 'traits' => { 56 | 'ip_address' => '1.2.3.40', 57 | 'ip_risk_snapshot' => 45.5, 58 | 'is_anycast' => true, 59 | 'is_residential_proxy' => true, 60 | 'network' => '1.2.3.0/24', 61 | 'static_ip_score' => 1.3, 62 | 'user_count' => 2, 63 | }, 64 | }.freeze 65 | 66 | CONTENT_TYPES = { 67 | country: 'application/vnd.maxmind.com-country+json; charset=UTF-8; version=2.1', 68 | }.freeze 69 | 70 | def test_country 71 | record = request(:country, '1.2.3.4') 72 | 73 | assert_instance_of(MaxMind::GeoIP2::Model::Country, record) 74 | 75 | assert_equal(42, record.continent.geoname_id) 76 | assert_equal('NA', record.continent.code) 77 | assert_equal({ 'en' => 'North America' }, record.continent.names) 78 | assert_equal('North America', record.continent.name) 79 | 80 | assert_equal(1, record.country.geoname_id) 81 | refute(record.country.in_european_union?) 82 | assert_equal('US', record.country.iso_code) 83 | assert_equal({ 'en' => 'United States of America' }, record.country.names) 84 | assert_equal('United States of America', record.country.name) 85 | 86 | assert_equal(11, record.maxmind.queries_remaining) 87 | 88 | refute(record.registered_country.in_european_union?) 89 | 90 | assert(record.traits.anycast?) 91 | assert_equal('1.2.3.0/24', record.traits.network) 92 | end 93 | 94 | def test_insights 95 | record = request(:insights, '1.2.3.40') 96 | 97 | assert_instance_of(MaxMind::GeoIP2::Model::Insights, record) 98 | 99 | assert_equal(42, record.continent.geoname_id) 100 | 101 | # Test anonymizer object 102 | assert_equal(85, record.anonymizer.confidence) 103 | assert(record.anonymizer.anonymous?) 104 | assert(record.anonymizer.anonymous_vpn?) 105 | refute(record.anonymizer.hosting_provider?) 106 | refute(record.anonymizer.public_proxy?) 107 | assert(record.anonymizer.residential_proxy?) 108 | refute(record.anonymizer.tor_exit_node?) 109 | assert_equal(Date.parse('2025-10-15'), record.anonymizer.network_last_seen) 110 | assert_equal('NordVPN', record.anonymizer.provider_name) 111 | 112 | # Test traits 113 | assert(record.traits.anycast?) 114 | assert(record.traits.residential_proxy?) 115 | assert_equal('1.2.3.0/24', record.traits.network) 116 | assert_in_delta(1.3, record.traits.static_ip_score) 117 | assert_equal(2, record.traits.user_count) 118 | assert_in_delta(45.5, record.traits.ip_risk_snapshot) 119 | end 120 | 121 | def test_city 122 | record = request(:city, '1.2.3.4') 123 | 124 | assert_instance_of(MaxMind::GeoIP2::Model::City, record) 125 | 126 | assert_equal('1.2.3.0/24', record.traits.network) 127 | end 128 | 129 | def test_me 130 | record = request(:city, 'me') 131 | 132 | assert_instance_of(MaxMind::GeoIP2::Model::City, record) 133 | end 134 | 135 | def test_no_body_error 136 | assert_raises( 137 | JSON::ParserError, 138 | ) { request(:country, '1.2.3.5') } 139 | end 140 | 141 | def test_bad_body_error 142 | assert_raises( 143 | JSON::ParserError, 144 | ) { request(:country, '2.2.3.5') } 145 | end 146 | 147 | def test_non_json_success_response 148 | error = assert_raises( 149 | MaxMind::GeoIP2::HTTPError, 150 | ) { request(:country, '3.2.3.5') } 151 | 152 | assert_equal( 153 | 'Received a success response for country but it is not JSON: extra bad body', 154 | error.message, 155 | ) 156 | end 157 | 158 | def test_invalid_ip_error_from_web_service 159 | error = assert_raises( 160 | MaxMind::GeoIP2::AddressInvalidError, 161 | ) { request(:country, '1.2.3.6') } 162 | 163 | assert_equal( 164 | 'The value "1.2.3" is not a valid IP address', 165 | error.message, 166 | ) 167 | end 168 | 169 | def test_invalid_ip_error_from_client 170 | error = assert_raises( 171 | MaxMind::GeoIP2::AddressInvalidError, 172 | ) { request(:country, '1.2.3') } 173 | 174 | assert_equal( 175 | 'The value "1.2.3" is not a valid IP address', 176 | error.message, 177 | ) 178 | end 179 | 180 | def test_no_error_body_ip_error 181 | assert_raises( 182 | JSON::ParserError, 183 | ) { request(:country, '1.2.3.7') } 184 | end 185 | 186 | def test_missing_key_ip_error 187 | error = assert_raises( 188 | MaxMind::GeoIP2::HTTPError, 189 | ) { request(:country, '1.2.3.71') } 190 | 191 | assert_equal( 192 | 'Received client error response (400) that is JSON but does not specify code or error keys: {"code":"HI"}', 193 | error.message, 194 | ) 195 | end 196 | 197 | def test_weird_error_body_ip_error 198 | error = assert_raises( 199 | MaxMind::GeoIP2::HTTPError, 200 | ) { request(:country, '1.2.3.8') } 201 | 202 | assert_equal( 203 | 'Received client error response (400) that is JSON but does not specify code or error keys: {"weird":42}', 204 | error.message, 205 | ) 206 | end 207 | 208 | def test_500_error 209 | error = assert_raises( 210 | MaxMind::GeoIP2::HTTPError, 211 | ) { request(:country, '1.2.3.10') } 212 | 213 | assert_equal( 214 | 'Received server error response (500) for country with body foo', 215 | error.message, 216 | ) 217 | end 218 | 219 | def test_300_response 220 | error = assert_raises( 221 | MaxMind::GeoIP2::HTTPError, 222 | ) { request(:country, '1.2.3.11') } 223 | 224 | assert_equal( 225 | 'Received unexpected response (300) for country with body bar', 226 | error.message, 227 | ) 228 | end 229 | 230 | def test_406_error 231 | error = assert_raises( 232 | MaxMind::GeoIP2::HTTPError, 233 | ) { request(:country, '1.2.3.12') } 234 | 235 | assert_equal( 236 | 'Received client error response (406) for country but it is not JSON: Cannot satisfy your Accept-Charset requirements', 237 | error.message, 238 | ) 239 | end 240 | 241 | def test_address_not_found_error 242 | error = assert_raises( 243 | MaxMind::GeoIP2::AddressNotFoundError, 244 | ) { request(:country, '1.2.3.13') } 245 | 246 | assert_equal( 247 | 'The address "1.2.3.13" is not in our database.', 248 | error.message, 249 | ) 250 | end 251 | 252 | def test_address_reserved_error 253 | error = assert_raises( 254 | MaxMind::GeoIP2::AddressReservedError, 255 | ) { request(:country, '1.2.3.14') } 256 | 257 | assert_equal( 258 | 'The address "1.2.3.14" is a private address.', 259 | error.message, 260 | ) 261 | end 262 | 263 | def test_authorization_error 264 | error = assert_raises( 265 | MaxMind::GeoIP2::AuthenticationError, 266 | ) { request(:country, '1.2.3.15') } 267 | 268 | assert_equal( 269 | 'An account ID and license key are required to use this service.', 270 | error.message, 271 | ) 272 | end 273 | 274 | def test_missing_license_key_error 275 | error = assert_raises( 276 | MaxMind::GeoIP2::AuthenticationError, 277 | ) { request(:country, '1.2.3.16') } 278 | 279 | assert_equal( 280 | 'A license key is required to use this service.', 281 | error.message, 282 | ) 283 | end 284 | 285 | def test_missing_account_id_error 286 | error = assert_raises( 287 | MaxMind::GeoIP2::AuthenticationError, 288 | ) { request(:country, '1.2.3.17') } 289 | 290 | assert_equal( 291 | 'An account ID is required to use this service.', 292 | error.message, 293 | ) 294 | end 295 | 296 | def test_insufficient_funds_error 297 | error = assert_raises( 298 | MaxMind::GeoIP2::InsufficientFundsError, 299 | ) { request(:country, '1.2.3.18') } 300 | 301 | assert_equal( 302 | 'The license key you have provided is out of queries.', 303 | error.message, 304 | ) 305 | end 306 | 307 | def test_unexpected_code_error 308 | error = assert_raises( 309 | MaxMind::GeoIP2::InvalidRequestError, 310 | ) { request(:country, '1.2.3.19') } 311 | 312 | assert_equal( 313 | 'Whoa!', 314 | error.message, 315 | ) 316 | end 317 | 318 | def request(method, ip_address) 319 | response = get_response(ip_address) 320 | 321 | stub_request(:get, /geoip/) 322 | .to_return( 323 | body: response[:body], 324 | headers: response[:headers], 325 | status: response[:status], 326 | ) 327 | 328 | client = MaxMind::GeoIP2::Client.new( 329 | account_id: 42, 330 | license_key: 'abcdef123456', 331 | ) 332 | 333 | client.send(method, ip_address) 334 | end 335 | 336 | def get_response(ip_address) 337 | responses = { 338 | 'me' => { 339 | body: JSON.generate(COUNTRY), 340 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 341 | status: 200, 342 | }, 343 | '1.2.3' => {}, 344 | '1.2.3.4' => { 345 | body: JSON.generate(COUNTRY), 346 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 347 | status: 200, 348 | }, 349 | '1.2.3.5' => { 350 | body: '', 351 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 352 | status: 200, 353 | }, 354 | '2.2.3.5' => { 355 | body: 'bad body', 356 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 357 | status: 200, 358 | }, 359 | '3.2.3.5' => { 360 | body: 'extra bad body', 361 | headers: {}, 362 | status: 200, 363 | }, 364 | '1.2.3.40' => { 365 | body: JSON.generate(INSIGHTS), 366 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 367 | status: 200, 368 | }, 369 | '1.2.3.6' => { 370 | body: JSON.generate({ 371 | 'code' => 'IP_ADDRESS_INVALID', 372 | 'error' => 'The value "1.2.3" is not a valid IP address', 373 | }), 374 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 375 | status: 400, 376 | }, 377 | '1.2.3.7' => { 378 | body: '', 379 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 380 | status: 400, 381 | }, 382 | '1.2.3.71' => { 383 | body: JSON.generate({ code: 'HI' }), 384 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 385 | status: 400, 386 | }, 387 | '1.2.3.8' => { 388 | body: JSON.generate({ weird: 42 }), 389 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 390 | status: 400, 391 | }, 392 | '1.2.3.10' => { 393 | body: 'foo', 394 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 395 | status: 500, 396 | }, 397 | '1.2.3.11' => { 398 | body: 'bar', 399 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 400 | status: 300, 401 | }, 402 | '1.2.3.12' => { 403 | body: 'Cannot satisfy your Accept-Charset requirements', 404 | headers: {}, 405 | status: 406, 406 | }, 407 | '1.2.3.13' => { 408 | body: JSON.generate({ 409 | 'code' => 'IP_ADDRESS_NOT_FOUND', 410 | 'error' => 'The address "1.2.3.13" is not in our database.', 411 | }), 412 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 413 | status: 400, 414 | }, 415 | '1.2.3.14' => { 416 | body: JSON.generate({ 417 | 'code' => 'IP_ADDRESS_RESERVED', 418 | 'error' => 'The address "1.2.3.14" is a private address.', 419 | }), 420 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 421 | status: 400, 422 | }, 423 | '1.2.3.15' => { 424 | body: JSON.generate({ 425 | 'code' => 'AUTHORIZATION_INVALID', 426 | 'error' => 'An account ID and license key are required to use this service.', 427 | }), 428 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 429 | status: 401, 430 | }, 431 | '1.2.3.16' => { 432 | body: JSON.generate({ 433 | 'code' => 'LICENSE_KEY_REQUIRED', 434 | 'error' => 'A license key is required to use this service.', 435 | }), 436 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 437 | status: 401, 438 | }, 439 | '1.2.3.17' => { 440 | body: JSON.generate({ 441 | 'code' => 'ACCOUNT_ID_REQUIRED', 442 | 'error' => 'An account ID is required to use this service.', 443 | }), 444 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 445 | status: 401, 446 | }, 447 | '1.2.3.18' => { 448 | body: JSON.generate({ 449 | 'code' => 'INSUFFICIENT_FUNDS', 450 | 'error' => 'The license key you have provided is out of queries.', 451 | }), 452 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 453 | status: 402, 454 | }, 455 | '1.2.3.19' => { 456 | body: JSON.generate({ 457 | 'code' => 'UNEXPECTED', 458 | 'error' => 'Whoa!', 459 | }), 460 | headers: { 'Content-Type': CONTENT_TYPES[:country] }, 461 | status: 400, 462 | }, 463 | } 464 | 465 | responses[ip_address] 466 | end 467 | end 468 | -------------------------------------------------------------------------------- /test/test_reader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ipaddr' 4 | require 'maxmind/db' 5 | require 'maxmind/geoip2' 6 | require 'minitest/autorun' 7 | 8 | class ReaderTest < Minitest::Test 9 | def test_anonymous_ip 10 | reader = MaxMind::GeoIP2::Reader.new( 11 | 'test/data/test-data/GeoIP2-Anonymous-IP-Test.mmdb', 12 | ) 13 | ip = '1.2.0.1' 14 | record = reader.anonymous_ip(ip) 15 | 16 | assert(record.anonymous?) 17 | assert(record.anonymous_vpn?) 18 | refute(record.hosting_provider?) 19 | refute(record.public_proxy?) 20 | refute(record.residential_proxy?) 21 | refute(record.tor_exit_node?) 22 | assert_equal(ip, record.ip_address) 23 | assert_equal('1.2.0.0/16', record.network) 24 | 25 | reader.close 26 | end 27 | 28 | def test_anonymous_ip_residential_proxy 29 | reader = MaxMind::GeoIP2::Reader.new( 30 | 'test/data/test-data/GeoIP2-Anonymous-IP-Test.mmdb', 31 | ) 32 | ip = '81.2.69.1' 33 | record = reader.anonymous_ip(ip) 34 | 35 | assert(record.residential_proxy?) 36 | 37 | reader.close 38 | end 39 | 40 | def test_anonymous_plus 41 | reader = MaxMind::GeoIP2::Reader.new( 42 | 'test/data/test-data/GeoIP-Anonymous-Plus-Test.mmdb', 43 | ) 44 | ip = '1.2.0.1' 45 | record = reader.anonymous_plus(ip) 46 | 47 | assert_equal(30, record.anonymizer_confidence) 48 | assert(record.anonymous?) 49 | assert(record.anonymous_vpn?) 50 | refute(record.hosting_provider?) 51 | assert_equal(Date.new(2025, 4, 14), record.network_last_seen) 52 | assert_equal('foo', record.provider_name) 53 | refute(record.public_proxy?) 54 | refute(record.residential_proxy?) 55 | refute(record.tor_exit_node?) 56 | 57 | assert_equal(ip, record.ip_address) 58 | assert_equal('1.2.0.1/32', record.network) 59 | 60 | reader.close 61 | end 62 | 63 | def test_asn 64 | reader = MaxMind::GeoIP2::Reader.new( 65 | 'test/data/test-data/GeoLite2-ASN-Test.mmdb', 66 | ) 67 | ip = '1.128.0.1' 68 | record = reader.asn(ip) 69 | 70 | assert_equal(1221, record.autonomous_system_number) 71 | assert_equal('Telstra Pty Ltd', record.autonomous_system_organization) 72 | assert_equal(ip, record.ip_address) 73 | assert_equal('1.128.0.0/11', record.network) 74 | 75 | reader.close 76 | end 77 | 78 | def test_city 79 | reader = MaxMind::GeoIP2::Reader.new( 80 | 'test/data/test-data/GeoIP2-City-Test.mmdb', 81 | ) 82 | record = reader.city('2.125.160.216') 83 | 84 | assert_equal('EU', record.continent.code) 85 | 86 | assert_equal(2_655_045, record.city.geoname_id) 87 | assert_equal({ 'en' => 'Boxford' }, record.city.names) 88 | assert_equal('Boxford', record.city.name) 89 | assert_nil(record.city.confidence) 90 | 91 | assert_equal(100, record.location.accuracy_radius) 92 | assert_in_delta(51.75, record.location.latitude) 93 | assert_in_delta(-1.25, record.location.longitude) 94 | assert_equal('Europe/London', record.location.time_zone) 95 | 96 | assert_equal(2, record.subdivisions.size) 97 | assert_equal('England', record.subdivisions[0].name) 98 | assert_equal('West Berkshire', record.subdivisions[1].name) 99 | assert_equal('West Berkshire', record.most_specific_subdivision.name) 100 | 101 | record = reader.city('216.160.83.56') 102 | 103 | assert_equal(819, record.location.metro_code) 104 | 105 | assert_equal('98354', record.postal.code) 106 | 107 | assert_equal(1, record.subdivisions.size) 108 | assert_equal(5_815_135, record.subdivisions[0].geoname_id) 109 | assert_equal('WA', record.subdivisions[0].iso_code) 110 | assert_equal( 111 | { 112 | 'en' => 'Washington', 113 | 'es' => 'Washington', 114 | 'fr' => 'État de Washington', 115 | 'ja' => 'ワシントン州', 116 | 'ru' => 'Вашингтон', 117 | 'zh-CN' => '华盛顿州', 118 | }, 119 | record.subdivisions[0].names, 120 | ) 121 | 122 | assert_equal('WA', record.most_specific_subdivision.iso_code) 123 | 124 | # This IP has is_anycast. 125 | 126 | ip_address = '214.1.1.0' 127 | record = reader.city(ip_address) 128 | 129 | assert(record.traits.anycast?) 130 | 131 | reader.close 132 | end 133 | 134 | def test_city_no_subdivisions 135 | reader = MaxMind::GeoIP2::Reader.new( 136 | 'test/data/test-data/GeoIP2-City-Test.mmdb', 137 | ) 138 | record = reader.city('2001:218::') 139 | 140 | assert_empty(record.subdivisions) 141 | assert_nil(record.most_specific_subdivision) 142 | 143 | reader.close 144 | end 145 | 146 | def test_connection_type 147 | reader = MaxMind::GeoIP2::Reader.new( 148 | 'test/data/test-data/GeoIP2-Connection-Type-Test.mmdb', 149 | ) 150 | ip = '1.0.1.1' 151 | record = reader.connection_type(ip) 152 | 153 | assert_equal('Cellular', record.connection_type) 154 | assert_equal(ip, record.ip_address) 155 | assert_equal('1.0.1.0/24', record.network) 156 | 157 | reader.close 158 | end 159 | 160 | def test_country 161 | reader = MaxMind::GeoIP2::Reader.new( 162 | 'test/data/test-data/GeoIP2-Country-Test.mmdb', 163 | ) 164 | record = reader.country('2.125.160.216') 165 | 166 | assert_equal('EU', record.continent.code) 167 | assert_equal(6_255_148, record.continent.geoname_id) 168 | assert_equal( 169 | { 170 | 'de' => 'Europa', 171 | 'en' => 'Europe', 172 | 'es' => 'Europa', 173 | 'fr' => 'Europe', 174 | 'ja' => 'ヨーロッパ', 175 | 'pt-BR' => 'Europa', 176 | 'ru' => 'Европа', 177 | 'zh-CN' => '欧洲', 178 | }, 179 | record.continent.names, 180 | ) 181 | assert_equal('Europe', record.continent.name) 182 | 183 | assert_equal(2_635_167, record.country.geoname_id) 184 | refute(record.country.in_european_union?) 185 | assert_equal('GB', record.country.iso_code) 186 | assert_equal( 187 | { 188 | 'de' => 'Vereinigtes Königreich', 189 | 'en' => 'United Kingdom', 190 | 'es' => 'Reino Unido', 191 | 'fr' => 'Royaume-Uni', 192 | 'ja' => 'イギリス', 193 | 'pt-BR' => 'Reino Unido', 194 | 'ru' => 'Великобритания', 195 | 'zh-CN' => '英国', 196 | }, 197 | record.country.names, 198 | ) 199 | assert_equal('United Kingdom', record.country.name) 200 | 201 | assert_equal(3_017_382, record.registered_country.geoname_id) 202 | assert(record.registered_country.in_european_union?) 203 | assert_equal('FR', record.registered_country.iso_code) 204 | assert_equal( 205 | { 206 | 'de' => 'Frankreich', 207 | 'en' => 'France', 208 | 'es' => 'Francia', 209 | 'fr' => 'France', 210 | 'ja' => 'フランス共和国', 211 | 'pt-BR' => 'França', 212 | 'ru' => 'Франция', 213 | 'zh-CN' => '法国', 214 | }, 215 | record.registered_country.names, 216 | ) 217 | assert_equal('France', record.registered_country.name) 218 | 219 | record = reader.country('202.196.224.0') 220 | 221 | assert_equal(6_252_001, record.represented_country.geoname_id) 222 | assert_equal('US', record.represented_country.iso_code) 223 | assert_equal( 224 | { 225 | 'de' => 'USA', 226 | 'en' => 'United States', 227 | 'es' => 'Estados Unidos', 228 | 'fr' => 'États-Unis', 229 | 'ja' => 'アメリカ合衆国', 230 | 'pt-BR' => 'Estados Unidos', 231 | 'ru' => 'США', 232 | 'zh-CN' => '美国', 233 | }, 234 | record.represented_country.names, 235 | ) 236 | assert_equal('United States', record.represented_country.name) 237 | assert_equal('military', record.represented_country.type) 238 | 239 | record = reader.country('81.2.69.163') 240 | 241 | assert_equal('81.2.69.163', record.traits.ip_address) 242 | assert_equal('81.2.69.160/27', record.traits.network) 243 | 244 | # This IP has is_anycast. 245 | 246 | ip_address = '214.1.1.0' 247 | record = reader.country(ip_address) 248 | 249 | assert(record.traits.anycast?) 250 | 251 | assert_raises(NoMethodError) { record.foo } 252 | reader.close 253 | end 254 | 255 | def test_is_method_returns_false 256 | reader = MaxMind::GeoIP2::Reader.new( 257 | 'test/data/test-data/GeoIP2-Country-Test.mmdb', 258 | ) 259 | record = reader.country('74.209.24.0') 260 | 261 | refute(record.country.in_european_union?) 262 | 263 | reader.close 264 | end 265 | 266 | def test_domain 267 | reader = MaxMind::GeoIP2::Reader.new( 268 | 'test/data/test-data/GeoIP2-Domain-Test.mmdb', 269 | ) 270 | ip = '1.2.0.1' 271 | record = reader.domain(ip) 272 | 273 | assert_equal('maxmind.com', record.domain) 274 | assert_equal(ip, record.ip_address) 275 | assert_equal('1.2.0.0/16', record.network) 276 | 277 | reader.close 278 | end 279 | 280 | def test_enterprise 281 | reader = MaxMind::GeoIP2::Reader.new( 282 | 'test/data/test-data/GeoIP2-Enterprise-Test.mmdb', 283 | ) 284 | record = reader.enterprise('2.125.160.216') 285 | 286 | assert_equal(50, record.city.confidence) 287 | assert_equal(2_655_045, record.city.geoname_id) 288 | assert_equal({ 'en' => 'Boxford' }, record.city.names) 289 | assert_equal('Boxford', record.city.name) 290 | 291 | ip_address = '74.209.24.0' 292 | record = reader.enterprise(ip_address) 293 | 294 | assert_equal(11, record.city.confidence) 295 | assert_equal(99, record.country.confidence) 296 | assert_equal(6_252_001, record.country.geoname_id) 297 | refute(record.country.in_european_union?) 298 | 299 | assert_equal(27, record.location.accuracy_radius) 300 | 301 | refute(record.registered_country.in_european_union?) 302 | 303 | assert_equal('Cable/DSL', record.traits.connection_type) 304 | assert(record.traits.legitimate_proxy?) 305 | 306 | assert_equal(ip_address, record.traits.ip_address) 307 | assert_equal('74.209.16.0/20', record.traits.network) 308 | 309 | # This IP has MCC/MNC data. 310 | 311 | ip_address = '149.101.100.0' 312 | record = reader.enterprise(ip_address) 313 | 314 | assert_equal('310', record.traits.mobile_country_code) 315 | assert_equal('004', record.traits.mobile_network_code) 316 | 317 | # This IP has is_anycast. 318 | 319 | ip_address = '214.1.1.0' 320 | record = reader.enterprise(ip_address) 321 | 322 | assert(record.traits.anycast?) 323 | 324 | reader.close 325 | end 326 | 327 | def test_isp 328 | reader = MaxMind::GeoIP2::Reader.new( 329 | 'test/data/test-data/GeoIP2-ISP-Test.mmdb', 330 | ) 331 | ip = '1.128.1.1' 332 | record = reader.isp(ip) 333 | 334 | assert_equal(1221, record.autonomous_system_number) 335 | assert_equal('Telstra Pty Ltd', record.autonomous_system_organization) 336 | assert_equal('Telstra Internet', record.isp) 337 | assert_equal('Telstra Internet', record.organization) 338 | assert_equal(ip, record.ip_address) 339 | assert_equal('1.128.0.0/11', record.network) 340 | 341 | # This IP has MCC/MNC data. 342 | 343 | ip_address = '149.101.100.0' 344 | record = reader.isp(ip_address) 345 | 346 | assert_equal('310', record.mobile_country_code) 347 | assert_equal('004', record.mobile_network_code) 348 | 349 | reader.close 350 | end 351 | 352 | def test_no_traits 353 | reader = MaxMind::GeoIP2::Reader.new( 354 | 'test/data/test-data/GeoIP2-Enterprise-Test.mmdb', 355 | ) 356 | record = reader.enterprise('2.125.160.216') 357 | 358 | assert_equal('2.125.160.216', record.traits.ip_address) 359 | assert_equal('2.125.160.216/29', record.traits.network) 360 | assert_nil(record.traits.autonomous_system_number) 361 | refute(record.traits.anonymous?) 362 | 363 | reader.close 364 | end 365 | 366 | def test_no_location 367 | reader = MaxMind::GeoIP2::Reader.new( 368 | 'test/data/test-data/GeoIP2-Enterprise-Test.mmdb', 369 | ) 370 | record = reader.enterprise('212.47.235.81') 371 | 372 | assert_nil(record.location.accuracy_radius) 373 | 374 | reader.close 375 | end 376 | 377 | def test_no_postal 378 | reader = MaxMind::GeoIP2::Reader.new( 379 | 'test/data/test-data/GeoIP2-Enterprise-Test.mmdb', 380 | ) 381 | record = reader.enterprise('212.47.235.81') 382 | 383 | assert_nil(record.postal.code) 384 | 385 | reader.close 386 | end 387 | 388 | def test_no_city 389 | reader = MaxMind::GeoIP2::Reader.new( 390 | 'test/data/test-data/GeoIP2-Enterprise-Test.mmdb', 391 | ) 392 | record = reader.enterprise('212.47.235.81') 393 | 394 | assert_nil(record.city.confidence) 395 | assert_nil(record.city.name) 396 | assert_nil(record.city.names) 397 | 398 | reader.close 399 | end 400 | 401 | def test_no_continent 402 | reader = MaxMind::GeoIP2::Reader.new( 403 | 'test/data/test-data/GeoIP2-Enterprise-Test.mmdb', 404 | ) 405 | record = reader.enterprise('212.47.235.81') 406 | 407 | assert_nil(record.continent.code) 408 | 409 | reader.close 410 | end 411 | 412 | def test_no_country 413 | reader = MaxMind::GeoIP2::Reader.new( 414 | 'test/data/test-data/GeoIP2-Enterprise-Test.mmdb', 415 | ) 416 | record = reader.enterprise('212.47.235.81') 417 | 418 | assert_nil(record.country.confidence) 419 | 420 | reader.close 421 | end 422 | 423 | def test_no_represented_country 424 | reader = MaxMind::GeoIP2::Reader.new( 425 | 'test/data/test-data/GeoIP2-Enterprise-Test.mmdb', 426 | ) 427 | record = reader.enterprise('212.47.235.81') 428 | 429 | assert_nil(record.represented_country.type) 430 | 431 | reader.close 432 | end 433 | 434 | def database_types 435 | [ 436 | { 'file' => 'City', 'method' => 'city' }, 437 | { 'file' => 'Country', 'method' => 'country' }, 438 | ] 439 | end 440 | 441 | def test_default_locale 442 | database_types.each do |t| 443 | reader = MaxMind::GeoIP2::Reader.new( 444 | "test/data/test-data/GeoIP2-#{t['file']}-Test.mmdb", 445 | ) 446 | record = reader.send(t['method'], '81.2.69.160') 447 | 448 | assert_equal('United Kingdom', record.country.name) 449 | reader.close 450 | end 451 | end 452 | 453 | def test_locale_list 454 | database_types.each do |t| 455 | reader = MaxMind::GeoIP2::Reader.new( 456 | "test/data/test-data/GeoIP2-#{t['file']}-Test.mmdb", 457 | %w[xx ru pt-BR es en], 458 | ) 459 | record = reader.send(t['method'], '81.2.69.160') 460 | 461 | assert_equal('Великобритания', record.country.name) 462 | reader.close 463 | end 464 | end 465 | 466 | def test_has_ip_address_and_network 467 | database_types.each do |t| 468 | reader = MaxMind::GeoIP2::Reader.new( 469 | "test/data/test-data/GeoIP2-#{t['file']}-Test.mmdb", 470 | ) 471 | record = reader.send(t['method'], '81.2.69.163') 472 | 473 | assert_equal('81.2.69.163', record.traits.ip_address) 474 | assert_equal('81.2.69.160/27', record.traits.network) 475 | reader.close 476 | end 477 | end 478 | 479 | def test_is_in_european_union 480 | database_types.each do |t| 481 | reader = MaxMind::GeoIP2::Reader.new( 482 | "test/data/test-data/GeoIP2-#{t['file']}-Test.mmdb", 483 | ) 484 | record = reader.send(t['method'], '81.2.69.160') 485 | 486 | refute(record.country.in_european_union?) 487 | refute(record.registered_country.in_european_union?) 488 | reader.close 489 | end 490 | end 491 | 492 | def test_unknown_address 493 | database_types.each do |t| 494 | reader = MaxMind::GeoIP2::Reader.new( 495 | "test/data/test-data/GeoIP2-#{t['file']}-Test.mmdb", 496 | ) 497 | error = assert_raises( 498 | MaxMind::GeoIP2::AddressNotFoundError, 499 | ) { reader.send(t['method'], '10.10.10.0') } 500 | assert_equal( 501 | 'The address 10.10.10.0 is not in the database.', 502 | error.message, 503 | ) 504 | reader.close 505 | end 506 | end 507 | 508 | def test_incorrect_database 509 | reader = MaxMind::GeoIP2::Reader.new( 510 | 'test/data/test-data/GeoIP2-City-Test.mmdb', 511 | ) 512 | error = assert_raises(ArgumentError) { reader.country('10.10.10.10') } 513 | assert_equal( 514 | 'The country method cannot be used with the GeoIP2-City database.', 515 | error.message, 516 | ) 517 | reader.close 518 | end 519 | 520 | def test_invalid_address 521 | reader = MaxMind::GeoIP2::Reader.new( 522 | 'test/data/test-data/GeoIP2-City-Test.mmdb', 523 | ) 524 | error = assert_raises( 525 | IPAddr::InvalidAddressError, 526 | ) { reader.city('invalid') } 527 | # Ruby 2.5 says just 'invalid address'. Ruby 2.6+ says 'invalid address: 528 | # invalid'. 529 | assert_match('invalid address', error.message) 530 | reader.close 531 | end 532 | 533 | def test_metadata 534 | reader = MaxMind::GeoIP2::Reader.new( 535 | 'test/data/test-data/GeoIP2-City-Test.mmdb', 536 | ) 537 | 538 | assert_equal('GeoIP2-City', reader.metadata.database_type) 539 | reader.close 540 | end 541 | 542 | def test_constructor_with_minimum_keyword_arguments 543 | reader = MaxMind::GeoIP2::Reader.new( 544 | database: 'test/data/test-data/GeoIP2-Country-Test.mmdb', 545 | ) 546 | record = reader.country('81.2.69.160') 547 | 548 | assert_equal('United Kingdom', record.country.name) 549 | reader.close 550 | end 551 | 552 | def test_constructor_with_all_keyword_arguments 553 | reader = MaxMind::GeoIP2::Reader.new( 554 | database: 'test/data/test-data/GeoIP2-Country-Test.mmdb', 555 | locales: %w[ru], 556 | mode: MaxMind::DB::MODE_MEMORY, 557 | ) 558 | record = reader.country('81.2.69.160') 559 | 560 | assert_equal('Великобритания', record.country.name) 561 | reader.close 562 | end 563 | 564 | def test_constructor_missing_database 565 | error = assert_raises(ArgumentError) do 566 | MaxMind::GeoIP2::Reader.new 567 | end 568 | assert_equal('Invalid database parameter', error.message) 569 | 570 | error = assert_raises(ArgumentError) do 571 | MaxMind::GeoIP2::Reader.new( 572 | locales: %w[ru], 573 | ) 574 | end 575 | assert_equal('Invalid database parameter', error.message) 576 | end 577 | 578 | def test_old_constructor_parameters 579 | reader = MaxMind::GeoIP2::Reader.new( 580 | 'test/data/test-data/GeoIP2-Country-Test.mmdb', 581 | %w[ru], 582 | mode: MaxMind::DB::MODE_MEMORY, 583 | ) 584 | record = reader.country('81.2.69.160') 585 | 586 | assert_equal('Великобритания', record.country.name) 587 | reader.close 588 | end 589 | end 590 | --------------------------------------------------------------------------------