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 if state.current_page_is_duplicate {
432 break;
437 } else {
438 bookmark_emitted = running_max.is_some();
440 yield faucet_core::StreamPage {
441 records,
442 bookmark: running_max.clone(),
443 };
444 break;
445 }
446
447 if let Some(delay) = self.config.request_delay {
448 tokio::time::sleep(delay).await;
449 }
450 }
451
452 if !bookmark_emitted && running_max.is_some() {
460 yield faucet_core::StreamPage {
461 records: Vec::new(),
462 bookmark: running_max,
463 };
464 }
465 })
466 }
467
468 async fn fetch_partition(
473 &self,
474 context: Option<&HashMap<String, Value>>,
475 max_records: Option<usize>,
476 ) -> Result<Vec<Value>, FaucetError> {
477 let mut all_records = Vec::new();
478 let mut pages_fetched = 0usize;
479 let mut pages = self.stream_pages_inner(context);
480
481 loop {
483 let page = std::future::poll_fn(|cx: &mut std::task::Context<'_>| {
484 pages.as_mut().poll_next(cx)
485 })
486 .await;
487
488 match page {
489 Some(Ok(page)) => {
490 pages_fetched += 1;
491 let records = page.records;
492 match max_records {
493 Some(limit) => {
494 let remaining = limit.saturating_sub(all_records.len());
495 all_records.extend(records.into_iter().take(remaining));
496 if all_records.len() >= limit {
497 break;
498 }
499 }
500 None => all_records.extend(records),
501 }
502 }
503 Some(Err(e)) => return Err(e),
504 None => break,
505 }
506 }
507
508 tracing::info!(
509 stream = self.config.name.as_deref().unwrap_or("(unnamed)"),
510 records = all_records.len(),
511 pages = pages_fetched,
512 "fetch complete"
513 );
514 Ok(all_records)
515 }
516
517 async fn execute_request(
528 &self,
529 params: &HashMap<String, String>,
530 url_override: Option<&str>,
531 path_context: Option<&HashMap<String, Value>>,
532 is_first_page: bool,
533 ) -> Result<(Value, HeaderMap), FaucetError> {
534 match self
535 .execute_request_once(params, url_override, path_context, is_first_page)
536 .await
537 {
538 Err(FaucetError::HttpStatus { status: 401, .. }) if self.uses_inline_cached_token() => {
539 tracing::warn!(
540 "401 Unauthorized with a cached inline OAuth2/TokenEndpoint token; \
541 invalidating the token cache and retrying once with a fresh token"
542 );
543 self.invalidate_inline_token_cache().await;
544 self.execute_request_once(params, url_override, path_context, is_first_page)
545 .await
546 }
547 other => other,
548 }
549 }
550
551 fn uses_inline_cached_token(&self) -> bool {
555 self.auth_provider.is_none()
556 && matches!(
557 self.config.auth,
558 AuthSpec::Inline(Auth::OAuth2 { .. })
559 | AuthSpec::Inline(Auth::TokenEndpoint { .. })
560 )
561 }
562
563 async fn invalidate_inline_token_cache(&self) {
566 match &self.config.auth {
567 AuthSpec::Inline(Auth::OAuth2 { .. }) => self.token_cache.invalidate().await,
568 AuthSpec::Inline(Auth::TokenEndpoint { .. }) => {
569 self.token_endpoint_cache.invalidate().await
570 }
571 _ => {}
572 }
573 }
574
575 async fn execute_request_once(
582 &self,
583 params: &HashMap<String, String>,
584 url_override: Option<&str>,
585 path_context: Option<&HashMap<String, Value>>,
586 is_first_page: bool,
587 ) -> Result<(Value, HeaderMap), FaucetError> {
588 let use_override = url_override.is_some();
589 let url = match url_override {
590 Some(u) => u.to_string(),
591 None => {
592 let path = match path_context {
593 Some(ctx) => faucet_core::util::substitute_context(&self.config.path, ctx),
594 None => self.config.path.clone(),
595 };
596 format!("{}/{}", self.config.base_url, path.trim_start_matches('/'))
597 }
598 };
599
600 let resolved_auth = if let Some(provider) = &self.auth_provider {
606 credential_to_auth(provider.credential().await?)
607 } else {
608 match &self.config.auth {
609 AuthSpec::Inline(Auth::OAuth2 {
610 token_url,
611 client_id,
612 client_secret,
613 scopes,
614 expiry_ratio,
615 }) => {
616 let token = self
617 .token_cache
618 .get_or_refresh(
619 &self.client,
620 token_url,
621 client_id,
622 client_secret,
623 scopes,
624 *expiry_ratio,
625 )
626 .await?;
627 Auth::Bearer { token }
628 }
629 AuthSpec::Inline(Auth::TokenEndpoint {
630 url: token_url,
631 method: token_method,
632 headers: token_headers,
633 body: token_body,
634 token_path,
635 expiry_path,
636 expiry_ratio,
637 response_validator,
638 }) => {
639 let token = self
640 .token_endpoint_cache
641 .get_or_refresh(
642 &self.client,
643 token_url,
644 token_method,
645 token_headers,
646 token_body.as_ref(),
647 token_path,
648 expiry_path.as_deref(),
649 *expiry_ratio,
650 response_validator.as_ref(),
651 )
652 .await?;
653 Auth::Bearer { token }
654 }
655 AuthSpec::Inline(other) => other.clone(),
656 AuthSpec::Reference(r) => {
657 return Err(FaucetError::Auth(format!(
658 "auth references provider '{}' but no provider was supplied; \
659 set one via the CLI `auth:` catalog or `with_auth_provider`",
660 r.name
661 )));
662 }
663 }
664 };
665
666 let mut headers = self.config.headers.clone();
667 resolved_auth.apply(&mut headers)?;
668
669 let mut req = self
670 .client
671 .request(self.config.method.clone(), &url)
672 .headers(headers);
673
674 if !use_override {
675 if let Some(ctx) = path_context {
678 let substituted: HashMap<String, String> = params
679 .iter()
680 .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, ctx)))
681 .collect();
682 req = req.query(&substituted.iter().collect::<Vec<_>>());
683 } else {
684 req = req.query(params);
685 }
686 }
687
688 if let AuthSpec::Inline(Auth::ApiKeyQuery { param, value }) = &self.config.auth {
690 req = req.query(&[(param.as_str(), value.as_str())]);
691 }
692
693 if let Some(body) = &self.config.body {
694 if let Some(ctx) = path_context {
703 let body_str = body.to_string();
704 let substituted = faucet_core::util::substitute_context_json(&body_str, ctx);
705 let substituted_value: Value = serde_json::from_str(&substituted).map_err(|e| {
706 FaucetError::Source(format!(
707 "REST source: context substitution produced an invalid JSON body: {e}"
708 ))
709 })?;
710 req = req.json(&substituted_value);
711 } else {
712 req = req.json(body);
713 }
714 }
715
716 let resp = req.send().await?;
717 let status = resp.status();
718
719 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
721 let wait = parse_retry_after(resp.headers());
722 return Err(FaucetError::RateLimited(wait));
723 }
724
725 if is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
733 tracing::debug!(
734 status = status.as_u16(),
735 "tolerated HTTP error on first request; treating as empty page"
736 );
737 return Ok((Value::Array(vec![]), HeaderMap::new()));
738 }
739 if !is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
740 tracing::warn!(
741 status = status.as_u16(),
742 "tolerated HTTP error mid-pagination; surfacing as an error to avoid \
743 silently truncating the stream"
744 );
745 }
746
747 if !status.is_success() {
751 let resp_url = redact_error_url(resp.url(), &self.config.auth);
757 let body_text = resp.text().await.unwrap_or_default();
758 let truncated = if body_text.len() > 1024 {
760 let end = body_text.floor_char_boundary(1024);
762 format!("{}...(truncated)", &body_text[..end])
763 } else {
764 body_text
765 };
766 return Err(FaucetError::HttpStatus {
767 status: status.as_u16(),
768 url: resp_url,
769 body: truncated,
770 });
771 }
772
773 let resp_headers = resp.headers().clone();
774
775 if status == reqwest::StatusCode::NO_CONTENT {
781 return Ok((Value::Array(vec![]), resp_headers));
782 }
783 let bytes = resp.bytes().await?;
784 if bytes.iter().all(u8::is_ascii_whitespace) {
785 return Ok((Value::Array(vec![]), resp_headers));
786 }
787 let body: Value = serde_json::from_slice(&bytes)?;
788 Ok((body, resp_headers))
789 }
790}
791
792fn redact_error_url(url: &reqwest::Url, auth: &AuthSpec<Auth>) -> String {
798 let mut redacted = url.clone();
799 if let AuthSpec::Inline(Auth::ApiKeyQuery { param, .. }) = auth {
800 let pairs: Vec<(String, String)> = url
801 .query_pairs()
802 .map(|(k, v)| {
803 if k == param.as_str() {
804 (k.into_owned(), "***".to_string())
805 } else {
806 (k.into_owned(), v.into_owned())
807 }
808 })
809 .collect();
810 redacted.set_query(None);
811 if !pairs.is_empty() {
812 let mut qp = redacted.query_pairs_mut();
813 for (k, v) in &pairs {
814 qp.append_pair(k, v);
815 }
816 }
817 }
818 faucet_core::redact_uri_credentials(redacted.as_str())
819}
820
821fn parse_retry_after(headers: &HeaderMap) -> Duration {
826 const DEFAULT: Duration = Duration::from_secs(60);
827 let Some(raw) = headers
828 .get(reqwest::header::RETRY_AFTER)
829 .and_then(|v| v.to_str().ok())
830 .map(str::trim)
831 else {
832 return DEFAULT;
833 };
834 if let Ok(secs) = raw.parse::<u64>() {
836 return Duration::from_secs(secs);
837 }
838 if let Ok(when) = httpdate::parse_http_date(raw) {
840 return when
841 .duration_since(std::time::SystemTime::now())
842 .unwrap_or(Duration::ZERO);
843 }
844 DEFAULT
845}
846
847#[async_trait]
848impl faucet_core::Source for RestStream {
849 async fn fetch_with_context(
850 &self,
851 context: &std::collections::HashMap<String, serde_json::Value>,
852 ) -> Result<Vec<Value>, FaucetError> {
853 if context.is_empty() {
854 RestStream::fetch_all(self).await
856 } else if self.config.partitions.is_empty() {
857 self.fetch_partition(Some(context), None).await
859 } else {
860 let mut all_records = Vec::new();
862 for partition in &self.config.partitions {
863 let mut merged = context.clone();
864 merged.extend(partition.iter().map(|(k, v)| (k.clone(), v.clone())));
865 all_records.extend(self.fetch_partition(Some(&merged), None).await?);
866 }
867 Ok(all_records)
868 }
869 }
870
871 async fn fetch_with_context_incremental(
872 &self,
873 context: &std::collections::HashMap<String, serde_json::Value>,
874 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
875 let records = self.fetch_with_context(context).await?;
876 let bookmark = self
877 .config
878 .replication_key
879 .as_deref()
880 .and_then(|key| faucet_core::replication::max_replication_value(&records, key))
881 .cloned();
882 Ok((records, bookmark))
883 }
884
885 fn connector_name(&self) -> &'static str {
886 "rest"
887 }
888
889 fn config_schema(&self) -> serde_json::Value {
890 serde_json::to_value(faucet_core::schema_for!(RestStreamConfig))
891 .expect("schema serialization")
892 }
893
894 fn dataset_uri(&self) -> String {
895 format!(
896 "{}{}",
897 faucet_core::redact_uri_credentials(&self.config.base_url),
898 self.config.path
899 )
900 }
901
902 fn state_key(&self) -> Option<String> {
903 self.config.state_key.clone()
904 }
905
906 fn stream_pages<'a>(
907 &'a self,
908 context: &'a HashMap<String, Value>,
909 _batch_size: usize,
910 ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + 'a>> {
911 self.stream_pages_inner(Some(context))
915 }
916
917 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
918 *self.runtime_start.lock().await = Some(bookmark);
919 Ok(())
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use serde_json::json;
927
928 #[test]
929 fn injected_policy_applies_when_legacy_fields_at_defaults() {
930 let stream =
932 RestStream::new(RestStreamConfig::new("https://api.example.com", "/items")).unwrap();
933 let injected = faucet_core::RetryPolicy {
934 max_attempts: 9,
935 base: Duration::from_secs(7),
936 ..faucet_core::RetryPolicy::default()
937 };
938 let stream = stream.with_retry_policy(injected);
939 assert_eq!(stream.retry_policy.max_attempts, 9);
940 assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
941 }
942
943 #[test]
944 fn legacy_fields_take_precedence_over_injected_policy() {
945 let config = RestStreamConfig::new("https://api.example.com", "/items").max_retries(7);
948 let stream = RestStream::new(config).unwrap();
949 assert_eq!(stream.retry_policy.max_attempts, 8);
951 let injected = faucet_core::RetryPolicy {
952 max_attempts: 99,
953 base: Duration::from_secs(42),
954 ..faucet_core::RetryPolicy::default()
955 };
956 let stream = stream.with_retry_policy(injected);
957 assert_eq!(stream.retry_policy.max_attempts, 8);
959 assert_eq!(stream.retry_policy.base, DEFAULT_RETRY_BACKOFF);
960 }
961
962 #[test]
963 fn redact_error_url_hides_api_key_query_param() {
964 let auth = AuthSpec::Inline(Auth::ApiKeyQuery {
966 param: "api_token".into(),
967 value: "SUPERSECRET".into(),
968 });
969 let url =
970 reqwest::Url::parse("https://api.example.com/v1/items?page=2&api_token=SUPERSECRET")
971 .unwrap();
972 let redacted = redact_error_url(&url, &auth);
973 assert!(
974 !redacted.contains("SUPERSECRET"),
975 "secret must be gone: {redacted}"
976 );
977 assert!(redacted.contains("api_token=%2A%2A%2A") || redacted.contains("api_token=***"));
978 assert!(
979 redacted.contains("page=2"),
980 "non-secret param kept: {redacted}"
981 );
982 }
983
984 #[test]
985 fn redact_error_url_without_api_key_query_still_scrubs_common_keys() {
986 let auth: AuthSpec<Auth> = AuthSpec::Inline(Auth::None);
989 let url = reqwest::Url::parse("https://u:pw@api.example.com/v1/items?token=abc").unwrap();
990 let redacted = redact_error_url(&url, &auth);
991 assert!(
992 !redacted.contains("abc"),
993 "common secret key redacted: {redacted}"
994 );
995 assert!(!redacted.contains("pw@"), "userinfo redacted: {redacted}");
996 }
997
998 #[test]
999 fn test_substitute_context_substitutes_placeholders() {
1000 let mut ctx = HashMap::new();
1001 ctx.insert("org_id".to_string(), json!("acme"));
1002 ctx.insert("repo".to_string(), json!("myrepo"));
1003 let result =
1004 faucet_core::util::substitute_context("/orgs/{org_id}/repos/{repo}/issues", &ctx);
1005 assert_eq!(result, "/orgs/acme/repos/myrepo/issues");
1006 }
1007
1008 #[test]
1009 fn test_substitute_context_no_placeholders() {
1010 let ctx = HashMap::new();
1011 let result = faucet_core::util::substitute_context("/api/users", &ctx);
1012 assert_eq!(result, "/api/users");
1013 }
1014
1015 #[test]
1016 fn test_substitute_context_numeric_value() {
1017 let mut ctx = HashMap::new();
1018 ctx.insert("id".to_string(), json!(42));
1019 let result = faucet_core::util::substitute_context("/items/{id}", &ctx);
1020 assert_eq!(result, "/items/42");
1021 }
1022
1023 #[test]
1024 fn test_parse_retry_after_valid() {
1025 let mut headers = HeaderMap::new();
1026 headers.insert(
1027 reqwest::header::RETRY_AFTER,
1028 reqwest::header::HeaderValue::from_static("30"),
1029 );
1030 assert_eq!(parse_retry_after(&headers), Duration::from_secs(30));
1031 }
1032
1033 #[test]
1034 fn test_parse_retry_after_missing_defaults_to_60() {
1035 assert_eq!(
1036 parse_retry_after(&HeaderMap::new()),
1037 Duration::from_secs(60)
1038 );
1039 }
1040
1041 #[test]
1042 fn test_parse_retry_after_non_numeric_defaults_to_60() {
1043 let mut headers = HeaderMap::new();
1044 headers.insert(
1045 reqwest::header::RETRY_AFTER,
1046 reqwest::header::HeaderValue::from_static("not-a-number"),
1047 );
1048 assert_eq!(parse_retry_after(&headers), Duration::from_secs(60));
1049 }
1050
1051 #[test]
1052 fn test_parse_retry_after_http_date() {
1053 let future = std::time::SystemTime::now() + Duration::from_secs(7200);
1055 let date = httpdate::fmt_http_date(future);
1056 let mut headers = HeaderMap::new();
1057 headers.insert(
1058 reqwest::header::RETRY_AFTER,
1059 reqwest::header::HeaderValue::from_str(&date).unwrap(),
1060 );
1061 let d = parse_retry_after(&headers);
1062 assert!(
1064 d > Duration::from_secs(3600),
1065 "expected ~2h from HTTP-date, got {d:?}"
1066 );
1067 assert!(
1068 d <= Duration::from_secs(7200),
1069 "should not exceed the target instant, got {d:?}"
1070 );
1071 }
1072
1073 #[test]
1074 fn test_parse_retry_after_past_http_date_is_zero() {
1075 let past = std::time::SystemTime::now() - Duration::from_secs(3600);
1077 let date = httpdate::fmt_http_date(past);
1078 let mut headers = HeaderMap::new();
1079 headers.insert(
1080 reqwest::header::RETRY_AFTER,
1081 reqwest::header::HeaderValue::from_str(&date).unwrap(),
1082 );
1083 assert_eq!(parse_retry_after(&headers), Duration::ZERO);
1084 }
1085
1086 #[test]
1087 fn test_new_rejects_invalid_expiry_ratio_zero() {
1088 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1089 token_url: "https://auth.example.com/token".into(),
1090 client_id: "id".into(),
1091 client_secret: "secret".into(),
1092 scopes: vec![],
1093 expiry_ratio: 0.0,
1094 });
1095 let result = RestStream::new(config);
1096 assert!(result.is_err());
1097 assert!(matches!(result, Err(FaucetError::Auth(_))));
1098 }
1099
1100 #[test]
1101 fn test_new_rejects_invalid_expiry_ratio_negative() {
1102 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1103 token_url: "https://auth.example.com/token".into(),
1104 client_id: "id".into(),
1105 client_secret: "secret".into(),
1106 scopes: vec![],
1107 expiry_ratio: -0.5,
1108 });
1109 assert!(RestStream::new(config).is_err());
1110 }
1111
1112 #[test]
1113 fn test_new_rejects_invalid_expiry_ratio_above_one() {
1114 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1115 token_url: "https://auth.example.com/token".into(),
1116 client_id: "id".into(),
1117 client_secret: "secret".into(),
1118 scopes: vec![],
1119 expiry_ratio: 1.5,
1120 });
1121 assert!(RestStream::new(config).is_err());
1122 }
1123
1124 #[test]
1125 fn test_new_accepts_valid_expiry_ratio() {
1126 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1127 token_url: "https://auth.example.com/token".into(),
1128 client_id: "id".into(),
1129 client_secret: "secret".into(),
1130 scopes: vec![],
1131 expiry_ratio: 1.0,
1132 });
1133 assert!(RestStream::new(config).is_ok());
1134 }
1135
1136 #[test]
1137 fn test_new_with_no_auth_succeeds() {
1138 let config = RestStreamConfig::new("https://example.com", "/data");
1139 assert!(RestStream::new(config).is_ok());
1140 }
1141
1142 #[test]
1143 fn test_new_with_timeout() {
1144 let config =
1145 RestStreamConfig::new("https://example.com", "/data").timeout(Duration::from_secs(10));
1146 assert!(RestStream::new(config).is_ok());
1147 }
1148
1149 #[test]
1150 fn test_substitute_context_missing_placeholder_unchanged() {
1151 let mut ctx = HashMap::new();
1152 ctx.insert("org".to_string(), json!("acme"));
1153 let result = faucet_core::util::substitute_context("/items/{missing}", &ctx);
1154 assert_eq!(result, "/items/{missing}");
1155 }
1156
1157 #[test]
1158 fn test_substitute_context_boolean_value() {
1159 let mut ctx = HashMap::new();
1160 ctx.insert("flag".to_string(), json!(true));
1161 let result = faucet_core::util::substitute_context("/items/{flag}", &ctx);
1162 assert_eq!(result, "/items/true");
1163 }
1164
1165 #[test]
1166 fn rest_source_connector_name_is_rest() {
1167 use faucet_core::Source;
1168 let source = RestStream::new(RestStreamConfig::new("https://example.com", "/data"))
1169 .expect("minimal RestStream construction");
1170 assert_eq!(source.connector_name(), "rest");
1171 }
1172
1173 #[test]
1174 fn dataset_uri_combines_base_and_path() {
1175 use faucet_core::Source;
1176 let source = RestStream::new(RestStreamConfig::new(
1177 "https://api.example.com",
1178 "/v1/users",
1179 ))
1180 .unwrap();
1181 assert_eq!(source.dataset_uri(), "https://api.example.com/v1/users");
1182 }
1183
1184 #[test]
1185 fn dataset_uri_redacts_credentials() {
1186 use faucet_core::Source;
1187 let source = RestStream::new(RestStreamConfig::new(
1188 "https://user:secret@api.example.com",
1189 "/v1/data",
1190 ))
1191 .unwrap();
1192 assert_eq!(source.dataset_uri(), "https://api.example.com/v1/data");
1193 }
1194}