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}