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 substitute_captured(s: &str, captured: &std::collections::BTreeMap<String, String>) -> String {
147 if captured.is_empty() || !s.contains("${") {
148 return s.to_string();
149 }
150 let mut out = s.to_string();
151 for (k, v) in captured {
152 out = out.replace(&format!("${{{k}}}"), v);
153 }
154 out
155}
156
157fn credential_to_auth(cred: Credential) -> Auth {
158 match cred {
159 Credential::Bearer(token) => Auth::Bearer { token },
160 Credential::Token(token) => Auth::Custom {
161 headers: std::iter::once(("Authorization".to_string(), token)).collect(),
162 },
163 Credential::Basic { username, password } => Auth::Basic { username, password },
164 Credential::Header { name, value } => Auth::Custom {
165 headers: std::iter::once((name, value)).collect(),
166 },
167 }
168}
169
170fn jsonpath_first_string(v: &Value, path: &str) -> Option<String> {
173 use jsonpath_rust::JsonPath;
174 let results = v.query(path).ok()?;
175 match results.first()? {
176 Value::String(s) => Some(s.clone()),
177 Value::Number(n) => Some(n.to_string()),
178 Value::Bool(b) => Some(b.to_string()),
179 _ => None,
180 }
181}
182
183fn jsonpath_first_value(v: &Value, path: &str) -> Option<Value> {
186 use jsonpath_rust::JsonPath;
187 v.query(path).ok()?.first().map(|x| (*x).clone())
188}
189
190fn is_terminal_locator(value: &str) -> bool {
193 let v = value.trim();
194 v.is_empty() || v.eq_ignore_ascii_case("null")
195}
196
197fn next_locator(
201 headers: &HeaderMap,
202 body: Option<&Value>,
203 job: &crate::async_job::AsyncJobConfig,
204) -> Option<String> {
205 if let Some(name) = &job.fetch.locator_header
206 && let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok())
207 && !is_terminal_locator(raw)
208 {
209 return Some(raw.trim().to_string());
210 }
211 if let Some(path) = &job.fetch.locator_body
212 && let Some(body) = body
213 && let Some(raw) = jsonpath_first_string(body, path)
214 && !is_terminal_locator(&raw)
215 {
216 return Some(raw.trim().to_string());
217 }
218 None
219}
220
221fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), FaucetError> {
224 let hn = HeaderName::from_bytes(name.as_bytes())
225 .map_err(|e| FaucetError::Config(format!("rest: invalid header name '{name}': {e}")))?;
226 let hv = HeaderValue::from_str(value).map_err(|e| {
227 FaucetError::Config(format!("rest: invalid value for header '{name}': {e}"))
228 })?;
229 headers.insert(hn, hv);
230 Ok(())
231}
232
233impl RestStream {
234 pub fn new(mut config: RestStreamConfig) -> Result<Self, FaucetError> {
236 config.apply_odata_defaults();
239 config.validate()?;
241 let expiry_ratio_to_validate = match &config.auth {
243 AuthSpec::Inline(Auth::OAuth2 { expiry_ratio, .. })
244 | AuthSpec::Inline(Auth::TokenEndpoint { expiry_ratio, .. }) => Some(*expiry_ratio),
245 _ => None,
246 };
247 if let Some(ratio) = expiry_ratio_to_validate
248 && (ratio <= 0.0 || ratio > 1.0)
249 {
250 return Err(FaucetError::Auth(format!(
251 "expiry_ratio must be in (0.0, 1.0], got {ratio}"
252 )));
253 }
254
255 let mut builder = Client::builder();
256 if let Some(t) = config.timeout {
257 builder = builder.timeout(t);
258 }
259 if let Some(tls) = &config.tls {
263 tls.validate()?;
264 builder = apply_client_tls(builder, tls)?;
265 }
266 let retry_policy = faucet_core::RetryPolicy {
271 max_attempts: config.max_retries.saturating_add(1),
272 backoff: faucet_core::BackoffKind::Exponential,
273 base: config.retry_backoff,
274 ..faucet_core::RetryPolicy::default()
275 };
276 let static_headers = crate::config::build_header_map(&config.headers)?;
279 Ok(Self {
280 config,
281 client: builder.build()?,
282 token_cache: TokenCache::new(),
283 token_endpoint_cache: TokenEndpointCache::new(),
284 auth_provider: None,
285 runtime_start: Arc::new(AsyncMutex::new(None)),
286 window_binds: Arc::new(AsyncMutex::new(Vec::new())),
287 now_override: None,
288 retry_policy,
289 static_headers,
290 })
291 }
292
293 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
300 self.auth_provider = Some(provider);
301 self
302 }
303
304 #[doc(hidden)]
309 pub fn with_now_override_rfc3339(mut self, rfc3339: &str) -> Self {
310 self.now_override = chrono::DateTime::parse_from_rfc3339(rfc3339)
311 .ok()
312 .map(|d| d.with_timezone(&chrono::Utc));
313 self
314 }
315
316 pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
335 let user_changed_legacy_fields = self.config.max_retries != DEFAULT_MAX_RETRIES
336 || self.config.retry_backoff != DEFAULT_RETRY_BACKOFF;
337 if !user_changed_legacy_fields {
338 self.retry_policy = policy;
339 }
340 self
341 }
342
343 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
352 if self.config.partitions.is_empty() {
353 self.fetch_partition(None, None).await
354 } else if let Some(concurrency) = self.config.partition_concurrency {
355 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency.max(1)));
357 let mut handles = Vec::with_capacity(self.config.partitions.len());
358
359 for ctx in &self.config.partitions {
360 let permit =
361 semaphore.clone().acquire_owned().await.map_err(|e| {
362 FaucetError::Config(format!("semaphore acquire failed: {e}"))
363 })?;
364 let fut = self.fetch_partition(Some(ctx), None);
365 handles.push(async move {
366 let result = fut.await;
367 drop(permit);
368 result
369 });
370 }
371
372 let results = futures::future::try_join_all(handles).await?;
373 Ok(results.into_iter().flatten().collect())
374 } else {
375 let mut all_records = Vec::new();
376 for ctx in &self.config.partitions {
377 let records = self.fetch_partition(Some(ctx), None).await?;
378 all_records.extend(records);
379 }
380 Ok(all_records)
381 }
382 }
383
384 pub async fn fetch_all_as<T: for<'de> Deserialize<'de>>(&self) -> Result<Vec<T>, FaucetError> {
386 let values = self.fetch_all().await?;
387 values
388 .into_iter()
389 .map(|v| serde_json::from_value(v).map_err(FaucetError::Json))
390 .collect()
391 }
392
393 pub async fn infer_schema(&self) -> Result<Value, FaucetError> {
405 if let Some(ref s) = self.config.schema {
406 return Ok(s.clone());
407 }
408 let limit = match self.config.schema_sample_size {
409 0 => None,
410 n => Some(n),
411 };
412 let records = self.fetch_partition(None, limit).await?;
413 Ok(schema::infer_schema(&records))
414 }
415
416 pub async fn fetch_all_incremental(&self) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
425 let records = self.fetch_all().await?;
426 let bookmark = self
427 .config
428 .replication_key
429 .as_deref()
430 .and_then(|key| max_replication_value(&records, key))
431 .cloned();
432 Ok((records, bookmark))
433 }
434
435 pub fn stream_pages(
463 &self,
464 ) -> Pin<Box<dyn Stream<Item = Result<Vec<Value>, FaucetError>> + Send + '_>> {
465 let mut inner = self.stream_pages_inner(None);
466 Box::pin(async_stream::try_stream! {
467 loop {
468 let page = std::future::poll_fn(|cx| inner.as_mut().poll_next(cx)).await;
469 match page {
470 Some(Ok(p)) => yield p.records,
471 Some(Err(e)) => Err(e)?,
472 None => break,
473 }
474 }
475 })
476 }
477
478 fn extract_page(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
485 extract::extract_configured(
486 body,
487 self.config.records_path.as_deref(),
488 self.config.record_ancestors.as_ref(),
489 &self.config.records_multi,
490 self.config.op_field.as_deref().unwrap_or("_op"),
491 )
492 }
493
494 fn stream_pages_inner(
502 &self,
503 context: Option<&HashMap<String, Value>>,
504 ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + '_>> {
505 let owned_context: Option<HashMap<String, Value>> = context.cloned();
508
509 Box::pin(async_stream::try_stream! {
510 if self.config.async_job.is_some() {
513 let records = self.run_async_job().await?;
514 yield faucet_core::StreamPage { records, bookmark: None };
515 return;
516 }
517
518 let effective_start: Option<Value> = {
523 let guard = self.runtime_start.lock().await;
524 guard
525 .clone()
526 .or_else(|| self.config.start_replication_value.clone())
527 };
528
529 if self.config.max_pages.is_some()
539 && self.config.replication_method == ReplicationMethod::Incremental
540 && self.config.replication_key.is_some()
541 {
542 tracing::warn!(
543 "max_pages combined with incremental replication assumes the API returns rows \
544 ordered ascending by the replication key; an unordered feed can drop unfetched \
545 lower-key records on resume. Ensure ordering, or remove max_pages for a full \
546 incremental sweep."
547 );
548 }
549
550 let windowed = self.config.window.is_some();
556 let passes: Vec<Option<faucet_core::Window>> = if let Some(win) = &self.config.window {
557 let start_val = effective_start.clone().ok_or_else(|| {
558 FaucetError::Config(
559 "rest: `window` slicing requires a start bookmark (from a `state:` store) \
560 or `start_replication_value` to anchor the first window".into(),
561 )
562 })?;
563 let start_instant = faucet_core::parse_instant(&start_val)?;
564 let now = self.now_override.unwrap_or_else(chrono::Utc::now);
565 let step = win.step_duration()?;
566 let lookback = win.lookback_duration()?;
567 let (windows, truncated) =
568 faucet_core::enumerate_windows(start_instant, now, step, lookback, win.max_windows);
569 if truncated {
570 tracing::warn!(
571 max_windows = win.max_windows,
572 "window slicing hit `max_windows`; this run's sweep is truncated — the next \
573 run resumes from the last completed window"
574 );
575 }
576 if windows.is_empty() {
577 tracing::debug!(
578 "window slicing: the bookmark is at or ahead of now; nothing to fetch"
579 );
580 }
581 windows.into_iter().map(Some).collect()
582 } else {
583 vec![None]
584 };
585
586 for pass in passes {
587 if let Some(w) = &pass {
591 let win = self
592 .config
593 .window
594 .as_ref()
595 .expect("a window pass implies a `window:` block");
596 let lower = (win.lower.into, win.lower.name.clone(), win.render_lower(w));
597 let upper_rendered = win.render_upper(w)?;
598 let upper = (win.upper.into, win.upper.name.clone(), upper_rendered);
599 *self.window_binds.lock().await = vec![lower, upper];
600 }
601
602 let window_bookmark: Option<Value> =
607 pass.as_ref().map(|w| Value::String(w.end.to_rfc3339()));
608
609 let mut state = PaginationState::default();
610 if self.config.persist_cursor
613 && let Some(seed) = effective_start.as_ref()
614 {
615 state.next_token =
616 Some(crate::pagination::value_to_param_string(seed));
617 }
618 let mut pages_fetched = 0usize;
619 let mut running_max: Option<Value> = effective_start.clone();
620 let mut running_cursor: Option<Value> = effective_start.clone();
622 let mut bookmark_emitted = false;
623
624 loop {
625 if let Some(max) = self.config.max_pages
626 && pages_fetched >= max
627 {
628 tracing::warn!("max pages ({max}) reached");
629 break;
630 }
631
632 let mut params = self.config.query_params.clone();
633 self.config.pagination.apply_params(&mut params, &state);
634
635 let url_override = match &self.config.pagination {
636 PaginationStyle::LinkHeader | PaginationStyle::NextLinkInBody { .. } => {
637 state.next_link.clone()
638 }
639 _ => None,
640 };
641
642 let body_params = self.config.pagination.body_params(&state);
646
647 let params_clone = params.clone();
648 let ctx_ref = owned_context.as_ref();
649 let is_first_page = pages_fetched == 0;
650 let (body, resp_headers) = retry::execute_with_retry(
651 self.retry_policy.max_attempts.saturating_sub(1),
657 self.retry_policy.base,
658 || {
659 self.execute_request(
660 ¶ms_clone,
661 url_override.as_deref(),
662 ctx_ref,
663 is_first_page,
664 &body_params,
665 )
666 },
667 )
668 .await?;
669
670 let raw_records = self.extract_page(&body)?;
671 let raw_count = raw_records.len();
672
673 if self.config.persist_cursor
675 && let Some(path) = self.config.pagination.cursor_path()
676 && let Some(cursor) = jsonpath_first_value(&body, path)
677 {
678 match &cursor {
679 Value::Null => {}
680 Value::String(s) if s.is_empty() => {}
681 _ => running_cursor = Some(cursor),
682 }
683 }
684
685 let records = if !windowed
689 && self.config.replication_method == ReplicationMethod::Incremental
690 {
691 if let (Some(key), Some(start)) =
692 (&self.config.replication_key, effective_start.as_ref())
693 {
694 filter_incremental(raw_records, key, start)
695 } else {
696 raw_records
697 }
698 } else {
699 raw_records
700 };
701
702 if !windowed
709 && self.config.replication_method == ReplicationMethod::Incremental
710 {
711 let page_max: Option<Value> = match self
712 .config
713 .replication_bind
714 .as_ref()
715 .and_then(|b| b.advance_from.as_deref())
716 {
717 Some(path) => faucet_core::util::extract_records(&body, Some(path))
718 .ok()
719 .and_then(|vs| vs.into_iter().next()),
720 None => self
721 .config
722 .replication_key
723 .as_deref()
724 .and_then(|key| max_replication_value(&records, key).cloned()),
725 };
726 if let Some(page_max) = page_max {
727 running_max = Some(match running_max.take() {
728 Some(prev) => max_value(prev, page_max),
729 None => page_max,
730 });
731 }
732 }
733
734 self.config
738 .pagination
739 .update_record_cursor(&records, &mut state);
740
741 let has_next = self
748 .config
749 .pagination
750 .advance(&body, &resp_headers, &mut state, raw_count)?;
751 pages_fetched += 1;
752
753 if has_next {
754 yield faucet_core::StreamPage { records, bookmark: None };
757 } else if state.current_page_is_duplicate {
758 break;
763 } else {
764 let bookmark = if self.config.persist_cursor {
766 running_cursor.clone()
767 } else if windowed {
768 window_bookmark.clone()
769 } else {
770 running_max.clone()
771 };
772 bookmark_emitted = bookmark.is_some();
773 yield faucet_core::StreamPage { records, bookmark };
774 break;
775 }
776
777 if let Some(delay) = self.config.request_delay {
778 tokio::time::sleep(delay).await;
779 }
780 }
781
782 let pass_bookmark = if self.config.persist_cursor {
789 running_cursor.clone()
790 } else if windowed {
791 window_bookmark.clone()
792 } else {
793 running_max.clone()
794 };
795 if !bookmark_emitted && pass_bookmark.is_some() {
796 yield faucet_core::StreamPage {
797 records: Vec::new(),
798 bookmark: pass_bookmark,
799 };
800 }
801 }
802
803 if windowed {
805 self.window_binds.lock().await.clear();
806 }
807 })
808 }
809
810 async fn fetch_partition(
815 &self,
816 context: Option<&HashMap<String, Value>>,
817 max_records: Option<usize>,
818 ) -> Result<Vec<Value>, FaucetError> {
819 let mut all_records = Vec::new();
820 let mut pages_fetched = 0usize;
821 let mut pages = self.stream_pages_inner(context);
822
823 loop {
825 let page = std::future::poll_fn(|cx: &mut std::task::Context<'_>| {
826 pages.as_mut().poll_next(cx)
827 })
828 .await;
829
830 match page {
831 Some(Ok(page)) => {
832 pages_fetched += 1;
833 let records = page.records;
834 match max_records {
835 Some(limit) => {
836 let remaining = limit.saturating_sub(all_records.len());
837 all_records.extend(records.into_iter().take(remaining));
838 if all_records.len() >= limit {
839 break;
840 }
841 }
842 None => all_records.extend(records),
843 }
844 }
845 Some(Err(e)) => return Err(e),
846 None => break,
847 }
848 }
849
850 tracing::info!(
851 stream = self.config.name.as_deref().unwrap_or("(unnamed)"),
852 records = all_records.len(),
853 pages = pages_fetched,
854 "fetch complete"
855 );
856 Ok(all_records)
857 }
858
859 async fn execute_request(
870 &self,
871 params: &HashMap<String, String>,
872 url_override: Option<&str>,
873 path_context: Option<&HashMap<String, Value>>,
874 is_first_page: bool,
875 body_params: &[(String, Value)],
876 ) -> Result<(Value, HeaderMap), FaucetError> {
877 match self
878 .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: 401, .. }) if self.uses_inline_cached_token() => {
888 tracing::warn!(
889 "401 Unauthorized with a cached inline OAuth2/TokenEndpoint token; \
890 invalidating the token cache and retrying once with a fresh token"
891 );
892 self.invalidate_inline_token_cache().await;
893 self.execute_request_once(
894 params,
895 url_override,
896 path_context,
897 is_first_page,
898 body_params,
899 )
900 .await
901 }
902 Err(FaucetError::HttpStatus { status, .. }) if self.provider_wants_reauth(status) => {
906 if let Some(provider) = &self.auth_provider {
907 tracing::warn!(
908 status,
909 "shared auth provider requested re-auth on this status; \
910 re-authenticating and retrying once"
911 );
912 let _ = provider.invalidate(&Credential::Token(String::new())).await;
913 }
914 self.execute_request_once(
915 params,
916 url_override,
917 path_context,
918 is_first_page,
919 body_params,
920 )
921 .await
922 }
923 other => other,
924 }
925 }
926
927 fn provider_wants_reauth(&self, status: u16) -> bool {
929 self.auth_provider
930 .as_ref()
931 .is_some_and(|p| p.reauth_statuses().contains(&status))
932 }
933
934 fn uses_inline_cached_token(&self) -> bool {
938 self.auth_provider.is_none()
939 && matches!(
940 self.config.auth,
941 AuthSpec::Inline(Auth::OAuth2 { .. })
942 | AuthSpec::Inline(Auth::TokenEndpoint { .. })
943 )
944 }
945
946 async fn invalidate_inline_token_cache(&self) {
949 match &self.config.auth {
950 AuthSpec::Inline(Auth::OAuth2 { .. }) => self.token_cache.invalidate().await,
951 AuthSpec::Inline(Auth::TokenEndpoint { .. }) => {
952 self.token_endpoint_cache.invalidate().await
953 }
954 _ => {}
955 }
956 }
957
958 async fn resolved_bind(&self) -> Result<Option<(BindTarget, String, String)>, FaucetError> {
962 let Some(bind) = &self.config.replication_bind else {
963 return Ok(None);
964 };
965 let bookmark = {
966 let guard = self.runtime_start.lock().await;
967 guard.clone()
968 }
969 .or_else(|| self.config.start_replication_value.clone());
970 match bookmark {
971 Some(bm) => Ok(Some((bind.into, bind.name.clone(), bind.render(&bm)?))),
972 None => Ok(None),
973 }
974 }
975
976 async fn job_request_bytes(
980 &self,
981 method: &str,
982 url: &str,
983 headers: &HashMap<String, String>,
984 query: &HashMap<String, String>,
985 json: Option<&Value>,
986 ) -> Result<(Vec<u8>, HeaderMap), FaucetError> {
987 let m = reqwest::Method::from_bytes(method.to_uppercase().as_bytes()).map_err(|_| {
988 FaucetError::Config(format!("async_job: invalid HTTP method '{method}'"))
989 })?;
990 let mut hdrs = self.static_headers.clone();
993 for (k, v) in self.metadata_headers(url).await?.iter() {
994 hdrs.insert(k.clone(), v.clone());
995 }
996 for (k, v) in headers {
997 insert_header(&mut hdrs, k, v)?;
998 }
999 let mut req = self.client.request(m, url).headers(hdrs);
1000 if !query.is_empty() {
1001 let pairs: Vec<(&str, &str)> = query
1002 .iter()
1003 .map(|(k, v)| (k.as_str(), v.as_str()))
1004 .collect();
1005 req = req.query(&pairs);
1006 }
1007 if let Some(j) = json {
1008 req = req.json(j);
1009 }
1010 let resp = req
1011 .send()
1012 .await
1013 .map_err(|e| FaucetError::Source(format!("async_job: request to {url} failed: {e}")))?;
1014 let status = resp.status();
1015 if !status.is_success() {
1016 return Err(FaucetError::HttpStatus {
1017 status: status.as_u16(),
1018 url: url.to_string(),
1019 body: format!("async_job: {url} returned HTTP {}", status.as_u16()),
1020 });
1021 }
1022 let resp_headers = resp.headers().clone();
1023 Ok((resp.bytes().await?.to_vec(), resp_headers))
1024 }
1025
1026 async fn job_request_json(
1027 &self,
1028 method: &str,
1029 url: &str,
1030 headers: &HashMap<String, String>,
1031 query: &HashMap<String, String>,
1032 json: Option<&Value>,
1033 ) -> Result<Value, FaucetError> {
1034 let (bytes, _headers) = self
1035 .job_request_bytes(method, url, headers, query, json)
1036 .await?;
1037 serde_json::from_slice(&bytes)
1038 .map_err(|e| FaucetError::Source(format!("async_job: {url} returned non-JSON: {e}")))
1039 }
1040
1041 async fn run_async_job(&self) -> Result<Vec<Value>, FaucetError> {
1044 use crate::async_job::{JobOutcome, resolve_url, substitute_job_id};
1045 let job = self
1046 .config
1047 .async_job
1048 .as_ref()
1049 .expect("run_async_job called with async_job set");
1050 let base = &self.config.base_url;
1051
1052 let submit_url = resolve_url(base, job.submit.url.as_deref().unwrap_or_default());
1054 let submit_body = self
1055 .job_request_json(
1056 &job.submit.method,
1057 &submit_url,
1058 &job.submit.headers,
1059 &job.submit.query,
1060 job.submit.json.as_ref(),
1061 )
1062 .await?;
1063 let job_id = jsonpath_first_string(&submit_body, &job.job_id).ok_or_else(|| {
1064 FaucetError::Source(format!(
1065 "async_job: submit response had no job id at '{}'",
1066 job.job_id
1067 ))
1068 })?;
1069
1070 let poll_url = resolve_url(base, &substitute_job_id(&job.poll.url, &job_id));
1072 let deadline =
1073 tokio::time::Instant::now() + std::time::Duration::from_secs(job.poll.timeout_secs);
1074 let last_poll_body: Value = loop {
1077 let body = self
1078 .job_request_json(
1079 &job.poll.method,
1080 &poll_url,
1081 &job.poll.headers,
1082 &job.poll.query,
1083 None,
1084 )
1085 .await?;
1086 let status = jsonpath_first_string(&body, &job.status.path).unwrap_or_default();
1087 match job.status.classify(&status) {
1088 JobOutcome::Success => break body,
1089 JobOutcome::Failure => {
1090 return Err(FaucetError::Source(format!(
1091 "async_job: job failed with status '{status}'"
1092 )));
1093 }
1094 JobOutcome::Pending => {
1095 if tokio::time::Instant::now() >= deadline {
1096 return Err(FaucetError::Source(format!(
1097 "async_job: polling timed out after {}s (last status '{status}')",
1098 job.poll.timeout_secs
1099 )));
1100 }
1101 tokio::time::sleep(std::time::Duration::from_secs(job.poll.interval_secs))
1102 .await;
1103 }
1104 }
1105 };
1106
1107 let fetch_url = match (&job.fetch.url_from, &job.fetch.url) {
1110 (Some(path), _) => {
1111 let resolved = jsonpath_first_string(&last_poll_body, path).ok_or_else(|| {
1112 FaucetError::Source(format!(
1113 "async_job: fetch.url_from '{path}' matched no string in the poll response"
1114 ))
1115 })?;
1116 resolve_url(base, &resolved)
1117 }
1118 (None, Some(url)) => resolve_url(base, &substitute_job_id(url, &job_id)),
1119 (None, None) => {
1120 return Err(FaucetError::Config(
1121 "async_job: `fetch` requires exactly one of `url` or `url_from`".into(),
1122 ));
1123 }
1124 };
1125
1126 let mut all_records = Vec::new();
1130 let mut locator: Option<String> = None;
1131 loop {
1132 let mut query = job.fetch.query.clone();
1134 if let (Some(loc), Some(param)) = (&locator, &job.fetch.locator_param) {
1135 query.insert(param.clone(), loc.clone());
1136 }
1137 let (bytes, resp_headers) = self
1138 .job_request_bytes(
1139 &job.fetch.method,
1140 &fetch_url,
1141 &job.fetch.headers,
1142 &query,
1143 job.fetch.json.as_ref(),
1144 )
1145 .await?;
1146 let (records, body_value) = self.parse_fetch_page(&bytes, job).await?;
1147 all_records.extend(records);
1148
1149 let next = next_locator(&resp_headers, body_value.as_ref(), job);
1152 match next {
1153 Some(loc) if locator.as_deref() != Some(loc.as_str()) => {
1154 locator = Some(loc);
1155 }
1156 _ => break,
1157 }
1158 }
1159 Ok(all_records)
1160 }
1161
1162 async fn parse_fetch_page(
1167 &self,
1168 bytes: &[u8],
1169 job: &crate::async_job::AsyncJobConfig,
1170 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1171 if !self.config.decode.is_empty() {
1172 let records = crate::decode::run_decode(bytes, &self.config.decode).await?;
1173 return Ok((records, None));
1174 }
1175 match self.config.response_format {
1176 crate::config::ResponseFormat::Json => {
1177 let v: Value = serde_json::from_slice(bytes).map_err(|e| {
1178 FaucetError::Source(format!("async_job: result is not JSON: {e}"))
1179 })?;
1180 let records = match job.fetch.records_path.as_deref() {
1181 Some(rp) => extract::extract_records(&v, Some(rp))?,
1182 None => self.extract_page(&v)?,
1183 };
1184 Ok((records, Some(v)))
1185 }
1186 crate::config::ResponseFormat::Csv => {
1187 let records = crate::format::parse_csv(
1188 bytes,
1189 self.config.csv_delimiter,
1190 self.config.csv_has_headers,
1191 )
1192 .await?;
1193 Ok((records, None))
1194 }
1195 crate::config::ResponseFormat::Excel => {
1196 let records = crate::format::parse_excel(
1197 bytes,
1198 self.config.excel_sheet.as_deref(),
1199 self.config.excel_header_row,
1200 )?;
1201 Ok((records, None))
1202 }
1203 }
1204 }
1205
1206 async fn metadata_headers(&self, url: &str) -> Result<HeaderMap, FaucetError> {
1211 let mut headers = HeaderMap::new();
1212 if let Some(provider) = &self.auth_provider {
1213 let ra = provider
1214 .request_auth("GET", url, &std::collections::BTreeMap::new())
1215 .await?;
1216 if ra.is_empty() {
1217 credential_to_auth(provider.credential().await?).apply(&mut headers)?;
1218 } else {
1219 for p in ra.placements {
1220 match p {
1221 CredentialPlacement::Header { name, value } => {
1222 insert_header(&mut headers, &name, &value)?
1223 }
1224 CredentialPlacement::Cookie { name, value } => {
1225 insert_header(&mut headers, "Cookie", &format!("{name}={value}"))?
1226 }
1227 _ => {}
1228 }
1229 }
1230 }
1231 } else {
1232 match &self.config.auth {
1233 AuthSpec::Inline(Auth::OAuth2 {
1234 token_url,
1235 client_id,
1236 client_secret,
1237 scopes,
1238 expiry_ratio,
1239 }) => {
1240 let token = self
1241 .token_cache
1242 .get_or_refresh(
1243 &self.client,
1244 token_url,
1245 client_id,
1246 client_secret,
1247 scopes,
1248 *expiry_ratio,
1249 )
1250 .await?;
1251 Auth::Bearer { token }.apply(&mut headers)?;
1252 }
1253 AuthSpec::Inline(Auth::TokenEndpoint {
1254 url: token_url,
1255 method: token_method,
1256 headers: token_headers,
1257 body: token_body,
1258 token_path,
1259 expiry_path,
1260 expiry_ratio,
1261 response_validator,
1262 }) => {
1263 let token = self
1264 .token_endpoint_cache
1265 .get_or_refresh(
1266 &self.client,
1267 token_url,
1268 token_method,
1269 token_headers,
1270 token_body.as_ref(),
1271 token_path,
1272 expiry_path.as_deref(),
1273 *expiry_ratio,
1274 response_validator.as_ref(),
1275 )
1276 .await?;
1277 Auth::Bearer { token }.apply(&mut headers)?;
1278 }
1279 AuthSpec::Inline(other) => other.apply(&mut headers)?,
1280 AuthSpec::Reference(_) => {}
1281 }
1282 }
1283 Ok(headers)
1284 }
1285
1286 async fn execute_request_once(
1293 &self,
1294 params: &HashMap<String, String>,
1295 url_override: Option<&str>,
1296 path_context: Option<&HashMap<String, Value>>,
1297 is_first_page: bool,
1298 body_params: &[(String, Value)],
1299 ) -> Result<(Value, HeaderMap), FaucetError> {
1300 let use_override = url_override.is_some();
1301
1302 let mut binds: Vec<(BindTarget, String, String)> = Vec::new();
1306 if let Some(b) = self.resolved_bind().await? {
1307 binds.push(b);
1308 }
1309 binds.extend(self.window_binds.lock().await.iter().cloned());
1310
1311 let query_btree: std::collections::BTreeMap<String, String> =
1312 params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1313
1314 let mut base_url = self.config.base_url.clone();
1319 let mut ra_headers: Vec<(String, String)> = Vec::new();
1320 let mut ra_query: Vec<(String, String)> = Vec::new();
1321 let mut ra_cookies: Vec<(String, String)> = Vec::new();
1322 let mut ra_body: Vec<(String, String)> = Vec::new();
1323 let mut captured: std::collections::BTreeMap<String, String> =
1324 std::collections::BTreeMap::new();
1325 let mut used_request_auth = false;
1326 if let Some(provider) = &self.auth_provider {
1327 let ra = provider
1328 .request_auth(self.config.method.as_str(), &base_url, &query_btree)
1329 .await?;
1330 if !ra.is_empty() {
1331 used_request_auth = true;
1332 if let Some(b) = ra.base_url {
1333 base_url = b;
1334 }
1335 captured = ra.captured;
1336 for p in ra.placements {
1337 match p {
1338 CredentialPlacement::Header { name, value } => {
1339 ra_headers.push((name, value))
1340 }
1341 CredentialPlacement::Query { name, value } => ra_query.push((name, value)),
1342 CredentialPlacement::Cookie { name, value } => {
1343 ra_cookies.push((name, value))
1344 }
1345 CredentialPlacement::BodyField { name, value } => {
1346 ra_body.push((name, value))
1347 }
1348 _ => {}
1349 }
1350 }
1351 }
1352 }
1353
1354 let mut url = match url_override {
1357 Some(u) => u.to_string(),
1358 None => {
1359 let path = match path_context {
1360 Some(ctx) => faucet_core::util::substitute_context(&self.config.path, ctx),
1361 None => self.config.path.clone(),
1362 };
1363 format!("{}/{}", base_url, path.trim_start_matches('/'))
1364 }
1365 };
1366 for (target, name, rendered) in &binds {
1367 if *target == BindTarget::Path {
1368 url = url.replace(&format!("{{{name}}}"), rendered);
1369 }
1370 }
1371 url = substitute_captured(&url, &captured);
1374
1375 let resolved_auth: Option<Auth> = if used_request_auth {
1380 None
1381 } else if let Some(provider) = &self.auth_provider {
1382 let cred = match provider
1386 .sign_request(self.config.method.as_str(), &url, &query_btree)
1387 .await?
1388 {
1389 Some(cred) => cred,
1390 None => provider.credential().await?,
1391 };
1392 Some(credential_to_auth(cred))
1393 } else {
1394 match &self.config.auth {
1395 AuthSpec::Inline(Auth::OAuth2 {
1396 token_url,
1397 client_id,
1398 client_secret,
1399 scopes,
1400 expiry_ratio,
1401 }) => {
1402 let token = self
1403 .token_cache
1404 .get_or_refresh(
1405 &self.client,
1406 token_url,
1407 client_id,
1408 client_secret,
1409 scopes,
1410 *expiry_ratio,
1411 )
1412 .await?;
1413 Some(Auth::Bearer { token })
1414 }
1415 AuthSpec::Inline(Auth::TokenEndpoint {
1416 url: token_url,
1417 method: token_method,
1418 headers: token_headers,
1419 body: token_body,
1420 token_path,
1421 expiry_path,
1422 expiry_ratio,
1423 response_validator,
1424 }) => {
1425 let token = self
1426 .token_endpoint_cache
1427 .get_or_refresh(
1428 &self.client,
1429 token_url,
1430 token_method,
1431 token_headers,
1432 token_body.as_ref(),
1433 token_path,
1434 expiry_path.as_deref(),
1435 *expiry_ratio,
1436 response_validator.as_ref(),
1437 )
1438 .await?;
1439 Some(Auth::Bearer { token })
1440 }
1441 AuthSpec::Inline(other) => Some(other.clone()),
1442 AuthSpec::Reference(r) => {
1443 return Err(FaucetError::Auth(format!(
1444 "auth references provider '{}' but no provider was supplied; \
1445 set one via the CLI `auth:` catalog or `with_auth_provider`",
1446 r.name
1447 )));
1448 }
1449 }
1450 };
1451
1452 let mut headers = if captured.is_empty() {
1457 self.static_headers.clone()
1458 } else {
1459 let mut h = HeaderMap::new();
1460 for (name, value) in self.static_headers.iter() {
1461 let sv = substitute_captured(value.to_str().unwrap_or_default(), &captured);
1462 let hv =
1463 reqwest::header::HeaderValue::from_str(&sv).unwrap_or_else(|_| value.clone());
1464 h.insert(name.clone(), hv);
1465 }
1466 h
1467 };
1468 if let Some(auth) = &resolved_auth {
1469 auth.apply(&mut headers)?;
1470 }
1471 for (name, value) in &ra_headers {
1473 insert_header(&mut headers, name, value)?;
1474 }
1475 if !ra_cookies.is_empty() {
1476 let cookie = ra_cookies
1477 .iter()
1478 .map(|(k, v)| format!("{k}={v}"))
1479 .collect::<Vec<_>>()
1480 .join("; ");
1481 insert_header(&mut headers, "Cookie", &cookie)?;
1482 }
1483 for (target, name, rendered) in &binds {
1485 if *target == BindTarget::Header {
1486 insert_header(&mut headers, name, rendered)?;
1487 }
1488 }
1489
1490 let mut req = self
1491 .client
1492 .request(self.config.method.clone(), &url)
1493 .headers(headers);
1494
1495 if !use_override {
1496 if let Some(ctx) = path_context {
1499 let substituted: HashMap<String, String> = params
1500 .iter()
1501 .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, ctx)))
1502 .collect();
1503 req = req.query(&substituted.iter().collect::<Vec<_>>());
1504 } else {
1505 req = req.query(params);
1506 }
1507 if !self.config.query_params_multi.is_empty() {
1511 let pairs: Vec<(String, String)> = self
1512 .config
1513 .query_params_multi
1514 .iter()
1515 .flat_map(|(k, vals)| {
1516 vals.iter().map(move |v| {
1517 let rendered = match path_context {
1518 Some(ctx) => faucet_core::util::substitute_context(v, ctx),
1519 None => v.clone(),
1520 };
1521 (k.clone(), rendered)
1522 })
1523 })
1524 .collect();
1525 req = req.query(
1526 &pairs
1527 .iter()
1528 .map(|(k, v)| (k.as_str(), v.as_str()))
1529 .collect::<Vec<_>>(),
1530 );
1531 }
1532 }
1533 if !ra_query.is_empty() {
1535 let pairs: Vec<(&str, &str)> = ra_query
1536 .iter()
1537 .map(|(k, v)| (k.as_str(), v.as_str()))
1538 .collect();
1539 req = req.query(&pairs);
1540 }
1541 for (target, name, rendered) in &binds {
1543 if *target == BindTarget::Query {
1544 req = req.query(&[(name.as_str(), rendered.as_str())]);
1545 }
1546 }
1547
1548 if let AuthSpec::Inline(Auth::ApiKeyQuery { param, value }) = &self.config.auth {
1550 req = req.query(&[(param.as_str(), value.as_str())]);
1551 }
1552
1553 let mut body_value: Option<Value> = match &self.config.body {
1563 Some(body) => match path_context {
1564 Some(ctx) => {
1565 let body_str = body.to_string();
1566 let substituted = faucet_core::util::substitute_context_json(&body_str, ctx);
1567 let substituted_value: Value =
1568 serde_json::from_str(&substituted).map_err(|e| {
1569 FaucetError::Source(format!(
1570 "REST source: context substitution produced an invalid JSON body: {e}"
1571 ))
1572 })?;
1573 Some(substituted_value)
1574 }
1575 None => Some(body.clone()),
1576 },
1577 None => None,
1578 };
1579 if !body_params.is_empty() {
1584 let obj = body_value.get_or_insert_with(|| Value::Object(serde_json::Map::new()));
1585 match obj.as_object_mut() {
1586 Some(map) => {
1587 for (field, value) in body_params {
1588 map.insert(field.clone(), value.clone());
1589 }
1590 }
1591 None => {
1592 return Err(FaucetError::Source(
1593 "REST source: body-carrying pagination requires a JSON object request \
1594 body to inject the pagination fields into"
1595 .into(),
1596 ));
1597 }
1598 }
1599 }
1600 let has_body_bind = binds.iter().any(|(t, _, _)| *t == BindTarget::Body);
1602 if !ra_body.is_empty() || has_body_bind {
1603 let obj = body_value.get_or_insert_with(|| Value::Object(serde_json::Map::new()));
1604 match obj.as_object_mut() {
1605 Some(map) => {
1606 for (name, value) in &ra_body {
1607 map.insert(name.clone(), Value::String(value.clone()));
1608 }
1609 for (target, name, rendered) in &binds {
1610 if *target == BindTarget::Body {
1611 map.insert(name.clone(), Value::String(rendered.clone()));
1612 }
1613 }
1614 }
1615 None => {
1616 return Err(FaucetError::Source(
1617 "REST source: a body-target auth/replication binding requires a JSON \
1618 object request body"
1619 .into(),
1620 ));
1621 }
1622 }
1623 }
1624 if let Some(body) = &body_value {
1625 req = req.json(body);
1626 }
1627
1628 let resp = req.send().await?;
1629 let status = resp.status();
1630
1631 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1633 let wait = parse_retry_after(resp.headers());
1634 return Err(FaucetError::RateLimited(wait));
1635 }
1636
1637 if is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
1645 tracing::debug!(
1646 status = status.as_u16(),
1647 "tolerated HTTP error on first request; treating as empty page"
1648 );
1649 return Ok((Value::Array(vec![]), HeaderMap::new()));
1650 }
1651 if !is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
1652 tracing::warn!(
1653 status = status.as_u16(),
1654 "tolerated HTTP error mid-pagination; surfacing as an error to avoid \
1655 silently truncating the stream"
1656 );
1657 }
1658
1659 if !status.is_success() {
1663 let resp_url = redact_error_url(resp.url(), &self.config.auth);
1669 let body_text = resp.text().await.unwrap_or_default();
1670 let truncated = if body_text.len() > 1024 {
1672 let end = body_text.floor_char_boundary(1024);
1674 format!("{}...(truncated)", &body_text[..end])
1675 } else {
1676 body_text
1677 };
1678 return Err(FaucetError::HttpStatus {
1679 status: status.as_u16(),
1680 url: resp_url,
1681 body: truncated,
1682 });
1683 }
1684
1685 let resp_headers = resp.headers().clone();
1686
1687 if status == reqwest::StatusCode::NO_CONTENT {
1693 return Ok((Value::Array(vec![]), resp_headers));
1694 }
1695 let bytes = resp.bytes().await?;
1696 if bytes.iter().all(u8::is_ascii_whitespace) {
1697 return Ok((Value::Array(vec![]), resp_headers));
1698 }
1699 if !self.config.decode.is_empty() {
1705 let records = crate::decode::run_decode(&bytes, &self.config.decode).await?;
1706 return Ok((Value::Array(records), resp_headers));
1707 }
1708 let body: Value = match self.config.response_format {
1713 crate::config::ResponseFormat::Json => serde_json::from_slice(&bytes)?,
1714 crate::config::ResponseFormat::Csv => Value::Array(
1715 crate::format::parse_csv(
1716 &bytes,
1717 self.config.csv_delimiter,
1718 self.config.csv_has_headers,
1719 )
1720 .await?,
1721 ),
1722 crate::config::ResponseFormat::Excel => Value::Array(crate::format::parse_excel(
1723 &bytes,
1724 self.config.excel_sheet.as_deref(),
1725 self.config.excel_header_row,
1726 )?),
1727 };
1728 Ok((body, resp_headers))
1729 }
1730}
1731
1732fn redact_error_url(url: &reqwest::Url, auth: &AuthSpec<Auth>) -> String {
1738 let mut redacted = url.clone();
1739 if let AuthSpec::Inline(Auth::ApiKeyQuery { param, .. }) = auth {
1740 let pairs: Vec<(String, String)> = url
1741 .query_pairs()
1742 .map(|(k, v)| {
1743 if k == param.as_str() {
1744 (k.into_owned(), "***".to_string())
1745 } else {
1746 (k.into_owned(), v.into_owned())
1747 }
1748 })
1749 .collect();
1750 redacted.set_query(None);
1751 if !pairs.is_empty() {
1752 let mut qp = redacted.query_pairs_mut();
1753 for (k, v) in &pairs {
1754 qp.append_pair(k, v);
1755 }
1756 }
1757 }
1758 faucet_core::redact_uri_credentials(redacted.as_str())
1759}
1760
1761fn parse_retry_after(headers: &HeaderMap) -> Duration {
1766 const DEFAULT: Duration = Duration::from_secs(60);
1767 let Some(raw) = headers
1768 .get(reqwest::header::RETRY_AFTER)
1769 .and_then(|v| v.to_str().ok())
1770 .map(str::trim)
1771 else {
1772 return DEFAULT;
1773 };
1774 if let Ok(secs) = raw.parse::<u64>() {
1776 return Duration::from_secs(secs);
1777 }
1778 if let Ok(when) = httpdate::parse_http_date(raw) {
1780 return when
1781 .duration_since(std::time::SystemTime::now())
1782 .unwrap_or(Duration::ZERO);
1783 }
1784 DEFAULT
1785}
1786
1787fn value_max(current: Option<Value>, candidate: Value) -> Option<Value> {
1792 match current {
1793 None => Some(candidate),
1794 Some(cur) => {
1795 let take_candidate = match (&cur, &candidate) {
1796 (Value::Number(a), Value::Number(b)) => {
1797 b.as_f64().unwrap_or(f64::MIN) > a.as_f64().unwrap_or(f64::MIN)
1798 }
1799 (Value::String(a), Value::String(b)) => b > a,
1800 _ => true,
1801 };
1802 Some(if take_candidate { candidate } else { cur })
1803 }
1804 }
1805}
1806
1807#[async_trait]
1808impl faucet_core::Source for RestStream {
1809 async fn fetch_with_context(
1810 &self,
1811 context: &std::collections::HashMap<String, serde_json::Value>,
1812 ) -> Result<Vec<Value>, FaucetError> {
1813 if context.is_empty() {
1814 RestStream::fetch_all(self).await
1816 } else if self.config.partitions.is_empty() {
1817 self.fetch_partition(Some(context), None).await
1819 } else {
1820 let mut all_records = Vec::new();
1822 for partition in &self.config.partitions {
1823 let mut merged = context.clone();
1824 merged.extend(partition.iter().map(|(k, v)| (k.clone(), v.clone())));
1825 all_records.extend(self.fetch_partition(Some(&merged), None).await?);
1826 }
1827 Ok(all_records)
1828 }
1829 }
1830
1831 async fn fetch_with_context_incremental(
1832 &self,
1833 context: &std::collections::HashMap<String, serde_json::Value>,
1834 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1835 let records = self.fetch_with_context(context).await?;
1836 let bookmark = self
1837 .config
1838 .replication_key
1839 .as_deref()
1840 .and_then(|key| faucet_core::replication::max_replication_value(&records, key))
1841 .cloned();
1842 Ok((records, bookmark))
1843 }
1844
1845 fn connector_name(&self) -> &'static str {
1846 "rest"
1847 }
1848
1849 fn config_schema(&self) -> serde_json::Value {
1850 serde_json::to_value(faucet_core::schema_for!(RestStreamConfig))
1851 .expect("schema serialization")
1852 }
1853
1854 fn dataset_uri(&self) -> String {
1855 format!(
1856 "{}{}",
1857 faucet_core::redact_uri_credentials(&self.config.base_url),
1858 self.config.path
1859 )
1860 }
1861
1862 fn state_key(&self) -> Option<String> {
1863 self.config.state_key.clone()
1864 }
1865
1866 fn stream_pages<'a>(
1867 &'a self,
1868 context: &'a HashMap<String, Value>,
1869 _batch_size: usize,
1870 ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + 'a>> {
1871 if self.config.partitions.is_empty() {
1881 return self.stream_pages_inner(Some(context));
1882 }
1883 let contexts: Vec<HashMap<String, Value>> = self
1884 .config
1885 .partitions
1886 .iter()
1887 .map(|p| {
1888 let mut merged = context.clone();
1889 merged.extend(p.iter().map(|(k, v)| (k.clone(), v.clone())));
1890 merged
1891 })
1892 .collect();
1893 Box::pin(async_stream::try_stream! {
1894 let mut max_bookmark: Option<Value> = None;
1899 for ctx in &contexts {
1900 let mut inner = self.stream_pages_inner(Some(ctx));
1901 loop {
1902 let page = std::future::poll_fn(|cx| inner.as_mut().poll_next(cx)).await;
1903 match page {
1904 Some(Ok(p)) => {
1905 if let Some(bm) = p.bookmark {
1906 max_bookmark = value_max(max_bookmark.take(), bm);
1907 yield faucet_core::StreamPage { records: p.records, bookmark: None };
1908 } else {
1909 yield p;
1910 }
1911 }
1912 Some(Err(e)) => Err(e)?,
1913 None => break,
1914 }
1915 }
1916 }
1917 if max_bookmark.is_some() {
1918 yield faucet_core::StreamPage { records: Vec::new(), bookmark: max_bookmark };
1919 }
1920 })
1921 }
1922
1923 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1924 *self.runtime_start.lock().await = Some(bookmark);
1925 Ok(())
1926 }
1927
1928 fn supports_discover(&self) -> bool {
1929 self.config.odata.is_some()
1932 }
1933
1934 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
1935 if self.config.odata.is_none() {
1936 return Err(FaucetError::Source(
1937 "rest: discovery is only supported for OData sources — set an `odata:` block"
1938 .into(),
1939 ));
1940 }
1941 let url = format!("{}/$metadata", self.config.base_url.trim_end_matches('/'));
1942 let mut headers = self.static_headers.clone();
1944 for (k, v) in self.metadata_headers(&url).await?.iter() {
1945 headers.insert(k.clone(), v.clone());
1946 }
1947 let resp = self
1948 .client
1949 .get(&url)
1950 .headers(headers)
1951 .send()
1952 .await
1953 .map_err(|e| {
1954 FaucetError::Source(format!("rest: OData $metadata request failed: {e}"))
1955 })?;
1956 let status = resp.status();
1957 if !status.is_success() {
1958 return Err(FaucetError::Source(format!(
1959 "rest: OData $metadata returned HTTP {}",
1960 status.as_u16()
1961 )));
1962 }
1963 let xml = resp.text().await.map_err(|e| {
1964 FaucetError::Source(format!("rest: reading OData $metadata failed: {e}"))
1965 })?;
1966 crate::odata::descriptors_from_edmx(&xml)
1967 }
1968}
1969
1970#[cfg(test)]
1971mod tests {
1972 use super::*;
1973 use serde_json::json;
1974
1975 #[test]
1976 fn value_max_consolidates_partition_bookmarks() {
1977 assert_eq!(
1979 value_max(None, json!("2026-01-01")),
1980 Some(json!("2026-01-01"))
1981 );
1982 assert_eq!(
1984 value_max(Some(json!("2026-01-01")), json!("2026-03-01")),
1985 Some(json!("2026-03-01"))
1986 );
1987 assert_eq!(
1988 value_max(Some(json!("2026-03-01")), json!("2026-01-01")),
1989 Some(json!("2026-03-01"))
1990 );
1991 assert_eq!(value_max(Some(json!(5)), json!(10)), Some(json!(10)));
1993 assert_eq!(value_max(Some(json!(10)), json!(5)), Some(json!(10)));
1994 assert_eq!(value_max(Some(json!("a")), json!(3)), Some(json!(3)));
1996 }
1997
1998 #[test]
1999 fn injected_policy_applies_when_legacy_fields_at_defaults() {
2000 let stream =
2002 RestStream::new(RestStreamConfig::new("https://api.example.com", "/items")).unwrap();
2003 let injected = faucet_core::RetryPolicy {
2004 max_attempts: 9,
2005 base: Duration::from_secs(7),
2006 ..faucet_core::RetryPolicy::default()
2007 };
2008 let stream = stream.with_retry_policy(injected);
2009 assert_eq!(stream.retry_policy.max_attempts, 9);
2010 assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
2011 }
2012
2013 #[test]
2014 fn legacy_fields_take_precedence_over_injected_policy() {
2015 let config = RestStreamConfig::new("https://api.example.com", "/items").max_retries(7);
2018 let stream = RestStream::new(config).unwrap();
2019 assert_eq!(stream.retry_policy.max_attempts, 8);
2021 let injected = faucet_core::RetryPolicy {
2022 max_attempts: 99,
2023 base: Duration::from_secs(42),
2024 ..faucet_core::RetryPolicy::default()
2025 };
2026 let stream = stream.with_retry_policy(injected);
2027 assert_eq!(stream.retry_policy.max_attempts, 8);
2029 assert_eq!(stream.retry_policy.base, DEFAULT_RETRY_BACKOFF);
2030 }
2031
2032 #[test]
2033 fn redact_error_url_hides_api_key_query_param() {
2034 let auth = AuthSpec::Inline(Auth::ApiKeyQuery {
2036 param: "api_token".into(),
2037 value: "SUPERSECRET".into(),
2038 });
2039 let url =
2040 reqwest::Url::parse("https://api.example.com/v1/items?page=2&api_token=SUPERSECRET")
2041 .unwrap();
2042 let redacted = redact_error_url(&url, &auth);
2043 assert!(
2044 !redacted.contains("SUPERSECRET"),
2045 "secret must be gone: {redacted}"
2046 );
2047 assert!(redacted.contains("api_token=%2A%2A%2A") || redacted.contains("api_token=***"));
2048 assert!(
2049 redacted.contains("page=2"),
2050 "non-secret param kept: {redacted}"
2051 );
2052 }
2053
2054 #[test]
2055 fn redact_error_url_without_api_key_query_still_scrubs_common_keys() {
2056 let auth: AuthSpec<Auth> = AuthSpec::Inline(Auth::None);
2059 let url = reqwest::Url::parse("https://u:pw@api.example.com/v1/items?token=abc").unwrap();
2060 let redacted = redact_error_url(&url, &auth);
2061 assert!(
2062 !redacted.contains("abc"),
2063 "common secret key redacted: {redacted}"
2064 );
2065 assert!(!redacted.contains("pw@"), "userinfo redacted: {redacted}");
2066 }
2067
2068 #[test]
2069 fn test_substitute_context_substitutes_placeholders() {
2070 let mut ctx = HashMap::new();
2071 ctx.insert("org_id".to_string(), json!("acme"));
2072 ctx.insert("repo".to_string(), json!("myrepo"));
2073 let result =
2074 faucet_core::util::substitute_context("/orgs/{org_id}/repos/{repo}/issues", &ctx);
2075 assert_eq!(result, "/orgs/acme/repos/myrepo/issues");
2076 }
2077
2078 #[test]
2079 fn test_substitute_context_no_placeholders() {
2080 let ctx = HashMap::new();
2081 let result = faucet_core::util::substitute_context("/api/users", &ctx);
2082 assert_eq!(result, "/api/users");
2083 }
2084
2085 #[test]
2086 fn test_substitute_context_numeric_value() {
2087 let mut ctx = HashMap::new();
2088 ctx.insert("id".to_string(), json!(42));
2089 let result = faucet_core::util::substitute_context("/items/{id}", &ctx);
2090 assert_eq!(result, "/items/42");
2091 }
2092
2093 #[test]
2094 fn test_parse_retry_after_valid() {
2095 let mut headers = HeaderMap::new();
2096 headers.insert(
2097 reqwest::header::RETRY_AFTER,
2098 reqwest::header::HeaderValue::from_static("30"),
2099 );
2100 assert_eq!(parse_retry_after(&headers), Duration::from_secs(30));
2101 }
2102
2103 #[test]
2104 fn test_parse_retry_after_missing_defaults_to_60() {
2105 assert_eq!(
2106 parse_retry_after(&HeaderMap::new()),
2107 Duration::from_secs(60)
2108 );
2109 }
2110
2111 #[test]
2112 fn test_parse_retry_after_non_numeric_defaults_to_60() {
2113 let mut headers = HeaderMap::new();
2114 headers.insert(
2115 reqwest::header::RETRY_AFTER,
2116 reqwest::header::HeaderValue::from_static("not-a-number"),
2117 );
2118 assert_eq!(parse_retry_after(&headers), Duration::from_secs(60));
2119 }
2120
2121 #[test]
2122 fn test_parse_retry_after_http_date() {
2123 let future = std::time::SystemTime::now() + Duration::from_secs(7200);
2125 let date = httpdate::fmt_http_date(future);
2126 let mut headers = HeaderMap::new();
2127 headers.insert(
2128 reqwest::header::RETRY_AFTER,
2129 reqwest::header::HeaderValue::from_str(&date).unwrap(),
2130 );
2131 let d = parse_retry_after(&headers);
2132 assert!(
2134 d > Duration::from_secs(3600),
2135 "expected ~2h from HTTP-date, got {d:?}"
2136 );
2137 assert!(
2138 d <= Duration::from_secs(7200),
2139 "should not exceed the target instant, got {d:?}"
2140 );
2141 }
2142
2143 #[test]
2144 fn test_parse_retry_after_past_http_date_is_zero() {
2145 let past = std::time::SystemTime::now() - Duration::from_secs(3600);
2147 let date = httpdate::fmt_http_date(past);
2148 let mut headers = HeaderMap::new();
2149 headers.insert(
2150 reqwest::header::RETRY_AFTER,
2151 reqwest::header::HeaderValue::from_str(&date).unwrap(),
2152 );
2153 assert_eq!(parse_retry_after(&headers), Duration::ZERO);
2154 }
2155
2156 #[test]
2157 fn test_new_rejects_invalid_expiry_ratio_zero() {
2158 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2159 token_url: "https://auth.example.com/token".into(),
2160 client_id: "id".into(),
2161 client_secret: "secret".into(),
2162 scopes: vec![],
2163 expiry_ratio: 0.0,
2164 });
2165 let result = RestStream::new(config);
2166 assert!(result.is_err());
2167 assert!(matches!(result, Err(FaucetError::Auth(_))));
2168 }
2169
2170 #[test]
2171 fn test_new_rejects_invalid_expiry_ratio_negative() {
2172 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2173 token_url: "https://auth.example.com/token".into(),
2174 client_id: "id".into(),
2175 client_secret: "secret".into(),
2176 scopes: vec![],
2177 expiry_ratio: -0.5,
2178 });
2179 assert!(RestStream::new(config).is_err());
2180 }
2181
2182 #[test]
2183 fn test_new_rejects_invalid_expiry_ratio_above_one() {
2184 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2185 token_url: "https://auth.example.com/token".into(),
2186 client_id: "id".into(),
2187 client_secret: "secret".into(),
2188 scopes: vec![],
2189 expiry_ratio: 1.5,
2190 });
2191 assert!(RestStream::new(config).is_err());
2192 }
2193
2194 #[test]
2195 fn test_new_accepts_valid_expiry_ratio() {
2196 let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2197 token_url: "https://auth.example.com/token".into(),
2198 client_id: "id".into(),
2199 client_secret: "secret".into(),
2200 scopes: vec![],
2201 expiry_ratio: 1.0,
2202 });
2203 assert!(RestStream::new(config).is_ok());
2204 }
2205
2206 #[test]
2207 fn test_new_with_no_auth_succeeds() {
2208 let config = RestStreamConfig::new("https://example.com", "/data");
2209 assert!(RestStream::new(config).is_ok());
2210 }
2211
2212 #[test]
2213 fn test_new_with_timeout() {
2214 let config =
2215 RestStreamConfig::new("https://example.com", "/data").timeout(Duration::from_secs(10));
2216 assert!(RestStream::new(config).is_ok());
2217 }
2218
2219 #[test]
2220 fn test_substitute_context_missing_placeholder_unchanged() {
2221 let mut ctx = HashMap::new();
2222 ctx.insert("org".to_string(), json!("acme"));
2223 let result = faucet_core::util::substitute_context("/items/{missing}", &ctx);
2224 assert_eq!(result, "/items/{missing}");
2225 }
2226
2227 #[test]
2228 fn test_substitute_context_boolean_value() {
2229 let mut ctx = HashMap::new();
2230 ctx.insert("flag".to_string(), json!(true));
2231 let result = faucet_core::util::substitute_context("/items/{flag}", &ctx);
2232 assert_eq!(result, "/items/true");
2233 }
2234
2235 #[test]
2236 fn rest_source_connector_name_is_rest() {
2237 use faucet_core::Source;
2238 let source = RestStream::new(RestStreamConfig::new("https://example.com", "/data"))
2239 .expect("minimal RestStream construction");
2240 assert_eq!(source.connector_name(), "rest");
2241 }
2242
2243 #[test]
2244 fn dataset_uri_combines_base_and_path() {
2245 use faucet_core::Source;
2246 let source = RestStream::new(RestStreamConfig::new(
2247 "https://api.example.com",
2248 "/v1/users",
2249 ))
2250 .unwrap();
2251 assert_eq!(source.dataset_uri(), "https://api.example.com/v1/users");
2252 }
2253
2254 #[test]
2255 fn dataset_uri_redacts_credentials() {
2256 use faucet_core::Source;
2257 let source = RestStream::new(RestStreamConfig::new(
2258 "https://user:secret@api.example.com",
2259 "/v1/data",
2260 ))
2261 .unwrap();
2262 assert_eq!(source.dataset_uri(), "https://api.example.com/v1/data");
2263 }
2264}
2265
2266#[cfg(all(test, feature = "mtls"))]
2269mod mtls_tests {
2270 use super::*;
2271 use crate::config::TlsClientConfig;
2272
2273 const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
2274 const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
2275
2276 fn pem() -> TlsClientConfig {
2277 TlsClientConfig {
2278 client_cert: Some(CERT.to_string()),
2279 client_key: Some(KEY.to_string()),
2280 ..Default::default()
2281 }
2282 }
2283
2284 #[test]
2285 fn pem_identity_builds() {
2286 let cfg = RestStreamConfig::new("https://x.test", "/y").tls(pem());
2287 assert!(RestStream::new(cfg).is_ok());
2288 }
2289
2290 #[test]
2291 fn min_version_branches_are_exercised() {
2292 let mut tls = pem();
2294 tls.min_version = Some("1.2".into());
2295 assert!(RestStream::new(RestStreamConfig::new("https://x.test", "/y").tls(tls)).is_ok());
2296 let mut tls = pem();
2300 tls.min_version = Some("1.3".into());
2301 let _ = RestStream::new(RestStreamConfig::new("https://x.test", "/y").tls(tls));
2302 }
2303
2304 #[test]
2305 fn pkcs12_identity_builds() {
2306 let p12 = concat!(
2307 env!("CARGO_MANIFEST_DIR"),
2308 "/tests/fixtures/mtls/identity.p12"
2309 );
2310 let tls = TlsClientConfig {
2311 client_identity_pkcs12: Some(p12.to_string()),
2312 pkcs12_password: Some("changeit".into()),
2313 ..Default::default()
2314 };
2315 let cfg = RestStreamConfig::new("https://x.test", "/y").tls(tls);
2316 assert!(RestStream::new(cfg).is_ok());
2317 }
2318
2319 #[test]
2320 fn invalid_pem_errors_without_leaking_key() {
2321 let tls = TlsClientConfig {
2322 client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
2323 client_key: Some("SUPERSECRETKEY".into()),
2324 ..Default::default()
2325 };
2326 let cfg = RestStreamConfig::new("https://x.test", "/y").tls(tls);
2327 let err = RestStream::new(cfg)
2328 .map(|_| ())
2329 .expect_err("bad PEM must error");
2330 assert!(!err.to_string().contains("SUPERSECRETKEY"));
2331 }
2332
2333 #[test]
2334 fn missing_pkcs12_file_errors() {
2335 let tls = TlsClientConfig {
2336 client_identity_pkcs12: Some("/no/such.p12".into()),
2337 pkcs12_password: Some("x".into()),
2338 ..Default::default()
2339 };
2340 let cfg = RestStreamConfig::new("https://x.test", "/y").tls(tls);
2341 assert!(RestStream::new(cfg).is_err());
2342 }
2343}