latlng 0.1.0

Rust SDK for the latlng.work geocoding and places API
Documentation
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderValue, ACCEPT};
use serde::de::DeserializeOwned;

use crate::error::{error_from_response, Error, Result};
use crate::models::{FeatureCollection, GeocodingResponse};
use crate::places::PlacesClient;

/// Default latlng API base URL.
pub const DEFAULT_BASE_URL: &str = "https://api.latlng.work";
/// Default latlng autosuggest API base URL.
pub const DEFAULT_AUTOSUGGEST_BASE_URL: &str = "https://suggest.latlng.work";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);

/// Async client for the latlng API.
#[derive(Clone, Debug)]
pub struct LatlngClient {
    http: reqwest::Client,
    base_url: String,
    autosuggest_base_url: String,
}

impl LatlngClient {
    /// Creates an anonymous client.
    ///
    /// Anonymous requests are supported but have lower rate limits.
    pub fn anonymous() -> Result<Self> {
        Self::builder().build()
    }

    /// Creates a client using an API key.
    pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
        Self::builder().api_key(api_key).build()
    }

    /// Creates a configurable client builder.
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    /// Returns a places sub-client.
    pub fn places(&self) -> PlacesClient<'_> {
        PlacesClient::new(self)
    }

    /// Forward geocode an address or place name.
    pub async fn geocode(&self, query: impl AsRef<str>) -> Result<GeocodingResponse> {
        self.geocode_with(GeocodeRequest::new(query.as_ref())).await
    }

    /// Forward geocode with optional parameters.
    pub async fn geocode_with(&self, request: GeocodeRequest<'_>) -> Result<GeocodingResponse> {
        let mut params = vec![("q", request.query.to_string())];
        push_param(&mut params, "limit", request.limit);
        push_param(&mut params, "lang", request.lang);
        push_param(&mut params, "lat", request.lat);
        push_param(&mut params, "lon", request.lon);

        let response: FeatureCollection = self.get_json("/api", &params).await?;
        Ok(response.into())
    }

    /// Reverse geocode coordinates to an address.
    pub async fn reverse(&self, lat: f64, lon: f64) -> Result<GeocodingResponse> {
        self.reverse_with(ReverseRequest::new(lat, lon)).await
    }

    /// Reverse geocode with optional parameters.
    pub async fn reverse_with(&self, request: ReverseRequest<'_>) -> Result<GeocodingResponse> {
        let mut params = vec![
            ("lat", request.lat.to_string()),
            ("lon", request.lon.to_string()),
        ];
        push_param(&mut params, "limit", request.limit);
        push_param(&mut params, "lang", request.lang);

        let response: FeatureCollection = self.get_json("/reverse", &params).await?;
        Ok(response.into())
    }

    /// Checks whether the API health endpoint returns HTTP 200.
    pub async fn health(&self) -> Result<bool> {
        let response = self
            .http
            .get(self.url("/health"))
            .send()
            .await
            .map_err(Error::Request)?;
        Ok(response.status().is_success())
    }

    pub(crate) async fn get_json<T>(&self, path: &str, params: &[(&str, String)]) -> Result<T>
    where
        T: DeserializeOwned,
    {
        let response = self
            .http
            .get(self.url(path))
            .query(params)
            .send()
            .await
            .map_err(Error::Request)?;

        if response.status().is_success() {
            response.json::<T>().await.map_err(Error::Request)
        } else {
            Err(error_from_response(response).await)
        }
    }

    pub(crate) async fn get_autosuggest_json<T>(
        &self,
        path: &str,
        params: &[(&str, String)],
    ) -> Result<T>
    where
        T: DeserializeOwned,
    {
        let response = self
            .http
            .get(format!("{}{}", self.autosuggest_base_url, path))
            .query(params)
            .send()
            .await
            .map_err(Error::Request)?;

        if response.status().is_success() {
            response.json::<T>().await.map_err(Error::Request)
        } else {
            Err(error_from_response(response).await)
        }
    }

    fn url(&self, path: &str) -> String {
        format!("{}{}", self.base_url, path)
    }
}

