Skip to main content

outlet_postgres/
lib.rs

1//! # outlet-postgres
2//!
3//! PostgreSQL logging handler for the outlet HTTP request/response middleware.
4//! This crate implements the `RequestHandler` trait from outlet to log HTTP
5//! requests and responses to PostgreSQL with JSONB serialization for bodies.
6//!
7//! ## Quick Start
8//!
9//! Basic usage:
10//!
11//! ```rust,no_run
12//! use outlet::{RequestLoggerLayer, RequestLoggerConfig};
13//! use outlet_postgres::PostgresHandler;
14//! use axum::{routing::get, Router};
15//! use tower::ServiceBuilder;
16//!
17//! async fn hello() -> &'static str {
18//!     "Hello, World!"
19//! }
20//!
21//! #[tokio::main]
22//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
23//!     let database_url = "postgresql://user:password@localhost/dbname";
24//!     let handler: PostgresHandler = PostgresHandler::new(database_url).await?;
25//!     let layer = RequestLoggerLayer::new(RequestLoggerConfig::default(), handler);
26//!
27//!     let app = Router::new()
28//!         .route("/hello", get(hello))
29//!         .layer(ServiceBuilder::new().layer(layer));
30//!
31//!     let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
32//!     axum::serve(listener, app).await?;
33//!     Ok(())
34//! }
35//! ```
36//!
37//! ## Features
38//!
39//! - **PostgreSQL Integration**: Uses sqlx for async PostgreSQL operations
40//! - **JSONB Bodies**: Serializes request/response bodies to JSONB fields
41//! - **Type-safe Querying**: Query logged data with typed request/response bodies
42//! - **Correlation**: Links requests and responses via correlation IDs
43//! - **Error Handling**: Graceful error handling with logging
44//! - **Flexible Serialization**: Generic error handling for custom serializer types
45
46/// Error type for serialization failures with fallback data.
47///
48/// When serializers fail to parse request/response bodies into structured types,
49/// this error provides both the parsing error details and fallback data that
50/// can be stored as a string representation.
51#[derive(Debug)]
52pub struct SerializationError {
53    /// The fallback representation of the data (e.g., base64-encoded, raw string)
54    pub fallback_data: String,
55    /// The underlying error that caused serialization to fail
56    pub error: Box<dyn std::error::Error + Send + Sync>,
57}
58
59impl SerializationError {
60    /// Create a new serialization error with fallback data
61    pub fn new(
62        fallback_data: String,
63        error: impl std::error::Error + Send + Sync + 'static,
64    ) -> Self {
65        Self {
66            fallback_data,
67            error: Box::new(error),
68        }
69    }
70}
71
72impl std::fmt::Display for SerializationError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "Serialization failed: {}", self.error)
75    }
76}
77
78impl std::error::Error for SerializationError {
79    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
80        Some(self.error.as_ref())
81    }
82}
83
84use chrono::{DateTime, Utc};
85use metrics::{counter, histogram};
86use outlet::{RequestData, RequestHandler, ResponseData};
87use serde::{Deserialize, Serialize};
88use serde_json::Value;
89use sqlx::PgPool;
90use std::collections::HashMap;
91use std::sync::Arc;
92use std::time::{Instant, SystemTime};
93use tracing::{debug, error, instrument, warn};
94use uuid::Uuid;
95
96pub mod error;
97pub mod repository;
98pub use error::PostgresHandlerError;
99pub use repository::{
100    HttpRequest, HttpResponse, RequestFilter, RequestRepository, RequestResponsePair,
101};
102
103// Re-export from sqlx-pool-router
104pub use sqlx_pool_router::{DbPools, PoolProvider, TestDbPools};
105
106/// Get the migrator for running outlet-postgres database migrations.
107///
108/// This returns a SQLx migrator that can be used to set up the required
109/// `http_requests` and `http_responses` tables. The consuming application
110/// is responsible for running these migrations at the appropriate time
111/// and in the appropriate database schema.
112///
113/// # Examples
114///
115/// ```rust,no_run
116/// use outlet_postgres::migrator;
117/// use sqlx::PgPool;
118///
119/// #[tokio::main]
120/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
121///     let pool = PgPool::connect("postgresql://user:pass@localhost/db").await?;
122///     
123///     // Run outlet migrations
124///     migrator().run(&pool).await?;
125///     
126///     Ok(())
127/// }
128/// ```
129pub fn migrator() -> sqlx::migrate::Migrator {
130    sqlx::migrate!("./migrations")
131}
132
133/// Type alias for request body serializers.
134///
135/// Request serializers take full request context including headers and body bytes.
136/// On failure, they return a `SerializationError` with fallback data.
137type RequestSerializer<T> =
138    Arc<dyn Fn(&outlet::RequestData) -> Result<T, SerializationError> + Send + Sync>;
139
140/// Type alias for response body serializers.
141///
142/// Response serializers take both request and response context, allowing them to
143/// make parsing decisions based on request details and response headers (e.g., compression).
144/// On failure, they return a `SerializationError` with fallback data.
145type ResponseSerializer<T> = Arc<
146    dyn Fn(&outlet::RequestData, &outlet::ResponseData) -> Result<T, SerializationError>
147        + Send
148        + Sync,
149>;
150
151/// PostgreSQL handler for outlet middleware.
152///
153/// Implements the `RequestHandler` trait to log HTTP requests and responses
154/// to PostgreSQL. Request and response bodies are serialized to JSONB fields.
155///
156/// Generic over:
157/// - `P`: Pool provider implementing `PoolProvider` trait for read/write routing
158/// - `TReq` and `TRes`: Request and response body types for JSONB serialization
159///
160/// Use `serde_json::Value` for flexible JSON storage, or custom structs for typed storage.
161#[derive(Clone)]
162pub struct PostgresHandler<P = PgPool, TReq = Value, TRes = Value>
163where
164    P: PoolProvider,
165    TReq: for<'de> Deserialize<'de> + Serialize + Send + Sync + 'static,
166    TRes: for<'de> Deserialize<'de> + Serialize + Send + Sync + 'static,
167{
168    pool: P,
169    request_serializer: RequestSerializer<TReq>,
170    response_serializer: ResponseSerializer<TRes>,
171    instance_id: Uuid,
172}
173
174impl<P, TReq, TRes> PostgresHandler<P, TReq, TRes>
175where
176    P: PoolProvider,
177    TReq: for<'de> Deserialize<'de> + Serialize + Send + Sync + 'static,
178    TRes: for<'de> Deserialize<'de> + Serialize + Send + Sync + 'static,
179{
180    /// Default serializer that attempts serde JSON deserialization.
181    /// On failure, returns a SerializationError with raw bytes as fallback data.
182    fn default_request_serializer() -> RequestSerializer<TReq> {
183        Arc::new(|request_data| {
184            let bytes = request_data.body.as_deref().unwrap_or(&[]);
185            serde_json::from_slice::<TReq>(bytes).map_err(|error| {
186                let fallback_data = String::from_utf8_lossy(bytes).to_string();
187                SerializationError::new(fallback_data, error)
188            })
189        })
190    }
191
192    /// Default serializer that attempts serde JSON deserialization.
193    /// On failure, returns a SerializationError with raw bytes as fallback data.
194    fn default_response_serializer() -> ResponseSerializer<TRes> {
195        Arc::new(|_request_data, response_data| {
196            let bytes = response_data.body.as_deref().unwrap_or(&[]);
197            serde_json::from_slice::<TRes>(bytes).map_err(|error| {
198                let fallback_data = String::from_utf8_lossy(bytes).to_string();
199                SerializationError::new(fallback_data, error)
200            })
201        })
202    }
203
204    /// Add a custom request body serializer.
205    ///
206    /// The serializer function takes raw bytes and should return a `Result<TReq, String>`.
207    /// If the serializer succeeds, the result will be stored as JSONB and `body_parsed` will be true.
208    /// If it fails, the raw content will be stored as a UTF-8 string and `body_parsed` will be false.
209    ///
210    /// # Panics
211    ///
212    /// This will panic if the serializer succeeds but the resulting `TReq` value cannot be
213    /// converted to JSON via `serde_json::to_value()`. This indicates a bug in the `Serialize`
214    /// implementation of `TReq` and should be fixed by the caller.
215    pub fn with_request_serializer<F>(mut self, serializer: F) -> Self
216    where
217        F: Fn(&outlet::RequestData) -> Result<TReq, SerializationError> + Send + Sync + 'static,
218    {
219        self.request_serializer = Arc::new(serializer);
220        self
221    }
222
223    /// Add a custom response body serializer.
224    ///
225    /// The serializer function takes raw bytes and should return a `Result<TRes, String>`.
226    /// If the serializer succeeds, the result will be stored as JSONB and `body_parsed` will be true.
227    /// If it fails, the raw content will be stored as a UTF-8 string and `body_parsed` will be false.
228    ///
229    /// # Panics
230    ///
231    /// This will panic if the serializer succeeds but the resulting `TRes` value cannot be
232    /// converted to JSON via `serde_json::to_value()`. This indicates a bug in the `Serialize`
233    /// implementation of `TRes` and should be fixed by the caller.
234    pub fn with_response_serializer<F>(mut self, serializer: F) -> Self
235    where
236        F: Fn(&outlet::RequestData, &outlet::ResponseData) -> Result<TRes, SerializationError>
237            + Send
238            + Sync
239            + 'static,
240    {
241        self.response_serializer = Arc::new(serializer);
242        self
243    }
244
245    /// Create a PostgreSQL handler from a pool provider.
246    ///
247    /// Use this if you want to use a custom pool provider implementation
248    /// (such as `DbPools` for read/write separation).
249    /// This will NOT run migrations - use `migrator()` to run migrations separately.
250    ///
251    /// # Arguments
252    ///
253    /// * `pool_provider` - Pool provider implementing `PoolProvider` trait
254    ///
255    /// # Examples
256    ///
257    /// ```rust,no_run
258    /// use outlet_postgres::{PostgresHandler, DbPools, migrator};
259    /// use sqlx::postgres::PgPoolOptions;
260    /// use serde::{Deserialize, Serialize};
261    ///
262    /// #[derive(Deserialize, Serialize)]
263    /// struct MyBodyType {
264    ///     id: u64,
265    ///     name: String,
266    /// }
267    ///
268    /// #[tokio::main]
269    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
270    ///     let primary = PgPoolOptions::new()
271    ///         .connect("postgresql://user:pass@primary/db").await?;
272    ///     let replica = PgPoolOptions::new()
273    ///         .connect("postgresql://user:pass@replica/db").await?;
274    ///
275    ///     // Run migrations on primary
276    ///     migrator().run(&primary).await?;
277    ///
278    ///     // Create handler with read/write separation
279    ///     let pools = DbPools::with_replica(primary, replica);
280    ///     let handler = PostgresHandler::<_, MyBodyType, MyBodyType>::from_pool_provider(pools).await?;
281    ///     Ok(())
282    /// }
283    /// ```
284    pub async fn from_pool_provider(pool_provider: P) -> Result<Self, PostgresHandlerError> {
285        Ok(Self {
286            pool: pool_provider,
287            request_serializer: Self::default_request_serializer(),
288            response_serializer: Self::default_response_serializer(),
289            instance_id: Uuid::new_v4(),
290        })
291    }
292
293    /// Convert headers to a JSONB-compatible format.
294    fn headers_to_json(headers: &HashMap<String, Vec<bytes::Bytes>>) -> Value {
295        let mut header_map = HashMap::new();
296        for (name, values) in headers {
297            if values.len() == 1 {
298                let value_str = String::from_utf8_lossy(&values[0]).to_string();
299                header_map.insert(name.clone(), Value::String(value_str));
300            } else {
301                let value_array: Vec<Value> = values
302                    .iter()
303                    .map(|v| Value::String(String::from_utf8_lossy(v).to_string()))
304                    .collect();
305                header_map.insert(name.clone(), Value::Array(value_array));
306            }
307        }
308        serde_json::to_value(header_map).unwrap_or(Value::Null)
309    }
310
311    /// Convert request data to a JSONB value using the configured serializer.
312    fn request_body_to_json_with_fallback(
313        &self,
314        request_data: &outlet::RequestData,
315    ) -> (Value, bool) {
316        match (self.request_serializer)(request_data) {
317            Ok(typed_value) => {
318                if let Ok(json_value) = serde_json::to_value(&typed_value) {
319                    (json_value, true)
320                } else {
321                    // This should never happen if the type implements Serialize correctly
322                    (
323                        Value::String(
324                            serde_json::to_string(&typed_value)
325                                .expect("Serialized value must be convertible to JSON string"),
326                        ),
327                        false,
328                    )
329                }
330            }
331            Err(serialization_error) => (Value::String(serialization_error.fallback_data), false),
332        }
333    }
334
335    /// Convert response data to a JSONB value using the configured serializer.
336    fn response_body_to_json_with_fallback(
337        &self,
338        request_data: &outlet::RequestData,
339        response_data: &outlet::ResponseData,
340    ) -> (Value, bool) {
341        match (self.response_serializer)(request_data, response_data) {
342            Ok(typed_value) => {
343                if let Ok(json_value) = serde_json::to_value(&typed_value) {
344                    (json_value, true)
345                } else {
346                    // This should never happen if the type implements Serialize correctly
347                    (
348                        Value::String(
349                            serde_json::to_string(&typed_value)
350                                .expect("Serialized value must be convertible to JSON string"),
351                        ),
352                        false,
353                    )
354                }
355            }
356            Err(serialization_error) => (Value::String(serialization_error.fallback_data), false),
357        }
358    }
359
360    /// Get a repository for querying logged requests and responses.
361    ///
362    /// Returns a `RequestRepository` with the same type parameters as this handler,
363    /// allowing for type-safe querying of request and response bodies.
364    pub fn repository(&self) -> crate::repository::RequestRepository<P, TReq, TRes> {
365        crate::repository::RequestRepository::new(self.pool.clone())
366    }
367}
368
369// Backward-compatible constructors for PgPool
370impl<TReq, TRes> PostgresHandler<PgPool, TReq, TRes>
371where
372    TReq: for<'de> Deserialize<'de> + Serialize + Send + Sync + 'static,
373    TRes: for<'de> Deserialize<'de> + Serialize + Send + Sync + 'static,
374{
375    /// Create a new PostgreSQL handler with a connection pool.
376    ///
377    /// This will connect to the database but will NOT run migrations.
378    /// Use `migrator()` to get a migrator and run migrations separately.
379    ///
380    /// # Arguments
381    ///
382    /// * `database_url` - PostgreSQL connection string
383    ///
384    /// # Examples
385    ///
386    /// ```rust,no_run
387    /// use outlet_postgres::{PostgresHandler, migrator};
388    /// use serde::{Deserialize, Serialize};
389    ///
390    /// #[derive(Deserialize, Serialize)]
391    /// struct MyBodyType {
392    ///     id: u64,
393    ///     name: String,
394    /// }
395    ///
396    /// #[tokio::main]
397    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
398    ///     // Run migrations first
399    ///     let pool = sqlx::PgPool::connect("postgresql://user:pass@localhost/db").await?;
400    ///     migrator().run(&pool).await?;
401    ///
402    ///     // Create handler
403    ///     let handler = PostgresHandler::<_, MyBodyType, MyBodyType>::new("postgresql://user:pass@localhost/db").await?;
404    ///     Ok(())
405    /// }
406    /// ```
407    pub async fn new(database_url: &str) -> Result<Self, PostgresHandlerError> {
408        let pool = PgPool::connect(database_url)
409            .await
410            .map_err(PostgresHandlerError::Connection)?;
411
412        Ok(Self {
413            pool,
414            request_serializer: Self::default_request_serializer(),
415            response_serializer: Self::default_response_serializer(),
416            instance_id: Uuid::new_v4(),
417        })
418    }
419
420    /// Create a PostgreSQL handler from an existing connection pool.
421    ///
422    /// Use this if you already have a connection pool and want to reuse it.
423    /// This will NOT run migrations - use `migrator()` to run migrations separately.
424    ///
425    /// # Arguments
426    ///
427    /// * `pool` - Existing PostgreSQL connection pool
428    ///
429    /// # Examples
430    ///
431    /// ```rust,no_run
432    /// use outlet_postgres::{PostgresHandler, migrator};
433    /// use sqlx::PgPool;
434    /// use serde::{Deserialize, Serialize};
435    ///
436    /// #[derive(Deserialize, Serialize)]
437    /// struct MyBodyType {
438    ///     id: u64,
439    ///     name: String,
440    /// }
441    ///
442    /// #[tokio::main]
443    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
444    ///     let pool = PgPool::connect("postgresql://user:pass@localhost/db").await?;
445    ///
446    ///     // Run migrations first
447    ///     migrator().run(&pool).await?;
448    ///
449    ///     // Create handler
450    ///     let handler = PostgresHandler::<_, MyBodyType, MyBodyType>::from_pool(pool).await?;
451    ///     Ok(())
452    /// }
453    /// ```
454    pub async fn from_pool(pool: PgPool) -> Result<Self, PostgresHandlerError> {
455        Self::from_pool_provider(pool).await
456    }
457}
458
459impl<P, TReq, TRes> RequestHandler for PostgresHandler<P, TReq, TRes>
460where
461    P: PoolProvider,
462    TReq: for<'de> Deserialize<'de> + Serialize + Send + Sync + 'static,
463    TRes: for<'de> Deserialize<'de> + Serialize + Send + Sync + 'static,
464{
465    #[instrument(name = "outlet.handle_request", skip(self, data), fields(correlation_id = %data.correlation_id))]
466    async fn handle_request(&self, data: RequestData) {
467        let headers_json = Self::headers_to_json(&data.headers);
468        let (body_json, parsed) = if data.body.is_some() {
469            let (json, parsed) = self.request_body_to_json_with_fallback(&data);
470            (Some(json), parsed)
471        } else {
472            (None, false)
473        };
474
475        let timestamp: DateTime<Utc> = data.timestamp.into();
476
477        let query_start = Instant::now();
478        let result = sqlx::query(
479            r#"
480            INSERT INTO http_requests (instance_id, correlation_id, timestamp, method, uri, headers, body, body_parsed, trace_id, span_id)
481            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
482            "#,
483        )
484        .bind(self.instance_id)
485        .bind(data.correlation_id as i64)
486        .bind(timestamp)
487        .bind(data.method.to_string())
488        .bind(data.uri.to_string())
489        .bind(headers_json)
490        .bind(body_json)
491        .bind(parsed)
492        .bind(&data.trace_id)
493        .bind(&data.span_id)
494        .execute(self.pool.write())
495        .await;
496        let query_duration = query_start.elapsed();
497        histogram!("outlet_write_duration_seconds", "operation" => "request")
498            .record(query_duration.as_secs_f64());
499
500        if let Err(e) = result {
501            counter!("outlet_write_errors_total", "operation" => "request").increment(1);
502            error!(correlation_id = %data.correlation_id, error = %e, "Failed to insert request data");
503        } else {
504            let processing_lag_ms = SystemTime::now()
505                .duration_since(data.timestamp)
506                .unwrap_or_default()
507                .as_millis();
508            if processing_lag_ms > 1000 {
509                warn!(correlation_id = %data.correlation_id, method = %data.method, uri = %data.uri, lag_ms = %processing_lag_ms, "Request logged (slow)");
510            } else {
511                debug!(correlation_id = %data.correlation_id, method = %data.method, uri = %data.uri, lag_ms = %processing_lag_ms, "Request logged");
512            }
513        }
514    }
515
516    #[instrument(name = "outlet.handle_response", skip(self, request_data, response_data), fields(correlation_id = %request_data.correlation_id))]
517    async fn handle_response(&self, request_data: RequestData, response_data: ResponseData) {
518        let headers_json = Self::headers_to_json(&response_data.headers);
519        let (body_json, parsed) = if response_data.body.is_some() {
520            let (json, parsed) =
521                self.response_body_to_json_with_fallback(&request_data, &response_data);
522            (Some(json), parsed)
523        } else {
524            (None, false)
525        };
526
527        let timestamp: DateTime<Utc> = response_data.timestamp.into();
528        let duration_ms = response_data.duration.as_millis() as i64;
529        let duration_to_first_byte_ms = response_data.duration_to_first_byte.as_millis() as i64;
530
531        let query_start = Instant::now();
532        let result = sqlx::query(
533            r#"
534            INSERT INTO http_responses (instance_id, correlation_id, timestamp, status_code, headers, body, body_parsed, duration_to_first_byte_ms, duration_ms)
535            SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9
536            WHERE EXISTS (SELECT 1 FROM http_requests WHERE instance_id = $1 AND correlation_id = $2)
537            "#,
538        )
539        .bind(self.instance_id)
540        .bind(request_data.correlation_id as i64)
541        .bind(timestamp)
542        .bind(response_data.status.as_u16() as i32)
543        .bind(headers_json)
544        .bind(body_json)
545        .bind(parsed)
546        .bind(duration_to_first_byte_ms)
547        .bind(duration_ms)
548        .execute(self.pool.write())
549        .await;
550        let query_duration = query_start.elapsed();
551        histogram!("outlet_write_duration_seconds", "operation" => "response")
552            .record(query_duration.as_secs_f64());
553
554        match result {
555            Err(e) => {
556                counter!("outlet_write_errors_total", "operation" => "response").increment(1);
557                error!(correlation_id = %request_data.correlation_id, error = %e, "Failed to insert response data");
558            }
559            Ok(query_result) => {
560                if query_result.rows_affected() > 0 {
561                    let processing_lag_ms = SystemTime::now()
562                        .duration_since(response_data.timestamp)
563                        .unwrap_or_default()
564                        .as_millis();
565                    if processing_lag_ms > 1000 {
566                        warn!(correlation_id = %request_data.correlation_id, status = %response_data.status, duration_ms = %duration_ms, lag_ms = %processing_lag_ms, "Response logged (slow)");
567                    } else {
568                        debug!(correlation_id = %request_data.correlation_id, status = %response_data.status, duration_ms = %duration_ms, lag_ms = %processing_lag_ms, "Response logged");
569                    }
570                } else {
571                    debug!(correlation_id = %request_data.correlation_id, "No matching request found for response, skipping insert")
572                }
573            }
574        }
575    }
576
577    #[instrument(name = "outlet.handle_request_batch", skip(self, batch), fields(batch_size = batch.len()))]
578    async fn handle_request_batch(&self, batch: &[RequestData]) {
579        if batch.is_empty() {
580            return;
581        }
582
583        let len = batch.len();
584        let mut instance_ids = Vec::with_capacity(len);
585        let mut correlation_ids = Vec::with_capacity(len);
586        let mut timestamps = Vec::with_capacity(len);
587        let mut methods = Vec::with_capacity(len);
588        let mut uris = Vec::with_capacity(len);
589        let mut headers_col: Vec<Value> = Vec::with_capacity(len);
590        let mut bodies: Vec<Option<Value>> = Vec::with_capacity(len);
591        let mut body_parsed_col = Vec::with_capacity(len);
592        let mut trace_ids: Vec<Option<String>> = Vec::with_capacity(len);
593        let mut span_ids: Vec<Option<String>> = Vec::with_capacity(len);
594
595        for data in batch {
596            instance_ids.push(self.instance_id);
597            correlation_ids.push(data.correlation_id as i64);
598            timestamps.push(DateTime::<Utc>::from(data.timestamp));
599            methods.push(data.method.to_string());
600            uris.push(data.uri.to_string());
601            headers_col.push(Self::headers_to_json(&data.headers));
602
603            let (body_json, parsed) = if data.body.is_some() {
604                let (json, parsed) = self.request_body_to_json_with_fallback(data);
605                (Some(json), parsed)
606            } else {
607                (None, false)
608            };
609            bodies.push(body_json);
610            body_parsed_col.push(parsed);
611            trace_ids.push(data.trace_id.clone());
612            span_ids.push(data.span_id.clone());
613        }
614
615        let query_start = Instant::now();
616        let result = sqlx::query(
617            r#"
618            INSERT INTO http_requests (instance_id, correlation_id, timestamp, method, uri, headers, body, body_parsed, trace_id, span_id)
619            SELECT * FROM UNNEST($1::uuid[], $2::bigint[], $3::timestamptz[], $4::varchar[], $5::text[], $6::jsonb[], $7::jsonb[], $8::boolean[], $9::varchar[], $10::varchar[])
620            "#,
621        )
622        .bind(&instance_ids)
623        .bind(&correlation_ids)
624        .bind(&timestamps)
625        .bind(&methods)
626        .bind(&uris)
627        .bind(&headers_col)
628        .bind(&bodies)
629        .bind(&body_parsed_col)
630        .bind(&trace_ids)
631        .bind(&span_ids)
632        .execute(self.pool.write())
633        .await;
634        let query_duration = query_start.elapsed();
635        histogram!("outlet_write_duration_seconds", "operation" => "request_batch")
636            .record(query_duration.as_secs_f64());
637
638        match result {
639            Ok(r) => {
640                debug!(
641                    rows = r.rows_affected(),
642                    duration_ms = query_duration.as_millis() as u64,
643                    "Request batch inserted"
644                );
645            }
646            Err(e) => {
647                counter!("outlet_write_errors_total", "operation" => "request_batch").increment(1);
648                error!(batch_size = len, error = %e, "Failed to bulk insert request batch");
649            }
650        }
651    }
652
653    #[instrument(name = "outlet.handle_response_batch", skip(self, batch), fields(batch_size = batch.len()))]
654    async fn handle_response_batch(&self, batch: &[(RequestData, ResponseData)]) {
655        if batch.is_empty() {
656            return;
657        }
658
659        let len = batch.len();
660        let mut instance_ids = Vec::with_capacity(len);
661        let mut correlation_ids = Vec::with_capacity(len);
662        let mut timestamps = Vec::with_capacity(len);
663        let mut status_codes = Vec::with_capacity(len);
664        let mut headers_col: Vec<Value> = Vec::with_capacity(len);
665        let mut bodies: Vec<Option<Value>> = Vec::with_capacity(len);
666        let mut body_parsed_col = Vec::with_capacity(len);
667        let mut duration_to_first_byte_ms_col = Vec::with_capacity(len);
668        let mut duration_ms_col = Vec::with_capacity(len);
669
670        for (request_data, response_data) in batch {
671            instance_ids.push(self.instance_id);
672            correlation_ids.push(request_data.correlation_id as i64);
673            timestamps.push(DateTime::<Utc>::from(response_data.timestamp));
674            status_codes.push(response_data.status.as_u16() as i32);
675            headers_col.push(Self::headers_to_json(&response_data.headers));
676
677            let (body_json, parsed) = if response_data.body.is_some() {
678                let (json, parsed) =
679                    self.response_body_to_json_with_fallback(request_data, response_data);
680                (Some(json), parsed)
681            } else {
682                (None, false)
683            };
684            bodies.push(body_json);
685            body_parsed_col.push(parsed);
686            duration_to_first_byte_ms_col
687                .push(response_data.duration_to_first_byte.as_millis() as i64);
688            duration_ms_col.push(response_data.duration.as_millis() as i64);
689        }
690
691        let query_start = Instant::now();
692        let result = sqlx::query(
693            r#"
694            INSERT INTO http_responses (instance_id, correlation_id, timestamp, status_code, headers, body, body_parsed, duration_to_first_byte_ms, duration_ms)
695            SELECT * FROM UNNEST($1::uuid[], $2::bigint[], $3::timestamptz[], $4::int[], $5::jsonb[], $6::jsonb[], $7::boolean[], $8::bigint[], $9::bigint[])
696            "#,
697        )
698        .bind(&instance_ids)
699        .bind(&correlation_ids)
700        .bind(&timestamps)
701        .bind(&status_codes)
702        .bind(&headers_col)
703        .bind(&bodies)
704        .bind(&body_parsed_col)
705        .bind(&duration_to_first_byte_ms_col)
706        .bind(&duration_ms_col)
707        .execute(self.pool.write())
708        .await;
709        let query_duration = query_start.elapsed();
710        histogram!("outlet_write_duration_seconds", "operation" => "response_batch")
711            .record(query_duration.as_secs_f64());
712
713        match result {
714            Ok(r) => {
715                debug!(
716                    rows = r.rows_affected(),
717                    duration_ms = query_duration.as_millis() as u64,
718                    "Response batch inserted"
719                );
720            }
721            Err(e) => {
722                counter!("outlet_write_errors_total", "operation" => "response_batch").increment(1);
723                error!(batch_size = len, error = %e, "Failed to bulk insert response batch");
724            }
725        }
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use bytes::Bytes;
733    use chrono::{DateTime, Utc};
734    use outlet::{RequestData, ResponseData};
735    use serde::{Deserialize, Serialize};
736    use serde_json::Value;
737    use sqlx::PgPool;
738    use std::collections::HashMap;
739    use std::time::{Duration, SystemTime};
740
741    #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
742    struct TestRequest {
743        user_id: u64,
744        action: String,
745    }
746
747    #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
748    struct TestResponse {
749        success: bool,
750        message: String,
751    }
752
753    fn create_test_request_data() -> RequestData {
754        let mut headers = HashMap::new();
755        headers.insert("content-type".to_string(), vec!["application/json".into()]);
756        headers.insert("user-agent".to_string(), vec!["test-client/1.0".into()]);
757
758        let test_req = TestRequest {
759            user_id: 123,
760            action: "create_user".to_string(),
761        };
762        let body = serde_json::to_vec(&test_req).unwrap();
763
764        RequestData {
765            method: http::Method::POST,
766            uri: http::Uri::from_static("/api/users"),
767            headers,
768            body: Some(Bytes::from(body)),
769            timestamp: SystemTime::now(),
770            correlation_id: 0,
771            trace_id: None,
772            span_id: None,
773        }
774    }
775
776    fn create_test_response_data() -> ResponseData {
777        let mut headers = HashMap::new();
778        headers.insert("content-type".to_string(), vec!["application/json".into()]);
779
780        let test_res = TestResponse {
781            success: true,
782            message: "User created successfully".to_string(),
783        };
784        let body = serde_json::to_vec(&test_res).unwrap();
785
786        ResponseData {
787            status: http::StatusCode::CREATED,
788            headers,
789            body: Some(Bytes::from(body)),
790            timestamp: SystemTime::now(),
791            duration_to_first_byte: Duration::from_millis(100),
792            duration: Duration::from_millis(150),
793            correlation_id: 0,
794            extensions: Default::default(),
795        }
796    }
797
798    #[sqlx::test]
799    async fn test_handler_creation(pool: PgPool) {
800        // Run migrations first
801        crate::migrator().run(&pool).await.unwrap();
802
803        let handler = PostgresHandler::<PgPool, TestRequest, TestResponse>::from_pool(pool.clone())
804            .await
805            .unwrap();
806
807        // Verify we can get a repository
808        let repository = handler.repository();
809
810        // Test initial state - no requests logged yet
811        let filter = RequestFilter::default();
812        let results = repository.query(filter).await.unwrap();
813        assert!(results.is_empty());
814    }
815
816    #[sqlx::test]
817    async fn test_handle_request_with_typed_body(pool: PgPool) {
818        // Run migrations first
819        crate::migrator().run(&pool).await.unwrap();
820
821        let handler = PostgresHandler::<PgPool, TestRequest, TestResponse>::from_pool(pool.clone())
822            .await
823            .unwrap();
824        let repository = handler.repository();
825
826        let mut request_data = create_test_request_data();
827        let correlation_id = 12345;
828        request_data.correlation_id = correlation_id;
829
830        // Handle the request
831        handler.handle_request(request_data.clone()).await;
832
833        // Query back the request
834        let filter = RequestFilter {
835            correlation_id: Some(correlation_id as i64),
836            ..Default::default()
837        };
838        let results = repository.query(filter).await.unwrap();
839
840        assert_eq!(results.len(), 1);
841        let pair = &results[0];
842
843        assert_eq!(pair.request.correlation_id, correlation_id as i64);
844        assert_eq!(pair.request.method, "POST");
845        assert_eq!(pair.request.uri, "/api/users");
846
847        // Check that body was parsed successfully
848        match &pair.request.body {
849            Some(Ok(parsed_body)) => {
850                assert_eq!(
851                    *parsed_body,
852                    TestRequest {
853                        user_id: 123,
854                        action: "create_user".to_string(),
855                    }
856                );
857            }
858            _ => panic!("Expected successfully parsed request body"),
859        }
860
861        // Headers should be converted to JSON properly
862        let headers_value = &pair.request.headers;
863        assert!(headers_value.get("content-type").is_some());
864        assert!(headers_value.get("user-agent").is_some());
865
866        // No response yet
867        assert!(pair.response.is_none());
868    }
869
870    #[sqlx::test]
871    async fn test_handle_response_with_typed_body(pool: PgPool) {
872        // Run migrations first
873        crate::migrator().run(&pool).await.unwrap();
874
875        let handler = PostgresHandler::<PgPool, TestRequest, TestResponse>::from_pool(pool.clone())
876            .await
877            .unwrap();
878        let repository = handler.repository();
879
880        let mut request_data = create_test_request_data();
881        let mut response_data = create_test_response_data();
882        let correlation_id = 54321;
883        request_data.correlation_id = correlation_id;
884        response_data.correlation_id = correlation_id;
885
886        // Handle both request and response
887        handler.handle_request(request_data.clone()).await;
888        handler
889            .handle_response(request_data, response_data.clone())
890            .await;
891
892        // Query back the complete pair
893        let filter = RequestFilter {
894            correlation_id: Some(correlation_id as i64),
895            ..Default::default()
896        };
897        let results = repository.query(filter).await.unwrap();
898
899        assert_eq!(results.len(), 1);
900        let pair = &results[0];
901
902        // Check response data
903        let response = pair.response.as_ref().expect("Response should be present");
904        assert_eq!(response.correlation_id, correlation_id as i64);
905        assert_eq!(response.status_code, 201);
906        assert_eq!(response.duration_ms, 150);
907
908        // Check that response body was parsed successfully
909        match &response.body {
910            Some(Ok(parsed_body)) => {
911                assert_eq!(
912                    *parsed_body,
913                    TestResponse {
914                        success: true,
915                        message: "User created successfully".to_string(),
916                    }
917                );
918            }
919            _ => panic!("Expected successfully parsed response body"),
920        }
921    }
922
923    #[sqlx::test]
924    async fn test_handle_unparseable_body_fallback(pool: PgPool) {
925        // Run migrations first
926        crate::migrator().run(&pool).await.unwrap();
927
928        let handler = PostgresHandler::<PgPool, TestRequest, TestResponse>::from_pool(pool.clone())
929            .await
930            .unwrap();
931        let repository = handler.repository();
932
933        // Create request with invalid JSON for TestRequest
934        let mut headers = HashMap::new();
935        headers.insert("content-type".to_string(), vec!["text/plain".into()]);
936
937        let invalid_json_body = b"not valid json for TestRequest";
938        let correlation_id = 99999;
939        let request_data = RequestData {
940            method: http::Method::POST,
941            uri: http::Uri::from_static("/api/test"),
942            headers,
943            body: Some(Bytes::from(invalid_json_body.to_vec())),
944            timestamp: SystemTime::now(),
945            correlation_id,
946            trace_id: None,
947            span_id: None,
948        };
949
950        handler.handle_request(request_data).await;
951
952        // Query back and verify fallback to base64
953        let filter = RequestFilter {
954            correlation_id: Some(correlation_id as i64),
955            ..Default::default()
956        };
957        let results = repository.query(filter).await.unwrap();
958
959        assert_eq!(results.len(), 1);
960        let pair = &results[0];
961
962        // Should fallback to raw bytes
963        match &pair.request.body {
964            Some(Err(raw_bytes)) => {
965                assert_eq!(raw_bytes.as_ref(), invalid_json_body);
966            }
967            _ => panic!("Expected raw bytes fallback for unparseable body"),
968        }
969    }
970
971    #[sqlx::test]
972    async fn test_query_with_multiple_filters(pool: PgPool) {
973        // Run migrations first
974        crate::migrator().run(&pool).await.unwrap();
975
976        let handler = PostgresHandler::<PgPool, Value, Value>::from_pool(pool.clone())
977            .await
978            .unwrap();
979        let repository = handler.repository();
980
981        // Insert multiple requests with different characteristics
982        let test_cases = vec![
983            (1001, "GET", "/api/users", 200, 100),
984            (1002, "POST", "/api/users", 201, 150),
985            (1003, "GET", "/api/orders", 404, 50),
986            (1004, "PUT", "/api/users/123", 200, 300),
987        ];
988
989        for (correlation_id, method, uri, status, duration_ms) in test_cases {
990            let mut headers = HashMap::new();
991            headers.insert("content-type".to_string(), vec!["application/json".into()]);
992
993            let request_data = RequestData {
994                method: method.parse().unwrap(),
995                uri: uri.parse().unwrap(),
996                headers: headers.clone(),
997                body: Some(Bytes::from(b"{}".to_vec())),
998                timestamp: SystemTime::now(),
999                correlation_id,
1000                trace_id: None,
1001                span_id: None,
1002            };
1003
1004            let response_data = ResponseData {
1005                correlation_id,
1006                status: http::StatusCode::from_u16(status).unwrap(),
1007                headers,
1008                body: Some(Bytes::from(b"{}".to_vec())),
1009                timestamp: SystemTime::now(),
1010                duration_to_first_byte: Duration::from_millis(duration_ms / 2),
1011                duration: Duration::from_millis(duration_ms),
1012                extensions: Default::default(),
1013            };
1014
1015            handler.handle_request(request_data.clone()).await;
1016            handler.handle_response(request_data, response_data).await;
1017        }
1018
1019        // Test method filter
1020        let filter = RequestFilter {
1021            method: Some("GET".to_string()),
1022            ..Default::default()
1023        };
1024        let results = repository.query(filter).await.unwrap();
1025        assert_eq!(results.len(), 2); // 1001, 1003
1026
1027        // Test status code filter
1028        let filter = RequestFilter {
1029            status_code: Some(200),
1030            ..Default::default()
1031        };
1032        let results = repository.query(filter).await.unwrap();
1033        assert_eq!(results.len(), 2); // 1001, 1004
1034
1035        // Test URI pattern filter
1036        let filter = RequestFilter {
1037            uri_pattern: Some("/api/users%".to_string()),
1038            ..Default::default()
1039        };
1040        let results = repository.query(filter).await.unwrap();
1041        assert_eq!(results.len(), 3); // 1001, 1002, 1004
1042
1043        // Test duration range filter
1044        let filter = RequestFilter {
1045            min_duration_ms: Some(100),
1046            max_duration_ms: Some(200),
1047            ..Default::default()
1048        };
1049        let results = repository.query(filter).await.unwrap();
1050        assert_eq!(results.len(), 2); // 1001, 1002
1051
1052        // Test combined filters
1053        let filter = RequestFilter {
1054            method: Some("GET".to_string()),
1055            status_code: Some(200),
1056            ..Default::default()
1057        };
1058        let results = repository.query(filter).await.unwrap();
1059        assert_eq!(results.len(), 1); // Only 1001
1060        assert_eq!(results[0].request.correlation_id, 1001);
1061    }
1062
1063    #[sqlx::test]
1064    async fn test_query_with_pagination_and_ordering(pool: PgPool) {
1065        // Run migrations first
1066        crate::migrator().run(&pool).await.unwrap();
1067
1068        let handler = PostgresHandler::<PgPool, Value, Value>::from_pool(pool.clone())
1069            .await
1070            .unwrap();
1071        let repository = handler.repository();
1072
1073        // Insert requests with known timestamps
1074        let now = SystemTime::now();
1075        for i in 0..5 {
1076            let correlation_id = 2000 + i;
1077            let timestamp = now + Duration::from_secs(i * 10); // 10 second intervals
1078
1079            let mut headers = HashMap::new();
1080            headers.insert("x-test-id".to_string(), vec![i.to_string().into()]);
1081
1082            let request_data = RequestData {
1083                method: http::Method::GET,
1084                uri: "/api/test".parse().unwrap(),
1085                headers,
1086                body: Some(Bytes::from(format!("{{\"id\": {i}}}").into_bytes())),
1087                timestamp,
1088                correlation_id,
1089                trace_id: None,
1090                span_id: None,
1091            };
1092
1093            handler.handle_request(request_data).await;
1094        }
1095
1096        // Test default ordering (ASC) with limit
1097        let filter = RequestFilter {
1098            limit: Some(3),
1099            ..Default::default()
1100        };
1101        let results = repository.query(filter).await.unwrap();
1102        assert_eq!(results.len(), 3);
1103
1104        // Should be in ascending timestamp order
1105        for i in 0..2 {
1106            assert!(results[i].request.timestamp <= results[i + 1].request.timestamp);
1107        }
1108
1109        // Test descending order with offset
1110        let filter = RequestFilter {
1111            order_by_timestamp_desc: true,
1112            limit: Some(2),
1113            offset: Some(1),
1114            ..Default::default()
1115        };
1116        let results = repository.query(filter).await.unwrap();
1117        assert_eq!(results.len(), 2);
1118
1119        // Should be in descending order, skipping the first (newest) one
1120        assert!(results[0].request.timestamp >= results[1].request.timestamp);
1121    }
1122
1123    #[sqlx::test]
1124    async fn test_headers_conversion(pool: PgPool) {
1125        // Run migrations first
1126        crate::migrator().run(&pool).await.unwrap();
1127
1128        let handler = PostgresHandler::<PgPool, Value, Value>::from_pool(pool.clone())
1129            .await
1130            .unwrap();
1131        let repository = handler.repository();
1132
1133        // Test various header scenarios
1134        let mut headers = HashMap::new();
1135        headers.insert("single-value".to_string(), vec!["test".into()]);
1136        headers.insert(
1137            "multi-value".to_string(),
1138            vec!["val1".into(), "val2".into()],
1139        );
1140        headers.insert("empty-value".to_string(), vec!["".into()]);
1141
1142        let request_data = RequestData {
1143            correlation_id: 3000,
1144            method: http::Method::GET,
1145            uri: "/test".parse().unwrap(),
1146            headers,
1147            body: None,
1148            timestamp: SystemTime::now(),
1149            trace_id: None,
1150            span_id: None,
1151        };
1152
1153        let correlation_id = 3000;
1154        handler.handle_request(request_data).await;
1155
1156        let filter = RequestFilter {
1157            correlation_id: Some(correlation_id as i64),
1158            ..Default::default()
1159        };
1160        let results = repository.query(filter).await.unwrap();
1161
1162        assert_eq!(results.len(), 1);
1163        let headers_json = &results[0].request.headers;
1164
1165        // Single value should be stored as string
1166        assert_eq!(
1167            headers_json["single-value"],
1168            Value::String("test".to_string())
1169        );
1170
1171        // Multi-value should be stored as array
1172        match &headers_json["multi-value"] {
1173            Value::Array(arr) => {
1174                assert_eq!(arr.len(), 2);
1175                assert_eq!(arr[0], Value::String("val1".to_string()));
1176                assert_eq!(arr[1], Value::String("val2".to_string()));
1177            }
1178            _ => panic!("Expected array for multi-value header"),
1179        }
1180
1181        // Empty value should still be a string
1182        assert_eq!(headers_json["empty-value"], Value::String("".to_string()));
1183    }
1184
1185    #[sqlx::test]
1186    async fn test_timestamp_filtering(pool: PgPool) {
1187        // Run migrations first
1188        crate::migrator().run(&pool).await.unwrap();
1189
1190        let handler = PostgresHandler::<PgPool, Value, Value>::from_pool(pool.clone())
1191            .await
1192            .unwrap();
1193        let repository = handler.repository();
1194
1195        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_000); // Sept 2020
1196
1197        // Insert requests at different times
1198        let times = [
1199            base_time + Duration::from_secs(0),    // correlation_id 4001
1200            base_time + Duration::from_secs(3600), // correlation_id 4002 (1 hour later)
1201            base_time + Duration::from_secs(7200), // correlation_id 4003 (2 hours later)
1202        ];
1203
1204        for (i, timestamp) in times.iter().enumerate() {
1205            let correlation_id = 4001 + i as u64;
1206            let request_data = RequestData {
1207                method: http::Method::GET,
1208                uri: "/test".parse().unwrap(),
1209                headers: HashMap::new(),
1210                body: None,
1211                timestamp: *timestamp,
1212                correlation_id,
1213                trace_id: None,
1214                span_id: None,
1215            };
1216
1217            handler.handle_request(request_data).await;
1218        }
1219
1220        // Test timestamp_after filter
1221        let after_time: DateTime<Utc> = (base_time + Duration::from_secs(1800)).into(); // 30 min after first
1222        let filter = RequestFilter {
1223            timestamp_after: Some(after_time),
1224            ..Default::default()
1225        };
1226        let results = repository.query(filter).await.unwrap();
1227        assert_eq!(results.len(), 2); // Should get 4002 and 4003
1228
1229        // Test timestamp_before filter
1230        let before_time: DateTime<Utc> = (base_time + Duration::from_secs(5400)).into(); // 1.5 hours after first
1231        let filter = RequestFilter {
1232            timestamp_before: Some(before_time),
1233            ..Default::default()
1234        };
1235        let results = repository.query(filter).await.unwrap();
1236        assert_eq!(results.len(), 2); // Should get 4001 and 4002
1237
1238        // Test timestamp range
1239        let filter = RequestFilter {
1240            timestamp_after: Some(after_time),
1241            timestamp_before: Some(before_time),
1242            ..Default::default()
1243        };
1244        let results = repository.query(filter).await.unwrap();
1245        assert_eq!(results.len(), 1); // Should get only 4002
1246        assert_eq!(results[0].request.correlation_id, 4002);
1247    }
1248
1249    // Note: Path filtering tests have been removed because path filtering
1250    // now happens at the outlet middleware layer, not in the PostgresHandler.
1251    // The handler now logs everything it receives, with filtering done upstream.
1252
1253    #[sqlx::test]
1254    async fn test_no_path_filtering_logs_everything(pool: PgPool) {
1255        // Run migrations first
1256        crate::migrator().run(&pool).await.unwrap();
1257
1258        // Handler without any path filtering
1259        let handler = PostgresHandler::<PgPool, Value, Value>::from_pool(pool.clone())
1260            .await
1261            .unwrap();
1262        let repository = handler.repository();
1263
1264        let test_uris = ["/api/users", "/health", "/metrics", "/random/path"];
1265        for (i, uri) in test_uris.iter().enumerate() {
1266            let correlation_id = 3000 + i as u64;
1267            let mut headers = HashMap::new();
1268            headers.insert("content-type".to_string(), vec!["application/json".into()]);
1269
1270            let request_data = RequestData {
1271                method: http::Method::GET,
1272                uri: uri.parse().unwrap(),
1273                headers,
1274                body: Some(Bytes::from(b"{}".to_vec())),
1275                timestamp: SystemTime::now(),
1276                correlation_id,
1277                trace_id: None,
1278                span_id: None,
1279            };
1280
1281            handler.handle_request(request_data).await;
1282        }
1283
1284        // Should have logged all 4 requests
1285        let filter = RequestFilter::default();
1286        let results = repository.query(filter).await.unwrap();
1287        assert_eq!(results.len(), 4);
1288    }
1289
1290    // Tests for read/write pool separation using TestDbPools
1291    #[sqlx::test]
1292    async fn test_write_operations_use_write_pool(pool: PgPool) {
1293        // Run migrations first
1294        crate::migrator().run(&pool).await.unwrap();
1295
1296        // Create TestDbPools which has a read-only replica
1297        let test_pools = crate::TestDbPools::new(pool).await.unwrap();
1298        let handler = PostgresHandler::<_, Value, Value>::from_pool_provider(test_pools.clone())
1299            .await
1300            .unwrap();
1301
1302        let mut request_data = create_test_request_data();
1303        let correlation_id = 5001;
1304        request_data.correlation_id = correlation_id;
1305
1306        // This should succeed because handle_request uses .write() which goes to primary
1307        handler.handle_request(request_data.clone()).await;
1308
1309        // Verify the write succeeded by reading from the primary pool
1310        let count: i64 =
1311            sqlx::query_scalar("SELECT COUNT(*) FROM http_requests WHERE correlation_id = $1")
1312                .bind(correlation_id as i64)
1313                .fetch_one(test_pools.write())
1314                .await
1315                .unwrap();
1316
1317        assert_eq!(count, 1, "Request should be written to primary pool");
1318    }
1319
1320    #[sqlx::test]
1321    async fn test_response_write_uses_write_pool(pool: PgPool) {
1322        // Run migrations first
1323        crate::migrator().run(&pool).await.unwrap();
1324
1325        let test_pools = crate::TestDbPools::new(pool).await.unwrap();
1326        let handler = PostgresHandler::<_, Value, Value>::from_pool_provider(test_pools.clone())
1327            .await
1328            .unwrap();
1329
1330        let mut request_data = create_test_request_data();
1331        let mut response_data = create_test_response_data();
1332        let correlation_id = 5002;
1333        request_data.correlation_id = correlation_id;
1334        response_data.correlation_id = correlation_id;
1335
1336        // Write request first
1337        handler.handle_request(request_data.clone()).await;
1338
1339        // Write response - should succeed because it uses .write()
1340        handler.handle_response(request_data, response_data).await;
1341
1342        // Verify both were written
1343        let count: i64 =
1344            sqlx::query_scalar("SELECT COUNT(*) FROM http_responses WHERE correlation_id = $1")
1345                .bind(correlation_id as i64)
1346                .fetch_one(test_pools.write())
1347                .await
1348                .unwrap();
1349
1350        assert_eq!(count, 1, "Response should be written to primary pool");
1351    }
1352
1353    #[sqlx::test]
1354    async fn test_repository_queries_use_read_pool(pool: PgPool) {
1355        // Run migrations first
1356        crate::migrator().run(&pool).await.unwrap();
1357
1358        let test_pools = crate::TestDbPools::new(pool).await.unwrap();
1359        let handler = PostgresHandler::<_, Value, Value>::from_pool_provider(test_pools.clone())
1360            .await
1361            .unwrap();
1362
1363        // Write some data using the handler (which uses write pool)
1364        let mut request_data = create_test_request_data();
1365        let correlation_id = 5003;
1366        request_data.correlation_id = correlation_id;
1367        handler.handle_request(request_data).await;
1368
1369        // Query using repository - should succeed because it uses .read()
1370        let repository = handler.repository();
1371        let filter = RequestFilter {
1372            correlation_id: Some(correlation_id as i64),
1373            ..Default::default()
1374        };
1375
1376        // This will succeed if repository.query() correctly uses .read()
1377        let results = repository.query(filter).await.unwrap();
1378        assert_eq!(results.len(), 1);
1379        assert_eq!(results[0].request.correlation_id, correlation_id as i64);
1380    }
1381
1382    #[sqlx::test]
1383    async fn test_replica_pool_rejects_writes(pool: PgPool) {
1384        // Run migrations first
1385        crate::migrator().run(&pool).await.unwrap();
1386
1387        let test_pools = crate::TestDbPools::new(pool).await.unwrap();
1388
1389        // Verify that the replica pool is actually read-only
1390        let result = sqlx::query("INSERT INTO http_requests (instance_id, correlation_id, timestamp, method, uri, headers, body, body_parsed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)")
1391            .bind(Uuid::new_v4())
1392            .bind(9999i64)
1393            .bind(Utc::now())
1394            .bind("GET")
1395            .bind("/test")
1396            .bind(serde_json::json!({}))
1397            .bind(None::<Value>)
1398            .bind(false)
1399            .execute(test_pools.read())
1400            .await;
1401
1402        // Should fail with a read-only transaction error
1403        assert!(
1404            result.is_err(),
1405            "Replica pool should reject write operations"
1406        );
1407
1408        let err = result.unwrap_err();
1409        let err_msg = err.to_string().to_lowercase();
1410        assert!(
1411            err_msg.contains("read-only") || err_msg.contains("read only"),
1412            "Error should mention read-only: {}",
1413            err
1414        );
1415    }
1416
1417    #[sqlx::test]
1418    async fn test_full_request_response_cycle_with_read_write_separation(pool: PgPool) {
1419        // Run migrations first
1420        crate::migrator().run(&pool).await.unwrap();
1421
1422        let test_pools = crate::TestDbPools::new(pool).await.unwrap();
1423        let handler =
1424            PostgresHandler::<_, TestRequest, TestResponse>::from_pool_provider(test_pools)
1425                .await
1426                .unwrap();
1427
1428        let mut request_data = create_test_request_data();
1429        let mut response_data = create_test_response_data();
1430        let correlation_id = 5004;
1431        request_data.correlation_id = correlation_id;
1432        response_data.correlation_id = correlation_id;
1433
1434        // Write request and response (uses write pool)
1435        handler.handle_request(request_data.clone()).await;
1436        handler.handle_response(request_data, response_data).await;
1437
1438        // Query back using repository (uses read pool)
1439        let repository = handler.repository();
1440        let filter = RequestFilter {
1441            correlation_id: Some(correlation_id as i64),
1442            ..Default::default()
1443        };
1444
1445        let results = repository.query(filter).await.unwrap();
1446        assert_eq!(results.len(), 1);
1447
1448        // Verify request data
1449        let pair = &results[0];
1450        assert_eq!(pair.request.correlation_id, correlation_id as i64);
1451        assert_eq!(pair.request.method, "POST");
1452        assert_eq!(pair.request.uri, "/api/users");
1453
1454        // Verify response data
1455        let response = pair.response.as_ref().expect("Response should exist");
1456        assert_eq!(response.correlation_id, correlation_id as i64);
1457        assert_eq!(response.status_code, 201);
1458
1459        // Verify parsed bodies
1460        match &pair.request.body {
1461            Some(Ok(parsed_body)) => {
1462                assert_eq!(
1463                    *parsed_body,
1464                    TestRequest {
1465                        user_id: 123,
1466                        action: "create_user".to_string(),
1467                    }
1468                );
1469            }
1470            _ => panic!("Expected successfully parsed request body"),
1471        }
1472
1473        match &response.body {
1474            Some(Ok(parsed_body)) => {
1475                assert_eq!(
1476                    *parsed_body,
1477                    TestResponse {
1478                        success: true,
1479                        message: "User created successfully".to_string(),
1480                    }
1481                );
1482            }
1483            _ => panic!("Expected successfully parsed response body"),
1484        }
1485    }
1486
1487    // -----------------------------------------------------------------------
1488    // Batch INSERT tests
1489    // -----------------------------------------------------------------------
1490
1491    #[sqlx::test]
1492    async fn test_request_batch_insert(pool: PgPool) {
1493        crate::migrator().run(&pool).await.unwrap();
1494        let handler = PostgresHandler::<PgPool, TestRequest, TestResponse>::from_pool(pool.clone())
1495            .await
1496            .unwrap();
1497
1498        let mut batch = Vec::new();
1499        for i in 0..5 {
1500            let mut req = create_test_request_data();
1501            req.correlation_id = 1000 + i;
1502            req.uri = format!("/api/batch/{i}").parse().unwrap();
1503            batch.push(req);
1504        }
1505
1506        handler.handle_request_batch(&batch).await;
1507
1508        // Verify all 5 rows were inserted
1509        let count: (i64,) = sqlx::query_as(
1510            "SELECT COUNT(*) FROM http_requests WHERE correlation_id BETWEEN 1000 AND 1004",
1511        )
1512        .fetch_one(&pool)
1513        .await
1514        .unwrap();
1515        assert_eq!(count.0, 5);
1516    }
1517
1518    #[sqlx::test]
1519    async fn test_response_batch_insert(pool: PgPool) {
1520        crate::migrator().run(&pool).await.unwrap();
1521        let handler = PostgresHandler::<PgPool, TestRequest, TestResponse>::from_pool(pool.clone())
1522            .await
1523            .unwrap();
1524
1525        // Insert matching requests first
1526        let mut pairs = Vec::new();
1527        for i in 0..3 {
1528            let mut req = create_test_request_data();
1529            req.correlation_id = 2000 + i;
1530            handler.handle_request(req.clone()).await;
1531
1532            let mut res = create_test_response_data();
1533            res.correlation_id = 2000 + i;
1534            pairs.push((req, res));
1535        }
1536
1537        handler.handle_response_batch(&pairs).await;
1538
1539        // Verify all 3 response rows were inserted
1540        let count: (i64,) = sqlx::query_as(
1541            "SELECT COUNT(*) FROM http_responses WHERE correlation_id BETWEEN 2000 AND 2002",
1542        )
1543        .fetch_one(&pool)
1544        .await
1545        .unwrap();
1546        assert_eq!(count.0, 3);
1547    }
1548
1549    #[sqlx::test]
1550    async fn test_batch_with_mixed_bodies(pool: PgPool) {
1551        crate::migrator().run(&pool).await.unwrap();
1552        let handler = PostgresHandler::<PgPool, TestRequest, TestResponse>::from_pool(pool.clone())
1553            .await
1554            .unwrap();
1555
1556        let mut batch = Vec::new();
1557
1558        // Request with body
1559        let mut req_with_body = create_test_request_data();
1560        req_with_body.correlation_id = 3000;
1561        batch.push(req_with_body);
1562
1563        // Request without body
1564        let mut req_no_body = create_test_request_data();
1565        req_no_body.correlation_id = 3001;
1566        req_no_body.body = None;
1567        batch.push(req_no_body);
1568
1569        // Request with unparseable body
1570        let mut req_bad_body = create_test_request_data();
1571        req_bad_body.correlation_id = 3002;
1572        req_bad_body.body = Some(Bytes::from("not valid json"));
1573        batch.push(req_bad_body);
1574
1575        handler.handle_request_batch(&batch).await;
1576
1577        // All 3 should be inserted
1578        let count: (i64,) = sqlx::query_as(
1579            "SELECT COUNT(*) FROM http_requests WHERE correlation_id BETWEEN 3000 AND 3002",
1580        )
1581        .fetch_one(&pool)
1582        .await
1583        .unwrap();
1584        assert_eq!(count.0, 3);
1585
1586        // Check body_parsed flags
1587        let rows: Vec<(i64, Option<bool>)> = sqlx::query_as(
1588            "SELECT correlation_id, body_parsed FROM http_requests WHERE correlation_id BETWEEN 3000 AND 3002 ORDER BY correlation_id",
1589        )
1590        .fetch_all(&pool)
1591        .await
1592        .unwrap();
1593
1594        assert_eq!(rows[0].1, Some(true)); // parsed JSON
1595        assert_eq!(rows[1].1, Some(false)); // no body
1596        assert_eq!(rows[2].1, Some(false)); // fallback string
1597    }
1598
1599    #[sqlx::test]
1600    async fn test_empty_batch_is_noop(pool: PgPool) {
1601        crate::migrator().run(&pool).await.unwrap();
1602        let handler = PostgresHandler::<PgPool, TestRequest, TestResponse>::from_pool(pool.clone())
1603            .await
1604            .unwrap();
1605
1606        // Should not error
1607        handler.handle_request_batch(&[]).await;
1608        handler.handle_response_batch(&[]).await;
1609    }
1610
1611    #[sqlx::test]
1612    async fn test_batch_write_uses_write_pool(pool: PgPool) {
1613        use sqlx_pool_router::TestDbPools;
1614        crate::migrator().run(&pool).await.unwrap();
1615        let test_pools = TestDbPools::new(pool).await.unwrap();
1616        let handler =
1617            PostgresHandler::<TestDbPools, TestRequest, TestResponse>::from_pool_provider(
1618                test_pools,
1619            )
1620            .await
1621            .unwrap();
1622
1623        let mut req = create_test_request_data();
1624        req.correlation_id = 4000;
1625        handler.handle_request_batch(&[req.clone()]).await;
1626
1627        let res = create_test_response_data();
1628        handler.handle_response_batch(&[(req, res)]).await;
1629    }
1630}