ipdatainfo 0.1.0

Official Rust client for the ipdata.info IP geolocation, ASN, and threat-intelligence API
Documentation
//! Blocking HTTP client for the ipdata.info API.

use std::time::Duration;

use serde::de::DeserializeOwned;

use crate::error::{api_error_message, Error};
use crate::models::{AsnBrief, GeoInfo, IpInfo, ThreatMatch};

/// Free tier host: 50 requests/min, no API key required.
pub const DEFAULT_BASE_URL: &str = "https://ipdata.info";
/// Paid tier host, used automatically once an API key is configured.
pub const PRO_BASE_URL: &str = "https://pro.ipdata.info";

const VERSION: &str = env!("CARGO_PKG_VERSION");
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);

/// Builder for [`Client`]. Use [`Client::builder`] to obtain one.
pub struct ClientBuilder {
    api_key: Option<String>,
    base_url: Option<String>,
    timeout: Duration,
}

impl ClientBuilder {
    fn new() -> Self {
        Self {
            api_key: None,
            base_url: None,
            timeout: DEFAULT_TIMEOUT,
        }
    }

    /// Sets the API key, sent as the `X-Api-Key` header. Unless a base URL is
    /// set explicitly, this also switches the default host to the pro tier.
    pub fn api_key(mut self, key: impl Into<String>) -> Self {
        self.api_key = Some(key.into());
        self
    }

    /// Overrides the API host (no trailing slash), e.g. for testing against a
    /// mock server.
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = Some(url.into());
        self
    }

    /// Overrides the request timeout (default: 10 seconds).
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Builds the [`Client`].
    pub fn build(self) -> Client {
        let base_url = self.base_url.unwrap_or_else(|| {
            if self.api_key.is_some() {
                PRO_BASE_URL.to_string()
            } else {
                DEFAULT_BASE_URL.to_string()
            }
        });
        let agent = ureq::AgentBuilder::new()
            .timeout(self.timeout)
            .user_agent(&format!("ipdata-rust/{VERSION}"))
            .build();
        Client {
            api_key: self.api_key,
            base_url,
            agent,
        }
    }
}

impl Default for ClientBuilder {
    fn default() -> Self {
        Self::new()
    }
}

/// Client for the ipdata.info IP geolocation, ASN, and threat-intelligence API.
///
/// Build one with [`Client::new`] (free tier) or [`Client::builder`] (to set
/// an API key, custom base URL, or timeout).
pub struct Client {
    api_key: Option<String>,
    base_url: String,
    agent: ureq::Agent,
}

impl Client {
    /// Creates a client targeting the free tier with the default 10s timeout.
    pub fn new() -> Self {
        ClientBuilder::new().build()
    }

    /// Returns a [`ClientBuilder`] for configuring an API key, base URL, or
    /// timeout.
    pub fn builder() -> ClientBuilder {
        ClientBuilder::new()
    }

    /// Returns the base URL this client sends requests to.
    pub fn base_url(&self) -> &str {
        &self.base_url
    }

    /// Returns the full geolocation record for `ip`. Pass `""` for the
    /// caller's own IP.
    pub fn lookup(&self, ip: &str) -> Result<IpInfo, Error> {
        self.get(&format!("/json/{}", encode_segment(ip)))
    }

    /// Returns the compact geo subset for `ip`.
    pub fn geo(&self, ip: &str) -> Result<GeoInfo, Error> {
        self.get(&format!("/api/v1/{}/geo", encode_segment(ip)))
    }

    /// Returns the compact ASN subset for `ip`.
    pub fn asn(&self, ip: &str) -> Result<AsnBrief, Error> {
        self.get(&format!("/api/v1/{}/asn", encode_segment(ip)))
    }

    /// Looks up many IPs in a single request. Requires an API key (paid
    /// tier); anonymous requests receive `403`.
    pub fn batch(&self, ips: &[&str]) -> Result<Vec<IpInfo>, Error> {
        self.post("/api/v1/batch", ips)
    }

