pjson_rs/infrastructure/http/
axum_extension.rs1use 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#[derive(Debug, Clone)]
22pub struct HttpExtensionConfig {
23 pub route_prefix: String,
25 pub auto_detect: bool,
27 pub default_priority: Priority,
29 pub max_streams_per_client: usize,
31 pub session_timeout: Duration,
33 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
84pub struct PjsExtension {
86 config: HttpExtensionConfig,
87 streamer: Arc<PriorityStreamer>,
88}
89
90impl PjsExtension {
91 pub fn new(config: HttpExtensionConfig) -> Self {
93 Self {
94 config,
95 streamer: Arc::new(PriorityStreamer::new()),
96 }
97 }
98
99 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 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 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 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#[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 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 request
182 .extensions_mut()
183 .insert(PjsStreamingRequest { enabled: true });
184 }
185
186 Ok(next.run(request).await)
187}
188
189#[derive(Debug, Clone)]
191pub struct PjsStreamingRequest {
192 pub enabled: bool,
194}
195
196#[derive(Debug, Deserialize)]
198pub struct StreamRequest {
199 pub data: JsonValue,
201 pub priority: Option<u8>,
203 pub format: Option<String>,
205 pub max_frames: Option<usize>,
207}
208
209#[derive(Debug, Serialize)]
211pub struct StreamResponse {
212 pub stream_id: String,
214 pub format: String,
216 pub estimated_frames: usize,
218}
219
220async 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 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 Ok((
260 StatusCode::CREATED,
261 [(
262 header::LOCATION,
263 format!("{}/stream/{}", config.route_prefix, stream_id),
264 )],
265 Json(response),
266 ))
267}
268
269async 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 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 let frames: Vec<_> = plan.frames().cloned().collect();
300 let stream = futures::stream::iter(frames).map(|frame| {
301 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
321async 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#[derive(Debug, thiserror::Error)]
338pub enum StreamExtensionError {
339 #[error("Analysis error: {0}")]
341 AnalysisError(String),
342
343 #[error("Response error: {0}")]
345 ResponseError(String),
346
347 #[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
366pub trait PjsResponseExt {
368 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 if let Some(pjs_request) = request.extensions().get::<PjsStreamingRequest>()
376 && pjs_request.enabled
377 {
378 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 self.into_response()
393 }
394}
395
396#[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 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 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 let response = app
435 .oneshot(
436 axum::http::Request::builder()
437 .uri("/pjs/health")
438 .body(axum::body::Body::empty())
439 .unwrap(),
441 )
442 .await
443 .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 .unwrap();
460
461 assert!(request.headers().get("Accept").is_some());
463 }
464}