#!/usr/bin/env ruby # frozen_string_literal: true # Otto GeoResolver Extension Guide # # Otto resolves a country code in this order (first hit wins): # 1. App-configured trusted header (configure_ip_privacy(geo_header:)) # 2. Built-in CDN/provider headers (Cloudflare, AWS, Vercel, ...) # 3. Custom resolver hook (GeoResolver.custom_resolver = ...) # 4. Local MMDB database (configure_ip_privacy(geo_db_path:/geo_db_reader:)) # 5. '**' (unknown) Otto does not guess from a hardcoded table # # This guide shows the extension points: # A. Built-in configuration (trusted header + local database) — no code # B. Custom resolver hook (inline or a callable object) # C. Subclass-based (full control) require 'bundler/setup' require 'otto' # ============================================================================= # A. Built-in configuration: trusted header + local country database # ============================================================================= # # No custom code needed — just configure the Otto instance. The database is # looked up on the already-MASKED IP, and a bad geo_db_path fails at boot. # # otto = Otto.new('routes.txt') # otto.configure_ip_privacy( # geo_header: 'X-Client-Country', # trusted header checked before CDN headers # geo_db_path: 'data/country.mmdb' # offline fallback (needs the maxmind-db gem) # ) # # Prefer to bring your own reader (any object responding to #get)? Inject it — # this keeps the reader/data-source choice independent of Otto: # # reader = MaxMind::DB.new('data/country.mmdb', mode: MaxMind::DB::MODE_MEMORY) # otto.configure_ip_privacy(geo_db_reader: reader) # # Security note: geo headers are only trusted for requests that arrive via a # configured trusted proxy (add_trusted_proxy), since they are client-spoofable # otherwise. configure_ip_privacy(geo: false) disables geo entirely. # ============================================================================= # B. Quick Start: Custom resolver hook # ============================================================================= puts 'Simple Custom Geo Resolution' puts '-' * 40 # Step 1: Define your resolver function custom_resolver = lambda do |ip, _env| case ip when '1.2.3.4' then 'US' when '5.6.7.8' then 'GB' else nil # nil = use Otto's built-in resolver end end # Step 2: Set it globally Otto::Privacy::GeoResolver.custom_resolver = custom_resolver # Step 3: Test it puts "1.2.3.4 -> #{Otto::Privacy::GeoResolver.resolve('1.2.3.4', {})}" # Resolver returns nil for 8.8.8.8, and there is no header or database, so the # honest answer is '**' (unknown) — Otto does not guess. puts "8.8.8.8 -> #{Otto::Privacy::GeoResolver.resolve('8.8.8.8', {})} (unknown)" # Reset for next example Otto::Privacy::GeoResolver.custom_resolver = nil # ============================================================================= # Real-World Example: API Integration with Caching # ============================================================================= puts "\nAPI Integration with Caching" puts '-' * 40 class CachedGeoAPI def initialize(api_key) @api_key = api_key @cache = {} end def call(ip, _env) # Return cached result if available return @cache[ip] if @cache.key?(ip) # Simulate API call (replace with real HTTP request) country = mock_api_call(ip) # Cache the result @cache[ip] = country country rescue StandardError => e puts "API failed: #{e.}" nil # Fallback to Otto's resolver end private def mock_api_call(ip) # Replace this with: HTTP.get("https://api.example.com/geo?ip=#{ip}") case ip when /^1\./ then 'US' when /^2\./ then 'GB' end end end # Use the cached API resolver api_resolver = CachedGeoAPI.new('your_api_key') Otto::Privacy::GeoResolver.custom_resolver = api_resolver puts "1.2.3.4 -> #{Otto::Privacy::GeoResolver.resolve('1.2.3.4', {})}" puts "1.2.3.4 -> #{Otto::Privacy::GeoResolver.resolve('1.2.3.4', {})} (cached)" Otto::Privacy::GeoResolver.custom_resolver = nil # ============================================================================= # Performance Tips # ============================================================================= puts "\nPerformance Tips" puts '-' * 40 puts '• Cache API results to avoid repeated calls' puts "• Return nil from custom resolver to use Otto's fast fallback" puts '• Use CloudFlare headers when available (fastest)' puts '• Consider async/background geo updates for heavy traffic' puts "\nProduction Pattern: Valkey/Redis Bloom Filters" puts '-' * 40 puts 'For high-traffic applications, consider Bloom/Cuckoo filters:' puts '• Store RIR IP prefixes in Valkey/Redis Bloom filter per country' puts '• Memory: ~1MB for entire IPv4 table at 1% false positive rate' puts '• Lookup: O(1) microsecond-level performance via BF.EXISTS' puts '• Zero external dependencies, rebuild nightly from public RIR files' puts '• Perfect for CDN header fallback when requests bypass edge' puts ''