Skip to main content

drasi_source_http/
lib.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![allow(unexpected_cfgs)]
16
17//! HTTP Source Plugin for Drasi
18//!
19//! This plugin exposes HTTP endpoints for receiving data change events. It supports
20//! two mutually exclusive modes:
21//!
22//! - **Standard Mode**: Fixed-format `HttpSourceChange` endpoints with adaptive batching
23//! - **Webhook Mode**: Configurable routes with template-based payload transformation
24//!
25//! # Standard Mode
26//!
27//! When no `webhooks` configuration is present, the source operates in standard mode
28//! with the following endpoints:
29//!
30//! - **`POST /sources/{source_id}/events`** - Submit a single event
31//! - **`POST /sources/{source_id}/events/batch`** - Submit multiple events
32//! - **`GET /health`** - Health check endpoint
33//!
34//! ## Data Format
35//!
36//! Events are submitted as JSON using the `HttpSourceChange` format:
37//!
38//! ### Insert Operation
39//!
40//! ```json
41//! {
42//!     "operation": "insert",
43//!     "element": {
44//!         "type": "node",
45//!         "id": "user-123",
46//!         "labels": ["User"],
47//!         "properties": {
48//!             "name": "Alice",
49//!             "email": "alice@example.com"
50//!         }
51//!     },
52//!     "timestamp": 1699900000000000000
53//! }
54//! ```
55//!
56//! ### Update Operation
57//!
58//! ```json
59//! {
60//!     "operation": "update",
61//!     "element": {
62//!         "type": "node",
63//!         "id": "user-123",
64//!         "labels": ["User"],
65//!         "properties": {
66//!             "name": "Alice Updated"
67//!         }
68//!     }
69//! }
70//! ```
71//!
72//! ### Delete Operation
73//!
74//! ```json
75//! {
76//!     "operation": "delete",
77//!     "id": "user-123",
78//!     "labels": ["User"]
79//! }
80//! ```
81//!
82//! # Webhook Mode
83//!
84//! When a `webhooks` section is present in the configuration, the source operates
85//! in webhook mode. This enables:
86//!
87//! - Custom routes with path parameters (e.g., `/github/events`, `/users/:id/hooks`)
88//! - Multiple HTTP methods per route (POST, PUT, PATCH, DELETE, GET)
89//! - Handlebars template-based payload transformation
90//! - HMAC signature verification (GitHub, Shopify style)
91//! - Bearer token authentication
92//! - Support for JSON, XML, YAML, and plain text payloads
93//!
94//! ## Webhook Configuration Example
95//!
96//! ```yaml
97//! webhooks:
98//!   error_behavior: accept_and_log
99//!   routes:
100//!     - path: "/github/events"
101//!       methods: ["POST"]
102//!       auth:
103//!         signature:
104//!           type: hmac-sha256
105//!           secret_env: GITHUB_WEBHOOK_SECRET
106//!           header: X-Hub-Signature-256
107//!           prefix: "sha256="
108//!       error_behavior: reject
109//!       mappings:
110//!         - when:
111//!             header: X-GitHub-Event
112//!             equals: push
113//!           operation: insert
114//!           element_type: node
115//!           effective_from: "{{payload.head_commit.timestamp}}"
116//!           template:
117//!             id: "commit-{{payload.head_commit.id}}"
118//!             labels: ["Commit"]
119//!             properties:
120//!               message: "{{payload.head_commit.message}}"
121//!               author: "{{payload.head_commit.author.name}}"
122//! ```
123//!
124//! ## Relation Element
125//!
126//! ```json
127//! {
128//!     "operation": "insert",
129//!     "element": {
130//!         "type": "relation",
131//!         "id": "follows-1",
132//!         "labels": ["FOLLOWS"],
133//!         "from": "user-123",
134//!         "to": "user-456",
135//!         "properties": {}
136//!     }
137//! }
138//! ```
139//!
140//! # Batch Submission
141//!
142//! ```json
143//! {
144//!     "events": [
145//!         { "operation": "insert", ... },
146//!         { "operation": "update", ... }
147//!     ]
148//! }
149//! ```
150//!
151//! # Adaptive Batching
152//!
153//! The HTTP source includes adaptive batching to optimize throughput. Events are
154//! buffered and dispatched in batches, with batch size and timing adjusted based
155//! on throughput patterns.
156//!
157//! | Parameter | Default | Description |
158//! |-----------|---------|-------------|
159//! | `adaptive_enabled` | `true` | Enable/disable adaptive batching |
160//! | `adaptive_max_batch_size` | `1000` | Maximum events per batch |
161//! | `adaptive_min_batch_size` | `1` | Minimum events per batch |
162//! | `adaptive_max_wait_ms` | `100` | Maximum wait time before dispatching |
163//! | `adaptive_min_wait_ms` | `10` | Minimum wait time between batches |
164//!
165//! # Configuration
166//!
167//! | Field | Type | Default | Description |
168//! |-------|------|---------|-------------|
169//! | `host` | string | *required* | Host address to bind to |
170//! | `port` | u16 | `8080` | Port to listen on |
171//! | `endpoint` | string | None | Optional custom path prefix |
172//! | `timeout_ms` | u64 | `10000` | Request timeout in milliseconds |
173//!
174//! # Example Configuration (YAML)
175//!
176//! ```yaml
177//! source_type: http
178//! properties:
179//!   host: "0.0.0.0"
180//!   port: 8080
181//!   adaptive_enabled: true
182//!   adaptive_max_batch_size: 500
183//! ```
184//!
185//! # Usage Examples
186//!
187//! ## Rust
188//!
189//! ```rust,no_run
190//! use drasi_source_http::{HttpSource, HttpSourceBuilder};
191//! use std::sync::Arc;
192//!
193//! # async fn example() -> anyhow::Result<()> {
194//! let source = HttpSource::builder("http-source")
195//!     .with_host("0.0.0.0")
196//!     .with_port(8080)
197//!     .with_adaptive_enabled(true)
198//!     .build()?;
199//! # Ok(())
200//! # }
201//! ```
202//!
203//! ## curl (Single Event)
204//!
205//! ```bash
206//! curl -X POST http://localhost:8080/sources/my-source/events \
207//!   -H "Content-Type: application/json" \
208//!   -d '{"operation":"insert","element":{"type":"node","id":"1","labels":["Test"],"properties":{}}}'
209//! ```
210//!
211//! ## curl (Batch)
212//!
213//! ```bash
214//! curl -X POST http://localhost:8080/sources/my-source/events/batch \
215//!   -H "Content-Type: application/json" \
216//!   -d '{"events":[...]}'
217//! ```
218
219pub mod config;
220pub mod descriptor;
221pub use config::HttpSourceConfig;
222
223mod adaptive_batcher;
224mod models;
225mod time;
226
227// Webhook support modules
228pub mod auth;
229pub mod content_parser;
230pub mod route_matcher;
231pub mod template_engine;
232
233// Export HTTP source models and conversion
234pub use models::{convert_http_to_source_change, HttpElement, HttpSourceChange};
235
236use anyhow::Result;
237use async_trait::async_trait;
238use axum::{
239    body::Bytes,
240    extract::{Path, State},
241    http::{header, Method, StatusCode},
242    response::IntoResponse,
243    routing::{delete, get, post, put},
244    Json, Router,
245};
246use log::{debug, error, info, trace, warn};
247use serde::{Deserialize, Serialize};
248use std::collections::HashMap;
249use std::sync::Arc;
250use std::time::Duration;
251use tokio::sync::mpsc;
252use tokio::time::timeout;
253use tower_http::cors::{Any, CorsLayer};
254
255use drasi_lib::channels::{ComponentType, *};
256use drasi_lib::schema::{NodeSchema, PropertySchema, RelationSchema, SourceSchema};
257use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
258use drasi_lib::wal::{WalError, WalProvider};
259use drasi_lib::Source;
260use tracing::Instrument;
261
262use crate::adaptive_batcher::{AdaptiveBatchConfig, AdaptiveBatcher};
263use crate::auth::{verify_auth, AuthResult};
264use crate::config::{CorsConfig, ErrorBehavior, WebhookConfig};
265use crate::content_parser::{parse_content, ContentType};
266use crate::route_matcher::{convert_method, find_matching_mappings, headers_to_map, RouteMatcher};
267use crate::template_engine::{TemplateContext, TemplateEngine};
268
269/// Response for event submission
270#[derive(Debug, Serialize, Deserialize)]
271pub struct EventResponse {
272    pub success: bool,
273    pub message: String,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub error: Option<String>,
276}
277
278/// HTTP source with configurable adaptive batching.
279///
280/// This source exposes HTTP endpoints for receiving data change events.
281/// It supports both single-event and batch submission modes, with adaptive
282/// batching for optimized throughput.
283///
284/// # Fields
285///
286/// - `base`: Common source functionality (dispatchers, status, lifecycle)
287/// - `config`: HTTP-specific configuration (host, port, timeout)
288/// - `adaptive_config`: Adaptive batching settings for throughput optimization
289pub struct HttpSource {
290    /// Base source implementation providing common functionality
291    base: SourceBase,
292    /// HTTP source configuration
293    config: HttpSourceConfig,
294    /// Adaptive batching configuration for throughput optimization
295    adaptive_config: AdaptiveBatchConfig,
296    /// WAL provider for durable event persistence (if durability is enabled)
297    wal: tokio::sync::RwLock<Option<Arc<dyn WalProvider>>>,
298    /// Handle to the WAL pruning background task (if running)
299    prune_task: tokio::sync::RwLock<Option<tokio::task::JoinHandle<()>>>,
300}
301
302/// Batch event request that can accept multiple events
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct BatchEventRequest {
305    pub events: Vec<HttpSourceChange>,
306}
307
308/// HTTP source app state with batching channel.
309///
310/// Shared state passed to Axum route handlers.
311#[derive(Clone)]
312struct HttpAppState {
313    /// The source ID for validation against incoming requests
314    source_id: String,
315    /// Channel for sending events to the adaptive batcher
316    batch_tx: mpsc::Sender<SourceChangeEvent>,
317    /// Webhook configuration (if in webhook mode)
318    webhook_config: Option<Arc<WebhookState>>,
319    /// WAL provider for durable persistence (if durability is enabled)
320    wal: Option<Arc<dyn WalProvider>>,
321}
322
323/// State for webhook mode processing
324struct WebhookState {
325    /// Webhook configuration
326    config: WebhookConfig,
327    /// Route matcher for incoming requests
328    route_matcher: RouteMatcher,
329    /// Template engine for payload transformation
330    template_engine: TemplateEngine,
331}
332
333fn extract_property_schemas(properties: Option<&serde_json::Value>) -> Vec<PropertySchema> {
334    match properties {
335        Some(serde_json::Value::Object(map)) => map
336            .keys()
337            .map(|key| PropertySchema::new(key.clone()))
338            .collect(),
339        _ => Vec::new(),
340    }
341}
342
343fn derive_schema_from_webhooks(webhooks: &WebhookConfig) -> Option<SourceSchema> {
344    let mut node_map: HashMap<String, Vec<PropertySchema>> = HashMap::new();
345    let mut relation_map: HashMap<String, Vec<PropertySchema>> = HashMap::new();
346
347    for route in &webhooks.routes {
348        for mapping in &route.mappings {
349            let Some(label) = mapping.template.labels.first().cloned() else {
350                continue;
351            };
352
353            let properties = extract_property_schemas(mapping.template.properties.as_ref());
354
355            match mapping.element_type {
356                crate::config::ElementType::Node => {
357                    let entry = node_map.entry(label).or_default();
358                    for prop in properties {
359                        if !entry.iter().any(|p| p.name == prop.name) {
360                            entry.push(prop);
361                        }
362                    }
363                }
364                crate::config::ElementType::Relation => {
365                    let entry = relation_map.entry(label).or_default();
366                    for prop in properties {
367                        if !entry.iter().any(|p| p.name == prop.name) {
368                            entry.push(prop);
369                        }
370                    }
371                }
372            }
373        }
374    }
375
376    let nodes: Vec<_> = node_map
377        .into_iter()
378        .map(|(label, properties)| NodeSchema { label, properties })
379        .collect();
380    let relations: Vec<_> = relation_map
381        .into_iter()
382        .map(|(label, properties)| RelationSchema {
383            label,
384            from: None,
385            to: None,
386            properties,
387        })
388        .collect();
389
390    if nodes.is_empty() && relations.is_empty() {
391        None
392    } else {
393        Some(SourceSchema { nodes, relations })
394    }
395}
396
397impl HttpSource {
398    /// Create a new HTTP source.
399    ///
400    /// The event channel is automatically injected when the source is added
401    /// to DrasiLib via `add_source()`.
402    ///
403    /// # Arguments
404    ///
405    /// * `id` - Unique identifier for this source instance
406    /// * `config` - HTTP source configuration
407    ///
408    /// # Returns
409    ///
410    /// A new `HttpSource` instance, or an error if construction fails.
411    ///
412    /// # Errors
413    ///
414    /// Returns an error if the base source cannot be initialized.
415    ///
416    /// # Example
417    ///
418    /// ```rust,no_run
419    /// use drasi_source_http::HttpSource;
420    ///
421    /// # fn example() -> anyhow::Result<()> {
422    /// let source = HttpSource::builder("my-http-source")
423    ///     .with_host("0.0.0.0")
424    ///     .with_port(8080)
425    ///     .build()?;
426    /// # Ok(())
427    /// # }
428    /// ```
429    pub fn new(id: impl Into<String>, config: HttpSourceConfig) -> Result<Self> {
430        let id = id.into();
431        let params = SourceBaseParams::new(id);
432
433        // Configure adaptive batching
434        let mut adaptive_config = AdaptiveBatchConfig::default();
435
436        // Allow overriding adaptive parameters from config
437        if let Some(max_batch) = config.adaptive_max_batch_size {
438            adaptive_config.max_batch_size = max_batch;
439        }
440        if let Some(min_batch) = config.adaptive_min_batch_size {
441            adaptive_config.min_batch_size = min_batch;
442        }
443        if let Some(max_wait_ms) = config.adaptive_max_wait_ms {
444            adaptive_config.max_wait_time = Duration::from_millis(max_wait_ms);
445        }
446        if let Some(min_wait_ms) = config.adaptive_min_wait_ms {
447            adaptive_config.min_wait_time = Duration::from_millis(min_wait_ms);
448        }
449        if let Some(window_secs) = config.adaptive_window_secs {
450            adaptive_config.throughput_window = Duration::from_secs(window_secs);
451        }
452        if let Some(enabled) = config.adaptive_enabled {
453            adaptive_config.adaptive_enabled = enabled;
454        }
455
456        Ok(Self {
457            base: SourceBase::new(params)?,
458            config,
459            adaptive_config,
460            wal: tokio::sync::RwLock::new(None),
461            prune_task: tokio::sync::RwLock::new(None),
462        })
463    }
464
465    /// Create a new HTTP source with custom dispatch settings.
466    ///
467    /// The event channel is automatically injected when the source is added
468    /// to DrasiLib via `add_source()`.
469    ///
470    /// # Arguments
471    ///
472    /// * `id` - Unique identifier for this source instance
473    /// * `config` - HTTP source configuration
474    /// * `dispatch_mode` - Optional dispatch mode (Channel, Direct, etc.)
475    /// * `dispatch_buffer_capacity` - Optional buffer capacity for channel dispatch
476    ///
477    /// # Returns
478    ///
479    /// A new `HttpSource` instance with custom dispatch settings.
480    ///
481    /// # Errors
482    ///
483    /// Returns an error if the base source cannot be initialized.
484    pub fn with_dispatch(
485        id: impl Into<String>,
486        config: HttpSourceConfig,
487        dispatch_mode: Option<DispatchMode>,
488        dispatch_buffer_capacity: Option<usize>,
489    ) -> Result<Self> {
490        let id = id.into();
491        let mut params = SourceBaseParams::new(id);
492        if let Some(mode) = dispatch_mode {
493            params = params.with_dispatch_mode(mode);
494        }
495        if let Some(capacity) = dispatch_buffer_capacity {
496            params = params.with_dispatch_buffer_capacity(capacity);
497        }
498
499        let mut adaptive_config = AdaptiveBatchConfig::default();
500
501        if let Some(max_batch) = config.adaptive_max_batch_size {
502            adaptive_config.max_batch_size = max_batch;
503        }
504        if let Some(min_batch) = config.adaptive_min_batch_size {
505            adaptive_config.min_batch_size = min_batch;
506        }
507        if let Some(max_wait_ms) = config.adaptive_max_wait_ms {
508            adaptive_config.max_wait_time = Duration::from_millis(max_wait_ms);
509        }
510        if let Some(min_wait_ms) = config.adaptive_min_wait_ms {
511            adaptive_config.min_wait_time = Duration::from_millis(min_wait_ms);
512        }
513        if let Some(window_secs) = config.adaptive_window_secs {
514            adaptive_config.throughput_window = Duration::from_secs(window_secs);
515        }
516        if let Some(enabled) = config.adaptive_enabled {
517            adaptive_config.adaptive_enabled = enabled;
518        }
519
520        Ok(Self {
521            base: SourceBase::new(params)?,
522            config,
523            adaptive_config,
524            wal: tokio::sync::RwLock::new(None),
525            prune_task: tokio::sync::RwLock::new(None),
526        })
527    }
528
529    /// Handle a single event submission from `POST /sources/{source_id}/events`.
530    ///
531    /// Validates the source ID matches this source and converts the HTTP event
532    /// to a source change before sending to the adaptive batcher.
533    async fn handle_single_event(
534        Path(source_id): Path<String>,
535        State(state): State<HttpAppState>,
536        Json(event): Json<HttpSourceChange>,
537    ) -> Result<impl IntoResponse, (StatusCode, Json<EventResponse>)> {
538        debug!("[{source_id}] HTTP endpoint received single event: {event:?}");
539        Self::process_events(&source_id, &state, vec![event]).await
540    }
541
542    /// Handle a batch event submission from `POST /sources/{source_id}/events/batch`.
543    ///
544    /// Validates the source ID and processes all events in the batch,
545    /// returning partial success if some events fail.
546    async fn handle_batch_events(
547        Path(source_id): Path<String>,
548        State(state): State<HttpAppState>,
549        Json(batch): Json<BatchEventRequest>,
550    ) -> Result<impl IntoResponse, (StatusCode, Json<EventResponse>)> {
551        debug!(
552            "[{}] HTTP endpoint received batch of {} events",
553            source_id,
554            batch.events.len()
555        );
556        Self::process_events(&source_id, &state, batch.events).await
557    }
558
559    /// Process a list of events, converting and sending them to the batcher.
560    ///
561    /// # Arguments
562    ///
563    /// * `source_id` - Source ID from the request path
564    /// * `state` - Shared app state containing the batch channel
565    /// * `events` - List of HTTP source changes to process
566    ///
567    /// # Returns
568    ///
569    /// Success response with count of processed events, or error if all fail.
570    async fn process_events(
571        source_id: &str,
572        state: &HttpAppState,
573        events: Vec<HttpSourceChange>,
574    ) -> Result<impl IntoResponse, (StatusCode, Json<EventResponse>)> {
575        trace!("[{}] Processing {} events", source_id, events.len());
576
577        if source_id != state.source_id {
578            error!(
579                "[{}] Source name mismatch. Expected '{}', got '{}'",
580                state.source_id, state.source_id, source_id
581            );
582            return Err((
583                StatusCode::BAD_REQUEST,
584                Json(EventResponse {
585                    success: false,
586                    message: "Source name mismatch".to_string(),
587                    error: Some(format!(
588                        "Expected source '{}', got '{}'",
589                        state.source_id, source_id
590                    )),
591                }),
592            ));
593        }
594
595        let mut success_count = 0;
596        let mut error_count = 0;
597        let mut last_error = None;
598
599        for (idx, event) in events.iter().enumerate() {
600            match convert_http_to_source_change(event, source_id) {
601                Ok(source_change) => {
602                    // WAL append before ACK (if durability enabled)
603                    let sequence = if let Some(ref wal) = state.wal {
604                        match wal.append(&state.source_id, &source_change).await {
605                            Ok(seq) => {
606                                trace!(
607                                    "[{}] WAL append succeeded: sequence={}",
608                                    state.source_id,
609                                    seq
610                                );
611                                Some(seq)
612                            }
613                            Err(WalError::CapacityExhausted(_)) => {
614                                warn!(
615                                    "[{}] WAL capacity exhausted, rejecting event",
616                                    state.source_id
617                                );
618                                return Err((
619                                    StatusCode::SERVICE_UNAVAILABLE,
620                                    Json(EventResponse {
621                                        success: false,
622                                        message: "WAL capacity exhausted".to_string(),
623                                        error: Some("Source durability buffer is full".to_string()),
624                                    }),
625                                ));
626                            }
627                            Err(e) => {
628                                error!(
629                                    "[{}] WAL append failed for event {}: {}",
630                                    state.source_id,
631                                    idx + 1,
632                                    e
633                                );
634                                // WAL durability failure is a server-side error — reject entire batch
635                                return Err((
636                                    StatusCode::SERVICE_UNAVAILABLE,
637                                    Json(EventResponse {
638                                        success: false,
639                                        message: format!(
640                                            "WAL durability failure at event {}: {e}",
641                                            idx + 1
642                                        ),
643                                        error: Some(format!("WAL error: {e}")),
644                                    }),
645                                ));
646                            }
647                        }
648                    } else {
649                        None
650                    };
651
652                    let change_event = SourceChangeEvent {
653                        source_id: source_id.to_string(),
654                        change: source_change,
655                        timestamp: chrono::Utc::now(),
656                        sequence,
657                    };
658
659                    if let Err(e) = state.batch_tx.send(change_event).await {
660                        error!(
661                            "[{}] Failed to send event {} to batch channel: {}",
662                            state.source_id,
663                            idx + 1,
664                            e
665                        );
666                        error_count += 1;
667                        last_error = Some("Internal channel error".to_string());
668                    } else {
669                        success_count += 1;
670                    }
671                }
672                Err(e) => {
673                    error!(
674                        "[{}] Failed to convert event {}: {}",
675                        state.source_id,
676                        idx + 1,
677                        e
678                    );
679                    error_count += 1;
680                    last_error = Some(e.to_string());
681                }
682            }
683        }
684
685        debug!(
686            "[{source_id}] Event processing complete: {success_count} succeeded, {error_count} failed"
687        );
688
689        if error_count > 0 && success_count == 0 {
690            Err((
691                StatusCode::BAD_REQUEST,
692                Json(EventResponse {
693                    success: false,
694                    message: format!("All {error_count} events failed"),
695                    error: last_error,
696                }),
697            ))
698        } else if error_count > 0 {
699            Ok(Json(EventResponse {
700                success: true,
701                message: format!(
702                    "Processed {success_count} events successfully, {error_count} failed"
703                ),
704                error: last_error,
705            }))
706        } else {
707            Ok(Json(EventResponse {
708                success: true,
709                message: format!("All {success_count} events processed successfully"),
710                error: None,
711            }))
712        }
713    }
714
715    async fn health_check() -> impl IntoResponse {
716        Json(serde_json::json!({
717            "status": "healthy",
718            "service": "http-source",
719            "features": ["adaptive-batching", "batch-endpoint", "webhooks"]
720        }))
721    }
722
723    /// Handle webhook requests
724    ///
725    /// This handler processes requests in webhook mode, matching against
726    /// configured routes and transforming payloads using templates.
727    async fn handle_webhook(
728        method: axum::http::Method,
729        uri: axum::http::Uri,
730        headers: axum::http::HeaderMap,
731        State(state): State<HttpAppState>,
732        body: Bytes,
733    ) -> impl IntoResponse {
734        let path = uri.path();
735        let source_id = &state.source_id;
736
737        debug!("[{source_id}] Webhook received: {method} {path}");
738
739        // Get webhook state - should always be present in webhook mode
740        let webhook_state = match &state.webhook_config {
741            Some(ws) => ws,
742            None => {
743                error!("[{source_id}] Webhook handler called but no webhook config present");
744                return (
745                    StatusCode::INTERNAL_SERVER_ERROR,
746                    Json(EventResponse {
747                        success: false,
748                        message: "Internal configuration error".to_string(),
749                        error: Some("Webhook mode not properly configured".to_string()),
750                    }),
751                );
752            }
753        };
754
755        // Convert method
756        let http_method = match convert_method(&method) {
757            Some(m) => m,
758            None => {
759                return handle_error(
760                    &webhook_state.config.error_behavior,
761                    source_id,
762                    StatusCode::METHOD_NOT_ALLOWED,
763                    "Method not supported",
764                    None,
765                );
766            }
767        };
768
769        // Match route
770        let route_match = match webhook_state.route_matcher.match_route(
771            path,
772            &http_method,
773            &webhook_state.config.routes,
774        ) {
775            Some(rm) => rm,
776            None => {
777                debug!("[{source_id}] No matching route for {method} {path}");
778                return handle_error(
779                    &webhook_state.config.error_behavior,
780                    source_id,
781                    StatusCode::NOT_FOUND,
782                    "No matching route",
783                    None,
784                );
785            }
786        };
787
788        let route = route_match.route;
789        let error_behavior = route
790            .error_behavior
791            .as_ref()
792            .unwrap_or(&webhook_state.config.error_behavior);
793
794        // Verify authentication
795        let auth_result = verify_auth(route.auth.as_ref(), &headers, &body);
796        if let AuthResult::Failed(reason) = auth_result {
797            warn!("[{source_id}] Authentication failed for {path}: {reason}");
798            return handle_error(
799                error_behavior,
800                source_id,
801                StatusCode::UNAUTHORIZED,
802                "Authentication failed",
803                Some(&reason),
804            );
805        }
806
807        // Parse content
808        let content_type = ContentType::from_header(
809            headers
810                .get(axum::http::header::CONTENT_TYPE)
811                .and_then(|v| v.to_str().ok()),
812        );
813
814        let payload = match parse_content(&body, content_type) {
815            Ok(p) => p,
816            Err(e) => {
817                warn!("[{source_id}] Failed to parse payload: {e}");
818                return handle_error(
819                    error_behavior,
820                    source_id,
821                    StatusCode::BAD_REQUEST,
822                    "Failed to parse payload",
823                    Some(&e.to_string()),
824                );
825            }
826        };
827
828        // Build template context
829        let headers_map = headers_to_map(&headers);
830        let query_map = parse_query_string(uri.query());
831
832        let context = TemplateContext {
833            payload: payload.clone(),
834            route: route_match.path_params,
835            query: query_map,
836            headers: headers_map.clone(),
837            method: method.to_string(),
838            path: path.to_string(),
839            source_id: source_id.clone(),
840        };
841
842        // Find matching mappings
843        let matching_mappings = find_matching_mappings(&route.mappings, &headers_map, &payload);
844
845        if matching_mappings.is_empty() {
846            debug!("[{source_id}] No matching mappings for request");
847            return handle_error(
848                error_behavior,
849                source_id,
850                StatusCode::BAD_REQUEST,
851                "No matching mapping for request",
852                None,
853            );
854        }
855
856        // Process each matching mapping
857        let mut success_count = 0;
858        let mut error_count = 0;
859        let mut last_error = None;
860
861        for mapping in matching_mappings {
862            match webhook_state
863                .template_engine
864                .process_mapping(mapping, &context, source_id)
865            {
866                Ok(source_change) => {
867                    // WAL append before ACK (if durability enabled)
868                    let sequence = if let Some(ref wal) = state.wal {
869                        match wal.append(&state.source_id, &source_change).await {
870                            Ok(seq) => Some(seq),
871                            Err(WalError::CapacityExhausted(_)) => {
872                                return handle_error(
873                                    error_behavior,
874                                    source_id,
875                                    StatusCode::SERVICE_UNAVAILABLE,
876                                    "WAL capacity exhausted",
877                                    None,
878                                );
879                            }
880                            Err(e) => {
881                                error!("[{source_id}] WAL append failed: {e}");
882                                // WAL durability failure — reject to maintain at-least-once guarantee
883                                return handle_error(
884                                    error_behavior,
885                                    source_id,
886                                    StatusCode::SERVICE_UNAVAILABLE,
887                                    &format!("WAL durability failure: {e}"),
888                                    None,
889                                );
890                            }
891                        }
892                    } else {
893                        None
894                    };
895
896                    let event = SourceChangeEvent {
897                        source_id: source_id.clone(),
898                        change: source_change,
899                        timestamp: chrono::Utc::now(),
900                        sequence,
901                    };
902
903                    if let Err(e) = state.batch_tx.send(event).await {
904                        error!("[{source_id}] Failed to send event to batcher: {e}");
905                        error_count += 1;
906                        last_error = Some(format!("Failed to queue event: {e}"));
907                    } else {
908                        success_count += 1;
909                    }
910                }
911                Err(e) => {
912                    warn!("[{source_id}] Failed to process mapping: {e}");
913                    error_count += 1;
914                    last_error = Some(e.to_string());
915                }
916            }
917        }
918
919        debug!("[{source_id}] Webhook processing complete: {success_count} succeeded, {error_count} failed");
920
921        if error_count > 0 && success_count == 0 {
922            handle_error(
923                error_behavior,
924                source_id,
925                StatusCode::BAD_REQUEST,
926                &format!("All {error_count} mappings failed"),
927                last_error.as_deref(),
928            )
929        } else if error_count > 0 {
930            (
931                StatusCode::OK,
932                Json(EventResponse {
933                    success: true,
934                    message: format!("Processed {success_count} events, {error_count} failed"),
935                    error: last_error,
936                }),
937            )
938        } else {
939            (
940                StatusCode::OK,
941                Json(EventResponse {
942                    success: true,
943                    message: format!("Processed {success_count} events successfully"),
944                    error: None,
945                }),
946            )
947        }
948    }
949
950    async fn run_adaptive_batcher(
951        batch_rx: mpsc::Receiver<SourceChangeEvent>,
952        dispatchers: Arc<
953            tokio::sync::RwLock<
954                Vec<
955                    Box<
956                        dyn drasi_lib::channels::ChangeDispatcher<SourceEventWrapper> + Send + Sync,
957                    >,
958                >,
959            >,
960        >,
961        adaptive_config: AdaptiveBatchConfig,
962        source_id: String,
963    ) {
964        let mut batcher = AdaptiveBatcher::new(batch_rx, adaptive_config.clone());
965        let mut total_events = 0u64;
966        let mut total_batches = 0u64;
967
968        info!("[{source_id}] Adaptive HTTP batcher started with config: {adaptive_config:?}");
969
970        while let Some(batch) = batcher.next_batch().await {
971            if batch.is_empty() {
972                debug!("[{source_id}] Batcher received empty batch, skipping");
973                continue;
974            }
975
976            let batch_size = batch.len();
977            total_events += batch_size as u64;
978            total_batches += 1;
979
980            debug!(
981                "[{source_id}] Batcher forwarding batch #{total_batches} with {batch_size} events to dispatchers"
982            );
983
984            let mut sent_count = 0;
985            let mut failed_count = 0;
986            for (idx, event) in batch.into_iter().enumerate() {
987                debug!(
988                    "[{}] Batch #{}, dispatching event {}/{}",
989                    source_id,
990                    total_batches,
991                    idx + 1,
992                    batch_size
993                );
994
995                let mut profiling = drasi_lib::profiling::ProfilingMetadata::new();
996                profiling.source_send_ns = Some(drasi_lib::profiling::timestamp_ns());
997
998                let mut wrapper = SourceEventWrapper::with_profiling(
999                    event.source_id.clone(),
1000                    SourceEvent::Change(event.change),
1001                    event.timestamp,
1002                    profiling,
1003                );
1004
1005                // Carry WAL-assigned sequence through to the wrapper
1006                if let Some(seq) = event.sequence {
1007                    wrapper.sequence = Some(seq);
1008                    wrapper.source_position = Some(bytes::Bytes::from(seq.to_be_bytes().to_vec()));
1009                }
1010
1011                if let Err(e) =
1012                    SourceBase::dispatch_from_task(dispatchers.clone(), wrapper.clone(), &source_id)
1013                        .await
1014                {
1015                    error!(
1016                        "[{}] Batch #{}, failed to dispatch event {}/{} (no subscribers): {}",
1017                        source_id,
1018                        total_batches,
1019                        idx + 1,
1020                        batch_size,
1021                        e
1022                    );
1023                    failed_count += 1;
1024                } else {
1025                    debug!(
1026                        "[{}] Batch #{}, successfully dispatched event {}/{}",
1027                        source_id,
1028                        total_batches,
1029                        idx + 1,
1030                        batch_size
1031                    );
1032                    sent_count += 1;
1033                }
1034            }
1035
1036            debug!(
1037                "[{source_id}] Batch #{total_batches} complete: {sent_count} dispatched, {failed_count} failed"
1038            );
1039
1040            if total_batches.is_multiple_of(100) {
1041                info!(
1042                    "[{}] Adaptive HTTP metrics - Batches: {}, Events: {}, Avg batch size: {:.1}",
1043                    source_id,
1044                    total_batches,
1045                    total_events,
1046                    total_events as f64 / total_batches as f64
1047                );
1048            }
1049        }
1050
1051        info!(
1052            "[{source_id}] Adaptive HTTP batcher stopped - Total batches: {total_batches}, Total events: {total_events}"
1053        );
1054    }
1055}
1056
1057#[async_trait]
1058impl Source for HttpSource {
1059    fn id(&self) -> &str {
1060        &self.base.id
1061    }
1062
1063    fn type_name(&self) -> &str {
1064        "http"
1065    }
1066
1067    fn properties(&self) -> HashMap<String, serde_json::Value> {
1068        use crate::descriptor::HttpSourceConfigDto;
1069
1070        self.base
1071            .properties_or_serialize(&HttpSourceConfigDto::from(&self.config))
1072    }
1073
1074    fn auto_start(&self) -> bool {
1075        self.base.get_auto_start()
1076    }
1077
1078    fn describe_schema(&self) -> Option<SourceSchema> {
1079        self.config
1080            .webhooks
1081            .as_ref()
1082            .and_then(derive_schema_from_webhooks)
1083    }
1084
1085    async fn start(&self) -> Result<()> {
1086        info!("[{}] Starting adaptive HTTP source", self.base.id);
1087
1088        self.base
1089            .set_status(
1090                ComponentStatus::Starting,
1091                Some("Starting adaptive HTTP source".to_string()),
1092            )
1093            .await;
1094
1095        // Initialize WAL if durability is enabled
1096        let wal_ref: Option<Arc<dyn WalProvider>> =
1097            if self.config.durability.as_ref().is_some_and(|d| d.enabled) {
1098                let ctx = self
1099                    .base
1100                    .context()
1101                    .await
1102                    .ok_or_else(|| anyhow::anyhow!("Context not initialized"))?;
1103                let wal = ctx.wal_provider.clone().ok_or_else(|| {
1104                    anyhow::anyhow!("Durability enabled but no WAL provider configured on DrasiLib")
1105                })?;
1106                let wal_config = self
1107                    .config
1108                    .durability
1109                    .as_ref()
1110                    .expect("durability checked above")
1111                    .to_wal_config();
1112                wal.register(&self.base.id, wal_config.clone())
1113                    .await
1114                    .map_err(|e| {
1115                        anyhow::anyhow!(
1116                            "Failed to register WAL for source '{}': {}",
1117                            self.base.id,
1118                            e
1119                        )
1120                    })?;
1121
1122                info!(
1123                    "[{}] WAL registered: max_events={}, policy={:?}",
1124                    self.base.id, wal_config.max_events, wal_config.capacity_policy
1125                );
1126
1127                // Resume sequence counter from WAL head
1128                let head = wal.head_sequence(&self.base.id).await.unwrap_or(0);
1129                if head > 0 {
1130                    self.base.set_next_sequence(head);
1131                    info!(
1132                        "[{}] WAL resumed from persisted state: head={}, next_sequence={}",
1133                        self.base.id,
1134                        head,
1135                        head + 1
1136                    );
1137                }
1138
1139                *self.wal.write().await = Some(wal.clone());
1140                Some(wal)
1141            } else {
1142                None
1143            };
1144
1145        let host = self.config.host.clone();
1146        let port = self.config.port;
1147
1148        // Create batch channel with capacity based on batch configuration
1149        let batch_channel_capacity = self.adaptive_config.recommended_channel_capacity();
1150        let (batch_tx, batch_rx) = mpsc::channel(batch_channel_capacity);
1151        info!(
1152            "[{}] HttpSource using batch channel capacity: {} (max_batch_size: {} x 5)",
1153            self.base.id, batch_channel_capacity, self.adaptive_config.max_batch_size
1154        );
1155
1156        // Start adaptive batcher task
1157        let adaptive_config = self.adaptive_config.clone();
1158        let source_id = self.base.id.clone();
1159        let dispatchers = self.base.dispatchers.clone();
1160
1161        // Get instance_id from context for log routing isolation
1162        let instance_id = self
1163            .base
1164            .context()
1165            .await
1166            .map(|c| c.instance_id)
1167            .unwrap_or_default();
1168
1169        info!("[{source_id}] Starting adaptive batcher task");
1170        let source_id_for_span = source_id.clone();
1171        let span = tracing::info_span!(
1172            "http_adaptive_batcher",
1173            instance_id = %instance_id,
1174            component_id = %source_id_for_span,
1175            component_type = "source"
1176        );
1177        tokio::spawn(
1178            async move {
1179                Self::run_adaptive_batcher(
1180                    batch_rx,
1181                    dispatchers,
1182                    adaptive_config,
1183                    source_id.clone(),
1184                )
1185                .await
1186            }
1187            .instrument(span),
1188        );
1189
1190        // Create app state
1191        let webhook_state = if let Some(ref webhook_config) = self.config.webhooks {
1192            info!(
1193                "[{}] Webhook mode enabled with {} routes",
1194                self.base.id,
1195                webhook_config.routes.len()
1196            );
1197            Some(Arc::new(WebhookState {
1198                config: webhook_config.clone(),
1199                route_matcher: RouteMatcher::new(&webhook_config.routes),
1200                template_engine: TemplateEngine::new(),
1201            }))
1202        } else {
1203            info!("[{}] Standard mode enabled", self.base.id);
1204            None
1205        };
1206
1207        let state = HttpAppState {
1208            source_id: self.base.id.clone(),
1209            batch_tx,
1210            webhook_config: webhook_state,
1211            wal: wal_ref.clone(),
1212        };
1213
1214        // Build router based on mode
1215        let app = if self.config.is_webhook_mode() {
1216            // Webhook mode: only health check + catch-all webhook handler
1217            let router = Router::new()
1218                .route("/health", get(Self::health_check))
1219                .fallback(Self::handle_webhook)
1220                .with_state(state);
1221
1222            // Apply CORS if configured
1223            if let Some(ref webhooks) = self.config.webhooks {
1224                if let Some(ref cors_config) = webhooks.cors {
1225                    if cors_config.enabled {
1226                        info!("[{}] CORS enabled for webhook endpoints", self.base.id);
1227                        router.layer(build_cors_layer(cors_config))
1228                    } else {
1229                        router
1230                    }
1231                } else {
1232                    router
1233                }
1234            } else {
1235                router
1236            }
1237        } else {
1238            // Standard mode: original endpoints
1239            Router::new()
1240                .route("/health", get(Self::health_check))
1241                .route(
1242                    "/sources/:source_id/events",
1243                    post(Self::handle_single_event),
1244                )
1245                .route(
1246                    "/sources/:source_id/events/batch",
1247                    post(Self::handle_batch_events),
1248                )
1249                .with_state(state)
1250        };
1251
1252        // Create shutdown channel
1253        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
1254
1255        let host_clone = host.clone();
1256
1257        // Start server
1258        let (error_tx, error_rx) = tokio::sync::oneshot::channel();
1259        let source_id = self.base.id.clone();
1260        let source_id_for_span = source_id.clone();
1261        let span = tracing::info_span!(
1262            "http_source_server",
1263            instance_id = %instance_id,
1264            component_id = %source_id_for_span,
1265            component_type = "source"
1266        );
1267        let server_handle = tokio::spawn(
1268            async move {
1269                let addr = format!("{host}:{port}");
1270                info!("[{source_id}] Adaptive HTTP source attempting to bind to {addr}");
1271
1272                let listener = match tokio::net::TcpListener::bind(&addr).await {
1273                    Ok(listener) => {
1274                        info!("[{source_id}] Adaptive HTTP source successfully listening on {addr}");
1275                        listener
1276                    }
1277                    Err(e) => {
1278                        error!("[{source_id}] Failed to bind HTTP server to {addr}: {e}");
1279                        let _ = error_tx.send(format!(
1280                        "Failed to bind HTTP server to {addr}: {e}. Common causes: port already in use, insufficient permissions"
1281                    ));
1282                    return;
1283                }
1284            };
1285
1286                if let Err(e) = axum::serve(listener, app)
1287                    .with_graceful_shutdown(async move {
1288                        let _ = shutdown_rx.await;
1289                    })
1290                    .await
1291                {
1292                    error!("[{source_id}] HTTP server error: {e}");
1293                }
1294            }
1295            .instrument(span),
1296        );
1297
1298        *self.base.task_handle.write().await = Some(server_handle);
1299        *self.base.shutdown_tx.write().await = Some(shutdown_tx);
1300
1301        // Check for startup errors with a short timeout
1302        match timeout(Duration::from_millis(500), error_rx).await {
1303            Ok(Ok(error_msg)) => {
1304                self.base.set_status(ComponentStatus::Error, None).await;
1305                return Err(anyhow::anyhow!("{error_msg}"));
1306            }
1307            _ => {
1308                self.base
1309                    .set_status(
1310                        ComponentStatus::Running,
1311                        Some(format!(
1312                    "Adaptive HTTP source running on {host_clone}:{port} with batch support"
1313                )),
1314                    )
1315                    .await;
1316            }
1317        }
1318
1319        // Spawn WAL pruning task if durability is enabled
1320        if let Some(wal) = wal_ref {
1321            let base = self.base.clone_shared();
1322            let source_id = self.base.id.clone();
1323            let prune_handle = tokio::spawn(async move {
1324                let mut interval = tokio::time::interval(Duration::from_secs(30));
1325                loop {
1326                    interval.tick().await;
1327                    if let Some(confirmed) = base.compute_confirmed_position().await {
1328                        if confirmed > 0 {
1329                            match wal.prune_up_to(&source_id, confirmed).await {
1330                                Ok(pruned) => {
1331                                    if pruned > 0 {
1332                                        let remaining =
1333                                            wal.event_count(&source_id).await.unwrap_or(0);
1334                                        debug!(
1335                                            "[{source_id}] WAL pruned: count={pruned}, confirmed_seq={confirmed}, remaining={remaining}"
1336                                        );
1337                                    }
1338                                }
1339                                Err(e) => {
1340                                    warn!("[{source_id}] WAL prune failed: {e}");
1341                                }
1342                            }
1343                        }
1344                    }
1345                }
1346            });
1347            *self.prune_task.write().await = Some(prune_handle);
1348        }
1349
1350        Ok(())
1351    }
1352
1353    async fn stop(&self) -> Result<()> {
1354        info!("[{}] Stopping adaptive HTTP source", self.base.id);
1355
1356        self.base
1357            .set_status(
1358                ComponentStatus::Stopping,
1359                Some("Stopping adaptive HTTP source".to_string()),
1360            )
1361            .await;
1362
1363        // Cancel WAL pruning task
1364        if let Some(handle) = self.prune_task.write().await.take() {
1365            handle.abort();
1366        }
1367
1368        if let Some(tx) = self.base.shutdown_tx.write().await.take() {
1369            let _ = tx.send(());
1370        }
1371
1372        if let Some(handle) = self.base.task_handle.write().await.take() {
1373            let _ = timeout(Duration::from_secs(5), handle).await;
1374        }
1375
1376        self.base
1377            .set_status(
1378                ComponentStatus::Stopped,
1379                Some("Adaptive HTTP source stopped".to_string()),
1380            )
1381            .await;
1382
1383        Ok(())
1384    }
1385
1386    async fn status(&self) -> ComponentStatus {
1387        self.base.get_status().await
1388    }
1389
1390    async fn subscribe(
1391        &self,
1392        settings: drasi_lib::config::SourceSubscriptionSettings,
1393    ) -> Result<SubscriptionResponse> {
1394        // If WAL is enabled and subscriber is resuming, use WAL replay
1395        let wal_guard = self.wal.read().await;
1396        if let (Some(wal), Some(ref resume_from)) = (wal_guard.as_ref(), &settings.resume_from) {
1397            // Decode resume_from as big-endian u64 sequence
1398            if resume_from.len() >= 8 {
1399                let resume_seq =
1400                    u64::from_be_bytes(resume_from[..8].try_into().unwrap_or_default());
1401                let wal_clone = wal.clone();
1402                drop(wal_guard);
1403                return self
1404                    .base
1405                    .subscribe_with_replay(&settings, wal_clone.as_ref(), resume_seq, "HTTP")
1406                    .await;
1407            } else {
1408                drop(wal_guard);
1409                return Err(anyhow::anyhow!(
1410                    "Invalid resume_from position: expected at least 8 bytes, got {}",
1411                    resume_from.len()
1412                ));
1413            }
1414        }
1415        drop(wal_guard);
1416        self.base.subscribe_with_bootstrap(&settings, "HTTP").await
1417    }
1418
1419    fn supports_replay(&self) -> bool {
1420        self.config.durability.as_ref().is_some_and(|d| d.enabled)
1421    }
1422
1423    async fn deprovision(&self) -> Result<()> {
1424        // Delete WAL data if durability was enabled
1425        let wal_guard = self.wal.read().await;
1426        if let Some(ref wal) = *wal_guard {
1427            info!("[{}] Deprovisioning: deleting WAL data", self.base.id);
1428            if let Err(e) = wal.delete_wal(&self.base.id).await {
1429                warn!(
1430                    "[{}] Failed to delete WAL during deprovision: {}",
1431                    self.base.id, e
1432                );
1433            }
1434        }
1435        drop(wal_guard);
1436        Ok(())
1437    }
1438
1439    fn as_any(&self) -> &dyn std::any::Any {
1440        self
1441    }
1442
1443    async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
1444        self.base.initialize(context).await;
1445    }
1446
1447    async fn set_bootstrap_provider(
1448        &self,
1449        provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
1450    ) {
1451        self.base.set_bootstrap_provider(provider).await;
1452    }
1453}
1454
1455/// Builder for HttpSource instances.
1456///
1457/// Provides a fluent API for constructing HTTP sources with sensible defaults
1458/// and adaptive batching settings. The builder takes the source ID at construction
1459/// and returns a fully constructed `HttpSource` from `build()`.
1460///
1461/// # Example
1462///
1463/// ```rust,no_run
1464/// use drasi_source_http::HttpSource;
1465///
1466/// # fn example() -> anyhow::Result<()> {
1467/// let source = HttpSource::builder("my-source")
1468///     .with_host("0.0.0.0")
1469///     .with_port(8080)
1470///     .with_adaptive_enabled(true)
1471///     .build()?;
1472/// # Ok(())
1473/// # }
1474/// ```
1475pub struct HttpSourceBuilder {
1476    id: String,
1477    host: String,
1478    port: u16,
1479    endpoint: Option<String>,
1480    timeout_ms: u64,
1481    adaptive_max_batch_size: Option<usize>,
1482    adaptive_min_batch_size: Option<usize>,
1483    adaptive_max_wait_ms: Option<u64>,
1484    adaptive_min_wait_ms: Option<u64>,
1485    adaptive_window_secs: Option<u64>,
1486    adaptive_enabled: Option<bool>,
1487    webhooks: Option<WebhookConfig>,
1488    durability: Option<drasi_lib::DurabilityConfig>,
1489    dispatch_mode: Option<DispatchMode>,
1490    dispatch_buffer_capacity: Option<usize>,
1491    bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
1492    auto_start: bool,
1493}
1494
1495impl HttpSourceBuilder {
1496    /// Create a new HTTP source builder with the given source ID.
1497    ///
1498    /// # Arguments
1499    ///
1500    /// * `id` - Unique identifier for the source instance
1501    pub fn new(id: impl Into<String>) -> Self {
1502        Self {
1503            id: id.into(),
1504            host: String::new(),
1505            port: 8080,
1506            endpoint: None,
1507            timeout_ms: 10000,
1508            adaptive_max_batch_size: None,
1509            adaptive_min_batch_size: None,
1510            adaptive_max_wait_ms: None,
1511            adaptive_min_wait_ms: None,
1512            adaptive_window_secs: None,
1513            adaptive_enabled: None,
1514            webhooks: None,
1515            durability: None,
1516            dispatch_mode: None,
1517            dispatch_buffer_capacity: None,
1518            bootstrap_provider: None,
1519            auto_start: true,
1520        }
1521    }
1522
1523    /// Set the HTTP host
1524    pub fn with_host(mut self, host: impl Into<String>) -> Self {
1525        self.host = host.into();
1526        self
1527    }
1528
1529    /// Set the HTTP port
1530    pub fn with_port(mut self, port: u16) -> Self {
1531        self.port = port;
1532        self
1533    }
1534
1535    /// Set the endpoint path
1536    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
1537        self.endpoint = Some(endpoint.into());
1538        self
1539    }
1540
1541    /// Set the request timeout in milliseconds
1542    pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
1543        self.timeout_ms = timeout_ms;
1544        self
1545    }
1546
1547    /// Set the adaptive batching maximum batch size
1548    pub fn with_adaptive_max_batch_size(mut self, size: usize) -> Self {
1549        self.adaptive_max_batch_size = Some(size);
1550        self
1551    }
1552
1553    /// Set the adaptive batching minimum batch size
1554    pub fn with_adaptive_min_batch_size(mut self, size: usize) -> Self {
1555        self.adaptive_min_batch_size = Some(size);
1556        self
1557    }
1558
1559    /// Set the adaptive batching maximum wait time in milliseconds
1560    pub fn with_adaptive_max_wait_ms(mut self, wait_ms: u64) -> Self {
1561        self.adaptive_max_wait_ms = Some(wait_ms);
1562        self
1563    }
1564
1565    /// Set the adaptive batching minimum wait time in milliseconds
1566    pub fn with_adaptive_min_wait_ms(mut self, wait_ms: u64) -> Self {
1567        self.adaptive_min_wait_ms = Some(wait_ms);
1568        self
1569    }
1570
1571    /// Set the adaptive batching throughput window in seconds
1572    pub fn with_adaptive_window_secs(mut self, secs: u64) -> Self {
1573        self.adaptive_window_secs = Some(secs);
1574        self
1575    }
1576
1577    /// Enable or disable adaptive batching
1578    pub fn with_adaptive_enabled(mut self, enabled: bool) -> Self {
1579        self.adaptive_enabled = Some(enabled);
1580        self
1581    }
1582
1583    /// Set the dispatch mode for event routing.
1584    pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1585        self.dispatch_mode = Some(mode);
1586        self
1587    }
1588
1589    /// Set the dispatch buffer capacity.
1590    pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1591        self.dispatch_buffer_capacity = Some(capacity);
1592        self
1593    }
1594
1595    /// Set the bootstrap provider for initial data delivery.
1596    pub fn with_bootstrap_provider(
1597        mut self,
1598        provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1599    ) -> Self {
1600        self.bootstrap_provider = Some(Box::new(provider));
1601        self
1602    }
1603
1604    /// Set whether this source should auto-start when DrasiLib starts.
1605    ///
1606    /// Default is `true`. Set to `false` if this source should only be
1607    /// started manually via `start_source()`.
1608    pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1609        self.auto_start = auto_start;
1610        self
1611    }
1612
1613    /// Set the webhook configuration to enable webhook mode.
1614    ///
1615    /// When webhook mode is enabled, the standard `HttpSourceChange` endpoints
1616    /// are disabled and custom webhook routes are used instead.
1617    pub fn with_webhooks(mut self, webhooks: WebhookConfig) -> Self {
1618        self.webhooks = Some(webhooks);
1619        self
1620    }
1621
1622    /// Set the WAL durability configuration.
1623    ///
1624    /// When enabled, events are persisted to a Write-Ahead Log before
1625    /// acknowledging the caller, enabling crash recovery and replay.
1626    pub fn with_durability(mut self, config: drasi_lib::DurabilityConfig) -> Self {
1627        self.durability = Some(config);
1628        self
1629    }
1630
1631    /// Set the full configuration at once
1632    pub fn with_config(mut self, config: HttpSourceConfig) -> Self {
1633        self.host = config.host;
1634        self.port = config.port;
1635        self.endpoint = config.endpoint;
1636        self.timeout_ms = config.timeout_ms;
1637        self.adaptive_max_batch_size = config.adaptive_max_batch_size;
1638        self.adaptive_min_batch_size = config.adaptive_min_batch_size;
1639        self.adaptive_max_wait_ms = config.adaptive_max_wait_ms;
1640        self.adaptive_min_wait_ms = config.adaptive_min_wait_ms;
1641        self.adaptive_window_secs = config.adaptive_window_secs;
1642        self.adaptive_enabled = config.adaptive_enabled;
1643        self.webhooks = config.webhooks;
1644        self.durability = config.durability;
1645        self
1646    }
1647
1648    /// Build the HttpSource instance.
1649    ///
1650    /// # Returns
1651    ///
1652    /// A fully constructed `HttpSource`, or an error if construction fails.
1653    pub fn build(self) -> Result<HttpSource> {
1654        let config = HttpSourceConfig {
1655            host: self.host,
1656            port: self.port,
1657            endpoint: self.endpoint,
1658            timeout_ms: self.timeout_ms,
1659            adaptive_max_batch_size: self.adaptive_max_batch_size,
1660            adaptive_min_batch_size: self.adaptive_min_batch_size,
1661            adaptive_max_wait_ms: self.adaptive_max_wait_ms,
1662            adaptive_min_wait_ms: self.adaptive_min_wait_ms,
1663            adaptive_window_secs: self.adaptive_window_secs,
1664            adaptive_enabled: self.adaptive_enabled,
1665            webhooks: self.webhooks,
1666            durability: self.durability,
1667        };
1668
1669        // Build SourceBaseParams with all settings
1670        let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1671        if let Some(mode) = self.dispatch_mode {
1672            params = params.with_dispatch_mode(mode);
1673        }
1674        if let Some(capacity) = self.dispatch_buffer_capacity {
1675            params = params.with_dispatch_buffer_capacity(capacity);
1676        }
1677        if let Some(provider) = self.bootstrap_provider {
1678            params = params.with_bootstrap_provider(provider);
1679        }
1680
1681        // Configure adaptive batching
1682        let mut adaptive_config = AdaptiveBatchConfig::default();
1683        if let Some(max_batch) = config.adaptive_max_batch_size {
1684            adaptive_config.max_batch_size = max_batch;
1685        }
1686        if let Some(min_batch) = config.adaptive_min_batch_size {
1687            adaptive_config.min_batch_size = min_batch;
1688        }
1689        if let Some(max_wait_ms) = config.adaptive_max_wait_ms {
1690            adaptive_config.max_wait_time = Duration::from_millis(max_wait_ms);
1691        }
1692        if let Some(min_wait_ms) = config.adaptive_min_wait_ms {
1693            adaptive_config.min_wait_time = Duration::from_millis(min_wait_ms);
1694        }
1695        if let Some(window_secs) = config.adaptive_window_secs {
1696            adaptive_config.throughput_window = Duration::from_secs(window_secs);
1697        }
1698        if let Some(enabled) = config.adaptive_enabled {
1699            adaptive_config.adaptive_enabled = enabled;
1700        }
1701
1702        Ok(HttpSource {
1703            base: SourceBase::new(params)?,
1704            config,
1705            adaptive_config,
1706            wal: tokio::sync::RwLock::new(None),
1707            prune_task: tokio::sync::RwLock::new(None),
1708        })
1709    }
1710}
1711
1712impl HttpSource {
1713    /// Create a builder for HttpSource with the given ID.
1714    ///
1715    /// This is the recommended way to construct an HttpSource.
1716    ///
1717    /// # Arguments
1718    ///
1719    /// * `id` - Unique identifier for the source instance
1720    ///
1721    /// # Example
1722    ///
1723    /// ```rust,no_run
1724    /// use drasi_source_http::HttpSource;
1725    ///
1726    /// # fn example() -> anyhow::Result<()> {
1727    /// let source = HttpSource::builder("my-source")
1728    ///     .with_host("0.0.0.0")
1729    ///     .with_port(8080)
1730    ///     .build()?;
1731    /// # Ok(())
1732    /// # }
1733    /// ```
1734    pub fn builder(id: impl Into<String>) -> HttpSourceBuilder {
1735        HttpSourceBuilder::new(id)
1736    }
1737}
1738
1739/// Handle errors according to configured error behavior
1740fn handle_error(
1741    behavior: &ErrorBehavior,
1742    source_id: &str,
1743    status: StatusCode,
1744    message: &str,
1745    detail: Option<&str>,
1746) -> (StatusCode, Json<EventResponse>) {
1747    match behavior {
1748        ErrorBehavior::Reject => {
1749            debug!("[{source_id}] Rejecting request: {message}");
1750            (
1751                status,
1752                Json(EventResponse {
1753                    success: false,
1754                    message: message.to_string(),
1755                    error: detail.map(String::from),
1756                }),
1757            )
1758        }
1759        ErrorBehavior::AcceptAndLog => {
1760            warn!("[{source_id}] Accepting with error (logged): {message}");
1761            (
1762                StatusCode::OK,
1763                Json(EventResponse {
1764                    success: true,
1765                    message: format!("Accepted with warning: {message}"),
1766                    error: detail.map(String::from),
1767                }),
1768            )
1769        }
1770        ErrorBehavior::AcceptAndSkip => {
1771            trace!("[{source_id}] Accepting silently: {message}");
1772            (
1773                StatusCode::OK,
1774                Json(EventResponse {
1775                    success: true,
1776                    message: "Accepted".to_string(),
1777                    error: None,
1778                }),
1779            )
1780        }
1781    }
1782}
1783
1784/// Parse query string into a HashMap
1785fn parse_query_string(query: Option<&str>) -> HashMap<String, String> {
1786    query
1787        .map(|q| {
1788            q.split('&')
1789                .filter_map(|pair| {
1790                    let mut parts = pair.splitn(2, '=');
1791                    let key = parts.next()?;
1792                    let value = parts.next().unwrap_or("");
1793                    Some((urlencoding_decode(key), urlencoding_decode(value)))
1794                })
1795                .collect()
1796        })
1797        .unwrap_or_default()
1798}
1799
1800/// Simple URL decoding (handles %XX sequences) with proper UTF-8 handling
1801fn urlencoding_decode(s: &str) -> String {
1802    // Collect decoded bytes first, then convert to String to properly handle UTF-8
1803    let mut decoded: Vec<u8> = Vec::with_capacity(s.len());
1804    let mut chars = s.chars();
1805
1806    while let Some(c) = chars.next() {
1807        if c == '%' {
1808            let mut hex = String::new();
1809            if let Some(c1) = chars.next() {
1810                hex.push(c1);
1811            }
1812            if let Some(c2) = chars.next() {
1813                hex.push(c2);
1814            }
1815
1816            if hex.len() == 2 {
1817                if let Ok(byte) = u8::from_str_radix(&hex, 16) {
1818                    decoded.push(byte);
1819                    continue;
1820                }
1821            }
1822
1823            // If we couldn't decode a valid %XX sequence, keep the original text
1824            decoded.extend_from_slice(b"%");
1825            decoded.extend_from_slice(hex.as_bytes());
1826        } else if c == '+' {
1827            decoded.push(b' ');
1828        } else {
1829            // Encode the character as UTF-8 and append its bytes
1830            let mut buf = [0u8; 4];
1831            let encoded = c.encode_utf8(&mut buf);
1832            decoded.extend_from_slice(encoded.as_bytes());
1833        }
1834    }
1835
1836    // Convert bytes to string, replacing invalid UTF-8 sequences
1837    String::from_utf8_lossy(&decoded).into_owned()
1838}
1839
1840/// Build a CORS layer from configuration
1841fn build_cors_layer(cors_config: &CorsConfig) -> CorsLayer {
1842    let mut cors = CorsLayer::new();
1843
1844    // Configure allowed origins
1845    if cors_config.allow_origins.len() == 1 && cors_config.allow_origins[0] == "*" {
1846        cors = cors.allow_origin(Any);
1847    } else {
1848        let origins: Vec<_> = cors_config
1849            .allow_origins
1850            .iter()
1851            .filter_map(|o| o.parse().ok())
1852            .collect();
1853        cors = cors.allow_origin(origins);
1854    }
1855
1856    // Configure allowed methods
1857    let methods: Vec<Method> = cors_config
1858        .allow_methods
1859        .iter()
1860        .filter_map(|m| m.parse().ok())
1861        .collect();
1862    cors = cors.allow_methods(methods);
1863
1864    // Configure allowed headers
1865    if cors_config.allow_headers.len() == 1 && cors_config.allow_headers[0] == "*" {
1866        cors = cors.allow_headers(Any);
1867    } else {
1868        let headers: Vec<header::HeaderName> = cors_config
1869            .allow_headers
1870            .iter()
1871            .filter_map(|h| h.parse().ok())
1872            .collect();
1873        cors = cors.allow_headers(headers);
1874    }
1875
1876    // Configure exposed headers
1877    if !cors_config.expose_headers.is_empty() {
1878        let exposed: Vec<header::HeaderName> = cors_config
1879            .expose_headers
1880            .iter()
1881            .filter_map(|h| h.parse().ok())
1882            .collect();
1883        cors = cors.expose_headers(exposed);
1884    }
1885
1886    // Configure credentials
1887    if cors_config.allow_credentials {
1888        cors = cors.allow_credentials(true);
1889    }
1890
1891    // Configure max age
1892    cors = cors.max_age(Duration::from_secs(cors_config.max_age));
1893
1894    cors
1895}
1896
1897#[cfg(test)]
1898mod tests {
1899    use super::*;
1900
1901    mod construction {
1902        use super::*;
1903
1904        #[test]
1905        fn test_builder_with_valid_config() {
1906            let source = HttpSourceBuilder::new("test-source")
1907                .with_host("localhost")
1908                .with_port(8080)
1909                .build();
1910            assert!(source.is_ok());
1911        }
1912
1913        #[test]
1914        fn test_builder_with_custom_config() {
1915            let source = HttpSourceBuilder::new("http-source")
1916                .with_host("0.0.0.0")
1917                .with_port(9000)
1918                .with_endpoint("/events")
1919                .build()
1920                .unwrap();
1921            assert_eq!(source.id(), "http-source");
1922        }
1923
1924        #[test]
1925        fn test_with_dispatch_creates_source() {
1926            let config = HttpSourceConfig {
1927                host: "localhost".to_string(),
1928                port: 8080,
1929                endpoint: None,
1930                timeout_ms: 10000,
1931                adaptive_max_batch_size: None,
1932                adaptive_min_batch_size: None,
1933                adaptive_max_wait_ms: None,
1934                adaptive_min_wait_ms: None,
1935                adaptive_window_secs: None,
1936                adaptive_enabled: None,
1937                webhooks: None,
1938                durability: None,
1939            };
1940            let source = HttpSource::with_dispatch(
1941                "dispatch-source",
1942                config,
1943                Some(DispatchMode::Channel),
1944                Some(1000),
1945            );
1946            assert!(source.is_ok());
1947            assert_eq!(source.unwrap().id(), "dispatch-source");
1948        }
1949    }
1950
1951    mod properties {
1952        use super::*;
1953
1954        #[test]
1955        fn test_id_returns_correct_value() {
1956            let source = HttpSourceBuilder::new("my-http-source")
1957                .with_host("localhost")
1958                .build()
1959                .unwrap();
1960            assert_eq!(source.id(), "my-http-source");
1961        }
1962
1963        #[test]
1964        fn test_type_name_returns_http() {
1965            let source = HttpSourceBuilder::new("test")
1966                .with_host("localhost")
1967                .build()
1968                .unwrap();
1969            assert_eq!(source.type_name(), "http");
1970        }
1971
1972        #[test]
1973        fn test_properties_contains_host_and_port() {
1974            let source = HttpSourceBuilder::new("test")
1975                .with_host("192.168.1.1")
1976                .with_port(9000)
1977                .build()
1978                .unwrap();
1979            let props = source.properties();
1980
1981            assert_eq!(
1982                props.get("host"),
1983                Some(&serde_json::Value::String("192.168.1.1".to_string()))
1984            );
1985            assert_eq!(
1986                props.get("port"),
1987                Some(&serde_json::Value::Number(9000.into()))
1988            );
1989        }
1990
1991        #[test]
1992        fn test_properties_includes_endpoint_when_set() {
1993            let source = HttpSourceBuilder::new("test")
1994                .with_host("localhost")
1995                .with_endpoint("/api/v1")
1996                .build()
1997                .unwrap();
1998            let props = source.properties();
1999
2000            assert_eq!(
2001                props.get("endpoint"),
2002                Some(&serde_json::Value::String("/api/v1".to_string()))
2003            );
2004        }
2005
2006        #[test]
2007        fn test_properties_excludes_endpoint_when_none() {
2008            let source = HttpSourceBuilder::new("test")
2009                .with_host("localhost")
2010                .build()
2011                .unwrap();
2012            let props = source.properties();
2013
2014            assert!(!props.contains_key("endpoint"));
2015        }
2016
2017        #[test]
2018        fn test_describe_schema_uses_webhook_mappings() {
2019            let source = HttpSourceBuilder::new("test")
2020                .with_host("localhost")
2021                .with_webhooks(WebhookConfig {
2022                    error_behavior: ErrorBehavior::AcceptAndLog,
2023                    cors: None,
2024                    routes: vec![crate::config::WebhookRoute {
2025                        path: "/events".to_string(),
2026                        methods: vec![crate::config::HttpMethod::Post],
2027                        auth: None,
2028                        error_behavior: None,
2029                        mappings: vec![crate::config::WebhookMapping {
2030                            when: None,
2031                            operation: Some(crate::config::OperationType::Insert),
2032                            operation_from: None,
2033                            operation_map: None,
2034                            element_type: crate::config::ElementType::Node,
2035                            effective_from: None,
2036                            template: crate::config::ElementTemplate {
2037                                id: "{{payload.id}}".to_string(),
2038                                labels: vec!["Order".to_string()],
2039                                properties: Some(serde_json::json!({
2040                                    "total": "{{payload.total}}",
2041                                    "status": "{{payload.status}}"
2042                                })),
2043                                from: None,
2044                                to: None,
2045                            },
2046                        }],
2047                    }],
2048                })
2049                .build()
2050                .unwrap();
2051
2052            let schema = source
2053                .describe_schema()
2054                .expect("webhook-configured HTTP source should expose schema");
2055
2056            assert_eq!(schema.nodes.len(), 1);
2057            let node = &schema.nodes[0];
2058            assert_eq!(node.label, "Order");
2059            assert!(node
2060                .properties
2061                .iter()
2062                .any(|property| property.name == "total"));
2063            assert!(node
2064                .properties
2065                .iter()
2066                .any(|property| property.name == "status"));
2067        }
2068
2069        #[test]
2070        fn test_describe_schema_includes_relation_mappings() {
2071            let source = HttpSourceBuilder::new("test")
2072                .with_host("localhost")
2073                .with_webhooks(WebhookConfig {
2074                    error_behavior: ErrorBehavior::AcceptAndLog,
2075                    cors: None,
2076                    routes: vec![crate::config::WebhookRoute {
2077                        path: "/events".to_string(),
2078                        methods: vec![crate::config::HttpMethod::Post],
2079                        auth: None,
2080                        error_behavior: None,
2081                        mappings: vec![crate::config::WebhookMapping {
2082                            when: None,
2083                            operation: Some(crate::config::OperationType::Insert),
2084                            operation_from: None,
2085                            operation_map: None,
2086                            element_type: crate::config::ElementType::Relation,
2087                            effective_from: None,
2088                            template: crate::config::ElementTemplate {
2089                                id: "{{payload.id}}".to_string(),
2090                                labels: vec!["PLACED_BY".to_string()],
2091                                properties: Some(serde_json::json!({
2092                                    "placed_at": "{{payload.timestamp}}"
2093                                })),
2094                                from: None,
2095                                to: None,
2096                            },
2097                        }],
2098                    }],
2099                })
2100                .build()
2101                .unwrap();
2102
2103            let schema = source
2104                .describe_schema()
2105                .expect("webhook-configured HTTP source should expose schema for relations");
2106
2107            assert_eq!(schema.relations.len(), 1);
2108            let relation = &schema.relations[0];
2109            assert_eq!(relation.label, "PLACED_BY");
2110            assert!(relation
2111                .properties
2112                .iter()
2113                .any(|property| property.name == "placed_at"));
2114            // Static derivation cannot infer endpoints
2115            assert_eq!(relation.from, None);
2116            assert_eq!(relation.to, None);
2117        }
2118    }
2119
2120    mod lifecycle {
2121        use super::*;
2122
2123        #[tokio::test]
2124        async fn test_initial_status_is_stopped() {
2125            let source = HttpSourceBuilder::new("test")
2126                .with_host("localhost")
2127                .build()
2128                .unwrap();
2129            assert_eq!(source.status().await, ComponentStatus::Stopped);
2130        }
2131    }
2132
2133    mod builder {
2134        use super::*;
2135
2136        #[test]
2137        fn test_http_builder_defaults() {
2138            let source = HttpSourceBuilder::new("test").build().unwrap();
2139            assert_eq!(source.config.port, 8080);
2140            assert_eq!(source.config.timeout_ms, 10000);
2141            assert_eq!(source.config.endpoint, None);
2142        }
2143
2144        #[test]
2145        fn test_http_builder_custom_values() {
2146            let source = HttpSourceBuilder::new("test")
2147                .with_host("api.example.com")
2148                .with_port(9000)
2149                .with_endpoint("/webhook")
2150                .with_timeout_ms(5000)
2151                .build()
2152                .unwrap();
2153
2154            assert_eq!(source.config.host, "api.example.com");
2155            assert_eq!(source.config.port, 9000);
2156            assert_eq!(source.config.endpoint, Some("/webhook".to_string()));
2157            assert_eq!(source.config.timeout_ms, 5000);
2158        }
2159
2160        #[test]
2161        fn test_http_builder_adaptive_batching() {
2162            let source = HttpSourceBuilder::new("test")
2163                .with_host("localhost")
2164                .with_adaptive_max_batch_size(1000)
2165                .with_adaptive_min_batch_size(10)
2166                .with_adaptive_max_wait_ms(500)
2167                .with_adaptive_min_wait_ms(50)
2168                .with_adaptive_window_secs(60)
2169                .with_adaptive_enabled(true)
2170                .build()
2171                .unwrap();
2172
2173            assert_eq!(source.config.adaptive_max_batch_size, Some(1000));
2174            assert_eq!(source.config.adaptive_min_batch_size, Some(10));
2175            assert_eq!(source.config.adaptive_max_wait_ms, Some(500));
2176            assert_eq!(source.config.adaptive_min_wait_ms, Some(50));
2177            assert_eq!(source.config.adaptive_window_secs, Some(60));
2178            assert_eq!(source.config.adaptive_enabled, Some(true));
2179        }
2180
2181        #[test]
2182        fn test_builder_id() {
2183            let source = HttpSource::builder("my-http-source")
2184                .with_host("localhost")
2185                .build()
2186                .unwrap();
2187
2188            assert_eq!(source.base.id, "my-http-source");
2189        }
2190    }
2191
2192    mod event_conversion {
2193        use super::*;
2194
2195        #[test]
2196        fn test_convert_node_insert() {
2197            let mut props = serde_json::Map::new();
2198            props.insert(
2199                "name".to_string(),
2200                serde_json::Value::String("Alice".to_string()),
2201            );
2202            props.insert("age".to_string(), serde_json::Value::Number(30.into()));
2203
2204            let http_change = HttpSourceChange::Insert {
2205                element: HttpElement::Node {
2206                    id: "user-1".to_string(),
2207                    labels: vec!["User".to_string()],
2208                    properties: props,
2209                },
2210                timestamp: Some(1234567890000000000),
2211            };
2212
2213            let result = convert_http_to_source_change(&http_change, "test-source");
2214            assert!(result.is_ok());
2215
2216            match result.unwrap() {
2217                drasi_core::models::SourceChange::Insert { element } => match element {
2218                    drasi_core::models::Element::Node {
2219                        metadata,
2220                        properties,
2221                    } => {
2222                        assert_eq!(metadata.reference.element_id.as_ref(), "user-1");
2223                        assert_eq!(metadata.labels.len(), 1);
2224                        assert_eq!(metadata.effective_from, 1234567890000);
2225                        assert!(properties.get("name").is_some());
2226                        assert!(properties.get("age").is_some());
2227                    }
2228                    _ => panic!("Expected Node element"),
2229                },
2230                _ => panic!("Expected Insert operation"),
2231            }
2232        }
2233
2234        #[test]
2235        fn test_convert_relation_insert() {
2236            let http_change = HttpSourceChange::Insert {
2237                element: HttpElement::Relation {
2238                    id: "follows-1".to_string(),
2239                    labels: vec!["FOLLOWS".to_string()],
2240                    from: "user-1".to_string(),
2241                    to: "user-2".to_string(),
2242                    properties: serde_json::Map::new(),
2243                },
2244                timestamp: None,
2245            };
2246
2247            let result = convert_http_to_source_change(&http_change, "test-source");
2248            assert!(result.is_ok());
2249
2250            match result.unwrap() {
2251                drasi_core::models::SourceChange::Insert { element } => match element {
2252                    drasi_core::models::Element::Relation {
2253                        metadata,
2254                        out_node,
2255                        in_node,
2256                        ..
2257                    } => {
2258                        assert_eq!(metadata.reference.element_id.as_ref(), "follows-1");
2259                        assert_eq!(in_node.element_id.as_ref(), "user-1");
2260                        assert_eq!(out_node.element_id.as_ref(), "user-2");
2261                    }
2262                    _ => panic!("Expected Relation element"),
2263                },
2264                _ => panic!("Expected Insert operation"),
2265            }
2266        }
2267
2268        #[test]
2269        fn test_convert_delete() {
2270            let http_change = HttpSourceChange::Delete {
2271                id: "user-1".to_string(),
2272                labels: Some(vec!["User".to_string()]),
2273                timestamp: Some(9999999999),
2274            };
2275
2276            let result = convert_http_to_source_change(&http_change, "test-source");
2277            assert!(result.is_ok());
2278
2279            match result.unwrap() {
2280                drasi_core::models::SourceChange::Delete { metadata } => {
2281                    assert_eq!(metadata.reference.element_id.as_ref(), "user-1");
2282                    assert_eq!(metadata.labels.len(), 1);
2283                }
2284                _ => panic!("Expected Delete operation"),
2285            }
2286        }
2287
2288        #[test]
2289        fn test_convert_update() {
2290            let http_change = HttpSourceChange::Update {
2291                element: HttpElement::Node {
2292                    id: "user-1".to_string(),
2293                    labels: vec!["User".to_string()],
2294                    properties: serde_json::Map::new(),
2295                },
2296                timestamp: None,
2297            };
2298
2299            let result = convert_http_to_source_change(&http_change, "test-source");
2300            assert!(result.is_ok());
2301
2302            match result.unwrap() {
2303                drasi_core::models::SourceChange::Update { .. } => {
2304                    // Success
2305                }
2306                _ => panic!("Expected Update operation"),
2307            }
2308        }
2309    }
2310
2311    mod adaptive_config {
2312        use super::*;
2313
2314        #[test]
2315        fn test_adaptive_config_from_http_config() {
2316            let source = HttpSourceBuilder::new("test")
2317                .with_host("localhost")
2318                .with_adaptive_max_batch_size(500)
2319                .with_adaptive_enabled(true)
2320                .build()
2321                .unwrap();
2322
2323            // The adaptive config should be initialized from the http config
2324            assert_eq!(source.adaptive_config.max_batch_size, 500);
2325            assert!(source.adaptive_config.adaptive_enabled);
2326        }
2327
2328        #[test]
2329        fn test_adaptive_config_uses_defaults_when_not_specified() {
2330            let source = HttpSourceBuilder::new("test")
2331                .with_host("localhost")
2332                .build()
2333                .unwrap();
2334
2335            // Should use AdaptiveBatchConfig defaults
2336            let default_config = AdaptiveBatchConfig::default();
2337            assert_eq!(
2338                source.adaptive_config.max_batch_size,
2339                default_config.max_batch_size
2340            );
2341            assert_eq!(
2342                source.adaptive_config.min_batch_size,
2343                default_config.min_batch_size
2344            );
2345        }
2346    }
2347}
2348
2349#[cfg(test)]
2350mod fallback_tests {
2351    use super::*;
2352    use drasi_lib::sources::Source;
2353
2354    #[test]
2355    fn test_builder_fallback_produces_camel_case() {
2356        let source = HttpSourceBuilder::new("http-fallback")
2357            .with_host("0.0.0.0")
2358            .with_port(9090)
2359            .with_endpoint("/ingest")
2360            .with_timeout_ms(5000)
2361            .with_adaptive_max_batch_size(500)
2362            .with_adaptive_min_batch_size(10)
2363            .with_adaptive_max_wait_ms(2000)
2364            .with_adaptive_min_wait_ms(100)
2365            .build()
2366            .unwrap();
2367
2368        let props = source.properties();
2369
2370        // Must use camelCase keys (DTO serialization)
2371        assert!(
2372            props.contains_key("timeoutMs"),
2373            "expected camelCase 'timeoutMs', got keys: {:?}",
2374            props.keys().collect::<Vec<_>>()
2375        );
2376        assert!(
2377            props.contains_key("adaptiveMaxBatchSize"),
2378            "expected camelCase 'adaptiveMaxBatchSize'"
2379        );
2380        assert!(
2381            props.contains_key("adaptiveMinBatchSize"),
2382            "expected camelCase 'adaptiveMinBatchSize'"
2383        );
2384        assert!(
2385            props.contains_key("adaptiveMaxWaitMs"),
2386            "expected camelCase 'adaptiveMaxWaitMs'"
2387        );
2388        assert!(
2389            props.contains_key("adaptiveMinWaitMs"),
2390            "expected camelCase 'adaptiveMinWaitMs'"
2391        );
2392
2393        // Must NOT have snake_case keys
2394        assert!(
2395            !props.contains_key("timeout_ms"),
2396            "should not have snake_case 'timeout_ms'"
2397        );
2398        assert!(
2399            !props.contains_key("adaptive_max_batch_size"),
2400            "should not have snake_case 'adaptive_max_batch_size'"
2401        );
2402
2403        // Values should be correct
2404        assert_eq!(props.get("host").and_then(|v| v.as_str()), Some("0.0.0.0"));
2405        assert_eq!(props.get("port").and_then(|v| v.as_u64()), Some(9090));
2406        assert_eq!(
2407            props.get("endpoint").and_then(|v| v.as_str()),
2408            Some("/ingest")
2409        );
2410        assert_eq!(props.get("timeoutMs").and_then(|v| v.as_u64()), Some(5000));
2411        assert_eq!(
2412            props.get("adaptiveMaxBatchSize").and_then(|v| v.as_u64()),
2413            Some(500)
2414        );
2415    }
2416}
2417
2418/// Dynamic plugin entry point.
2419///
2420/// Dynamic plugin entry point.
2421#[cfg(feature = "dynamic-plugin")]
2422drasi_plugin_sdk::export_plugin!(
2423    plugin_id = "http-source",
2424    core_version = env!("CARGO_PKG_VERSION"),
2425    lib_version = env!("CARGO_PKG_VERSION"),
2426    plugin_version = env!("CARGO_PKG_VERSION"),
2427    source_descriptors = [descriptor::HttpSourceDescriptor],
2428    reaction_descriptors = [],
2429    bootstrap_descriptors = [],
2430);