use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, USER_AGENT};
use serde::{Deserialize, Serialize};
use std::fmt::Display;
const DEFAULT_BASE_URL: &str = "https://api.zipcodestack.com/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMethod {
Header,
QueryParam,
}
#[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"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiErrorPayload {
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub message: Option<String>,
}
#[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),
}
#[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>,
}
#[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>,
}
#[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>,
}
#[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>,
}
pub struct ZipCodeStackClientBuilder {
api_key: String,
base_url: String,
auth_method: AuthMethod,
user_agent: Option<String>,
}
impl ZipCodeStackClientBuilder {
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,
}
}
pub fn from_api_key<S: Into<String>>(api_key: S) -> Self { Self::new(api_key) }
pub fn with_base_url<S: Into<String>>(mut self, base_url: S) -> Self {
self.base_url = base_url.into();
self
}
pub fn with_auth_method(mut self, method: AuthMethod) -> Self {
self.auth_method = method;
self
}
pub fn with_user_agent<S: Into<String>>(mut self, ua: S) -> Self {
self.user_agent = Some(ua.into());
self
}
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,
}
}
}
pub struct ZipCodeStackClient {
http: reqwest::Client,
api_key: String,
base_url: String,
auth_method: AuthMethod,
}
impl ZipCodeStackClient {
pub fn builder<S: Into<String>>(api_key: S) -> ZipCodeStackClientBuilder {
ZipCodeStackClientBuilder::new(api_key)
}
pub fn new<S: Into<String>>(api_key: S) -> Self {
Self::builder(api_key).build()
}
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
}
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
}
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() }