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}