/// Builder for [`LatlngClient`].
#[derive(Debug, Clone)]
pub struct ClientBuilder {
    api_key: Option<String>,
    base_url: String,
    autosuggest_base_url: String,
    timeout: Duration,
}

impl Default for ClientBuilder {
    fn default() -> Self {
        Self {
            api_key: None,
            base_url: DEFAULT_BASE_URL.to_string(),
            autosuggest_base_url: DEFAULT_AUTOSUGGEST_BASE_URL.to_string(),
            timeout: DEFAULT_TIMEOUT,
        }
    }
}

impl ClientBuilder {
    /// Sets an API key.
    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
        self.api_key = Some(api_key.into());
        self
    }

    /// Sets the base URL. Useful for tests and private deployments.
    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        let base_url = base_url.into().trim_end_matches('/').to_string();
        self.base_url = base_url.clone();
        self.autosuggest_base_url = base_url;
        self
    }

    /// Sets the autosuggest base URL. Useful for tests and private deployments.
    ///
    /// By default, autosuggest uses [`DEFAULT_AUTOSUGGEST_BASE_URL`].
    pub fn autosuggest_base_url(mut self, base_url: impl Into<String>) -> Self {
        self.autosuggest_base_url = base_url.into().trim_end_matches('/').to_string();
        self
    }

    /// Sets the HTTP timeout.
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Builds the client.
    pub fn build(self) -> Result<LatlngClient> {
        let mut headers = HeaderMap::new();
        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));

        if let Some(api_key) = self.api_key.filter(|key| !key.trim().is_empty()) {
            headers.insert(
                "x-api-key",
                HeaderValue::from_str(&api_key).map_err(Error::InvalidApiKey)?,
            );
        }

        let http = reqwest::Client::builder()
            .default_headers(headers)
            .timeout(self.timeout)
            .build()
            .map_err(Error::ClientBuild)?;

        Ok(LatlngClient {
            http,
            base_url: self.base_url.trim_end_matches('/').to_string(),
            autosuggest_base_url: self.autosuggest_base_url.trim_end_matches('/').to_string(),
        })
    }
}

/// Optional parameters for forward geocoding.
#[derive(Debug, Clone, Copy)]
pub struct GeocodeRequest<'a> {
    query: &'a str,
    limit: Option<u32>,
    lang: Option<&'a str>,
    lat: Option<f64>,
    lon: Option<f64>,
}

impl<'a> GeocodeRequest<'a> {
    /// Creates a forward geocoding request.
    pub fn new(query: &'a str) -> Self {
        Self {
            query,
            limit: None,
            lang: None,
            lat: None,
            lon: None,
        }
    }

    /// Sets the maximum number of results.
    pub fn limit(mut self, limit: u32) -> Self {
        self.limit = Some(limit);
        self
    }

    /// Sets the response language.
    pub fn lang(mut self, lang: &'a str) -> Self {
        self.lang = Some(lang);
        self
    }

    /// Biases results near a coordinate.
    pub fn bias(mut self, lat: f64, lon: f64) -> Self {
        self.lat = Some(lat);
        self.lon = Some(lon);
        self
    }
}

/// Optional parameters for reverse geocoding.
#[derive(Debug, Clone, Copy)]
pub struct ReverseRequest<'a> {
    lat: f64,
    lon: f64,
    limit: Option<u32>,
    lang: Option<&'a str>,
}

impl<'a> ReverseRequest<'a> {
    /// Creates a reverse geocoding request.
    pub fn new(lat: f64, lon: f64) -> Self {
        Self {
            lat,
            lon,
            limit: None,
            lang: None,
        }
    }

    /// Sets the maximum number of results.
    pub fn limit(mut self, limit: u32) -> Self {
        self.limit = Some(limit);
        self
    }

    /// Sets the response language.
    pub fn lang(mut self, lang: &'a str) -> Self {
        self.lang = Some(lang);
        self
    }
}

fn push_param<'a, T>(params: &mut Vec<(&'a str, String)>, key: &'a str, value: Option<T>)
where
    T: ToString,
{
    if let Some(value) = value {
        params.push((key, value.to_string()));
    }
}