Skip to main content

pjson_rs/infrastructure/http/
axum_adapter.rs

1//! Axum HTTP server adapter for PJS streaming
2
3use crate::domain::value_objects::JsonData;
4use axum::{
5    Json, Router,
6    extract::DefaultBodyLimit,
7    http::{
8        HeaderValue, Method, StatusCode,
9        header::{AUTHORIZATION, CONTENT_TYPE},
10    },
11    middleware,
12    response::{IntoResponse, Response},
13    routing::{get, post},
14};
15use serde::{Deserialize, Serialize};
16use std::{
17    sync::Arc,
18    time::{Duration, Instant},
19};
20use tower::limit::GlobalConcurrencyLimitLayer;
21use tower_http::{
22    cors::{AllowOrigin, CorsLayer},
23    timeout::{ResponseBodyTimeoutLayer, TimeoutLayer},
24    trace::TraceLayer,
25};
26
27use crate::{
28    application::{
29        handlers::{
30            command_handlers::SessionCommandHandler,
31            query_handlers::{SessionQueryHandler, StreamQueryHandler, SystemQueryHandler},
32        },
33        queries::SortOrder,
34    },
35    domain::{
36        SessionState,
37        aggregates::stream_session::SessionHealth,
38        entities::Frame,
39        ports::{
40            DictionaryStore, EventPublisherGat, FrameStoreGat, NoopDictionaryStore,
41            SessionSortField, StreamRepositoryGat, StreamStoreGat,
42        },
43        value_objects::{SessionId, StreamId},
44    },
45    infrastructure::{
46        adapters::InMemoryFrameStore,
47        http::middleware::{RateLimitMiddleware, security_middleware},
48    },
49};
50
51#[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
52use super::handlers::dictionary::get_session_dictionary;
53use super::handlers::{
54    health::{get_system_stats, system_health},
55    sessions::{
56        create_session, get_session, get_session_stats, list_sessions, search_sessions,
57        session_health,
58    },
59    streams::{
60        create_stream, generate_frames, get_stream, get_stream_frames, start_stream,
61        stream_stream_frames,
62    },
63};
64
65/// HTTP server configuration.
66///
67/// # Production warning
68///
69/// `HttpServerConfig::default()` returns a configuration suitable for **local development
70/// only** — it allows a single hard-coded origin (`http://localhost:3000`). Production
71/// deployments must construct an explicit `HttpServerConfig` with the actual list of
72/// allowed origins, or pass `vec![]` to deny all cross-origin requests.
73///
74/// Use [`create_pjs_router_with_config`] to apply a non-default configuration.
75///
76/// # Adding fields
77///
78/// This struct is marked `#[non_exhaustive]` so future additive fields
79/// (e.g. `allow_credentials`, `max_age`) do not become breaking changes.
80/// External callers cannot use the struct-init pattern; construct an instance
81/// via [`HttpServerConfig::new`] or [`HttpServerConfig::default`] and mutate
82/// the public fields you need.
83#[derive(Debug, Clone)]
84#[non_exhaustive]
85pub struct HttpServerConfig {
86    /// List of origins allowed by the CORS layer.
87    ///
88    /// # Matching semantics
89    ///
90    /// Origins are matched against the request's `Origin` header by **case-sensitive byte
91    /// equality**. This is `tower_http::cors::AllowOrigin::list` behavior; it is not the
92    /// case-insensitive scheme/host comparison defined by RFC 6454 §6.
93    ///
94    /// In practice this matches all real browser traffic, because mainstream browsers
95    /// always send lowercase scheme and host. Write your origins in lowercase.
96    ///
97    /// # Special values
98    ///
99    /// - `[]` (empty) — deny all cross-origin requests (fail-closed)
100    /// - `["*"]` — allow any origin (passes through to `tower_http::cors::Any`)
101    /// - Mixing `"*"` with explicit origins is rejected at construction time
102    pub allowed_origins: Vec<String>,
103}
104
105impl HttpServerConfig {
106    /// Construct a configuration with an explicit list of allowed CORS origins.
107    ///
108    /// Pass `vec![]` to deny all cross-origin requests, or `vec!["*".into()]`
109    /// to allow any origin. Mixing `"*"` with explicit origins is rejected
110    /// later when the CORS layer is built.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use pjson_rs::infrastructure::http::HttpServerConfig;
116    ///
117    /// let config = HttpServerConfig::new(vec!["https://app.example.com".into()]);
118    /// assert_eq!(config.allowed_origins.len(), 1);
119    /// ```
120    pub fn new(allowed_origins: Vec<String>) -> Self {
121        Self { allowed_origins }
122    }
123}
124
125impl Default for HttpServerConfig {
126    /// Local-development default: allows `http://localhost:3000`.
127    ///
128    /// **Do not use this in production.** See the type-level docs.
129    fn default() -> Self {
130        Self {
131            allowed_origins: vec!["http://localhost:3000".to_string()],
132        }
133    }
134}
135
136/// Build a [`CorsLayer`] from an [`HttpServerConfig`].
137///
138/// # Errors
139///
140/// Returns [`PjsError::HttpError`] if:
141/// - `allowed_origins` is a mix of `"*"` and explicit origins
142/// - any origin string fails to parse as a valid `HeaderValue`
143fn build_cors_layer(config: &HttpServerConfig) -> Result<CorsLayer, PjsError> {
144    build_cors_layer_from_origins(&config.allowed_origins)
145}
146
147/// Build a [`CorsLayer`] from a raw allowed-origins list.
148///
149/// Shared validated-allowlist logic behind both [`build_cors_layer`] (used by
150/// [`create_pjs_router_with_config`]) and `axum_extension::PjsExtension`'s
151/// own opt-in `allowed_origins` config — see that module for why it needs
152/// its own CORS layer rather than always relying on [`build_cors_layer`]'s
153/// caller.
154///
155/// # Matching semantics
156///
157/// - `[]` (empty) — deny all cross-origin requests (fail-closed)
158/// - `["*"]` — allow any origin (passes through to `tower_http::cors::Any`)
159/// - Mixing `"*"` with explicit origins is rejected at construction time
160/// - Explicit origins are matched against the request's `Origin` header by
161///   **case-sensitive byte equality** (`tower_http::cors::AllowOrigin::list`
162///   behavior, not RFC 6454 §6's case-insensitive scheme/host comparison —
163///   write origins in lowercase, which matches all real browser traffic)
164///
165/// # Errors
166///
167/// Returns [`PjsError::HttpError`] if:
168/// - `allowed_origins` is a mix of `"*"` and explicit origins
169/// - any origin string fails to parse as a valid `HeaderValue`
170pub(crate) fn build_cors_layer_from_origins(
171    allowed_origins: &[String],
172) -> Result<CorsLayer, PjsError> {
173    // We intentionally do NOT call .allow_credentials(true).
174    // PJS does not use cookie-based auth; the Authorization header works without
175    // credentials mode. allow_credentials(true) is incompatible with allow_origin(Any),
176    // which would forbid the `["*"]` config path.
177    let base = CorsLayer::new()
178        .allow_methods([Method::GET, Method::POST])
179        .allow_headers([CONTENT_TYPE, AUTHORIZATION])
180        .max_age(std::time::Duration::from_secs(3600));
181
182    let has_wildcard = allowed_origins.iter().any(|o| o == "*");
183    let has_explicit = allowed_origins.iter().any(|o| o != "*");
184
185    let layer = match (allowed_origins.is_empty(), has_wildcard, has_explicit) {
186        (true, _, _) => base.allow_origin(AllowOrigin::list(std::iter::empty::<HeaderValue>())),
187        (_, true, true) => {
188            return Err(PjsError::HttpError(
189                "CORS: wildcard '*' cannot be combined with explicit origins".into(),
190            ));
191        }
192        (_, true, false) => base.allow_origin(tower_http::cors::Any),
193        (_, false, _) => {
194            let origins: Vec<HeaderValue> = allowed_origins
195                .iter()
196                .map(|o| {
197                    o.parse::<HeaderValue>()
198                        .map_err(|e| PjsError::HttpError(format!("invalid CORS origin {o:?}: {e}")))
199                })
200                .collect::<Result<_, _>>()?;
201            base.allow_origin(AllowOrigin::list(origins))
202        }
203    };
204    Ok(layer)
205}
206
207/// Axum application state with PJS GAT-based handlers.
208///
209/// All fields are `pub(crate)` so the route handlers in
210/// [`crate::infrastructure::http::handlers`] can access them without
211/// exposing them as public API.
212pub struct PjsAppState<R, P, S, F = InMemoryFrameStore>
213where
214    R: StreamRepositoryGat + Send + Sync + 'static,
215    P: EventPublisherGat + Send + Sync + 'static,
216    S: StreamStoreGat + Send + Sync + 'static,
217    F: FrameStoreGat + Send + Sync + 'static,
218{
219    pub(crate) command_handler: Arc<SessionCommandHandler<R, P, F>>,
220    pub(crate) session_query_handler: Arc<SessionQueryHandler<R>>,
221    pub(crate) stream_query_handler: Arc<StreamQueryHandler<R, S, F>>,
222    pub(crate) system_handler: Arc<SystemQueryHandler<R>>,
223    pub(crate) dictionary_store: Arc<dyn DictionaryStore>,
224}
225
226impl<R, P, S, F> Clone for PjsAppState<R, P, S, F>
227where
228    R: StreamRepositoryGat + Send + Sync + 'static,
229    P: EventPublisherGat + Send + Sync + 'static,
230    S: StreamStoreGat + Send + Sync + 'static,
231    F: FrameStoreGat + Send + Sync + 'static,
232{
233    fn clone(&self) -> Self {
234        Self {
235            command_handler: self.command_handler.clone(),
236            session_query_handler: self.session_query_handler.clone(),
237            stream_query_handler: self.stream_query_handler.clone(),
238            system_handler: self.system_handler.clone(),
239            dictionary_store: self.dictionary_store.clone(),
240        }
241    }
242}
243
244impl<R, P, S> PjsAppState<R, P, S, InMemoryFrameStore>
245where
246    R: StreamRepositoryGat + Send + Sync + 'static,
247    P: EventPublisherGat + Send + Sync + 'static,
248    S: StreamStoreGat + Send + Sync + 'static,
249{
250    /// Create a new application state with default [`NoopDictionaryStore`] and
251    /// an in-memory frame store.
252    ///
253    /// The `/pjs/sessions/{id}/dictionary` endpoint will return 404 until
254    /// you upgrade to [`PjsAppState::with_dictionary_store`] with a concrete
255    /// implementation such as [`crate::infrastructure::repositories::InMemoryDictionaryStore`].
256    ///
257    /// Records the current instant as the process start time for uptime reporting.
258    pub fn new(repository: Arc<R>, event_publisher: Arc<P>, stream_store: Arc<S>) -> Self {
259        Self::with_dictionary_store(
260            repository,
261            event_publisher,
262            stream_store,
263            Arc::new(NoopDictionaryStore),
264        )
265    }
266
267    /// Create a new application state with a custom [`DictionaryStore`] and an
268    /// in-memory frame store.
269    ///
270    /// Pass `Arc::new(InMemoryDictionaryStore::new(...))` to enable end-to-end
271    /// dictionary training and serving.
272    pub fn with_dictionary_store(
273        repository: Arc<R>,
274        event_publisher: Arc<P>,
275        stream_store: Arc<S>,
276        dictionary_store: Arc<dyn DictionaryStore>,
277    ) -> Self {
278        Self::with_stores(
279            repository,
280            event_publisher,
281            stream_store,
282            dictionary_store,
283            Arc::new(InMemoryFrameStore::new()),
284        )
285    }
286}
287
288impl<R, P, S, F> PjsAppState<R, P, S, F>
289where
290    R: StreamRepositoryGat + Send + Sync + 'static,
291    P: EventPublisherGat + Send + Sync + 'static,
292    S: StreamStoreGat + Send + Sync + 'static,
293    F: FrameStoreGat + Send + Sync + 'static,
294{
295    /// Create a new application state with custom [`DictionaryStore`] and
296    /// [`FrameStoreGat`] implementations.
297    pub fn with_stores(
298        repository: Arc<R>,
299        event_publisher: Arc<P>,
300        stream_store: Arc<S>,
301        dictionary_store: Arc<dyn DictionaryStore>,
302        frame_store: Arc<F>,
303    ) -> Self {
304        let started_at = Instant::now();
305        Self {
306            command_handler: Arc::new(SessionCommandHandler::with_stores(
307                repository.clone(),
308                event_publisher,
309                dictionary_store.clone(),
310                frame_store.clone(),
311            )),
312            session_query_handler: Arc::new(SessionQueryHandler::new(repository.clone())),
313            stream_query_handler: Arc::new(StreamQueryHandler::new(
314                repository.clone(),
315                stream_store,
316                frame_store,
317            )),
318            system_handler: Arc::new(SystemQueryHandler::with_start_time(repository, started_at)),
319            dictionary_store,
320        }
321    }
322}
323
324/// Request to create a new streaming session
325///
326/// `max_concurrent_streams: 0`, `timeout_seconds: 0`, or a `timeout_seconds`
327/// above [`crate::domain::config::limits::MAX_SESSION_TIMEOUT_SECONDS`] (7
328/// days) are rejected with `400 Bad Request` before the session is created.
329#[derive(Debug, Deserialize)]
330pub struct CreateSessionRequest {
331    /// Maximum number of streams the session is allowed to host concurrently.
332    pub max_concurrent_streams: Option<usize>,
333    /// Idle timeout for the session, in seconds.
334    pub timeout_seconds: Option<u64>,
335    /// Optional human-readable client identifier.
336    pub client_info: Option<String>,
337}
338
339/// Response for session creation
340#[derive(Debug, Serialize)]
341pub struct CreateSessionResponse {
342    /// Newly assigned session identifier.
343    pub session_id: String,
344    /// Wall-clock instant after which the session expires.
345    pub expires_at: chrono::DateTime<chrono::Utc>,
346}
347
348/// Request to start streaming data
349#[derive(Debug, Deserialize)]
350pub struct StartStreamRequest {
351    /// JSON payload to be decomposed into priority frames.
352    ///
353    /// A `null` payload is rejected with `400 Bad Request` before the
354    /// session is looked up.
355    pub data: JsonData,
356    /// Minimum frame priority to emit; lower-priority frames are dropped.
357    pub priority_threshold: Option<u8>,
358    /// Maximum number of frames to emit before the stream is closed.
359    pub max_frames: Option<usize>,
360}
361
362/// Stream response parameters
363#[derive(Debug, Deserialize)]
364pub struct StreamParams {
365    /// Identifier of the streaming session.
366    pub session_id: String,
367    /// Optional minimum priority filter applied to emitted frames.
368    pub priority: Option<u8>,
369    /// Optional response format selector (for example, `"json"` or `"sse"`).
370    pub format: Option<String>,
371}
372
373/// Request body for generating priority-filtered frames on an existing stream.
374///
375/// Both fields are optional; defaults match the lowest-cost configuration that
376/// still drives the priority pipeline:
377/// - `priority_threshold` defaults to [`crate::domain::value_objects::Priority::BACKGROUND`] (10) — accepts every frame.
378/// - `max_frames` defaults to 16 — bounded so a single request cannot emit an
379///   unbounded number of frames.
380///
381/// An explicit `max_frames` of `0` or above
382/// [`crate::domain::config::limits::MAX_FRAMES_PER_REQUEST`] (1000) is
383/// rejected with `400 Bad Request`.
384#[derive(Debug, Default, Deserialize)]
385pub struct GenerateFramesRequest {
386    /// Minimum frame priority to emit; lower-priority frames are dropped.
387    pub priority_threshold: Option<u8>,
388    /// Maximum number of frames to emit in this request.
389    pub max_frames: Option<usize>,
390}
391
392/// Response body for `POST .../streams/{stream_id}/generate-frames`.
393///
394/// Returns the frames produced by the stream's priority extractor, in the
395/// same shape as `GET .../frames` but freshly generated (and fed into the
396/// per-session dictionary training corpus when the `compression` feature
397/// is enabled).
398#[derive(Debug, Serialize)]
399pub struct GenerateFramesResponse {
400    /// Frames produced by the priority extractor in this request.
401    pub frames: Vec<Frame>,
402    /// Number of frames returned (always equal to `frames.len()`).
403    pub frame_count: usize,
404}
405
406/// Session health response
407#[derive(Debug, Serialize)]
408pub struct SessionHealthResponse {
409    /// Aggregate health flag derived from rates and recent activity.
410    pub is_healthy: bool,
411    /// Number of streams currently in an active state.
412    pub active_streams: usize,
413    /// Number of streams that have terminated with an error.
414    pub failed_streams: usize,
415    /// Whether the session has passed its expiry instant.
416    pub is_expired: bool,
417    /// Number of seconds since the session was created.
418    pub uptime_seconds: i64,
419}
420
421impl From<SessionHealth> for SessionHealthResponse {
422    fn from(health: SessionHealth) -> Self {
423        Self {
424            is_healthy: health.is_healthy,
425            active_streams: health.active_streams,
426            failed_streams: health.failed_streams,
427            is_expired: health.is_expired,
428            uptime_seconds: health.uptime_seconds,
429        }
430    }
431}
432
433/// Create PJS-enabled Axum router with the default CORS configuration.
434///
435/// Uses [`HttpServerConfig::default`] which allows `http://localhost:3000`.
436///
437/// # Security Note
438///
439/// This is suitable for local development only. For production, use
440/// [`create_pjs_router_with_config`] with an explicit [`HttpServerConfig`], and
441/// apply authentication via [`create_pjs_router_with_auth`] or
442/// [`create_pjs_router_with_rate_limit_and_auth`] — API key and JWT layers are
443/// available in [`crate::infrastructure::http::auth`]
444/// (`ApiKeyAuthLayer`, `JwtAuthLayer`).
445pub fn create_pjs_router<R, P, S>() -> Router<PjsAppState<R, P, S>>
446where
447    R: StreamRepositoryGat + Send + Sync + 'static,
448    P: EventPublisherGat + Send + Sync + 'static,
449    S: StreamStoreGat + Send + Sync + 'static,
450{
451    create_pjs_router_with_config::<R, P, S>(&HttpServerConfig::default())
452        .expect("default HttpServerConfig must always produce a valid CORS layer")
453}
454
455/// Create PJS-enabled Axum router with a custom [`HttpServerConfig`].
456///
457/// # Errors
458///
459/// Returns [`PjsError::HttpError`] if `config` contains invalid CORS origins —
460/// specifically, when `allowed_origins` mixes `"*"` with explicit origins, or
461/// any origin string fails to parse as a valid `HeaderValue`.
462///
463/// # Examples
464///
465/// ```rust,ignore
466/// use pjson_rs::infrastructure::http::{HttpServerConfig, create_pjs_router_with_config};
467///
468/// let config = HttpServerConfig::new(vec!["https://app.example.com".to_string()]);
469/// let router = create_pjs_router_with_config::<R, P, S>(&config)?;
470/// ```
471pub fn create_pjs_router_with_config<R, P, S>(
472    config: &HttpServerConfig,
473) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
474where
475    R: StreamRepositoryGat + Send + Sync + 'static,
476    P: EventPublisherGat + Send + Sync + 'static,
477    S: StreamStoreGat + Send + Sync + 'static,
478{
479    let all_routes = public_routes::<R, P, S>().merge(protected_routes::<R, P, S>());
480    apply_common_layers(all_routes, config, None)
481}
482
483/// Create PJS-enabled Axum router with rate limiting and the default CORS configuration.
484///
485/// Adds rate limiting middleware to protect against DoS attacks.
486/// Default: 100 requests per minute per IP address.
487///
488/// Uses [`HttpServerConfig::default`] which allows `http://localhost:3000`.
489/// For production, use [`create_pjs_router_with_rate_limit_and_config`].
490///
491/// # Security Note
492///
493/// Rate limiting is applied globally to all endpoints, keyed on the real TCP
494/// peer address by default — the router must be served with
495/// `into_make_service_with_connect_info::<std::net::SocketAddr>()` (as done for
496/// the WebSocket upgrade handler) so that peer address is populated; otherwise
497/// every request falls back to the same key (`127.0.0.1`). To trust
498/// `X-Forwarded-For`/`X-Real-IP` behind a reverse proxy, opt in via
499/// [`RateLimitConfig::with_trusted_proxies`](crate::infrastructure::http::middleware::RateLimitConfig::with_trusted_proxies).
500/// Returns 429 Too Many Requests with Retry-After header when limit exceeded.
501/// Adds X-RateLimit-* headers per RFC 6585.
502pub fn create_pjs_router_with_rate_limit<R, P, S>(
503    rate_limit_middleware: RateLimitMiddleware,
504) -> Router<PjsAppState<R, P, S>>
505where
506    R: StreamRepositoryGat + Send + Sync + 'static,
507    P: EventPublisherGat + Send + Sync + 'static,
508    S: StreamStoreGat + Send + Sync + 'static,
509{
510    create_pjs_router_with_rate_limit_and_config::<R, P, S>(
511        &HttpServerConfig::default(),
512        rate_limit_middleware,
513    )
514    .expect("default HttpServerConfig must always produce a valid CORS layer")
515}
516
517/// Create PJS-enabled Axum router with rate limiting and a custom [`HttpServerConfig`].
518///
519/// `rate_limit_middleware` is threaded into the crate's common middleware stack:
520/// inside `security_middleware`/`CorsLayer`/`TraceLayer`, so a `429` still gets
521/// security headers, CORS headers, and shows up in traces, but outside the global
522/// concurrency limiter, so a `429` never consumes a permit.
523///
524/// # Errors
525///
526/// Returns [`PjsError::HttpError`] if `config` contains invalid CORS origins.
527pub fn create_pjs_router_with_rate_limit_and_config<R, P, S>(
528    config: &HttpServerConfig,
529    rate_limit_middleware: RateLimitMiddleware,
530) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
531where
532    R: StreamRepositoryGat + Send + Sync + 'static,
533    P: EventPublisherGat + Send + Sync + 'static,
534    S: StreamStoreGat + Send + Sync + 'static,
535{
536    let all_routes = public_routes::<R, P, S>().merge(protected_routes::<R, P, S>());
537    apply_common_layers(all_routes, config, Some(rate_limit_middleware))
538}
539
540/// Create PJS-enabled Axum router with API key authentication and a custom [`HttpServerConfig`].
541///
542/// The health endpoint (`/pjs/health`) is **not** protected by auth — it lives in a
543/// separate public sub-router that is merged without the auth layer. All other routes
544/// require a valid API key.
545///
546/// # Errors
547///
548/// Returns [`PjsError::HttpError`] if `config` contains invalid CORS origins.
549///
550/// # Examples
551///
552/// ```rust,ignore
553/// use pjson_rs::infrastructure::http::{
554///     HttpServerConfig, auth::{ApiKeyConfig, ApiKeyAuthLayer},
555///     create_pjs_router_with_auth,
556/// };
557///
558/// let api_config = ApiKeyConfig::new(&["my-api-key"])?;
559/// let auth_layer = ApiKeyAuthLayer::new(api_config);
560/// let config = HttpServerConfig::default();
561/// let router = create_pjs_router_with_auth::<R, P, S>(&config, auth_layer)?;
562/// ```
563#[cfg(feature = "http-server")]
564pub fn create_pjs_router_with_auth<R, P, S>(
565    config: &HttpServerConfig,
566    auth: crate::infrastructure::http::auth::ApiKeyAuthLayer,
567) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
568where
569    R: StreamRepositoryGat + Send + Sync + 'static,
570    P: EventPublisherGat + Send + Sync + 'static,
571    S: StreamStoreGat + Send + Sync + 'static,
572{
573    // Auth wraps only the protected sub-router. Public routes (health, metrics) are
574    // merged separately so there is zero path-string comparison logic in the auth layer.
575    let protected = protected_routes::<R, P, S>().layer(auth);
576    let merged = public_routes::<R, P, S>().merge(protected);
577    apply_common_layers(merged, config, None)
578}
579
580/// Create PJS-enabled Axum router with both rate limiting and API key authentication.
581///
582/// Layer ordering (axum's `Router::layer` makes the last `.layer()` call outermost):
583/// ```text
584/// TraceLayer                  ← outermost: distributed tracing
585/// TimeoutLayer                ← whole-request timeout
586/// ResponseBodyTimeoutLayer    ← per-frame idle timeout on the response body
587/// CorsLayer                   ← CORS
588/// DefaultBodyLimit            ← body size guard
589/// security_middleware         ← security headers
590/// rate_limit                  ← rejects with 429 before a concurrency permit, but
591///                                after security/CORS/trace so 429s keep all three
592///   GlobalConcurrencyLimitLayer ← global in-flight request cap
593///     public_routes (no auth)
594///     protected_routes
595///       auth    ← innermost: wraps only protected routes
596///       handlers
597/// ```
598///
599/// Rate limiting is applied to **both** the public and protected sub-routers (DoS
600/// protection for `/pjs/health` is still desirable). Rate limit sits *outside* auth
601/// (auth is applied to the protected sub-router before this router ever reaches
602/// the common middleware stack, so it ends up innermost of everything) — every
603/// request, authenticated or not, consumes rate-limit quota before auth gets a
604/// chance to reject the unauthenticated ones. This is an intentional trade-off, not
605/// an oversight: it is what lets the same rate limiter also protect the
606/// unauthenticated `/pjs/health` route, at the cost of an unauthenticated flood
607/// being able to consume quota that would otherwise be available to legitimate
608/// authenticated clients.
609///
610/// # Errors
611///
612/// Returns [`PjsError::HttpError`] if `config` contains invalid CORS origins.
613#[cfg(feature = "http-server")]
614pub fn create_pjs_router_with_rate_limit_and_auth<R, P, S>(
615    config: &HttpServerConfig,
616    rate_limit: RateLimitMiddleware,
617    auth: crate::infrastructure::http::auth::ApiKeyAuthLayer,
618) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
619where
620    R: StreamRepositoryGat + Send + Sync + 'static,
621    P: EventPublisherGat + Send + Sync + 'static,
622    S: StreamStoreGat + Send + Sync + 'static,
623{
624    let protected = protected_routes::<R, P, S>().layer(auth);
625    let merged = public_routes::<R, P, S>().merge(protected);
626    apply_common_layers(merged, config, Some(rate_limit))
627}
628
629// ── Route table helpers ────────────────────────────────────────────────────────────
630
631/// Routes that are always public — no authentication applied.
632///
633/// Currently: `/pjs/health` and (when the `metrics` feature is enabled) `/metrics`.
634fn public_routes<R, P, S>() -> Router<PjsAppState<R, P, S>>
635where
636    R: StreamRepositoryGat + Send + Sync + 'static,
637    P: EventPublisherGat + Send + Sync + 'static,
638    S: StreamStoreGat + Send + Sync + 'static,
639{
640    let router = Router::new().route("/pjs/health", get(system_health));
641
642    #[cfg(feature = "metrics")]
643    let router = router.route(
644        "/metrics",
645        get(crate::infrastructure::http::metrics::metrics_handler),
646    );
647
648    router
649}
650
651/// Routes that require authentication when an auth layer is applied.
652fn protected_routes<R, P, S>() -> Router<PjsAppState<R, P, S>>
653where
654    R: StreamRepositoryGat + Send + Sync + 'static,
655    P: EventPublisherGat + Send + Sync + 'static,
656    S: StreamStoreGat + Send + Sync + 'static,
657{
658    let router = Router::new()
659        .route("/pjs/sessions", post(create_session::<R, P, S>))
660        .route("/pjs/sessions/{session_id}", get(get_session::<R, P, S>))
661        .route(
662            "/pjs/sessions/{session_id}/health",
663            get(session_health::<R, P, S>),
664        )
665        .route(
666            "/pjs/sessions/{session_id}/stats",
667            get(get_session_stats::<R, P, S>),
668        )
669        .route(
670            "/pjs/sessions/{session_id}/streams",
671            post(create_stream::<R, P, S>),
672        )
673        .route(
674            "/pjs/sessions/{session_id}/streams/{stream_id}/start",
675            post(start_stream::<R, P, S>),
676        )
677        .route(
678            "/pjs/sessions/{session_id}/streams/{stream_id}/generate-frames",
679            post(generate_frames::<R, P, S>),
680        )
681        .route(
682            "/pjs/sessions/{session_id}/streams/{stream_id}",
683            get(get_stream::<R, P, S>),
684        )
685        .route(
686            "/pjs/sessions/{session_id}/streams/{stream_id}/frames",
687            get(get_stream_frames::<R, P, S>),
688        )
689        .route(
690            "/pjs/sessions/{session_id}/streams/{stream_id}/frames/stream",
691            get(stream_stream_frames::<R, P, S>),
692        )
693        .route("/pjs/sessions/search", get(search_sessions::<R, P, S>))
694        .route("/pjs/sessions", get(list_sessions::<R, P, S>))
695        .route("/pjs/stats", get(get_system_stats::<R, P, S>));
696
697    #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
698    let router = router.route(
699        "/pjs/sessions/{session_id}/dictionary",
700        get(get_session_dictionary::<R, P, S>),
701    );
702
703    router
704}
705
706/// Global cap on concurrent in-flight requests, independent of
707/// [`RateLimitMiddleware`]'s per-client, per-window token bucket.
708///
709/// Enforced via [`GlobalConcurrencyLimitLayer`], not the plain (non-`Global`)
710/// `ConcurrencyLimitLayer`: axum's `Router::layer` applies a layer once per matched
711/// route in the routing table (`PathRouter::layer` calls `layer.clone()` per route),
712/// and `ConcurrencyLimitLayer::layer()` constructs a brand new `Semaphore` on every
713/// call — so using it here would silently produce one independent semaphore *per
714/// route* (an effective ceiling of `MAX_CONCURRENT_REQUESTS * route_count`, not a
715/// real global cap). `GlobalConcurrencyLimitLayer` holds a single `Arc<Semaphore>`
716/// in the layer itself and clones the `Arc` (not the semaphore) on each per-route
717/// application, so every route actually shares one pool.
718///
719/// This bounds handler *execution* concurrency only — not connections, sockets, or
720/// parsed-request memory. Axum's `Router::poll_ready` always returns `Ready`, and
721/// hyper's `TowerToHyperService` wraps every request in a fresh `Oneshot`, so hyper
722/// never observes tower-stack readiness and keeps accepting, reading, and parsing
723/// requests regardless of how many permits are free. A request over
724/// `MAX_CONCURRENT_REQUESTS` is already fully accepted/read/parsed by the time it
725/// reaches this layer; it then parks waiting for a permit inside its own
726/// per-request future, bounded only by the outer `TimeoutLayer` (`REQUEST_TIMEOUT`
727/// -> `408`) rather than being deferred at the connection level. For the streaming
728/// route (`GET .../frames/stream`), the permit is released as soon as the handler
729/// returns its `Response` — i.e. once the streaming body starts, not once it
730/// finishes — so this bounds concurrent *request handling*, not concurrent open
731/// streaming bodies; there is currently no mechanism here that bounds the latter
732/// (see [`RESPONSE_BODY_IDLE_TIMEOUT`]'s doc for why that gap remains open).
733const MAX_CONCURRENT_REQUESTS: usize = 512;
734
735/// Whole-request timeout: bounds the time from receiving a request to the handler
736/// producing a `Response` (headers + body constructor), after which the client gets
737/// `408 Request Timeout`.
738///
739/// [`TimeoutLayer`] times the `Service::call` future only — for the streaming route
740/// that future resolves as soon as `create_streaming_response` builds the chunked
741/// `Response`, before any frame is written, so this never cuts off an
742/// already-streaming connection. It exists to bound the (normally sub-second)
743/// query/domain-lookup phase common to every route, and — because it sits outer of
744/// [`MAX_CONCURRENT_REQUESTS`]'s layer in [`apply_common_layers`] — also bounds how
745/// long a request can queue waiting for a concurrency permit before failing with
746/// `408` instead of queueing indefinitely.
747const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
748
749/// Idle timeout applied to every response body: [`TimeoutBody`]'s deadline resets
750/// each time the body is *polled* and yields a frame, so this bounds a stall on the
751/// *producer* side (a source that stops yielding frames), not total transfer time.
752///
753/// [`TimeoutBody`]: tower_http::timeout::TimeoutBody
754///
755/// This does **not** protect against a slow or non-reading *consumer* — the
756/// scenario the original #515 report was about. `TimeoutBody` only resets its clock
757/// when polled, and hyper stops polling a response body once its outbound buffer
758/// fills waiting on the client to read the socket, so a client that stops reading
759/// entirely is never caught by this layer; that requires connection/socket-level
760/// accounting in whatever owns the `TcpListener`. [`serve_with_limits`](super::serve::serve_with_limits)
761/// (`infrastructure::http::serve`) closes that gap at the connection level via its
762/// `max_connection_duration` limit, for any caller that serves this crate's routers
763/// through it instead of plain `axum::serve` (#523).
764///
765/// On the current streaming route (`GET .../frames/stream`, #511) this layer is
766/// close to a no-op even for the producer-stall case it does cover:
767/// `stream_stream_frames` fully materializes its frames into a `Vec` (bounded by
768/// `MAX_PAGINATION_LIMIT`) before streaming begins, and `BatchFrameStream` then
769/// iterates that already-in-memory `Vec` — there is no upstream source that can
770/// actually stall mid-stream on this route today. This layer still has value for
771/// any future or other route whose data source can genuinely stall while producing
772/// (e.g. a backpressured or slow upstream), and is retained as a defensible general
773/// mitigation rather than removed.
774const RESPONSE_BODY_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
775
776/// Apply the cross-cutting middleware stack shared by all router variants.
777///
778/// `rate_limit` is optional because only the `*_with_rate_limit_*` router
779/// constructors have one to apply; `None` simply omits that layer from the stack.
780///
781/// Order — axum's `Router::layer` re-wraps whatever was built by earlier `.layer()`
782/// calls, so the **last** `.layer()` call ends up outermost (sees the request
783/// first, the response last):
784/// ```text
785/// TraceLayer                  ← distributed tracing (outermost)
786/// TimeoutLayer                ← whole-request timeout (pre-response phase only)
787/// ResponseBodyTimeoutLayer    ← per-frame idle timeout on the response body
788/// CorsLayer                   ← CORS (outside auth, so preflight is answered before auth)
789/// DefaultBodyLimit            ← body size guard
790/// security_middleware         ← security headers
791/// rate_limit                  ← per-client quota (only when `Some`)
792/// GlobalConcurrencyLimitLayer ← global in-flight request cap (innermost)
793/// ```
794///
795/// `rate_limit` sits *inside* `security_middleware`/`CorsLayer`/`TraceLayer` and
796/// *outside* `GlobalConcurrencyLimitLayer`, which is deliberate on both sides:
797/// - Inside security/CORS/trace: a `429` still gets security headers
798///   (`X-Content-Type-Options`, `X-Frame-Options`, CSP), a browser making a
799///   cross-origin request still gets a readable `429`+`Retry-After` instead of an
800///   opaque CORS network error, and rate-limit rejections still show up in request
801///   traces — losing any of these on a rejection path defeats the point of a DoS
802///   mitigation feature. An earlier revision of this function had `rate_limit`
803///   applied by the caller after this function returned (i.e. outermost of
804///   everything), which regressed exactly these three properties; that version is
805///   not what ships.
806/// - Outside the concurrency limiter: a request the rate limiter rejects with
807///   `429` never reaches (and never consumes) a `GlobalConcurrencyLimitLayer`
808///   permit, and `GlobalConcurrencyLimitLayer` being innermost overall means a
809///   request that *does* pass the rate limiter but then queues for a permit is
810///   still bounded by the outer `TimeoutLayer`'s 30s deadline, so a saturated pool
811///   degrades to `408`s instead of queueing forever.
812///
813/// Relative order between `ResponseBodyTimeoutLayer` and `TimeoutLayer` does not
814/// affect correctness despite `TimeoutLayer` requiring its inner response body to
815/// implement `Default`: axum's `Route` re-boxes every layer's output back into the
816/// canonical `axum::body::Body`-based `Response` (`Route::new`'s `MapIntoResponse`)
817/// before the next `.layer()` call ever sees it, so each `.layer()` call always
818/// observes a plain, `Default`-implementing `axum::body::Body`, regardless of what
819/// came before it in this list.
820fn apply_common_layers<R, P, S>(
821    router: Router<PjsAppState<R, P, S>>,
822    config: &HttpServerConfig,
823    rate_limit: Option<RateLimitMiddleware>,
824) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
825where
826    R: StreamRepositoryGat + Send + Sync + 'static,
827    P: EventPublisherGat + Send + Sync + 'static,
828    S: StreamStoreGat + Send + Sync + 'static,
829{
830    let cors = build_cors_layer(config)?;
831    let router = router.layer(GlobalConcurrencyLimitLayer::new(MAX_CONCURRENT_REQUESTS));
832    let router = match rate_limit {
833        Some(rate_limit) => router.layer(rate_limit),
834        None => router,
835    };
836    Ok(router
837        .layer(middleware::from_fn(security_middleware))
838        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
839        .layer(cors)
840        .layer(ResponseBodyTimeoutLayer::new(RESPONSE_BODY_IDLE_TIMEOUT))
841        .layer(TimeoutLayer::with_status_code(
842            StatusCode::REQUEST_TIMEOUT,
843            REQUEST_TIMEOUT,
844        ))
845        .layer(TraceLayer::new_for_http()))
846}
847
848/// Parse a raw path segment into a [`SessionId`], mapping failure to [`PjsError::InvalidSessionId`].
849pub(crate) fn parse_session_id(raw: String) -> Result<SessionId, PjsError> {
850    SessionId::from_string(&raw).map_err(|_| PjsError::InvalidSessionId(raw))
851}
852
853/// Parse raw `(session_id, stream_id)` path segments, mapping failures to the matching
854/// [`PjsError::InvalidSessionId`] / [`PjsError::InvalidStreamId`] variant.
855pub(crate) fn parse_session_and_stream_id(
856    session_raw: String,
857    stream_raw: String,
858) -> Result<(SessionId, StreamId), PjsError> {
859    let session_id = parse_session_id(session_raw)?;
860    let stream_id =
861        StreamId::from_string(&stream_raw).map_err(|_| PjsError::InvalidStreamId(stream_raw))?;
862    Ok((session_id, stream_id))
863}
864
865/// Parse a raw `state` query-string value into a [`SessionState`], mapping failure to
866/// [`PjsError::InvalidSessionState`].
867///
868/// Kept as a raw `String` on [`SearchSessionsParams`] (rather than typing the field itself
869/// as `SessionState`) so a bad value is rejected here, inside the handler, with the API's
870/// standard JSON error envelope — not by axum's `Query` extractor, which fails before the
871/// handler runs and responds with a plain-text body inconsistent with every other 4xx this
872/// API returns. Accepts only the exact spellings [`SessionState`] serializes as (e.g.
873/// `"Active"`), matching [`SessionState::as_str`] — lowercase or mixed-case input is
874/// rejected, unlike the pre-#414 substring/case-insensitive repository match.
875pub(crate) fn parse_session_state(raw: String) -> Result<SessionState, PjsError> {
876    serde_json::from_value(serde_json::Value::String(raw.clone()))
877        .map_err(|_| PjsError::InvalidSessionState(raw))
878}
879
880/// Parse a raw `sort_by` query-string value into a [`SessionSortField`], mapping failure to
881/// [`PjsError::InvalidSortField`].
882///
883/// Kept as a raw `String` on [`SearchSessionsParams`] for the same reason as
884/// [`parse_session_state`]: a bad value is rejected here, inside the handler, with the API's
885/// standard JSON error envelope rather than axum's `Query` extractor's plain-text rejection.
886/// Delegates to [`SessionSortField`]'s `#[serde(rename_all = "snake_case")]` derive, matching
887/// its exact serialized spellings (e.g. `created_at`).
888pub(crate) fn parse_sort_field(raw: String) -> Result<SessionSortField, PjsError> {
889    serde_json::from_value(serde_json::Value::String(raw.clone()))
890        .map_err(|_| PjsError::InvalidSortField(raw))
891}
892
893/// Parse a raw `sort_order` query-string value into a [`SortOrder`], mapping failure to
894/// [`PjsError::InvalidSortOrder`].
895///
896/// Kept as a raw `String` on [`SearchSessionsParams`] for the same reason as
897/// [`parse_sort_field`]: a bad value is rejected here, inside the handler, with the API's
898/// standard JSON error envelope rather than axum's `Query` extractor's plain-text rejection.
899/// Delegates to [`SortOrder`]'s `#[serde(rename_all = "snake_case")]` derive, which also
900/// carries `#[serde(alias = "asc")]`/`#[serde(alias = "desc")]` on its variants — so both the
901/// long (`ascending`/`descending`) and short (`asc`/`desc`) spellings are accepted.
902pub(crate) fn parse_sort_order(raw: String) -> Result<SortOrder, PjsError> {
903    serde_json::from_value(serde_json::Value::String(raw.clone()))
904        .map_err(|_| PjsError::InvalidSortOrder(raw))
905}
906
907/// Pagination parameters
908#[derive(Debug, Deserialize)]
909pub struct PaginationParams {
910    /// Maximum number of items to return.
911    pub limit: Option<usize>,
912    /// Number of items to skip before returning results.
913    pub offset: Option<usize>,
914}
915
916/// Query parameters for session search endpoint.
917#[derive(Debug, Deserialize)]
918pub struct SearchSessionsParams {
919    /// Match sessions whose state equals this value.
920    ///
921    /// Must be one of [`SessionState`]'s exact serialized spellings, case-sensitive —
922    /// `Initializing`, `Active`, `Closing`, `Completed`, or `Failed` — or the request is
923    /// rejected with `400`. Parsed via an internal helper rather than typed directly, so
924    /// the rejection goes through the API's standard JSON error envelope instead of axum's
925    /// raw `Query`-extractor rejection body.
926    pub state: Option<String>,
927    /// Field name to sort by. Must be one of [`SessionSortField`]'s exact serialized
928    /// spellings — `created_at`, `updated_at`, `stream_count`, `total_bytes` — or the
929    /// request is rejected with `400`. Parsed via `parse_sort_field` rather than typed
930    /// directly, so the rejection goes through the API's standard JSON error envelope
931    /// instead of axum's raw `Query`-extractor rejection body. An empty value (`?sort_by=`)
932    /// is also rejected with `400`, same as `parse_session_state` treats an empty
933    /// `?state=` — it is not treated as "absent". Omitting the parameter entirely still
934    /// yields `None` (no sort applied), which continues to return `200`.
935    pub sort_by: Option<String>,
936    /// Sort direction. Must be `"asc"`, `"ascending"`, `"desc"`, or `"descending"`,
937    /// case-sensitive.
938    ///
939    /// Same treatment as `sort_by`: parsed via `parse_sort_order` rather than typed
940    /// directly, so an unrecognized or empty value is rejected with `400` through the
941    /// API's standard JSON error envelope instead of being silently ignored or falling
942    /// through axum's raw `Query`-extractor rejection body. Omitting the parameter
943    /// entirely still yields `None` (default sort order), which continues to return `200`.
944    pub sort_order: Option<String>,
945    /// Maximum number of sessions to return.
946    pub limit: Option<usize>,
947    /// Number of sessions to skip before returning results.
948    pub offset: Option<usize>,
949}
950
951/// Query parameters for frame listing
952#[derive(Debug, Deserialize)]
953pub struct FrameQueryParams {
954    /// Return only frames whose sequence number is greater than this value.
955    pub since_sequence: Option<u64>,
956    /// Return only frames whose priority satisfies this filter.
957    pub priority: Option<u8>,
958    /// Maximum number of frames to return.
959    pub limit: Option<usize>,
960}
961
962// HTTP rate limiting is implemented by `RateLimitMiddleware`
963// (crate::infrastructure::http::middleware), wired in via
964// `create_pjs_router_with_rate_limit[_and_config]` and
965// `create_pjs_router_with_rate_limit_and_auth` above. It keys on the real
966// ConnectInfo<SocketAddr> peer address by default; see
967// `RateLimitConfig::with_trusted_proxies` to opt in to trusting
968// X-Forwarded-For/X-Real-IP behind a known reverse proxy.
969
970/// PJS-specific errors for HTTP endpoints
971#[derive(Debug, thiserror::Error)]
972pub enum PjsError {
973    /// Wraps an application-layer error returned by a CQRS handler.
974    #[error("Application error: {0}")]
975    Application(#[from] crate::application::ApplicationError),
976
977    /// Provided session identifier is malformed or not a valid UUID.
978    #[error("Invalid session ID: {0}")]
979    InvalidSessionId(String),
980
981    /// Provided stream identifier is malformed or not a valid UUID.
982    #[error("Invalid stream ID: {0}")]
983    InvalidStreamId(String),
984
985    /// Priority value is out of range or otherwise invalid.
986    #[error("Invalid priority: {0}")]
987    InvalidPriority(String),
988
989    /// Provided session state filter does not match any `SessionState` variant.
990    #[error(
991        "Invalid session state: {0} (expected one of: Initializing, Active, Closing, Completed, Failed)"
992    )]
993    InvalidSessionState(String),
994
995    /// Provided `sort_by` value does not match any `SessionSortField` variant.
996    #[error(
997        "Invalid sort field: {0} (expected one of: created_at, updated_at, stream_count, total_bytes)"
998    )]
999    InvalidSortField(String),
1000
1001    /// Provided `sort_order` value does not match any recognized sort direction.
1002    #[error("Invalid sort order: {0} (expected one of: asc, ascending, desc, descending)")]
1003    InvalidSortOrder(String),
1004
1005    /// Generic HTTP-layer error not covered by other variants.
1006    ///
1007    /// # Invariant
1008    ///
1009    /// For any construction site reachable while handling a request (i.e.
1010    /// the error can end up in a response sent to an HTTP client), the
1011    /// wrapped `String` **must never** carry the `Display` output of a
1012    /// wrapped or foreign error — that text may contain paths, connection
1013    /// details, or other internals. Log the real error server-side (e.g. via
1014    /// `tracing::error!`) and construct this variant with a generic,
1015    /// client-safe message instead. Known channels that can leak the
1016    /// wrapped string to a client: this type's `IntoResponse` implementation
1017    /// (below), and any handler that builds a response body directly from
1018    /// the error (e.g. `metrics_handler` in
1019    /// `crate::infrastructure::http::metrics`, which bypasses
1020    /// `IntoResponse`).
1021    ///
1022    /// The construction sites in `build_cors_layer` (private, this module)
1023    /// are the intentional exemption: they run at router-build time from
1024    /// operator-supplied config, before any request is served, and their
1025    /// `PjsError` is never routed into a response — a build failure aborts
1026    /// server startup.
1027    #[error("HTTP error: {0}")]
1028    HttpError(String),
1029}
1030
1031impl IntoResponse for PjsError {
1032    fn into_response(self) -> Response {
1033        let (status, error_message) = match &self {
1034            PjsError::Application(app_err) => {
1035                use crate::application::ApplicationError;
1036                let status = match app_err {
1037                    ApplicationError::NotFound(_) => StatusCode::NOT_FOUND,
1038                    ApplicationError::Validation(_) => StatusCode::BAD_REQUEST,
1039                    ApplicationError::Authorization(_) => StatusCode::UNAUTHORIZED,
1040                    ApplicationError::Concurrency(_) | ApplicationError::Conflict(_) => {
1041                        StatusCode::CONFLICT
1042                    }
1043                    ApplicationError::Domain(_) | ApplicationError::Logic(_) => {
1044                        StatusCode::INTERNAL_SERVER_ERROR
1045                    }
1046                };
1047                (status, self.to_string())
1048            }
1049            PjsError::InvalidSessionId(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1050            PjsError::InvalidStreamId(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1051            PjsError::InvalidPriority(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1052            PjsError::InvalidSessionState(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1053            PjsError::InvalidSortField(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1054            PjsError::InvalidSortOrder(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1055            PjsError::HttpError(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
1056        };
1057
1058        let body = Json(serde_json::json!({
1059            "error": error_message
1060        }));
1061
1062        (status, body).into_response()
1063    }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069    use axum::http::header;
1070
1071    // --- build_cors_layer unit tests ---
1072
1073    #[test]
1074    fn cors_empty_origins_denies_all() {
1075        let config = HttpServerConfig {
1076            allowed_origins: vec![],
1077        };
1078        // Empty list must succeed (returns a layer that denies all origins).
1079        let result = build_cors_layer(&config);
1080        assert!(
1081            result.is_ok(),
1082            "empty origins should return Ok (deny-all layer)"
1083        );
1084    }
1085
1086    #[test]
1087    fn cors_wildcard_only_is_ok() {
1088        let config = HttpServerConfig {
1089            allowed_origins: vec!["*".to_string()],
1090        };
1091        let result = build_cors_layer(&config);
1092        assert!(result.is_ok(), "wildcard-only should return Ok");
1093    }
1094
1095    #[test]
1096    fn cors_mixed_wildcard_and_explicit_is_err() {
1097        let config = HttpServerConfig {
1098            allowed_origins: vec!["*".to_string(), "http://example.com".to_string()],
1099        };
1100        let result = build_cors_layer(&config);
1101        assert!(
1102            result.is_err(),
1103            "mixing wildcard with explicit origins must fail"
1104        );
1105        let msg = result.unwrap_err().to_string();
1106        assert!(
1107            msg.contains("wildcard"),
1108            "error message should mention wildcard: {msg}"
1109        );
1110    }
1111
1112    #[test]
1113    fn cors_valid_single_origin_is_ok() {
1114        let config = HttpServerConfig {
1115            allowed_origins: vec!["http://example.com".to_string()],
1116        };
1117        assert!(build_cors_layer(&config).is_ok());
1118    }
1119
1120    #[test]
1121    fn cors_valid_multiple_origins_is_ok() {
1122        let config = HttpServerConfig {
1123            allowed_origins: vec![
1124                "https://app.example.com".to_string(),
1125                "https://admin.example.com".to_string(),
1126            ],
1127        };
1128        assert!(build_cors_layer(&config).is_ok());
1129    }
1130
1131    #[test]
1132    fn cors_invalid_origin_string_is_err() {
1133        let config = HttpServerConfig {
1134            // HeaderValue rejects strings containing control characters / invalid bytes.
1135            allowed_origins: vec!["not a\nvalid header".to_string()],
1136        };
1137        let result = build_cors_layer(&config);
1138        assert!(result.is_err(), "invalid origin string must return Err");
1139    }
1140
1141    #[test]
1142    fn default_config_is_valid() {
1143        // Guarantees that the expect() in create_pjs_router / create_pjs_router_with_rate_limit
1144        // will never panic at runtime.
1145        assert!(
1146            build_cors_layer(&HttpServerConfig::default()).is_ok(),
1147            "default HttpServerConfig must produce a valid CORS layer"
1148        );
1149    }
1150
1151    // --- parse_session_id / parse_session_and_stream_id unit tests ---
1152
1153    #[test]
1154    fn parse_session_id_valid_roundtrips() {
1155        let id = SessionId::new();
1156        let parsed = parse_session_id(id.to_string()).expect("valid uuid must parse");
1157        assert_eq!(parsed, id);
1158    }
1159
1160    #[test]
1161    fn parse_session_id_invalid_returns_invalid_session_id_error() {
1162        let raw = "not-a-valid-uuid".to_string();
1163        let err = parse_session_id(raw.clone()).unwrap_err();
1164        match err {
1165            PjsError::InvalidSessionId(msg) => assert_eq!(msg, raw),
1166            other => panic!("expected InvalidSessionId, got {other:?}"),
1167        }
1168    }
1169
1170    #[test]
1171    fn parse_session_and_stream_id_valid_roundtrips() {
1172        let session_id = SessionId::new();
1173        let stream_id = StreamId::new();
1174        let (parsed_session, parsed_stream) =
1175            parse_session_and_stream_id(session_id.to_string(), stream_id.to_string())
1176                .expect("valid uuids must parse");
1177        assert_eq!(parsed_session, session_id);
1178        assert_eq!(parsed_stream, stream_id);
1179    }
1180
1181    #[test]
1182    fn parse_session_and_stream_id_invalid_session_short_circuits() {
1183        let raw_session = "bad-session".to_string();
1184        let err = parse_session_and_stream_id(raw_session.clone(), StreamId::new().to_string())
1185            .unwrap_err();
1186        match err {
1187            PjsError::InvalidSessionId(msg) => assert_eq!(msg, raw_session),
1188            other => panic!("expected InvalidSessionId, got {other:?}"),
1189        }
1190    }
1191
1192    #[test]
1193    fn parse_session_and_stream_id_invalid_stream_returns_invalid_stream_id_error() {
1194        let raw_stream = "bad-stream".to_string();
1195        let err = parse_session_and_stream_id(SessionId::new().to_string(), raw_stream.clone())
1196            .unwrap_err();
1197        match err {
1198            PjsError::InvalidStreamId(msg) => assert_eq!(msg, raw_stream),
1199            other => panic!("expected InvalidStreamId, got {other:?}"),
1200        }
1201    }
1202
1203    // --- existing integration tests ---
1204
1205    use crate::domain::{
1206        entities::Stream,
1207        events::DomainEvent,
1208        ports::{
1209            EventPublisherGat, PriorityDistribution, StreamFilter, StreamStatistics, StreamStatus,
1210            StreamStoreGat,
1211        },
1212        value_objects::{SessionId, StreamId},
1213    };
1214    use crate::test_support::MockRepository;
1215    use chrono::Utc;
1216
1217    struct MockEventPublisher;
1218
1219    impl EventPublisherGat for MockEventPublisher {
1220        type PublishFuture<'a>
1221            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1222        where
1223            Self: 'a;
1224
1225        type PublishBatchFuture<'a>
1226            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1227        where
1228            Self: 'a;
1229
1230        fn publish(&self, _event: DomainEvent) -> Self::PublishFuture<'_> {
1231            async move { Ok(()) }
1232        }
1233
1234        fn publish_batch(&self, _events: Vec<DomainEvent>) -> Self::PublishBatchFuture<'_> {
1235            async move { Ok(()) }
1236        }
1237    }
1238
1239    struct MockStreamStore;
1240
1241    impl StreamStoreGat for MockStreamStore {
1242        type StoreStreamFuture<'a>
1243            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1244        where
1245            Self: 'a;
1246
1247        type GetStreamFuture<'a>
1248            = impl std::future::Future<Output = crate::domain::DomainResult<Option<Stream>>>
1249            + Send
1250            + 'a
1251        where
1252            Self: 'a;
1253
1254        type DeleteStreamFuture<'a>
1255            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1256        where
1257            Self: 'a;
1258
1259        type ListStreamsForSessionFuture<'a>
1260            =
1261            impl std::future::Future<Output = crate::domain::DomainResult<Vec<Stream>>> + Send + 'a
1262        where
1263            Self: 'a;
1264
1265        type FindStreamsBySessionFuture<'a>
1266            =
1267            impl std::future::Future<Output = crate::domain::DomainResult<Vec<Stream>>> + Send + 'a
1268        where
1269            Self: 'a;
1270
1271        type UpdateStreamStatusFuture<'a>
1272            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1273        where
1274            Self: 'a;
1275
1276        type GetStreamStatisticsFuture<'a>
1277            = impl std::future::Future<Output = crate::domain::DomainResult<StreamStatistics>>
1278            + Send
1279            + 'a
1280        where
1281            Self: 'a;
1282
1283        fn store_stream(&self, _stream: Stream) -> Self::StoreStreamFuture<'_> {
1284            async move { Ok(()) }
1285        }
1286
1287        fn get_stream(&self, _stream_id: StreamId) -> Self::GetStreamFuture<'_> {
1288            async move { Ok(None) }
1289        }
1290
1291        fn delete_stream(&self, _stream_id: StreamId) -> Self::DeleteStreamFuture<'_> {
1292            async move { Ok(()) }
1293        }
1294
1295        fn list_streams_for_session(
1296            &self,
1297            _session_id: SessionId,
1298        ) -> Self::ListStreamsForSessionFuture<'_> {
1299            async move { Ok(vec![]) }
1300        }
1301
1302        fn find_streams_by_session(
1303            &self,
1304            _session_id: SessionId,
1305            _filter: StreamFilter,
1306        ) -> Self::FindStreamsBySessionFuture<'_> {
1307            async move { Ok(vec![]) }
1308        }
1309
1310        fn update_stream_status(
1311            &self,
1312            _stream_id: StreamId,
1313            _status: StreamStatus,
1314        ) -> Self::UpdateStreamStatusFuture<'_> {
1315            async move { Ok(()) }
1316        }
1317
1318        fn get_stream_statistics(
1319            &self,
1320            _stream_id: StreamId,
1321        ) -> Self::GetStreamStatisticsFuture<'_> {
1322            async move {
1323                Ok(StreamStatistics {
1324                    total_frames: 0,
1325                    total_bytes: 0,
1326                    priority_distribution: PriorityDistribution::default(),
1327                    avg_frame_size: 0.0,
1328                    creation_time: Utc::now(),
1329                    completion_time: None,
1330                    processing_duration: None,
1331                })
1332            }
1333        }
1334    }
1335
1336    #[tokio::test]
1337    async fn test_system_health() {
1338        let response = system_health().await;
1339        let health_data: serde_json::Value = response.0;
1340
1341        assert_eq!(health_data["status"], "healthy");
1342        assert!(!health_data["features"].as_array().unwrap().is_empty());
1343    }
1344
1345    #[tokio::test]
1346    async fn test_app_state_creation() {
1347        let repository = Arc::new(MockRepository::new());
1348        let event_publisher = Arc::new(MockEventPublisher);
1349        let stream_store = Arc::new(MockStreamStore);
1350
1351        let _state = PjsAppState::new(repository, event_publisher, stream_store);
1352    }
1353
1354    #[tokio::test]
1355    async fn test_get_system_stats_returns_real_uptime() {
1356        use crate::application::handlers::QueryHandlerGat;
1357        use crate::application::handlers::query_handlers::SystemQueryHandler;
1358        use crate::application::queries::GetSystemStatsQuery;
1359        use std::time::{Duration, Instant};
1360
1361        let repository = Arc::new(MockRepository::new());
1362        // Simulate a handler that started 5 seconds ago.
1363        let started_at = Instant::now() - Duration::from_secs(5);
1364        let handler = SystemQueryHandler::with_start_time(repository, started_at);
1365
1366        let query = GetSystemStatsQuery {
1367            include_historical: false,
1368        };
1369        let result = QueryHandlerGat::handle(&handler, query).await.unwrap();
1370
1371        // uptime must reflect the real elapsed time, not a hard-coded value.
1372        assert!(
1373            result.uptime_seconds >= 5,
1374            "uptime_seconds should be at least 5, got {}",
1375            result.uptime_seconds
1376        );
1377        // Must not be the old placeholder value (3600).
1378        assert_ne!(
1379            result.uptime_seconds, 3600,
1380            "uptime_seconds must not be the hard-coded placeholder 3600"
1381        );
1382    }
1383
1384    #[cfg(feature = "metrics")]
1385    #[tokio::test]
1386    async fn test_metrics_endpoint_returns_prometheus_format() {
1387        use crate::infrastructure::http::metrics::install_global_recorder;
1388
1389        // Install the recorder and verify the handle renders text/plain output.
1390        let handle = install_global_recorder().expect("recorder install should succeed");
1391        let rendered = handle.render();
1392        // Prometheus text format: empty registry produces an empty string or
1393        // comment lines; never a JSON error body.
1394        assert!(
1395            !rendered.contains("{\"error\""),
1396            "rendered metrics should not be a JSON error: {rendered}"
1397        );
1398
1399        // Calling again must be idempotent.
1400        let handle2 = install_global_recorder().expect("second call must not fail");
1401        assert_eq!(
1402            handle.render(),
1403            handle2.render(),
1404            "both handles must render the same metrics"
1405        );
1406    }
1407
1408    #[cfg(feature = "metrics")]
1409    #[test]
1410    fn test_metrics_router_has_metrics_route() {
1411        // Verify that the router includes /metrics by exercising the route builder.
1412        // We check this at compile time through the feature-gated code path.
1413        let _router =
1414            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1415                &HttpServerConfig::default(),
1416            )
1417            .expect("router should build successfully with metrics feature");
1418    }
1419
1420    /// Also guards the genuinely-absent `sort_by` path: omitting the query param
1421    /// entirely must still return `200` (i.e. `parse_sort_field` is never invoked, and
1422    /// `SearchSessionsQuery.sort_by` resolves to `None`) — a regression check against a
1423    /// future `unwrap_or_default`-style refactor of `search_sessions` breaking this case.
1424    /// See [`search_sessions_route_rejects_empty_sort_by`] for the present-but-empty case.
1425    #[tokio::test]
1426    async fn search_sessions_route_returns_ok() {
1427        use axum::http::Request;
1428        use tower::ServiceExt;
1429
1430        let repository = Arc::new(MockRepository::new());
1431        let event_publisher = Arc::new(MockEventPublisher);
1432        let stream_store = Arc::new(MockStreamStore);
1433        let state = PjsAppState::new(repository, event_publisher, stream_store);
1434
1435        let router =
1436            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1437                &HttpServerConfig::default(),
1438            )
1439            .expect("router should build")
1440            .with_state(state);
1441
1442        let req = Request::builder()
1443            .uri("/pjs/sessions/search")
1444            .body(axum::body::Body::empty())
1445            .unwrap();
1446
1447        let resp = router.oneshot(req).await.unwrap();
1448        assert_eq!(resp.status(), StatusCode::OK);
1449    }
1450
1451    /// #414: an exact, correctly-cased `state` value is accepted end-to-end through the
1452    /// real router — the `Query<SearchSessionsParams>` extractor plus `parse_session_state`
1453    /// inside `search_sessions` must not reject a value matching `SessionState::as_str()`.
1454    #[tokio::test]
1455    async fn search_sessions_route_accepts_valid_state() {
1456        use axum::http::Request;
1457        use tower::ServiceExt;
1458
1459        let repository = Arc::new(MockRepository::new());
1460        let event_publisher = Arc::new(MockEventPublisher);
1461        let stream_store = Arc::new(MockStreamStore);
1462        let state = PjsAppState::new(repository, event_publisher, stream_store);
1463
1464        let router =
1465            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1466                &HttpServerConfig::default(),
1467            )
1468            .expect("router should build")
1469            .with_state(state);
1470
1471        let req = Request::builder()
1472            .uri("/pjs/sessions/search?state=Active")
1473            .body(axum::body::Body::empty())
1474            .unwrap();
1475
1476        let resp = router.oneshot(req).await.unwrap();
1477        assert_eq!(resp.status(), StatusCode::OK);
1478    }
1479
1480    /// #414: an unrecognized `state` value must be rejected with a `400` carrying the
1481    /// API's standard `{"error": ...}` JSON envelope (via `PjsError::InvalidSessionState`),
1482    /// not axum's raw `Query`-extractor rejection body — see impl-critic gap S2. Also
1483    /// exercises the case-sensitivity break called out in the CHANGELOG: `"active"`
1484    /// (lowercase) previously matched via the repository's case-insensitive comparison
1485    /// and now must be rejected, since `SessionState` only deserializes its exact
1486    /// spellings (e.g. `"Active"`).
1487    #[tokio::test]
1488    async fn search_sessions_route_rejects_unknown_state() {
1489        use axum::body::to_bytes;
1490        use axum::http::Request;
1491        use tower::ServiceExt;
1492
1493        let repository = Arc::new(MockRepository::new());
1494        let event_publisher = Arc::new(MockEventPublisher);
1495        let stream_store = Arc::new(MockStreamStore);
1496        let state = PjsAppState::new(repository, event_publisher, stream_store);
1497
1498        let router =
1499            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1500                &HttpServerConfig::default(),
1501            )
1502            .expect("router should build")
1503            .with_state(state);
1504
1505        let req = Request::builder()
1506            .uri("/pjs/sessions/search?state=active")
1507            .body(axum::body::Body::empty())
1508            .unwrap();
1509
1510        let resp = router.oneshot(req).await.unwrap();
1511        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1512
1513        let content_type = resp
1514            .headers()
1515            .get(header::CONTENT_TYPE)
1516            .and_then(|v| v.to_str().ok())
1517            .unwrap_or_default()
1518            .to_string();
1519        assert!(
1520            content_type.starts_with("application/json"),
1521            "rejection must use the API's JSON envelope, got content-type: {content_type}"
1522        );
1523
1524        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1525        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1526        assert!(
1527            json.get("error").is_some_and(|e| e.is_string()),
1528            "body must match the standard {{\"error\": ...}} envelope, got: {json}"
1529        );
1530    }
1531
1532    /// #492/#494: an exact, correctly-spelled `sort_by` value is accepted end-to-end
1533    /// through the real router — the `Query<SearchSessionsParams>` extractor plus
1534    /// `parse_sort_field` inside `search_sessions` must not reject a value matching
1535    /// one of `SessionSortField`'s serialized spellings.
1536    #[tokio::test]
1537    async fn search_sessions_route_accepts_valid_sort_by() {
1538        use axum::http::Request;
1539        use tower::ServiceExt;
1540
1541        let repository = Arc::new(MockRepository::new());
1542        let event_publisher = Arc::new(MockEventPublisher);
1543        let stream_store = Arc::new(MockStreamStore);
1544        let state = PjsAppState::new(repository, event_publisher, stream_store);
1545
1546        let router =
1547            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1548                &HttpServerConfig::default(),
1549            )
1550            .expect("router should build")
1551            .with_state(state);
1552
1553        let req = Request::builder()
1554            .uri("/pjs/sessions/search?sort_by=created_at")
1555            .body(axum::body::Body::empty())
1556            .unwrap();
1557
1558        let resp = router.oneshot(req).await.unwrap();
1559        assert_eq!(resp.status(), StatusCode::OK);
1560    }
1561
1562    /// #492: an unrecognized `sort_by` value must be rejected with a `400` carrying
1563    /// the API's standard `{"error": ...}` JSON envelope (via
1564    /// `PjsError::InvalidSortField`), not silently ignored — see #492 for the prior
1565    /// behavior of silently skipping an unknown sort field.
1566    #[tokio::test]
1567    async fn search_sessions_route_rejects_unknown_sort_by() {
1568        use axum::body::to_bytes;
1569        use axum::http::Request;
1570        use tower::ServiceExt;
1571
1572        let repository = Arc::new(MockRepository::new());
1573        let event_publisher = Arc::new(MockEventPublisher);
1574        let stream_store = Arc::new(MockStreamStore);
1575        let state = PjsAppState::new(repository, event_publisher, stream_store);
1576
1577        let router =
1578            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1579                &HttpServerConfig::default(),
1580            )
1581            .expect("router should build")
1582            .with_state(state);
1583
1584        let req = Request::builder()
1585            .uri("/pjs/sessions/search?sort_by=bogus")
1586            .body(axum::body::Body::empty())
1587            .unwrap();
1588
1589        let resp = router.oneshot(req).await.unwrap();
1590        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1591
1592        let content_type = resp
1593            .headers()
1594            .get(header::CONTENT_TYPE)
1595            .and_then(|v| v.to_str().ok())
1596            .unwrap_or_default()
1597            .to_string();
1598        assert!(
1599            content_type.starts_with("application/json"),
1600            "rejection must use the API's JSON envelope, got content-type: {content_type}"
1601        );
1602
1603        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1604        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1605        assert!(
1606            json.get("error").is_some_and(|e| e.is_string()),
1607            "body must match the standard {{\"error\": ...}} envelope, got: {json}"
1608        );
1609    }
1610
1611    /// #494: exercises the missing-underscore spelling called out in the issue —
1612    /// `createdat` never matched the old hand-rolled `match` either (it fell through
1613    /// to `_ => None` and was silently ignored, never accepted); now it is rejected
1614    /// with `400` since `SessionSortField` only deserializes its exact `snake_case`
1615    /// spellings (e.g. `created_at`).
1616    #[tokio::test]
1617    async fn search_sessions_route_rejects_sort_by_missing_underscore() {
1618        use axum::http::Request;
1619        use tower::ServiceExt;
1620
1621        let repository = Arc::new(MockRepository::new());
1622        let event_publisher = Arc::new(MockEventPublisher);
1623        let stream_store = Arc::new(MockStreamStore);
1624        let state = PjsAppState::new(repository, event_publisher, stream_store);
1625
1626        let router =
1627            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1628                &HttpServerConfig::default(),
1629            )
1630            .expect("router should build")
1631            .with_state(state);
1632
1633        let req = Request::builder()
1634            .uri("/pjs/sessions/search?sort_by=createdat")
1635            .body(axum::body::Body::empty())
1636            .unwrap();
1637
1638        let resp = router.oneshot(req).await.unwrap();
1639        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1640    }
1641
1642    /// #492: `?sort_by=` (present but empty) is rejected with `400`, the same treatment
1643    /// [`parse_session_state`] gives an empty `?state=` — an empty value is not treated
1644    /// as "absent". See [`search_sessions_route_returns_ok`] for the genuinely-absent
1645    /// case (no `sort_by` param at all), which must still return `200`.
1646    #[tokio::test]
1647    async fn search_sessions_route_rejects_empty_sort_by() {
1648        use axum::http::Request;
1649        use tower::ServiceExt;
1650
1651        let repository = Arc::new(MockRepository::new());
1652        let event_publisher = Arc::new(MockEventPublisher);
1653        let stream_store = Arc::new(MockStreamStore);
1654        let state = PjsAppState::new(repository, event_publisher, stream_store);
1655
1656        let router =
1657            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1658                &HttpServerConfig::default(),
1659            )
1660            .expect("router should build")
1661            .with_state(state);
1662
1663        let req = Request::builder()
1664            .uri("/pjs/sessions/search?sort_by=")
1665            .body(axum::body::Body::empty())
1666            .unwrap();
1667
1668        let resp = router.oneshot(req).await.unwrap();
1669        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1670    }
1671
1672    /// #497: each spelling `parse_sort_order` accepts — both the short (`asc`/`desc`)
1673    /// and long (`ascending`/`descending`) forms — is accepted end-to-end through the
1674    /// real router.
1675    #[tokio::test]
1676    async fn search_sessions_route_accepts_valid_sort_order() {
1677        use axum::http::Request;
1678        use tower::ServiceExt;
1679
1680        let repository = Arc::new(MockRepository::new());
1681        let event_publisher = Arc::new(MockEventPublisher);
1682        let stream_store = Arc::new(MockStreamStore);
1683        let state = PjsAppState::new(repository, event_publisher, stream_store);
1684
1685        let router =
1686            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1687                &HttpServerConfig::default(),
1688            )
1689            .expect("router should build")
1690            .with_state(state);
1691
1692        for value in ["asc", "ascending", "desc", "descending"] {
1693            let req = Request::builder()
1694                .uri(format!("/pjs/sessions/search?sort_order={value}"))
1695                .body(axum::body::Body::empty())
1696                .unwrap();
1697
1698            let resp = router.clone().oneshot(req).await.unwrap();
1699            assert_eq!(
1700                resp.status(),
1701                StatusCode::OK,
1702                "value {value} should be accepted"
1703            );
1704        }
1705    }
1706
1707    /// #497: an unrecognized `sort_order` value must be rejected with a `400` carrying
1708    /// the API's standard `{"error": ...}` JSON envelope (via `PjsError::InvalidSortOrder`),
1709    /// mirroring `sort_by`'s treatment — previously an unrecognized value was silently
1710    /// ignored, falling back to the default sort order.
1711    #[tokio::test]
1712    async fn search_sessions_route_rejects_unknown_sort_order() {
1713        use axum::body::to_bytes;
1714        use axum::http::Request;
1715        use tower::ServiceExt;
1716
1717        let repository = Arc::new(MockRepository::new());
1718        let event_publisher = Arc::new(MockEventPublisher);
1719        let stream_store = Arc::new(MockStreamStore);
1720        let state = PjsAppState::new(repository, event_publisher, stream_store);
1721
1722        let router =
1723            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1724                &HttpServerConfig::default(),
1725            )
1726            .expect("router should build")
1727            .with_state(state);
1728
1729        let req = Request::builder()
1730            .uri("/pjs/sessions/search?sort_order=bogus")
1731            .body(axum::body::Body::empty())
1732            .unwrap();
1733
1734        let resp = router.oneshot(req).await.unwrap();
1735        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1736
1737        let content_type = resp
1738            .headers()
1739            .get(header::CONTENT_TYPE)
1740            .and_then(|v| v.to_str().ok())
1741            .unwrap_or_default()
1742            .to_string();
1743        assert!(
1744            content_type.starts_with("application/json"),
1745            "rejection must use the API's JSON envelope, got content-type: {content_type}"
1746        );
1747
1748        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1749        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1750        assert!(
1751            json.get("error").is_some_and(|e| e.is_string()),
1752            "body must match the standard {{\"error\": ...}} envelope, got: {json}"
1753        );
1754    }
1755
1756    /// #497: `?sort_order=` (present but empty) is rejected with `400`, the same
1757    /// treatment [`parse_sort_field`] gives an empty `?sort_by=` — an empty value is not
1758    /// treated as "absent". See [`search_sessions_route_returns_ok`] for the
1759    /// genuinely-absent case (no `sort_order` param at all), which must still return `200`.
1760    #[tokio::test]
1761    async fn search_sessions_route_rejects_empty_sort_order() {
1762        use axum::http::Request;
1763        use tower::ServiceExt;
1764
1765        let repository = Arc::new(MockRepository::new());
1766        let event_publisher = Arc::new(MockEventPublisher);
1767        let stream_store = Arc::new(MockStreamStore);
1768        let state = PjsAppState::new(repository, event_publisher, stream_store);
1769
1770        let router =
1771            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1772                &HttpServerConfig::default(),
1773            )
1774            .expect("router should build")
1775            .with_state(state);
1776
1777        let req = Request::builder()
1778            .uri("/pjs/sessions/search?sort_order=")
1779            .body(axum::body::Body::empty())
1780            .unwrap();
1781
1782        let resp = router.oneshot(req).await.unwrap();
1783        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1784    }
1785
1786    /// #497: exercises the issue's literal repro — `?sort_order=decs` (a typo for `desc`)
1787    /// previously fell through the old hand-rolled `match`'s `_ => None` arm and was
1788    /// silently ignored rather than rejected; now it returns `400`.
1789    #[tokio::test]
1790    async fn search_sessions_route_rejects_sort_order_typo() {
1791        use axum::http::Request;
1792        use tower::ServiceExt;
1793
1794        let repository = Arc::new(MockRepository::new());
1795        let event_publisher = Arc::new(MockEventPublisher);
1796        let stream_store = Arc::new(MockStreamStore);
1797        let state = PjsAppState::new(repository, event_publisher, stream_store);
1798
1799        let router =
1800            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1801                &HttpServerConfig::default(),
1802            )
1803            .expect("router should build")
1804            .with_state(state);
1805
1806        let req = Request::builder()
1807            .uri("/pjs/sessions/search?sort_order=decs")
1808            .body(axum::body::Body::empty())
1809            .unwrap();
1810
1811        let resp = router.oneshot(req).await.unwrap();
1812        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1813    }
1814
1815    /// #497: `sort_order` matching is case-sensitive, like `sort_by` and `state` — an
1816    /// otherwise-valid spelling in the wrong case (`"ASC"`) is rejected with `400` rather
1817    /// than being accepted or silently ignored.
1818    #[tokio::test]
1819    async fn search_sessions_route_rejects_uppercase_sort_order() {
1820        use axum::http::Request;
1821        use tower::ServiceExt;
1822
1823        let repository = Arc::new(MockRepository::new());
1824        let event_publisher = Arc::new(MockEventPublisher);
1825        let stream_store = Arc::new(MockStreamStore);
1826        let state = PjsAppState::new(repository, event_publisher, stream_store);
1827
1828        let router =
1829            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1830                &HttpServerConfig::default(),
1831            )
1832            .expect("router should build")
1833            .with_state(state);
1834
1835        let req = Request::builder()
1836            .uri("/pjs/sessions/search?sort_order=ASC")
1837            .body(axum::body::Body::empty())
1838            .unwrap();
1839
1840        let resp = router.oneshot(req).await.unwrap();
1841        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1842    }
1843
1844    /// End-to-end HTTP smoke test for the frame-generation route added in issue #230.
1845    ///
1846    /// Drives `create-session → create-stream → start-stream → generate-frames`
1847    /// over the real Axum router and asserts each step succeeds. After issue
1848    /// #232 implemented `Stream::extract_patches` and its patch-to-frame
1849    /// batching (now `Stream::chunk_patches_for_commit`), the route now
1850    /// produces frames for non-empty source data — the assertion
1851    /// `frame_count > 0` verifies the full chain end-to-end.
1852    #[tokio::test]
1853    async fn generate_frames_route_dispatches_command_end_to_end() {
1854        use axum::body::to_bytes;
1855        use axum::http::{Method, Request};
1856        use tower::ServiceExt;
1857
1858        let repository = Arc::new(MockRepository::new());
1859        let event_publisher = Arc::new(MockEventPublisher);
1860        let stream_store = Arc::new(MockStreamStore);
1861        let state = PjsAppState::new(repository, event_publisher, stream_store);
1862
1863        let router =
1864            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1865                &HttpServerConfig::default(),
1866            )
1867            .expect("router should build")
1868            .with_state(state);
1869
1870        let create_session = Request::builder()
1871            .method(Method::POST)
1872            .uri("/pjs/sessions")
1873            .header(header::CONTENT_TYPE, "application/json")
1874            .body(axum::body::Body::from("{}"))
1875            .unwrap();
1876        let resp = router.clone().oneshot(create_session).await.unwrap();
1877        assert_eq!(resp.status(), StatusCode::OK);
1878        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1879        let session: serde_json::Value = serde_json::from_slice(&body).unwrap();
1880        let session_id = session["session_id"].as_str().unwrap().to_string();
1881
1882        let create_stream = Request::builder()
1883            .method(Method::POST)
1884            .uri(format!("/pjs/sessions/{session_id}/streams"))
1885            .header(header::CONTENT_TYPE, "application/json")
1886            .body(axum::body::Body::from(
1887                serde_json::json!({ "data": { "items": [1, 2, 3] } }).to_string(),
1888            ))
1889            .unwrap();
1890        let resp = router.clone().oneshot(create_stream).await.unwrap();
1891        assert_eq!(resp.status(), StatusCode::OK);
1892        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1893        let stream: serde_json::Value = serde_json::from_slice(&body).unwrap();
1894        let stream_id = stream["stream_id"].as_str().unwrap().to_string();
1895
1896        let start = Request::builder()
1897            .method(Method::POST)
1898            .uri(format!(
1899                "/pjs/sessions/{session_id}/streams/{stream_id}/start"
1900            ))
1901            .body(axum::body::Body::empty())
1902            .unwrap();
1903        let resp = router.clone().oneshot(start).await.unwrap();
1904        assert_eq!(resp.status(), StatusCode::OK);
1905
1906        let generate = Request::builder()
1907            .method(Method::POST)
1908            .uri(format!(
1909                "/pjs/sessions/{session_id}/streams/{stream_id}/generate-frames"
1910            ))
1911            .header(header::CONTENT_TYPE, "application/json")
1912            .body(axum::body::Body::from(
1913                serde_json::json!({ "max_frames": 4 }).to_string(),
1914            ))
1915            .unwrap();
1916        let resp = router.oneshot(generate).await.unwrap();
1917        assert_eq!(
1918            resp.status(),
1919            StatusCode::OK,
1920            "POST .../generate-frames must be reachable end-to-end"
1921        );
1922        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1923        let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
1924        assert!(payload["frames"].is_array(), "response must carry frames[]");
1925        let frame_count = payload["frame_count"]
1926            .as_u64()
1927            .expect("response must carry numeric frame_count");
1928        assert!(
1929            frame_count > 0,
1930            "extract_patches must yield at least one patch frame for `{{\"items\": [1,2,3]}}` \
1931             — frame_count was {frame_count}"
1932        );
1933    }
1934
1935    /// End-to-end dictionary path: drive `generate-frames` enough times to
1936    /// cross the `N_TRAIN` threshold, then assert the dictionary endpoint
1937    /// transitions from `404 Not Found` to `200 OK`. This is the chain that
1938    /// issues #224, #230, and #232 together claim to deliver.
1939    #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
1940    #[tokio::test]
1941    async fn dictionary_endpoint_becomes_reachable_after_training() {
1942        use crate::compression::zstd::N_TRAIN;
1943        use crate::infrastructure::repositories::InMemoryDictionaryStore;
1944        use crate::security::CompressionBombDetector;
1945        use axum::body::to_bytes;
1946        use axum::http::{Method, Request};
1947        use tower::ServiceExt;
1948
1949        let repository = Arc::new(MockRepository::new());
1950        let event_publisher = Arc::new(MockEventPublisher);
1951        let stream_store = Arc::new(MockStreamStore);
1952        let dictionary_store = Arc::new(InMemoryDictionaryStore::new(
1953            Arc::new(CompressionBombDetector::default()),
1954            64 * 1024,
1955        ));
1956        let state = PjsAppState::with_dictionary_store(
1957            repository,
1958            event_publisher,
1959            stream_store,
1960            dictionary_store,
1961        );
1962
1963        let router =
1964            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1965                &HttpServerConfig::default(),
1966            )
1967            .expect("router should build")
1968            .with_state(state);
1969
1970        let create_session = Request::builder()
1971            .method(Method::POST)
1972            .uri("/pjs/sessions")
1973            .header(header::CONTENT_TYPE, "application/json")
1974            .body(axum::body::Body::from("{}"))
1975            .unwrap();
1976        let resp = router.clone().oneshot(create_session).await.unwrap();
1977        assert_eq!(resp.status(), StatusCode::OK);
1978        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1979        let session: serde_json::Value = serde_json::from_slice(&body).unwrap();
1980        let session_id = session["session_id"].as_str().unwrap().to_string();
1981
1982        // Source data with N_TRAIN+ leaf patches keeps the test self-contained:
1983        // a single generate-frames call yields enough samples to cross the
1984        // training threshold.
1985        let mut payload = serde_json::Map::new();
1986        for i in 0..(N_TRAIN + 4) {
1987            payload.insert(
1988                format!("field_{i}"),
1989                serde_json::Value::String(format!("value_{i}")),
1990            );
1991        }
1992        let create_stream = Request::builder()
1993            .method(Method::POST)
1994            .uri(format!("/pjs/sessions/{session_id}/streams"))
1995            .header(header::CONTENT_TYPE, "application/json")
1996            .body(axum::body::Body::from(
1997                serde_json::json!({ "data": serde_json::Value::Object(payload) }).to_string(),
1998            ))
1999            .unwrap();
2000        let resp = router.clone().oneshot(create_stream).await.unwrap();
2001        assert_eq!(resp.status(), StatusCode::OK);
2002        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
2003        let stream: serde_json::Value = serde_json::from_slice(&body).unwrap();
2004        let stream_id = stream["stream_id"].as_str().unwrap().to_string();
2005
2006        let start = Request::builder()
2007            .method(Method::POST)
2008            .uri(format!(
2009                "/pjs/sessions/{session_id}/streams/{stream_id}/start"
2010            ))
2011            .body(axum::body::Body::empty())
2012            .unwrap();
2013        let resp = router.clone().oneshot(start).await.unwrap();
2014        assert_eq!(resp.status(), StatusCode::OK);
2015
2016        // Before training: the dictionary endpoint must be 404.
2017        let dict_before = Request::builder()
2018            .method(Method::GET)
2019            .uri(format!("/pjs/sessions/{session_id}/dictionary"))
2020            .body(axum::body::Body::empty())
2021            .unwrap();
2022        let resp = router.clone().oneshot(dict_before).await.unwrap();
2023        assert_eq!(
2024            resp.status(),
2025            StatusCode::NOT_FOUND,
2026            "dictionary endpoint must be 404 before N_TRAIN samples accumulate"
2027        );
2028
2029        // Generate enough frames to cross N_TRAIN. With max_frames at least
2030        // N_TRAIN+4, every leaf patch lands in its own frame.
2031        let max_frames = N_TRAIN + 4;
2032        let generate = Request::builder()
2033            .method(Method::POST)
2034            .uri(format!(
2035                "/pjs/sessions/{session_id}/streams/{stream_id}/generate-frames"
2036            ))
2037            .header(header::CONTENT_TYPE, "application/json")
2038            .body(axum::body::Body::from(
2039                serde_json::json!({ "max_frames": max_frames }).to_string(),
2040            ))
2041            .unwrap();
2042        let resp = router.clone().oneshot(generate).await.unwrap();
2043        assert_eq!(resp.status(), StatusCode::OK);
2044        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
2045        let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
2046        let frame_count = payload["frame_count"].as_u64().unwrap();
2047        assert!(
2048            frame_count >= N_TRAIN as u64,
2049            "single generate-frames call must yield at least N_TRAIN ({}) frames \
2050             so train_if_ready triggers training; got {frame_count}",
2051            N_TRAIN
2052        );
2053
2054        // After training: the dictionary endpoint must be 200.
2055        let dict_after = Request::builder()
2056            .method(Method::GET)
2057            .uri(format!("/pjs/sessions/{session_id}/dictionary"))
2058            .body(axum::body::Body::empty())
2059            .unwrap();
2060        let resp = router.oneshot(dict_after).await.unwrap();
2061        assert_eq!(
2062            resp.status(),
2063            StatusCode::OK,
2064            "dictionary endpoint must transition to 200 OK once N_TRAIN samples have been fed"
2065        );
2066        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
2067        assert!(
2068            !body.is_empty(),
2069            "trained dictionary body must be non-empty"
2070        );
2071    }
2072
2073    /// `priority_threshold = 0` is invalid per `Priority::new` — the route
2074    /// must reject the request with `400 Bad Request` rather than reaching
2075    /// the command handler.
2076    #[tokio::test]
2077    async fn generate_frames_route_rejects_invalid_priority() {
2078        use axum::http::{Method, Request};
2079        use tower::ServiceExt;
2080
2081        let repository = Arc::new(MockRepository::new());
2082        let event_publisher = Arc::new(MockEventPublisher);
2083        let stream_store = Arc::new(MockStreamStore);
2084        let state = PjsAppState::new(repository, event_publisher, stream_store);
2085
2086        let router =
2087            create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
2088                &HttpServerConfig::default(),
2089            )
2090            .expect("router should build")
2091            .with_state(state);
2092
2093        let sid = SessionId::new();
2094        let stream_id = StreamId::new();
2095        let req = Request::builder()
2096            .method(Method::POST)
2097            .uri(format!(
2098                "/pjs/sessions/{sid}/streams/{stream_id}/generate-frames"
2099            ))
2100            .header(header::CONTENT_TYPE, "application/json")
2101            .body(axum::body::Body::from(
2102                serde_json::json!({ "priority_threshold": 0 }).to_string(),
2103            ))
2104            .unwrap();
2105        let resp = router.oneshot(req).await.unwrap();
2106        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2107    }
2108}