use crate::domain::value_objects::JsonData;
use axum::{
Json, Router,
extract::DefaultBodyLimit,
http::{
HeaderValue, Method, StatusCode,
header::{AUTHORIZATION, CONTENT_TYPE},
},
middleware,
response::{IntoResponse, Response},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use std::{
sync::Arc,
time::{Duration, Instant},
};
use tower::limit::GlobalConcurrencyLimitLayer;
use tower_http::{
cors::{AllowOrigin, CorsLayer},
timeout::{ResponseBodyTimeoutLayer, TimeoutLayer},
trace::TraceLayer,
};
use crate::{
application::{
handlers::{
command_handlers::SessionCommandHandler,
query_handlers::{SessionQueryHandler, StreamQueryHandler, SystemQueryHandler},
},
queries::SortOrder,
},
domain::{
SessionState,
aggregates::stream_session::SessionHealth,
entities::Frame,
ports::{
DictionaryStore, EventPublisherGat, FrameStoreGat, NoopDictionaryStore,
SessionSortField, StreamRepositoryGat, StreamStoreGat,
},
value_objects::{SessionId, StreamId},
},
infrastructure::{
adapters::InMemoryFrameStore,
http::middleware::{RateLimitMiddleware, security_middleware},
},
};
#[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
use super::handlers::dictionary::get_session_dictionary;
use super::handlers::{
health::{get_system_stats, system_health},
sessions::{
create_session, get_session, get_session_stats, list_sessions, search_sessions,
session_health,
},
streams::{
create_stream, generate_frames, get_stream, get_stream_frames, start_stream,
stream_stream_frames,
},
};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct HttpServerConfig {
pub allowed_origins: Vec<String>,
}
impl HttpServerConfig {
pub fn new(allowed_origins: Vec<String>) -> Self {
Self { allowed_origins }
}
}
impl Default for HttpServerConfig {
fn default() -> Self {
Self {
allowed_origins: vec!["http://localhost:3000".to_string()],
}
}
}
fn build_cors_layer(config: &HttpServerConfig) -> Result<CorsLayer, PjsError> {
build_cors_layer_from_origins(&config.allowed_origins)
}
pub(crate) fn build_cors_layer_from_origins(
allowed_origins: &[String],
) -> Result<CorsLayer, PjsError> {
let base = CorsLayer::new()
.allow_methods([Method::GET, Method::POST])
.allow_headers([CONTENT_TYPE, AUTHORIZATION])
.max_age(std::time::Duration::from_secs(3600));
let has_wildcard = allowed_origins.iter().any(|o| o == "*");
let has_explicit = allowed_origins.iter().any(|o| o != "*");
let layer = match (allowed_origins.is_empty(), has_wildcard, has_explicit) {
(true, _, _) => base.allow_origin(AllowOrigin::list(std::iter::empty::<HeaderValue>())),
(_, true, true) => {
return Err(PjsError::HttpError(
"CORS: wildcard '*' cannot be combined with explicit origins".into(),
));
}
(_, true, false) => base.allow_origin(tower_http::cors::Any),
(_, false, _) => {
let origins: Vec<HeaderValue> = allowed_origins
.iter()
.map(|o| {
o.parse::<HeaderValue>()
.map_err(|e| PjsError::HttpError(format!("invalid CORS origin {o:?}: {e}")))
})
.collect::<Result<_, _>>()?;
base.allow_origin(AllowOrigin::list(origins))
}
};
Ok(layer)
}
pub struct PjsAppState<R, P, S, F = InMemoryFrameStore>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
F: FrameStoreGat + Send + Sync + 'static,
{
pub(crate) command_handler: Arc<SessionCommandHandler<R, P, F>>,
pub(crate) session_query_handler: Arc<SessionQueryHandler<R>>,
pub(crate) stream_query_handler: Arc<StreamQueryHandler<R, S, F>>,
pub(crate) system_handler: Arc<SystemQueryHandler<R>>,
pub(crate) dictionary_store: Arc<dyn DictionaryStore>,
}
impl<R, P, S, F> Clone for PjsAppState<R, P, S, F>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
F: FrameStoreGat + Send + Sync + 'static,
{
fn clone(&self) -> Self {
Self {
command_handler: self.command_handler.clone(),
session_query_handler: self.session_query_handler.clone(),
stream_query_handler: self.stream_query_handler.clone(),
system_handler: self.system_handler.clone(),
dictionary_store: self.dictionary_store.clone(),
}
}
}
impl<R, P, S> PjsAppState<R, P, S, InMemoryFrameStore>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
pub fn new(repository: Arc<R>, event_publisher: Arc<P>, stream_store: Arc<S>) -> Self {
Self::with_dictionary_store(
repository,
event_publisher,
stream_store,
Arc::new(NoopDictionaryStore),
)
}
pub fn with_dictionary_store(
repository: Arc<R>,
event_publisher: Arc<P>,
stream_store: Arc<S>,
dictionary_store: Arc<dyn DictionaryStore>,
) -> Self {
Self::with_stores(
repository,
event_publisher,
stream_store,
dictionary_store,
Arc::new(InMemoryFrameStore::new()),
)
}
}
impl<R, P, S, F> PjsAppState<R, P, S, F>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
F: FrameStoreGat + Send + Sync + 'static,
{
pub fn with_stores(
repository: Arc<R>,
event_publisher: Arc<P>,
stream_store: Arc<S>,
dictionary_store: Arc<dyn DictionaryStore>,
frame_store: Arc<F>,
) -> Self {
let started_at = Instant::now();
Self {
command_handler: Arc::new(SessionCommandHandler::with_stores(
repository.clone(),
event_publisher,
dictionary_store.clone(),
frame_store.clone(),
)),
session_query_handler: Arc::new(SessionQueryHandler::new(repository.clone())),
stream_query_handler: Arc::new(StreamQueryHandler::new(
repository.clone(),
stream_store,
frame_store,
)),
system_handler: Arc::new(SystemQueryHandler::with_start_time(repository, started_at)),
dictionary_store,
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateSessionRequest {
pub max_concurrent_streams: Option<usize>,
pub timeout_seconds: Option<u64>,
pub client_info: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CreateSessionResponse {
pub session_id: String,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct StartStreamRequest {
pub data: JsonData,
pub priority_threshold: Option<u8>,
pub max_frames: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct StreamParams {
pub session_id: String,
pub priority: Option<u8>,
pub format: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct GenerateFramesRequest {
pub priority_threshold: Option<u8>,
pub max_frames: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct GenerateFramesResponse {
pub frames: Vec<Frame>,
pub frame_count: usize,
}
#[derive(Debug, Serialize)]
pub struct SessionHealthResponse {
pub is_healthy: bool,
pub active_streams: usize,
pub failed_streams: usize,
pub is_expired: bool,
pub uptime_seconds: i64,
}
impl From<SessionHealth> for SessionHealthResponse {
fn from(health: SessionHealth) -> Self {
Self {
is_healthy: health.is_healthy,
active_streams: health.active_streams,
failed_streams: health.failed_streams,
is_expired: health.is_expired,
uptime_seconds: health.uptime_seconds,
}
}
}
pub fn create_pjs_router<R, P, S>() -> Router<PjsAppState<R, P, S>>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
create_pjs_router_with_config::<R, P, S>(&HttpServerConfig::default())
.expect("default HttpServerConfig must always produce a valid CORS layer")
}
pub fn create_pjs_router_with_config<R, P, S>(
config: &HttpServerConfig,
) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
let all_routes = public_routes::<R, P, S>().merge(protected_routes::<R, P, S>());
apply_common_layers(all_routes, config, None)
}
pub fn create_pjs_router_with_rate_limit<R, P, S>(
rate_limit_middleware: RateLimitMiddleware,
) -> Router<PjsAppState<R, P, S>>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
create_pjs_router_with_rate_limit_and_config::<R, P, S>(
&HttpServerConfig::default(),
rate_limit_middleware,
)
.expect("default HttpServerConfig must always produce a valid CORS layer")
}
pub fn create_pjs_router_with_rate_limit_and_config<R, P, S>(
config: &HttpServerConfig,
rate_limit_middleware: RateLimitMiddleware,
) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
let all_routes = public_routes::<R, P, S>().merge(protected_routes::<R, P, S>());
apply_common_layers(all_routes, config, Some(rate_limit_middleware))
}
#[cfg(feature = "http-server")]
pub fn create_pjs_router_with_auth<R, P, S>(
config: &HttpServerConfig,
auth: crate::infrastructure::http::auth::ApiKeyAuthLayer,
) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
let protected = protected_routes::<R, P, S>().layer(auth);
let merged = public_routes::<R, P, S>().merge(protected);
apply_common_layers(merged, config, None)
}
#[cfg(feature = "http-server")]
pub fn create_pjs_router_with_rate_limit_and_auth<R, P, S>(
config: &HttpServerConfig,
rate_limit: RateLimitMiddleware,
auth: crate::infrastructure::http::auth::ApiKeyAuthLayer,
) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
let protected = protected_routes::<R, P, S>().layer(auth);
let merged = public_routes::<R, P, S>().merge(protected);
apply_common_layers(merged, config, Some(rate_limit))
}
fn public_routes<R, P, S>() -> Router<PjsAppState<R, P, S>>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
let router = Router::new().route("/pjs/health", get(system_health));
#[cfg(feature = "metrics")]
let router = router.route(
"/metrics",
get(crate::infrastructure::http::metrics::metrics_handler),
);
router
}
fn protected_routes<R, P, S>() -> Router<PjsAppState<R, P, S>>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
let router = Router::new()
.route("/pjs/sessions", post(create_session::<R, P, S>))
.route("/pjs/sessions/{session_id}", get(get_session::<R, P, S>))
.route(
"/pjs/sessions/{session_id}/health",
get(session_health::<R, P, S>),
)
.route(
"/pjs/sessions/{session_id}/stats",
get(get_session_stats::<R, P, S>),
)
.route(
"/pjs/sessions/{session_id}/streams",
post(create_stream::<R, P, S>),
)
.route(
"/pjs/sessions/{session_id}/streams/{stream_id}/start",
post(start_stream::<R, P, S>),
)
.route(
"/pjs/sessions/{session_id}/streams/{stream_id}/generate-frames",
post(generate_frames::<R, P, S>),
)
.route(
"/pjs/sessions/{session_id}/streams/{stream_id}",
get(get_stream::<R, P, S>),
)
.route(
"/pjs/sessions/{session_id}/streams/{stream_id}/frames",
get(get_stream_frames::<R, P, S>),
)
.route(
"/pjs/sessions/{session_id}/streams/{stream_id}/frames/stream",
get(stream_stream_frames::<R, P, S>),
)
.route("/pjs/sessions/search", get(search_sessions::<R, P, S>))
.route("/pjs/sessions", get(list_sessions::<R, P, S>))
.route("/pjs/stats", get(get_system_stats::<R, P, S>));
#[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
let router = router.route(
"/pjs/sessions/{session_id}/dictionary",
get(get_session_dictionary::<R, P, S>),
);
router
}
const MAX_CONCURRENT_REQUESTS: usize = 512;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const RESPONSE_BODY_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
fn apply_common_layers<R, P, S>(
router: Router<PjsAppState<R, P, S>>,
config: &HttpServerConfig,
rate_limit: Option<RateLimitMiddleware>,
) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
where
R: StreamRepositoryGat + Send + Sync + 'static,
P: EventPublisherGat + Send + Sync + 'static,
S: StreamStoreGat + Send + Sync + 'static,
{
let cors = build_cors_layer(config)?;
let router = router.layer(GlobalConcurrencyLimitLayer::new(MAX_CONCURRENT_REQUESTS));
let router = match rate_limit {
Some(rate_limit) => router.layer(rate_limit),
None => router,
};
Ok(router
.layer(middleware::from_fn(security_middleware))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
.layer(cors)
.layer(ResponseBodyTimeoutLayer::new(RESPONSE_BODY_IDLE_TIMEOUT))
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
REQUEST_TIMEOUT,
))
.layer(TraceLayer::new_for_http()))
}
pub(crate) fn parse_session_id(raw: String) -> Result<SessionId, PjsError> {
SessionId::from_string(&raw).map_err(|_| PjsError::InvalidSessionId(raw))
}
pub(crate) fn parse_session_and_stream_id(
session_raw: String,
stream_raw: String,
) -> Result<(SessionId, StreamId), PjsError> {
let session_id = parse_session_id(session_raw)?;
let stream_id =
StreamId::from_string(&stream_raw).map_err(|_| PjsError::InvalidStreamId(stream_raw))?;
Ok((session_id, stream_id))
}
pub(crate) fn parse_session_state(raw: String) -> Result<SessionState, PjsError> {
serde_json::from_value(serde_json::Value::String(raw.clone()))
.map_err(|_| PjsError::InvalidSessionState(raw))
}
pub(crate) fn parse_sort_field(raw: String) -> Result<SessionSortField, PjsError> {
serde_json::from_value(serde_json::Value::String(raw.clone()))
.map_err(|_| PjsError::InvalidSortField(raw))
}
pub(crate) fn parse_sort_order(raw: String) -> Result<SortOrder, PjsError> {
serde_json::from_value(serde_json::Value::String(raw.clone()))
.map_err(|_| PjsError::InvalidSortOrder(raw))
}
#[derive(Debug, Deserialize)]
pub struct PaginationParams {
pub limit: Option<usize>,
pub offset: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct SearchSessionsParams {
pub state: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct FrameQueryParams {
pub since_sequence: Option<u64>,
pub priority: Option<u8>,
pub limit: Option<usize>,
}
#[derive(Debug, thiserror::Error)]
pub enum PjsError {
#[error("Application error: {0}")]
Application(#[from] crate::application::ApplicationError),
#[error("Invalid session ID: {0}")]
InvalidSessionId(String),
#[error("Invalid stream ID: {0}")]
InvalidStreamId(String),
#[error("Invalid priority: {0}")]
InvalidPriority(String),
#[error(
"Invalid session state: {0} (expected one of: Initializing, Active, Closing, Completed, Failed)"
)]
InvalidSessionState(String),
#[error(
"Invalid sort field: {0} (expected one of: created_at, updated_at, stream_count, total_bytes)"
)]
InvalidSortField(String),
#[error("Invalid sort order: {0} (expected one of: asc, ascending, desc, descending)")]
InvalidSortOrder(String),
#[error("HTTP error: {0}")]
HttpError(String),
}
impl IntoResponse for PjsError {
fn into_response(self) -> Response {
let (status, error_message) = match &self {
PjsError::Application(app_err) => {
use crate::application::ApplicationError;
let status = match app_err {
ApplicationError::NotFound(_) => StatusCode::NOT_FOUND,
ApplicationError::Validation(_) => StatusCode::BAD_REQUEST,
ApplicationError::Authorization(_) => StatusCode::UNAUTHORIZED,
ApplicationError::Concurrency(_) | ApplicationError::Conflict(_) => {
StatusCode::CONFLICT
}
ApplicationError::Domain(_) | ApplicationError::Logic(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
};
(status, self.to_string())
}
PjsError::InvalidSessionId(_) => (StatusCode::BAD_REQUEST, self.to_string()),
PjsError::InvalidStreamId(_) => (StatusCode::BAD_REQUEST, self.to_string()),
PjsError::InvalidPriority(_) => (StatusCode::BAD_REQUEST, self.to_string()),
PjsError::InvalidSessionState(_) => (StatusCode::BAD_REQUEST, self.to_string()),
PjsError::InvalidSortField(_) => (StatusCode::BAD_REQUEST, self.to_string()),
PjsError::InvalidSortOrder(_) => (StatusCode::BAD_REQUEST, self.to_string()),
PjsError::HttpError(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
};
let body = Json(serde_json::json!({
"error": error_message
}));
(status, body).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::header;
#[test]
fn cors_empty_origins_denies_all() {
let config = HttpServerConfig {
allowed_origins: vec![],
};
let result = build_cors_layer(&config);
assert!(
result.is_ok(),
"empty origins should return Ok (deny-all layer)"
);
}
#[test]
fn cors_wildcard_only_is_ok() {
let config = HttpServerConfig {
allowed_origins: vec!["*".to_string()],
};
let result = build_cors_layer(&config);
assert!(result.is_ok(), "wildcard-only should return Ok");
}
#[test]
fn cors_mixed_wildcard_and_explicit_is_err() {
let config = HttpServerConfig {
allowed_origins: vec!["*".to_string(), "http://example.com".to_string()],
};
let result = build_cors_layer(&config);
assert!(
result.is_err(),
"mixing wildcard with explicit origins must fail"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("wildcard"),
"error message should mention wildcard: {msg}"
);
}
#[test]
fn cors_valid_single_origin_is_ok() {
let config = HttpServerConfig {
allowed_origins: vec!["http://example.com".to_string()],
};
assert!(build_cors_layer(&config).is_ok());
}
#[test]
fn cors_valid_multiple_origins_is_ok() {
let config = HttpServerConfig {
allowed_origins: vec![
"https://app.example.com".to_string(),
"https://admin.example.com".to_string(),
],
};
assert!(build_cors_layer(&config).is_ok());
}
#[test]
fn cors_invalid_origin_string_is_err() {
let config = HttpServerConfig {
allowed_origins: vec!["not a\nvalid header".to_string()],
};
let result = build_cors_layer(&config);
assert!(result.is_err(), "invalid origin string must return Err");
}
#[test]
fn default_config_is_valid() {
assert!(
build_cors_layer(&HttpServerConfig::default()).is_ok(),
"default HttpServerConfig must produce a valid CORS layer"
);
}
#[test]
fn parse_session_id_valid_roundtrips() {
let id = SessionId::new();
let parsed = parse_session_id(id.to_string()).expect("valid uuid must parse");
assert_eq!(parsed, id);
}
#[test]
fn parse_session_id_invalid_returns_invalid_session_id_error() {
let raw = "not-a-valid-uuid".to_string();
let err = parse_session_id(raw.clone()).unwrap_err();
match err {
PjsError::InvalidSessionId(msg) => assert_eq!(msg, raw),
other => panic!("expected InvalidSessionId, got {other:?}"),
}
}
#[test]
fn parse_session_and_stream_id_valid_roundtrips() {
let session_id = SessionId::new();
let stream_id = StreamId::new();
let (parsed_session, parsed_stream) =
parse_session_and_stream_id(session_id.to_string(), stream_id.to_string())
.expect("valid uuids must parse");
assert_eq!(parsed_session, session_id);
assert_eq!(parsed_stream, stream_id);
}
#[test]
fn parse_session_and_stream_id_invalid_session_short_circuits() {
let raw_session = "bad-session".to_string();
let err = parse_session_and_stream_id(raw_session.clone(), StreamId::new().to_string())
.unwrap_err();
match err {
PjsError::InvalidSessionId(msg) => assert_eq!(msg, raw_session),
other => panic!("expected InvalidSessionId, got {other:?}"),
}
}
#[test]
fn parse_session_and_stream_id_invalid_stream_returns_invalid_stream_id_error() {
let raw_stream = "bad-stream".to_string();
let err = parse_session_and_stream_id(SessionId::new().to_string(), raw_stream.clone())
.unwrap_err();
match err {
PjsError::InvalidStreamId(msg) => assert_eq!(msg, raw_stream),
other => panic!("expected InvalidStreamId, got {other:?}"),
}
}
use crate::domain::{
entities::Stream,
events::DomainEvent,
ports::{
EventPublisherGat, PriorityDistribution, StreamFilter, StreamStatistics, StreamStatus,
StreamStoreGat,
},
value_objects::{SessionId, StreamId},
};
use crate::test_support::MockRepository;
use chrono::Utc;
struct MockEventPublisher;
impl EventPublisherGat for MockEventPublisher {
type PublishFuture<'a>
= impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
where
Self: 'a;
type PublishBatchFuture<'a>
= impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
where
Self: 'a;
fn publish(&self, _event: DomainEvent) -> Self::PublishFuture<'_> {
async move { Ok(()) }
}
fn publish_batch(&self, _events: Vec<DomainEvent>) -> Self::PublishBatchFuture<'_> {
async move { Ok(()) }
}
}
struct MockStreamStore;
impl StreamStoreGat for MockStreamStore {
type StoreStreamFuture<'a>
= impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
where
Self: 'a;
type GetStreamFuture<'a>
= impl std::future::Future<Output = crate::domain::DomainResult<Option<Stream>>>
+ Send
+ 'a
where
Self: 'a;
type DeleteStreamFuture<'a>
= impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
where
Self: 'a;
type ListStreamsForSessionFuture<'a>
=
impl std::future::Future<Output = crate::domain::DomainResult<Vec<Stream>>> + Send + 'a
where
Self: 'a;
type FindStreamsBySessionFuture<'a>
=
impl std::future::Future<Output = crate::domain::DomainResult<Vec<Stream>>> + Send + 'a
where
Self: 'a;
type UpdateStreamStatusFuture<'a>
= impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
where
Self: 'a;
type GetStreamStatisticsFuture<'a>
= impl std::future::Future<Output = crate::domain::DomainResult<StreamStatistics>>
+ Send
+ 'a
where
Self: 'a;
fn store_stream(&self, _stream: Stream) -> Self::StoreStreamFuture<'_> {
async move { Ok(()) }
}
fn get_stream(&self, _stream_id: StreamId) -> Self::GetStreamFuture<'_> {
async move { Ok(None) }
}
fn delete_stream(&self, _stream_id: StreamId) -> Self::DeleteStreamFuture<'_> {
async move { Ok(()) }
}
fn list_streams_for_session(
&self,
_session_id: SessionId,
) -> Self::ListStreamsForSessionFuture<'_> {
async move { Ok(vec![]) }
}
fn find_streams_by_session(
&self,
_session_id: SessionId,
_filter: StreamFilter,
) -> Self::FindStreamsBySessionFuture<'_> {
async move { Ok(vec![]) }
}
fn update_stream_status(
&self,
_stream_id: StreamId,
_status: StreamStatus,
) -> Self::UpdateStreamStatusFuture<'_> {
async move { Ok(()) }
}
fn get_stream_statistics(
&self,
_stream_id: StreamId,
) -> Self::GetStreamStatisticsFuture<'_> {
async move {
Ok(StreamStatistics {
total_frames: 0,
total_bytes: 0,
priority_distribution: PriorityDistribution::default(),
avg_frame_size: 0.0,
creation_time: Utc::now(),
completion_time: None,
processing_duration: None,
})
}
}
}
#[tokio::test]
async fn test_system_health() {
let response = system_health().await;
let health_data: serde_json::Value = response.0;
assert_eq!(health_data["status"], "healthy");
assert!(!health_data["features"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_app_state_creation() {
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let _state = PjsAppState::new(repository, event_publisher, stream_store);
}
#[tokio::test]
async fn test_get_system_stats_returns_real_uptime() {
use crate::application::handlers::QueryHandlerGat;
use crate::application::handlers::query_handlers::SystemQueryHandler;
use crate::application::queries::GetSystemStatsQuery;
use std::time::{Duration, Instant};
let repository = Arc::new(MockRepository::new());
let started_at = Instant::now() - Duration::from_secs(5);
let handler = SystemQueryHandler::with_start_time(repository, started_at);
let query = GetSystemStatsQuery {
include_historical: false,
};
let result = QueryHandlerGat::handle(&handler, query).await.unwrap();
assert!(
result.uptime_seconds >= 5,
"uptime_seconds should be at least 5, got {}",
result.uptime_seconds
);
assert_ne!(
result.uptime_seconds, 3600,
"uptime_seconds must not be the hard-coded placeholder 3600"
);
}
#[cfg(feature = "metrics")]
#[tokio::test]
async fn test_metrics_endpoint_returns_prometheus_format() {
use crate::infrastructure::http::metrics::install_global_recorder;
let handle = install_global_recorder().expect("recorder install should succeed");
let rendered = handle.render();
assert!(
!rendered.contains("{\"error\""),
"rendered metrics should not be a JSON error: {rendered}"
);
let handle2 = install_global_recorder().expect("second call must not fail");
assert_eq!(
handle.render(),
handle2.render(),
"both handles must render the same metrics"
);
}
#[cfg(feature = "metrics")]
#[test]
fn test_metrics_router_has_metrics_route() {
let _router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build successfully with metrics feature");
}
#[tokio::test]
async fn search_sessions_route_returns_ok() {
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_sessions_route_accepts_valid_state() {
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?state=Active")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_sessions_route_rejects_unknown_state() {
use axum::body::to_bytes;
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?state=active")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
content_type.starts_with("application/json"),
"rejection must use the API's JSON envelope, got content-type: {content_type}"
);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("error").is_some_and(|e| e.is_string()),
"body must match the standard {{\"error\": ...}} envelope, got: {json}"
);
}
#[tokio::test]
async fn search_sessions_route_accepts_valid_sort_by() {
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?sort_by=created_at")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_sessions_route_rejects_unknown_sort_by() {
use axum::body::to_bytes;
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?sort_by=bogus")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
content_type.starts_with("application/json"),
"rejection must use the API's JSON envelope, got content-type: {content_type}"
);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("error").is_some_and(|e| e.is_string()),
"body must match the standard {{\"error\": ...}} envelope, got: {json}"
);
}
#[tokio::test]
async fn search_sessions_route_rejects_sort_by_missing_underscore() {
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?sort_by=createdat")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn search_sessions_route_rejects_empty_sort_by() {
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?sort_by=")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn search_sessions_route_accepts_valid_sort_order() {
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
for value in ["asc", "ascending", "desc", "descending"] {
let req = Request::builder()
.uri(format!("/pjs/sessions/search?sort_order={value}"))
.body(axum::body::Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"value {value} should be accepted"
);
}
}
#[tokio::test]
async fn search_sessions_route_rejects_unknown_sort_order() {
use axum::body::to_bytes;
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?sort_order=bogus")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
content_type.starts_with("application/json"),
"rejection must use the API's JSON envelope, got content-type: {content_type}"
);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("error").is_some_and(|e| e.is_string()),
"body must match the standard {{\"error\": ...}} envelope, got: {json}"
);
}
#[tokio::test]
async fn search_sessions_route_rejects_empty_sort_order() {
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?sort_order=")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn search_sessions_route_rejects_sort_order_typo() {
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?sort_order=decs")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn search_sessions_route_rejects_uppercase_sort_order() {
use axum::http::Request;
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let req = Request::builder()
.uri("/pjs/sessions/search?sort_order=ASC")
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn generate_frames_route_dispatches_command_end_to_end() {
use axum::body::to_bytes;
use axum::http::{Method, Request};
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let create_session = Request::builder()
.method(Method::POST)
.uri("/pjs/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from("{}"))
.unwrap();
let resp = router.clone().oneshot(create_session).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let session: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = session["session_id"].as_str().unwrap().to_string();
let create_stream = Request::builder()
.method(Method::POST)
.uri(format!("/pjs/sessions/{session_id}/streams"))
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::json!({ "data": { "items": [1, 2, 3] } }).to_string(),
))
.unwrap();
let resp = router.clone().oneshot(create_stream).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let stream: serde_json::Value = serde_json::from_slice(&body).unwrap();
let stream_id = stream["stream_id"].as_str().unwrap().to_string();
let start = Request::builder()
.method(Method::POST)
.uri(format!(
"/pjs/sessions/{session_id}/streams/{stream_id}/start"
))
.body(axum::body::Body::empty())
.unwrap();
let resp = router.clone().oneshot(start).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let generate = Request::builder()
.method(Method::POST)
.uri(format!(
"/pjs/sessions/{session_id}/streams/{stream_id}/generate-frames"
))
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::json!({ "max_frames": 4 }).to_string(),
))
.unwrap();
let resp = router.oneshot(generate).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"POST .../generate-frames must be reachable end-to-end"
);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(payload["frames"].is_array(), "response must carry frames[]");
let frame_count = payload["frame_count"]
.as_u64()
.expect("response must carry numeric frame_count");
assert!(
frame_count > 0,
"extract_patches must yield at least one patch frame for `{{\"items\": [1,2,3]}}` \
— frame_count was {frame_count}"
);
}
#[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
#[tokio::test]
async fn dictionary_endpoint_becomes_reachable_after_training() {
use crate::compression::zstd::N_TRAIN;
use crate::infrastructure::repositories::InMemoryDictionaryStore;
use crate::security::CompressionBombDetector;
use axum::body::to_bytes;
use axum::http::{Method, Request};
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let dictionary_store = Arc::new(InMemoryDictionaryStore::new(
Arc::new(CompressionBombDetector::default()),
64 * 1024,
));
let state = PjsAppState::with_dictionary_store(
repository,
event_publisher,
stream_store,
dictionary_store,
);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let create_session = Request::builder()
.method(Method::POST)
.uri("/pjs/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from("{}"))
.unwrap();
let resp = router.clone().oneshot(create_session).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let session: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = session["session_id"].as_str().unwrap().to_string();
let mut payload = serde_json::Map::new();
for i in 0..(N_TRAIN + 4) {
payload.insert(
format!("field_{i}"),
serde_json::Value::String(format!("value_{i}")),
);
}
let create_stream = Request::builder()
.method(Method::POST)
.uri(format!("/pjs/sessions/{session_id}/streams"))
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::json!({ "data": serde_json::Value::Object(payload) }).to_string(),
))
.unwrap();
let resp = router.clone().oneshot(create_stream).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let stream: serde_json::Value = serde_json::from_slice(&body).unwrap();
let stream_id = stream["stream_id"].as_str().unwrap().to_string();
let start = Request::builder()
.method(Method::POST)
.uri(format!(
"/pjs/sessions/{session_id}/streams/{stream_id}/start"
))
.body(axum::body::Body::empty())
.unwrap();
let resp = router.clone().oneshot(start).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let dict_before = Request::builder()
.method(Method::GET)
.uri(format!("/pjs/sessions/{session_id}/dictionary"))
.body(axum::body::Body::empty())
.unwrap();
let resp = router.clone().oneshot(dict_before).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"dictionary endpoint must be 404 before N_TRAIN samples accumulate"
);
let max_frames = N_TRAIN + 4;
let generate = Request::builder()
.method(Method::POST)
.uri(format!(
"/pjs/sessions/{session_id}/streams/{stream_id}/generate-frames"
))
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::json!({ "max_frames": max_frames }).to_string(),
))
.unwrap();
let resp = router.clone().oneshot(generate).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let frame_count = payload["frame_count"].as_u64().unwrap();
assert!(
frame_count >= N_TRAIN as u64,
"single generate-frames call must yield at least N_TRAIN ({}) frames \
so train_if_ready triggers training; got {frame_count}",
N_TRAIN
);
let dict_after = Request::builder()
.method(Method::GET)
.uri(format!("/pjs/sessions/{session_id}/dictionary"))
.body(axum::body::Body::empty())
.unwrap();
let resp = router.oneshot(dict_after).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"dictionary endpoint must transition to 200 OK once N_TRAIN samples have been fed"
);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
assert!(
!body.is_empty(),
"trained dictionary body must be non-empty"
);
}
#[tokio::test]
async fn generate_frames_route_rejects_invalid_priority() {
use axum::http::{Method, Request};
use tower::ServiceExt;
let repository = Arc::new(MockRepository::new());
let event_publisher = Arc::new(MockEventPublisher);
let stream_store = Arc::new(MockStreamStore);
let state = PjsAppState::new(repository, event_publisher, stream_store);
let router =
create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
&HttpServerConfig::default(),
)
.expect("router should build")
.with_state(state);
let sid = SessionId::new();
let stream_id = StreamId::new();
let req = Request::builder()
.method(Method::POST)
.uri(format!(
"/pjs/sessions/{sid}/streams/{stream_id}/generate-frames"
))
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::json!({ "priority_threshold": 0 }).to_string(),
))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}