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