Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 2.0.2

- Hide API keys in request errors and typed bulk error messages.
- Preserve HTTP error types and status codes when error responses are incomplete or invalid.
- Add Arabic (`ar`) for single and bulk lookups.

Existing public methods and Ruby 2.7+ support remain unchanged.

## 2.0.1

- Mask API keys in configuration serialization and client/config inspection.
Expand Down
34 changes: 9 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ You can also pass `LookupIpGeolocationRequest` and `BulkLookupIpGeolocationReque
| Supported Endpoints | `/v3/ipgeo`, `/v3/ipgeo-bulk` |
| Supported Inputs | IPv4, IPv6, domain |
| Main Data Returned | Geolocation, company, ASN, time zone, network, hostname, abuse, user-agent, currency, security |
| Authentication | API key, request-origin auth for `/v3/ipgeo` only |
| Authentication | API key |
| Response Formats | Structured JSON, raw JSON, raw XML |
| Bulk Limit | Up to 50,000 IPs or domains per request |
| Transport | `Net::HTTP` |
Expand All @@ -101,7 +101,7 @@ To use most SDK features, create or access your IPGeolocation.io account and cop
4. Open your dashboard: <https://app.ipgeolocation.io/dashboard>
5. Copy an API key from the `API Keys` section

For server-side code, keep the API key in an environment variable or secret manager. For browser-based single lookups on paid plans, use request-origin auth instead of exposing an API key in frontend code.
Keep the API key in an environment variable or secret manager.

## Authentication

Expand All @@ -113,21 +113,7 @@ client = IpgeolocationSdk::IpGeolocationClient.new(
)
```

### Request-Origin Auth

```ruby
client = IpgeolocationSdk::IpGeolocationClient.new(
request_origin: "https://app.example.com"
)
```

`request_origin` must be an absolute `http` or `https` origin with no path, query string, fragment, or userinfo.

> [!IMPORTANT]
> Request-origin auth does not work with `/v3/ipgeo-bulk`. Bulk lookup always requires `api_key`.

> [!NOTE]
> If you set both `api_key` and `request_origin`, single lookup still uses the API key. The API key is sent as the `apiKey` query parameter, so avoid logging full request URLs.
The API key is sent as the `apiKey` query parameter, so avoid logging full request URLs.

## Plan Behavior

Expand All @@ -139,7 +125,6 @@ Feature availability depends on your plan and request parameters.
| Domain lookup | Not supported | Supported |
| Bulk lookup | Not supported | Supported |
| Non-English `lang` | Not supported | Supported |
| Request-origin auth | Not supported | Supported for `/v3/ipgeo` only |
| Optional modules via `include` | Not supported | Supported |
| `include: ["*"]` | Base response only | All plan-available modules |

Expand All @@ -149,8 +134,7 @@ Paid plans still need `include` for optional modules. `fields` and `excludes` on

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `api_key` | `String` | unset | Required for bulk lookup. Optional for single lookup if `request_origin` is set. |
| `request_origin` | `String` | unset | Must be an absolute `http` or `https` origin. |
| `api_key` | `String` | unset | Your IPGeolocation.io API key. |
| `base_url` | `String` | `https://api.ipgeolocation.io` | Override the API base URL. |
| `connect_timeout` | `Numeric` | `10.0` | Time to open the connection, in seconds. |
| `read_timeout` | `Numeric` | `30.0` | Time to wait while reading the response body, in seconds. |
Expand All @@ -176,7 +160,7 @@ The client constructor accepts either an `IpGeolocationClientConfig` or a hash w
|-------|------------|-------|
| `ip` | Single lookup | IPv4, IPv6, or domain. Omit it for caller IP lookup. |
| `ips` | Bulk lookup | Array of 1 to 50,000 IPs or domains. |
| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`. |
| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`, `ar` (Arabic). |
| `include` | Single and bulk | Array of module names such as `security`, `abuse`, `user_agent`, `hostname`, `liveHostname`, `hostnameFallbackLive`, `geo_accuracy`, `dma_code`, or `*`. |
| `fields` | Single and bulk | Array of field paths to keep, for example `["location.country_name", "security.threat_score"]`. |
| `excludes` | Single and bulk | Array of field paths to remove from the response. |
Expand Down Expand Up @@ -335,6 +319,8 @@ Compact mode omits `nil` fields. Full mode keeps them. When serializing a client

## Errors

If a request error includes your configured API key, the SDK replaces it with `[REDACTED]`. This also applies to errors in typed bulk results. SDK error types and HTTP status codes stay the same. Some error details may be shortened to keep the key hidden.

The SDK raises these exception classes:

- `IpgeolocationSdk::ValidationError`
Expand Down Expand Up @@ -367,10 +353,8 @@ end

## Troubleshooting

