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
6 changes: 6 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ jobs:
- 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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ The main response type is `IpInfo` which includes:
- `days_seen`: number of days the IP has been seen on the list
- **Languages**: `languages` (vec of `Language` with `name`, `native`, `code`)

All response structs ignore unknown keys and decode a missing key and an explicit `null` alike: `Option` fields read `None`, every other field reads its default (`""`, `false`, `0`, `0.0` or an empty list), so a `null` anywhere in a response does not fail the lookup.

## License

MIT
73 changes: 23 additions & 50 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,54 +157,42 @@ impl IpData {
pub async fn lookup_field(&self, ip: &str, field: &str) -> Result<serde_json::Value> {
validate_ip(ip)?;
let url = format!("{}/{}/{}", self.base_url, ip, field);
let resp = self
.client
.get(&url)
.query(&[("api-key", &self.api_key)])
.send()
.await?;

let status = resp.status();
if !status.is_success() {
return Err(parse_error(status.as_u16(), resp).await);
}

Ok(resp.json().await?)
self.get(&url, &[]).await
}

/// Returns ASN (Autonomous System Number) data for an IP address.
pub async fn asn(&self, ip: &str) -> Result<Asn> {
validate_ip(ip)?;
let url = format!("{}/{}/asn", self.base_url, ip);
self.get_typed(&url).await
self.get(&url, &[]).await
}

/// Returns mobile carrier data for an IP address.
pub async fn carrier(&self, ip: &str) -> Result<Carrier> {
validate_ip(ip)?;
let url = format!("{}/{}/carrier", self.base_url, ip);
self.get_typed(&url).await
self.get(&url, &[]).await
}

/// Returns currency data for an IP address.
pub async fn currency(&self, ip: &str) -> Result<Currency> {
validate_ip(ip)?;
let url = format!("{}/{}/currency", self.base_url, ip);
self.get_typed(&url).await
self.get(&url, &[]).await
}

/// Returns time zone data for an IP address.
pub async fn time_zone(&self, ip: &str) -> Result<TimeZone> {
validate_ip(ip)?;
let url = format!("{}/{}/time_zone", self.base_url, ip);
self.get_typed(&url).await
self.get(&url, &[]).await
}

/// Returns threat intelligence data for an IP address.
pub async fn threat(&self, ip: &str) -> Result<Threat> {
validate_ip(ip)?;
let url = format!("{}/{}/threat", self.base_url, ip);
self.get_typed(&url).await
self.get(&url, &[]).await
}

/// Performs a bulk lookup for up to 100 IP addresses.
Expand Down Expand Up @@ -240,12 +228,7 @@ impl IpData {
Err(e) => return Err(e.into()),
};

let status = resp.status();
if !status.is_success() {
return Err(parse_error(status.as_u16(), resp).await);
}

let bulk: BulkResponse = resp.json().await?;
let bulk: BulkResponse = decode_response(resp).await?;
Ok(bulk.responses)
}

Expand All @@ -259,39 +242,18 @@ impl IpData {
.await
}

/// Internal typed GET helper for sub-resource endpoints.
async fn get_typed<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
let resp = self
.client
.get(url)
.query(&[("api-key", &self.api_key)])
.send()
.await?;

let status = resp.status();
if !status.is_success() {
return Err(parse_error(status.as_u16(), resp).await);
}

Ok(resp.json().await?)
}

/// Internal GET helper for IP lookups with optional field filtering.
async fn get(&self, url: &str, fields: &[&str]) -> Result<IpInfo> {
/// Internal GET helper shared by the full lookups, the raw single-field lookup and
/// the typed sub-resources: sends the API key and, when `fields` is not empty, a
/// comma-separated `fields` filter, then decodes the JSON body into `T`.
async fn get<T: serde::de::DeserializeOwned>(&self, url: &str, fields: &[&str]) -> Result<T> {
let mut req = self.client.get(url).query(&[("api-key", &self.api_key)]);

if !fields.is_empty() {
let fields_str = fields.join(",");
req = req.query(&[("fields", &fields_str)]);
}

let resp = req.send().await?;
let status = resp.status();
if !status.is_success() {
return Err(parse_error(status.as_u16(), resp).await);
}

Ok(resp.json().await?)
decode_response(req.send().await?).await
}
}

Expand Down Expand Up @@ -335,6 +297,17 @@ fn validate_ip(ip: &str) -> Result<()> {
.map_err(|_| Error::InvalidIp(ip.to_string()))
}

/// Decodes the JSON body of a successful response into `T`. A non-success status
/// becomes [`Error::Api`] with the message from the response body.
async fn decode_response<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
let status = resp.status();
if !status.is_success() {
return Err(parse_error(status.as_u16(), resp).await);
}

Ok(resp.json().await?)
}

/// Parses an error response from the API.
async fn parse_error(status: u16, resp: reqwest::Response) -> Error {
let message = match resp.json::<ApiErrorResponse>().await {
Expand Down
7 changes: 7 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
//! # Ok(())
//! # }
//! ```
//!
//! ## Response types
//!
//! [`IpInfo`] and the nested types ([`Asn`], [`Threat`], [`TimeZone`] and the others)
//! ignore unknown keys and decode a missing key and an explicit JSON `null` the same
//! way: `Option` fields read `None`, every other field reads its default (`""`, `false`,
//! `0`, `0.0` or an empty list). A `null` anywhere in a response never fails a lookup.

mod client;
mod error;
Expand Down
Loading
Loading