1#![allow(unexpected_cfgs)]
16
17pub mod config;
219pub mod descriptor;
220pub use config::HttpSourceConfig;
221
222mod adaptive_batcher;
223mod models;
224mod time;
225
226pub mod auth;
228pub mod content_parser;
229pub mod route_matcher;
230pub mod template_engine;
231
232pub use models::{convert_http_to_source_change, HttpElement, HttpSourceChange};
234
235use anyhow::Result;
236use async_trait::async_trait;
237use axum::{
238 body::Bytes,
239 extract::{Path, State},
240 http::{header, Method, StatusCode},
241 response::IntoResponse,
242 routing::{delete, get, post, put},
243 Json, Router,
244};
245use log::{debug, error, info, trace, warn};
246use serde::{Deserialize, Serialize};
247use std::collections::HashMap;
248use std::sync::Arc;
249use std::time::Duration;
250use tokio::sync::mpsc;
251use tokio::time::timeout;
252use tower_http::cors::{Any, CorsLayer};
253
254use drasi_lib::channels::{ComponentType, *};
255use drasi_lib::schema::{NodeSchema, PropertySchema, RelationSchema, SourceSchema};
256use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
257use drasi_lib::wal::{WalError, WalProvider};
258use drasi_lib::Source;
259use tracing::Instrument;
260
261use crate::adaptive_batcher::{AdaptiveBatchConfig, AdaptiveBatcher};
262use crate::auth::{verify_auth, AuthResult};
263use crate::config::{CorsConfig, ErrorBehavior, WebhookConfig};
264use crate::content_parser::{parse_content, ContentType};
265use crate::route_matcher::{convert_method, find_matching_mappings, headers_to_map, RouteMatcher};
266use crate::template_engine::{TemplateContext, TemplateEngine};
267
268#[derive(Debug, Serialize, Deserialize)]
270pub struct EventResponse {
271 pub success: bool,
272 pub message: String,
273 #[serde(skip_serializing_if = "Option::is_none")]
274 pub error: Option<String>,
275}
276
277pub struct HttpSource {
289 base: SourceBase,
291 config: HttpSourceConfig,
293 adaptive_config: AdaptiveBatchConfig,
295 wal: tokio::sync::RwLock<Option<Arc<dyn WalProvider>>>,
297 prune_task: tokio::sync::RwLock<Option<tokio::task::JoinHandle<()>>>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct BatchEventRequest {
304 pub events: Vec<HttpSourceChange>,
305}
306
307#[derive(Clone)]
311struct HttpAppState {
312 source_id: String,
314 batch_tx: mpsc::Sender<SourceChangeEvent>,
316 webhook_config: Option<Arc<WebhookState>>,
318 wal: Option<Arc<dyn WalProvider>>,
320}
321
322struct WebhookState {
324 config: WebhookConfig,
326 route_matcher: RouteMatcher,
328 template_engine: TemplateEngine,
330}
331
332fn extract_property_schemas(properties: Option<&serde_json::Value>) -> Vec<PropertySchema> {
333 match properties {
334 Some(serde_json::Value::Object(map)) => map
335 .keys()
336 .map(|key| PropertySchema::new(key.clone()))
337 .collect(),
338 _ => Vec::new(),
339 }
340}
341
342fn derive_schema_from_webhooks(webhooks: &WebhookConfig) -> Option<SourceSchema> {
343 let mut node_map: HashMap<String, Vec<PropertySchema>> = HashMap::new();
344 let mut relation_map: HashMap<String, Vec<PropertySchema>> = HashMap::new();
345
346 for route in &webhooks.routes {
347 for mapping in &route.mappings {
348 let Some(label) = mapping.template.labels.first().cloned() else {
349 continue;
350 };
351
352 let properties = extract_property_schemas(mapping.template.properties.as_ref());
353
354 match mapping.element_type {
355 crate::config::ElementType::Node => {
356 let entry = node_map.entry(label).or_default();
357 for prop in properties {
358 if !entry.iter().any(|p| p.name == prop.name) {
359 entry.push(prop);
360 }
361 }
362 }
363 crate::config::ElementType::Relation => {
364 let entry = relation_map.entry(label).or_default();
365 for prop in properties {
366 if !entry.iter().any(|p| p.name == prop.name) {
367 entry.push(prop);
368 }
369 }
370 }
371 }
372 }
373 }
374
375 let nodes: Vec<_> = node_map
376 .into_iter()
377 .map(|(label, properties)| NodeSchema { label, properties })
378 .collect();
379 let relations: Vec<_> = relation_map
380 .into_iter()
381 .map(|(label, properties)| RelationSchema {
382 label,
383 from: None,
384 to: None,
385 properties,
386 })
387 .collect();
388
389 if nodes.is_empty() && relations.is_empty() {
390 None
391 } else {
392 Some(SourceSchema { nodes, relations })
393 }
394}
395
396impl HttpSource {
397 pub fn new(id: impl Into<String>, config: HttpSourceConfig) -> Result<Self> {
428 let id = id.into();
429 let params = SourceBaseParams::new(id);
430
431 let mut adaptive_config = AdaptiveBatchConfig::default();
433
434 if let Some(max_batch) = config.adaptive_max_batch_size {
436 adaptive_config.max_batch_size = max_batch;
437 }
438 if let Some(min_batch) = config.adaptive_min_batch_size {
439 adaptive_config.min_batch_size = min_batch;
440 }
441 if let Some(max_wait_ms) = config.adaptive_max_wait_ms {
442 adaptive_config.max_wait_time = Duration::from_millis(max_wait_ms);
443 }
444 if let Some(min_wait_ms) = config.adaptive_min_wait_ms {
445 adaptive_config.min_wait_time = Duration::from_millis(min_wait_ms);
446 }
447 if let Some(window_secs) = config.adaptive_window_secs {
448 adaptive_config.throughput_window = Duration::from_secs(window_secs);
449 }
450 if let Some(enabled) = config.adaptive_enabled {
451 adaptive_config.adaptive_enabled = enabled;
452 }
453
454 Ok(Self {
455 base: SourceBase::new(params)?,
456 config,
457 adaptive_config,
458 wal: tokio::sync::RwLock::new(None),
459 prune_task: tokio::sync::RwLock::new(None),
460 })
461 }
462
463 pub fn with_dispatch(
483 id: impl Into<String>,
484 config: HttpSourceConfig,
485 dispatch_mode: Option<DispatchMode>,
486 dispatch_buffer_capacity: Option<usize>,
487 ) -> Result<Self> {
488 let id = id.into();
489 let mut params = SourceBaseParams::new(id);
490 if let Some(mode) = dispatch_mode {
491 params = params.with_dispatch_mode(mode);
492 }
493 if let Some(capacity) = dispatch_buffer_capacity {
494 params = params.with_dispatch_buffer_capacity(capacity);
495 }
496
497 let mut adaptive_config = AdaptiveBatchConfig::default();
498
499 if let Some(max_batch) = config.adaptive_max_batch_size {
500 adaptive_config.max_batch_size = max_batch;
501 }
502 if let Some(min_batch) = config.adaptive_min_batch_size {
503 adaptive_config.min_batch_size = min_batch;
504 }
505 if let Some(max_wait_ms) = config.adaptive_max_wait_ms {
506 adaptive_config.max_wait_time = Duration::from_millis(max_wait_ms);
507 }
508 if let Some(min_wait_ms) = config.adaptive_min_wait_ms {
509 adaptive_config.min_wait_time = Duration::from_millis(min_wait_ms);
510 }
511 if let Some(window_secs) = config.adaptive_window_secs {
512 adaptive_config.throughput_window = Duration::from_secs(window_secs);
513 }
514 if let Some(enabled) = config.adaptive_enabled {
515 adaptive_config.adaptive_enabled = enabled;
516 }
517
518 Ok(Self {
519 base: SourceBase::new(params)?,
520 config,
521 adaptive_config,
522 wal: tokio::sync::RwLock::new(None),
523 prune_task: tokio::sync::RwLock::new(None),
524 })
525 }
526
527 async fn handle_single_event(
532 Path(source_id): Path<String>,
533 State(state): State<HttpAppState>,
534 Json(event): Json<HttpSourceChange>,
535 ) -> Result<impl IntoResponse, (StatusCode, Json<EventResponse>)> {
536 debug!("[{source_id}] HTTP endpoint received single event: {event:?}");
537 Self::process_events(&source_id, &state, vec![event]).await
538 }
539
540 async fn handle_batch_events(
545 Path(source_id): Path<String>,
546 State(state): State<HttpAppState>,
547 Json(batch): Json<BatchEventRequest>,
548 ) -> Result<impl IntoResponse, (StatusCode, Json<EventResponse>)> {
549 debug!(
550 "[{}] HTTP endpoint received batch of {} events",
551 source_id,
552 batch.events.len()
553 );
554 Self::process_events(&source_id, &state, batch.events).await
555 }
556
557 async fn process_events(
569 source_id: &str,
570 state: &HttpAppState,
571 events: Vec<HttpSourceChange>,
572 ) -> Result<impl IntoResponse, (StatusCode, Json<EventResponse>)> {
573 trace!("[{}] Processing {} events", source_id, events.len());
574
575 if source_id != state.source_id {
576 error!(
577 "[{}] Source name mismatch. Expected '{}', got '{}'",
578 state.source_id, state.source_id, source_id
579 );
580 return Err((
581 StatusCode::BAD_REQUEST,
582 Json(EventResponse {
583 success: false,
584 message: "Source name mismatch".to_string(),
585 error: Some(format!(
586 "Expected source '{}', got '{}'",
587 state.source_id, source_id
588 )),
589 }),
590 ));
591 }
592
593 let mut success_count = 0;
594 let mut error_count = 0;
595 let mut last_error = None;
596
597 for (idx, event) in events.iter().enumerate() {
598 match convert_http_to_source_change(event, source_id) {
599 Ok(source_change) => {
600 let sequence = if let Some(ref wal) = state.wal {
602 match wal.append(&state.source_id, &source_change).await {
603 Ok(seq) => {
604 trace!(
605 "[{}] WAL append succeeded: sequence={}",
606 state.source_id,
607 seq
608 );
609 Some(seq)
610 }
611 Err(WalError::CapacityExhausted(_)) => {
612 warn!(
613 "[{}] WAL capacity exhausted, rejecting event",
614 state.source_id
615 );
616 return Err((
617 StatusCode::SERVICE_UNAVAILABLE,
618 Json(EventResponse {
619 success: false,
620 message: "WAL capacity exhausted".to_string(),
621 error: Some("Source durability buffer is full".to_string()),
622 }),
623 ));
624 }
625 Err(e) => {
626 error!(
627 "[{}] WAL append failed for event {}: {}",
628 state.source_id,
629 idx + 1,
630 e
631 );
632 return Err((
634 StatusCode::SERVICE_UNAVAILABLE,
635 Json(EventResponse {
636 success: false,
637 message: format!(
638 "WAL durability failure at event {}: {e}",
639 idx + 1
640 ),
641 error: Some(format!("WAL error: {e}")),
642 }),
643 ));
644 }
645 }
646 } else {
647 None
648 };
649
650 let change_event = SourceChangeEvent {
651 source_id: source_id.to_string(),
652 change: source_change,
653 timestamp: chrono::Utc::now(),
654 sequence,
655 };
656
657 if let Err(e) = state.batch_tx.send(change_event).await {
658 error!(
659 "[{}] Failed to send event {} to batch channel: {}",
660 state.source_id,
661 idx + 1,
662 e
663 );
664 error_count += 1;
665 last_error = Some("Internal channel error".to_string());
666 } else {
667 success_count += 1;
668 }
669 }
670 Err(e) => {
671 error!(
672 "[{}] Failed to convert event {}: {}",
673 state.source_id,
674 idx + 1,
675 e
676 );
677 error_count += 1;
678 last_error = Some(e.to_string());
679 }
680 }
681 }
682
683 debug!(
684 "[{source_id}] Event processing complete: {success_count} succeeded, {error_count} failed"
685 );
686
687 if error_count > 0 && success_count == 0 {
688 Err((
689 StatusCode::BAD_REQUEST,
690 Json(EventResponse {
691 success: false,
692 message: format!("All {error_count} events failed"),
693 error: last_error,
694 }),
695 ))
696 } else if error_count > 0 {
697 Ok(Json(EventResponse {
698 success: true,
699 message: format!(
700 "Processed {success_count} events successfully, {error_count} failed"
701 ),
702 error: last_error,
703 }))
704 } else {
705 Ok(Json(EventResponse {
706 success: true,
707 message: format!("All {success_count} events processed successfully"),
708 error: None,
709 }))
710 }
711 }
712
713 async fn health_check() -> impl IntoResponse {
714 Json(serde_json::json!({
715 "status": "healthy",
716 "service": "http-source",
717 "features": ["adaptive-batching", "batch-endpoint", "webhooks"]
718 }))
719 }
720
721 async fn handle_webhook(
726 method: axum::http::Method,
727 uri: axum::http::Uri,
728 headers: axum::http::HeaderMap,
729 State(state): State<HttpAppState>,
730 body: Bytes,
731 ) -> impl IntoResponse {
732 let path = uri.path();
733 let source_id = &state.source_id;
734
735 debug!("[{source_id}] Webhook received: {method} {path}");
736
737 let webhook_state = match &state.webhook_config {
739 Some(ws) => ws,
740 None => {
741 error!("[{source_id}] Webhook handler called but no webhook config present");
742 return (
743 StatusCode::INTERNAL_SERVER_ERROR,
744 Json(EventResponse {
745 success: false,
746 message: "Internal configuration error".to_string(),
747 error: Some("Webhook mode not properly configured".to_string()),
748 }),
749 );
750 }
751 };
752
753 let http_method = match convert_method(&method) {
755 Some(m) => m,
756 None => {
757 return handle_error(
758 &webhook_state.config.error_behavior,
759 source_id,
760 StatusCode::METHOD_NOT_ALLOWED,
761 "Method not supported",
762 None,
763 );
764 }
765 };
766
767 let route_match = match webhook_state.route_matcher.match_route(
769 path,
770 &http_method,
771 &webhook_state.config.routes,
772 ) {
773 Some(rm) => rm,
774 None => {
775 debug!("[{source_id}] No matching route for {method} {path}");
776 return handle_error(
777 &webhook_state.config.error_behavior,
778 source_id,
779 StatusCode::NOT_FOUND,
780 "No matching route",
781 None,
782 );
783 }
784 };
785
786 let route = route_match.route;
787 let error_behavior = route
788 .error_behavior
789 .as_ref()
790 .unwrap_or(&webhook_state.config.error_behavior);
791
792 let auth_result = verify_auth(route.auth.as_ref(), &headers, &body);
794 if let AuthResult::Failed(reason) = auth_result {
795 warn!("[{source_id}] Authentication failed for {path}: {reason}");
796 return handle_error(
797 error_behavior,
798 source_id,
799 StatusCode::UNAUTHORIZED,
800 "Authentication failed",
801 Some(&reason),
802 );
803 }
804
805 let content_type = ContentType::from_header(
807 headers
808 .get(axum::http::header::CONTENT_TYPE)
809 .and_then(|v| v.to_str().ok()),
810 );
811
812 let payload = match parse_content(&body, content_type) {
813 Ok(p) => p,
814 Err(e) => {
815 warn!("[{source_id}] Failed to parse payload: {e}");
816 return handle_error(
817 error_behavior,
818 source_id,
819 StatusCode::BAD_REQUEST,
820 "Failed to parse payload",
821 Some(&e.to_string()),
822 );
823 }
824 };
825
826 let headers_map = headers_to_map(&headers);
828 let query_map = parse_query_string(uri.query());
829
830 let context = TemplateContext {
831 payload: payload.clone(),
832 route: route_match.path_params,
833 query: query_map,
834 headers: headers_map.clone(),
835 method: method.to_string(),
836 path: path.to_string(),
837 source_id: source_id.clone(),
838 };
839
840 let matching_mappings = find_matching_mappings(&route.mappings, &headers_map, &payload);
842
843 if matching_mappings.is_empty() {
844 debug!("[{source_id}] No matching mappings for request");
845 return handle_error(
846 error_behavior,
847 source_id,
848 StatusCode::BAD_REQUEST,
849 "No matching mapping for request",
850 None,
851 );
852 }
853
854 let mut success_count = 0;
856 let mut error_count = 0;
857 let mut last_error = None;
858
859 for mapping in matching_mappings {
860 match webhook_state
861 .template_engine
862 .process_mapping(mapping, &context, source_id)
863 {
864 Ok(source_change) => {
865 let sequence = if let Some(ref wal) = state.wal {
867 match wal.append(&state.source_id, &source_change).await {
868 Ok(seq) => Some(seq),
869 Err(WalError::CapacityExhausted(_)) => {
870 return handle_error(
871 error_behavior,
872 source_id,
873 StatusCode::SERVICE_UNAVAILABLE,
874 "WAL capacity exhausted",
875 None,
876 );
877 }
878 Err(e) => {
879 error!("[{source_id}] WAL append failed: {e}");
880 return handle_error(
882 error_behavior,
883 source_id,
884 StatusCode::SERVICE_UNAVAILABLE,
885 &format!("WAL durability failure: {e}"),
886 None,
887 );
888 }
889 }
890 } else {
891 None
892 };
893
894 let event = SourceChangeEvent {
895 source_id: source_id.clone(),
896 change: source_change,
897 timestamp: chrono::Utc::now(),
898 sequence,
899 };
900
901 if let Err(e) = state.batch_tx.send(event).await {
902 error!("[{source_id}] Failed to send event to batcher: {e}");
903 error_count += 1;
904 last_error = Some(format!("Failed to queue event: {e}"));
905 } else {
906 success_count += 1;
907 }
908 }
909 Err(e) => {
910 warn!("[{source_id}] Failed to process mapping: {e}");
911 error_count += 1;
912 last_error = Some(e.to_string());
913 }
914 }
915 }
916
917 debug!("[{source_id}] Webhook processing complete: {success_count} succeeded, {error_count} failed");
918
919 if error_count > 0 && success_count == 0 {
920 handle_error(
921 error_behavior,
922 source_id,
923 StatusCode::BAD_REQUEST,
924 &format!("All {error_count} mappings failed"),
925 last_error.as_deref(),
926 )
927 } else if error_count > 0 {
928 (
929 StatusCode::OK,
930 Json(EventResponse {
931 success: true,
932 message: format!("Processed {success_count} events, {error_count} failed"),
933 error: last_error,
934 }),
935 )
936 } else {
937 (
938 StatusCode::OK,
939 Json(EventResponse {
940 success: true,
941 message: format!("Processed {success_count} events successfully"),
942 error: None,
943 }),
944 )
945 }
946 }
947
948 async fn run_adaptive_batcher(
949 batch_rx: mpsc::Receiver<SourceChangeEvent>,
950 dispatchers: Arc<
951 tokio::sync::RwLock<
952 Vec<
953 Box<
954 dyn drasi_lib::channels::ChangeDispatcher<SourceEventWrapper> + Send + Sync,
955 >,
956 >,
957 >,
958 >,
959 adaptive_config: AdaptiveBatchConfig,
960 source_id: String,
961 ) {
962 let mut batcher = AdaptiveBatcher::new(batch_rx, adaptive_config.clone());
963 let mut total_events = 0u64;
964 let mut total_batches = 0u64;
965
966 info!("[{source_id}] Adaptive HTTP batcher started with config: {adaptive_config:?}");
967
968 while let Some(batch) = batcher.next_batch().await {
969 if batch.is_empty() {
970 debug!("[{source_id}] Batcher received empty batch, skipping");
971 continue;
972 }
973
974 let batch_size = batch.len();
975 total_events += batch_size as u64;
976 total_batches += 1;
977
978 debug!(
979 "[{source_id}] Batcher forwarding batch #{total_batches} with {batch_size} events to dispatchers"
980 );
981
982 let mut sent_count = 0;
983 let mut failed_count = 0;
984 for (idx, event) in batch.into_iter().enumerate() {
985 debug!(
986 "[{}] Batch #{}, dispatching event {}/{}",
987 source_id,
988 total_batches,
989 idx + 1,
990 batch_size
991 );
992
993 let mut profiling = drasi_lib::profiling::ProfilingMetadata::new();
994 profiling.source_send_ns = Some(drasi_lib::profiling::timestamp_ns());
995
996 let mut wrapper = SourceEventWrapper::with_profiling(
997 event.source_id.clone(),
998 SourceEvent::Change(event.change),
999 event.timestamp,
1000 profiling,
1001 );
1002
1003 if let Some(seq) = event.sequence {
1005 wrapper.sequence = Some(seq);
1006 wrapper.source_position = Some(bytes::Bytes::from(seq.to_be_bytes().to_vec()));
1007 }
1008
1009 if let Err(e) =
1010 SourceBase::dispatch_from_task(dispatchers.clone(), wrapper.clone(), &source_id)
1011 .await
1012 {
1013 error!(
1014 "[{}] Batch #{}, failed to dispatch event {}/{} (no subscribers): {}",
1015 source_id,
1016 total_batches,
1017 idx + 1,
1018 batch_size,
1019 e
1020 );
1021 failed_count += 1;
1022 } else {
1023 debug!(
1024 "[{}] Batch #{}, successfully dispatched event {}/{}",
1025 source_id,
1026 total_batches,
1027 idx + 1,
1028 batch_size
1029 );
1030 sent_count += 1;
1031 }
1032 }
1033
1034 debug!(
1035 "[{source_id}] Batch #{total_batches} complete: {sent_count} dispatched, {failed_count} failed"
1036 );
1037
1038 if total_batches.is_multiple_of(100) {
1039 info!(
1040 "[{}] Adaptive HTTP metrics - Batches: {}, Events: {}, Avg batch size: {:.1}",
1041 source_id,
1042 total_batches,
1043 total_events,
1044 total_events as f64 / total_batches as f64
1045 );
1046 }
1047 }
1048
1049 info!(
1050 "[{source_id}] Adaptive HTTP batcher stopped - Total batches: {total_batches}, Total events: {total_events}"
1051 );
1052 }
1053}
1054
1055#[async_trait]
1056impl Source for HttpSource {
1057 fn id(&self) -> &str {
1058 &self.base.id
1059 }
1060
1061 fn type_name(&self) -> &str {
1062 "http"
1063 }
1064
1065 fn properties(&self) -> HashMap<String, serde_json::Value> {
1066 use crate::descriptor::HttpSourceConfigDto;
1067
1068 self.base
1069 .properties_or_serialize(&HttpSourceConfigDto::from(&self.config))
1070 }
1071
1072 fn auto_start(&self) -> bool {
1073 self.base.get_auto_start()
1074 }
1075
1076 fn describe_schema(&self) -> Option<SourceSchema> {
1077 self.config
1078 .webhooks
1079 .as_ref()
1080 .and_then(derive_schema_from_webhooks)
1081 }
1082
1083 async fn start(&self) -> Result<()> {
1084 info!("[{}] Starting adaptive HTTP source", self.base.id);
1085
1086 self.base
1087 .set_status(
1088 ComponentStatus::Starting,
1089 Some("Starting adaptive HTTP source".to_string()),
1090 )
1091 .await;
1092
1093 let wal_ref: Option<Arc<dyn WalProvider>> =
1095 if self.config.durability.as_ref().is_some_and(|d| d.enabled) {
1096 let ctx = self
1097 .base
1098 .context()
1099 .await
1100 .ok_or_else(|| anyhow::anyhow!("Context not initialized"))?;
1101 let wal = ctx.wal_provider.clone().ok_or_else(|| {
1102 anyhow::anyhow!("Durability enabled but no WAL provider configured on DrasiLib")
1103 })?;
1104 let wal_config = self
1105 .config
1106 .durability
1107 .as_ref()
1108 .expect("durability checked above")
1109 .to_wal_config();
1110 wal.register(&self.base.id, wal_config.clone())
1111 .await
1112 .map_err(|e| {
1113 anyhow::anyhow!(
1114 "Failed to register WAL for source '{}': {}",
1115 self.base.id,
1116 e
1117 )
1118 })?;
1119
1120 info!(
1121 "[{}] WAL registered: max_events={}, policy={:?}",
1122 self.base.id, wal_config.max_events, wal_config.capacity_policy
1123 );
1124
1125 let head = wal.head_sequence(&self.base.id).await.unwrap_or(0);
1127 if head > 0 {
1128 self.base.set_next_sequence(head);
1129 info!(
1130 "[{}] WAL resumed from persisted state: head={}, next_sequence={}",
1131 self.base.id,
1132 head,
1133 head + 1
1134 );
1135 }
1136
1137 *self.wal.write().await = Some(wal.clone());
1138 Some(wal)
1139 } else {
1140 None
1141 };
1142
1143 let host = self.config.host.clone();
1144 let port = self.config.port;
1145
1146 let batch_channel_capacity = self.adaptive_config.recommended_channel_capacity();
1148 let (batch_tx, batch_rx) = mpsc::channel(batch_channel_capacity);
1149 info!(
1150 "[{}] HttpSource using batch channel capacity: {} (max_batch_size: {} x 5)",
1151 self.base.id, batch_channel_capacity, self.adaptive_config.max_batch_size
1152 );
1153
1154 let adaptive_config = self.adaptive_config.clone();
1156 let source_id = self.base.id.clone();
1157 let dispatchers = self.base.dispatchers.clone();
1158
1159 let instance_id = self
1161 .base
1162 .context()
1163 .await
1164 .map(|c| c.instance_id)
1165 .unwrap_or_default();
1166
1167 info!("[{source_id}] Starting adaptive batcher task");
1168 let source_id_for_span = source_id.clone();
1169 let span = tracing::info_span!(
1170 "http_adaptive_batcher",
1171 instance_id = %instance_id,
1172 component_id = %source_id_for_span,
1173 component_type = "source"
1174 );
1175 tokio::spawn(
1176 async move {
1177 Self::run_adaptive_batcher(
1178 batch_rx,
1179 dispatchers,
1180 adaptive_config,
1181 source_id.clone(),
1182 )
1183 .await
1184 }
1185 .instrument(span),
1186 );
1187
1188 let webhook_state = if let Some(ref webhook_config) = self.config.webhooks {
1190 info!(
1191 "[{}] Webhook mode enabled with {} routes",
1192 self.base.id,
1193 webhook_config.routes.len()
1194 );
1195 Some(Arc::new(WebhookState {
1196 config: webhook_config.clone(),
1197 route_matcher: RouteMatcher::new(&webhook_config.routes),
1198 template_engine: TemplateEngine::new(),
1199 }))
1200 } else {
1201 info!("[{}] Standard mode enabled", self.base.id);
1202 None
1203 };
1204
1205 let state = HttpAppState {
1206 source_id: self.base.id.clone(),
1207 batch_tx,
1208 webhook_config: webhook_state,
1209 wal: wal_ref.clone(),
1210 };
1211
1212 let app = if self.config.is_webhook_mode() {
1214 let router = Router::new()
1216 .route("/health", get(Self::health_check))
1217 .fallback(Self::handle_webhook)
1218 .with_state(state);
1219
1220 if let Some(ref webhooks) = self.config.webhooks {
1222 if let Some(ref cors_config) = webhooks.cors {
1223 if cors_config.enabled {
1224 info!("[{}] CORS enabled for webhook endpoints", self.base.id);
1225 router.layer(build_cors_layer(cors_config))
1226 } else {
1227 router
1228 }
1229 } else {
1230 router
1231 }
1232 } else {
1233 router
1234 }
1235 } else {
1236 Router::new()
1238 .route("/health", get(Self::health_check))
1239 .route(
1240 "/sources/:source_id/events",
1241 post(Self::handle_single_event),
1242 )
1243 .route(
1244 "/sources/:source_id/events/batch",
1245 post(Self::handle_batch_events),
1246 )
1247 .with_state(state)
1248 };
1249
1250 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
1252
1253 let host_clone = host.clone();
1254
1255 let (error_tx, error_rx) = tokio::sync::oneshot::channel();
1257 let source_id = self.base.id.clone();
1258 let source_id_for_span = source_id.clone();
1259 let span = tracing::info_span!(
1260 "http_source_server",
1261 instance_id = %instance_id,
1262 component_id = %source_id_for_span,
1263 component_type = "source"
1264 );
1265 let server_handle = tokio::spawn(
1266 async move {
1267 let addr = format!("{host}:{port}");
1268 info!("[{source_id}] Adaptive HTTP source attempting to bind to {addr}");
1269
1270 let listener = match tokio::net::TcpListener::bind(&addr).await {
1271 Ok(listener) => {
1272 info!("[{source_id}] Adaptive HTTP source successfully listening on {addr}");
1273 listener
1274 }
1275 Err(e) => {
1276 error!("[{source_id}] Failed to bind HTTP server to {addr}: {e}");
1277 let _ = error_tx.send(format!(
1278 "Failed to bind HTTP server to {addr}: {e}. Common causes: port already in use, insufficient permissions"
1279 ));
1280 return;
1281 }
1282 };
1283
1284 if let Err(e) = axum::serve(listener, app)
1285 .with_graceful_shutdown(async move {
1286 let _ = shutdown_rx.await;
1287 })
1288 .await
1289 {
1290 error!("[{source_id}] HTTP server error: {e}");
1291 }
1292 }
1293 .instrument(span),
1294 );
1295
1296 *self.base.task_handle.write().await = Some(server_handle);
1297 *self.base.shutdown_tx.write().await = Some(shutdown_tx);
1298
1299 match timeout(Duration::from_millis(500), error_rx).await {
1301 Ok(Ok(error_msg)) => {
1302 self.base.set_status(ComponentStatus::Error, None).await;
1303 return Err(anyhow::anyhow!("{error_msg}"));
1304 }
1305 _ => {
1306 self.base
1307 .set_status(
1308 ComponentStatus::Running,
1309 Some(format!(
1310 "Adaptive HTTP source running on {host_clone}:{port} with batch support"
1311 )),
1312 )
1313 .await;
1314 }
1315 }
1316
1317 if let Some(wal) = wal_ref {
1319 let base = self.base.clone_shared();
1320 let source_id = self.base.id.clone();
1321 let prune_handle = tokio::spawn(async move {
1322 let mut interval = tokio::time::interval(Duration::from_secs(30));
1323 loop {
1324 interval.tick().await;
1325 if let Some(confirmed) = base.compute_confirmed_position().await {
1326 if confirmed > 0 {
1327 match wal.prune_up_to(&source_id, confirmed).await {
1328 Ok(pruned) => {
1329 if pruned > 0 {
1330 let remaining =
1331 wal.event_count(&source_id).await.unwrap_or(0);
1332 debug!(
1333 "[{source_id}] WAL pruned: count={pruned}, confirmed_seq={confirmed}, remaining={remaining}"
1334 );
1335 }
1336 }
1337 Err(e) => {
1338 warn!("[{source_id}] WAL prune failed: {e}");
1339 }
1340 }
1341 }
1342 }
1343 }
1344 });
1345 *self.prune_task.write().await = Some(prune_handle);
1346 }
1347
1348 Ok(())
1349 }
1350
1351 async fn stop(&self) -> Result<()> {
1352 info!("[{}] Stopping adaptive HTTP source", self.base.id);
1353
1354 self.base
1355 .set_status(
1356 ComponentStatus::Stopping,
1357 Some("Stopping adaptive HTTP source".to_string()),
1358 )
1359 .await;
1360
1361 if let Some(handle) = self.prune_task.write().await.take() {
1363 handle.abort();
1364 }
1365
1366 if let Some(tx) = self.base.shutdown_tx.write().await.take() {
1367 let _ = tx.send(());
1368 }
1369
1370 if let Some(handle) = self.base.task_handle.write().await.take() {
1371 let _ = timeout(Duration::from_secs(5), handle).await;
1372 }
1373
1374 self.base
1375 .set_status(
1376 ComponentStatus::Stopped,
1377 Some("Adaptive HTTP source stopped".to_string()),
1378 )
1379 .await;
1380
1381 Ok(())
1382 }
1383
1384 async fn status(&self) -> ComponentStatus {
1385 self.base.get_status().await
1386 }
1387
1388 async fn subscribe(
1389 &self,
1390 settings: drasi_lib::config::SourceSubscriptionSettings,
1391 ) -> Result<SubscriptionResponse> {
1392 let wal_guard = self.wal.read().await;
1394 if let (Some(wal), Some(ref resume_from)) = (wal_guard.as_ref(), &settings.resume_from) {
1395 if resume_from.len() >= 8 {
1397 let resume_seq =
1398 u64::from_be_bytes(resume_from[..8].try_into().unwrap_or_default());
1399 let wal_clone = wal.clone();
1400 drop(wal_guard);
1401 return self
1402 .base
1403 .subscribe_with_replay(&settings, wal_clone.as_ref(), resume_seq, "HTTP")
1404 .await;
1405 } else {
1406 drop(wal_guard);
1407 return Err(anyhow::anyhow!(
1408 "Invalid resume_from position: expected at least 8 bytes, got {}",
1409 resume_from.len()
1410 ));
1411 }
1412 }
1413 drop(wal_guard);
1414 self.base.subscribe_with_bootstrap(&settings, "HTTP").await
1415 }
1416
1417 fn supports_replay(&self) -> bool {
1418 self.config.durability.as_ref().is_some_and(|d| d.enabled)
1419 }
1420
1421 async fn deprovision(&self) -> Result<()> {
1422 let wal_guard = self.wal.read().await;
1424 if let Some(ref wal) = *wal_guard {
1425 info!("[{}] Deprovisioning: deleting WAL data", self.base.id);
1426 if let Err(e) = wal.delete_wal(&self.base.id).await {
1427 warn!(
1428 "[{}] Failed to delete WAL during deprovision: {}",
1429 self.base.id, e
1430 );
1431 }
1432 }
1433 drop(wal_guard);
1434 Ok(())
1435 }
1436
1437 fn as_any(&self) -> &dyn std::any::Any {
1438 self
1439 }
1440
1441 async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
1442 self.base.initialize(context).await;
1443 }
1444
1445 async fn set_bootstrap_provider(
1446 &self,
1447 provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
1448 ) {
1449 self.base.set_bootstrap_provider(provider).await;
1450 }
1451}
1452
1453pub struct HttpSourceBuilder {
1472 id: String,
1473 host: String,
1474 port: u16,
1475 endpoint: Option<String>,
1476 timeout_ms: u64,
1477 adaptive_max_batch_size: Option<usize>,
1478 adaptive_min_batch_size: Option<usize>,
1479 adaptive_max_wait_ms: Option<u64>,
1480 adaptive_min_wait_ms: Option<u64>,
1481 adaptive_window_secs: Option<u64>,
1482 adaptive_enabled: Option<bool>,
1483 webhooks: Option<WebhookConfig>,
1484 durability: Option<drasi_lib::DurabilityConfig>,
1485 dispatch_mode: Option<DispatchMode>,
1486 dispatch_buffer_capacity: Option<usize>,
1487 bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
1488 auto_start: bool,
1489}
1490
1491impl HttpSourceBuilder {
1492 pub fn new(id: impl Into<String>) -> Self {
1498 Self {
1499 id: id.into(),
1500 host: String::new(),
1501 port: 8080,
1502 endpoint: None,
1503 timeout_ms: 10000,
1504 adaptive_max_batch_size: None,
1505 adaptive_min_batch_size: None,
1506 adaptive_max_wait_ms: None,
1507 adaptive_min_wait_ms: None,
1508 adaptive_window_secs: None,
1509 adaptive_enabled: None,
1510 webhooks: None,
1511 durability: None,
1512 dispatch_mode: None,
1513 dispatch_buffer_capacity: None,
1514 bootstrap_provider: None,
1515 auto_start: true,
1516 }
1517 }
1518
1519 pub fn with_host(mut self, host: impl Into<String>) -> Self {
1521 self.host = host.into();
1522 self
1523 }
1524
1525 pub fn with_port(mut self, port: u16) -> Self {
1527 self.port = port;
1528 self
1529 }
1530
1531 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
1533 self.endpoint = Some(endpoint.into());
1534 self
1535 }
1536
1537 pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
1539 self.timeout_ms = timeout_ms;
1540 self
1541 }
1542
1543 pub fn with_adaptive_max_batch_size(mut self, size: usize) -> Self {
1545 self.adaptive_max_batch_size = Some(size);
1546 self
1547 }
1548
1549 pub fn with_adaptive_min_batch_size(mut self, size: usize) -> Self {
1551 self.adaptive_min_batch_size = Some(size);
1552 self
1553 }
1554
1555 pub fn with_adaptive_max_wait_ms(mut self, wait_ms: u64) -> Self {
1557 self.adaptive_max_wait_ms = Some(wait_ms);
1558 self
1559 }
1560
1561 pub fn with_adaptive_min_wait_ms(mut self, wait_ms: u64) -> Self {
1563 self.adaptive_min_wait_ms = Some(wait_ms);
1564 self
1565 }
1566
1567 pub fn with_adaptive_window_secs(mut self, secs: u64) -> Self {
1569 self.adaptive_window_secs = Some(secs);
1570 self
1571 }
1572
1573 pub fn with_adaptive_enabled(mut self, enabled: bool) -> Self {
1575 self.adaptive_enabled = Some(enabled);
1576 self
1577 }
1578
1579 pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1581 self.dispatch_mode = Some(mode);
1582 self
1583 }
1584
1585 pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1587 self.dispatch_buffer_capacity = Some(capacity);
1588 self
1589 }
1590
1591 pub fn with_bootstrap_provider(
1593 mut self,
1594 provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1595 ) -> Self {
1596 self.bootstrap_provider = Some(Box::new(provider));
1597 self
1598 }
1599
1600 pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1605 self.auto_start = auto_start;
1606 self
1607 }
1608
1609 pub fn with_webhooks(mut self, webhooks: WebhookConfig) -> Self {
1614 self.webhooks = Some(webhooks);
1615 self
1616 }
1617
1618 pub fn with_durability(mut self, config: drasi_lib::DurabilityConfig) -> Self {
1623 self.durability = Some(config);
1624 self
1625 }
1626
1627 pub fn with_config(mut self, config: HttpSourceConfig) -> Self {
1629 self.host = config.host;
1630 self.port = config.port;
1631 self.endpoint = config.endpoint;
1632 self.timeout_ms = config.timeout_ms;
1633 self.adaptive_max_batch_size = config.adaptive_max_batch_size;
1634 self.adaptive_min_batch_size = config.adaptive_min_batch_size;
1635 self.adaptive_max_wait_ms = config.adaptive_max_wait_ms;
1636 self.adaptive_min_wait_ms = config.adaptive_min_wait_ms;
1637 self.adaptive_window_secs = config.adaptive_window_secs;
1638 self.adaptive_enabled = config.adaptive_enabled;
1639 self.webhooks = config.webhooks;
1640 self.durability = config.durability;
1641 self
1642 }
1643
1644 pub fn build(self) -> Result<HttpSource> {
1650 let config = HttpSourceConfig {
1651 host: self.host,
1652 port: self.port,
1653 endpoint: self.endpoint,
1654 timeout_ms: self.timeout_ms,
1655 adaptive_max_batch_size: self.adaptive_max_batch_size,
1656 adaptive_min_batch_size: self.adaptive_min_batch_size,
1657 adaptive_max_wait_ms: self.adaptive_max_wait_ms,
1658 adaptive_min_wait_ms: self.adaptive_min_wait_ms,
1659 adaptive_window_secs: self.adaptive_window_secs,
1660 adaptive_enabled: self.adaptive_enabled,
1661 webhooks: self.webhooks,
1662 durability: self.durability,
1663 };
1664
1665 let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1667 if let Some(mode) = self.dispatch_mode {
1668 params = params.with_dispatch_mode(mode);
1669 }
1670 if let Some(capacity) = self.dispatch_buffer_capacity {
1671 params = params.with_dispatch_buffer_capacity(capacity);
1672 }
1673 if let Some(provider) = self.bootstrap_provider {
1674 params = params.with_bootstrap_provider(provider);
1675 }
1676
1677 let mut adaptive_config = AdaptiveBatchConfig::default();
1679 if let Some(max_batch) = config.adaptive_max_batch_size {
1680 adaptive_config.max_batch_size = max_batch;
1681 }
1682 if let Some(min_batch) = config.adaptive_min_batch_size {
1683 adaptive_config.min_batch_size = min_batch;
1684 }
1685 if let Some(max_wait_ms) = config.adaptive_max_wait_ms {
1686 adaptive_config.max_wait_time = Duration::from_millis(max_wait_ms);
1687 }
1688 if let Some(min_wait_ms) = config.adaptive_min_wait_ms {
1689 adaptive_config.min_wait_time = Duration::from_millis(min_wait_ms);
1690 }
1691 if let Some(window_secs) = config.adaptive_window_secs {
1692 adaptive_config.throughput_window = Duration::from_secs(window_secs);
1693 }
1694 if let Some(enabled) = config.adaptive_enabled {
1695 adaptive_config.adaptive_enabled = enabled;
1696 }
1697
1698 Ok(HttpSource {
1699 base: SourceBase::new(params)?,
1700 config,
1701 adaptive_config,
1702 wal: tokio::sync::RwLock::new(None),
1703 prune_task: tokio::sync::RwLock::new(None),
1704 })
1705 }
1706}
1707
1708impl HttpSource {
1709 pub fn builder(id: impl Into<String>) -> HttpSourceBuilder {
1727 HttpSourceBuilder::new(id)
1728 }
1729}
1730
1731fn handle_error(
1733 behavior: &ErrorBehavior,
1734 source_id: &str,
1735 status: StatusCode,
1736 message: &str,
1737 detail: Option<&str>,
1738) -> (StatusCode, Json<EventResponse>) {
1739 match behavior {
1740 ErrorBehavior::Reject => {
1741 debug!("[{source_id}] Rejecting request: {message}");
1742 (
1743 status,
1744 Json(EventResponse {
1745 success: false,
1746 message: message.to_string(),
1747 error: detail.map(String::from),
1748 }),
1749 )
1750 }
1751 ErrorBehavior::AcceptAndLog => {
1752 warn!("[{source_id}] Accepting with error (logged): {message}");
1753 (
1754 StatusCode::OK,
1755 Json(EventResponse {
1756 success: true,
1757 message: format!("Accepted with warning: {message}"),
1758 error: detail.map(String::from),
1759 }),
1760 )
1761 }
1762 ErrorBehavior::AcceptAndSkip => {
1763 trace!("[{source_id}] Accepting silently: {message}");
1764 (
1765 StatusCode::OK,
1766 Json(EventResponse {
1767 success: true,
1768 message: "Accepted".to_string(),
1769 error: None,
1770 }),
1771 )
1772 }
1773 }
1774}
1775
1776fn parse_query_string(query: Option<&str>) -> HashMap<String, String> {
1778 query
1779 .map(|q| {
1780 q.split('&')
1781 .filter_map(|pair| {
1782 let mut parts = pair.splitn(2, '=');
1783 let key = parts.next()?;
1784 let value = parts.next().unwrap_or("");
1785 Some((urlencoding_decode(key), urlencoding_decode(value)))
1786 })
1787 .collect()
1788 })
1789 .unwrap_or_default()
1790}
1791
1792fn urlencoding_decode(s: &str) -> String {
1794 let mut decoded: Vec<u8> = Vec::with_capacity(s.len());
1796 let mut chars = s.chars();
1797
1798 while let Some(c) = chars.next() {
1799 if c == '%' {
1800 let mut hex = String::new();
1801 if let Some(c1) = chars.next() {
1802 hex.push(c1);
1803 }
1804 if let Some(c2) = chars.next() {
1805 hex.push(c2);
1806 }
1807
1808 if hex.len() == 2 {
1809 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
1810 decoded.push(byte);
1811 continue;
1812 }
1813 }
1814
1815 decoded.extend_from_slice(b"%");
1817 decoded.extend_from_slice(hex.as_bytes());
1818 } else if c == '+' {
1819 decoded.push(b' ');
1820 } else {
1821 let mut buf = [0u8; 4];
1823 let encoded = c.encode_utf8(&mut buf);
1824 decoded.extend_from_slice(encoded.as_bytes());
1825 }
1826 }
1827
1828 String::from_utf8_lossy(&decoded).into_owned()
1830}
1831
1832fn build_cors_layer(cors_config: &CorsConfig) -> CorsLayer {
1834 let mut cors = CorsLayer::new();
1835
1836 if cors_config.allow_origins.len() == 1 && cors_config.allow_origins[0] == "*" {
1838 cors = cors.allow_origin(Any);
1839 } else {
1840 let origins: Vec<_> = cors_config
1841 .allow_origins
1842 .iter()
1843 .filter_map(|o| o.parse().ok())
1844 .collect();
1845 cors = cors.allow_origin(origins);
1846 }
1847
1848 let methods: Vec<Method> = cors_config
1850 .allow_methods
1851 .iter()
1852 .filter_map(|m| m.parse().ok())
1853 .collect();
1854 cors = cors.allow_methods(methods);
1855
1856 if cors_config.allow_headers.len() == 1 && cors_config.allow_headers[0] == "*" {
1858 cors = cors.allow_headers(Any);
1859 } else {
1860 let headers: Vec<header::HeaderName> = cors_config
1861 .allow_headers
1862 .iter()
1863 .filter_map(|h| h.parse().ok())
1864 .collect();
1865 cors = cors.allow_headers(headers);
1866 }
1867
1868 if !cors_config.expose_headers.is_empty() {
1870 let exposed: Vec<header::HeaderName> = cors_config
1871 .expose_headers
1872 .iter()
1873 .filter_map(|h| h.parse().ok())
1874 .collect();
1875 cors = cors.expose_headers(exposed);
1876 }
1877
1878 if cors_config.allow_credentials {
1880 cors = cors.allow_credentials(true);
1881 }
1882
1883 cors = cors.max_age(Duration::from_secs(cors_config.max_age));
1885
1886 cors
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891 use super::*;
1892
1893 mod construction {
1894 use super::*;
1895
1896 #[test]
1897 fn test_builder_with_valid_config() {
1898 let source = HttpSourceBuilder::new("test-source")
1899 .with_host("localhost")
1900 .with_port(8080)
1901 .build();
1902 assert!(source.is_ok());
1903 }
1904
1905 #[test]
1906 fn test_builder_with_custom_config() {
1907 let source = HttpSourceBuilder::new("http-source")
1908 .with_host("0.0.0.0")
1909 .with_port(9000)
1910 .with_endpoint("/events")
1911 .build()
1912 .unwrap();
1913 assert_eq!(source.id(), "http-source");
1914 }
1915
1916 #[test]
1917 fn test_with_dispatch_creates_source() {
1918 let config = HttpSourceConfig {
1919 host: "localhost".to_string(),
1920 port: 8080,
1921 endpoint: None,
1922 timeout_ms: 10000,
1923 adaptive_max_batch_size: None,
1924 adaptive_min_batch_size: None,
1925 adaptive_max_wait_ms: None,
1926 adaptive_min_wait_ms: None,
1927 adaptive_window_secs: None,
1928 adaptive_enabled: None,
1929 webhooks: None,
1930 durability: None,
1931 };
1932 let source = HttpSource::with_dispatch(
1933 "dispatch-source",
1934 config,
1935 Some(DispatchMode::Channel),
1936 Some(1000),
1937 );
1938 assert!(source.is_ok());
1939 assert_eq!(source.unwrap().id(), "dispatch-source");
1940 }
1941 }
1942
1943 mod properties {
1944 use super::*;
1945
1946 #[test]
1947 fn test_id_returns_correct_value() {
1948 let source = HttpSourceBuilder::new("my-http-source")
1949 .with_host("localhost")
1950 .build()
1951 .unwrap();
1952 assert_eq!(source.id(), "my-http-source");
1953 }
1954
1955 #[test]
1956 fn test_type_name_returns_http() {
1957 let source = HttpSourceBuilder::new("test")
1958 .with_host("localhost")
1959 .build()
1960 .unwrap();
1961 assert_eq!(source.type_name(), "http");
1962 }
1963
1964 #[test]
1965 fn test_properties_contains_host_and_port() {
1966 let source = HttpSourceBuilder::new("test")
1967 .with_host("192.168.1.1")
1968 .with_port(9000)
1969 .build()
1970 .unwrap();
1971 let props = source.properties();
1972
1973 assert_eq!(
1974 props.get("host"),
1975 Some(&serde_json::Value::String("192.168.1.1".to_string()))
1976 );
1977 assert_eq!(
1978 props.get("port"),
1979 Some(&serde_json::Value::Number(9000.into()))
1980 );
1981 }
1982
1983 #[test]
1984 fn test_properties_includes_endpoint_when_set() {
1985 let source = HttpSourceBuilder::new("test")
1986 .with_host("localhost")
1987 .with_endpoint("/api/v1")
1988 .build()
1989 .unwrap();
1990 let props = source.properties();
1991
1992 assert_eq!(
1993 props.get("endpoint"),
1994 Some(&serde_json::Value::String("/api/v1".to_string()))
1995 );
1996 }
1997
1998 #[test]
1999 fn test_properties_excludes_endpoint_when_none() {
2000 let source = HttpSourceBuilder::new("test")
2001 .with_host("localhost")
2002 .build()
2003 .unwrap();
2004 let props = source.properties();
2005
2006 assert!(!props.contains_key("endpoint"));
2007 }
2008
2009 #[test]
2010 fn test_describe_schema_uses_webhook_mappings() {
2011 let source = HttpSourceBuilder::new("test")
2012 .with_host("localhost")
2013 .with_webhooks(WebhookConfig {
2014 error_behavior: ErrorBehavior::AcceptAndLog,
2015 cors: None,
2016 routes: vec![crate::config::WebhookRoute {
2017 path: "/events".to_string(),
2018 methods: vec![crate::config::HttpMethod::Post],
2019 auth: None,
2020 error_behavior: None,
2021 mappings: vec![crate::config::WebhookMapping {
2022 when: None,
2023 operation: Some(crate::config::OperationType::Insert),
2024 operation_from: None,
2025 operation_map: None,
2026 element_type: crate::config::ElementType::Node,
2027 effective_from: None,
2028 template: crate::config::ElementTemplate {
2029 id: "{{payload.id}}".to_string(),
2030 labels: vec!["Order".to_string()],
2031 properties: Some(serde_json::json!({
2032 "total": "{{payload.total}}",
2033 "status": "{{payload.status}}"
2034 })),
2035 from: None,
2036 to: None,
2037 },
2038 }],
2039 }],
2040 })
2041 .build()
2042 .unwrap();
2043
2044 let schema = source
2045 .describe_schema()
2046 .expect("webhook-configured HTTP source should expose schema");
2047
2048 assert_eq!(schema.nodes.len(), 1);
2049 let node = &schema.nodes[0];
2050 assert_eq!(node.label, "Order");
2051 assert!(node
2052 .properties
2053 .iter()
2054 .any(|property| property.name == "total"));
2055 assert!(node
2056 .properties
2057 .iter()
2058 .any(|property| property.name == "status"));
2059 }
2060
2061 #[test]
2062 fn test_describe_schema_includes_relation_mappings() {
2063 let source = HttpSourceBuilder::new("test")
2064 .with_host("localhost")
2065 .with_webhooks(WebhookConfig {
2066 error_behavior: ErrorBehavior::AcceptAndLog,
2067 cors: None,
2068 routes: vec![crate::config::WebhookRoute {
2069 path: "/events".to_string(),
2070 methods: vec![crate::config::HttpMethod::Post],
2071 auth: None,
2072 error_behavior: None,
2073 mappings: vec![crate::config::WebhookMapping {
2074 when: None,
2075 operation: Some(crate::config::OperationType::Insert),
2076 operation_from: None,
2077 operation_map: None,
2078 element_type: crate::config::ElementType::Relation,
2079 effective_from: None,
2080 template: crate::config::ElementTemplate {
2081 id: "{{payload.id}}".to_string(),
2082 labels: vec!["PLACED_BY".to_string()],
2083 properties: Some(serde_json::json!({
2084 "placed_at": "{{payload.timestamp}}"
2085 })),
2086 from: None,
2087 to: None,
2088 },
2089 }],
2090 }],
2091 })
2092 .build()
2093 .unwrap();
2094
2095 let schema = source
2096 .describe_schema()
2097 .expect("webhook-configured HTTP source should expose schema for relations");
2098
2099 assert_eq!(schema.relations.len(), 1);
2100 let relation = &schema.relations[0];
2101 assert_eq!(relation.label, "PLACED_BY");
2102 assert!(relation
2103 .properties
2104 .iter()
2105 .any(|property| property.name == "placed_at"));
2106 assert_eq!(relation.from, None);
2108 assert_eq!(relation.to, None);
2109 }
2110 }
2111
2112 mod lifecycle {
2113 use super::*;
2114
2115 #[tokio::test]
2116 async fn test_initial_status_is_stopped() {
2117 let source = HttpSourceBuilder::new("test")
2118 .with_host("localhost")
2119 .build()
2120 .unwrap();
2121 assert_eq!(source.status().await, ComponentStatus::Stopped);
2122 }
2123 }
2124
2125 mod builder {
2126 use super::*;
2127
2128 #[test]
2129 fn test_http_builder_defaults() {
2130 let source = HttpSourceBuilder::new("test").build().unwrap();
2131 assert_eq!(source.config.port, 8080);
2132 assert_eq!(source.config.timeout_ms, 10000);
2133 assert_eq!(source.config.endpoint, None);
2134 }
2135
2136 #[test]
2137 fn test_http_builder_custom_values() {
2138 let source = HttpSourceBuilder::new("test")
2139 .with_host("api.example.com")
2140 .with_port(9000)
2141 .with_endpoint("/webhook")
2142 .with_timeout_ms(5000)
2143 .build()
2144 .unwrap();
2145
2146 assert_eq!(source.config.host, "api.example.com");
2147 assert_eq!(source.config.port, 9000);
2148 assert_eq!(source.config.endpoint, Some("/webhook".to_string()));
2149 assert_eq!(source.config.timeout_ms, 5000);
2150 }
2151
2152 #[test]
2153 fn test_http_builder_adaptive_batching() {
2154 let source = HttpSourceBuilder::new("test")
2155 .with_host("localhost")
2156 .with_adaptive_max_batch_size(1000)
2157 .with_adaptive_min_batch_size(10)
2158 .with_adaptive_max_wait_ms(500)
2159 .with_adaptive_min_wait_ms(50)
2160 .with_adaptive_window_secs(60)
2161 .with_adaptive_enabled(true)
2162 .build()
2163 .unwrap();
2164
2165 assert_eq!(source.config.adaptive_max_batch_size, Some(1000));
2166 assert_eq!(source.config.adaptive_min_batch_size, Some(10));
2167 assert_eq!(source.config.adaptive_max_wait_ms, Some(500));
2168 assert_eq!(source.config.adaptive_min_wait_ms, Some(50));
2169 assert_eq!(source.config.adaptive_window_secs, Some(60));
2170 assert_eq!(source.config.adaptive_enabled, Some(true));
2171 }
2172
2173 #[test]
2174 fn test_builder_id() {
2175 let source = HttpSource::builder("my-http-source")
2176 .with_host("localhost")
2177 .build()
2178 .unwrap();
2179
2180 assert_eq!(source.base.id, "my-http-source");
2181 }
2182 }
2183
2184 mod event_conversion {
2185 use super::*;
2186
2187 #[test]
2188 fn test_convert_node_insert() {
2189 let mut props = serde_json::Map::new();
2190 props.insert(
2191 "name".to_string(),
2192 serde_json::Value::String("Alice".to_string()),
2193 );
2194 props.insert("age".to_string(), serde_json::Value::Number(30.into()));
2195
2196 let http_change = HttpSourceChange::Insert {
2197 element: HttpElement::Node {
2198 id: "user-1".to_string(),
2199 labels: vec!["User".to_string()],
2200 properties: props,
2201 },
2202 timestamp: Some(1234567890000000000),
2203 };
2204
2205 let result = convert_http_to_source_change(&http_change, "test-source");
2206 assert!(result.is_ok());
2207
2208 match result.unwrap() {
2209 drasi_core::models::SourceChange::Insert { element } => match element {
2210 drasi_core::models::Element::Node {
2211 metadata,
2212 properties,
2213 } => {
2214 assert_eq!(metadata.reference.element_id.as_ref(), "user-1");
2215 assert_eq!(metadata.labels.len(), 1);
2216 assert_eq!(metadata.effective_from, 1234567890000);
2217 assert!(properties.get("name").is_some());
2218 assert!(properties.get("age").is_some());
2219 }
2220 _ => panic!("Expected Node element"),
2221 },
2222 _ => panic!("Expected Insert operation"),
2223 }
2224 }
2225
2226 #[test]
2227 fn test_convert_relation_insert() {
2228 let http_change = HttpSourceChange::Insert {
2229 element: HttpElement::Relation {
2230 id: "follows-1".to_string(),
2231 labels: vec!["FOLLOWS".to_string()],
2232 from: "user-1".to_string(),
2233 to: "user-2".to_string(),
2234 properties: serde_json::Map::new(),
2235 },
2236 timestamp: None,
2237 };
2238
2239 let result = convert_http_to_source_change(&http_change, "test-source");
2240 assert!(result.is_ok());
2241
2242 match result.unwrap() {
2243 drasi_core::models::SourceChange::Insert { element } => match element {
2244 drasi_core::models::Element::Relation {
2245 metadata,
2246 out_node,
2247 in_node,
2248 ..
2249 } => {
2250 assert_eq!(metadata.reference.element_id.as_ref(), "follows-1");
2251 assert_eq!(in_node.element_id.as_ref(), "user-1");
2252 assert_eq!(out_node.element_id.as_ref(), "user-2");
2253 }
2254 _ => panic!("Expected Relation element"),
2255 },
2256 _ => panic!("Expected Insert operation"),
2257 }
2258 }
2259
2260 #[test]
2261 fn test_convert_delete() {
2262 let http_change = HttpSourceChange::Delete {
2263 id: "user-1".to_string(),
2264 labels: Some(vec!["User".to_string()]),
2265 timestamp: Some(9999999999),
2266 };
2267
2268 let result = convert_http_to_source_change(&http_change, "test-source");
2269 assert!(result.is_ok());
2270
2271 match result.unwrap() {
2272 drasi_core::models::SourceChange::Delete { metadata } => {
2273 assert_eq!(metadata.reference.element_id.as_ref(), "user-1");
2274 assert_eq!(metadata.labels.len(), 1);
2275 }
2276 _ => panic!("Expected Delete operation"),
2277 }
2278 }
2279
2280 #[test]
2281 fn test_convert_update() {
2282 let http_change = HttpSourceChange::Update {
2283 element: HttpElement::Node {
2284 id: "user-1".to_string(),
2285 labels: vec!["User".to_string()],
2286 properties: serde_json::Map::new(),
2287 },
2288 timestamp: None,
2289 };
2290
2291 let result = convert_http_to_source_change(&http_change, "test-source");
2292 assert!(result.is_ok());
2293
2294 match result.unwrap() {
2295 drasi_core::models::SourceChange::Update { .. } => {
2296 }
2298 _ => panic!("Expected Update operation"),
2299 }
2300 }
2301 }
2302
2303 mod adaptive_config {
2304 use super::*;
2305
2306 #[test]
2307 fn test_adaptive_config_from_http_config() {
2308 let source = HttpSourceBuilder::new("test")
2309 .with_host("localhost")
2310 .with_adaptive_max_batch_size(500)
2311 .with_adaptive_enabled(true)
2312 .build()
2313 .unwrap();
2314
2315 assert_eq!(source.adaptive_config.max_batch_size, 500);
2317 assert!(source.adaptive_config.adaptive_enabled);
2318 }
2319
2320 #[test]
2321 fn test_adaptive_config_uses_defaults_when_not_specified() {
2322 let source = HttpSourceBuilder::new("test")
2323 .with_host("localhost")
2324 .build()
2325 .unwrap();
2326
2327 let default_config = AdaptiveBatchConfig::default();
2329 assert_eq!(
2330 source.adaptive_config.max_batch_size,
2331 default_config.max_batch_size
2332 );
2333 assert_eq!(
2334 source.adaptive_config.min_batch_size,
2335 default_config.min_batch_size
2336 );
2337 }
2338 }
2339}
2340
2341#[cfg(test)]
2342mod fallback_tests {
2343 use super::*;
2344 use drasi_lib::sources::Source;
2345
2346 #[test]
2347 fn test_builder_fallback_produces_camel_case() {
2348 let source = HttpSourceBuilder::new("http-fallback")
2349 .with_host("0.0.0.0")
2350 .with_port(9090)
2351 .with_endpoint("/ingest")
2352 .with_timeout_ms(5000)
2353 .with_adaptive_max_batch_size(500)
2354 .with_adaptive_min_batch_size(10)
2355 .with_adaptive_max_wait_ms(2000)
2356 .with_adaptive_min_wait_ms(100)
2357 .build()
2358 .unwrap();
2359
2360 let props = source.properties();
2361
2362 assert!(
2364 props.contains_key("timeoutMs"),
2365 "expected camelCase 'timeoutMs', got keys: {:?}",
2366 props.keys().collect::<Vec<_>>()
2367 );
2368 assert!(
2369 props.contains_key("adaptiveMaxBatchSize"),
2370 "expected camelCase 'adaptiveMaxBatchSize'"
2371 );
2372 assert!(
2373 props.contains_key("adaptiveMinBatchSize"),
2374 "expected camelCase 'adaptiveMinBatchSize'"
2375 );
2376 assert!(
2377 props.contains_key("adaptiveMaxWaitMs"),
2378 "expected camelCase 'adaptiveMaxWaitMs'"
2379 );
2380 assert!(
2381 props.contains_key("adaptiveMinWaitMs"),
2382 "expected camelCase 'adaptiveMinWaitMs'"
2383 );
2384
2385 assert!(
2387 !props.contains_key("timeout_ms"),
2388 "should not have snake_case 'timeout_ms'"
2389 );
2390 assert!(
2391 !props.contains_key("adaptive_max_batch_size"),
2392 "should not have snake_case 'adaptive_max_batch_size'"
2393 );
2394
2395 assert_eq!(props.get("host").and_then(|v| v.as_str()), Some("0.0.0.0"));
2397 assert_eq!(props.get("port").and_then(|v| v.as_u64()), Some(9090));
2398 assert_eq!(
2399 props.get("endpoint").and_then(|v| v.as_str()),
2400 Some("/ingest")
2401 );
2402 assert_eq!(props.get("timeoutMs").and_then(|v| v.as_u64()), Some(5000));
2403 assert_eq!(
2404 props.get("adaptiveMaxBatchSize").and_then(|v| v.as_u64()),
2405 Some(500)
2406 );
2407 }
2408}
2409
2410#[cfg(feature = "dynamic-plugin")]
2414drasi_plugin_sdk::export_plugin!(
2415 plugin_id = "http-source",
2416 core_version = env!("CARGO_PKG_VERSION"),
2417 lib_version = env!("CARGO_PKG_VERSION"),
2418 plugin_version = env!("CARGO_PKG_VERSION"),
2419 source_descriptors = [descriptor::HttpSourceDescriptor],
2420 reaction_descriptors = [],
2421 bootstrap_descriptors = [],
2422);