zipcodestack 0.1.2

Idiomatic Rust client for the zipcodestack.com API (status, search, distance)
Documentation
//! Idiomatic Rust client for the zipcodestack.com API.
//!
//! This crate provides a small, async wrapper around the public API at
//! https://zipcodestack.com/.
//! It includes helpers for API status, zip code search, and computing distances
//! between two zip codes.
//!
//! The client supports authenticating either via `apikey: <API_KEY>` header
//! or by passing the API key as an `apikey` query parameter, to match the docs' guidance
//! that authentication can be done using headers or GET parameters.
//!
//! Example:
//!
//! ```no_run
//! use zipcodestack::{ZipCodeStackClient, AuthMethod, DistanceUnit};
//!
//! # #[tokio::main]
//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = ZipCodeStackClient::builder("YOUR_API_KEY")
//!     .with_auth_method(AuthMethod::Header)
//!     .build();
//!
//! let status = client.status().await?;
//! println!("API up? {}", status.up.unwrap_or(true));
//!
//! let search = client.search_zip("90210", Some("US")).await?;
//! println!("Found: {} {}", search.city.unwrap_or_default(), search.state_code.unwrap_or_default());
//!
//! let distance = client.distance_between("90210", "10001", Some(DistanceUnit::Miles)).await?;
//! println!("Distance (mi): {}", distance.distance_miles.unwrap_or_default());
//! # Ok(())
//! # }
//! ```

use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, USER_AGENT};
use serde::{Deserialize, Serialize};
use std::fmt::Display;

/// Default base URL for the zipcodestack.com API.
const DEFAULT_BASE_URL: &str = "https://api.zipcodestack.com/v1";

/// Strategy for authenticating requests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMethod {
    /// Send header: `apikey: <API_KEY>`
    Header,
    /// Append `api_key=<API_KEY>` to the query string
    QueryParam,
}

/// Unit for distance calculations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DistanceUnit {
    Miles,
    Kilometers,
}

impl std::fmt::Display for DistanceUnit {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DistanceUnit::Miles => write!(f, "miles"),
            DistanceUnit::Kilometers => write!(f, "kilometers"),
        }
    }
}

/// API error payload (if server returns structured error).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiErrorPayload {
    #[serde(default)]
    pub code: Option<String>,
    #[serde(default)]
    pub message: Option<String>,
}

/// Error type for this crate.
#[derive(thiserror::Error, Debug)]
pub enum ZipCodeStackError {
    #[error("http error: {0}")]
    Http(#[from] reqwest::Error),

    #[error("api returned error: {0}")]
    Api(String),

    #[error("invalid header value")]
    InvalidHeader,

    #[error("serialization error: {0}")]
    Serde(#[from] serde_json::Error),
}

/// API status and quota usage response.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StatusResponse {
    #[serde(default)]
    pub up: Option<bool>,
    #[serde(default)]
    pub status: Option<String>,
    #[serde(default)]
    pub quota: Option<Quota>,
}

/// Quota details for the authenticated account.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Quota {
    #[serde(default)]
    pub used: Option<u64>,
    #[serde(default)]
    pub remaining: Option<u64>,
    #[serde(default)]
    pub limit: Option<u64>,
    #[serde(default)]
    pub reset_at: Option<String>,
}

/// Zip code search response.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ZipSearchResponse {
    #[serde(default)]
    pub zip_code: Option<String>,
    #[serde(default)]
    pub city: Option<String>,
    #[serde(default)]
    pub state: Option<String>,
    #[serde(default)]
    pub state_code: Option<String>,
    #[serde(default)]
    pub county: Option<String>,
    #[serde(default)]
    pub country: Option<String>,
    #[serde(default)]
    pub country_code: Option<String>,
    #[serde(default)]
    pub latitude: Option<f64>,
    #[serde(default)]
    pub longitude: Option<f64>,
    #[serde(default)]
    pub timezone: Option<String>,
}

/// Distance response between two zip codes.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DistanceResponse {
    #[serde(default)]
    pub from_zip: Option<String>,
    #[serde(default)]
    pub to_zip: Option<String>,
    #[serde(default)]
    pub unit: Option<DistanceUnit>,
    #[serde(default)]
    pub distance_miles: Option<f64>,
    #[serde(default)]
    pub distance_kilometers: Option<f64>,
}

/// Builder to configure a `ZipCodeStackClient`.
pub struct ZipCodeStackClientBuilder {
    api_key: String,
    base_url: String,
    auth_method: AuthMethod,
    user_agent: Option<String>,
}

impl ZipCodeStackClientBuilder {
    /// Create a builder with the required API key.
    pub fn new<S: Into<String>>(api_key: S) -> Self {
        Self {
            api_key: api_key.into(),
            base_url: DEFAULT_BASE_URL.to_string(),
            auth_method: AuthMethod::Header,
            user_agent: None,
        }
    }

    /// Alias for `new` to read nicer when chained from `ZipCodeStackClient::builder`.
    pub fn from_api_key<S: Into<String>>(api_key: S) -> Self { Self::new(api_key) }

