pub mod endpoints;
pub mod error;
pub mod models;
pub use endpoints::for_sale_metrics::ForSaleMetricsParams;
pub use endpoints::investor_metrics::InvestorMetricsParams;
pub use endpoints::market_metrics::MetricsParams;
pub use endpoints::new_construction_metrics::NewConstructionMetricsParams;
pub use endpoints::portfolio_metrics::PortfolioMetricsParams;
pub use endpoints::property::{EventHistoryParams, PropertySearchParams};
pub use endpoints::rental_metrics::RentalMetricsParams;
pub use endpoints::search::SearchParams;
pub use error::{ParclError, Result};
pub use models::*;
use endpoints::{
ForSaleMetricsClient, InvestorMetricsClient, MarketMetricsClient, NewConstructionMetricsClient,
PortfolioMetricsClient, PriceFeedClient, PropertyClient, RentalMetricsClient, SearchClient,
};
use reqwest::Client;
use std::env;
use std::sync::atomic::{AtomicI64, Ordering};
const DEFAULT_BASE_URL: &str = "https://api.parcllabs.com";
const ENV_API_KEY: &str = "PARCL_LABS_API_KEY";
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_backoff_ms: u64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff_ms: 1000,
}
}
}
pub struct ParclClient {
pub(crate) http: Client,
pub(crate) base_url: String,
pub(crate) api_key: String,
pub(crate) retry_config: RetryConfig,
session_credits_used: AtomicI64,
remaining_credits: AtomicI64,
}
impl std::fmt::Debug for ParclClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ParclClient")
.field("base_url", &self.base_url)
.field("api_key", &"***")
.field("retry_config", &self.retry_config)
.field(
"session_credits_used",
&self.session_credits_used.load(Ordering::Relaxed),
)
.field(
"remaining_credits",
&self.remaining_credits.load(Ordering::Relaxed),
)
.finish()
}
}
impl ParclClient {
pub fn new() -> Result<Self> {
let api_key = env::var(ENV_API_KEY).map_err(|_| ParclError::MissingApiKey)?;
Ok(Self {
http: Client::new(),
base_url: DEFAULT_BASE_URL.to_string(),
api_key,
retry_config: RetryConfig::default(),
session_credits_used: AtomicI64::new(0),
remaining_credits: AtomicI64::new(0),
})
}
pub fn with_api_key(api_key: impl Into<String>) -> Self {
Self {
http: Client::new(),
base_url: DEFAULT_BASE_URL.to_string(),
api_key: api_key.into(),
retry_config: RetryConfig::default(),
session_credits_used: AtomicI64::new(0),
remaining_credits: AtomicI64::new(0),
}
}
pub fn with_config(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
Self {
http: Client::new(),
base_url: base_url.into(),
api_key: api_key.into(),
retry_config: RetryConfig::default(),
session_credits_used: AtomicI64::new(0),
remaining_credits: AtomicI64::new(0),
}
}
pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
self.retry_config = config;
self
}
pub(crate) fn update_credits(&self, account: &Option<AccountInfo>) {
if let Some(info) = account {
if let Some(used) = info.est_credits_used {
self.session_credits_used.fetch_add(used, Ordering::Relaxed);
}
if let Some(remaining) = info.est_remaining_credits {
self.remaining_credits.store(remaining, Ordering::Relaxed);
}
}
}
pub fn account_info(&self) -> AccountUsage {
AccountUsage {
est_session_credits_used: self.session_credits_used.load(Ordering::Relaxed),
est_remaining_credits: self.remaining_credits.load(Ordering::Relaxed),
}
}
pub fn session_credits_used(&self) -> i64 {
self.session_credits_used.load(Ordering::Relaxed)
}
pub fn remaining_credits(&self) -> i64 {
self.remaining_credits.load(Ordering::Relaxed)
}
pub fn search(&self) -> SearchClient<'_> {
SearchClient::new(self)
}
pub fn market_metrics(&self) -> MarketMetricsClient<'_> {
MarketMetricsClient::new(self)
}
pub fn investor_metrics(&self) -> InvestorMetricsClient<'_> {
InvestorMetricsClient::new(self)
}
pub fn for_sale_metrics(&self) -> ForSaleMetricsClient<'_> {
ForSaleMetricsClient::new(self)
}
pub fn rental_metrics(&self) -> RentalMetricsClient<'_> {
RentalMetricsClient::new(self)
}
pub fn price_feed(&self) -> PriceFeedClient<'_> {
PriceFeedClient::new(self)
}
pub fn new_construction_metrics(&self) -> NewConstructionMetricsClient<'_> {
NewConstructionMetricsClient::new(self)
}
pub fn portfolio_metrics(&self) -> PortfolioMetricsClient<'_> {
PortfolioMetricsClient::new(self)
}
pub fn property(&self) -> PropertyClient<'_> {
PropertyClient::new(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_with_api_key() {
let client = ParclClient::with_api_key("test-key");
assert_eq!(client.api_key, "test-key");
assert_eq!(client.base_url, DEFAULT_BASE_URL);
}
#[test]
fn client_with_config() {
let client = ParclClient::with_config("my-key", "https://custom.api.com");
assert_eq!(client.api_key, "my-key");
assert_eq!(client.base_url, "https://custom.api.com");
}
#[test]
fn client_new_missing_env() {
let original = env::var(ENV_API_KEY).ok();
env::remove_var(ENV_API_KEY);
let result = ParclClient::new();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParclError::MissingApiKey));
if let Some(val) = original {
env::set_var(ENV_API_KEY, val);
}
}
#[test]
fn client_new_with_env() {
let original = env::var(ENV_API_KEY).ok();
env::set_var(ENV_API_KEY, "env-test-key");
let result = ParclClient::new();
assert!(result.is_ok());
let client = result.unwrap();
assert_eq!(client.api_key, "env-test-key");
if let Some(val) = original {
env::set_var(ENV_API_KEY, val);
} else {
env::remove_var(ENV_API_KEY);
}
}
#[test]
fn client_with_retry_config() {
let config = RetryConfig {
max_retries: 5,
initial_backoff_ms: 2000,
};
let client = ParclClient::with_api_key("test").with_retry_config(config);
assert_eq!(client.retry_config.max_retries, 5);
assert_eq!(client.retry_config.initial_backoff_ms, 2000);
}
#[test]
fn retry_config_default() {
let config = RetryConfig::default();
assert_eq!(config.max_retries, 3);
assert_eq!(config.initial_backoff_ms, 1000);
}
#[test]
fn update_credits_accumulates() {
let client = ParclClient::with_api_key("test");
let info1 = Some(AccountInfo {
est_credits_used: Some(10),
est_remaining_credits: Some(990),
});
client.update_credits(&info1);
assert_eq!(client.session_credits_used(), 10);
assert_eq!(client.remaining_credits(), 990);
let info2 = Some(AccountInfo {
est_credits_used: Some(5),
est_remaining_credits: Some(985),
});
client.update_credits(&info2);
assert_eq!(client.session_credits_used(), 15);
assert_eq!(client.remaining_credits(), 985);
}
#[test]
fn update_credits_none_is_noop() {
let client = ParclClient::with_api_key("test");
client.update_credits(&None);
assert_eq!(client.session_credits_used(), 0);
assert_eq!(client.remaining_credits(), 0);
}
#[test]
fn account_info_returns_session_state() {
let client = ParclClient::with_api_key("test");
let info = Some(AccountInfo {
est_credits_used: Some(42),
est_remaining_credits: Some(958),
});
client.update_credits(&info);
let usage = client.account_info();
assert_eq!(usage.est_session_credits_used, 42);
assert_eq!(usage.est_remaining_credits, 958);
}
#[test]
fn client_debug_hides_api_key() {
let client = ParclClient::with_api_key("super-secret-key");
let debug = format!("{:?}", client);
assert!(!debug.contains("super-secret-key"));
assert!(debug.contains("***"));
}
#[test]
fn client_returns_search_client() {
let client = ParclClient::with_api_key("test");
let _search = client.search();
}
#[test]
fn client_returns_market_metrics_client() {
let client = ParclClient::with_api_key("test");
let _metrics = client.market_metrics();
}
#[test]
fn client_returns_investor_metrics_client() {
let client = ParclClient::with_api_key("test");
let _investor = client.investor_metrics();
}
#[test]
fn client_returns_price_feed_client() {
let client = ParclClient::with_api_key("test");
let _feed = client.price_feed();
}
#[test]
fn client_returns_for_sale_metrics_client() {
let client = ParclClient::with_api_key("test");
let _for_sale = client.for_sale_metrics();
}
#[test]
fn client_returns_rental_metrics_client() {
let client = ParclClient::with_api_key("test");
let _rental = client.rental_metrics();
}
#[test]
fn client_returns_new_construction_metrics_client() {
let client = ParclClient::with_api_key("test");
let _new_construction = client.new_construction_metrics();
}
#[test]
fn client_returns_portfolio_metrics_client() {
let client = ParclClient::with_api_key("test");
let _portfolio = client.portfolio_metrics();
}
#[test]
fn client_returns_property_client() {
let client = ParclClient::with_api_key("test");
let _property = client.property();
}
}