use crate::models;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct Configuration {
pub base_path: String,
pub user_agent: Option<String>,
pub client: reqwest::Client,
}
impl Configuration {
pub fn new() -> Self {
Self::default()
}
}
impl Default for Configuration {
fn default() -> Self {
Self {
base_path: "https://catalog.data.gov".to_owned(),
user_agent: Some(concat!("data-gov-rs/", env!("CARGO_PKG_VERSION")).to_owned()),
client: reqwest::Client::new(),
}
}
}
pub struct CatalogClient {
configuration: Arc<Configuration>,
}
impl std::fmt::Debug for CatalogClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CatalogClient")
.field("base_path", &self.configuration.base_path)
.finish()
}
}
#[derive(Debug)]
pub enum CatalogError {
RequestError(Box<dyn std::error::Error + Send + Sync>),
ParseError(serde_json::Error),
ApiError {
status: u16,
message: String,
},
}
impl std::fmt::Display for CatalogError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CatalogError::RequestError(e) => write!(f, "Request error: {e}"),
CatalogError::ParseError(e) => write!(f, "Parse error: {e}"),
CatalogError::ApiError { status, message } => {
write!(f, "Catalog API error ({status}): {message}")
}
}
}
}
impl std::error::Error for CatalogError {}
#[derive(Debug, Default, Clone)]
pub struct SearchParams {
pub q: Option<String>,
pub sort: Option<String>,
pub per_page: Option<i32>,
pub org_slug: Option<String>,
pub org_type: Option<String>,
pub keyword: Vec<String>,
pub spatial_filter: Option<String>,
pub spatial_geometry: Option<Value>,
pub spatial_within: Option<bool>,
pub after: Option<String>,
pub slug: Option<String>,
}
impl SearchParams {
pub fn new() -> Self {
Self::default()
}
pub fn q(mut self, q: impl Into<String>) -> Self {
self.q = Some(q.into());
self
}
pub fn sort(mut self, sort: impl Into<String>) -> Self {
self.sort = Some(sort.into());
self
}
pub fn per_page(mut self, per_page: i32) -> Self {
self.per_page = Some(per_page);
self
}
pub fn org_slug(mut self, slug: impl Into<String>) -> Self {
self.org_slug = Some(slug.into());
self
}
pub fn org_type(mut self, org_type: impl Into<String>) -> Self {
self.org_type = Some(org_type.into());
self
}
pub fn keyword(mut self, keyword: impl Into<String>) -> Self {
self.keyword.push(keyword.into());
self
}
pub fn keywords<I, S>(mut self, keywords: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.keyword = keywords.into_iter().map(Into::into).collect();
self
}
pub fn spatial_filter(mut self, mode: impl Into<String>) -> Self {
self.spatial_filter = Some(mode.into());
self
}
pub fn spatial_geometry(mut self, geometry: Value) -> Self {
self.spatial_geometry = Some(geometry);
self
}
pub fn spatial_within(mut self, within: bool) -> Self {
self.spatial_within = Some(within);
self
}
pub fn after(mut self, after: impl Into<String>) -> Self {
self.after = Some(after.into());
self
}
pub fn slug(mut self, slug: impl Into<String>) -> Self {
self.slug = Some(slug.into());
self
}
fn to_query(&self) -> Vec<(&'static str, String)> {
let mut q: Vec<(&'static str, String)> = Vec::new();
if let Some(v) = &self.q {
q.push(("q", v.clone()));
}
if let Some(v) = &self.sort {
q.push(("sort", v.clone()));
}
if let Some(v) = self.per_page {
q.push(("per_page", v.to_string()));
}
if let Some(v) = &self.org_slug {
q.push(("org_slug", v.clone()));
}
if let Some(v) = &self.org_type {
q.push(("org_type", v.clone()));
}
for kw in &self.keyword {
q.push(("keyword", kw.clone()));
}
if let Some(v) = &self.spatial_filter {
q.push(("spatial_filter", v.clone()));
}
if let Some(v) = &self.spatial_geometry {
q.push(("spatial_geometry", v.to_string()));
}
if let Some(v) = self.spatial_within {
q.push(("spatial_within", v.to_string()));
}
if let Some(v) = &self.after {
q.push(("after", v.clone()));
}
if let Some(v) = &self.slug {
q.push(("slug", v.clone()));
}
q
}
}
impl CatalogClient {
pub fn new(configuration: Arc<Configuration>) -> Self {
Self { configuration }
}
fn url(&self, path: &str) -> String {
let base = self.configuration.base_path.trim_end_matches('/');
format!("{base}{path}")
}
async fn get_json<T: DeserializeOwned, Q: serde::Serialize + ?Sized>(
&self,
path: &str,
params: &Q,
) -> Result<T, CatalogError> {
let mut req = self.configuration.client.get(self.url(path)).query(params);
if let Some(ua) = &self.configuration.user_agent {
req = req.header(reqwest::header::USER_AGENT, ua);
}
let response = req
.send()
.await
.map_err(|e| CatalogError::RequestError(Box::new(e)))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response
.text()
.await
.unwrap_or_else(|_| "<no body>".to_string());
return Err(CatalogError::ApiError { status, message });
}
let bytes = response
.bytes()
.await
.map_err(|e| CatalogError::RequestError(Box::new(e)))?;
serde_json::from_slice(&bytes).map_err(CatalogError::ParseError)
}
pub async fn search(
&self,
params: SearchParams,
) -> Result<models::SearchResponse, CatalogError> {
let query = params.to_query();
self.get_json("/search", &query).await
}
pub async fn dataset_by_slug(
&self,
slug: &str,
) -> Result<Option<models::SearchHit>, CatalogError> {
let params = SearchParams::new().q(slug).per_page(20);
let response: models::SearchResponse = self.search(params).await?;
Ok(response
.results
.into_iter()
.find(|hit| hit.slug.as_deref() == Some(slug)))
}
pub async fn organizations(&self) -> Result<models::OrganizationsResponse, CatalogError> {
self.get_json("/api/organizations", &[(); 0]).await
}
pub async fn keywords(
&self,
size: Option<i32>,
min_count: Option<i32>,
) -> Result<models::KeywordsResponse, CatalogError> {
let mut params: Vec<(&str, String)> = Vec::new();
if let Some(s) = size {
params.push(("size", s.to_string()));
}
if let Some(m) = min_count {
params.push(("min_count", m.to_string()));
}
self.get_json("/api/keywords", ¶ms).await
}
pub async fn locations_search(
&self,
q: &str,
size: Option<i32>,
) -> Result<models::LocationsResponse, CatalogError> {
let mut params: Vec<(&str, String)> = vec![("q", q.to_string())];
if let Some(s) = size {
params.push(("size", s.to_string()));
}
self.get_json("/api/locations/search", ¶ms).await
}
pub async fn location_geometry(&self, id: &str) -> Result<Value, CatalogError> {
let path = format!("/api/location/{id}");
self.get_json(&path, &[(); 0]).await
}
pub async fn harvest_record(&self, id: &str) -> Result<models::HarvestRecord, CatalogError> {
let path = format!("/harvest_record/{id}");
self.get_json(&path, &[(); 0]).await
}
pub async fn harvest_record_raw(&self, id: &str) -> Result<Value, CatalogError> {
let path = format!("/harvest_record/{id}/raw");
self.get_json(&path, &[(); 0]).await
}
pub async fn harvest_record_transformed(
&self,
id: &str,
) -> Result<models::Dataset, CatalogError> {
let path = format!("/harvest_record/{id}/transformed");
self.get_json(&path, &[(); 0]).await
}
}