diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..54aa445 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - run: cargo fmt --check + + - run: cargo clippy --all-targets -- -D warnings + + - run: cargo test diff --git a/Cargo.toml b/Cargo.toml index 4db026f..2f36e39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ipdata" -version = "0.1.1" +version = "0.1.2" edition = "2021" description = "Official Rust client for the ipdata.co IP geolocation and threat intelligence API" license = "MIT" @@ -9,10 +9,11 @@ keywords = ["ipdata", "geolocation", "ip", "api", "threat-intelligence"] categories = ["api-bindings", "web-programming::http-client"] [dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "http2"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" [dev-dependencies] -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +wiremock = "0.6" diff --git a/README.md b/README.md index 2a65d43..b4eeedb 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,21 @@ let client = ipdata::IpData::eu("your-api-key"); let client = ipdata::IpData::with_base_url("your-api-key", "https://custom-endpoint.example.com"); ``` +## Connection reuse and HTTP/2 + +An `IpData` client keeps a persistent HTTPS connection pool for as long as it lives. Create one client per process (or one per API key or endpoint), keep it, and reuse it for every lookup: each new client pays a fresh TLS handshake. Requests to api.ipdata.co are multiplexed over HTTP/2 when the platform negotiates it, and fall back to HTTP/1.1 keep-alive otherwise. + +`IpData` is `Send + Sync`: share one instance across tasks by reference or in an `Arc`, or `clone()` it; clones are cheap and share the same pool. There is no close method: dropping the last clone releases the pool and its connections. To bring your own `reqwest::Client` (a pool shared with the rest of your application, timeouts, a proxy or TLS settings), use `IpData::with_client`; the client is used as given, so set the `User-Agent` yourself if you want to keep the SDK's `ipdata-rust/` header. + +```rust +let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; +let client = ipdata::IpData::with_client("your-api-key", "https://api.ipdata.co", http); +``` + +The default client, used by `IpData::new`, `IpData::eu` and `IpData::with_base_url`, negotiates HTTP/2 through ALPN with automatic HTTP/1.1 fallback and is tuned for reuse: pooled connections idle for 60 s are closed, HTTP/2 connections are kept alive with a PING every 30 s, and TCP keep-alive probes run every 30 s. No total request timeout is set. A bulk lookup (`POST /bulk`) whose pooled connection turns out to be dead before any response byte arrives is sent again exactly once on a fresh connection; HTTP error statuses are never retried. + ## Error Handling ```rust @@ -107,6 +122,9 @@ match client.lookup("8.8.8.8").await { The main response type is `IpInfo` which includes: - **Geolocation**: `city`, `region`, `country_name`, `country_code`, `continent_name`, `latitude`, `longitude`, `postal` +- **Flags**: `is_eu` (EU member country), plus two flags the API sends only when they are true, so the typed fields read `false` when the key is absent: + - `is_satellite`: the IP belongs to a satellite internet provider + - `is_anycast`: the IP is an anycast address announced from several locations at once - **ASN**: `asn` (struct with `asn`, `name`, `domain`, `route`, `asn_type`) - **Company**: `company` (struct with `name`, `domain`, `network`, `company_type`) - **Carrier**: `carrier` (struct with `name`, `mcc`, `mnc`) @@ -114,6 +132,10 @@ The main response type is `IpInfo` which includes: - **Time Zone**: `time_zone` (struct with `name`, `abbreviation`, `offset`, `is_dst`, `current_time`) - **Organisation**: `organisation` (ISP / organization name) - **Threat**: `threat` (struct with `is_tor`, `is_vpn`, `is_proxy`, `is_anonymous`, `is_threat`, `is_bogon`, `blocklists`, `scores`, and more) +- **Blocklist entries**: each `Blocklist` in `threat.blocklists` has `name`, `site` and `blocklist_type`, plus three fields that are `None` when a feed does not report them (ipdata's own vpn feed, for example): + - `first_seen`: first day the IP was seen on the list, as a `YYYY-MM-DD` string (UTC) + - `last_seen`: most recent day the IP was seen on the list, as a `YYYY-MM-DD` string (UTC) + - `days_seen`: number of days the IP has been seen on the list - **Languages**: `languages` (vec of `Language` with `name`, `native`, `code`) ## License diff --git a/examples/lookup.rs b/examples/lookup.rs index 9f5df90..2418157 100644 --- a/examples/lookup.rs +++ b/examples/lookup.rs @@ -24,7 +24,11 @@ async fn main() -> Result<(), Box> { // Bulk lookup let results = client.bulk(&["8.8.8.8", "1.1.1.1"]).await?; for result in &results { - println!("{} -> {}", result.ip, result.country_name.as_deref().unwrap_or("unknown")); + println!( + "{} -> {}", + result.ip, + result.country_name.as_deref().unwrap_or("unknown") + ); } Ok(()) diff --git a/src/client.rs b/src/client.rs index 0fd7202..3a8e87f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; use crate::error::{ApiErrorResponse, Error, Result}; @@ -15,8 +17,41 @@ const BULK_LIMIT: usize = 100; /// SDK version. const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Idle time after which the client closes a pooled connection. +/// +/// The API drops idle connections itself after roughly two minutes; closing them +/// earlier on the client side avoids sending a request into a socket the server has +/// already given up on. +const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Interval between HTTP/2 keep-alive PING frames. +const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(30); + +/// Time a keep-alive PING may stay unanswered before the connection is closed. +const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Interval between TCP keep-alive probes. +const TCP_KEEPALIVE: Duration = Duration::from_secs(30); + /// Client for the ipdata.co API. /// +/// An `IpData` owns a [`reqwest::Client`] whose pool of HTTPS connections is kept +/// alive and reused across requests. Build the client once (per process, or one per +/// API key or endpoint) and reuse it for every lookup: each call to [`IpData::new`], +/// [`IpData::eu`] or [`IpData::with_base_url`] creates a new pool, and the first +/// request on a new pool pays a fresh TCP and TLS handshake. Requests use HTTP/2 when +/// the server negotiates it and fall back to HTTP/1.1 keep-alive otherwise. +/// +/// `IpData` is `Send + Sync`, so one instance can be shared across tasks and threads +/// by reference or in an `Arc`. `Clone` is cheap: a clone shares the connection pool +/// of the client it was cloned from. There is no close method; dropping the last clone +/// releases the pool and its connections. Pooled connections are closed after 60 +/// seconds idle, HTTP/2 connections are kept alive with a PING every 30 seconds, and +/// TCP keep-alive probes are sent every 30 seconds. +/// +/// To reuse an existing [`reqwest::Client`], or to set timeouts, proxies or TLS +/// options, use [`IpData::with_client`]. +/// /// # Examples /// /// ```no_run @@ -49,15 +84,35 @@ impl IpData { /// Creates a new client with a custom base URL. pub fn with_base_url(api_key: impl Into, base_url: impl Into) -> Self { - let mut headers = HeaderMap::new(); - let ua = format!("ipdata-rust/{VERSION}"); - headers.insert(USER_AGENT, HeaderValue::from_str(&ua).unwrap()); - - let client = reqwest::Client::builder() - .default_headers(headers) - .build() - .expect("failed to build HTTP client"); + Self::with_client(api_key, base_url, build_http_client()) + } + /// Creates a client that sends its requests through a caller-supplied + /// [`reqwest::Client`]. + /// + /// Use this to share one connection pool with the rest of your application, or to + /// configure timeouts, proxies, TLS roots or other options the default client does + /// not set. The client is used as given: the SDK's `User-Agent` header + /// (`ipdata-rust/`) and its keep-alive tuning are only applied by the + /// default client, so add them to your builder if you want them. `base_url` is the + /// API endpoint, normally `https://api.ipdata.co` or `https://eu-api.ipdata.co`. + /// + /// # Examples + /// + /// ```no_run + /// # fn example() -> Result<(), reqwest::Error> { + /// let http = reqwest::Client::builder() + /// .timeout(std::time::Duration::from_secs(10)) + /// .build()?; + /// let client = ipdata::IpData::with_client("your-api-key", "https://api.ipdata.co", http); + /// # Ok(()) + /// # } + /// ``` + pub fn with_client( + api_key: impl Into, + base_url: impl Into, + client: reqwest::Client, + ) -> Self { Self { client, api_key: api_key.into(), @@ -99,11 +154,7 @@ impl IpData { /// /// * `ip` - IPv4 or IPv6 address /// * `field` - Field name (e.g. `"asn"`, `"threat"`, `"currency"`) - pub async fn lookup_field( - &self, - ip: &str, - field: &str, - ) -> Result { + pub async fn lookup_field(&self, ip: &str, field: &str) -> Result { validate_ip(ip)?; let url = format!("{}/{}/{}", self.base_url, ip, field); let resp = self @@ -159,6 +210,12 @@ impl IpData { /// Performs a bulk lookup for up to 100 IP addresses. /// /// Requires a paid API key. Returns results in the same order as the input. + /// + /// The lookup is a `POST`, which the HTTP stack does not retry on its own once the + /// request has been written. If a pooled connection turns out to be dead before any + /// response byte arrives (reset, EOF or broken pipe on a reused socket), the request + /// is sent again exactly once on a fresh connection. HTTP error statuses and + /// failures while reading the response body are never retried. pub async fn bulk(&self, ips: &[&str]) -> Result> { if ips.is_empty() { return Err(Error::BulkEmpty); @@ -172,13 +229,16 @@ impl IpData { } let url = format!("{}/bulk", self.base_url); - let resp = self - .client - .post(&url) - .query(&[("api-key", &self.api_key)]) - .json(&ips) - .send() - .await?; + let resp = match self.post_bulk(&url, ips).await { + Ok(resp) => resp, + // The server or a middlebox can drop a pooled connection while it is idle + // without the client noticing until the next request fails on it. hyper + // transparently re-sends a request it had not yet written to such a + // connection; one that was already written is retried once here. The failed + // connection has been discarded, so the retry uses a fresh one. + Err(e) if is_stale_connection_error(&e) => self.post_bulk(&url, ips).await?, + Err(e) => return Err(e.into()), + }; let status = resp.status(); if !status.is_success() { @@ -189,6 +249,16 @@ impl IpData { Ok(bulk.responses) } + /// Sends the bulk `POST` once and returns the raw response. + async fn post_bulk(&self, url: &str, ips: &[&str]) -> reqwest::Result { + self.client + .post(url) + .query(&[("api-key", &self.api_key)]) + .json(&ips) + .send() + .await + } + /// Internal typed GET helper for sub-resource endpoints. async fn get_typed(&self, url: &str) -> Result { let resp = self @@ -225,6 +295,39 @@ impl IpData { } } +/// Builds the [`reqwest::Client`] used by [`IpData::new`], [`IpData::eu`] and +/// [`IpData::with_base_url`]. +/// +/// The client identifies the SDK with a `User-Agent` default header and is tuned for +/// connection reuse: HTTP/2 is negotiated through ALPN with automatic HTTP/1.1 +/// fallback, pooled connections are closed after 60 s idle, HTTP/2 connections are kept +/// alive with a PING every 30 s (10 s to answer, also while idle) and TCP keep-alive +/// probes run every 30 s. No total request timeout is set. +fn build_http_client() -> reqwest::Client { + let mut headers = HeaderMap::new(); + let ua = format!("ipdata-rust/{VERSION}"); + headers.insert(USER_AGENT, HeaderValue::from_str(&ua).unwrap()); + + reqwest::Client::builder() + .default_headers(headers) + .pool_idle_timeout(POOL_IDLE_TIMEOUT) + .tcp_keepalive(TCP_KEEPALIVE) + .http2_keep_alive_interval(HTTP2_KEEP_ALIVE_INTERVAL) + .http2_keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT) + .http2_keep_alive_while_idle(true) + .build() + .expect("failed to build HTTP client") +} + +/// Whether a `send()` failure means the request died on an established connection +/// before any response arrived: a reset, EOF or broken pipe on a reused socket. +/// +/// Connection-level failures (DNS, TCP connect, TLS, proxy) and timeouts are excluded: +/// a retry would not help, or the server may already have received the request. +fn is_stale_connection_error(e: &reqwest::Error) -> bool { + e.is_request() && !e.is_connect() && !e.is_timeout() +} + /// Validates that a string is a valid IPv4 or IPv6 address. fn validate_ip(ip: &str) -> Result<()> { ip.parse::() diff --git a/src/types.rs b/src/types.rs index 63ee6dd..c0b1e4b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -11,6 +11,20 @@ pub struct IpInfo { #[serde(default)] pub is_eu: bool, + /// Whether the IP belongs to a satellite internet provider. + /// + /// The API sends this key only when it is true; the field reads `false` when the + /// key is absent. + #[serde(default)] + pub is_satellite: bool, + + /// Whether the IP is an anycast address, announced from several locations at once. + /// + /// The API sends this key only when it is true; the field reads `false` when the + /// key is absent. + #[serde(default)] + pub is_anycast: bool, + /// City name. #[serde(default)] pub city: Option, @@ -217,29 +231,42 @@ pub struct Currency { } /// Time zone information. +/// +/// The API sends every field as `null` when it cannot determine a time zone, for +/// example for anycast addresses; such fields decode to their default (empty string or +/// `false`). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TimeZone { /// IANA time zone name (e.g. "America/New_York"). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub name: String, /// Time zone abbreviation (e.g. "EST"). - #[serde(default, rename = "abbr")] + #[serde(default, rename = "abbr", deserialize_with = "null_as_default")] pub abbreviation: String, /// UTC offset string (e.g. "-0500"). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub offset: String, /// Whether daylight saving time is active. - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub is_dst: bool, /// Current time in the time zone (ISO 8601). - #[serde(default)] + #[serde(default, deserialize_with = "null_as_default")] pub current_time: String, } +/// Deserializes an explicit JSON `null` as the field's default value. +fn null_as_default<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + /// Threat intelligence data for an IP address. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Threat { @@ -328,6 +355,25 @@ pub struct Blocklist { /// Blocklist category / type. #[serde(default, rename = "type")] pub blocklist_type: String, + + /// First day the IP was seen on this blocklist, as a `YYYY-MM-DD` date (UTC). + /// + /// Some feeds, including ipdata's own vpn feed, report no dates; the field is then + /// `None`. + #[serde(default)] + pub first_seen: Option, + + /// Most recent day the IP was seen on this blocklist, as a `YYYY-MM-DD` date (UTC). + /// + /// `None` when the feed reports no dates. + #[serde(default)] + pub last_seen: Option, + + /// Number of days the IP has been seen on this blocklist. + /// + /// `None` when the feed reports no dates. + #[serde(default)] + pub days_seen: Option, } /// Response wrapper for bulk lookups. diff --git a/tests/client.rs b/tests/client.rs new file mode 100644 index 0000000..9cb26c7 --- /dev/null +++ b/tests/client.rs @@ -0,0 +1,828 @@ +//! Integration tests for [`ipdata::IpData`] against local HTTP servers. +//! +//! Every request goes to a server started by the test itself; nothing here talks to +//! api.ipdata.co. The servers speak HTTP/1.1 only (reqwest negotiates HTTP/2 through +//! TLS ALPN alone), so these tests cover request shape, response decoding, error +//! mapping and connection reuse over HTTP/1.1 keep-alive. The HTTP/2 negotiation +//! against the real API is checked by hand and recorded in the pull request. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use ipdata::{Error, IpData}; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde_json::{json, Value}; +use wiremock::matchers::{body_json, header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const API_KEY: &str = "test-key"; +const USER_AGENT: &str = concat!("ipdata-rust/", env!("CARGO_PKG_VERSION")); + +/// Time to let hyper hand a connection back to the pool between two lookups, as +/// happens between real requests. +const BETWEEN_REQUESTS: Duration = Duration::from_millis(100); + +/// A full lookup response in today's shape, trimmed to what the tests assert. +fn ip_info_json() -> Value { + json!({ + "ip": "8.8.8.8", + "is_eu": false, + "city": null, + "country_name": "United States", + "country_code": "US", + "continent_code": "NA", + "latitude": 39.7599983215332, + "longitude": -98.5, + "asn": { + "asn": "AS15169", + "name": "Google LLC", + "domain": "about.google", + "route": "8.8.8.0/24", + "type": "hosting" + }, + "threat": { + "is_tor": false, + "is_vpn": false, + "is_datacenter": true, + "is_threat": false, + "blocklists": [], + "scores": { "vpn_score": 0, "proxy_score": 1, "threat_score": 8, "trust_score": 97 } + }, + "count": "0" + }) +} + +fn asn_json() -> Value { + json!({ + "asn": "AS15169", + "name": "Google LLC", + "domain": "about.google", + "route": "8.8.8.0/24", + "type": "hosting" + }) +} + +fn client_for(server: &MockServer) -> IpData { + IpData::with_base_url(API_KEY, server.uri()) +} + +/// Mounts a GET mock for `request_path` that checks the API key and answers `body`. +async fn mock_get(server: &MockServer, request_path: &str, body: Value) { + Mock::given(method("GET")) + .and(path(request_path)) + .and(query_param("api-key", API_KEY)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(server) + .await; +} + +#[tokio::test] +async fn lookup_sends_api_key_and_user_agent_and_decodes_the_response() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/8.8.8.8")) + .and(query_param("api-key", API_KEY)) + .and(header("user-agent", USER_AGENT)) + .respond_with(ResponseTemplate::new(200).set_body_json(ip_info_json())) + .expect(1) + .mount(&server) + .await; + + let info = client_for(&server).lookup("8.8.8.8").await.unwrap(); + + assert_eq!(info.ip, "8.8.8.8"); + assert!(!info.is_eu); + assert_eq!(info.city, None); + assert_eq!(info.country_name.as_deref(), Some("United States")); + assert_eq!(info.country_code.as_deref(), Some("US")); + assert!((info.latitude - 39.76).abs() < 0.01); + assert!((info.longitude + 98.5).abs() < 0.01); + let asn = info.asn.unwrap(); + assert_eq!(asn.asn, "AS15169"); + assert_eq!(asn.asn_type, "hosting"); + let threat = info.threat.unwrap(); + assert!(threat.is_datacenter); + assert!(!threat.is_threat); + assert!(threat.blocklists.is_empty()); + assert_eq!(threat.scores.unwrap().trust_score, 97); + assert_eq!(info.count.as_deref(), Some("0")); +} + +#[tokio::test] +async fn lookup_self_requests_the_base_url() { + let server = MockServer::start().await; + mock_get(&server, "/", ip_info_json()).await; + + let info = client_for(&server).lookup_self().await.unwrap(); + + assert_eq!(info.ip, "8.8.8.8"); +} + +#[tokio::test] +async fn lookup_fields_sends_the_comma_separated_fields_parameter() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/8.8.8.8")) + .and(query_param("api-key", API_KEY)) + .and(query_param("fields", "ip,country_name,asn")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ip": "8.8.8.8", + "country_name": "United States", + "asn": asn_json() + }))) + .expect(1) + .mount(&server) + .await; + + let info = client_for(&server) + .lookup_fields("8.8.8.8", &["ip", "country_name", "asn"]) + .await + .unwrap(); + + assert_eq!(info.ip, "8.8.8.8"); + assert_eq!(info.country_name.as_deref(), Some("United States")); + assert_eq!(info.asn.unwrap().name, "Google LLC"); + assert!(info.threat.is_none()); +} + +#[tokio::test] +async fn lookup_field_returns_the_raw_json_value() { + let server = MockServer::start().await; + mock_get(&server, "/8.8.8.8/asn", asn_json()).await; + mock_get(&server, "/8.8.8.8/is_eu", json!(false)).await; + let client = client_for(&server); + + let asn = client.lookup_field("8.8.8.8", "asn").await.unwrap(); + assert_eq!(asn["asn"], "AS15169"); + assert_eq!(asn["type"], "hosting"); + + let is_eu = client.lookup_field("8.8.8.8", "is_eu").await.unwrap(); + assert_eq!(is_eu, Value::Bool(false)); +} + +#[tokio::test] +async fn asn_decodes_the_typed_sub_resource() { + let server = MockServer::start().await; + mock_get(&server, "/8.8.8.8/asn", asn_json()).await; + + let asn = client_for(&server).asn("8.8.8.8").await.unwrap(); + + assert_eq!(asn.asn, "AS15169"); + assert_eq!(asn.name, "Google LLC"); + assert_eq!(asn.domain, "about.google"); + assert_eq!(asn.route, "8.8.8.0/24"); + assert_eq!(asn.asn_type, "hosting"); +} + +#[tokio::test] +async fn carrier_decodes_the_typed_sub_resource() { + let server = MockServer::start().await; + mock_get( + &server, + "/1.2.3.4/carrier", + json!({ "name": "T-Mobile", "mcc": "310", "mnc": "160" }), + ) + .await; + + let carrier = client_for(&server).carrier("1.2.3.4").await.unwrap(); + + assert_eq!(carrier.name, "T-Mobile"); + assert_eq!(carrier.mcc, "310"); + assert_eq!(carrier.mnc, "160"); +} + +#[tokio::test] +async fn currency_decodes_the_typed_sub_resource() { + let server = MockServer::start().await; + mock_get( + &server, + "/8.8.8.8/currency", + json!({ + "name": "US Dollar", + "code": "USD", + "symbol": "$", + "native": "$", + "plural": "US dollars" + }), + ) + .await; + + let currency = client_for(&server).currency("8.8.8.8").await.unwrap(); + + assert_eq!(currency.name, "US Dollar"); + assert_eq!(currency.code, "USD"); + assert_eq!(currency.symbol, "$"); + assert_eq!(currency.plural, "US dollars"); +} + +#[tokio::test] +async fn time_zone_decodes_the_typed_sub_resource() { + let server = MockServer::start().await; + mock_get( + &server, + "/8.8.8.8/time_zone", + json!({ + "name": "America/Chicago", + "abbr": "CDT", + "offset": "-0500", + "is_dst": true, + "current_time": "2026-11-02T10:00:00-05:00" + }), + ) + .await; + + let tz = client_for(&server).time_zone("8.8.8.8").await.unwrap(); + + assert_eq!(tz.name, "America/Chicago"); + assert_eq!(tz.abbreviation, "CDT"); + assert_eq!(tz.offset, "-0500"); + assert!(tz.is_dst); + assert_eq!(tz.current_time, "2026-11-02T10:00:00-05:00"); +} + +#[tokio::test] +async fn threat_decodes_the_typed_sub_resource() { + let server = MockServer::start().await; + mock_get( + &server, + "/2001:db8::1/threat", + json!({ + "is_tor": false, + "is_vpn": true, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": true, + "is_anonymous": true, + "is_known_attacker": false, + "is_known_abuser": false, + "is_threat": false, + "is_bogon": false, + "blocklists": [ + { "name": "Gluetun", "site": "https://github.com/qdm12/gluetun", "type": "vpn" } + ], + "scores": { "vpn_score": 100, "proxy_score": 99, "threat_score": 100, "trust_score": 0 } + }), + ) + .await; + + let threat = client_for(&server).threat("2001:db8::1").await.unwrap(); + + assert!(threat.is_vpn); + assert!(threat.is_anonymous); + assert!(!threat.is_tor); + assert_eq!(threat.blocklists.len(), 1); + assert_eq!(threat.blocklists[0].name, "Gluetun"); + assert_eq!(threat.blocklists[0].blocklist_type, "vpn"); + let scores = threat.scores.unwrap(); + assert_eq!(scores.vpn_score, 100); + assert_eq!(scores.trust_score, 0); +} + +#[tokio::test] +async fn bulk_posts_the_ip_list_and_returns_results_in_order() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/bulk")) + .and(query_param("api-key", API_KEY)) + .and(header("content-type", "application/json")) + .and(header("user-agent", USER_AGENT)) + .and(body_json(json!(["8.8.8.8", "1.1.1.1"]))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "responses": [ + { "ip": "8.8.8.8", "country_code": "US", "asn": asn_json() }, + { "ip": "1.1.1.1", "country_code": "AU" } + ] + }))) + .expect(1) + .mount(&server) + .await; + + let results = client_for(&server) + .bulk(&["8.8.8.8", "1.1.1.1"]) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].ip, "8.8.8.8"); + assert_eq!(results[0].country_code.as_deref(), Some("US")); + assert_eq!(results[0].asn.as_ref().unwrap().asn, "AS15169"); + assert_eq!(results[1].ip, "1.1.1.1"); + assert_eq!(results[1].country_code.as_deref(), Some("AU")); + assert!(results[1].asn.is_none()); +} + +#[tokio::test] +async fn api_errors_carry_the_status_and_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/8.8.8.8")) + .respond_with( + ResponseTemplate::new(401) + .set_body_json(json!({ "message": "You have not provided a valid API Key." })), + ) + .expect(1) + .mount(&server) + .await; + + let err = client_for(&server).lookup("8.8.8.8").await.unwrap_err(); + + assert!(matches!( + &err, + Error::Api { status: 401, message } if message == "You have not provided a valid API Key." + )); + assert_eq!(err.status(), Some(401)); + assert_eq!( + err.to_string(), + "API error (HTTP 401): You have not provided a valid API Key." + ); +} + +#[tokio::test] +async fn api_errors_without_a_json_body_get_a_generic_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/8.8.8.8/asn")) + .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable")) + .expect(1) + .mount(&server) + .await; + + let err = client_for(&server).asn("8.8.8.8").await.unwrap_err(); + + assert!(matches!( + &err, + Error::Api { status: 503, message } if message == "request failed with status 503" + )); +} + +#[tokio::test] +async fn an_unparseable_success_body_is_a_transport_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/8.8.8.8")) + .respond_with(ResponseTemplate::new(200).set_body_string("not json")) + .expect(1) + .mount(&server) + .await; + + let err = client_for(&server).lookup("8.8.8.8").await.unwrap_err(); + + assert!(matches!(err, Error::Http(_)), "unexpected error: {err:?}"); + assert_eq!(err.status(), None); +} + +#[tokio::test] +async fn invalid_input_is_rejected_before_any_request_is_sent() { + let server = MockServer::start().await; + let client = client_for(&server); + + assert!(matches!( + client.lookup("not-an-ip").await.unwrap_err(), + Error::InvalidIp(ip) if ip == "not-an-ip" + )); + assert!(matches!( + client.asn("300.1.1.1").await.unwrap_err(), + Error::InvalidIp(_) + )); + assert!(matches!( + client.bulk(&[]).await.unwrap_err(), + Error::BulkEmpty + )); + let too_many = vec!["8.8.8.8"; 101]; + assert!(matches!( + client.bulk(&too_many).await.unwrap_err(), + Error::BulkLimitExceeded + )); + assert!(matches!( + client.bulk(&["8.8.8.8", "nope"]).await.unwrap_err(), + Error::InvalidIp(ip) if ip == "nope" + )); + + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn a_trailing_slash_in_the_base_url_is_ignored() { + let server = MockServer::start().await; + mock_get(&server, "/8.8.8.8", ip_info_json()).await; + + let client = IpData::with_base_url(API_KEY, format!("{}/", server.uri())); + + assert_eq!(client.lookup("8.8.8.8").await.unwrap().ip, "8.8.8.8"); +} + +#[tokio::test] +async fn with_client_sends_requests_through_the_supplied_client() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/8.8.8.8")) + .and(query_param("api-key", API_KEY)) + .and(header("x-ipdata-test", "injected")) + .respond_with(ResponseTemplate::new(200).set_body_json(ip_info_json())) + .expect(1) + .mount(&server) + .await; + + let mut headers = HeaderMap::new(); + headers.insert("x-ipdata-test", HeaderValue::from_static("injected")); + let http = reqwest::Client::builder() + .default_headers(headers) + .build() + .unwrap(); + let client = IpData::with_client(API_KEY, server.uri(), http); + + let info = client.lookup("8.8.8.8").await.unwrap(); + + assert_eq!(info.ip, "8.8.8.8"); +} + +#[test] +fn ipdata_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} + +#[tokio::test] +async fn sequential_lookups_on_one_client_reuse_one_connection() { + let server = ScriptedServer::start(vec![Step::Respond; 3], ip_info_json().to_string()); + let client = IpData::with_base_url(API_KEY, &server.uri); + + for _ in 0..3 { + let info = client.lookup("8.8.8.8").await.unwrap(); + assert_eq!(info.ip, "8.8.8.8"); + tokio::time::sleep(BETWEEN_REQUESTS).await; + } + + let requests = server.requests(); + assert_eq!(requests.len(), 3); + assert!(requests + .iter() + .all(|r| r.request_line == "GET /8.8.8.8?api-key=test-key HTTP/1.1")); + assert_eq!( + server.connections(), + 1, + "every lookup should reuse the first connection" + ); +} + +#[tokio::test] +async fn clones_share_the_connection_pool() { + let server = ScriptedServer::start(vec![Step::Respond; 2], ip_info_json().to_string()); + let client = IpData::with_base_url(API_KEY, &server.uri); + let clone = client.clone(); + + client.lookup("8.8.8.8").await.unwrap(); + tokio::time::sleep(BETWEEN_REQUESTS).await; + clone.lookup("8.8.8.8").await.unwrap(); + + assert_eq!(server.requests().len(), 2); + assert_eq!(server.connections(), 1); +} + +#[tokio::test] +async fn a_connection_closed_by_the_server_while_idle_is_replaced() { + let server = ScriptedServer::start( + vec![Step::RespondAndClose, Step::Respond], + ip_info_json().to_string(), + ); + let client = IpData::with_base_url(API_KEY, &server.uri); + + client.lookup("8.8.8.8").await.unwrap(); + tokio::time::sleep(BETWEEN_REQUESTS).await; + client.lookup("8.8.8.8").await.unwrap(); + + assert_eq!(server.requests().len(), 2); + assert_eq!(server.connections(), 2); +} + +fn bulk_json() -> String { + json!({ "responses": [{ "ip": "8.8.8.8" }, { "ip": "1.1.1.1" }] }).to_string() +} + +#[tokio::test] +async fn bulk_is_retried_once_when_a_pooled_connection_dies_before_the_response() { + let server = ScriptedServer::start( + vec![Step::Respond, Step::Hangup, Step::Respond], + bulk_json(), + ); + let client = IpData::with_base_url(API_KEY, &server.uri); + + // First request: served normally, connection stays in the pool. + client.bulk(&["8.8.8.8", "1.1.1.1"]).await.unwrap(); + tokio::time::sleep(BETWEEN_REQUESTS).await; + // Second request: the server reads it from the pooled connection and closes + // without answering; the SDK sends it again on a fresh connection. + let results = client.bulk(&["8.8.8.8", "1.1.1.1"]).await.unwrap(); + + assert_eq!(results.len(), 2); + assert_eq!(results[1].ip, "1.1.1.1"); + let requests = server.requests(); + assert_eq!(requests.len(), 3); + assert!(requests + .iter() + .all(|r| r.request_line == "POST /bulk?api-key=test-key HTTP/1.1")); + assert_eq!(requests[2].body, r#"["8.8.8.8","1.1.1.1"]"#); + assert_eq!(requests[1].body, requests[2].body); + assert_eq!(server.connections(), 2); +} + +#[tokio::test] +async fn bulk_is_retried_at_most_once() { + let server = + ScriptedServer::start(vec![Step::Respond, Step::Hangup, Step::Hangup], bulk_json()); + let client = IpData::with_base_url(API_KEY, &server.uri); + + client.bulk(&["8.8.8.8"]).await.unwrap(); + tokio::time::sleep(BETWEEN_REQUESTS).await; + let err = client.bulk(&["8.8.8.8"]).await.unwrap_err(); + + assert!(matches!(err, Error::Http(_)), "unexpected error: {err:?}"); + assert_eq!(server.requests().len(), 3); + assert_eq!(server.connections(), 2); +} + +#[tokio::test] +async fn bulk_is_not_retried_on_an_http_error_status() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/bulk")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ "message": "boom" }))) + .expect(1) + .mount(&server) + .await; + + let err = client_for(&server).bulk(&["8.8.8.8"]).await.unwrap_err(); + + assert!(matches!(&err, Error::Api { status: 500, message } if message == "boom")); + assert_eq!(server.received_requests().await.unwrap().len(), 1); +} + +/// What the scripted server does with the next request it receives. +#[derive(Clone, Copy)] +enum Step { + /// Answer with `200 OK` and keep the connection open. + Respond, + /// Answer with `200 OK`, then close the connection. + RespondAndClose, + /// Read the request, then close the connection without answering. + Hangup, +} + +/// One request as seen by the scripted server. +#[derive(Clone, Debug)] +struct Recorded { + /// The request line, e.g. `GET /8.8.8.8?api-key=test-key HTTP/1.1`. + request_line: String, + /// The request body (empty for GET). + body: String, +} + +/// A plain HTTP/1.1 keep-alive server on a std thread that follows a script request +/// by request and records every connection and request it sees. wiremock cannot tell +/// which TCP connection carried a request, so connection reuse and stale-connection +/// handling are tested against this instead. +struct ScriptedServer { + uri: String, + connections: Arc, + requests: Arc>>, +} + +impl ScriptedServer { + fn start(script: Vec, body: String) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let uri = format!("http://{}", listener.local_addr().unwrap()); + let connections = Arc::new(AtomicUsize::new(0)); + let requests = Arc::new(Mutex::new(Vec::new())); + let server = Self { + uri, + connections: Arc::clone(&connections), + requests: Arc::clone(&requests), + }; + + std::thread::spawn(move || { + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ); + let mut script = script.into_iter(); + 'connections: loop { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + connections.fetch_add(1, Ordering::SeqCst); + // A stalled read means the client went elsewhere; serve the next + // connection instead of hanging the test. + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut buf = Vec::new(); + loop { + let Some(request) = read_request(&mut stream, &mut buf) else { + continue 'connections; + }; + requests.lock().unwrap().push(request); + let Some(step) = script.next() else { + return; + }; + match step { + Step::Hangup => continue 'connections, + Step::Respond | Step::RespondAndClose => { + if stream.write_all(response.as_bytes()).is_err() { + continue 'connections; + } + } + } + if script.len() == 0 { + return; + } + if matches!(step, Step::RespondAndClose) { + continue 'connections; + } + } + } + }); + + server + } + + fn connections(&self) -> usize { + self.connections.load(Ordering::SeqCst) + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +/// Reads one HTTP/1.1 request (head plus `Content-Length` body) from `stream`, using +/// `buf` to carry over bytes between requests. Returns `None` when the connection is +/// closed or idle for too long. +fn read_request(stream: &mut TcpStream, buf: &mut Vec) -> Option { + let mut chunk = [0u8; 4096]; + let head_len = loop { + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break pos + 4; + } + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return None, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + }; + let head = String::from_utf8_lossy(&buf[..head_len]).into_owned(); + let content_length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + while buf.len() < head_len + content_length { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return None, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + } + let body = String::from_utf8_lossy(&buf[head_len..head_len + content_length]).into_owned(); + buf.drain(..head_len + content_length); + Some(Recorded { + request_line: head.lines().next().unwrap_or_default().to_string(), + body, + }) +} + +// --- New response fields: is_satellite, is_anycast and dated blocklist entries ------ + +const SATELLITE_FIXTURE: &str = include_str!("fixtures/satellite.json"); +const ANYCAST_FIXTURE: &str = include_str!("fixtures/anycast.json"); +const VPN_EXIT_FIXTURE: &str = include_str!("fixtures/vpn_exit.json"); + +fn fixture(json: &str) -> Value { + serde_json::from_str(json).unwrap() +} + +#[tokio::test] +async fn lookup_decodes_is_satellite_and_is_anycast() { + let server = MockServer::start().await; + mock_get(&server, "/98.97.34.0", fixture(SATELLITE_FIXTURE)).await; + mock_get(&server, "/8.8.8.8", fixture(ANYCAST_FIXTURE)).await; + let client = client_for(&server); + + let satellite = client.lookup("98.97.34.0").await.unwrap(); + assert!(satellite.is_satellite); + assert!(!satellite.is_anycast); + + let anycast = client.lookup("8.8.8.8").await.unwrap(); + assert!(anycast.is_anycast); + assert!(!anycast.is_satellite); + let entry = &anycast.threat.unwrap().blocklists[0]; + assert_eq!(entry.first_seen.as_deref(), Some("2026-11-02")); + assert_eq!(entry.last_seen.as_deref(), Some("2026-11-02")); + assert_eq!(entry.days_seen, Some(1)); +} + +#[tokio::test] +async fn lookup_fields_accepts_the_new_field_names() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/8.8.8.8")) + .and(query_param("api-key", API_KEY)) + .and(query_param("fields", "is_satellite,is_anycast")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "is_anycast": true }))) + .expect(1) + .mount(&server) + .await; + + let info = client_for(&server) + .lookup_fields("8.8.8.8", &["is_satellite", "is_anycast"]) + .await + .unwrap(); + + assert!(info.is_anycast); + assert!(!info.is_satellite); +} + +#[tokio::test] +async fn lookup_field_fetches_the_new_flags_on_their_own() { + let server = MockServer::start().await; + mock_get(&server, "/8.8.8.8/is_anycast", json!(true)).await; + mock_get(&server, "/98.97.34.0/is_satellite", json!(true)).await; + let client = client_for(&server); + + let is_anycast = client.lookup_field("8.8.8.8", "is_anycast").await.unwrap(); + assert_eq!(is_anycast, Value::Bool(true)); + let is_satellite = client + .lookup_field("98.97.34.0", "is_satellite") + .await + .unwrap(); + assert_eq!(is_satellite, Value::Bool(true)); +} + +#[tokio::test] +async fn threat_decodes_blocklist_entries_with_and_without_dates() { + let server = MockServer::start().await; + mock_get( + &server, + "/187.13.250.1/threat", + fixture(VPN_EXIT_FIXTURE)["threat"].clone(), + ) + .await; + + let threat = client_for(&server).threat("187.13.250.1").await.unwrap(); + + assert!(threat.is_vpn); + assert_eq!(threat.blocklists.len(), 6); + assert_eq!(threat.blocklists[0].name, "Gluetun"); + assert_eq!(threat.blocklists[0].days_seen, Some(2)); + let undated = &threat.blocklists[2]; + assert_eq!(undated.name, "ipdata"); + assert_eq!(undated.blocklist_type, "vpn"); + assert_eq!(undated.first_seen, None); + assert_eq!(undated.last_seen, None); + assert_eq!(undated.days_seen, None); +} + +#[tokio::test] +async fn bulk_items_carry_the_new_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/bulk")) + .and(query_param("api-key", API_KEY)) + .and(body_json(json!(["98.97.34.0", "8.8.8.8", "187.13.250.1"]))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "responses": [ + fixture(SATELLITE_FIXTURE), + fixture(ANYCAST_FIXTURE), + fixture(VPN_EXIT_FIXTURE) + ] + }))) + .expect(1) + .mount(&server) + .await; + + let results = client_for(&server) + .bulk(&["98.97.34.0", "8.8.8.8", "187.13.250.1"]) + .await + .unwrap(); + + assert_eq!(results.len(), 3); + assert!(results[0].is_satellite); + assert!(!results[0].is_anycast); + assert!(results[1].is_anycast); + assert!(!results[1].is_satellite); + assert!(!results[2].is_satellite); + assert!(!results[2].is_anycast); + let threat = results[2].threat.as_ref().unwrap(); + assert!(threat.is_vpn); + assert_eq!(threat.blocklists[2].first_seen, None); + assert_eq!(threat.blocklists[3].name, "NordVPN"); + assert_eq!(threat.blocklists[3].days_seen, Some(2)); +} diff --git a/tests/fixtures/anycast.json b/tests/fixtures/anycast.json new file mode 100644 index 0000000..67424bb --- /dev/null +++ b/tests/fixtures/anycast.json @@ -0,0 +1,35 @@ +{ + "ip": "8.8.8.8", + "is_anycast": true, + "is_eu": false, + "city": null, + "region": null, + "region_code": null, + "region_type": null, + "country_name": "United States", + "country_code": "US", + "continent_name": "North America", + "continent_code": "NA", + "latitude": 39.7599983215332, + "longitude": -98.5, + "postal": null, + "calling_code": "1", + "flag": "https://ipdata.co/flags/us.png", + "emoji_flag": "🇺🇸", + "emoji_unicode": "U+1F1FA U+1F1F8", + "asn": { "asn": "AS15169", "name": "Google LLC", "domain": "about.google", "route": "8.8.8.0/24", "type": "hosting" }, + "company": { "name": "Google LLC", "domain": "about.google", "network": "8.8.8.0/24", "type": "hosting" }, + "languages": [ { "name": "English", "native": "English", "code": "en" } ], + "currency": { "name": "US Dollar", "code": "USD", "symbol": "$", "native": "$", "plural": "US dollars" }, + "time_zone": { "name": null, "abbr": null, "offset": null, "is_dst": null, "current_time": null }, + "threat": { + "is_tor": false, "is_vpn": false, "is_icloud_relay": false, "is_proxy": false, "is_datacenter": true, + "is_anonymous": false, "is_known_attacker": false, "is_known_abuser": false, "is_threat": false, "is_bogon": false, + "blocklists": [ + { "days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "cloud" }, + { "days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "open_resolvers" } + ], + "scores": { "vpn_score": 100, "proxy_score": 93, "threat_score": 100, "trust_score": 2 } + }, + "count": "0" +} diff --git a/tests/fixtures/satellite.json b/tests/fixtures/satellite.json new file mode 100644 index 0000000..c0abd6f --- /dev/null +++ b/tests/fixtures/satellite.json @@ -0,0 +1,10 @@ +{ + "ip": "98.97.34.0", + "is_satellite": true, + "is_eu": false, + "city": "Seattle", + "country_code": "US", + "asn": { "asn": "AS14593", "name": "Space Exploration Technologies Corporation", "domain": "spacex.com", "route": "98.97.32.0/21", "type": "business" }, + "threat": { "is_tor": false, "is_vpn": false, "is_icloud_relay": false, "is_proxy": false, "is_datacenter": false, "is_anonymous": false, "is_known_attacker": false, "is_known_abuser": false, "is_threat": false, "is_bogon": false, "blocklists": [], "scores": { "vpn_score": 0, "proxy_score": 1, "threat_score": 8, "trust_score": 97 } }, + "count": "0" +} diff --git a/tests/fixtures/vpn_exit.json b/tests/fixtures/vpn_exit.json new file mode 100644 index 0000000..f4d1485 --- /dev/null +++ b/tests/fixtures/vpn_exit.json @@ -0,0 +1,18 @@ +{ + "ip": "187.13.250.1", + "is_eu": false, + "threat": { + "is_tor": false, "is_vpn": true, "is_icloud_relay": false, "is_proxy": false, "is_datacenter": true, + "is_anonymous": true, "is_known_attacker": true, "is_known_abuser": true, "is_threat": true, "is_bogon": false, + "blocklists": [ + { "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "Gluetun", "site": "https://github.com/qdm12/gluetun", "type": "vpn" }, + { "days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "cloud" }, + { "name": "ipdata", "site": "https://ipdata.co", "type": "vpn" }, + { "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "NordVPN", "site": "https://nordvpn.com", "type": "vpn" }, + { "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "Wikimedia Global Blocklist", "site": "https://wikimedia.org", "type": "general" }, + { "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "X4BNet", "site": "https://github.com/X4BNet/lists_vpn", "type": "vpn" } + ], + "scores": { "vpn_score": 100, "proxy_score": 99, "threat_score": 100, "trust_score": 0 } + }, + "count": "0" +} diff --git a/tests/types.rs b/tests/types.rs new file mode 100644 index 0000000..5f3457c --- /dev/null +++ b/tests/types.rs @@ -0,0 +1,166 @@ +//! Decoding tests for the response types, built from API samples in `tests/fixtures`: +//! a satellite ISP (`is_satellite`), an anycast address with dated blocklist entries +//! (`is_anycast`) and a VPN exit whose blocklist includes an entry without dates. + +use ipdata::{Blocklist, IpInfo, Threat, TimeZone}; +use serde_json::{json, Value}; + +const SATELLITE: &str = include_str!("fixtures/satellite.json"); +const ANYCAST: &str = include_str!("fixtures/anycast.json"); +const VPN_EXIT: &str = include_str!("fixtures/vpn_exit.json"); + +#[test] +fn satellite_sample_sets_is_satellite_and_reads_is_anycast_as_false() { + let info: IpInfo = serde_json::from_str(SATELLITE).unwrap(); + + assert_eq!(info.ip, "98.97.34.0"); + assert!(info.is_satellite); + assert!(!info.is_anycast); + assert!(!info.is_eu); + assert_eq!(info.city.as_deref(), Some("Seattle")); + assert_eq!( + info.asn.unwrap().name, + "Space Exploration Technologies Corporation" + ); + assert!(info.threat.unwrap().blocklists.is_empty()); +} + +#[test] +fn anycast_sample_sets_is_anycast_and_decodes_dated_blocklist_entries() { + let info: IpInfo = serde_json::from_str(ANYCAST).unwrap(); + + assert_eq!(info.ip, "8.8.8.8"); + assert!(info.is_anycast); + assert!(!info.is_satellite); + assert_eq!(info.city, None); + assert_eq!(info.country_code.as_deref(), Some("US")); + assert_eq!(info.languages.len(), 1); + assert_eq!(info.currency.unwrap().code, "USD"); + + let threat = info.threat.unwrap(); + assert!(threat.is_datacenter); + assert_eq!(threat.blocklists.len(), 2); + let entry = &threat.blocklists[0]; + assert_eq!(entry.name, "ipdata"); + assert_eq!(entry.site, "https://ipdata.co"); + assert_eq!(entry.blocklist_type, "cloud"); + assert_eq!(entry.first_seen.as_deref(), Some("2026-11-02")); + assert_eq!(entry.last_seen.as_deref(), Some("2026-11-02")); + assert_eq!(entry.days_seen, Some(1)); + assert_eq!(threat.blocklists[1].blocklist_type, "open_resolvers"); + assert_eq!(threat.scores.unwrap().trust_score, 2); +} + +#[test] +fn anycast_sample_decodes_a_time_zone_of_nulls_to_defaults() { + let info: IpInfo = serde_json::from_str(ANYCAST).unwrap(); + + let tz = info.time_zone.unwrap(); + assert_eq!(tz.name, ""); + assert_eq!(tz.abbreviation, ""); + assert_eq!(tz.offset, ""); + assert!(!tz.is_dst); + assert_eq!(tz.current_time, ""); + + let tz: TimeZone = serde_json::from_value(json!({ + "name": "America/Chicago", + "abbr": "CDT", + "offset": "-0500", + "is_dst": true, + "current_time": "2026-11-02T10:00:00-05:00" + })) + .unwrap(); + assert_eq!(tz.name, "America/Chicago"); + assert_eq!(tz.abbreviation, "CDT"); + assert!(tz.is_dst); +} + +#[test] +fn vpn_exit_sample_reads_both_flags_as_false_and_tolerates_undated_entries() { + let info: IpInfo = serde_json::from_str(VPN_EXIT).unwrap(); + + assert_eq!(info.ip, "187.13.250.1"); + assert!(!info.is_satellite); + assert!(!info.is_anycast); + + let threat = info.threat.unwrap(); + assert!(threat.is_vpn); + assert!(threat.is_threat); + assert_eq!(threat.blocklists.len(), 6); + + let dated = &threat.blocklists[0]; + assert_eq!(dated.name, "Gluetun"); + assert_eq!(dated.first_seen.as_deref(), Some("2026-10-31")); + assert_eq!(dated.last_seen.as_deref(), Some("2026-11-02")); + assert_eq!(dated.days_seen, Some(2)); + + let undated = &threat.blocklists[2]; + assert_eq!(undated.name, "ipdata"); + assert_eq!(undated.site, "https://ipdata.co"); + assert_eq!(undated.blocklist_type, "vpn"); + assert_eq!(undated.first_seen, None); + assert_eq!(undated.last_seen, None); + assert_eq!(undated.days_seen, None); + + assert_eq!(threat.blocklists[5].name, "X4BNet"); +} + +#[test] +fn threat_and_blocklist_decode_on_their_own() { + let sample: Value = serde_json::from_str(VPN_EXIT).unwrap(); + let threat: Threat = serde_json::from_value(sample["threat"].clone()).unwrap(); + assert!(threat.is_known_attacker); + assert_eq!(threat.blocklists[3].name, "NordVPN"); + assert_eq!(threat.blocklists[3].days_seen, Some(2)); + + let entry: Blocklist = serde_json::from_value( + json!({ "name": "ipdata", "site": "https://ipdata.co", "type": "vpn" }), + ) + .unwrap(); + assert_eq!(entry.blocklist_type, "vpn"); + assert_eq!(entry.first_seen, None); + assert_eq!(entry.last_seen, None); + assert_eq!(entry.days_seen, None); +} + +#[test] +fn flags_default_to_false_when_the_keys_are_absent() { + let info: IpInfo = serde_json::from_value(json!({ "ip": "1.1.1.1", "is_eu": false })).unwrap(); + assert!(!info.is_satellite); + assert!(!info.is_anycast); + + let info = IpInfo::default(); + assert!(!info.is_satellite); + assert!(!info.is_anycast); +} + +#[test] +fn unknown_keys_are_ignored_at_every_level() { + let mut sample: Value = serde_json::from_str(ANYCAST).unwrap(); + sample["brand_new_field"] = json!({ "nested": true }); + sample["threat"]["brand_new_flag"] = json!(true); + sample["threat"]["blocklists"][0]["brand_new_detail"] = json!("x"); + sample["asn"]["brand_new_detail"] = json!(1); + + let info: IpInfo = serde_json::from_value(sample).unwrap(); + + assert!(info.is_anycast); + assert_eq!(info.threat.unwrap().blocklists[0].days_seen, Some(1)); +} + +#[test] +fn new_fields_round_trip_through_serialization() { + let info: IpInfo = serde_json::from_str(ANYCAST).unwrap(); + + let value = serde_json::to_value(&info).unwrap(); + + assert_eq!(value["is_anycast"], json!(true)); + assert_eq!(value["is_satellite"], json!(false)); + assert_eq!( + value["threat"]["blocklists"][0]["first_seen"], + json!("2026-11-02") + ); + assert_eq!(value["threat"]["blocklists"][0]["days_seen"], json!(1)); + let again: IpInfo = serde_json::from_value(value).unwrap(); + assert!(again.is_anycast); +}