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