1use crate::auth::Auth;
4use crate::auth::oauth2::TokenCache;
5use crate::auth::token_endpoint::TokenEndpointCache;
6use crate::config::RestStreamConfig;
7use crate::extract;
8use crate::pagination::{PaginationState, PaginationStyle};
9use crate::retry;
10use async_trait::async_trait;
11use faucet_core::replication::{
12 ReplicationMethod, filter_incremental, max_replication_value, max_value,
13};
14use faucet_core::schema;
15use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider};
16use futures_core::Stream;
17use reqwest::Client;
18use reqwest::header::HeaderMap;
19use serde::Deserialize;
20use serde_json::Value;
21use std::collections::HashMap;
22use std::pin::Pin;
23use std::sync::Arc;
24use std::time::Duration;
25use tokio::sync::Mutex as AsyncMutex;
26
27pub struct RestStream {
29 config: RestStreamConfig,
30 client: Client,
31 token_cache: TokenCache,
33 token_endpoint_cache: TokenEndpointCache,
35 auth_provider: Option<SharedAuthProvider>,
41 runtime_start: Arc<AsyncMutex<Option<Value>>>,
45 retry_policy: faucet_core::RetryPolicy,
53}
54
55const DEFAULT_MAX_RETRIES: u32 = 3;
59const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_secs(1);
62
63fn credential_to_auth(cred: Credential) -> Auth {
66 match cred {
67 Credential::Bearer(token) => Auth::Bearer { token },
68 Credential::Token(token) => Auth::Custom {
69 headers: std::iter::once(("Authorization".to_string(), token)).collect(),
70 },
71 Credential::Basic { username, password } => Auth::Basic { username, password },
72 Credential::Header { name, value } => Auth::Custom {
73 headers: std::iter::once((name, value)).collect(),
74 },
75 }
76}
77
78impl RestStream {
79 pub fn new(config: RestStreamConfig) -> Result<Self, FaucetError> {
81 let expiry_ratio_to_validate = match &config.auth {
83 AuthSpec::Inline(Auth::OAuth2 { expiry_ratio, .. })
84 | AuthSpec::Inline(Auth::TokenEndpoint { expiry_ratio, .. }) => Some(*expiry_ratio),
85 _ => None,
86 };
87 if let Some(ratio) = expiry_ratio_to_validate
88 && (ratio <= 0.0 || ratio > 1.0)
89 {
90 return Err(FaucetError::Auth(format!(
91 "expiry_ratio must be in (0.0, 1.0], got {ratio}"
92 )));
93 }
94
95 let mut builder = Client::builder();
96 if let Some(t) = config.timeout {
97 builder = builder.timeout(t);
98 }
99 let retry_policy = faucet_core::RetryPolicy {
104 max_attempts: config.max_retries.saturating_add(1),
105 backoff: faucet_core::BackoffKind::Exponential,
106 base: config.retry_backoff,
107 ..faucet_core::RetryPolicy::default()
108 };
109 Ok(Self {
110 config,
111 client: builder.build()?,
112 token_cache: TokenCache::new(),
113 token_endpoint_cache: TokenEndpointCache::new(),
114 auth_provider: None,
115 runtime_start: Arc::new(AsyncMutex::new(None)),
116 retry_policy,
117 })
118 }
119
120 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
127 self.auth_provider = Some(provider);
128 self
129 }
130
131 pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
150 let user_changed_legacy_fields = self.config.max_retries != DEFAULT_MAX_RETRIES
151 || self.config.retry_backoff != DEFAULT_RETRY_BACKOFF;
152 if !user_changed_legacy_fields {
153 self.retry_policy = policy;
154 }
155 self
156 }
157
158 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
167 if self.config.partitions.is_empty() {
168 self.fetch_partition(None, None).await
169 } else if let Some(concurrency) = self.config.partition_concurrency {
170 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency.max(1)));
172 let mut handles = Vec::with_capacity(self.config.partitions.len());
173
174 for ctx in &self.config.partitions {
175 let permit =
176 semaphore.clone().acquire_owned().await.map_err(|e| {
177 FaucetError::Config(format!("semaphore acquire failed: {e}"))
178 })?;
179 let fut = self.fetch_partition(Some(ctx), None);
180 handles.push(async move {
181 let result = fut.await;
182 drop(permit);
183 result
184 });
185 }
186
187 let results = futures::future::try_join_all(handles).await?;
188 Ok(results.into_iter().flatten().collect())
189 } else {
190 let mut all_records = Vec::new();
191 for ctx in &self.config.partitions {
192 let records = self.fetch_partition(Some(ctx), None).await?;
193 all_records.extend(records);
194 }
195 Ok(all_records)
196 }
197 }
198
199 pub async fn fetch_all_as<T: for<'de> Deserialize<'de>>(&self) -> Result<Vec<T>, FaucetError> {
201 let values = self.fetch_all().await?;
202 values
203 .into_iter()
204 .map(|v| serde_json::from_value(v).map_err(FaucetError::Json))
205 .collect()
206 }
207
208 pub async fn infer_schema(&self) -> Result<Value, FaucetError> {
220 if let Some(ref s) = self.config.schema {
221 return Ok(s.clone());
222 }
223 let limit = match self.config.schema_sample_size {
224 0 => None,
225 n => Some(n),
226 };
227 let records = self.fetch_partition(None, limit).await?;
228 Ok(schema::infer_schema(&records))
229 }
230
231 pub async fn fetch_all_incremental(&self) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
240 let records = self.fetch_all().await?;
241 let bookmark = self
242 .config
243 .replication_key
244 .as_deref()
245 .and_then(|key| max_replication_value(&records, key))
246 .cloned();
247 Ok((records, bookmark))
248 }
249
250 pub fn stream_pages(
276 &self,
277 ) -> Pin<Box<dyn Stream<Item = Result<Vec<Value>, FaucetError>> + Send + '_>> {
278 let mut inner = self.stream_pages_inner(None);
279 Box::pin(async_stream::try_stream! {
280 loop {
281 let page = std::future::poll_fn(|cx| inner.as_mut().poll_next(cx)).await;
282 match page {
283 Some(Ok(p)) => yield p.records,
284 Some(Err(e)) => Err(e)?,
285 None => break,
286 }
287 }
288 })
289 }
290
291 fn stream_pages_inner(
301 &self,
302 context: Option<&HashMap<String, Value>>,
303 ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + '_>> {
304 let owned_context: Option<HashMap<String, Value>> = context.cloned();
307
308 Box::pin(async_stream::try_stream! {
309 let effective_start: Option<Value> = {
314 let guard = self.runtime_start.lock().await;
315 guard
316 .clone()
317 .or_else(|| self.config.start_replication_value.clone())
318 };
319
320 let mut state = PaginationState::default();
321 let mut pages_fetched = 0usize;
322 let mut running_max: Option<Value> = effective_start.clone();
323 let mut bookmark_emitted = false;
324
325 if self.config.max_pages.is_some()
335 && self.config.replication_method == ReplicationMethod::Incremental
336 && self.config.replication_key.is_some()
337 {
338 tracing::warn!(
339 "max_pages combined with incremental replication assumes the API returns rows \
340 ordered ascending by the replication key; an unordered feed can drop unfetched \
341 lower-key records on resume. Ensure ordering, or remove max_pages for a full \
342 incremental sweep."
343 );
344 }
345
346 loop {
347 if let Some(max) = self.config.max_pages
348 && pages_fetched >= max
349 {
350 tracing::warn!("max pages ({max}) reached");
351 break;
352 }
353
354 let mut params = self.config.query_params.clone();
355 self.config.pagination.apply_params(&mut params, &state);
356
357 let url_override = match &self.config.pagination {
358 PaginationStyle::LinkHeader | PaginationStyle::NextLinkInBody { .. } => {
359 state.next_link.clone()
360 }
361 _ => None,
362 };
363
364 let params_clone = params.clone();
365 let ctx_ref = owned_context.as_ref();
366 let is_first_page = pages_fetched == 0;
367 let (body, resp_headers) = retry::execute_with_retry(
368 self.retry_policy.max_attempts.saturating_sub(1),
374 self.retry_policy.base,
375 || {
376 self.execute_request(
377 ¶ms_clone,
378 url_override.as_deref(),
379 ctx_ref,
380 is_first_page,
381 )
382 },
383 )
384 .await?;
385
386 let raw_records =
387 extract::extract_records(&body, self.config.records_path.as_deref())?;
388 let raw_count = raw_records.len();
389
390 let records =
391 if self.config.replication_method == ReplicationMethod::Incremental {
392 if let (Some(key), Some(start)) =
393 (&self.config.replication_key, effective_start.as_ref())
394 {
395 filter_incremental(raw_records, key, start)
396 } else {
397 raw_records
398 }
399 } else {
400 raw_records
401 };
402
403 if self.config.replication_method == ReplicationMethod::Incremental
406 && let Some(key) = self.config.replication_key.as_deref()
407 && let Some(page_max) = max_replication_value(&records, key) {
408 let page_max = page_max.clone();
409 running_max = Some(match running_max.take() {
410 Some(prev) => max_value(prev, page_max),
411 None => page_max,
412 });
413 }
414
415 let has_next = self
422 .config
423 .pagination
424 .advance(&body, &resp_headers, &mut state, raw_count)?;
425 pages_fetched += 1;
426
427 if has_next {
428 yield faucet_core::StreamPage { records, bookmark: None };
431 } else {
432 bookmark_emitted = running_max.is_some();
434 yield faucet_core::StreamPage {
435 records,
436 bookmark: running_max.clone(),
437 };
438 break;
439 }
440
441 if let Some(delay) = self.config.request_delay {
442 tokio::time::sleep(delay).await;
443 }
444 }
445
446 if !bookmark_emitted && running_max.is_some() {
454 yield faucet_core::StreamPage {
455 records: Vec::new(),
456 bookmark: running_max,
457 };
458 }
459 })
460 }
461
462 async fn fetch_partition(
467 &self,
468 context: Option<&HashMap<String, Value>>,
469 max_records: Option<usize>,
470 ) -> Result<Vec<Value>, FaucetError> {
471 let mut all_records = Vec::new();
472 let mut pages_fetched = 0usize;
473 let mut pages = self.stream_pages_inner(context);
474
475 loop {
477 let page = std::future::poll_fn(|cx: &mut std::task::Context<'_>| {
478 pages.as_mut().poll_next(cx)
479 })
480 .await;
481
482 match page {
483 Some(Ok(page)) => {
484 pages_fetched += 1;
485 let records = page.records;
486 match max_records {
487 Some(limit) => {
488 let remaining = limit.saturating_sub(all_records.len());
489 all_records.extend(records.into_iter().take(remaining));
490 if all_records.len() >= limit {
491 break;
492 }
493 }
494 None => all_records.extend(records),
495 }
496 }
497 Some(Err(e)) => return Err(e),
498 None => break,
499 }
500 }
501
502 tracing::info!(
503 stream = self.config.name.as_deref().unwrap_or("(unnamed)"),
504 records = all_records.len(),
505 pages = pages_fetched,
506 "fetch complete"
507 );
508 Ok(all_records)
509 }
510
511 async fn execute_request(
522 &self,
523 params: &HashMap<String, String>,
524 url_override: Option<&str>,
525 path_context: Option<&HashMap<String, Value>>,
526 is_first_page: bool,
527 ) -> Result<(Value, HeaderMap), FaucetError> {
528 match self
529 .execute_request_once(params, url_override, path_context, is_first_page)
530 .await
531 {
532 Err(FaucetError::HttpStatus { status: 401, .. }) if self.uses_inline_cached_token() => {
533 tracing::warn!(
534 "401 Unauthorized with a cached inline OAuth2/TokenEndpoint token; \
535 invalidating the token cache and retrying once with a fresh token"
536 );
537 self.invalidate_inline_token_cache().await;
538 self.execute_request_once(params, url_override, path_context, is_first_page)
539 .await
540 }
541 other => other,
542 }
543 }
544
545 fn uses_inline_cached_token(&self) -> bool {
549 self.auth_provider.is_none()
550 && matches!(
551 self.config.auth,
552 AuthSpec::Inline(Auth::OAuth2 { .. })
553 | AuthSpec::Inline(Auth::TokenEndpoint { .. })
554 )
555 }
556
557 async fn invalidate_inline_token_cache(&self) {
560 match &self.config.auth {
561 AuthSpec::Inline(Auth::OAuth2 { .. }) => self.token_cache.invalidate().await,
562 AuthSpec::Inline(Auth::TokenEndpoint { .. }) => {
563 self.token_endpoint_cache.invalidate().await
564 }
565 _ => {}
566 }
567 }
568
569 async fn execute_request_once(
576 &self,
577 params: &HashMap<String, String>,
578 url_override: Option<&str>,
579 path_context: Option<&HashMap<String, Value>>,
580 is_first_page: bool,
581 ) -> Result<(Value, HeaderMap), FaucetError> {
582 let use_override = url_override.is_some();
583 let url = match url_override {
584 Some(u) => u.to_string(),
585 None => {
586 let path = match path_context {
587 Some(ctx) => faucet_core::util::substitute_context(&self.config.path, ctx),
588 None => self.config.path.clone(),
589 };
590 format!("{}/{}", self.config.base_url, path.trim_start_matches('/'))
591 }
592 };
593
594 let resolved_auth = if let Some(provider) = &self.auth_provider {
600 credential_to_auth(provider.credential().await?)
601 } else {
602 match &self.config.auth {
603 AuthSpec::Inline(Auth::OAuth2 {
604 token_url,
605 client_id,
606 client_secret,
607 scopes,
608 expiry_ratio,
609 }) => {
610 let token = self
611 .token_cache
612 .get_or_refresh(
613 &self.client,
614 token_url,
615 client_id,
616 client_secret,
617 scopes,
618 *expiry_ratio,
619 )
620 .await?;
621 Auth::Bearer { token }
622 }
623 AuthSpec::Inline(Auth::TokenEndpoint {
624 url: token_url,
625 method: token_method,
626 headers: token_headers,
627 body: token_body,
628 token_path,
629 expiry_path,
630 expiry_ratio,
631 response_validator,
632 }) => {
633 let token = self
634 .token_endpoint_cache
635 .get_or_refresh(
636 &self.client,
637 token_url,
638 token_method,
639 token_headers,
640 token_body.as_ref(),
641 token_path,
642 expiry_path.as_deref(),
643 *expiry_ratio,
644 response_validator.as_ref(),
645 )
646 .await?;
647 Auth::Bearer { token }
648 }
649 AuthSpec::Inline(other) => other.clone(),
650 AuthSpec::Reference(r) => {
651 return Err(FaucetError::Auth(format!(
652 "auth references provider '{}' but no provider was supplied; \
653 set one via the CLI `auth:` catalog or `with_auth_provider`",
654 r.name
655 )));
656 }
657 }
658 };
659
660 let mut headers = self.config.headers.clone();
661 resolved_auth.apply(&mut headers)?;
662
663 let mut req = self
664 .client
665 .request(self.config.method.clone(), &url)
666 .headers(headers);
667
668 if !use_override {
669 if let Some(ctx) = path_context {
672 let substituted: HashMap<String, String> = params
673 .iter()
674 .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, ctx)))
675 .collect();
676 req = req.query(&substituted.iter().collect::<Vec<_>>());
677 } else {
678 req = req.query(params);
679 }
680 }
681
682 if let AuthSpec::Inline(Auth::ApiKeyQuery { param, value }) = &self.config.auth {
684 req = req.query(&[(param.as_str(), value.as_str())]);
685 }
686
687 if let Some(body) = &self.config.body {
688 if let Some(ctx) = path_context {
690 let body_str = body.to_string();
691 let substituted = faucet_core::util::substitute_context(&body_str, ctx);
692 let substituted_value: Value =
693 serde_json::from_str(&substituted).unwrap_or(Value::String(substituted));
694 req = req.json(&substituted_value);
695 } else {
696 req = req.json(body);
697 }
698 }
699
700 let resp = req.send().await?;
701 let status = resp.status();
702
703 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
705 let wait = parse_retry_after(resp.headers());
706 return Err(FaucetError::RateLimited(wait));
707 }
708
709 if is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
717 tracing::debug!(
718 status = status.as_u16(),
719 "tolerated HTTP error on first request; treating as empty page"
720 );
721 return Ok((Value::Array(vec![]), HeaderMap::new()));
722 }
723 if !is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
724 tracing::warn!(
725 status = status.as_u16(),
726 "tolerated HTTP error mid-pagination; surfacing as an error to avoid \
727 silently truncating the stream"
728 );
729 }
730
731 if !status.is_success() {
735 let resp_url = resp.url().to_string();
736 let body_text = resp.text().await.unwrap_or_default();
737 let truncated = if body_text.len() > 1024 {
739 let end = body_text.floor_char_boundary(1024);
741 format!("{}...(truncated)", &body_text[..end])
742 } else {
743 body_text
744 };
745 return Err(FaucetError::HttpStatus {
746 status: status.as_u16(),
747 url: resp_url,
748 body: truncated,
749 });
750 }
751
752 let resp_headers = resp.headers().clone();
753
754 if status == reqwest::StatusCode::NO_CONTENT {
760 return Ok((Value::Array(vec![]), resp_headers));
761 }
762 let bytes = resp.bytes().await?;
763 if bytes.iter().all(u8::is_ascii_whitespace) {
764 return Ok((Value::Array(vec![]), resp_headers));
765 }
766 let body: Value = serde_json::from_slice(&bytes)?;
767 Ok((body, resp_headers))
768 }
769}
770
771fn parse_retry_after(headers: &HeaderMap) -> Duration {
776 const DEFAULT: Duration = Duration::from_secs(60);
777 let Some(raw) = headers
778 .get(reqwest::header::RETRY_AFTER)
779 .and_then(|v| v.to_str().ok())
780 .map(str::trim)
781 else {
782 return DEFAULT;
783 };
784 if let Ok(secs) = raw.parse::<u64>() {
786 return Duration::from_secs(secs);
787 }
788 if let Ok(when) = httpdate::parse_http_date(raw) {
790 return when
791 .duration_since(std::time::SystemTime::now())
792 .unwrap_or(Duration::ZERO);
793 }
794 DEFAULT
795}
796
797#[async_trait]
798impl faucet_core::Source for RestStream {
799 async fn fetch_with_context(
800 &self,
801 context: &std::collections::HashMap<String, serde_json::Value>,
802 ) -> Result<Vec<Value>, FaucetError> {
803 if context.is_empty() {
804 RestStream::fetch_all(self).await
806 } else if self.config.partitions.is_empty() {
807 self.fetch_partition(Some(context), None).await
809 } else {
810 let mut all_records = Vec::new();
812 for partition in &self.config.partitions {
813 let mut merged = context.clone();
814 merged.extend(partition.iter().map(|(k, v)| (k.clone(), v.clone())));
815 all_records.extend(self.fetch_partition(Some(&merged), None).await?);
816 }
817 Ok(all_records)
818 }
819 }
820
821 async fn fetch_with_context_incremental(
822 &self,
823 context: &std::collections::HashMap<String, serde_json::Value>,
824 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
825 let records = self.fetch_with_context(context).await?;
826 let bookmark = self
827 .config
828 .replication_key
829 .as_deref()
830 .and_then(|key| faucet_core::replication::max_replication_value(&records, key))
831 .cloned();
832 Ok((records, bookmark))
833 }
834
835 fn connector_name(&self) -> &'static str {
836 "rest"
837 }
838
839 fn config_schema(&self) -> serde_json::Value {
840 serde_json::to_value(faucet_core::schema_for!(RestStreamConfig))
841 .expect("schema serialization")
842 }
843
844 fn dataset_uri(&self) -> String {
845 format!(
846 "{}{}",
847 faucet_core::redact_uri_credentials(&self.config.base_url),
848 self.config.path
849 )
850 }
851
852 fn state_key(&self) -> Option<String> {
853 self.config.state_key.clone()
854 }
855
856 fn stream_pages<'a>(
857 &'a self,
858 context: &'a HashMap<String, Value>,
859 _batch_size: usize,
860 ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + 'a>> {
861 self.stream_pages_inner(Some(context))
865 }
866
867 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
868 *self.runtime_start.lock().await = Some(bookmark);
869 Ok(())
870 }
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876 use serde_json::json;
877
878 #[test]
879 fn injected_policy_applies_when_legacy_fields_at_defaults() {
880 let stream =
882 RestStream::new(RestStreamConfig::new("https://api.example.com", "/items")).unwrap();
883 let injected = faucet_core::RetryPolicy {
884 max_attempts: 9,
885 base: Duration::from_secs(7),
886 ..faucet_core::RetryPolicy::default()
887 };
888 let stream = stream.with_retry_policy(injected);
889 assert_eq!(stream.retry_policy.max_attempts, 9);
890 assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
891 }
892
893 #[test]
894 fn legacy_fields_take_precedence_over_injected_policy() {
895 let config = RestStreamConfig::new("https://api.example.com", "/items").max_retries(7);
898 let stream = RestStream::new(config).unwrap();
899 assert_eq!(stream.retry_policy.max_attempts, 8);
901 let injected = faucet_core::RetryPolicy {
902 max_attempts: 99,
903 base: Duration::from_secs(42),
904 ..faucet_core::RetryPolicy::default()
905 };
906 let stream = stream.with_retry_policy(injected);
907 assert_eq!(stream.retry_policy.max_attempts, 8);
909 assert_eq!(stream.retry_policy.base, DEFAULT_RETRY_BACKOFF);
910 }
911
912 #[test]
913 fn test_substitute_context_substitutes_placeholders() {
914 let mut ctx = HashMap::new();
915 ctx.insert("org_id".to_string(), json!("acme"));
916 ctx.insert("repo".to_string(), json!("myrepo"));
917 let result =
918 faucet_core::util::substitute_context("/orgs/{org_id}/repos/{repo}/issues", &ctx);
919 assert_eq!(result, "/orgs/acme/repos/myrepo/issues");
920 }
921
922 #[test]
923 fn test_substitute_context_no_placeholders() {
924 let ctx = HashMap::new();
925 let result = faucet_core::util::substitute_context("/api/users", &ctx);
926 assert_eq!(result, "/api/users");
927 }
928
929 #[test]
930 fn test_substitute_context_numeric_value() {
931 let mut ctx = HashMap::new();
932 ctx.insert("id".to_string(), json!(42));
933 let result = faucet_core::util::substitute_context("/items/{id}", &ctx);
934 assert_eq!(result, "/items/42");
935 }
936
937 #[test]
938 fn test_parse_retry_after_valid() {
939 let mut headers = HeaderMap::new();
940 headers.insert(
941 reqwest::header::RETRY_AFTER,
942 reqwest::header::HeaderValue::from_static("30"),
943 );
944 assert_eq!(parse_retry_after(&headers), Duration::from_secs(30));
945 }
946
947 #[test]
948 fn test_parse_retry_after_missing_defaults_to_60() {
949 assert_eq!(
950 parse_retry_after(&HeaderMap::new()),
951 Duration::from_secs(60)
952 );
953 }
954
955 #[test]
956 fn test_parse_retry_after_non_numeric_defaults_to_60() {
957 let mut headers = HeaderMap::new();
958 headers.insert(
959 reqwest::header::RETRY_AFTER,
960 reqwest::header::HeaderValue::from_static("not-a-number"),
961 );
962 assert_eq!(parse_retry_after(&headers), Duration::from_secs(60));
963 }
964
965 #[test]
966 fn test_parse_retry_after_http_date() {
967 let future = std::time::SystemTime::now() + Duration::from_secs(7200);
969 let date = httpdate::fmt_http_date(future);
970 let mut headers = HeaderMap::new();
971 headers.insert(
972 reqwest::header::RETRY_AFTER,
973 reqwest::header::HeaderValue::from_str(&date).unwrap(),
974 );
975 let d = parse_retry_after(&headers);
976 assert!(
978 d > Duration::from_secs(3600),
979 "expected ~2h from HTTP-date, got {d:?}"
980 );
981 assert!(
982 d <= Duration::from_secs(7200),
983 "should not exceed the target instant, got {d:?}"
984 );
985 }
986
987 #[test]
988 fn test_parse_retry_after_past_http_date_is_zero() {
989 let past = std::time::SystemTime::now() - Duration::from_secs(3600);
991 let date = httpdate::fmt_http_date(past);
992 let mut headers = HeaderMap::new();
993 headers.insert(
994 reqwest::header::RETRY_AFTER,
995 reqwest::header::HeaderValue::from_str(&date).unwrap(),
996 );
997 assert_eq!(parse_retry_after(&headers), Duration::ZERO);
998 }
999
1000 #[test]
1001 fn test_new_rejects_invalid_expiry_ratio_zero() {
1002 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1003 token_url: "https://auth.example.com/token".into(),
1004 client_id: "id".into(),
1005 client_secret: "secret".into(),
1006 scopes: vec![],
1007 expiry_ratio: 0.0,
1008 });
1009 let result = RestStream::new(config);
1010 assert!(result.is_err());
1011 assert!(matches!(result, Err(FaucetError::Auth(_))));
1012 }
1013
1014 #[test]
1015 fn test_new_rejects_invalid_expiry_ratio_negative() {
1016 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1017 token_url: "https://auth.example.com/token".into(),
1018 client_id: "id".into(),
1019 client_secret: "secret".into(),
1020 scopes: vec![],
1021 expiry_ratio: -0.5,
1022 });
1023 assert!(RestStream::new(config).is_err());
1024 }
1025
1026 #[test]
1027 fn test_new_rejects_invalid_expiry_ratio_above_one() {
1028 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1029 token_url: "https://auth.example.com/token".into(),
1030 client_id: "id".into(),
1031 client_secret: "secret".into(),
1032 scopes: vec![],
1033 expiry_ratio: 1.5,
1034 });
1035 assert!(RestStream::new(config).is_err());
1036 }
1037
1038 #[test]
1039 fn test_new_accepts_valid_expiry_ratio() {
1040 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1041 token_url: "https://auth.example.com/token".into(),
1042 client_id: "id".into(),
1043 client_secret: "secret".into(),
1044 scopes: vec![],
1045 expiry_ratio: 1.0,
1046 });
1047 assert!(RestStream::new(config).is_ok());
1048 }
1049
1050 #[test]
1051 fn test_new_with_no_auth_succeeds() {
1052 let config = RestStreamConfig::new("https://example.com", "/data");
1053 assert!(RestStream::new(config).is_ok());
1054 }
1055
1056 #[test]
1057 fn test_new_with_timeout() {
1058 let config =
1059 RestStreamConfig::new("https://example.com", "/data").timeout(Duration::from_secs(10));
1060 assert!(RestStream::new(config).is_ok());
1061 }
1062
1063 #[test]
1064 fn test_substitute_context_missing_placeholder_unchanged() {
1065 let mut ctx = HashMap::new();
1066 ctx.insert("org".to_string(), json!("acme"));
1067 let result = faucet_core::util::substitute_context("/items/{missing}", &ctx);
1068 assert_eq!(result, "/items/{missing}");
1069 }
1070
1071 #[test]
1072 fn test_substitute_context_boolean_value() {
1073 let mut ctx = HashMap::new();
1074 ctx.insert("flag".to_string(), json!(true));
1075 let result = faucet_core::util::substitute_context("/items/{flag}", &ctx);
1076 assert_eq!(result, "/items/true");
1077 }
1078
1079 #[test]
1080 fn rest_source_connector_name_is_rest() {
1081 use faucet_core::Source;
1082 let source = RestStream::new(RestStreamConfig::new("https://example.com", "/data"))
1083 .expect("minimal RestStream construction");
1084 assert_eq!(source.connector_name(), "rest");
1085 }
1086
1087 #[test]
1088 fn dataset_uri_combines_base_and_path() {
1089 use faucet_core::Source;
1090 let source = RestStream::new(RestStreamConfig::new(
1091 "https://api.example.com",
1092 "/v1/users",
1093 ))
1094 .unwrap();
1095 assert_eq!(source.dataset_uri(), "https://api.example.com/v1/users");
1096 }
1097
1098 #[test]
1099 fn dataset_uri_redacts_credentials() {
1100 use faucet_core::Source;
1101 let source = RestStream::new(RestStreamConfig::new(
1102 "https://user:secret@api.example.com",
1103 "/v1/data",
1104 ))
1105 .unwrap();
1106 assert_eq!(source.dataset_uri(), "https://api.example.com/v1/data");
1107 }
1108}