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