- `bulk lookup requires api_key in client config`
Bulk lookup does not support request-origin auth on its own.
- `single lookup requires api_key or request_origin in client config`
Set at least one authentication option before calling the single lookup methods.
- Missing API key
Set `api_key` in the client configuration before making a lookup.
- `XML output is not supported by typed methods`
Use `lookup_ip_geolocation_raw` or `bulk_lookup_ip_geolocation_raw` for XML output.
- `client is closed`
Expand Down
50 changes: 37 additions & 13 deletions lib/ipgeolocation_sdk/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,61 +33,85 @@ def inspect
end

def lookup_ip_geolocation(request = nil)
api_key = @config.api_key&.strip
redactor = ErrorRedactor.new(api_key)
ensure_open
typed_request = to_lookup_request(request)
validate_lookup_request_base
Serde.validate_json_output(typed_request.output)

response, duration_ms = execute_with_metrics(build_lookup_http_request(typed_request))
raise Serde.to_api_error(response.status_code, response.body) unless success_status?(response.status_code)
response, duration_ms = execute_with_metrics(build_lookup_http_request(typed_request, api_key))
raise Serde.to_api_error(response.status_code, response.body, redactor) unless success_status?(response.status_code)

ApiResponse.new(
data: Serde.parse_single_lookup(response.body),
metadata: Serde.to_metadata(response.status_code, duration_ms, response.headers)
)
rescue StandardError => error
raise if api_key.nil?

raise redactor.copy(error), cause: nil
end

def lookup_ip_geolocation_raw(request = nil)
api_key = @config.api_key&.strip
redactor = ErrorRedactor.new(api_key)
ensure_open
typed_request = to_lookup_request(request)
validate_lookup_request_base

response, duration_ms = execute_with_metrics(build_lookup_http_request(typed_request))
raise Serde.to_api_error(response.status_code, response.body) unless success_status?(response.status_code)
response, duration_ms = execute_with_metrics(build_lookup_http_request(typed_request, api_key))
raise Serde.to_api_error(response.status_code, response.body, redactor) unless success_status?(response.status_code)

ApiResponse.new(
data: response.body,
metadata: Serde.to_metadata(response.status_code, duration_ms, response.headers)
)
rescue StandardError => error
raise if api_key.nil?

raise redactor.copy(error), cause: nil
end

def bulk_lookup_ip_geolocation(request)
api_key = @config.api_key&.strip
redactor = ErrorRedactor.new(api_key)
ensure_open
typed_request = to_bulk_request(request)
validate_bulk_request_base
Serde.validate_json_output(typed_request.output)

response, duration_ms = execute_with_metrics(build_bulk_http_request(typed_request))
raise Serde.to_api_error(response.status_code, response.body) unless success_status?(response.status_code)
response, duration_ms = execute_with_metrics(build_bulk_http_request(typed_request, api_key))
raise Serde.to_api_error(response.status_code, response.body, redactor) unless success_status?(response.status_code)

ApiResponse.new(
data: Serde.parse_bulk_lookup(response.body),
data: Serde.parse_bulk_lookup(response.body, redactor),
metadata: Serde.to_metadata(response.status_code, duration_ms, response.headers)
)
rescue StandardError => error
raise if api_key.nil?

raise redactor.copy(error), cause: nil
end

def bulk_lookup_ip_geolocation_raw(request)
api_key = @config.api_key&.strip
redactor = ErrorRedactor.new(api_key)
ensure_open
typed_request = to_bulk_request(request)
validate_bulk_request_base

response, duration_ms = execute_with_metrics(build_bulk_http_request(typed_request))
raise Serde.to_api_error(response.status_code, response.body) unless success_status?(response.status_code)
response, duration_ms = execute_with_metrics(build_bulk_http_request(typed_request, api_key))
raise Serde.to_api_error(response.status_code, response.body, redactor) unless success_status?(response.status_code)

ApiResponse.new(
data: response.body,
metadata: Serde.to_metadata(response.status_code, duration_ms, response.headers)
)
rescue StandardError => error
raise if api_key.nil?

raise redactor.copy(error), cause: nil
end

def close
Expand Down Expand Up @@ -140,10 +164,10 @@ def to_bulk_request(request)
end
end

def build_lookup_http_request(request)
def build_lookup_http_request(request, api_key)
output = request.output || ResponseFormat::JSON
query = Serde.build_query(
"apiKey" => @config.api_key,
"apiKey" => api_key,
"ip" => request.ip,
"lang" => request.lang,
"include" => request.include.empty? ? nil : request.include.join(","),
Expand Down Expand Up @@ -175,10 +199,10 @@ def build_lookup_http_request(request)
)
end