    /// Returns the detailed ASN record (prefixes, peering) as a raw JSON
    /// value; the shape is large and only loosely specified.
    pub fn asn_detail(&self, number: u32) -> Result<serde_json::Value, Error> {
        self.get(&format!("/api/v1/asn/{number}"))
    }

    /// Returns the ASN whois history as a raw JSON value.
    pub fn asn_whois_history(&self, number: u32) -> Result<serde_json::Value, Error> {
        self.get(&format!("/api/v1/asn/{number}/whois-history"))
    }

    /// Returns the ASN change feed as a raw JSON value.
    pub fn asn_changes(&self) -> Result<serde_json::Value, Error> {
        self.get("/api/v1/asn-changes")
    }

    /// Looks up a domain in the threat-intel store.
    pub fn threat_domain(&self, domain: &str) -> Result<ThreatMatch, Error> {
        self.get(&format!("/api/v1/threat/domain/{}", encode_segment(domain)))
    }

    /// Looks up a file hash (md5/sha1/sha256) in the threat-intel store.
    pub fn threat_hash(&self, hash: &str) -> Result<ThreatMatch, Error> {
        self.get(&format!("/api/v1/threat/hash/{}", encode_segment(hash)))
    }

    /// Looks up a URL in the threat-intel store (the server falls back to the
    /// URL's domain on a miss).
    pub fn threat_url(&self, url: &str) -> Result<ThreatMatch, Error> {
        let full = format!("{}{}", self.base_url, "/api/v1/threat/url");
        let mut req = self.agent.get(&full).query("u", url);
        if let Some(key) = &self.api_key {
            req = req.set("X-Api-Key", key);
        }
        self.send(req)
    }

    fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
        let url = format!("{}{}", self.base_url, path);
        let mut req = self.agent.get(&url);
        if let Some(key) = &self.api_key {
            req = req.set("X-Api-Key", key);
        }
        self.send(req)
    }

    fn post<T: DeserializeOwned>(&self, path: &str, body: &[&str]) -> Result<T, Error> {
        let url = format!("{}{}", self.base_url, path);
        let mut req = self.agent.post(&url);
        if let Some(key) = &self.api_key {
            req = req.set("X-Api-Key", key);
        }
        match req.send_json(serde_json::json!(body)) {
            Ok(resp) => decode(resp),
            Err(e) => Err(map_ureq_error(e)),
        }
    }

    fn send<T: DeserializeOwned>(&self, req: ureq::Request) -> Result<T, Error> {
        match req.call() {
            Ok(resp) => decode(resp),
            Err(e) => Err(map_ureq_error(e)),
        }
    }
}

impl Default for Client {
    fn default() -> Self {
        Self::new()
    }
}

fn decode<T: DeserializeOwned>(resp: ureq::Response) -> Result<T, Error> {
    resp.into_json::<T>()
        .map_err(|e| Error::Decode(e.to_string()))
}

fn map_ureq_error(e: ureq::Error) -> Error {
    match e {
        ureq::Error::Status(status, resp) => {
            let body = resp.into_string().unwrap_or_default();
            Error::Api {
                status,
                message: api_error_message(&body),
            }
        }
        ureq::Error::Transport(t) => Error::Transport(t.to_string()),
    }
}

/// Minimal percent-encoding for a single path segment (IP address, domain, or
/// hash). Only escapes characters that are unsafe in a URL path; alphanumerics
/// and the common IP/domain/hash punctuation (`.`, `:`, `-`, `_`) pass through
/// unchanged.
fn encode_segment(segment: &str) -> String {
    let mut out = String::with_capacity(segment.len());
    for byte in segment.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b':' | b'-' | b'_' => {
                out.push(byte as char)
            }
            _ => out.push_str(&format!("%{byte:02X}")),
        }
    }
    out
}