1#![allow(unexpected_cfgs)]
2pub mod config;
218pub mod descriptor;
219pub use config::HttpSourceConfig;
220
221mod adaptive_batcher;
222mod models;
223mod time;
224
225pub mod auth;
227pub mod content_parser;
228pub mod route_matcher;
229pub mod template_engine;
230
231pub use models::{convert_http_to_source_change, HttpElement, HttpSourceChange};
233
234use anyhow::Result;
235use async_trait::async_trait;
236use axum::{
237 body::Bytes,
238 extract::{Path, State},
239 http::{header, Method, StatusCode},
240 response::IntoResponse,
241 routing::{delete, get, post, put},
242 Json, Router,
243};
244use log::{debug, error, info, trace, warn};
245use serde::{Deserialize, Serialize};
246use std::collections::HashMap;
247use std::sync::Arc;
248use std::time::Duration;
249use tokio::sync::mpsc;
250use tokio::time::timeout;
251use tower_http::cors::{Any, CorsLayer};
252
253use drasi_lib::channels::{ComponentType, *};
254use drasi_lib::schema::{NodeSchema, PropertySchema, RelationSchema, SourceSchema};
255use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
256use drasi_lib::Source;
257use tracing::Instrument;
258
259use crate::adaptive_batcher::{AdaptiveBatchConfig, AdaptiveBatcher};
260use crate::auth::{verify_auth, AuthResult};
261use crate::config::{CorsConfig, ErrorBehavior, WebhookConfig};
262use crate::content_parser::{parse_content, ContentType};
263use crate::route_matcher::{convert_method, find_matching_mappings, headers_to_map, RouteMatcher};
264use crate::template_engine::{TemplateContext, TemplateEngine};
265
266#[derive(Debug, Serialize, Deserialize)]
268pub struct EventResponse {
269 pub success: bool,
270 pub message: String,
271 #[serde(skip_serializing_if = "Option::is_none")]
272 pub error: Option<String>,
273}
274
275pub struct HttpSource {
287 base: SourceBase,
289 config: HttpSourceConfig,
291 adaptive_config: AdaptiveBatchConfig,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct BatchEventRequest {
298 pub events: Vec<HttpSourceChange>,
299}
300
301#[derive(Clone)]
305struct HttpAppState {
306 source_id: String,
308 batch_tx: mpsc::Sender<SourceChangeEvent>,
310 webhook_config: Option<Arc<WebhookState>>,
312}
313
314struct WebhookState {
316 config: WebhookConfig,
318 route_matcher: RouteMatcher,
320 template_engine: TemplateEngine,
322}
323
324fn extract_property_schemas(properties: Option<&serde_json::Value>) -> Vec<PropertySchema> {
325 match properties {
326 Some(serde_json::Value::Object(map)) => map
327 .keys()
328 .map(|key| PropertySchema::new(key.clone()))
329 .collect(),
330 _ => Vec::new(),
331 }
332}
333
334fn derive_schema_from_webhooks(webhooks: &WebhookConfig) -> Option<SourceSchema> {
335 let mut node_map: HashMap<String, Vec<PropertySchema>> = HashMap::new();
336 let mut relation_map: HashMap<String, Vec<PropertySchema>> = HashMap::new();
337
338 for route in &webhooks.routes {
339 for mapping in &route.mappings {
340 let Some(label) = mapping.template.labels.first().cloned() else {
341 continue;
342 };
343
344 let properties = extract_property_schemas(mapping.template.properties.as_ref());
345
346 match mapping.element_type {
347 crate::config::ElementType::Node => {
348 let entry = node_map.entry(label).or_default();
349 for prop in properties {
350 if !entry.iter().any(|p| p.name == prop.name) {
351 entry.push(prop);
352 }
353 }
354 }
355 crate::config::ElementType::Relation => {
356 let entry = relation_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 }
364 }
365 }
366
367 let nodes: Vec<_> = node_map
368 .into_iter()
369 .map(|(label, properties)| NodeSchema { label, properties })
370 .collect();
371 let relations: Vec<_> = relation_map
372 .into_iter()
373 .map(|(label, properties)| RelationSchema {
374 label,
375 from: None,
376 to: None,
377 properties,
378 })
379 .collect();
380
381 if nodes.is_empty() && relations.is_empty() {
382 None
383 } else {
384 Some(SourceSchema { nodes, relations })
385 }
386}
387
388impl HttpSource {
389 pub fn new(id: impl Into<String>, config: HttpSourceConfig) -> Result<Self> {
420 let id = id.into();
421 let params = SourceBaseParams::new(id);
422
423 let mut adaptive_config = AdaptiveBatchConfig::default();
425
426 if let Some(max_batch) = config.adaptive_max_batch_size {
428 adaptive_config.max_batch_size = max_batch;
429 }
430 if let Some(min_batch) = config.adaptive_min_batch_size {
431 adaptive_config.min_batch_size = min_batch;
432 }
433 if let Some(max_wait_ms) = config.adaptive_max_wait_ms {
434 adaptive_config.max_wait_time = Duration::from_millis(max_wait_ms);
435 }
436 if let Some(min_wait_ms) = config.adaptive_min_wait_ms {
437 adaptive_config.min_wait_time = Duration::from_millis(min_wait_ms);
438 }
439 if let Some(window_secs) = config.adaptive_window_secs {
440 adaptive_config.throughput_window = Duration::from_secs(window_secs);
441 }
442 if let Some(enabled) = config.adaptive_enabled {
443 adaptive_config.adaptive_enabled = enabled;
444 }
445
446 Ok(Self {
447 base: SourceBase::new(params)?,
448 config,
449 adaptive_config,
450 })
451 }
452
453 pub fn with_dispatch(
473 id: impl Into<String>,
474 config: HttpSourceConfig,
475 dispatch_mode: Option<DispatchMode>,
476 dispatch_buffer_capacity: Option<usize>,
477 ) -> Result<Self> {
478 let id = id.into();
479 let mut params = SourceBaseParams::new(id);
480 if let Some(mode) = dispatch_mode {
481 params = params.with_dispatch_mode(mode);
482 }
483 if let Some(capacity) = dispatch_buffer_capacity {
484 params = params.with_dispatch_buffer_capacity(capacity);
485 }
486
487 let mut adaptive_config = AdaptiveBatchConfig::default();
488
489 if let Some(max_batch) = config.adaptive_max_batch_size {
490 adaptive_config.max_batch_size = max_batch;
491 }
492 if let Some(min_batch) = config.adaptive_min_batch_size {
493 adaptive_config.min_batch_size = min_batch;
494 }
495 if let Some(max_wait_ms) = config.adaptive_max_wait_ms {
496 adaptive_config.max_wait_time = Duration::from_millis(max_wait_ms);
497 }
498 if let Some(min_wait_ms) = config.adaptive_min_wait_ms {
499 adaptive_config.min_wait_time = Duration::from_millis(min_wait_ms);
500 }
501 if let Some(window_secs) = config.adaptive_window_secs {
502 adaptive_config.throughput_window = Duration::from_secs(window_secs);
503 }
504 if let Some(enabled) = config.adaptive_enabled {
505 adaptive_config.adaptive_enabled = enabled;
506 }
507
508 Ok(Self {
509 base: SourceBase::new(params)?,
510 config,
511 adaptive_config,
512 })
513 }
514
515 async fn handle_single_event(
520 Path(source_id): Path<String>,
521 State(state): State<HttpAppState>,
522 Json(event): Json<HttpSourceChange>,
523 ) -> Result<impl IntoResponse, (StatusCode, Json<EventResponse>)> {
524 debug!("[{source_id}] HTTP endpoint received single event: {event:?}");
525 Self::process_events(&source_id, &state, vec![event]).await
526 }
527
528 async fn handle_batch_events(
533 Path(source_id): Path<String>,
534 State(state): State<HttpAppState>,
535 Json(batch): Json<BatchEventRequest>,
536 ) -> Result<impl IntoResponse, (StatusCode, Json<EventResponse>)> {
537 debug!(
538 "[{}] HTTP endpoint received batch of {} events",
539 source_id,
540 batch.events.len()
541 );
542 Self::process_events(&source_id, &state, batch.events).await
543 }
544
545 async fn process_events(
557 source_id: &str,
558 state: &HttpAppState,
559 events: Vec<HttpSourceChange>,
560 ) -> Result<impl IntoResponse, (StatusCode, Json<EventResponse>)> {
561 trace!("[{}] Processing {} events", source_id, events.len());
562
563 if source_id != state.source_id {
564 error!(
565 "[{}] Source name mismatch. Expected '{}', got '{}'",
566 state.source_id, state.source_id, source_id
567 );
568 return Err((
569 StatusCode::BAD_REQUEST,
570 Json(EventResponse {
571 success: false,
572 message: "Source name mismatch".to_string(),
573 error: Some(format!(
574 "Expected source '{}', got '{}'",
575 state.source_id, source_id
576 )),
577 }),
578 ));
579 }
580
581 let mut success_count = 0;
582 let mut error_count = 0;
583 let mut last_error = None;
584
585 for (idx, event) in events.iter().enumerate() {
586 match convert_http_to_source_change(event, source_id) {
587 Ok(source_change) => {
588 let change_event = SourceChangeEvent {
589 source_id: source_id.to_string(),
590 change: source_change,
591 timestamp: chrono::Utc::now(),
592 };
593
594 if let Err(e) = state.batch_tx.send(change_event).await {
595 error!(
596 "[{}] Failed to send event {} to batch channel: {}",
597 state.source_id,
598 idx + 1,
599 e
600 );
601 error_count += 1;
602 last_error = Some("Internal channel error".to_string());
603 } else {
604 success_count += 1;
605 }
606 }
607 Err(e) => {
608 error!(
609 "[{}] Failed to convert event {}: {}",
610 state.source_id,
611 idx + 1,
612 e
613 );
614 error_count += 1;
615 last_error = Some(e.to_string());
616 }
617 }
618 }
619
620 debug!(
621 "[{source_id}] Event processing complete: {success_count} succeeded, {error_count} failed"
622 );
623
624 if error_count > 0 && success_count == 0 {
625 Err((
626 StatusCode::BAD_REQUEST,
627 Json(EventResponse {
628 success: false,
629 message: format!("All {error_count} events failed"),
630 error: last_error,
631 }),
632 ))
633 } else if error_count > 0 {
634 Ok(Json(EventResponse {
635 success: true,
636 message: format!(
637 "Processed {success_count} events successfully, {error_count} failed"
638 ),
639 error: last_error,
640 }))
641 } else {
642 Ok(Json(EventResponse {
643 success: true,
644 message: format!("All {success_count} events processed successfully"),
645 error: None,
646 }))
647 }
648 }
649
650 async fn health_check() -> impl IntoResponse {
651 Json(serde_json::json!({
652 "status": "healthy",
653 "service": "http-source",
654 "features": ["adaptive-batching", "batch-endpoint", "webhooks"]
655 }))
656 }
657
658 async fn handle_webhook(
663 method: axum::http::Method,
664 uri: axum::http::Uri,
665 headers: axum::http::HeaderMap,
666 State(state): State<HttpAppState>,
667 body: Bytes,
668 ) -> impl IntoResponse {
669 let path = uri.path();
670 let source_id = &state.source_id;
671
672 debug!("[{source_id}] Webhook received: {method} {path}");
673
674 let webhook_state = match &state.webhook_config {
676 Some(ws) => ws,
677 None => {
678 error!("[{source_id}] Webhook handler called but no webhook config present");
679 return (
680 StatusCode::INTERNAL_SERVER_ERROR,
681 Json(EventResponse {
682 success: false,
683 message: "Internal configuration error".to_string(),
684 error: Some("Webhook mode not properly configured".to_string()),
685 }),
686 );
687 }
688 };
689
690 let http_method = match convert_method(&method) {
692 Some(m) => m,
693 None => {
694 return handle_error(
695 &webhook_state.config.error_behavior,
696 source_id,
697 StatusCode::METHOD_NOT_ALLOWED,
698 "Method not supported",
699 None,
700 );
701 }
702 };
703
704 let route_match = match webhook_state.route_matcher.match_route(
706 path,
707 &http_method,
708 &webhook_state.config.routes,
709 ) {
710 Some(rm) => rm,
711 None => {
712 debug!("[{source_id}] No matching route for {method} {path}");
713 return handle_error(
714 &webhook_state.config.error_behavior,
715 source_id,
716 StatusCode::NOT_FOUND,
717 "No matching route",
718 None,
719 );
720 }
721 };
722
723 let route = route_match.route;
724 let error_behavior = route
725 .error_behavior
726 .as_ref()
727 .unwrap_or(&webhook_state.config.error_behavior);
728
729 let auth_result = verify_auth(route.auth.as_ref(), &headers, &body);
731 if let AuthResult::Failed(reason) = auth_result {
732 warn!("[{source_id}] Authentication failed for {path}: {reason}");
733 return handle_error(
734 error_behavior,
735 source_id,
736 StatusCode::UNAUTHORIZED,
737 "Authentication failed",
738 Some(&reason),
739 );
740 }
741
742 let content_type = ContentType::from_header(
744 headers
745 .get(axum::http::header::CONTENT_TYPE)
746 .and_then(|v| v.to_str().ok()),
747 );
748
749 let payload = match parse_content(&body, content_type) {
750 Ok(p) => p,
751 Err(e) => {
752 warn!("[{source_id}] Failed to parse payload: {e}");
753 return handle_error(
754 error_behavior,
755 source_id,
756 StatusCode::BAD_REQUEST,
757 "Failed to parse payload",
758 Some(&e.to_string()),
759 );
760 }
761 };
762
763 let headers_map = headers_to_map(&headers);
765 let query_map = parse_query_string(uri.query());
766
767 let context = TemplateContext {
768 payload: payload.clone(),
769 route: route_match.path_params,
770 query: query_map,
771 headers: headers_map.clone(),
772 method: method.to_string(),
773 path: path.to_string(),
774 source_id: source_id.clone(),
775 };
776
777 let matching_mappings = find_matching_mappings(&route.mappings, &headers_map, &payload);
779
780 if matching_mappings.is_empty() {
781 debug!("[{source_id}] No matching mappings for request");
782 return handle_error(
783 error_behavior,
784 source_id,
785 StatusCode::BAD_REQUEST,
786 "No matching mapping for request",
787 None,
788 );
789 }
790
791 let mut success_count = 0;
793 let mut error_count = 0;
794 let mut last_error = None;
795
796 for mapping in matching_mappings {
797 match webhook_state
798 .template_engine
799 .process_mapping(mapping, &context, source_id)
800 {
801 Ok(source_change) => {
802 let event = SourceChangeEvent {
803 source_id: source_id.clone(),
804 change: source_change,
805 timestamp: chrono::Utc::now(),
806 };
807
808 if let Err(e) = state.batch_tx.send(event).await {
809 error!("[{source_id}] Failed to send event to batcher: {e}");
810 error_count += 1;
811 last_error = Some(format!("Failed to queue event: {e}"));
812 } else {
813 success_count += 1;
814 }
815 }
816 Err(e) => {
817 warn!("[{source_id}] Failed to process mapping: {e}");
818 error_count += 1;
819 last_error = Some(e.to_string());
820 }
821 }
822 }
823
824 debug!("[{source_id}] Webhook processing complete: {success_count} succeeded, {error_count} failed");
825
826 if error_count > 0 && success_count == 0 {
827 handle_error(
828 error_behavior,
829 source_id,
830 StatusCode::BAD_REQUEST,
831 &format!("All {error_count} mappings failed"),
832 last_error.as_deref(),
833 )
834 } else if error_count > 0 {
835 (
836 StatusCode::OK,
837 Json(EventResponse {
838 success: true,
839 message: format!("Processed {success_count} events, {error_count} failed"),
840 error: last_error,
841 }),
842 )
843 } else {
844 (
845 StatusCode::OK,
846 Json(EventResponse {
847 success: true,
848 message: format!("Processed {success_count} events successfully"),
849 error: None,
850 }),
851 )
852 }
853 }
854
855 async fn run_adaptive_batcher(
856 batch_rx: mpsc::Receiver<SourceChangeEvent>,
857 dispatchers: Arc<
858 tokio::sync::RwLock<
859 Vec<
860 Box<
861 dyn drasi_lib::channels::ChangeDispatcher<SourceEventWrapper> + Send + Sync,
862 >,
863 >,
864 >,
865 >,
866 adaptive_config: AdaptiveBatchConfig,
867 source_id: String,
868 ) {
869 let mut batcher = AdaptiveBatcher::new(batch_rx, adaptive_config.clone());
870 let mut total_events = 0u64;
871 let mut total_batches = 0u64;
872
873 info!("[{source_id}] Adaptive HTTP batcher started with config: {adaptive_config:?}");
874
875 while let Some(batch) = batcher.next_batch().await {
876 if batch.is_empty() {
877 debug!("[{source_id}] Batcher received empty batch, skipping");
878 continue;
879 }
880
881 let batch_size = batch.len();
882 total_events += batch_size as u64;
883 total_batches += 1;
884
885 debug!(
886 "[{source_id}] Batcher forwarding batch #{total_batches} with {batch_size} events to dispatchers"
887 );
888
889 let mut sent_count = 0;
890 let mut failed_count = 0;
891 for (idx, event) in batch.into_iter().enumerate() {
892 debug!(
893 "[{}] Batch #{}, dispatching event {}/{}",
894 source_id,
895 total_batches,
896 idx + 1,
897 batch_size
898 );
899
900 let mut profiling = drasi_lib::profiling::ProfilingMetadata::new();
901 profiling.source_send_ns = Some(drasi_lib::profiling::timestamp_ns());
902
903 let wrapper = SourceEventWrapper::with_profiling(
904 event.source_id.clone(),
905 SourceEvent::Change(event.change),
906 event.timestamp,
907 profiling,
908 );
909
910 if let Err(e) =
911 SourceBase::dispatch_from_task(dispatchers.clone(), wrapper.clone(), &source_id)
912 .await
913 {
914 error!(
915 "[{}] Batch #{}, failed to dispatch event {}/{} (no subscribers): {}",
916 source_id,
917 total_batches,
918 idx + 1,
919 batch_size,
920 e
921 );
922 failed_count += 1;
923 } else {
924 debug!(
925 "[{}] Batch #{}, successfully dispatched event {}/{}",
926 source_id,
927 total_batches,
928 idx + 1,
929 batch_size
930 );
931 sent_count += 1;
932 }
933 }
934
935 debug!(
936 "[{source_id}] Batch #{total_batches} complete: {sent_count} dispatched, {failed_count} failed"
937 );
938
939 if total_batches.is_multiple_of(100) {
940 info!(
941 "[{}] Adaptive HTTP metrics - Batches: {}, Events: {}, Avg batch size: {:.1}",
942 source_id,
943 total_batches,
944 total_events,
945 total_events as f64 / total_batches as f64
946 );
947 }
948 }
949
950 info!(
951 "[{source_id}] Adaptive HTTP batcher stopped - Total batches: {total_batches}, Total events: {total_events}"
952 );
953 }
954}
955
956#[async_trait]
957impl Source for HttpSource {
958 fn id(&self) -> &str {
959 &self.base.id
960 }
961
962 fn type_name(&self) -> &str {
963 "http"
964 }
965
966 fn properties(&self) -> HashMap<String, serde_json::Value> {
967 use crate::descriptor::HttpSourceConfigDto;
968
969 self.base
970 .properties_or_serialize(&HttpSourceConfigDto::from(&self.config))
971 }
972
973 fn auto_start(&self) -> bool {
974 self.base.get_auto_start()
975 }
976
977 fn describe_schema(&self) -> Option<SourceSchema> {
978 self.config
979 .webhooks
980 .as_ref()
981 .and_then(derive_schema_from_webhooks)
982 }
983
984 async fn start(&self) -> Result<()> {
985 info!("[{}] Starting adaptive HTTP source", self.base.id);
986
987 self.base
988 .set_status(
989 ComponentStatus::Starting,
990 Some("Starting adaptive HTTP source".to_string()),
991 )
992 .await;
993
994 let host = self.config.host.clone();
995 let port = self.config.port;
996
997 let batch_channel_capacity = self.adaptive_config.recommended_channel_capacity();
999 let (batch_tx, batch_rx) = mpsc::channel(batch_channel_capacity);
1000 info!(
1001 "[{}] HttpSource using batch channel capacity: {} (max_batch_size: {} x 5)",
1002 self.base.id, batch_channel_capacity, self.adaptive_config.max_batch_size
1003 );
1004
1005 let adaptive_config = self.adaptive_config.clone();
1007 let source_id = self.base.id.clone();
1008 let dispatchers = self.base.dispatchers.clone();
1009
1010 let instance_id = self
1012 .base
1013 .context()
1014 .await
1015 .map(|c| c.instance_id)
1016 .unwrap_or_default();
1017
1018 info!("[{source_id}] Starting adaptive batcher task");
1019 let source_id_for_span = source_id.clone();
1020 let span = tracing::info_span!(
1021 "http_adaptive_batcher",
1022 instance_id = %instance_id,
1023 component_id = %source_id_for_span,
1024 component_type = "source"
1025 );
1026 tokio::spawn(
1027 async move {
1028 Self::run_adaptive_batcher(
1029 batch_rx,
1030 dispatchers,
1031 adaptive_config,
1032 source_id.clone(),
1033 )
1034 .await
1035 }
1036 .instrument(span),
1037 );
1038
1039 let webhook_state = if let Some(ref webhook_config) = self.config.webhooks {
1041 info!(
1042 "[{}] Webhook mode enabled with {} routes",
1043 self.base.id,
1044 webhook_config.routes.len()
1045 );
1046 Some(Arc::new(WebhookState {
1047 config: webhook_config.clone(),
1048 route_matcher: RouteMatcher::new(&webhook_config.routes),
1049 template_engine: TemplateEngine::new(),
1050 }))
1051 } else {
1052 info!("[{}] Standard mode enabled", self.base.id);
1053 None
1054 };
1055
1056 let state = HttpAppState {
1057 source_id: self.base.id.clone(),
1058 batch_tx,
1059 webhook_config: webhook_state,
1060 };
1061
1062 let app = if self.config.is_webhook_mode() {
1064 let router = Router::new()
1066 .route("/health", get(Self::health_check))
1067 .fallback(Self::handle_webhook)
1068 .with_state(state);
1069
1070 if let Some(ref webhooks) = self.config.webhooks {
1072 if let Some(ref cors_config) = webhooks.cors {
1073 if cors_config.enabled {
1074 info!("[{}] CORS enabled for webhook endpoints", self.base.id);
1075 router.layer(build_cors_layer(cors_config))
1076 } else {
1077 router
1078 }
1079 } else {
1080 router
1081 }
1082 } else {
1083 router
1084 }
1085 } else {
1086 Router::new()
1088 .route("/health", get(Self::health_check))
1089 .route(
1090 "/sources/:source_id/events",
1091 post(Self::handle_single_event),
1092 )
1093 .route(
1094 "/sources/:source_id/events/batch",
1095 post(Self::handle_batch_events),
1096 )
1097 .with_state(state)
1098 };
1099
1100 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
1102
1103 let host_clone = host.clone();
1104
1105 let (error_tx, error_rx) = tokio::sync::oneshot::channel();
1107 let source_id = self.base.id.clone();
1108 let source_id_for_span = source_id.clone();
1109 let span = tracing::info_span!(
1110 "http_source_server",
1111 instance_id = %instance_id,
1112 component_id = %source_id_for_span,
1113 component_type = "source"
1114 );
1115 let server_handle = tokio::spawn(
1116 async move {
1117 let addr = format!("{host}:{port}");
1118 info!("[{source_id}] Adaptive HTTP source attempting to bind to {addr}");
1119
1120 let listener = match tokio::net::TcpListener::bind(&addr).await {
1121 Ok(listener) => {
1122 info!("[{source_id}] Adaptive HTTP source successfully listening on {addr}");
1123 listener
1124 }
1125 Err(e) => {
1126 error!("[{source_id}] Failed to bind HTTP server to {addr}: {e}");
1127 let _ = error_tx.send(format!(
1128 "Failed to bind HTTP server to {addr}: {e}. Common causes: port already in use, insufficient permissions"
1129 ));
1130 return;
1131 }
1132 };
1133
1134 if let Err(e) = axum::serve(listener, app)
1135 .with_graceful_shutdown(async move {
1136 let _ = shutdown_rx.await;
1137 })
1138 .await
1139 {
1140 error!("[{source_id}] HTTP server error: {e}");
1141 }
1142 }
1143 .instrument(span),
1144 );
1145
1146 *self.base.task_handle.write().await = Some(server_handle);
1147 *self.base.shutdown_tx.write().await = Some(shutdown_tx);
1148
1149 match timeout(Duration::from_millis(500), error_rx).await {
1151 Ok(Ok(error_msg)) => {
1152 self.base.set_status(ComponentStatus::Error, None).await;
1153 return Err(anyhow::anyhow!("{error_msg}"));
1154 }
1155 _ => {
1156 self.base
1157 .set_status(
1158 ComponentStatus::Running,
1159 Some(format!(
1160 "Adaptive HTTP source running on {host_clone}:{port} with batch support"
1161 )),
1162 )
1163 .await;
1164 }
1165 }
1166
1167 Ok(())
1168 }
1169
1170 async fn stop(&self) -> Result<()> {
1171 info!("[{}] Stopping adaptive HTTP source", self.base.id);
1172
1173 self.base
1174 .set_status(
1175 ComponentStatus::Stopping,
1176 Some("Stopping adaptive HTTP source".to_string()),
1177 )
1178 .await;
1179
1180 if let Some(tx) = self.base.shutdown_tx.write().await.take() {
1181 let _ = tx.send(());
1182 }
1183
1184 if let Some(handle) = self.base.task_handle.write().await.take() {
1185 let _ = timeout(Duration::from_secs(5), handle).await;
1186 }
1187
1188 self.base
1189 .set_status(
1190 ComponentStatus::Stopped,
1191 Some("Adaptive HTTP source stopped".to_string()),
1192 )
1193 .await;
1194
1195 Ok(())
1196 }
1197
1198 async fn status(&self) -> ComponentStatus {
1199 self.base.get_status().await
1200 }
1201
1202 async fn subscribe(
1203 &self,
1204 settings: drasi_lib::config::SourceSubscriptionSettings,
1205 ) -> Result<SubscriptionResponse> {
1206 self.base.subscribe_with_bootstrap(&settings, "HTTP").await
1207 }
1208
1209 fn as_any(&self) -> &dyn std::any::Any {
1210 self
1211 }
1212
1213 async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
1214 self.base.initialize(context).await;
1215 }
1216
1217 async fn set_bootstrap_provider(
1218 &self,
1219 provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
1220 ) {
1221 self.base.set_bootstrap_provider(provider).await;
1222 }
1223}
1224
1225pub struct HttpSourceBuilder {
1244 id: String,
1245 host: String,
1246 port: u16,
1247 endpoint: Option<String>,
1248 timeout_ms: u64,
1249 adaptive_max_batch_size: Option<usize>,
1250 adaptive_min_batch_size: Option<usize>,
1251 adaptive_max_wait_ms: Option<u64>,
1252 adaptive_min_wait_ms: Option<u64>,
1253 adaptive_window_secs: Option<u64>,
1254 adaptive_enabled: Option<bool>,
1255 webhooks: Option<WebhookConfig>,
1256 dispatch_mode: Option<DispatchMode>,
1257 dispatch_buffer_capacity: Option<usize>,
1258 bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
1259 auto_start: bool,
1260}
1261
1262impl HttpSourceBuilder {
1263 pub fn new(id: impl Into<String>) -> Self {
1269 Self {
1270 id: id.into(),
1271 host: String::new(),
1272 port: 8080,
1273 endpoint: None,
1274 timeout_ms: 10000,
1275 adaptive_max_batch_size: None,
1276 adaptive_min_batch_size: None,
1277 adaptive_max_wait_ms: None,
1278 adaptive_min_wait_ms: None,
1279 adaptive_window_secs: None,
1280 adaptive_enabled: None,
1281 webhooks: None,
1282 dispatch_mode: None,
1283 dispatch_buffer_capacity: None,
1284 bootstrap_provider: None,
1285 auto_start: true,
1286 }
1287 }
1288
1289 pub fn with_host(mut self, host: impl Into<String>) -> Self {
1291 self.host = host.into();
1292 self
1293 }
1294
1295 pub fn with_port(mut self, port: u16) -> Self {
1297 self.port = port;
1298 self
1299 }
1300
1301 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
1303 self.endpoint = Some(endpoint.into());
1304 self
1305 }
1306
1307 pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
1309 self.timeout_ms = timeout_ms;
1310 self
1311 }
1312
1313 pub fn with_adaptive_max_batch_size(mut self, size: usize) -> Self {
1315 self.adaptive_max_batch_size = Some(size);
1316 self
1317 }
1318
1319 pub fn with_adaptive_min_batch_size(mut self, size: usize) -> Self {
1321 self.adaptive_min_batch_size = Some(size);
1322 self
1323 }
1324
1325 pub fn with_adaptive_max_wait_ms(mut self, wait_ms: u64) -> Self {
1327 self.adaptive_max_wait_ms = Some(wait_ms);
1328 self
1329 }
1330
1331 pub fn with_adaptive_min_wait_ms(mut self, wait_ms: u64) -> Self {
1333 self.adaptive_min_wait_ms = Some(wait_ms);
1334 self
1335 }
1336
1337 pub fn with_adaptive_window_secs(mut self, secs: u64) -> Self {
1339 self.adaptive_window_secs = Some(secs);
1340 self
1341 }
1342
1343 pub fn with_adaptive_enabled(mut self, enabled: bool) -> Self {
1345 self.adaptive_enabled = Some(enabled);
1346 self
1347 }
1348
1349 pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1351 self.dispatch_mode = Some(mode);
1352 self
1353 }
1354
1355 pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1357 self.dispatch_buffer_capacity = Some(capacity);
1358 self
1359 }
1360
1361 pub fn with_bootstrap_provider(
1363 mut self,
1364 provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1365 ) -> Self {
1366 self.bootstrap_provider = Some(Box::new(provider));
1367 self
1368 }
1369
1370 pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1375 self.auto_start = auto_start;
1376 self
1377 }
1378
1379 pub fn with_webhooks(mut self, webhooks: WebhookConfig) -> Self {
1384 self.webhooks = Some(webhooks);
1385 self
1386 }
1387
1388 pub fn with_config(mut self, config: HttpSourceConfig) -> Self {
1390 self.host = config.host;
1391 self.port = config.port;
1392 self.endpoint = config.endpoint;
1393 self.timeout_ms = config.timeout_ms;
1394 self.adaptive_max_batch_size = config.adaptive_max_batch_size;
1395 self.adaptive_min_batch_size = config.adaptive_min_batch_size;
1396 self.adaptive_max_wait_ms = config.adaptive_max_wait_ms;
1397 self.adaptive_min_wait_ms = config.adaptive_min_wait_ms;
1398 self.adaptive_window_secs = config.adaptive_window_secs;
1399 self.adaptive_enabled = config.adaptive_enabled;
1400 self.webhooks = config.webhooks;
1401 self
1402 }
1403
1404 pub fn build(self) -> Result<HttpSource> {
1410 let config = HttpSourceConfig {
1411 host: self.host,
1412 port: self.port,
1413 endpoint: self.endpoint,
1414 timeout_ms: self.timeout_ms,
1415 adaptive_max_batch_size: self.adaptive_max_batch_size,
1416 adaptive_min_batch_size: self.adaptive_min_batch_size,
1417 adaptive_max_wait_ms: self.adaptive_max_wait_ms,
1418 adaptive_min_wait_ms: self.adaptive_min_wait_ms,
1419 adaptive_window_secs: self.adaptive_window_secs,
1420 adaptive_enabled: self.adaptive_enabled,
1421 webhooks: self.webhooks,
1422 };
1423
1424 let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1426 if let Some(mode) = self.dispatch_mode {
1427 params = params.with_dispatch_mode(mode);
1428 }
1429 if let Some(capacity) = self.dispatch_buffer_capacity {
1430 params = params.with_dispatch_buffer_capacity(capacity);
1431 }
1432 if let Some(provider) = self.bootstrap_provider {
1433 params = params.with_bootstrap_provider(provider);
1434 }
1435
1436 let mut adaptive_config = AdaptiveBatchConfig::default();
1438 if let Some(max_batch) = config.adaptive_max_batch_size {
1439 adaptive_config.max_batch_size = max_batch;
1440 }
1441 if let Some(min_batch) = config.adaptive_min_batch_size {
1442 adaptive_config.min_batch_size = min_batch;
1443 }
1444 if let Some(max_wait_ms) = config.adaptive_max_wait_ms {
1445 adaptive_config.max_wait_time = Duration::from_millis(max_wait_ms);
1446 }
1447 if let Some(min_wait_ms) = config.adaptive_min_wait_ms {
1448 adaptive_config.min_wait_time = Duration::from_millis(min_wait_ms);
1449 }
1450 if let Some(window_secs) = config.adaptive_window_secs {
1451 adaptive_config.throughput_window = Duration::from_secs(window_secs);
1452 }
1453 if let Some(enabled) = config.adaptive_enabled {
1454 adaptive_config.adaptive_enabled = enabled;
1455 }
1456
1457 Ok(HttpSource {
1458 base: SourceBase::new(params)?,
1459 config,
1460 adaptive_config,
1461 })
1462 }
1463}
1464
1465impl HttpSource {
1466 pub fn builder(id: impl Into<String>) -> HttpSourceBuilder {
1484 HttpSourceBuilder::new(id)
1485 }
1486}
1487
1488fn handle_error(
1490 behavior: &ErrorBehavior,
1491 source_id: &str,
1492 status: StatusCode,
1493 message: &str,
1494 detail: Option<&str>,
1495) -> (StatusCode, Json<EventResponse>) {
1496 match behavior {
1497 ErrorBehavior::Reject => {
1498 debug!("[{source_id}] Rejecting request: {message}");
1499 (
1500 status,
1501 Json(EventResponse {
1502 success: false,
1503 message: message.to_string(),
1504 error: detail.map(String::from),
1505 }),
1506 )
1507 }
1508 ErrorBehavior::AcceptAndLog => {
1509 warn!("[{source_id}] Accepting with error (logged): {message}");
1510 (
1511 StatusCode::OK,
1512 Json(EventResponse {
1513 success: true,
1514 message: format!("Accepted with warning: {message}"),
1515 error: detail.map(String::from),
1516 }),
1517 )
1518 }
1519 ErrorBehavior::AcceptAndSkip => {
1520 trace!("[{source_id}] Accepting silently: {message}");
1521 (
1522 StatusCode::OK,
1523 Json(EventResponse {
1524 success: true,
1525 message: "Accepted".to_string(),
1526 error: None,
1527 }),
1528 )
1529 }
1530 }
1531}
1532
1533fn parse_query_string(query: Option<&str>) -> HashMap<String, String> {
1535 query
1536 .map(|q| {
1537 q.split('&')
1538 .filter_map(|pair| {
1539 let mut parts = pair.splitn(2, '=');
1540 let key = parts.next()?;
1541 let value = parts.next().unwrap_or("");
1542 Some((urlencoding_decode(key), urlencoding_decode(value)))
1543 })
1544 .collect()
1545 })
1546 .unwrap_or_default()
1547}
1548
1549fn urlencoding_decode(s: &str) -> String {
1551 let mut decoded: Vec<u8> = Vec::with_capacity(s.len());
1553 let mut chars = s.chars();
1554
1555 while let Some(c) = chars.next() {
1556 if c == '%' {
1557 let mut hex = String::new();
1558 if let Some(c1) = chars.next() {
1559 hex.push(c1);
1560 }
1561 if let Some(c2) = chars.next() {
1562 hex.push(c2);
1563 }
1564
1565 if hex.len() == 2 {
1566 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
1567 decoded.push(byte);
1568 continue;
1569 }
1570 }
1571
1572 decoded.extend_from_slice(b"%");
1574 decoded.extend_from_slice(hex.as_bytes());
1575 } else if c == '+' {
1576 decoded.push(b' ');
1577 } else {
1578 let mut buf = [0u8; 4];
1580 let encoded = c.encode_utf8(&mut buf);
1581 decoded.extend_from_slice(encoded.as_bytes());
1582 }
1583 }
1584
1585 String::from_utf8_lossy(&decoded).into_owned()
1587}
1588
1589fn build_cors_layer(cors_config: &CorsConfig) -> CorsLayer {
1591 let mut cors = CorsLayer::new();
1592
1593 if cors_config.allow_origins.len() == 1 && cors_config.allow_origins[0] == "*" {
1595 cors = cors.allow_origin(Any);
1596 } else {
1597 let origins: Vec<_> = cors_config
1598 .allow_origins
1599 .iter()
1600 .filter_map(|o| o.parse().ok())
1601 .collect();
1602 cors = cors.allow_origin(origins);
1603 }
1604
1605 let methods: Vec<Method> = cors_config
1607 .allow_methods
1608 .iter()
1609 .filter_map(|m| m.parse().ok())
1610 .collect();
1611 cors = cors.allow_methods(methods);
1612
1613 if cors_config.allow_headers.len() == 1 && cors_config.allow_headers[0] == "*" {
1615 cors = cors.allow_headers(Any);
1616 } else {
1617 let headers: Vec<header::HeaderName> = cors_config
1618 .allow_headers
1619 .iter()
1620 .filter_map(|h| h.parse().ok())
1621 .collect();
1622 cors = cors.allow_headers(headers);
1623 }
1624
1625 if !cors_config.expose_headers.is_empty() {
1627 let exposed: Vec<header::HeaderName> = cors_config
1628 .expose_headers
1629 .iter()
1630 .filter_map(|h| h.parse().ok())
1631 .collect();
1632 cors = cors.expose_headers(exposed);
1633 }
1634
1635 if cors_config.allow_credentials {
1637 cors = cors.allow_credentials(true);
1638 }
1639
1640 cors = cors.max_age(Duration::from_secs(cors_config.max_age));
1642
1643 cors
1644}
1645
1646#[cfg(test)]
1647mod tests {
1648 use super::*;
1649
1650 mod construction {
1651 use super::*;
1652
1653 #[test]
1654 fn test_builder_with_valid_config() {
1655 let source = HttpSourceBuilder::new("test-source")
1656 .with_host("localhost")
1657 .with_port(8080)
1658 .build();
1659 assert!(source.is_ok());
1660 }
1661
1662 #[test]
1663 fn test_builder_with_custom_config() {
1664 let source = HttpSourceBuilder::new("http-source")
1665 .with_host("0.0.0.0")
1666 .with_port(9000)
1667 .with_endpoint("/events")
1668 .build()
1669 .unwrap();
1670 assert_eq!(source.id(), "http-source");
1671 }
1672
1673 #[test]
1674 fn test_with_dispatch_creates_source() {
1675 let config = HttpSourceConfig {
1676 host: "localhost".to_string(),
1677 port: 8080,
1678 endpoint: None,
1679 timeout_ms: 10000,
1680 adaptive_max_batch_size: None,
1681 adaptive_min_batch_size: None,
1682 adaptive_max_wait_ms: None,
1683 adaptive_min_wait_ms: None,
1684 adaptive_window_secs: None,
1685 adaptive_enabled: None,
1686 webhooks: None,
1687 };
1688 let source = HttpSource::with_dispatch(
1689 "dispatch-source",
1690 config,
1691 Some(DispatchMode::Channel),
1692 Some(1000),
1693 );
1694 assert!(source.is_ok());
1695 assert_eq!(source.unwrap().id(), "dispatch-source");
1696 }
1697 }
1698
1699 mod properties {
1700 use super::*;
1701
1702 #[test]
1703 fn test_id_returns_correct_value() {
1704 let source = HttpSourceBuilder::new("my-http-source")
1705 .with_host("localhost")
1706 .build()
1707 .unwrap();
1708 assert_eq!(source.id(), "my-http-source");
1709 }
1710
1711 #[test]
1712 fn test_type_name_returns_http() {
1713 let source = HttpSourceBuilder::new("test")
1714 .with_host("localhost")
1715 .build()
1716 .unwrap();
1717 assert_eq!(source.type_name(), "http");
1718 }
1719
1720 #[test]
1721 fn test_properties_contains_host_and_port() {
1722 let source = HttpSourceBuilder::new("test")
1723 .with_host("192.168.1.1")
1724 .with_port(9000)
1725 .build()
1726 .unwrap();
1727 let props = source.properties();
1728
1729 assert_eq!(
1730 props.get("host"),
1731 Some(&serde_json::Value::String("192.168.1.1".to_string()))
1732 );
1733 assert_eq!(
1734 props.get("port"),
1735 Some(&serde_json::Value::Number(9000.into()))
1736 );
1737 }
1738
1739 #[test]
1740 fn test_properties_includes_endpoint_when_set() {
1741 let source = HttpSourceBuilder::new("test")
1742 .with_host("localhost")
1743 .with_endpoint("/api/v1")
1744 .build()
1745 .unwrap();
1746 let props = source.properties();
1747
1748 assert_eq!(
1749 props.get("endpoint"),
1750 Some(&serde_json::Value::String("/api/v1".to_string()))
1751 );
1752 }
1753
1754 #[test]
1755 fn test_properties_excludes_endpoint_when_none() {
1756 let source = HttpSourceBuilder::new("test")
1757 .with_host("localhost")
1758 .build()
1759 .unwrap();
1760 let props = source.properties();
1761
1762 assert!(!props.contains_key("endpoint"));
1763 }
1764
1765 #[test]
1766 fn test_describe_schema_uses_webhook_mappings() {
1767 let source = HttpSourceBuilder::new("test")
1768 .with_host("localhost")
1769 .with_webhooks(WebhookConfig {
1770 error_behavior: ErrorBehavior::AcceptAndLog,
1771 cors: None,
1772 routes: vec![crate::config::WebhookRoute {
1773 path: "/events".to_string(),
1774 methods: vec![crate::config::HttpMethod::Post],
1775 auth: None,
1776 error_behavior: None,
1777 mappings: vec![crate::config::WebhookMapping {
1778 when: None,
1779 operation: Some(crate::config::OperationType::Insert),
1780 operation_from: None,
1781 operation_map: None,
1782 element_type: crate::config::ElementType::Node,
1783 effective_from: None,
1784 template: crate::config::ElementTemplate {
1785 id: "{{payload.id}}".to_string(),
1786 labels: vec!["Order".to_string()],
1787 properties: Some(serde_json::json!({
1788 "total": "{{payload.total}}",
1789 "status": "{{payload.status}}"
1790 })),
1791 from: None,
1792 to: None,
1793 },
1794 }],
1795 }],
1796 })
1797 .build()
1798 .unwrap();
1799
1800 let schema = source
1801 .describe_schema()
1802 .expect("webhook-configured HTTP source should expose schema");
1803
1804 assert_eq!(schema.nodes.len(), 1);
1805 let node = &schema.nodes[0];
1806 assert_eq!(node.label, "Order");
1807 assert!(node
1808 .properties
1809 .iter()
1810 .any(|property| property.name == "total"));
1811 assert!(node
1812 .properties
1813 .iter()
1814 .any(|property| property.name == "status"));
1815 }
1816
1817 #[test]
1818 fn test_describe_schema_includes_relation_mappings() {
1819 let source = HttpSourceBuilder::new("test")
1820 .with_host("localhost")
1821 .with_webhooks(WebhookConfig {
1822 error_behavior: ErrorBehavior::AcceptAndLog,
1823 cors: None,
1824 routes: vec![crate::config::WebhookRoute {
1825 path: "/events".to_string(),
1826 methods: vec![crate::config::HttpMethod::Post],
1827 auth: None,
1828 error_behavior: None,
1829 mappings: vec![crate::config::WebhookMapping {
1830 when: None,
1831 operation: Some(crate::config::OperationType::Insert),
1832 operation_from: None,
1833 operation_map: None,
1834 element_type: crate::config::ElementType::Relation,
1835 effective_from: None,
1836 template: crate::config::ElementTemplate {
1837 id: "{{payload.id}}".to_string(),
1838 labels: vec!["PLACED_BY".to_string()],
1839 properties: Some(serde_json::json!({
1840 "placed_at": "{{payload.timestamp}}"
1841 })),
1842 from: None,
1843 to: None,
1844 },
1845 }],
1846 }],
1847 })
1848 .build()
1849 .unwrap();
1850
1851 let schema = source
1852 .describe_schema()
1853 .expect("webhook-configured HTTP source should expose schema for relations");
1854
1855 assert_eq!(schema.relations.len(), 1);
1856 let relation = &schema.relations[0];
1857 assert_eq!(relation.label, "PLACED_BY");
1858 assert!(relation
1859 .properties
1860 .iter()
1861 .any(|property| property.name == "placed_at"));
1862 assert_eq!(relation.from, None);
1864 assert_eq!(relation.to, None);
1865 }
1866 }
1867
1868 mod lifecycle {
1869 use super::*;
1870
1871 #[tokio::test]
1872 async fn test_initial_status_is_stopped() {
1873 let source = HttpSourceBuilder::new("test")
1874 .with_host("localhost")
1875 .build()
1876 .unwrap();
1877 assert_eq!(source.status().await, ComponentStatus::Stopped);
1878 }
1879 }
1880
1881 mod builder {
1882 use super::*;
1883
1884 #[test]
1885 fn test_http_builder_defaults() {
1886 let source = HttpSourceBuilder::new("test").build().unwrap();
1887 assert_eq!(source.config.port, 8080);
1888 assert_eq!(source.config.timeout_ms, 10000);
1889 assert_eq!(source.config.endpoint, None);
1890 }
1891
1892 #[test]
1893 fn test_http_builder_custom_values() {
1894 let source = HttpSourceBuilder::new("test")
1895 .with_host("api.example.com")
1896 .with_port(9000)
1897 .with_endpoint("/webhook")
1898 .with_timeout_ms(5000)
1899 .build()
1900 .unwrap();
1901
1902 assert_eq!(source.config.host, "api.example.com");
1903 assert_eq!(source.config.port, 9000);
1904 assert_eq!(source.config.endpoint, Some("/webhook".to_string()));
1905 assert_eq!(source.config.timeout_ms, 5000);
1906 }
1907
1908 #[test]
1909 fn test_http_builder_adaptive_batching() {
1910 let source = HttpSourceBuilder::new("test")
1911 .with_host("localhost")
1912 .with_adaptive_max_batch_size(1000)
1913 .with_adaptive_min_batch_size(10)
1914 .with_adaptive_max_wait_ms(500)
1915 .with_adaptive_min_wait_ms(50)
1916 .with_adaptive_window_secs(60)
1917 .with_adaptive_enabled(true)
1918 .build()
1919 .unwrap();
1920
1921 assert_eq!(source.config.adaptive_max_batch_size, Some(1000));
1922 assert_eq!(source.config.adaptive_min_batch_size, Some(10));
1923 assert_eq!(source.config.adaptive_max_wait_ms, Some(500));
1924 assert_eq!(source.config.adaptive_min_wait_ms, Some(50));
1925 assert_eq!(source.config.adaptive_window_secs, Some(60));
1926 assert_eq!(source.config.adaptive_enabled, Some(true));
1927 }
1928
1929 #[test]
1930 fn test_builder_id() {
1931 let source = HttpSource::builder("my-http-source")
1932 .with_host("localhost")
1933 .build()
1934 .unwrap();
1935
1936 assert_eq!(source.base.id, "my-http-source");
1937 }
1938 }
1939
1940 mod event_conversion {
1941 use super::*;
1942
1943 #[test]
1944 fn test_convert_node_insert() {
1945 let mut props = serde_json::Map::new();
1946 props.insert(
1947 "name".to_string(),
1948 serde_json::Value::String("Alice".to_string()),
1949 );
1950 props.insert("age".to_string(), serde_json::Value::Number(30.into()));
1951
1952 let http_change = HttpSourceChange::Insert {
1953 element: HttpElement::Node {
1954 id: "user-1".to_string(),
1955 labels: vec!["User".to_string()],
1956 properties: props,
1957 },
1958 timestamp: Some(1234567890000000000),
1959 };
1960
1961 let result = convert_http_to_source_change(&http_change, "test-source");
1962 assert!(result.is_ok());
1963
1964 match result.unwrap() {
1965 drasi_core::models::SourceChange::Insert { element } => match element {
1966 drasi_core::models::Element::Node {
1967 metadata,
1968 properties,
1969 } => {
1970 assert_eq!(metadata.reference.element_id.as_ref(), "user-1");
1971 assert_eq!(metadata.labels.len(), 1);
1972 assert_eq!(metadata.effective_from, 1234567890000);
1973 assert!(properties.get("name").is_some());
1974 assert!(properties.get("age").is_some());
1975 }
1976 _ => panic!("Expected Node element"),
1977 },
1978 _ => panic!("Expected Insert operation"),
1979 }
1980 }
1981
1982 #[test]
1983 fn test_convert_relation_insert() {
1984 let http_change = HttpSourceChange::Insert {
1985 element: HttpElement::Relation {
1986 id: "follows-1".to_string(),
1987 labels: vec!["FOLLOWS".to_string()],
1988 from: "user-1".to_string(),
1989 to: "user-2".to_string(),
1990 properties: serde_json::Map::new(),
1991 },
1992 timestamp: None,
1993 };
1994
1995 let result = convert_http_to_source_change(&http_change, "test-source");
1996 assert!(result.is_ok());
1997
1998 match result.unwrap() {
1999 drasi_core::models::SourceChange::Insert { element } => match element {
2000 drasi_core::models::Element::Relation {
2001 metadata,
2002 out_node,
2003 in_node,
2004 ..
2005 } => {
2006 assert_eq!(metadata.reference.element_id.as_ref(), "follows-1");
2007 assert_eq!(in_node.element_id.as_ref(), "user-1");
2008 assert_eq!(out_node.element_id.as_ref(), "user-2");
2009 }
2010 _ => panic!("Expected Relation element"),
2011 },
2012 _ => panic!("Expected Insert operation"),
2013 }
2014 }
2015
2016 #[test]
2017 fn test_convert_delete() {
2018 let http_change = HttpSourceChange::Delete {
2019 id: "user-1".to_string(),
2020 labels: Some(vec!["User".to_string()]),
2021 timestamp: Some(9999999999),
2022 };
2023
2024 let result = convert_http_to_source_change(&http_change, "test-source");
2025 assert!(result.is_ok());
2026
2027 match result.unwrap() {
2028 drasi_core::models::SourceChange::Delete { metadata } => {
2029 assert_eq!(metadata.reference.element_id.as_ref(), "user-1");
2030 assert_eq!(metadata.labels.len(), 1);
2031 }
2032 _ => panic!("Expected Delete operation"),
2033 }
2034 }
2035
2036 #[test]
2037 fn test_convert_update() {
2038 let http_change = HttpSourceChange::Update {
2039 element: HttpElement::Node {
2040 id: "user-1".to_string(),
2041 labels: vec!["User".to_string()],
2042 properties: serde_json::Map::new(),
2043 },
2044 timestamp: None,
2045 };
2046
2047 let result = convert_http_to_source_change(&http_change, "test-source");
2048 assert!(result.is_ok());
2049
2050 match result.unwrap() {
2051 drasi_core::models::SourceChange::Update { .. } => {
2052 }
2054 _ => panic!("Expected Update operation"),
2055 }
2056 }
2057 }
2058
2059 mod adaptive_config {
2060 use super::*;
2061
2062 #[test]
2063 fn test_adaptive_config_from_http_config() {
2064 let source = HttpSourceBuilder::new("test")
2065 .with_host("localhost")
2066 .with_adaptive_max_batch_size(500)
2067 .with_adaptive_enabled(true)
2068 .build()
2069 .unwrap();
2070
2071 assert_eq!(source.adaptive_config.max_batch_size, 500);
2073 assert!(source.adaptive_config.adaptive_enabled);
2074 }
2075
2076 #[test]
2077 fn test_adaptive_config_uses_defaults_when_not_specified() {
2078 let source = HttpSourceBuilder::new("test")
2079 .with_host("localhost")
2080 .build()
2081 .unwrap();
2082
2083 let default_config = AdaptiveBatchConfig::default();
2085 assert_eq!(
2086 source.adaptive_config.max_batch_size,
2087 default_config.max_batch_size
2088 );
2089 assert_eq!(
2090 source.adaptive_config.min_batch_size,
2091 default_config.min_batch_size
2092 );
2093 }
2094 }
2095}
2096
2097#[cfg(test)]
2098mod fallback_tests {
2099 use super::*;
2100 use drasi_lib::sources::Source;
2101
2102 #[test]
2103 fn test_builder_fallback_produces_camel_case() {
2104 let source = HttpSourceBuilder::new("http-fallback")
2105 .with_host("0.0.0.0")
2106 .with_port(9090)
2107 .with_endpoint("/ingest")
2108 .with_timeout_ms(5000)
2109 .with_adaptive_max_batch_size(500)
2110 .with_adaptive_min_batch_size(10)
2111 .with_adaptive_max_wait_ms(2000)
2112 .with_adaptive_min_wait_ms(100)
2113 .build()
2114 .unwrap();
2115
2116 let props = source.properties();
2117
2118 assert!(
2120 props.contains_key("timeoutMs"),
2121 "expected camelCase 'timeoutMs', got keys: {:?}",
2122 props.keys().collect::<Vec<_>>()
2123 );
2124 assert!(
2125 props.contains_key("adaptiveMaxBatchSize"),
2126 "expected camelCase 'adaptiveMaxBatchSize'"
2127 );
2128 assert!(
2129 props.contains_key("adaptiveMinBatchSize"),
2130 "expected camelCase 'adaptiveMinBatchSize'"
2131 );
2132 assert!(
2133 props.contains_key("adaptiveMaxWaitMs"),
2134 "expected camelCase 'adaptiveMaxWaitMs'"
2135 );
2136 assert!(
2137 props.contains_key("adaptiveMinWaitMs"),
2138 "expected camelCase 'adaptiveMinWaitMs'"
2139 );
2140
2141 assert!(
2143 !props.contains_key("timeout_ms"),
2144 "should not have snake_case 'timeout_ms'"
2145 );
2146 assert!(
2147 !props.contains_key("adaptive_max_batch_size"),
2148 "should not have snake_case 'adaptive_max_batch_size'"
2149 );
2150
2151 assert_eq!(props.get("host").and_then(|v| v.as_str()), Some("0.0.0.0"));
2153 assert_eq!(props.get("port").and_then(|v| v.as_u64()), Some(9090));
2154 assert_eq!(
2155 props.get("endpoint").and_then(|v| v.as_str()),
2156 Some("/ingest")
2157 );
2158 assert_eq!(props.get("timeoutMs").and_then(|v| v.as_u64()), Some(5000));
2159 assert_eq!(
2160 props.get("adaptiveMaxBatchSize").and_then(|v| v.as_u64()),
2161 Some(500)
2162 );
2163 }
2164}
2165
2166#[cfg(feature = "dynamic-plugin")]
2170drasi_plugin_sdk::export_plugin!(
2171 plugin_id = "http-source",
2172 core_version = env!("CARGO_PKG_VERSION"),
2173 lib_version = env!("CARGO_PKG_VERSION"),
2174 plugin_version = env!("CARGO_PKG_VERSION"),
2175 source_descriptors = [descriptor::HttpSourceDescriptor],
2176 reaction_descriptors = [],
2177 bootstrap_descriptors = [],
2178);