def build_bulk_http_request(request)
def build_bulk_http_request(request, api_key)
output = request.output || ResponseFormat::JSON
query = Serde.build_query(
"apiKey" => @config.api_key,
"apiKey" => api_key,
"lang" => request.lang,
"include" => request.include.empty? ? nil : request.include.join(","),
"fields" => request.fields.empty? ? nil : request.fields.join(","),
Expand Down
3 changes: 2 additions & 1 deletion lib/ipgeolocation_sdk/enums.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ module Language
KO = "ko"
FA = "fa"
PT = "pt"
AR = "ar"

VALUES = [
EN, DE, RU, JA, FR, CN, ES, CS, IT, KO, FA, PT
EN, DE, RU, JA, FR, CN, ES, CS, IT, KO, FA, PT, AR
].freeze

module_function
Expand Down
55 changes: 55 additions & 0 deletions lib/ipgeolocation_sdk/errors.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# frozen_string_literal: true

require "cgi"
require "json"

module IpgeolocationSdk
class IpGeolocationError < StandardError
attr_reader :cause
Expand Down Expand Up @@ -35,4 +38,56 @@ class LockedError < ApiError; end
class RateLimitError < ApiError; end
class ClientClosedRequestError < ApiError; end
class ServerError < ApiError; end

class ErrorRedactor
def initialize(api_key)
return if api_key.nil? || api_key.empty?

key = api_key.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
escaped = CGI.escape(key)
forms = [key, escaped, escaped.gsub("+", "%20"), CGI.escapeHTML(key),
JSON.generate(key)[1...-1], JSON.generate(key, ascii_only: true)[1...-1]]
patterns = forms.uniq.sort_by { |form| -form.length }.map do |form|
Regexp.escape(form).gsub(/%[0-9A-F]{2}/) { |escape| "(?i:#{escape})" }
end
@pattern = Regexp.new(patterns.join("|"))
end

def text(value)
return value if value.nil? || @pattern.nil?

value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).gsub(@pattern, "[REDACTED]")
end

def copy(error, depth = 0)
return nil if error.nil? || depth >= 8

message = error.is_a?(JSON::ParserError) ? "Invalid JSON response" : text(error.message)
cause = copy(error.cause, depth + 1)
sanitized =
begin
if error.is_a?(ApiError)
error.class.new(message, error.status_code, text(error.api_message))
elsif error.is_a?(IpGeolocationError)
error.class.new(message, cause: cause)
else
error.class.new(message)
end
rescue StandardError
if error.is_a?(ApiError)
ApiError.new(message, error.status_code, text(error.api_message))
elsif error.is_a?(IpGeolocationError)
klass = [RequestTimeoutError, TransportError, ValidationError, SerializationError, IpGeolocationError].find do |type|
error.is_a?(type)
end
klass.new(message, cause: cause)
else
StandardError.new(message)
end
end
sanitized.set_backtrace(error.backtrace&.map { |line| text(line) })
sanitized
end
end
private_constant :ErrorRedactor
end
21 changes: 13 additions & 8 deletions lib/ipgeolocation_sdk/serde.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ def parse_single_lookup(body)
parse_ip_geolocation_response(payload)
end

def parse_bulk_lookup(body)
def parse_bulk_lookup(body, redactor = nil)
payload = parse_json(body, "Failed to deserialize bulk lookup response")
unless payload.is_a?(Array)
raise SerializationError, "Failed to deserialize bulk response: expected an array payload"
end

payload.map do |item|
if bulk_error_item?(item)
message = string_or_nil(item["message"])
message = redactor.text(message) unless redactor.nil?
BulkLookupError.new(
error: BulkLookupErrorDetails.new(message: string_or_nil(item["message"]))
error: BulkLookupErrorDetails.new(message: message)
)
else
raise SerializationError, "Failed to deserialize API response" unless item.is_a?(Hash)
Expand Down Expand Up @@ -83,8 +85,8 @@ def validate_json_output(output)
raise ValidationError, "XML output is not supported by typed methods. Use ResponseFormat::JSON."
end

def to_api_error(status_code, body)
api_message = extract_api_message(body)
def to_api_error(status_code, body, redactor = nil)
api_message = extract_api_message(body, redactor)
message =
if api_message.nil?
"API request failed with HTTP status #{status_code}"
Expand All @@ -110,22 +112,25 @@ def to_api_error(status_code, body)
klass.new(message, status_code, api_message)
end

def extract_api_message(body)
def extract_api_message(body, redactor = nil)
return nil if body.nil?

normalized = body.to_s.strip
return nil if normalized.empty?

fallback = (redactor.nil? ? normalized : redactor.text(normalized))[0, 512]
payload = JSON.parse(normalized)
return normalized[0, 512] unless payload.is_a?(Hash)
return fallback unless payload.is_a?(Hash)

message = payload["message"]
if message.nil? && payload["error"].is_a?(Hash)
message = payload["error"]["message"]
end
string_or_nil(message) || normalized[0, 512]
return fallback unless message.is_a?(String)

redactor.nil? ? message : redactor.text(message)
rescue JSON::ParserError
normalized[0, 512]
fallback
end

def parse_int_header(value)
Expand Down
2 changes: 1 addition & 1 deletion lib/ipgeolocation_sdk/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module IpgeolocationSdk
VERSION = "2.0.1"
VERSION = "2.0.2"
end
Loading
Loading