1use crate::auth::Auth;
4use crate::auth::oauth2::TokenCache;
5use crate::auth::token_endpoint::TokenEndpointCache;
6use crate::config::{RestStreamConfig, TlsClientConfig};
7use crate::extract;
8use crate::pagination::{PaginationState, PaginationStyle};
9use crate::retry;
10use async_trait::async_trait;
11use faucet_core::replication::{
12 BindTarget, ReplicationMethod, filter_incremental, max_replication_value, max_value,
13};
14use faucet_core::schema;
15use faucet_core::{AuthSpec, Credential, CredentialPlacement, FaucetError, SharedAuthProvider};
16use futures_core::Stream;
17use reqwest::Client;
18use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
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 window_binds: Arc<AsyncMutex<Vec<(BindTarget, String, String)>>>,
52 now_override: Option<chrono::DateTime<chrono::Utc>>,
56 retry_policy: faucet_core::RetryPolicy,
64 static_headers: HeaderMap,
69}
70
71const DEFAULT_MAX_RETRIES: u32 = 3;
75const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_secs(1);
78
79#[cfg(feature = "mtls")]
84fn apply_client_tls(
85 builder: reqwest::ClientBuilder,
86 tls: &TlsClientConfig,
87) -> Result<reqwest::ClientBuilder, FaucetError> {
88 let identity = build_identity(tls)?;
89 let mut builder = builder.identity(identity).use_native_tls();
93 if let Some(v) = &tls.min_version {
94 let version = if v == "1.3" {
96 reqwest::tls::Version::TLS_1_3
97 } else {
98 reqwest::tls::Version::TLS_1_2
99 };
100 builder = builder.min_tls_version(version);
101 }
102 Ok(builder)
103}
104
105#[cfg(not(feature = "mtls"))]
106fn apply_client_tls(
107 _builder: reqwest::ClientBuilder,
108 _tls: &TlsClientConfig,
109) -> Result<reqwest::ClientBuilder, FaucetError> {
110 Err(FaucetError::Config(
111 "a `tls:` (mutual-TLS) block is configured, but this build of \
112 faucet-source-rest lacks the `mtls` feature; rebuild with \
113 `--features mtls`"
114 .into(),
115 ))
116}
117
118#[cfg(feature = "mtls")]
121fn build_identity(tls: &TlsClientConfig) -> Result<reqwest::Identity, FaucetError> {
122 if let Some(p12_path) = &tls.client_identity_pkcs12 {
123 let der = std::fs::read(p12_path).map_err(|e| {
124 FaucetError::Config(format!(
125 "tls: could not read PKCS#12 file {p12_path:?}: {e}"
126 ))
127 })?;
128 let password = tls.pkcs12_password.as_deref().unwrap_or("");
129 reqwest::Identity::from_pkcs12_der(&der, password)
130 .map_err(|e| FaucetError::Config(format!("tls: invalid PKCS#12 identity: {e}")))
131 } else {
132 let cert = tls.client_cert.as_deref().unwrap_or_default();
134 let key = tls.client_key.as_deref().unwrap_or_default();
135 reqwest::Identity::from_pkcs8_pem(cert.as_bytes(), key.as_bytes())
136 .map_err(|e| FaucetError::Config(format!("tls: invalid PEM client identity: {e}")))
137 }
138}
139
140fn credential_to_auth(cred: Credential) -> Auth {
143 match cred {
144 Credential::Bearer(token) => Auth::Bearer { token },
145 Credential::Token(token) => Auth::Custom {
146 headers: std::iter::once(("Authorization".to_string(), token)).collect(),
147 },
148 Credential::Basic { username, password } => Auth::Basic { username, password },
149 Credential::Header { name, value } => Auth::Custom {
150 headers: std::iter::once((name, value)).collect(),
151 },
152 }
153}
154
155fn jsonpath_first_string(v: &Value, path: &str) -> Option<String> {
158 use jsonpath_rust::JsonPath;
159 let results = v.query(path).ok()?;
160 match results.first()? {
161 Value::String(s) => Some(s.clone()),
162 Value::Number(n) => Some(n.to_string()),
163 Value::Bool(b) => Some(b.to_string()),
164 _ => None,
165 }
166}
167
168fn jsonpath_first_value(v: &Value, path: &str) -> Option<Value> {
171 use jsonpath_rust::JsonPath;
172 v.query(path).ok()?.first().map(|x| (*x).clone())
173}
174
175fn is_terminal_locator(value: &str) -> bool {
178 let v = value.trim();
179 v.is_empty() || v.eq_ignore_ascii_case("null")
180}
181
182fn next_locator(
186 headers: &HeaderMap,
187 body: Option<&Value>,
188 job: &crate::async_job::AsyncJobConfig,
189) -> Option<String> {
190 if let Some(name) = &job.fetch.locator_header
191 && let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok())
192 && !is_terminal_locator(raw)
193 {
194 return Some(raw.trim().to_string());
195 }
196 if let Some(path) = &job.fetch.locator_body
197 && let Some(body) = body
198 && let Some(raw) = jsonpath_first_string(body, path)
199 && !is_terminal_locator(&raw)
200 {
201 return Some(raw.trim().to_string());
202 }
203 None
204}
205
206fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), FaucetError> {
209 let hn = HeaderName::from_bytes(name.as_bytes())
210 .map_err(|e| FaucetError::Config(format!("rest: invalid header name '{name}': {e}")))?;
211 let hv = HeaderValue::from_str(value).map_err(|e| {
212 FaucetError::Config(format!("rest: invalid value for header '{name}': {e}"))
213 })?;
214 headers.insert(hn, hv);
215 Ok(())
216}
217
218impl RestStream {
219 pub fn new(mut config: RestStreamConfig) -> Result<Self, FaucetError> {
221 config.apply_odata_defaults();
224 config.validate()?;
226 let expiry_ratio_to_validate = match &config.auth {
228 AuthSpec::Inline(Auth::OAuth2 { expiry_ratio, .. })
229 | AuthSpec::Inline(Auth::TokenEndpoint { expiry_ratio, .. }) => Some(*expiry_ratio),
230 _ => None,
231 };
232 if let Some(ratio) = expiry_ratio_to_validate
233 && (ratio <= 0.0 || ratio > 1.0)
234 {
235 return Err(FaucetError::Auth(format!(
236 "expiry_ratio must be in (0.0, 1.0], got {ratio}"
237 )));
238 }
239
240 let mut builder = Client::builder();
241 if let Some(t) = config.timeout {
242 builder = builder.timeout(t);
243 }
244 if let Some(tls) = &config.tls {
248 tls.validate()?;
249 builder = apply_client_tls(builder, tls)?;
250 }
251 let retry_policy = faucet_core::RetryPolicy {
256 max_attempts: config.max_retries.saturating_add(1),
257 backoff: faucet_core::BackoffKind::Exponential,
258 base: config.retry_backoff,
259 ..faucet_core::RetryPolicy::default()
260 };
261 let static_headers = crate::config::build_header_map(&config.headers)?;
264 Ok(Self {
265 config,
266 client: builder.build()?,
267 token_cache: TokenCache::new(),
268 token_endpoint_cache: TokenEndpointCache::new(),
269 auth_provider: None,
270 runtime_start: Arc::new(AsyncMutex::new(None)),
271 window_binds: Arc::new(AsyncMutex::new(Vec::new())),
272 now_override: None,
273 retry_policy,
274 static_headers,
275 })
276 }
277
278 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
285 self.auth_provider = Some(provider);
286 self
287 }
288
289 #[doc(hidden)]
294 pub fn with_now_override_rfc3339(mut self, rfc3339: &str) -> Self {
295 self.now_override = chrono::DateTime::parse_from_rfc3339(rfc3339)
296 .ok()
297 .map(|d| d.with_timezone(&chrono::Utc));
298 self
299 }
300
301 pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
320 let user_changed_legacy_fields = self.config.max_retries != DEFAULT_MAX_RETRIES
321 || self.config.retry_backoff != DEFAULT_RETRY_BACKOFF;
322 if !user_changed_legacy_fields {
323 self.retry_policy = policy;
324 }
325 self
326 }
327
328 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
337 if self.config.partitions.is_empty() {
338 self.fetch_partition(None, None).await
339 } else if let Some(concurrency) = self.config.partition_concurrency {
340 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency.max(1)));
342 let mut handles = Vec::with_capacity(self.config.partitions.len());
343
344 for ctx in &self.config.partitions {
345 let permit =
346 semaphore.clone().acquire_owned().await.map_err(|e| {
347 FaucetError::Config(format!("semaphore acquire failed: {e}"))
348 })?;
349 let fut = self.fetch_partition(Some(ctx), None);
350 handles.push(async move {
351 let result = fut.await;
352 drop(permit);
353 result
354 });
355 }
356
357 let results = futures::future::try_join_all(handles).await?;
358 Ok(results.into_iter().flatten().collect())
359 } else {
360 let mut all_records = Vec::new();
361 for ctx in &self.config.partitions {
362 let records = self.fetch_partition(Some(ctx), None).await?;
363 all_records.extend(records);
364 }
365 Ok(all_records)
366 }
367 }
368
369 pub async fn fetch_all_as<T: for<'de> Deserialize<'de>>(&self) -> Result<Vec<T>, FaucetError> {
371 let values = self.fetch_all().await?;
372 values
373 .into_iter()
374 .map(|v| serde_json::from_value(v).map_err(FaucetError::Json))
375 .collect()
376 }
377
378 pub async fn infer_schema(&self) -> Result<Value, FaucetError> {
390 if let Some(ref s) = self.config.schema {
391 return Ok(s.clone());
392 }
393 let limit = match self.config.schema_sample_size {
394 0 => None,
395 n => Some(n),
396 };
397 let records = self.fetch_partition(None, limit).await?;
398 Ok(schema::infer_schema(&records))
399 }
400
401 pub async fn fetch_all_incremental(&self) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
410 let records = self.fetch_all().await?;
411 let bookmark = self
412 .config
413 .replication_key
414 .as_deref()
415 .and_then(|key| max_replication_value(&records, key))
416 .cloned();
417 Ok((records, bookmark))
418 }
419
420 pub fn stream_pages(
448 &self,
449 ) -> Pin<Box<dyn Stream<Item = Result<Vec<Value>, FaucetError>> + Send + '_>> {
450 let mut inner = self.stream_pages_inner(None);
451 Box::pin(async_stream::try_stream! {
452 loop {
453 let page = std::future::poll_fn(|cx| inner.as_mut().poll_next(cx)).await;
454 match page {
455 Some(Ok(p)) => yield p.records,
456 Some(Err(e)) => Err(e)?,
457 None => break,
458 }
459 }
460 })
461 }
462
463 fn extract_page(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
470 extract::extract_configured(
471 body,
472 self.config.records_path.as_deref(),
473 self.config.record_ancestors.as_ref(),
474 &self.config.records_multi,
475 self.config.op_field.as_deref().unwrap_or("_op"),
476 )
477 }
478
479 fn stream_pages_inner(
487 &self,
488 context: Option<&HashMap<String, Value>>,
489 ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + '_>> {
490 let owned_context: Option<HashMap<String, Value>> = context.cloned();
493
494 Box::pin(async_stream::try_stream! {
495 if self.config.async_job.is_some() {
498 let records = self.run_async_job().await?;
499 yield faucet_core::StreamPage { records, bookmark: None };
500 return;
501 }
502
503 let effective_start: Option<Value> = {
508 let guard = self.runtime_start.lock().await;
509 guard
510 .clone()
511 .or_else(|| self.config.start_replication_value.clone())
512 };
513
514 if self.config.max_pages.is_some()
524 && self.config.replication_method == ReplicationMethod::Incremental
525 && self.config.replication_key.is_some()
526 {
527 tracing::warn!(
528 "max_pages combined with incremental replication assumes the API returns rows \
529 ordered ascending by the replication key; an unordered feed can drop unfetched \
530 lower-key records on resume. Ensure ordering, or remove max_pages for a full \
531 incremental sweep."
532 );
533 }
534
535 let windowed = self.config.window.is_some();
541 let passes: Vec<Option<faucet_core::Window>> = if let Some(win) = &self.config.window {
542 let start_val = effective_start.clone().ok_or_else(|| {
543 FaucetError::Config(
544 "rest: `window` slicing requires a start bookmark (from a `state:` store) \
545 or `start_replication_value` to anchor the first window".into(),
546 )
547 })?;
548 let start_instant = faucet_core::parse_instant(&start_val)?;
549 let now = self.now_override.unwrap_or_else(chrono::Utc::now);
550 let step = win.step_duration()?;
551 let lookback = win.lookback_duration()?;
552 let (windows, truncated) =
553 faucet_core::enumerate_windows(start_instant, now, step, lookback, win.max_windows);
554 if truncated {
555 tracing::warn!(
556 max_windows = win.max_windows,
557 "window slicing hit `max_windows`; this run's sweep is truncated — the next \
558 run resumes from the last completed window"
559 );
560 }
561 if windows.is_empty() {
562 tracing::debug!(
563 "window slicing: the bookmark is at or ahead of now; nothing to fetch"
564 );
565 }
566 windows.into_iter().map(Some).collect()
567 } else {
568 vec![None]
569 };
570
571 for pass in passes {
572 if let Some(w) = &pass {
576 let win = self
577 .config
578 .window
579 .as_ref()
580 .expect("a window pass implies a `window:` block");
581 let lower = (win.lower.into, win.lower.name.clone(), win.render_lower(w));
582 let upper_rendered = win.render_upper(w)?;
583 let upper = (win.upper.into, win.upper.name.clone(), upper_rendered);
584 *self.window_binds.lock().await = vec![lower, upper];
585 }
586
587 let window_bookmark: Option<Value> =
592 pass.as_ref().map(|w| Value::String(w.end.to_rfc3339()));
593
594 let mut state = PaginationState::default();
595 if self.config.persist_cursor
598 && let Some(seed) = effective_start.as_ref()
599 {
600 state.next_token =
601 Some(crate::pagination::value_to_param_string(seed));
602 }
603 let mut pages_fetched = 0usize;
604 let mut running_max: Option<Value> = effective_start.clone();
605 let mut running_cursor: Option<Value> = effective_start.clone();
607 let mut bookmark_emitted = false;
608
609 loop {
610 if let Some(max) = self.config.max_pages
611 && pages_fetched >= max
612 {
613 tracing::warn!("max pages ({max}) reached");
614 break;
615 }
616
617 let mut params = self.config.query_params.clone();
618 self.config.pagination.apply_params(&mut params, &state);
619
620 let url_override = match &self.config.pagination {
621 PaginationStyle::LinkHeader | PaginationStyle::NextLinkInBody { .. } => {
622 state.next_link.clone()
623 }
624 _ => None,
625 };
626
627 let body_params = self.config.pagination.body_params(&state);
631
632 let params_clone = params.clone();
633 let ctx_ref = owned_context.as_ref();
634 let is_first_page = pages_fetched == 0;
635 let (body, resp_headers) = retry::execute_with_retry(
636 self.retry_policy.max_attempts.saturating_sub(1),
642 self.retry_policy.base,
643 || {
644 self.execute_request(
645 ¶ms_clone,
646 url_override.as_deref(),
647 ctx_ref,
648 is_first_page,
649 &body_params,
650 )
651 },
652 )
653 .await?;
654
655 let raw_records = self.extract_page(&body)?;
656 let raw_count = raw_records.len();
657
658 if self.config.persist_cursor
660 && let Some(path) = self.config.pagination.cursor_path()
661 && let Some(cursor) = jsonpath_first_value(&body, path)
662 {
663 match &cursor {
664 Value::Null => {}
665 Value::String(s) if s.is_empty() => {}
666 _ => running_cursor = Some(cursor),
667 }
668 }
669
670 let records = if !windowed
674 && self.config.replication_method == ReplicationMethod::Incremental
675 {
676 if let (Some(key), Some(start)) =
677 (&self.config.replication_key, effective_start.as_ref())
678 {
679 filter_incremental(raw_records, key, start)
680 } else {
681 raw_records
682 }
683 } else {
684 raw_records
685 };
686
687 if !windowed
694 && self.config.replication_method == ReplicationMethod::Incremental
695 {
696 let page_max: Option<Value> = match self
697 .config
698 .replication_bind
699 .as_ref()
700 .and_then(|b| b.advance_from.as_deref())
701 {
702 Some(path) => faucet_core::util::extract_records(&body, Some(path))
703 .ok()
704 .and_then(|vs| vs.into_iter().next()),
705 None => self
706 .config
707 .replication_key
708 .as_deref()
709 .and_then(|key| max_replication_value(&records, key).cloned()),
710 };
711 if let Some(page_max) = page_max {
712 running_max = Some(match running_max.take() {
713 Some(prev) => max_value(prev, page_max),
714 None => page_max,
715 });
716 }
717 }
718
719 self.config
723 .pagination
724 .update_record_cursor(&records, &mut state);
725
726 let has_next = self
733 .config
734 .pagination
735 .advance(&body, &resp_headers, &mut state, raw_count)?;
736 pages_fetched += 1;
737
738 if has_next {
739 yield faucet_core::StreamPage { records, bookmark: None };
742 } else if state.current_page_is_duplicate {
743 break;
748 } else {
749 let bookmark = if self.config.persist_cursor {
751 running_cursor.clone()
752 } else if windowed {
753 window_bookmark.clone()
754 } else {
755 running_max.clone()
756 };
757 bookmark_emitted = bookmark.is_some();
758 yield faucet_core::StreamPage { records, bookmark };
759 break;
760 }
761
762 if let Some(delay) = self.config.request_delay {
763 tokio::time::sleep(delay).await;
764 }
765 }
766
767 let pass_bookmark = if self.config.persist_cursor {
774 running_cursor.clone()
775 } else if windowed {
776 window_bookmark.clone()
777 } else {
778 running_max.clone()
779 };
780 if !bookmark_emitted && pass_bookmark.is_some() {
781 yield faucet_core::StreamPage {
782 records: Vec::new(),
783 bookmark: pass_bookmark,
784 };
785 }
786 }
787
788 if windowed {
790 self.window_binds.lock().await.clear();
791 }
792 })
793 }
794
795 async fn fetch_partition(
800 &self,
801 context: Option<&HashMap<String, Value>>,
802 max_records: Option<usize>,
803 ) -> Result<Vec<Value>, FaucetError> {
804 let mut all_records = Vec::new();
805 let mut pages_fetched = 0usize;
806 let mut pages = self.stream_pages_inner(context);
807
808 loop {
810 let page = std::future::poll_fn(|cx: &mut std::task::Context<'_>| {
811 pages.as_mut().poll_next(cx)
812 })
813 .await;
814
815 match page {
816 Some(Ok(page)) => {
817 pages_fetched += 1;
818 let records = page.records;
819 match max_records {
820 Some(limit) => {
821 let remaining = limit.saturating_sub(all_records.len());
822 all_records.extend(records.into_iter().take(remaining));
823 if all_records.len() >= limit {
824 break;
825 }
826 }
827 None => all_records.extend(records),
828 }
829 }
830 Some(Err(e)) => return Err(e),
831 None => break,
832 }
833 }
834
835 tracing::info!(
836 stream = self.config.name.as_deref().unwrap_or("(unnamed)"),
837 records = all_records.len(),
838 pages = pages_fetched,
839 "fetch complete"
840 );
841 Ok(all_records)
842 }
843
844 async fn execute_request(
855 &self,
856 params: &HashMap<String, String>,
857 url_override: Option<&str>,
858 path_context: Option<&HashMap<String, Value>>,
859 is_first_page: bool,
860 body_params: &[(String, Value)],
861 ) -> Result<(Value, HeaderMap), FaucetError> {
862 match self
863 .execute_request_once(
864 params,
865 url_override,
866 path_context,
867 is_first_page,
868 body_params,
869 )
870 .await
871 {
872 Err(FaucetError::HttpStatus { status: 401, .. }) if self.uses_inline_cached_token() => {
873 tracing::warn!(
874 "401 Unauthorized with a cached inline OAuth2/TokenEndpoint token; \
875 invalidating the token cache and retrying once with a fresh token"
876 );
877 self.invalidate_inline_token_cache().await;
878 self.execute_request_once(
879 params,
880 url_override,
881 path_context,
882 is_first_page,
883 body_params,
884 )
885 .await
886 }
887 Err(FaucetError::HttpStatus { status, .. }) if self.provider_wants_reauth(status) => {
891 if let Some(provider) = &self.auth_provider {
892 tracing::warn!(
893 status,
894 "shared auth provider requested re-auth on this status; \
895 re-authenticating and retrying once"
896 );
897 let _ = provider.invalidate(&Credential::Token(String::new())).await;
898 }
899 self.execute_request_once(
900 params,
901 url_override,
902 path_context,
903 is_first_page,
904 body_params,
905 )
906 .await
907 }
908 other => other,
909 }
910 }
911
912 fn provider_wants_reauth(&self, status: u16) -> bool {
914 self.auth_provider
915 .as_ref()
916 .is_some_and(|p| p.reauth_statuses().contains(&status))
917 }
918
919 fn uses_inline_cached_token(&self) -> bool {
923 self.auth_provider.is_none()
924 && matches!(
925 self.config.auth,
926 AuthSpec::Inline(Auth::OAuth2 { .. })
927 | AuthSpec::Inline(Auth::TokenEndpoint { .. })
928 )
929 }
930
931 async fn invalidate_inline_token_cache(&self) {
934 match &self.config.auth {
935 AuthSpec::Inline(Auth::OAuth2 { .. }) => self.token_cache.invalidate().await,
936 AuthSpec::Inline(Auth::TokenEndpoint { .. }) => {
937 self.token_endpoint_cache.invalidate().await
938 }
939 _ => {}
940 }
941 }
942
943 async fn resolved_bind(&self) -> Result<Option<(BindTarget, String, String)>, FaucetError> {
947 let Some(bind) = &self.config.replication_bind else {
948 return Ok(None);
949 };
950 let bookmark = {
951 let guard = self.runtime_start.lock().await;
952 guard.clone()
953 }
954 .or_else(|| self.config.start_replication_value.clone());
955 match bookmark {
956 Some(bm) => Ok(Some((bind.into, bind.name.clone(), bind.render(&bm)?))),
957 None => Ok(None),
958 }
959 }
960
961 async fn job_request_bytes(
965 &self,
966 method: &str,
967 url: &str,
968 headers: &HashMap<String, String>,
969 query: &HashMap<String, String>,
970 json: Option<&Value>,
971 ) -> Result<(Vec<u8>, HeaderMap), FaucetError> {
972 let m = reqwest::Method::from_bytes(method.to_uppercase().as_bytes()).map_err(|_| {
973 FaucetError::Config(format!("async_job: invalid HTTP method '{method}'"))
974 })?;
975 let mut hdrs = self.static_headers.clone();
978 for (k, v) in self.metadata_headers(url).await?.iter() {
979 hdrs.insert(k.clone(), v.clone());
980 }
981 for (k, v) in headers {
982 insert_header(&mut hdrs, k, v)?;
983 }
984 let mut req = self.client.request(m, url).headers(hdrs);
985 if !query.is_empty() {
986 let pairs: Vec<(&str, &str)> = query
987 .iter()
988 .map(|(k, v)| (k.as_str(), v.as_str()))
989 .collect();
990 req = req.query(&pairs);
991 }
992 if let Some(j) = json {
993 req = req.json(j);
994 }
995 let resp = req
996 .send()
997 .await
998 .map_err(|e| FaucetError::Source(format!("async_job: request to {url} failed: {e}")))?;
999 let status = resp.status();
1000 if !status.is_success() {
1001 return Err(FaucetError::HttpStatus {
1002 status: status.as_u16(),
1003 url: url.to_string(),
1004 body: format!("async_job: {url} returned HTTP {}", status.as_u16()),
1005 });
1006 }
1007 let resp_headers = resp.headers().clone();
1008 Ok((resp.bytes().await?.to_vec(), resp_headers))
1009 }
1010
1011 async fn job_request_json(
1012 &self,
1013 method: &str,
1014 url: &str,
1015 headers: &HashMap<String, String>,
1016 query: &HashMap<String, String>,
1017 json: Option<&Value>,
1018 ) -> Result<Value, FaucetError> {
1019 let (bytes, _headers) = self
1020 .job_request_bytes(method, url, headers, query, json)
1021 .await?;
1022 serde_json::from_slice(&bytes)
1023 .map_err(|e| FaucetError::Source(format!("async_job: {url} returned non-JSON: {e}")))
1024 }
1025
1026 async fn run_async_job(&self) -> Result<Vec<Value>, FaucetError> {
1029 use crate::async_job::{JobOutcome, resolve_url, substitute_job_id};
1030 let job = self
1031 .config
1032 .async_job
1033 .as_ref()
1034 .expect("run_async_job called with async_job set");
1035 let base = &self.config.base_url;
1036
1037 let submit_url = resolve_url(base, job.submit.url.as_deref().unwrap_or_default());
1039 let submit_body = self
1040 .job_request_json(
1041 &job.submit.method,
1042 &submit_url,
1043 &job.submit.headers,
1044 &job.submit.query,
1045 job.submit.json.as_ref(),
1046 )
1047 .await?;
1048 let job_id = jsonpath_first_string(&submit_body, &job.job_id).ok_or_else(|| {
1049 FaucetError::Source(format!(
1050 "async_job: submit response had no job id at '{}'",
1051 job.job_id
1052 ))
1053 })?;
1054
1055 let poll_url = resolve_url(base, &substitute_job_id(&job.poll.url, &job_id));
1057 let deadline =
1058 tokio::time::Instant::now() + std::time::Duration::from_secs(job.poll.timeout_secs);
1059 let last_poll_body: Value = loop {
1062 let body = self
1063 .job_request_json(
1064 &job.poll.method,
1065 &poll_url,
1066 &job.poll.headers,
1067 &job.poll.query,
1068 None,
1069 )
1070 .await?;
1071 let status = jsonpath_first_string(&body, &job.status.path).unwrap_or_default();
1072 match job.status.classify(&status) {
1073 JobOutcome::Success => break body,
1074 JobOutcome::Failure => {
1075 return Err(FaucetError::Source(format!(
1076 "async_job: job failed with status '{status}'"
1077 )));
1078 }
1079 JobOutcome::Pending => {
1080 if tokio::time::Instant::now() >= deadline {
1081 return Err(FaucetError::Source(format!(
1082 "async_job: polling timed out after {}s (last status '{status}')",
1083 job.poll.timeout_secs
1084 )));
1085 }
1086 tokio::time::sleep(std::time::Duration::from_secs(job.poll.interval_secs))
1087 .await;
1088 }
1089 }
1090 };
1091
1092 let fetch_url = match (&job.fetch.url_from, &job.fetch.url) {
1095 (Some(path), _) => {
1096 let resolved = jsonpath_first_string(&last_poll_body, path).ok_or_else(|| {
1097 FaucetError::Source(format!(
1098 "async_job: fetch.url_from '{path}' matched no string in the poll response"
1099 ))
1100 })?;
1101 resolve_url(base, &resolved)
1102 }
1103 (None, Some(url)) => resolve_url(base, &substitute_job_id(url, &job_id)),
1104 (None, None) => {
1105 return Err(FaucetError::Config(
1106 "async_job: `fetch` requires exactly one of `url` or `url_from`".into(),
1107 ));
1108 }
1109 };
1110
1111 let mut all_records = Vec::new();
1115 let mut locator: Option<String> = None;
1116 loop {
1117 let mut query = job.fetch.query.clone();
1119 if let (Some(loc), Some(param)) = (&locator, &job.fetch.locator_param) {
1120 query.insert(param.clone(), loc.clone());
1121 }
1122 let (bytes, resp_headers) = self
1123 .job_request_bytes(
1124 &job.fetch.method,
1125 &fetch_url,
1126 &job.fetch.headers,
1127 &query,
1128 job.fetch.json.as_ref(),
1129 )
1130 .await?;
1131 let (records, body_value) = self.parse_fetch_page(&bytes, job).await?;
1132 all_records.extend(records);
1133
1134 let next = next_locator(&resp_headers, body_value.as_ref(), job);
1137 match next {
1138 Some(loc) if locator.as_deref() != Some(loc.as_str()) => {
1139 locator = Some(loc);
1140 }
1141 _ => break,
1142 }
1143 }
1144 Ok(all_records)
1145 }
1146
1147 async fn parse_fetch_page(
1152 &self,
1153 bytes: &[u8],
1154 job: &crate::async_job::AsyncJobConfig,
1155 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1156 if !self.config.decode.is_empty() {
1157 let records = crate::decode::run_decode(bytes, &self.config.decode).await?;
1158 return Ok((records, None));
1159 }
1160 match self.config.response_format {
1161 crate::config::ResponseFormat::Json => {
1162 let v: Value = serde_json::from_slice(bytes).map_err(|e| {
1163 FaucetError::Source(format!("async_job: result is not JSON: {e}"))
1164 })?;
1165 let records = match job.fetch.records_path.as_deref() {
1166 Some(rp) => extract::extract_records(&v, Some(rp))?,
1167 None => self.extract_page(&v)?,
1168 };
1169 Ok((records, Some(v)))
1170 }
1171 crate::config::ResponseFormat::Csv => {
1172 let records = crate::format::parse_csv(
1173 bytes,
1174 self.config.csv_delimiter,
1175 self.config.csv_has_headers,
1176 )
1177 .await?;
1178 Ok((records, None))
1179 }
1180 crate::config::ResponseFormat::Excel => {
1181 let records = crate::format::parse_excel(
1182 bytes,
1183 self.config.excel_sheet.as_deref(),
1184 self.config.excel_header_row,
1185 )?;
1186 Ok((records, None))
1187 }
1188 }
1189 }
1190
1191 async fn metadata_headers(&self, url: &str) -> Result<HeaderMap, FaucetError> {
1196 let mut headers = HeaderMap::new();
1197 if let Some(provider) = &self.auth_provider {
1198 let ra = provider
1199 .request_auth("GET", url, &std::collections::BTreeMap::new())
1200 .await?;
1201 if ra.is_empty() {
1202 credential_to_auth(provider.credential().await?).apply(&mut headers)?;
1203 } else {
1204 for p in ra.placements {
1205 match p {
1206 CredentialPlacement::Header { name, value } => {
1207 insert_header(&mut headers, &name, &value)?
1208 }
1209 CredentialPlacement::Cookie { name, value } => {
1210 insert_header(&mut headers, "Cookie", &format!("{name}={value}"))?
1211 }
1212 _ => {}
1213 }
1214 }
1215 }
1216 } else {
1217 match &self.config.auth {
1218 AuthSpec::Inline(Auth::OAuth2 {
1219 token_url,
1220 client_id,
1221 client_secret,
1222 scopes,
1223 expiry_ratio,
1224 }) => {
1225 let token = self
1226 .token_cache
1227 .get_or_refresh(
1228 &self.client,
1229 token_url,
1230 client_id,
1231 client_secret,
1232 scopes,
1233 *expiry_ratio,
1234 )
1235 .await?;
1236 Auth::Bearer { token }.apply(&mut headers)?;
1237 }
1238 AuthSpec::Inline(Auth::TokenEndpoint {
1239 url: token_url,
1240 method: token_method,
1241 headers: token_headers,
1242 body: token_body,
1243 token_path,
1244 expiry_path,
1245 expiry_ratio,
1246 response_validator,
1247 }) => {
1248 let token = self
1249 .token_endpoint_cache
1250 .get_or_refresh(
1251 &self.client,
1252 token_url,
1253 token_method,
1254 token_headers,
1255 token_body.as_ref(),
1256 token_path,
1257 expiry_path.as_deref(),
1258 *expiry_ratio,
1259 response_validator.as_ref(),
1260 )
1261 .await?;
1262 Auth::Bearer { token }.apply(&mut headers)?;
1263 }
1264 AuthSpec::Inline(other) => other.apply(&mut headers)?,
1265 AuthSpec::Reference(_) => {}
1266 }
1267 }
1268 Ok(headers)
1269 }
1270
1271 async fn execute_request_once(
1278 &self,
1279 params: &HashMap<String, String>,
1280 url_override: Option<&str>,
1281 path_context: Option<&HashMap<String, Value>>,
1282 is_first_page: bool,
1283 body_params: &[(String, Value)],
1284 ) -> Result<(Value, HeaderMap), FaucetError> {
1285 let use_override = url_override.is_some();
1286
1287 let mut binds: Vec<(BindTarget, String, String)> = Vec::new();
1291 if let Some(b) = self.resolved_bind().await? {
1292 binds.push(b);
1293 }
1294 binds.extend(self.window_binds.lock().await.iter().cloned());
1295
1296 let query_btree: std::collections::BTreeMap<String, String> =
1297 params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1298
1299 let mut base_url = self.config.base_url.clone();
1304 let mut ra_headers: Vec<(String, String)> = Vec::new();
1305 let mut ra_query: Vec<(String, String)> = Vec::new();
1306 let mut ra_cookies: Vec<(String, String)> = Vec::new();
1307 let mut ra_body: Vec<(String, String)> = Vec::new();
1308 let mut used_request_auth = false;
1309 if let Some(provider) = &self.auth_provider {
1310 let ra = provider
1311 .request_auth(self.config.method.as_str(), &base_url, &query_btree)
1312 .await?;
1313 if !ra.is_empty() {
1314 used_request_auth = true;
1315 if let Some(b) = ra.base_url {
1316 base_url = b;
1317 }
1318 for p in ra.placements {
1319 match p {
1320 CredentialPlacement::Header { name, value } => {
1321 ra_headers.push((name, value))
1322 }
1323 CredentialPlacement::Query { name, value } => ra_query.push((name, value)),
1324 CredentialPlacement::Cookie { name, value } => {
1325 ra_cookies.push((name, value))
1326 }
1327 CredentialPlacement::BodyField { name, value } => {
1328 ra_body.push((name, value))
1329 }
1330 _ => {}
1331 }
1332 }
1333 }
1334 }
1335
1336 let mut url = match url_override {
1339 Some(u) => u.to_string(),
1340 None => {
1341 let path = match path_context {
1342 Some(ctx) => faucet_core::util::substitute_context(&self.config.path, ctx),
1343 None => self.config.path.clone(),
1344 };
1345 format!("{}/{}", base_url, path.trim_start_matches('/'))
1346 }
1347 };
1348 for (target, name, rendered) in &binds {
1349 if *target == BindTarget::Path {
1350 url = url.replace(&format!("{{{name}}}"), rendered);
1351 }
1352 }
1353
1354 let resolved_auth: Option<Auth> = if used_request_auth {
1359 None
1360 } else if let Some(provider) = &self.auth_provider {
1361 let cred = match provider
1365 .sign_request(self.config.method.as_str(), &url, &query_btree)
1366 .await?
1367 {
1368 Some(cred) => cred,
1369 None => provider.credential().await?,
1370 };
1371 Some(credential_to_auth(cred))
1372 } else {
1373 match &self.config.auth {
1374 AuthSpec::Inline(Auth::OAuth2 {
1375 token_url,
1376 client_id,
1377 client_secret,
1378 scopes,
1379 expiry_ratio,
1380 }) => {
1381 let token = self
1382 .token_cache
1383 .get_or_refresh(
1384 &self.client,
1385 token_url,
1386 client_id,
1387 client_secret,
1388 scopes,
1389 *expiry_ratio,
1390 )
1391 .await?;
1392 Some(Auth::Bearer { token })
1393 }
1394 AuthSpec::Inline(Auth::TokenEndpoint {
1395 url: token_url,
1396 method: token_method,
1397 headers: token_headers,
1398 body: token_body,
1399 token_path,
1400 expiry_path,
1401 expiry_ratio,
1402 response_validator,
1403 }) => {
1404 let token = self
1405 .token_endpoint_cache
1406 .get_or_refresh(
1407 &self.client,
1408 token_url,
1409 token_method,
1410 token_headers,
1411 token_body.as_ref(),
1412 token_path,
1413 expiry_path.as_deref(),
1414 *expiry_ratio,
1415 response_validator.as_ref(),
1416 )
1417 .await?;
1418 Some(Auth::Bearer { token })
1419 }
1420 AuthSpec::Inline(other) => Some(other.clone()),
1421 AuthSpec::Reference(r) => {
1422 return Err(FaucetError::Auth(format!(
1423 "auth references provider '{}' but no provider was supplied; \
1424 set one via the CLI `auth:` catalog or `with_auth_provider`",
1425 r.name
1426 )));
1427 }
1428 }
1429 };
1430
1431 let mut headers = self.static_headers.clone();
1434 if let Some(auth) = &resolved_auth {
1435 auth.apply(&mut headers)?;
1436 }
1437 for (name, value) in &ra_headers {
1439 insert_header(&mut headers, name, value)?;
1440 }
1441 if !ra_cookies.is_empty() {
1442 let cookie = ra_cookies
1443 .iter()
1444 .map(|(k, v)| format!("{k}={v}"))
1445 .collect::<Vec<_>>()
1446 .join("; ");
1447 insert_header(&mut headers, "Cookie", &cookie)?;
1448 }
1449 for (target, name, rendered) in &binds {
1451 if *target == BindTarget::Header {
1452 insert_header(&mut headers, name, rendered)?;
1453 }
1454 }
1455
1456 let mut req = self
1457 .client
1458 .request(self.config.method.clone(), &url)
1459 .headers(headers);
1460
1461 if !use_override {
1462 if let Some(ctx) = path_context {
1465 let substituted: HashMap<String, String> = params
1466 .iter()
1467 .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, ctx)))
1468 .collect();
1469 req = req.query(&substituted.iter().collect::<Vec<_>>());
1470 } else {
1471 req = req.query(params);
1472 }
1473 if !self.config.query_params_multi.is_empty() {
1477 let pairs: Vec<(String, String)> = self
1478 .config
1479 .query_params_multi
1480 .iter()
1481 .flat_map(|(k, vals)| {
1482 vals.iter().map(move |v| {
1483 let rendered = match path_context {
1484 Some(ctx) => faucet_core::util::substitute_context(v, ctx),
1485 None => v.clone(),
1486 };
1487 (k.clone(), rendered)
1488 })
1489 })
1490 .collect();
1491 req = req.query(
1492 &pairs
1493 .iter()
1494 .map(|(k, v)| (k.as_str(), v.as_str()))
1495 .collect::<Vec<_>>(),
1496 );
1497 }
1498 }
1499 if !ra_query.is_empty() {
1501 let pairs: Vec<(&str, &str)> = ra_query
1502 .iter()
1503 .map(|(k, v)| (k.as_str(), v.as_str()))
1504 .collect();
1505 req = req.query(&pairs);
1506 }
1507 for (target, name, rendered) in &binds {
1509 if *target == BindTarget::Query {
1510 req = req.query(&[(name.as_str(), rendered.as_str())]);
1511 }
1512 }
1513
1514 if let AuthSpec::Inline(Auth::ApiKeyQuery { param, value }) = &self.config.auth {
1516 req = req.query(&[(param.as_str(), value.as_str())]);
1517 }
1518
1519 let mut body_value: Option<Value> = match &self.config.body {
1529 Some(body) => match path_context {
1530 Some(ctx) => {
1531 let body_str = body.to_string();
1532 let substituted = faucet_core::util::substitute_context_json(&body_str, ctx);
1533 let substituted_value: Value =
1534 serde_json::from_str(&substituted).map_err(|e| {
1535 FaucetError::Source(format!(
1536 "REST source: context substitution produced an invalid JSON body: {e}"
1537 ))
1538 })?;
1539 Some(substituted_value)
1540 }
1541 None => Some(body.clone()),
1542 },
1543 None => None,
1544 };
1545 if !body_params.is_empty() {
1550 let obj = body_value.get_or_insert_with(|| Value::Object(serde_json::Map::new()));
1551 match obj.as_object_mut() {
1552 Some(map) => {
1553 for (field, value) in body_params {
1554 map.insert(field.clone(), value.clone());
1555 }
1556 }
1557 None => {
1558 return Err(FaucetError::Source(
1559 "REST source: body-carrying pagination requires a JSON object request \
1560 body to inject the pagination fields into"
1561 .into(),
1562 ));
1563 }
1564 }
1565 }
1566 let has_body_bind = binds.iter().any(|(t, _, _)| *t == BindTarget::Body);
1568 if !ra_body.is_empty() || has_body_bind {
1569 let obj = body_value.get_or_insert_with(|| Value::Object(serde_json::Map::new()));
1570 match obj.as_object_mut() {
1571 Some(map) => {
1572 for (name, value) in &ra_body {
1573 map.insert(name.clone(), Value::String(value.clone()));
1574 }
1575 for (target, name, rendered) in &binds {
1576 if *target == BindTarget::Body {
1577 map.insert(name.clone(), Value::String(rendered.clone()));
1578 }
1579 }
1580 }
1581 None => {
1582 return Err(FaucetError::Source(
1583 "REST source: a body-target auth/replication binding requires a JSON \
1584 object request body"
1585 .into(),
1586 ));
1587 }
1588 }
1589 }
1590 if let Some(body) = &body_value {
1591 req = req.json(body);
1592 }
1593
1594 let resp = req.send().await?;
1595 let status = resp.status();
1596
1597 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1599 let wait = parse_retry_after(resp.headers());
1600 return Err(FaucetError::RateLimited(wait));
1601 }
1602
1603 if is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
1611 tracing::debug!(
1612 status = status.as_u16(),
1613 "tolerated HTTP error on first request; treating as empty page"
1614 );
1615 return Ok((Value::Array(vec![]), HeaderMap::new()));
1616 }
1617 if !is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
1618 tracing::warn!(
1619 status = status.as_u16(),
1620 "tolerated HTTP error mid-pagination; surfacing as an error to avoid \
1621 silently truncating the stream"
1622 );
1623 }
1624
1625 if !status.is_success() {
1629 let resp_url = redact_error_url(resp.url(), &self.config.auth);
1635 let body_text = resp.text().await.unwrap_or_default();
1636 let truncated = if body_text.len() > 1024 {
1638 let end = body_text.floor_char_boundary(1024);
1640 format!("{}...(truncated)", &body_text[..end])
1641 } else {
1642 body_text
1643 };
1644 return Err(FaucetError::HttpStatus {
1645 status: status.as_u16(),
1646 url: resp_url,
1647 body: truncated,
1648 });
1649 }
1650
1651 let resp_headers = resp.headers().clone();
1652
1653 if status == reqwest::StatusCode::NO_CONTENT {
1659 return Ok((Value::Array(vec![]), resp_headers));
1660 }
1661 let bytes = resp.bytes().await?;
1662 if bytes.iter().all(u8::is_ascii_whitespace) {
1663 return Ok((Value::Array(vec![]), resp_headers));
1664 }
1665 if !self.config.decode.is_empty() {
1671 let records = crate::decode::run_decode(&bytes, &self.config.decode).await?;
1672 return Ok((Value::Array(records), resp_headers));
1673 }
1674 let body: Value = match self.config.response_format {
1679 crate::config::ResponseFormat::Json => serde_json::from_slice(&bytes)?,
1680 crate::config::ResponseFormat::Csv => Value::Array(
1681 crate::format::parse_csv(
1682 &bytes,
1683 self.config.csv_delimiter,
1684 self.config.csv_has_headers,
1685 )
1686 .await?,
1687 ),
1688 crate::config::ResponseFormat::Excel => Value::Array(crate::format::parse_excel(
1689 &bytes,
1690 self.config.excel_sheet.as_deref(),
1691 self.config.excel_header_row,
1692 )?),
1693 };
1694 Ok((body, resp_headers))
1695 }
1696}
1697
1698fn redact_error_url(url: &reqwest::Url, auth: &AuthSpec<Auth>) -> String {
1704 let mut redacted = url.clone();
1705 if let AuthSpec::Inline(Auth::ApiKeyQuery { param, .. }) = auth {
1706 let pairs: Vec<(String, String)> = url
1707 .query_pairs()
1708 .map(|(k, v)| {
1709 if k == param.as_str() {
1710 (k.into_owned(), "***".to_string())
1711 } else {
1712 (k.into_owned(), v.into_owned())
1713 }
1714 })
1715 .collect();
1716 redacted.set_query(None);
1717 if !pairs.is_empty() {
1718 let mut qp = redacted.query_pairs_mut();
1719 for (k, v) in &pairs {
1720 qp.append_pair(k, v);
1721 }
1722 }
1723 }
1724 faucet_core::redact_uri_credentials(redacted.as_str())
1725}
1726
1727fn parse_retry_after(headers: &HeaderMap) -> Duration {
1732 const DEFAULT: Duration = Duration::from_secs(60);
1733 let Some(raw) = headers
1734 .get(reqwest::header::RETRY_AFTER)
1735 .and_then(|v| v.to_str().ok())
1736 .map(str::trim)
1737 else {
1738 return DEFAULT;
1739 };
1740 if let Ok(secs) = raw.parse::<u64>() {
1742 return Duration::from_secs(secs);
1743 }
1744 if let Ok(when) = httpdate::parse_http_date(raw) {
1746 return when
1747 .duration_since(std::time::SystemTime::now())
1748 .unwrap_or(Duration::ZERO);
1749 }
1750 DEFAULT
1751}
1752
1753fn value_max(current: Option<Value>, candidate: Value) -> Option<Value> {
1758 match current {
1759 None => Some(candidate),
1760 Some(cur) => {
1761 let take_candidate = match (&cur, &candidate) {
1762 (Value::Number(a), Value::Number(b)) => {
1763 b.as_f64().unwrap_or(f64::MIN) > a.as_f64().unwrap_or(f64::MIN)
1764 }
1765 (Value::String(a), Value::String(b)) => b > a,
1766 _ => true,
1767 };
1768 Some(if take_candidate { candidate } else { cur })
1769 }
1770 }
1771}
1772
1773#[async_trait]
1774impl faucet_core::Source for RestStream {
1775 async fn fetch_with_context(
1776 &self,
1777 context: &std::collections::HashMap<String, serde_json::Value>,
1778 ) -> Result<Vec<Value>, FaucetError> {
1779 if context.is_empty() {
1780 RestStream::fetch_all(self).await
1782 } else if self.config.partitions.is_empty() {
1783 self.fetch_partition(Some(context), None).await
1785 } else {
1786 let mut all_records = Vec::new();
1788 for partition in &self.config.partitions {
1789 let mut merged = context.clone();
1790 merged.extend(partition.iter().map(|(k, v)| (k.clone(), v.clone())));
1791 all_records.extend(self.fetch_partition(Some(&merged), None).await?);
1792 }
1793 Ok(all_records)
1794 }
1795 }
1796
1797 async fn fetch_with_context_incremental(
1798 &self,
1799 context: &std::collections::HashMap<String, serde_json::Value>,
1800 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1801 let records = self.fetch_with_context(context).await?;
1802 let bookmark = self
1803 .config
1804 .replication_key
1805 .as_deref()
1806 .and_then(|key| faucet_core::replication::max_replication_value(&records, key))
1807 .cloned();
1808 Ok((records, bookmark))
1809 }
1810
1811 fn connector_name(&self) -> &'static str {
1812 "rest"
1813 }
1814
1815 fn config_schema(&self) -> serde_json::Value {
1816 serde_json::to_value(faucet_core::schema_for!(RestStreamConfig))
1817 .expect("schema serialization")
1818 }
1819
1820 fn dataset_uri(&self) -> String {
1821 format!(
1822 "{}{}",
1823 faucet_core::redact_uri_credentials(&self.config.base_url),
1824 self.config.path
1825 )
1826 }
1827
1828 fn state_key(&self) -> Option<String> {
1829 self.config.state_key.clone()
1830 }
1831
1832 fn stream_pages<'a>(
1833 &'a self,
1834 context: &'a HashMap<String, Value>,
1835 _batch_size: usize,
1836 ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + 'a>> {
1837 if self.config.partitions.is_empty() {
1847 return self.stream_pages_inner(Some(context));
1848 }
1849 let contexts: Vec<HashMap<String, Value>> = self
1850 .config
1851 .partitions
1852 .iter()
1853 .map(|p| {
1854 let mut merged = context.clone();
1855 merged.extend(p.iter().map(|(k, v)| (k.clone(), v.clone())));
1856 merged
1857 })
1858 .collect();
1859 Box::pin(async_stream::try_stream! {
1860 let mut max_bookmark: Option<Value> = None;
1865 for ctx in &contexts {
1866 let mut inner = self.stream_pages_inner(Some(ctx));
1867 loop {
1868 let page = std::future::poll_fn(|cx| inner.as_mut().poll_next(cx)).await;
1869 match page {
1870 Some(Ok(p)) => {
1871 if let Some(bm) = p.bookmark {
1872 max_bookmark = value_max(max_bookmark.take(), bm);
1873 yield faucet_core::StreamPage { records: p.records, bookmark: None };
1874 } else {
1875 yield p;
1876 }
1877 }
1878 Some(Err(e)) => Err(e)?,
1879 None => break,
1880 }
1881 }
1882 }
1883 if max_bookmark.is_some() {
1884 yield faucet_core::StreamPage { records: Vec::new(), bookmark: max_bookmark };
1885 }
1886 })
1887 }
1888
1889 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1890 *self.runtime_start.lock().await = Some(bookmark);
1891 Ok(())
1892 }
1893
1894 fn supports_discover(&self) -> bool {
1895 self.config.odata.is_some()
1898 }
1899
1900 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
1901 if self.config.odata.is_none() {
1902 return Err(FaucetError::Source(
1903 "rest: discovery is only supported for OData sources — set an `odata:` block"
1904 .into(),
1905 ));
1906 }
1907 let url = format!("{}/$metadata", self.config.base_url.trim_end_matches('/'));
1908 let mut headers = self.static_headers.clone();
1910 for (k, v) in self.metadata_headers(&url).await?.iter() {
1911 headers.insert(k.clone(), v.clone());
1912 }
1913 let resp = self
1914 .client
1915 .get(&url)
1916 .headers(headers)
1917 .send()
1918 .await
1919 .map_err(|e| {
1920 FaucetError::Source(format!("rest: OData $metadata request failed: {e}"))
1921 })?;
1922 let status = resp.status();
1923 if !status.is_success() {
1924 return Err(FaucetError::Source(format!(
1925 "rest: OData $metadata returned HTTP {}",
1926 status.as_u16()
1927 )));
1928 }
1929 let xml = resp.text().await.map_err(|e| {
1930 FaucetError::Source(format!("rest: reading OData $metadata failed: {e}"))
1931 })?;
1932 crate::odata::descriptors_from_edmx(&xml)
1933 }
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938 use super::*;
1939 use serde_json::json;
1940
1941 #[test]
1942 fn value_max_consolidates_partition_bookmarks() {
1943 assert_eq!(
1945 value_max(None, json!("2026-01-01")),
1946 Some(json!("2026-01-01"))
1947 );
1948 assert_eq!(
1950 value_max(Some(json!("2026-01-01")), json!("2026-03-01")),
1951 Some(json!("2026-03-01"))
1952 );
1953 assert_eq!(
1954 value_max(Some(json!("2026-03-01")), json!("2026-01-01")),
1955 Some(json!("2026-03-01"))
1956 );
1957 assert_eq!(value_max(Some(json!(5)), json!(10)), Some(json!(10)));
1959 assert_eq!(value_max(Some(json!(10)), json!(5)), Some(json!(10)));
1960 assert_eq!(value_max(Some(json!("a")), json!(3)), Some(json!(3)));
1962 }
1963
1964 #[test]
1965 fn injected_policy_applies_when_legacy_fields_at_defaults() {
1966 let stream =
1968 RestStream::new(RestStreamConfig::new("https://api.example.com", "/items")).unwrap();
1969 let injected = faucet_core::RetryPolicy {
1970 max_attempts: 9,
1971 base: Duration::from_secs(7),
1972 ..faucet_core::RetryPolicy::default()
1973 };
1974 let stream = stream.with_retry_policy(injected);
1975 assert_eq!(stream.retry_policy.max_attempts, 9);
1976 assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
1977 }
1978
1979 #[test]
1980 fn legacy_fields_take_precedence_over_injected_policy() {
1981 let config = RestStreamConfig::new("https://api.example.com", "/items").max_retries(7);
1984 let stream = RestStream::new(config).unwrap();
1985 assert_eq!(stream.retry_policy.max_attempts, 8);
1987 let injected = faucet_core::RetryPolicy {
1988 max_attempts: 99,
1989 base: Duration::from_secs(42),
1990 ..faucet_core::RetryPolicy::default()
1991 };
1992 let stream = stream.with_retry_policy(injected);
1993 assert_eq!(stream.retry_policy.max_attempts, 8);
1995 assert_eq!(stream.retry_policy.base, DEFAULT_RETRY_BACKOFF);
1996 }
1997
1998 #[test]
1999 fn redact_error_url_hides_api_key_query_param() {
2000 let auth = AuthSpec::Inline(Auth::ApiKeyQuery {
2002 param: "api_token".into(),
2003 value: "SUPERSECRET".into(),
2004 });
2005 let url =
2006 reqwest::Url::parse("https://api.example.com/v1/items?page=2&api_token=SUPERSECRET")
2007 .unwrap();
2008 let redacted = redact_error_url(&url, &auth);
2009 assert!(
2010 !redacted.contains("SUPERSECRET"),
2011 "secret must be gone: {redacted}"
2012 );
2013 assert!(redacted.contains("api_token=%2A%2A%2A") || redacted.contains("api_token=***"));
2014 assert!(
2015 redacted.contains("page=2"),
2016 "non-secret param kept: {redacted}"
2017 );
2018 }
2019
2020 #[test]
2021 fn redact_error_url_without_api_key_query_still_scrubs_common_keys() {
2022 let auth: AuthSpec<Auth> = AuthSpec::Inline(Auth::None);
2025 let url = reqwest::Url::parse("https://u:pw@api.example.com/v1/items?token=abc").unwrap();
2026 let redacted = redact_error_url(&url, &auth);
2027 assert!(
2028 !redacted.contains("abc"),
2029 "common secret key redacted: {redacted}"
2030 );
2031 assert!(!redacted.contains("pw@"), "userinfo redacted: {redacted}");
2032 }
2033
2034 #[test]
2035 fn test_substitute_context_substitutes_placeholders() {
2036 let mut ctx = HashMap::new();
2037 ctx.insert("org_id".to_string(), json!("acme"));
2038 ctx.insert("repo".to_string(), json!("myrepo"));
2039 let result =
2040 faucet_core::util::substitute_context("/orgs/{org_id}/repos/{repo}/issues", &ctx);
2041 assert_eq!(result, "/orgs/acme/repos/myrepo/issues");
2042 }
2043
2044 #[test]
2045 fn test_substitute_context_no_placeholders() {
2046 let ctx = HashMap::new();
2047 let result = faucet_core::util::substitute_context("/api/users", &ctx);
2048 assert_eq!(result, "/api/users");
2049 }
2050
2051 #[test]
2052 fn test_substitute_context_numeric_value() {
2053 let mut ctx = HashMap::new();
2054 ctx.insert("id".to_string(), json!(42));
2055 let result = faucet_core::util::substitute_context("/items/{id}", &ctx);
2056 assert_eq!(result, "/items/42");
2057 }
2058
2059 #[test]
2060 fn test_parse_retry_after_valid() {
2061 let mut headers = HeaderMap::new();
2062 headers.insert(
2063 reqwest::header::RETRY_AFTER,
2064 reqwest::header::HeaderValue::from_static("30"),
2065 );
2066 assert_eq!(parse_retry_after(&headers), Duration::from_secs(30));
2067 }
2068
2069 #[test]
2070 fn test_parse_retry_after_missing_defaults_to_60() {
2071 assert_eq!(
2072 parse_retry_after(&HeaderMap::new()),
2073 Duration::from_secs(60)
2074 );
2075 }
2076
2077 #[test]
2078 fn test_parse_retry_after_non_numeric_defaults_to_60() {
2079 let mut headers = HeaderMap::new();
2080 headers.insert(
2081 reqwest::header::RETRY_AFTER,
2082 reqwest::header::HeaderValue::from_static("not-a-number"),
2083 );
2084 assert_eq!(parse_retry_after(&headers), Duration::from_secs(60));
2085 }
2086
2087 #[test]
2088 fn test_parse_retry_after_http_date() {
2089 let future = std::time::SystemTime::now() + Duration::from_secs(7200);
2091 let date = httpdate::fmt_http_date(future);
2092 let mut headers = HeaderMap::new();
2093 headers.insert(
2094 reqwest::header::RETRY_AFTER,
2095 reqwest::header::HeaderValue::from_str(&date).unwrap(),
2096 );
2097 let d = parse_retry_after(&headers);
2098 assert!(
2100 d > Duration::from_secs(3600),
2101 "expected ~2h from HTTP-date, got {d:?}"
2102 );
2103 assert!(
2104 d <= Duration::from_secs(7200),
2105 "should not exceed the target instant, got {d:?}"
2106 );
2107 }
2108
2109 #[test]
2110 fn test_parse_retry_after_past_http_date_is_zero() {
2111 let past = std::time::SystemTime::now() - Duration::from_secs(3600);
2113 let date = httpdate::fmt_http_date(past);
2114 let mut headers = HeaderMap::new();
2115 headers.insert(
2116 reqwest::header::RETRY_AFTER,
2117 reqwest::header::HeaderValue::from_str(&date).unwrap(),
2118 );
2119 assert_eq!(parse_retry_after(&headers), Duration::ZERO);
2120 }
2121
2122 #[test]
2123 fn test_new_rejects_invalid_expiry_ratio_zero() {
2124 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2125 token_url: "https://auth.example.com/token".into(),
2126 client_id: "id".into(),
2127 client_secret: "secret".into(),
2128 scopes: vec![],
2129 expiry_ratio: 0.0,
2130 });
2131 let result = RestStream::new(config);
2132 assert!(result.is_err());
2133 assert!(matches!(result, Err(FaucetError::Auth(_))));
2134 }
2135
2136 #[test]
2137 fn test_new_rejects_invalid_expiry_ratio_negative() {
2138 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2139 token_url: "https://auth.example.com/token".into(),
2140 client_id: "id".into(),
2141 client_secret: "secret".into(),
2142 scopes: vec![],
2143 expiry_ratio: -0.5,
2144 });
2145 assert!(RestStream::new(config).is_err());
2146 }
2147
2148 #[test]
2149 fn test_new_rejects_invalid_expiry_ratio_above_one() {
2150 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2151 token_url: "https://auth.example.com/token".into(),
2152 client_id: "id".into(),
2153 client_secret: "secret".into(),
2154 scopes: vec![],
2155 expiry_ratio: 1.5,
2156 });
2157 assert!(RestStream::new(config).is_err());
2158 }
2159
2160 #[test]
2161 fn test_new_accepts_valid_expiry_ratio() {
2162 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2163 token_url: "https://auth.example.com/token".into(),
2164 client_id: "id".into(),
2165 client_secret: "secret".into(),
2166 scopes: vec![],
2167 expiry_ratio: 1.0,
2168 });
2169 assert!(RestStream::new(config).is_ok());
2170 }
2171
2172 #[test]
2173 fn test_new_with_no_auth_succeeds() {
2174 let config = RestStreamConfig::new("https://example.com", "/data");
2175 assert!(RestStream::new(config).is_ok());
2176 }
2177
2178 #[test]
2179 fn test_new_with_timeout() {
2180 let config =
2181 RestStreamConfig::new("https://example.com", "/data").timeout(Duration::from_secs(10));
2182 assert!(RestStream::new(config).is_ok());
2183 }
2184
2185 #[test]
2186 fn test_substitute_context_missing_placeholder_unchanged() {
2187 let mut ctx = HashMap::new();
2188 ctx.insert("org".to_string(), json!("acme"));
2189 let result = faucet_core::util::substitute_context("/items/{missing}", &ctx);
2190 assert_eq!(result, "/items/{missing}");
2191 }
2192
2193 #[test]
2194 fn test_substitute_context_boolean_value() {
2195 let mut ctx = HashMap::new();
2196 ctx.insert("flag".to_string(), json!(true));
2197 let result = faucet_core::util::substitute_context("/items/{flag}", &ctx);
2198 assert_eq!(result, "/items/true");
2199 }
2200
2201 #[test]
2202 fn rest_source_connector_name_is_rest() {
2203 use faucet_core::Source;
2204 let source = RestStream::new(RestStreamConfig::new("https://example.com", "/data"))
2205 .expect("minimal RestStream construction");
2206 assert_eq!(source.connector_name(), "rest");
2207 }
2208
2209 #[test]
2210 fn dataset_uri_combines_base_and_path() {
2211 use faucet_core::Source;
2212 let source = RestStream::new(RestStreamConfig::new(
2213 "https://api.example.com",
2214 "/v1/users",
2215 ))
2216 .unwrap();
2217 assert_eq!(source.dataset_uri(), "https://api.example.com/v1/users");
2218 }
2219
2220 #[test]
2221 fn dataset_uri_redacts_credentials() {
2222 use faucet_core::Source;
2223 let source = RestStream::new(RestStreamConfig::new(
2224 "https://user:secret@api.example.com",
2225 "/v1/data",
2226 ))
2227 .unwrap();
2228 assert_eq!(source.dataset_uri(), "https://api.example.com/v1/data");
2229 }
2230}
2231
2232#[cfg(all(test, feature = "mtls"))]
2235mod mtls_tests {
2236 use super::*;
2237 use crate::config::TlsClientConfig;
2238
2239 const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
2240 const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
2241
2242 fn pem() -> TlsClientConfig {
2243 TlsClientConfig {
2244 client_cert: Some(CERT.to_string()),
2245 client_key: Some(KEY.to_string()),
2246 ..Default::default()
2247 }
2248 }
2249
2250 #[test]
2251 fn pem_identity_builds() {
2252 let cfg = RestStreamConfig::new("https://x.test", "/y").tls(pem());
2253 assert!(RestStream::new(cfg).is_ok());
2254 }
2255
2256 #[test]
2257 fn min_version_branches_are_exercised() {
2258 let mut tls = pem();
2260 tls.min_version = Some("1.2".into());
2261 assert!(RestStream::new(RestStreamConfig::new("https://x.test", "/y").tls(tls)).is_ok());
2262 let mut tls = pem();
2266 tls.min_version = Some("1.3".into());
2267 let _ = RestStream::new(RestStreamConfig::new("https://x.test", "/y").tls(tls));
2268 }
2269
2270 #[test]
2271 fn pkcs12_identity_builds() {
2272 let p12 = concat!(
2273 env!("CARGO_MANIFEST_DIR"),
2274 "/tests/fixtures/mtls/identity.p12"
2275 );
2276 let tls = TlsClientConfig {
2277 client_identity_pkcs12: Some(p12.to_string()),
2278 pkcs12_password: Some("changeit".into()),
2279 ..Default::default()
2280 };
2281 let cfg = RestStreamConfig::new("https://x.test", "/y").tls(tls);
2282 assert!(RestStream::new(cfg).is_ok());
2283 }
2284
2285 #[test]
2286 fn invalid_pem_errors_without_leaking_key() {
2287 let tls = TlsClientConfig {
2288 client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
2289 client_key: Some("SUPERSECRETKEY".into()),
2290 ..Default::default()
2291 };
2292 let cfg = RestStreamConfig::new("https://x.test", "/y").tls(tls);
2293 let err = RestStream::new(cfg)
2294 .map(|_| ())
2295 .expect_err("bad PEM must error");
2296 assert!(!err.to_string().contains("SUPERSECRETKEY"));
2297 }
2298
2299 #[test]
2300 fn missing_pkcs12_file_errors() {
2301 let tls = TlsClientConfig {
2302 client_identity_pkcs12: Some("/no/such.p12".into()),
2303 pkcs12_password: Some("x".into()),
2304 ..Default::default()
2305 };
2306 let cfg = RestStreamConfig::new("https://x.test", "/y").tls(tls);
2307 assert!(RestStream::new(cfg).is_err());
2308 }
2309}