reso_client/client.rs
1// src/client.rs
2
3//! Client configuration and connection management
4
5use crate::error::{ResoError, Result};
6use reqwest::Client;
7use std::time::Duration;
8
9/// Configuration for RESO client
10///
11/// Holds all configuration needed to connect to a RESO Web API server,
12/// including the base URL, authentication token, optional dataset ID,
13/// and HTTP timeout settings.
14///
15/// # Examples
16///
17/// ```
18/// # use reso_client::ClientConfig;
19/// # use std::time::Duration;
20/// // Create basic configuration
21/// let config = ClientConfig::new(
22/// "https://api.mls.com/odata",
23/// "your-token"
24/// );
25///
26/// // With dataset ID
27/// let config = ClientConfig::new(
28/// "https://api.mls.com/odata",
29/// "your-token"
30/// )
31/// .with_dataset_id("actris_ref");
32///
33/// // With custom timeout
34/// let config = ClientConfig::new(
35/// "https://api.mls.com/odata",
36/// "your-token"
37/// )
38/// .with_timeout(Duration::from_secs(60));
39/// ```
40#[derive(Clone)]
41pub struct ClientConfig {
42 /// Base URL of the RESO Web API server
43 pub base_url: String,
44
45 /// OAuth bearer token
46 pub token: String,
47
48 /// Optional dataset ID (inserted between base_url and resource)
49 pub dataset_id: Option<String>,
50
51 /// HTTP timeout duration
52 pub timeout: Duration,
53}
54
55impl std::fmt::Debug for ClientConfig {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("ClientConfig")
58 .field("base_url", &self.base_url)
59 .field("token", &"<redacted>")
60 .field("dataset_id", &self.dataset_id)
61 .field("timeout", &self.timeout)
62 .finish()
63 }
64}
65
66impl ClientConfig {
67 /// Create configuration from environment variables
68 ///
69 /// Expects:
70 /// - `RESO_BASE_URL` - Base URL of the RESO server (e.g., `https://api.mls.com/api/v2/OData`)
71 /// - `RESO_TOKEN` - OAuth bearer token
72 /// - `RESO_DATASET_ID` (optional) - Dataset ID inserted in URL path
73 /// - `RESO_TIMEOUT` (optional) - Timeout in seconds (default: 30)
74 ///
75 /// # Examples
76 ///
77 /// ```no_run
78 /// # use reso_client::ClientConfig;
79 /// // Reads RESO_BASE_URL, RESO_TOKEN, and optional variables from environment
80 /// let config = ClientConfig::from_env()?;
81 /// # Ok::<(), Box<dyn std::error::Error>>(())
82 /// ```
83 pub fn from_env() -> Result<Self> {
84 let base_url = std::env::var("RESO_BASE_URL")
85 .map_err(|_| ResoError::Config("RESO_BASE_URL not set".into()))?;
86
87 let token = std::env::var("RESO_TOKEN")
88 .map_err(|_| ResoError::Config("RESO_TOKEN not set".into()))?;
89
90 let dataset_id = std::env::var("RESO_DATASET_ID").ok();
91
92 let timeout_secs = std::env::var("RESO_TIMEOUT")
93 .ok()
94 .and_then(|s| s.parse::<u64>().ok())
95 .unwrap_or(30);
96
97 Ok(Self {
98 base_url: base_url.trim_end_matches('/').to_string(),
99 token,
100 dataset_id,
101 timeout: Duration::from_secs(timeout_secs),
102 })
103 }
104
105 /// Create configuration manually
106 ///
107 /// # Examples
108 ///
109 /// ```
110 /// # use reso_client::ClientConfig;
111 /// let config = ClientConfig::new(
112 /// "https://api.mls.com/odata",
113 /// "your-bearer-token"
114 /// );
115 /// ```
116 pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
117 Self {
118 base_url: base_url.into().trim_end_matches('/').to_string(),
119 token: token.into(),
120 dataset_id: None,
121 timeout: Duration::from_secs(30),
122 }
123 }
124
125 /// Set dataset ID
126 ///
127 /// Some RESO servers require a dataset identifier in the URL path.
128 /// When set, URLs will be formatted as: `{base_url}/{dataset_id}/{resource}`
129 ///
130 /// # Examples
131 ///
132 /// ```
133 /// # use reso_client::ClientConfig;
134 /// let config = ClientConfig::new("https://api.mls.com/odata", "token")
135 /// .with_dataset_id("actris_ref");
136 /// ```
137 pub fn with_dataset_id(mut self, dataset_id: impl Into<String>) -> Self {
138 self.dataset_id = Some(dataset_id.into());
139 self
140 }
141
142 /// Set custom timeout
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// # use reso_client::ClientConfig;
148 /// # use std::time::Duration;
149 /// let config = ClientConfig::new("https://api.mls.com/odata", "token")
150 /// .with_timeout(Duration::from_secs(60));
151 /// ```
152 pub fn with_timeout(mut self, timeout: Duration) -> Self {
153 self.timeout = timeout;
154 self
155 }
156}
157
158/// RESO Web API client
159pub struct ResoClient {
160 config: ClientConfig,
161 http_client: Client,
162}
163
164impl ResoClient {
165 /// Create a new client from environment variables
166 ///
167 /// # Environment Variables
168 ///
169 /// - `RESO_BASE_URL` - Base URL of the RESO server (required)
170 /// Example: `https://api.mls.com/api/v2/OData`
171 /// - `RESO_TOKEN` - OAuth bearer token (required)
172 /// - `RESO_DATASET_ID` - Dataset ID for URL path (optional)
173 /// - `RESO_TIMEOUT` - Timeout in seconds (optional, default: 30)
174 ///
175 /// # Examples
176 ///
177 /// ```no_run
178 /// # use reso_client::ResoClient;
179 /// let client = ResoClient::from_env()?;
180 /// # Ok::<(), Box<dyn std::error::Error>>(())
181 /// ```
182 pub fn from_env() -> Result<Self> {
183 let config = ClientConfig::from_env()?;
184 Self::with_config(config)
185 }
186
187 /// Create a new client with manual configuration
188 ///
189 /// # Examples
190 ///
191 /// ```no_run
192 /// # use reso_client::{ResoClient, ClientConfig};
193 /// let config = ClientConfig::new(
194 /// "https://api.mls.com/reso/odata",
195 /// "your-token"
196 /// );
197 /// let client = ResoClient::with_config(config)?;
198 /// # Ok::<(), Box<dyn std::error::Error>>(())
199 /// ```
200 pub fn with_config(config: ClientConfig) -> Result<Self> {
201 let http_client = Client::builder()
202 .timeout(config.timeout)
203 .build()
204 .map_err(|e| ResoError::Config(format!("Failed to create HTTP client: {}", e)))?;
205
206 Ok(Self {
207 config,
208 http_client,
209 })
210 }
211
212 /// Get the base URL
213 ///
214 /// # Examples
215 ///
216 /// ```no_run
217 /// # use reso_client::{ResoClient, ClientConfig};
218 /// let config = ClientConfig::new("https://api.mls.com/odata", "token");
219 /// let client = ResoClient::with_config(config)?;
220 /// assert_eq!(client.base_url(), "https://api.mls.com/odata");
221 /// # Ok::<(), Box<dyn std::error::Error>>(())
222 /// ```
223 pub fn base_url(&self) -> &str {
224 &self.config.base_url
225 }
226
227 /// Build full URL with optional dataset_id
228 ///
229 /// Some RESO servers require a dataset ID in the URL path between the base URL
230 /// and the resource/query path (e.g., `https://api.mls.com/odata/{dataset_id}/Property`).
231 /// This method handles both cases transparently.
232 fn build_url(&self, path: &str) -> String {
233 match &self.config.dataset_id {
234 Some(dataset_id) => format!("{}/{}/{}", self.config.base_url, dataset_id, path),
235 None => format!("{}/{}", self.config.base_url, path),
236 }
237 }
238
239 /// Send an authenticated GET request and handle error responses
240 ///
241 /// This helper method encapsulates the common pattern of:
242 /// 1. Sending a GET request with Authorization header
243 /// 2. Checking the response status
244 /// 3. Converting error responses to appropriate ResoError variants
245 async fn send_authenticated_request(
246 &self,
247 url: &str,
248 accept: &str,
249 ) -> Result<reqwest::Response> {
250 let response = self
251 .http_client
252 .get(url)
253 .header("Authorization", format!("Bearer {}", self.config.token))
254 .header("Accept", accept)
255 .send()
256 .await
257 .map_err(|e| ResoError::Network(e.to_string()))?;
258
259 let status = response.status();
260
261 // Check for error responses and extract the body for detailed error information
262 if !status.is_success() {
263 let body = response.text().await.unwrap_or_default();
264 // from_status() parses OData error format if present and maps to appropriate error variant
265 return Err(ResoError::from_status(status.as_u16(), &body));
266 }
267
268 Ok(response)
269 }
270
271 /// Parse JSON response from a successful request
272 async fn parse_json_response(response: reqwest::Response) -> Result<serde_json::Value> {
273 response
274 .json::<serde_json::Value>()
275 .await
276 .map_err(|e| ResoError::Parse(format!("Failed to parse JSON: {}", e)))
277 }
278
279 /// Parse text response from a successful request
280 async fn parse_text_response(response: reqwest::Response) -> Result<String> {
281 response
282 .text()
283 .await
284 .map_err(|e| ResoError::Parse(format!("Failed to read response: {}", e)))
285 }
286
287 /// Execute a query and return raw JSON
288 ///
289 /// Executes a standard OData query and returns the full JSON response.
290 /// The response follows the OData format with records in a `value` array.
291 ///
292 /// # Examples
293 ///
294 /// ```no_run
295 /// # use reso_client::{ResoClient, QueryBuilder};
296 /// # async fn example(client: &ResoClient) -> Result<(), Box<dyn std::error::Error>> {
297 /// let query = QueryBuilder::new("Property")
298 /// .filter("City eq 'Austin'")
299 /// .select(&["ListingKey", "City", "ListPrice"])
300 /// .top(10)
301 /// .build()?;
302 ///
303 /// let results = client.execute(&query).await?;
304 ///
305 /// // Access records from OData response
306 /// if let Some(records) = results["value"].as_array() {
307 /// for record in records {
308 /// println!("{}", record["ListingKey"]);
309 /// }
310 /// }
311 ///
312 /// // Access count if requested with with_count()
313 /// if let Some(count) = results["@odata.count"].as_u64() {
314 /// println!("Total: {}", count);
315 /// }
316 /// # Ok(())
317 /// # }
318 /// ```
319 pub async fn execute(&self, query: &crate::queries::Query) -> Result<serde_json::Value> {
320 use tracing::{debug, info};
321
322 let url = self.build_url(&query.to_odata_string());
323 info!("Executing query: {}", url);
324
325 let response = self
326 .send_authenticated_request(&url, "application/json")
327 .await?;
328 let json = Self::parse_json_response(response).await?;
329
330 debug!(
331 "Query result: {} records",
332 json.get("value")
333 .and_then(|v| v.as_array())
334 .map(|a| a.len())
335 .unwrap_or(0)
336 );
337
338 Ok(json)
339 }
340
341 /// Execute a direct key access query and return a single record
342 ///
343 /// Direct key access queries (e.g., `Property('12345')`) return a single object
344 /// instead of an array wrapped in `{"value": [...]}`. This method is optimized
345 /// for such queries.
346 ///
347 /// # Examples
348 ///
349 /// ```no_run
350 /// # use reso_client::{ResoClient, QueryBuilder};
351 /// # async fn example(client: &ResoClient) -> Result<(), Box<dyn std::error::Error>> {
352 /// // Fetch a single property by key
353 /// let query = QueryBuilder::by_key("Property", "12345")
354 /// .select(&["ListingKey", "City", "ListPrice"])
355 /// .build()?;
356 ///
357 /// let record = client.execute_by_key(&query).await?;
358 ///
359 /// // With expand
360 /// let query = QueryBuilder::by_key("Property", "12345")
361 /// .expand(&["ListOffice", "ListAgent"])
362 /// .build()?;
363 ///
364 /// let record = client.execute_by_key(&query).await?;
365 /// # Ok(())
366 /// # }
367 /// ```
368 pub async fn execute_by_key(&self, query: &crate::queries::Query) -> Result<serde_json::Value> {
369 use tracing::info;
370
371 let url = self.build_url(&query.to_odata_string());
372 info!("Executing key access query: {}", url);
373
374 let response = self
375 .send_authenticated_request(&url, "application/json")
376 .await?;
377 Self::parse_json_response(response).await
378 }
379
380 /// Execute a count-only query and return the count as an integer
381 ///
382 /// Uses the OData `/$count` endpoint to efficiently get just the count
383 /// without fetching any records. More efficient than using `with_count()`
384 /// when you only need the count.
385 ///
386 /// # Examples
387 ///
388 /// ```no_run
389 /// # use reso_client::{ResoClient, QueryBuilder};
390 /// # async fn example(client: &ResoClient) -> Result<(), Box<dyn std::error::Error>> {
391 /// let query = QueryBuilder::new("Property")
392 /// .filter("City eq 'Austin'")
393 /// .count()
394 /// .build()?;
395 ///
396 /// let count = client.execute_count(&query).await?;
397 /// println!("Total properties in Austin: {}", count);
398 /// # Ok(())
399 /// # }
400 /// ```
401 pub async fn execute_count(&self, query: &crate::queries::Query) -> Result<u64> {
402 use tracing::info;
403
404 let url = self.build_url(&query.to_odata_string());
405 info!("Executing count query: {}", url);
406
407 let response = self.send_authenticated_request(&url, "text/plain").await?;
408 let text = Self::parse_text_response(response).await?;
409
410 let count = text
411 .trim()
412 .parse::<u64>()
413 .map_err(|e| ResoError::Parse(format!("Failed to parse count '{}': {}", text, e)))?;
414
415 info!("Count result: {}", count);
416
417 Ok(count)
418 }
419
420 /// Fetch $metadata XML
421 ///
422 /// Retrieves the OData metadata document which describes the schema,
423 /// entity types, properties, and relationships available in the API.
424 ///
425 /// # Examples
426 ///
427 /// ```no_run
428 /// # use reso_client::ResoClient;
429 /// # async fn example(client: &ResoClient) -> Result<(), Box<dyn std::error::Error>> {
430 /// let metadata = client.fetch_metadata().await?;
431 ///
432 /// // Parse or save the XML metadata
433 /// println!("Metadata length: {} bytes", metadata.len());
434 /// # Ok(())
435 /// # }
436 /// ```
437 pub async fn fetch_metadata(&self) -> Result<String> {
438 use tracing::info;
439
440 let url = self.build_url("$metadata");
441 info!("Fetching metadata from: {}", url);
442
443 let response = self
444 .send_authenticated_request(&url, "application/xml")
445 .await?;
446 Self::parse_text_response(response).await
447 }
448
449 /// Execute a replication query
450 ///
451 /// The replication endpoint is designed for bulk data transfer and supports
452 /// up to 2000 records per request. The response includes a `next` link in
453 /// the headers for pagination through large datasets.
454 ///
455 /// # Important Notes
456 ///
457 /// - Replication functionality requires MLS authorization
458 /// - Results are ordered oldest to newest by default
459 /// - Use `$select` to reduce payload size and improve performance
460 /// - For datasets >10,000 records, replication is required
461 ///
462 /// # Examples
463 ///
464 /// ```no_run
465 /// # use reso_client::{ResoClient, ReplicationQueryBuilder};
466 /// # async fn example(client: &ResoClient) -> Result<(), Box<dyn std::error::Error>> {
467 /// let query = ReplicationQueryBuilder::new("Property")
468 /// .filter("StandardStatus eq 'Active'")
469 /// .select(&["ListingKey", "City", "ListPrice"])
470 /// .top(2000)
471 /// .build()?;
472 ///
473 /// let response = client.execute_replication(&query).await?;
474 ///
475 /// println!("Retrieved {} records", response.record_count);
476 ///
477 /// // Continue with next link if more records available
478 /// if let Some(next_link) = response.next_link {
479 /// let next_response = client.execute_next_link(&next_link).await?;
480 /// }
481 /// # Ok(())
482 /// # }
483 /// ```
484 pub async fn execute_replication(
485 &self,
486 query: &crate::queries::ReplicationQuery,
487 ) -> Result<crate::replication::ReplicationResponse> {
488 use tracing::{debug, info};
489
490 let url = self.build_url(&query.to_odata_string());
491 info!("Executing replication query: {}", url);
492
493 let response = self
494 .send_authenticated_request(&url, "application/json")
495 .await?;
496
497 // Extract next link from response headers before consuming response
498 // The replication endpoint uses the "next" header (preferred) or "link" header
499 // to indicate more records are available. This must be extracted before reading
500 // the response body since consuming the response moves ownership.
501 let next_link = response
502 .headers()
503 .get("next")
504 .or_else(|| response.headers().get("link"))
505 .and_then(|v| v.to_str().ok())
506 .map(|s| s.to_string());
507
508 debug!("Next link from headers: {:?}", next_link);
509
510 let json = Self::parse_json_response(response).await?;
511
512 // Extract records from OData response envelope
513 // OData wraps result arrays in a "value" field: {"value": [...], "@odata.context": "..."}
514 let records = json
515 .get("value")
516 .and_then(|v| v.as_array())
517 .cloned()
518 .unwrap_or_default();
519
520 debug!("Retrieved {} records", records.len());
521
522 Ok(crate::replication::ReplicationResponse::new(
523 records, next_link,
524 ))
525 }
526
527 /// Execute a next link from a previous replication response
528 ///
529 /// Takes the full URL from a previous replication response's `next_link`
530 /// field and fetches the next batch of records.
531 ///
532 /// # Examples
533 ///
534 /// ```no_run
535 /// # use reso_client::{ResoClient, ReplicationQueryBuilder};
536 /// # async fn example(client: &ResoClient) -> Result<(), Box<dyn std::error::Error>> {
537 /// let query = ReplicationQueryBuilder::new("Property")
538 /// .top(2000)
539 /// .build()?;
540 ///
541 /// let mut response = client.execute_replication(&query).await?;
542 /// let mut total_records = response.record_count;
543 ///
544 /// // Continue fetching while next link is available
545 /// while let Some(next_link) = response.next_link {
546 /// response = client.execute_next_link(&next_link).await?;
547 /// total_records += response.record_count;
548 /// }
549 ///
550 /// println!("Total records fetched: {}", total_records);
551 /// # Ok(())
552 /// # }
553 /// ```
554 pub async fn execute_next_link(
555 &self,
556 next_link: &str,
557 ) -> Result<crate::replication::ReplicationResponse> {
558 use tracing::{debug, info};
559
560 info!("Executing next link: {}", next_link);
561
562 let response = self
563 .send_authenticated_request(next_link, "application/json")
564 .await?;
565
566 // Extract next link from response headers before consuming response
567 // The replication endpoint uses the "next" header (preferred) or "link" header
568 // to indicate more records are available. This must be extracted before reading
569 // the response body since consuming the response moves ownership.
570 let next_link = response
571 .headers()
572 .get("next")
573 .or_else(|| response.headers().get("link"))
574 .and_then(|v| v.to_str().ok())
575 .map(|s| s.to_string());
576
577 debug!("Next link from headers: {:?}", next_link);
578
579 let json = Self::parse_json_response(response).await?;
580
581 // Extract records from OData response envelope
582 // OData wraps result arrays in a "value" field: {"value": [...], "@odata.context": "..."}
583 let records = json
584 .get("value")
585 .and_then(|v| v.as_array())
586 .cloned()
587 .unwrap_or_default();
588
589 debug!("Retrieved {} records", records.len());
590
591 Ok(crate::replication::ReplicationResponse::new(
592 records, next_link,
593 ))
594 }
595}