Skip to main content

pjson_rs/infrastructure/http/
axum_extension.rs

1//! Universal Axum extension for existing APIs
2//!
3//! This module provides middleware and utilities to easily add PJS streaming
4//! capabilities to existing Axum applications without requiring major refactoring.
5
6use axum::{
7    Extension, Json,
8    extract::{Path, Query, Request, State},
9    http::{HeaderMap, StatusCode, header},
10    middleware::Next,
11    response::{IntoResponse, Response},
12};
13use futures::StreamExt;
14use serde::{Deserialize, Serialize};
15use serde_json::Value as JsonValue;
16use std::{collections::HashMap, sync::Arc, time::Duration};
17
18use crate::{Priority, PriorityStreamer};
19
20/// Configuration for PJS extension
21#[derive(Debug, Clone)]
22pub struct HttpExtensionConfig {
23    /// Route prefix for PJS endpoints (default: "/pjs")
24    pub route_prefix: String,
25    /// Enable automatic PJS detection based on Accept header
26    pub auto_detect: bool,
27    /// Default priority for streaming
28    pub default_priority: Priority,
29    /// Maximum concurrent streams per client
30    pub max_streams_per_client: usize,
31    /// Session timeout
32    pub session_timeout: Duration,
33    /// Origins allowed to receive `Access-Control-Allow-Origin` on the PJS
34    /// routes mounted by [`PjsExtension::extend_router`] (including the SSE
35    /// stream endpoint), validated with the same rules as
36    /// [`super::axum_adapter::HttpServerConfig::allowed_origins`].
37    ///
38    /// # Security
39    ///
40    /// Defaults to `vec![]` — same-origin only, no `Access-Control-Allow-Origin`
41    /// header is added at all. `PjsExtension` is meant to bolt onto an
42    /// arbitrary existing router, so it must not weaken that router's
43    /// cross-origin exposure unless the operator explicitly opts in here
44    /// (CWE-942): earlier versions unconditionally emitted a hardcoded
45    /// `Access-Control-Allow-Origin: *` on the SSE endpoint regardless of
46    /// the mounting application's own CORS policy.
47    ///
48    /// Set this to the origin(s) a cross-origin consumer runs on — e.g.
49    /// `pjs-js-client`'s `EventSource`-based SSE transport — to opt back
50    /// into cross-origin access with a validated allowlist instead of an
51    /// unconditional wildcard. `["*"]` allows any origin; mixing `"*"` with
52    /// explicit origins is invalid and falls back to no CORS layer at all
53    /// (logged) rather than panicking. Origins are matched against the
54    /// request's `Origin` header by case-sensitive byte equality — write
55    /// them in lowercase.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use pjson_rs::infrastructure::http::HttpExtensionConfig;
61    ///
62    /// let config = HttpExtensionConfig {
63    ///     allowed_origins: vec!["https://app.example.com".to_string()],
64    ///     ..Default::default()
65    /// };
66    /// assert_eq!(config.allowed_origins.len(), 1);
67    /// ```
68    pub allowed_origins: Vec<String>,
69}
70
71impl Default for HttpExtensionConfig {
72    fn default() -> Self {
73        Self {
74            route_prefix: "/pjs".to_string(),
75            auto_detect: true,
76            default_priority: Priority::MEDIUM,
77            max_streams_per_client: 10,
78            session_timeout: Duration::from_secs(3600),
79            allowed_origins: Vec::new(),
80        }
81    }
82}
83
84/// Universal PJS extension that can be added to any Axum router
85pub struct PjsExtension {
86    config: HttpExtensionConfig,
87    streamer: Arc<PriorityStreamer>,
88}
89
90impl PjsExtension {
91    /// Build a new extension with the given configuration.
92    pub fn new(config: HttpExtensionConfig) -> Self {
93        Self {
94            config,
95            streamer: Arc::new(PriorityStreamer::new()),
96        }
97    }
98
99    /// Add PJS capabilities to an existing Axum router.
100    ///
101    /// The mounted routes (including the SSE stream endpoint) add no
102    /// `Access-Control-Allow-Origin` header unless
103    /// [`HttpExtensionConfig::allowed_origins`] is set — see that field's
104    /// docs if a cross-origin consumer (e.g. `pjs-js-client`'s SSE
105    /// transport) needs to reach these routes.
106    pub fn extend_router<S>(self, router: axum::Router<S>) -> axum::Router<S>
107    where
108        S: Clone + Send + Sync + 'static,
109    {
110        let pjs_routes = self.create_pjs_routes();
111
112        router.nest(&self.config.route_prefix, pjs_routes).layer(
113            axum::middleware::from_fn_with_state(Arc::new(self), pjs_middleware::<S>),
114        )
115    }
116
117    /// Create PJS-specific routes
118    fn create_pjs_routes<S>(&self) -> axum::Router<S>
119    where
120        S: Clone + Send + Sync + 'static,
121    {
122        let router = axum::Router::new()
123            .route("/stream", axum::routing::post(handle_stream_request))
124            .route(
125                "/stream/{stream_id}/sse",
126                axum::routing::get(handle_sse_stream),
127            )
128            .route("/health", axum::routing::get(handle_pjs_health))
129            .layer(Extension(self.config.clone()))
130            .layer(Extension(self.streamer.clone()));
131
132        // `allowed_origins` defaults to empty (same-origin only, see its doc
133        // for the CWE-942 rationale), so no CORS layer is added unless the
134        // operator opts in.
135        if self.config.allowed_origins.is_empty() {
136            return router;
137        }
138
139        match super::axum_adapter::build_cors_layer_from_origins(&self.config.allowed_origins) {
140            Ok(cors) => router.layer(cors),
141            Err(err) => {
142                // Fail closed: an invalid list (e.g. mixing "*" with
143                // explicit origins) must not silently fall back to
144                // permissive behavior, so no CORS layer is added at all —
145                // same as leaving `allowed_origins` empty.
146                tracing::error!(
147                    "PjsExtension: invalid `allowed_origins` config ({err}); \
148                     no CORS header will be added to PJS routes"
149                );
150                router
151            }
152        }
153    }
154}
155
156/// Middleware that automatically detects PJS streaming requests
157#[allow(clippy::extra_unused_type_parameters)]
158async fn pjs_middleware<S>(
159    State(_state): State<Arc<PjsExtension>>,
160    headers: HeaderMap,
161    request: Request,
162    next: Next,
163) -> Result<Response, StatusCode>
164where
165    S: Clone + Send + Sync + 'static,
166{
167    // Check if client requested PJS streaming
168    let wants_pjs = headers
169        .get(header::ACCEPT)
170        .and_then(|h| h.to_str().ok())
171        .map(|accept| {
172            accept.contains("application/pjs-stream")
173                || accept.contains("text/event-stream")
174                || headers.contains_key("x-pjs-stream")
175        })
176        .unwrap_or(false);
177
178    let mut request = request;
179    if wants_pjs {
180        // Add PJS metadata to request
181        request
182            .extensions_mut()
183            .insert(PjsStreamingRequest { enabled: true });
184    }
185
186    Ok(next.run(request).await)
187}
188
189/// Marker for PJS streaming requests
190#[derive(Debug, Clone)]
191pub struct PjsStreamingRequest {
192    /// `true` when the middleware detected an opt-in to PJS streaming.
193    pub enabled: bool,
194}
195
196/// Request parameters for streaming
197#[derive(Debug, Deserialize)]
198pub struct StreamRequest {
199    /// JSON data to stream
200    pub data: JsonValue,
201    /// Priority threshold (0-255)
202    pub priority: Option<u8>,
203    /// Stream format (json, ndjson, sse)
204    pub format: Option<String>,
205    /// Maximum number of frames
206    pub max_frames: Option<usize>,
207}
208
209/// Stream response
210#[derive(Debug, Serialize)]
211pub struct StreamResponse {
212    /// Identifier assigned to the new stream.
213    pub stream_id: String,
214    /// Selected wire format (`"json"`, `"ndjson"`, or `"sse"`).
215    pub format: String,
216    /// Estimated number of frames the stream will emit.
217    pub estimated_frames: usize,
218}
219
220/// Handle stream creation request
221async fn handle_stream_request(
222    Extension(config): Extension<HttpExtensionConfig>,
223    Extension(streamer): Extension<Arc<PriorityStreamer>>,
224    headers: HeaderMap,
225    Json(request): Json<StreamRequest>,
226) -> Result<impl IntoResponse, StreamExtensionError> {
227    let stream_id = uuid::Uuid::new_v4().to_string();
228
229    // Create streaming plan
230    let plan = streamer
231        .analyze(&request.data)
232        .map_err(|e| StreamExtensionError::AnalysisError(e.to_string()))?;
233
234    let format = request.format.unwrap_or_else(|| {
235        headers
236            .get(header::ACCEPT)
237            .and_then(|h| h.to_str().ok())
238            .map(|accept| {
239                if accept.contains("text/event-stream") {
240                    "sse".to_string()
241                } else if accept.contains("application/x-ndjson") {
242                    "ndjson".to_string()
243                } else {
244                    "json".to_string()
245                }
246            })
247            .unwrap_or_else(|| "json".to_string())
248    });
249
250    let response = StreamResponse {
251        stream_id: stream_id.clone(),
252        format: format.clone(),
253        estimated_frames: plan.frames().count(),
254    };
255
256    // Store stream for later retrieval
257    // In production, this would use a proper store
258
259    Ok((
260        StatusCode::CREATED,
261        [(
262            header::LOCATION,
263            format!("{}/stream/{}", config.route_prefix, stream_id),
264        )],
265        Json(response),
266    ))
267}
268
269/// Handle Server-Sent Events streaming
270///
271/// Sets no `Access-Control-Allow-Origin` header itself. Any such header
272/// comes from the `CorsLayer` `PjsExtension::create_pjs_routes` conditionally
273/// wraps the PJS routes in, driven by [`HttpExtensionConfig::allowed_origins`]
274/// — see that field's docs for why this handler must not impose its own
275/// unconditional CORS policy (CWE-942).
276async fn handle_sse_stream(
277    Path(_stream_id): Path<String>,
278    Extension(streamer): Extension<Arc<PriorityStreamer>>,
279    Query(_params): Query<HashMap<String, String>>,
280) -> Result<impl IntoResponse, StreamExtensionError> {
281    // In production, retrieve stream data from store using stream_id
282    let sample_data = serde_json::json!({
283        "products": [
284            {"id": 1, "name": "Product A", "price": 19.99, "category": "electronics"},
285            {"id": 2, "name": "Product B", "price": 29.99, "category": "books"},
286            {"id": 3, "name": "Product C", "price": 39.99, "category": "clothing"}
287        ],
288        "metadata": {
289            "total": 3,
290            "updated_at": "2024-01-01T00:00:00Z"
291        }
292    });
293
294    let plan = streamer
295        .analyze(&sample_data)
296        .map_err(|e| StreamExtensionError::AnalysisError(e.to_string()))?;
297
298    // Collect frames to avoid lifetime issues
299    let frames: Vec<_> = plan.frames().cloned().collect();
300    let stream = futures::stream::iter(frames).map(|frame| {
301        // JsonData::float rejects NaN/Infinity at construction (RFC 8259 §6), so
302        // any Frame built through the public API cannot contain non-finite floats and
303        // serialization is therefore infallible on this path.
304        let data = serde_json::to_string(&frame).expect(
305            "Frame serialization is infallible: JsonData rejects NaN/Infinity at construction",
306        );
307        Ok::<_, StreamExtensionError>(format!("data: {data}\n\n"))
308    });
309
310    let response = axum::response::Response::builder()
311        .status(StatusCode::OK)
312        .header(header::CONTENT_TYPE, "text/event-stream")
313        .header(header::CACHE_CONTROL, "no-cache")
314        .header(header::CONNECTION, "keep-alive")
315        .body(axum::body::Body::from_stream(stream))
316        .map_err(|e| StreamExtensionError::ResponseError(e.to_string()))?;
317
318    Ok(response)
319}
320
321/// Health check for PJS extension
322async fn handle_pjs_health() -> Json<serde_json::Value> {
323    Json(serde_json::json!({
324        "status": "healthy",
325        "service": "pjs-extension",
326        "version": env!("CARGO_PKG_VERSION"),
327        "capabilities": [
328            "priority-streaming",
329            "sse-support",
330            "ndjson-support",
331            "auto-detection"
332        ]
333    }))
334}
335
336/// Extension-specific errors
337#[derive(Debug, thiserror::Error)]
338pub enum StreamExtensionError {
339    /// Failed to analyze the requested payload.
340    #[error("Analysis error: {0}")]
341    AnalysisError(String),
342
343    /// Failed to build the HTTP response object.
344    #[error("Response error: {0}")]
345    ResponseError(String),
346
347    /// Requested stream identifier is unknown to the extension.
348    #[error("Stream not found: {0}")]
349    StreamNotFound(String),
350}
351
352impl IntoResponse for StreamExtensionError {
353    fn into_response(self) -> Response {
354        let (status, message) = match &self {
355            StreamExtensionError::AnalysisError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
356            StreamExtensionError::ResponseError(_) => {
357                (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
358            }
359            StreamExtensionError::StreamNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
360        };
361
362        (status, Json(serde_json::json!({"error": message}))).into_response()
363    }
364}
365
366/// Trait to easily add PJS support to any JSON response
367pub trait PjsResponseExt {
368    /// Convert response to PJS streaming if requested
369    fn pjs_stream(self, request: &axum::extract::Request) -> impl IntoResponse;
370}
371
372impl PjsResponseExt for Json<JsonValue> {
373    fn pjs_stream(self, request: &axum::extract::Request) -> impl IntoResponse {
374        // Check if PJS streaming was requested
375        if let Some(pjs_request) = request.extensions().get::<PjsStreamingRequest>()
376            && pjs_request.enabled
377        {
378            // Convert to streaming response
379            // This is a simplified implementation
380            return (
381                StatusCode::OK,
382                [
383                    (header::CONTENT_TYPE, "application/pjs-stream"),
384                    (header::CACHE_CONTROL, "no-cache"),
385                ],
386                self.0.to_string(),
387            )
388                .into_response();
389        }
390
391        // Return regular JSON response
392        self.into_response()
393    }
394}
395
396/// Helper macro to easily add PJS to existing endpoints
397#[macro_export]
398macro_rules! pjs_endpoint {
399    ($handler:expr) => {
400        |req: axum::extract::Request| async move {
401            let response = $handler(req).await;
402            response.pjs_stream(&req)
403        }
404    };
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use axum::{Router, routing::get};
411    use tower::ServiceExt;
412
413    #[tokio::test]
414    async fn test_pjs_extension_integration() {
415        // Create a regular API route
416        async fn api_route() -> Json<JsonValue> {
417            Json(serde_json::json!({
418                "users": [
419                    {"id": 1, "name": "Alice"},
420                    {"id": 2, "name": "Bob"}
421                ]
422            }))
423        }
424
425        // Create router with PJS extension
426        let config = HttpExtensionConfig::default();
427        let pjs_extension = PjsExtension::new(config);
428
429        let app = Router::new().route("/api/users", get(api_route));
430
431        let app = pjs_extension.extend_router(app);
432
433        // Test that PJS routes are available
434        let response = app
435            .oneshot(
436                axum::http::Request::builder()
437                    .uri("/pjs/health")
438                    .body(axum::body::Body::empty())
439                    // TODO: Handle unwrap() - add proper error handling for request building in tests
440                    .unwrap(),
441            )
442            .await
443            // TODO: Handle unwrap() - add proper error handling for response in tests
444            .unwrap();
445
446        assert_eq!(response.status(), StatusCode::OK);
447    }
448
449    #[tokio::test]
450    async fn test_auto_detection_middleware() {
451        let config = HttpExtensionConfig::default();
452        let _pjs_extension = Arc::new(PjsExtension::new(config));
453
454        let _headers = HeaderMap::new();
455        let request = axum::http::Request::builder()
456            .header("Accept", "text/event-stream")
457            .body(axum::body::Body::empty())
458            // TODO: Handle unwrap() - add proper error handling for request building in tests
459            .unwrap();
460
461        // Test middleware detection logic
462        assert!(request.headers().get("Accept").is_some());
463    }
464}