Skip to main content

marketdata_core/rest/
client.rs

1//! REST client for Fugle marketdata API
2
3use super::auth::Auth;
4use super::retry::{self, RetryPolicy};
5use crate::errors::MarketDataError;
6use crate::tls::{build_rustls_config, TlsConfig};
7
8/// Main REST client with connection pooling via ureq Agent
9///
10/// The RestClient uses ureq's Agent for automatic connection pooling and reuse.
11/// Cloning the client is cheap - it shares the same connection pool.
12///
13/// # Connection Pooling
14///
15/// The underlying ureq Agent maintains a connection pool that:
16/// - Reuses TCP connections across multiple requests
17/// - Reduces connection overhead for subsequent requests
18/// - Automatically handles connection lifecycle
19///
20/// # Thread Safety
21///
22/// The RestClient is NOT Send/Sync due to ureq::Agent implementation.
23/// For multi-threaded usage, create a separate client per thread.
24pub struct RestClient {
25    agent: ureq::Agent,
26    auth: Auth,
27    base_url: String,
28    /// Optional retry policy. `None` (default) means each request is
29    /// attempted exactly once and any error propagates to the caller.
30    retry_policy: Option<RetryPolicy>,
31}
32
33impl RestClient {
34    /// Create a new REST client with authentication
35    ///
36    /// # Example
37    /// ```
38    /// use marketdata_core::{RestClient, Auth};
39    ///
40    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
41    /// ```
42    pub fn new(auth: Auth) -> Self {
43        // Building a default rustls config can only realistically fail if the
44        // crypto provider installs unexpectedly differently (extremely rare).
45        // Panic at construction so consumers get a clear failure mode instead
46        // of an opaque error on first request.
47        Self::with_tls(auth, TlsConfig::default())
48            .expect("default rustls config should build on supported platforms")
49    }
50
51    /// Create a REST client with custom TLS configuration (custom root CA
52    /// or "accept invalid certs"). Prefer `new()` for production usage
53    /// against public Fugle endpoints.
54    ///
55    /// Returns a `ConfigError` if the PEM in `tls.root_cert_pem` is malformed.
56    ///
57    /// # Errors
58    /// Returns [`MarketDataError`] on transport, deserialization, validation,
59    /// or non-2xx API failures.
60    pub fn with_tls(auth: Auth, tls: TlsConfig) -> Result<Self, MarketDataError> {
61        let tls_config = build_rustls_config(&tls)?;
62        let builder = ureq::AgentBuilder::new()
63            .timeout_read(std::time::Duration::from_secs(30))
64            .timeout_write(std::time::Duration::from_secs(30))
65            .tls_config(tls_config);
66
67        Ok(Self {
68            agent: builder.build(),
69            auth,
70            base_url: crate::urls::REST_BASE.to_string(),
71            retry_policy: None,
72        })
73    }
74
75    /// Enable transparent retry of failed requests.
76    ///
77    /// By default the client does not retry — observability use cases
78    /// need real failures visible. With a [`RetryPolicy`] installed,
79    /// errors for which [`MarketDataError::is_retryable`] returns `true`
80    /// (HTTP 429, HTTP 5xx, transport timeouts and connection errors)
81    /// are retried with exponential backoff plus jitter, up to
82    /// `max_attempts` total attempts. Other errors propagate immediately.
83    ///
84    /// # Example
85    /// ```
86    /// use marketdata_core::{Auth, RestClient, RetryPolicy};
87    ///
88    /// let client = RestClient::new(Auth::SdkToken("t".into()))
89    ///     .with_retry(RetryPolicy::conservative());
90    /// ```
91    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
92        self.retry_policy = Some(policy);
93        self
94    }
95
96    /// Execute a prepared `ureq::Request`, applying any installed
97    /// [`RetryPolicy`].
98    ///
99    /// Builders inside this crate route their `.call()` through here so
100    /// retry semantics remain centralized.
101    pub(crate) fn execute(
102        &self,
103        request: ureq::Request,
104    ) -> Result<ureq::Response, MarketDataError> {
105        match self.retry_policy {
106            Some(policy) => retry::run(&policy, || {
107                let req = request.clone();
108                req.call().map_err(MarketDataError::from)
109            }),
110            None => request.call().map_err(MarketDataError::from),
111        }
112    }
113
114    /// Override the base URL (useful for testing or custom endpoints)
115    ///
116    /// # Example
117    /// ```
118    /// use marketdata_core::{RestClient, Auth};
119    ///
120    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()))
121    ///     .base_url("https://custom.api.example.com");
122    /// ```
123    pub fn base_url(mut self, url: &str) -> Self {
124        self.base_url = url.to_string();
125        self
126    }
127
128    /// Access stock-related endpoints
129    ///
130    /// # Example
131    /// ```
132    /// use marketdata_core::{RestClient, Auth};
133    ///
134    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
135    /// let stock_client = client.stock();
136    /// ```
137    pub fn stock(&self) -> StockClient<'_> {
138        StockClient { client: self }
139    }
140
141    /// Access FutOpt (futures and options) endpoints
142    ///
143    /// # Example
144    /// ```
145    /// use marketdata_core::{RestClient, Auth};
146    ///
147    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
148    /// let futopt_client = client.futopt();
149    /// ```
150    pub fn futopt(&self) -> super::futopt::FutOptClient<'_> {
151        super::futopt::FutOptClient { client: self }
152    }
153
154    /// Internal helper to get the agent
155    pub(crate) fn agent(&self) -> &ureq::Agent {
156        &self.agent
157    }
158
159    /// Internal helper to get the auth
160    pub(crate) fn auth(&self) -> &Auth {
161        &self.auth
162    }
163
164    /// Internal helper to get the base URL
165    pub(crate) fn get_base_url(&self) -> &str {
166        &self.base_url
167    }
168}
169
170impl Clone for RestClient {
171    /// Clone the RestClient, sharing the same connection pool
172    ///
173    /// Cloning is cheap because ureq::Agent internally uses Arc for connection pool sharing.
174    /// Multiple cloned clients will share the same connection pool.
175    fn clone(&self) -> Self {
176        Self {
177            agent: self.agent.clone(),
178            auth: self.auth.clone(),
179            base_url: self.base_url.clone(),
180            retry_policy: self.retry_policy,
181        }
182    }
183}
184
185/// Stock-related endpoints client
186pub struct StockClient<'a> {
187    client: &'a RestClient,
188}
189
190impl<'a> StockClient<'a> {
191    /// Access intraday (real-time) endpoints
192    ///
193    /// # Example
194    /// ```
195    /// use marketdata_core::{RestClient, Auth};
196    ///
197    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
198    /// let intraday = client.stock().intraday();
199    /// ```
200    pub fn intraday(&self) -> IntradayClient<'a> {
201        IntradayClient {
202            client: self.client,
203        }
204    }
205
206    /// Access historical data endpoints
207    ///
208    /// # Example
209    /// ```
210    /// use marketdata_core::{RestClient, Auth};
211    ///
212    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
213    /// let historical = client.stock().historical();
214    /// ```
215    pub fn historical(&self) -> HistoricalClient<'a> {
216        HistoricalClient {
217            client: self.client,
218        }
219    }
220
221    /// Access technical indicator endpoints
222    ///
223    /// # Example
224    /// ```
225    /// use marketdata_core::{RestClient, Auth};
226    ///
227    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
228    /// let technical = client.stock().technical();
229    /// ```
230    pub fn technical(&self) -> crate::rest::stock::technical::TechnicalClient<'a> {
231        crate::rest::stock::technical::TechnicalClient::new(self.client)
232    }
233
234    /// Access snapshot endpoints for market-wide data
235    ///
236    /// # Example
237    /// ```
238    /// use marketdata_core::{RestClient, Auth};
239    ///
240    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
241    /// let snapshot = client.stock().snapshot();
242    /// ```
243    pub fn snapshot(&self) -> crate::rest::stock::snapshot::SnapshotClient<'a> {
244        crate::rest::stock::snapshot::SnapshotClient::new(self.client)
245    }
246
247    /// Access corporate actions endpoints (capital changes, dividends, IPO listings)
248    ///
249    /// # Example
250    /// ```
251    /// use marketdata_core::{RestClient, Auth};
252    ///
253    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
254    /// let corporate_actions = client.stock().corporate_actions();
255    /// ```
256    pub fn corporate_actions(&self) -> CorporateActionsClient<'a> {
257        CorporateActionsClient {
258            client: self.client,
259        }
260    }
261}
262
263/// Corporate actions endpoints client
264pub struct CorporateActionsClient<'a> {
265    client: &'a RestClient,
266}
267
268impl<'a> CorporateActionsClient<'a> {
269    /// Get capital structure changes
270    ///
271    /// # Example
272    /// ```no_run
273    /// use marketdata_core::{RestClient, Auth};
274    ///
275    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
276    /// let changes = client.stock().corporate_actions().capital_changes().send()?;
277    /// # Ok::<(), marketdata_core::MarketDataError>(())
278    /// ```
279    pub fn capital_changes(&self) -> crate::rest::stock::corporate_actions::CapitalChangesRequestBuilder<'_> {
280        crate::rest::stock::corporate_actions::CapitalChangesRequestBuilder::new(self.client)
281    }
282
283    /// Get dividend announcements
284    ///
285    /// # Example
286    /// ```no_run
287    /// use marketdata_core::{RestClient, Auth};
288    ///
289    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
290    /// let dividends = client.stock().corporate_actions().dividends().send()?;
291    /// # Ok::<(), marketdata_core::MarketDataError>(())
292    /// ```
293    pub fn dividends(&self) -> crate::rest::stock::corporate_actions::DividendsRequestBuilder<'_> {
294        crate::rest::stock::corporate_actions::DividendsRequestBuilder::new(self.client)
295    }
296
297    /// Get IPO listing applicants
298    ///
299    /// # Example
300    /// ```no_run
301    /// use marketdata_core::{RestClient, Auth};
302    ///
303    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
304    /// let applicants = client.stock().corporate_actions().listing_applicants().send()?;
305    /// # Ok::<(), marketdata_core::MarketDataError>(())
306    /// ```
307    pub fn listing_applicants(&self) -> crate::rest::stock::corporate_actions::ListingApplicantsRequestBuilder<'_> {
308        crate::rest::stock::corporate_actions::ListingApplicantsRequestBuilder::new(self.client)
309    }
310}
311
312/// Historical data endpoints client
313pub struct HistoricalClient<'a> {
314    client: &'a RestClient,
315}
316
317impl<'a> HistoricalClient<'a> {
318    /// Get historical candles for a symbol
319    ///
320    /// # Example
321    /// ```no_run
322    /// use marketdata_core::{RestClient, Auth};
323    ///
324    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
325    /// let candles = client.stock().historical().candles()
326    ///     .symbol("2330")
327    ///     .from("2024-01-01")
328    ///     .to("2024-01-31")
329    ///     .send()?;
330    /// # Ok::<(), marketdata_core::MarketDataError>(())
331    /// ```
332    pub fn candles(&self) -> crate::rest::stock::historical::HistoricalCandlesRequestBuilder<'_> {
333        crate::rest::stock::historical::HistoricalCandlesRequestBuilder::new(self.client)
334    }
335
336    /// Get historical stats for a symbol
337    ///
338    /// # Example
339    /// ```no_run
340    /// use marketdata_core::{RestClient, Auth};
341    ///
342    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
343    /// let stats = client.stock().historical().stats()
344    ///     .symbol("2330")
345    ///     .send()?;
346    /// # Ok::<(), marketdata_core::MarketDataError>(())
347    /// ```
348    pub fn stats(&self) -> crate::rest::stock::historical::StatsRequestBuilder<'_> {
349        crate::rest::stock::historical::StatsRequestBuilder::new(self.client)
350    }
351}
352
353/// Intraday (real-time) endpoints client
354pub struct IntradayClient<'a> {
355    client: &'a RestClient,
356}
357
358impl<'a> IntradayClient<'a> {
359    /// Get intraday quote for a symbol
360    ///
361    /// # Example
362    /// ```no_run
363    /// use marketdata_core::{RestClient, Auth};
364    ///
365    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
366    /// let quote = client.stock().intraday().quote().symbol("2330").send()?;
367    /// # Ok::<(), marketdata_core::MarketDataError>(())
368    /// ```
369    pub fn quote(&self) -> crate::rest::stock::intraday::QuoteRequestBuilder<'_> {
370        crate::rest::stock::intraday::QuoteRequestBuilder::new(self.client)
371    }
372
373    /// Get intraday ticker info for a symbol
374    ///
375    /// # Example
376    /// ```no_run
377    /// use marketdata_core::{RestClient, Auth};
378    ///
379    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
380    /// let ticker = client.stock().intraday().ticker().symbol("2330").send()?;
381    /// # Ok::<(), marketdata_core::MarketDataError>(())
382    /// ```
383    pub fn ticker(&self) -> crate::rest::stock::intraday::TickerRequestBuilder<'_> {
384        crate::rest::stock::intraday::TickerRequestBuilder::new(self.client)
385    }
386
387    /// Get intraday tickers (batch list) for a security type
388    ///
389    /// # Example
390    /// ```no_run
391    /// use marketdata_core::{RestClient, Auth};
392    ///
393    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
394    /// let tickers = client.stock().intraday().tickers().typ("EQUITY").send()?;
395    /// # Ok::<(), marketdata_core::MarketDataError>(())
396    /// ```
397    pub fn tickers(&self) -> crate::rest::stock::intraday::TickersRequestBuilder<'_> {
398        crate::rest::stock::intraday::TickersRequestBuilder::new(self.client)
399    }
400
401    /// Get intraday candles for a symbol
402    ///
403    /// # Example
404    /// ```no_run
405    /// use marketdata_core::{RestClient, Auth};
406    ///
407    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
408    /// let candles = client.stock().intraday().candles().symbol("2330").timeframe("5").send()?;
409    /// # Ok::<(), marketdata_core::MarketDataError>(())
410    /// ```
411    pub fn candles(&self) -> crate::rest::stock::intraday::CandlesRequestBuilder<'_> {
412        crate::rest::stock::intraday::CandlesRequestBuilder::new(self.client)
413    }
414
415    /// Get intraday trades for a symbol
416    ///
417    /// # Example
418    /// ```no_run
419    /// use marketdata_core::{RestClient, Auth};
420    ///
421    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
422    /// let trades = client.stock().intraday().trades().symbol("2330").send()?;
423    /// # Ok::<(), marketdata_core::MarketDataError>(())
424    /// ```
425    pub fn trades(&self) -> crate::rest::stock::intraday::TradesRequestBuilder<'_> {
426        crate::rest::stock::intraday::TradesRequestBuilder::new(self.client)
427    }
428
429    /// Get intraday volumes for a symbol
430    ///
431    /// # Example
432    /// ```no_run
433    /// use marketdata_core::{RestClient, Auth};
434    ///
435    /// let client = RestClient::new(Auth::SdkToken("my-token".to_string()));
436    /// let volumes = client.stock().intraday().volumes().symbol("2330").send()?;
437    /// # Ok::<(), marketdata_core::MarketDataError>(())
438    /// ```
439    pub fn volumes(&self) -> crate::rest::stock::intraday::VolumesRequestBuilder<'_> {
440        crate::rest::stock::intraday::VolumesRequestBuilder::new(self.client)
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_rest_client_creation() {
450        let client = RestClient::new(Auth::SdkToken("test-token".to_string()));
451        assert_eq!(client.get_base_url(), "https://api.fugle.tw/marketdata/v1.0");
452    }
453
454    #[test]
455    fn test_rest_client_custom_base_url() {
456        let client = RestClient::new(Auth::SdkToken("test-token".to_string()))
457            .base_url("https://custom.example.com");
458        assert_eq!(client.get_base_url(), "https://custom.example.com");
459    }
460
461    #[test]
462    fn test_stock_client_creation() {
463        let client = RestClient::new(Auth::ApiKey("test-key".to_string()));
464        let stock_client = client.stock();
465        assert_eq!(stock_client.client.get_base_url(), "https://api.fugle.tw/marketdata/v1.0");
466    }
467
468    #[test]
469    fn test_intraday_client_creation() {
470        let client = RestClient::new(Auth::BearerToken("test-bearer".to_string()));
471        let intraday = client.stock().intraday();
472        assert_eq!(intraday.client.get_base_url(), "https://api.fugle.tw/marketdata/v1.0");
473    }
474
475    #[test]
476    fn test_chained_client_access() {
477        let client = RestClient::new(Auth::SdkToken("test".to_string()));
478        let _intraday = client.stock().intraday();
479        // Compilation success proves the chaining works
480    }
481
482    #[test]
483    fn test_auth_types() {
484        // Test all three auth types
485        let _client1 = RestClient::new(Auth::ApiKey("key".to_string()));
486        let _client2 = RestClient::new(Auth::BearerToken("token".to_string()));
487        let _client3 = RestClient::new(Auth::SdkToken("sdk".to_string()));
488    }
489
490    #[test]
491    fn test_client_clone() {
492        let client = RestClient::new(Auth::SdkToken("test".to_string()));
493        let cloned = client.clone();
494
495        // Cloned client should have same base URL and auth
496        assert_eq!(client.get_base_url(), cloned.get_base_url());
497    }
498
499    #[test]
500    fn test_connection_pool_sharing() {
501        // Create client with connection pool
502        let client = RestClient::new(Auth::SdkToken("test".to_string()));
503
504        // Clone shares the same connection pool (via Arc in ureq::Agent)
505        let cloned = client.clone();
506
507        // Both clients should be usable
508        let _stock1 = client.stock().intraday();
509        let _stock2 = cloned.stock().intraday();
510
511        // Compilation and execution success proves connection pool works
512    }
513
514    #[test]
515    fn test_custom_base_url_preserved_in_clone() {
516        let client = RestClient::new(Auth::SdkToken("test".to_string()))
517            .base_url("https://custom.example.com");
518
519        let cloned = client.clone();
520        assert_eq!(cloned.get_base_url(), "https://custom.example.com");
521    }
522
523    #[test]
524    fn test_futopt_client_creation() {
525        let client = RestClient::new(Auth::SdkToken("test".to_string()));
526        let futopt = client.futopt();
527        assert_eq!(futopt.client.get_base_url(), "https://api.fugle.tw/marketdata/v1.0");
528    }
529
530    #[test]
531    fn test_futopt_intraday_client_creation() {
532        let client = RestClient::new(Auth::SdkToken("test".to_string()));
533        let intraday = client.futopt().intraday();
534        assert_eq!(intraday.client.get_base_url(), "https://api.fugle.tw/marketdata/v1.0");
535    }
536
537    #[test]
538    fn test_futopt_chained_client_access() {
539        let client = RestClient::new(Auth::SdkToken("test".to_string()));
540        let _intraday = client.futopt().intraday();
541        // Compilation success proves the chaining works
542    }
543
544    #[test]
545    fn test_both_stock_and_futopt() {
546        let client = RestClient::new(Auth::SdkToken("test".to_string()));
547
548        // Both stock and futopt should be accessible from the same client
549        let _stock = client.stock().intraday();
550        let _futopt = client.futopt().intraday();
551    }
552
553    #[test]
554    fn test_corporate_actions_client_creation() {
555        let client = RestClient::new(Auth::SdkToken("test".to_string()));
556        let corporate_actions = client.stock().corporate_actions();
557        assert_eq!(corporate_actions.client.get_base_url(), "https://api.fugle.tw/marketdata/v1.0");
558    }
559
560    #[test]
561    fn test_corporate_actions_chained_access() {
562        let client = RestClient::new(Auth::SdkToken("test".to_string()));
563        // Test that all corporate actions endpoints are accessible
564        let _capital_changes = client.stock().corporate_actions().capital_changes();
565        let _dividends = client.stock().corporate_actions().dividends();
566        let _listing_applicants = client.stock().corporate_actions().listing_applicants();
567    }
568}