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