    /// Override the base URL (e.g., for testing or future API versions).
    pub fn with_base_url<S: Into<String>>(mut self, base_url: S) -> Self {
        self.base_url = base_url.into();
        self
    }

    /// Select whether to send the API key as header or query param.
    pub fn with_auth_method(mut self, method: AuthMethod) -> Self {
        self.auth_method = method;
        self
    }

    /// Set a custom User-Agent.
    pub fn with_user_agent<S: Into<String>>(mut self, ua: S) -> Self {
        self.user_agent = Some(ua.into());
        self
    }

    /// Build the final `ZipCodeStackClient`.
    pub fn build(self) -> ZipCodeStackClient {
        let mut default_headers = HeaderMap::new();
        default_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
        if let Some(ua) = self.user_agent.as_ref() {
            if let Ok(value) = HeaderValue::from_str(ua) {
                default_headers.insert(USER_AGENT, value);
            }
        } else {
            let _ = default_headers.insert(USER_AGENT, HeaderValue::from_static("zipcodestack-rs/0.1 (reqwest)"));
        }

        let http = reqwest::Client::builder()
            .default_headers(default_headers)
            .build()
            .expect("failed to build reqwest client");

        ZipCodeStackClient {
            http,
            api_key: self.api_key,
            base_url: self.base_url,
            auth_method: self.auth_method,
        }
    }
}

/// Async client for the zipcodestack.com API.
pub struct ZipCodeStackClient {
    http: reqwest::Client,
    api_key: String,
    base_url: String,
    auth_method: AuthMethod,
}

impl ZipCodeStackClient {
    /// Start building a `ZipCodeStackClient`.
    pub fn builder<S: Into<String>>(api_key: S) -> ZipCodeStackClientBuilder {
        ZipCodeStackClientBuilder::new(api_key)
    }

    /// Convenience constructor with defaults.
    pub fn new<S: Into<String>>(api_key: S) -> Self {
        Self::builder(api_key).build()
    }

    /// Query the API status and (optionally) quota usage.
    ///
    /// Endpoint path used: `/status`
    pub async fn status(&self) -> Result<StatusResponse, ZipCodeStackError> {
        let url = self.join_path("/status");
        let req = self.http.get(url);
        let req = self.apply_auth(req);
        let resp = req.send().await?;
        Self::handle_json_response::<StatusResponse>(resp).await
    }

    /// Search for details about a zip/postal code.
    ///
    /// Endpoint path used: `/search`
    /// Supported query params: `zip_code`, optional `country`
    pub async fn search_zip(
        &self,
        zip_code: &str,
        country: Option<&str>,
    ) -> Result<ZipSearchResponse, ZipCodeStackError> {
        let url = self.join_path("/search");
        let mut req = self.http.get(url);
        let mut qp: Vec<(String, String)> = vec![("zip_code".to_string(), zip_code.to_string())];
        if let Some(c) = country { qp.push(("country".to_string(), c.to_string())); }
        req = req.query(&qp);
        let req = self.apply_auth(req);
        let resp = req.send().await?;
        Self::handle_json_response::<ZipSearchResponse>(resp).await
    }

    /// Compute distance between two zip/postal codes.
    ///
    /// Endpoint path used: `/distance`
    /// Supported query params: `from`, `to`, optional `unit` ("miles" | "kilometers")
    pub async fn distance_between(
        &self,
        from_zip: &str,
        to_zip: &str,
        unit: Option<DistanceUnit>,
    ) -> Result<DistanceResponse, ZipCodeStackError> {
        let url = self.join_path("/distance");
        let mut req = self.http.get(url);
        let mut qp: Vec<(String, String)> = vec![
            ("from".to_string(), from_zip.to_string()),
            ("to".to_string(), to_zip.to_string()),
        ];
        if let Some(u) = unit { qp.push(("unit".to_string(), to_lower(u))); }
        req = req.query(&qp);
        let req = self.apply_auth(req);
        let resp = req.send().await?;
        Self::handle_json_response::<DistanceResponse>(resp).await
    }

    fn join_path(&self, path: &str) -> String {
        let base = self.base_url.trim_end_matches('/');
        let child = path.trim_start_matches('/');
        format!("{}/{}", base, child)
    }

    fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
        match self.auth_method {
            AuthMethod::Header => req.header("apikey", &self.api_key),
            AuthMethod::QueryParam => req.query(&[("apikey", self.api_key.as_str())]),
        }
    }

    async fn handle_json_response<T: for<'de> Deserialize<'de>>(
        resp: reqwest::Response,
    ) -> Result<T, ZipCodeStackError> {
        let status = resp.status();
        let text = resp.text().await?;
        if !status.is_success() {
            if let Ok(err_payload) = serde_json::from_str::<ApiErrorPayload>(&text) {
                let message = err_payload.message.unwrap_or_else(|| format!("HTTP {}", status));
                return Err(ZipCodeStackError::Api(message));
            }
            return Err(ZipCodeStackError::Api(format!("HTTP {}: {}", status, text)));
        }
        let parsed: T = serde_json::from_str(&text)?;
        Ok(parsed)
    }
}

fn to_lower<T: Display>(value: T) -> String { value.to_string().to_lowercase() }