Skip to main content

drasi_source_http/
lib.rs

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