use std::sync::Arc;
use std::time::Duration;
use axum::{
Router,
body::Body,
extract::{Request, State},
http::{StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
routing::post,
};
use crate::core::DEFAULT_MAX_BODY_BYTES;
use crate::http::{HttpHeaders, HttpRequest};
#[cfg(feature = "as2")]
use crate::transport::ingress::as2_ingress_from_http;
#[cfg(feature = "as4")]
use crate::transport::ingress::as4_ingress_from_http;
#[derive(Debug, Clone)]
pub struct RouterConfig {
pub body_read_timeout: Duration,
pub max_body_bytes: usize,
}
impl Default for RouterConfig {
fn default() -> Self {
Self {
body_read_timeout: Duration::from_secs(30),
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
}
}
}
struct RouterState<H> {
handler: Arc<H>,
config: Arc<RouterConfig>,
}
impl<H> Clone for RouterState<H> {
fn clone(&self) -> Self {
Self {
handler: Arc::clone(&self.handler),
config: Arc::clone(&self.config),
}
}
}
pub use crate::transport::ingress::{As2HttpIngress, As4HttpIngress};
#[derive(Debug)]
#[non_exhaustive]
pub enum HandlerOutcome {
Accepted {
body: Option<Vec<u8>>,
content_type: Option<String>,
},
Rejected {
status: u16,
message: String,
},
}
impl HandlerOutcome {
#[inline]
pub fn ok() -> Self {
Self::Accepted {
body: None,
content_type: None,
}
}
#[inline]
pub fn ok_with_body(body: Vec<u8>, content_type: impl Into<String>) -> Self {
Self::Accepted {
body: Some(body),
content_type: Some(content_type.into()),
}
}
#[inline]
pub fn bad_request(message: impl Into<String>) -> Self {
Self::Rejected {
status: 400,
message: message.into(),
}
}
#[inline]
pub fn server_error(message: impl Into<String>) -> Self {
Self::Rejected {
status: 500,
message: message.into(),
}
}
}
pub trait As2AxumHandler: Send + Sync + 'static {
fn handle(
&self,
ingress: As2HttpIngress,
) -> impl std::future::Future<Output = HandlerOutcome> + Send;
}
pub trait As4AxumHandler: Send + Sync + 'static {
fn handle(
&self,
ingress: As4HttpIngress,
) -> impl std::future::Future<Output = HandlerOutcome> + Send;
}
#[cfg(feature = "as2")]
pub fn as2_router<H: As2AxumHandler>(handler: Arc<H>, path: &str, config: RouterConfig) -> Router {
let state = RouterState {
handler,
config: Arc::new(config),
};
Router::new()
.route(path, post(as2_axum_handler::<H>))
.with_state(state)
}
#[cfg(feature = "as4")]
pub fn as4_router<H: As4AxumHandler>(handler: Arc<H>, path: &str, config: RouterConfig) -> Router {
let state = RouterState {
handler,
config: Arc::new(config),
};
Router::new()
.route(path, post(as4_axum_handler::<H>))
.with_state(state)
}
#[allow(clippy::result_large_err)]
async fn read_http_request(req: Request, config: &RouterConfig) -> Result<HttpRequest, Response> {
let (parts, body) = req.into_parts();
let headers: HttpHeaders = parts
.headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let bytes = tokio::time::timeout(
config.body_read_timeout,
axum::body::to_bytes(body, config.max_body_bytes),
)
.await
.map_err(|_| (StatusCode::REQUEST_TIMEOUT, "request body read timed out").into_response())?
.map_err(|e| (StatusCode::PAYLOAD_TOO_LARGE, e.to_string()).into_response())?;
Ok(HttpRequest {
method: parts.method.as_str().to_string(),
uri: parts.uri.to_string(),
headers,
body: bytes.to_vec().into(),
})
}
fn outcome_to_response(outcome: HandlerOutcome) -> Response {
match outcome {
HandlerOutcome::Accepted {
body: None,
content_type: _,
} => StatusCode::OK.into_response(),
HandlerOutcome::Accepted {
body: Some(bytes),
content_type,
} => {
let ct = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
axum::response::Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, ct)
.body(Body::from(bytes))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
HandlerOutcome::Rejected { status, message } => {
let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
(code, message).into_response()
}
}
}
#[cfg(feature = "as2")]
async fn as2_axum_handler<H: As2AxumHandler>(
State(state): State<RouterState<H>>,
req: Request,
) -> Response {
let http_req = match read_http_request(req, &state.config).await {
Ok(r) => r,
Err(resp) => return resp,
};
match as2_ingress_from_http(http_req) {
Ok(ingress) => outcome_to_response(state.handler.handle(ingress).await),
Err(e) => (StatusCode::BAD_REQUEST, e.message).into_response(),
}
}
#[cfg(feature = "as4")]
async fn as4_axum_handler<H: As4AxumHandler>(
State(state): State<RouterState<H>>,
req: Request,
) -> Response {
let http_req = match read_http_request(req, &state.config).await {
Ok(r) => r,
Err(resp) => return resp,
};
match as4_ingress_from_http(http_req) {
Ok(ingress) => outcome_to_response(state.handler.handle(ingress).await),
Err(e) => (StatusCode::BAD_REQUEST, e.message).into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use tower::ServiceExt;
#[cfg(feature = "as2")]
fn minimal_as2_post(body: &'static [u8]) -> axum::http::Request<Body> {
axum::http::Request::builder()
.method("POST")
.uri("/as2/inbox")
.header("AS2-From", "sender-id")
.header("AS2-To", "receiver-id")
.header(
"Content-Type",
"application/pkcs7-mime; smime-type=enveloped-data",
)
.body(Body::from(body))
.unwrap()
}
#[cfg(feature = "as2")]
struct EchoAcceptAs2;
#[cfg(feature = "as2")]
impl As2AxumHandler for EchoAcceptAs2 {
async fn handle(&self, _: As2HttpIngress) -> HandlerOutcome {
HandlerOutcome::ok()
}
}
#[cfg(feature = "as4")]
fn minimal_as4_post(body: &'static [u8]) -> axum::http::Request<Body> {
axum::http::Request::builder()
.method("POST")
.uri("/as4/inbox")
.header("Content-Type", "application/soap+xml; charset=UTF-8")
.body(Body::from(body))
.unwrap()
}
#[cfg(feature = "as4")]
struct EchoAcceptAs4;
#[cfg(feature = "as4")]
impl As4AxumHandler for EchoAcceptAs4 {
async fn handle(&self, _: As4HttpIngress) -> HandlerOutcome {
HandlerOutcome::ok()
}
}
#[cfg(feature = "as2")]
#[tokio::test]
async fn as2_valid_post_returns_200() {
let app = as2_router(
Arc::new(EchoAcceptAs2),
"/as2/inbox",
RouterConfig::default(),
);
let resp = app.oneshot(minimal_as2_post(b"hello")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[cfg(feature = "as2")]
#[tokio::test]
async fn as2_missing_as2_from_returns_400() {
let app = as2_router(
Arc::new(EchoAcceptAs2),
"/as2/inbox",
RouterConfig::default(),
);
let req = axum::http::Request::builder()
.method("POST")
.uri("/as2/inbox")
.header("AS2-To", "receiver-id")
.header("Content-Type", "application/pkcs7-mime")
.body(Body::from("payload"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[cfg(feature = "as2")]
#[tokio::test]
async fn as2_get_method_returns_405() {
let app = as2_router(
Arc::new(EchoAcceptAs2),
"/as2/inbox",
RouterConfig::default(),
);
let req = axum::http::Request::builder()
.method("GET")
.uri("/as2/inbox")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[cfg(feature = "as2")]
#[tokio::test]
async fn as2_handler_sync_mdn_response_carries_content_type() {
struct MdnAs2Handler;
impl As2AxumHandler for MdnAs2Handler {
async fn handle(&self, _: As2HttpIngress) -> HandlerOutcome {
HandlerOutcome::ok_with_body(
b"--boundary\r\nContent-Type: message/disposition-notification\r\n\r\n"
.to_vec(),
"multipart/report; report-type=disposition-notification; boundary=boundary",
)
}
}
let app = as2_router(
Arc::new(MdnAs2Handler),
"/as2/inbox",
RouterConfig::default(),
);
let resp = app.oneshot(minimal_as2_post(b"payload")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("Content-Type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.starts_with("multipart/report"));
}
#[cfg(feature = "as2")]
#[tokio::test]
async fn as2_handler_rejected_propagates_status_code() {
struct RejectAs2;
impl As2AxumHandler for RejectAs2 {
async fn handle(&self, _: As2HttpIngress) -> HandlerOutcome {
HandlerOutcome::server_error("crypto verification failed")
}
}
let app = as2_router(Arc::new(RejectAs2), "/as2/inbox", RouterConfig::default());
let resp = app.oneshot(minimal_as2_post(b"bad")).await.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[cfg(feature = "as2")]
#[tokio::test]
async fn as2_handler_receives_correct_ingress_fields() {
use std::sync::{Arc as StdArc, Mutex};
struct CaptureAs2Handler {
captured: Mutex<Option<As2HttpIngress>>,
}
impl As2AxumHandler for CaptureAs2Handler {
async fn handle(&self, ingress: As2HttpIngress) -> HandlerOutcome {
*self.captured.lock().unwrap() = Some(ingress);
HandlerOutcome::ok()
}
}
let cap = StdArc::new(CaptureAs2Handler {
captured: Mutex::new(None),
});
let app = as2_router(cap.clone(), "/as2/inbox", RouterConfig::default());
app.oneshot(minimal_as2_post(b"body-content"))
.await
.unwrap();
let ingress = cap.captured.lock().unwrap().take().unwrap();
assert_eq!(ingress.as2_from, "sender-id");
assert_eq!(ingress.as2_to, "receiver-id");
assert_eq!(ingress.body.as_ref(), b"body-content");
}
#[cfg(feature = "as4")]
#[tokio::test]
async fn as4_valid_post_returns_200() {
let app = as4_router(
Arc::new(EchoAcceptAs4),
"/as4/inbox",
RouterConfig::default(),
);
let resp = app.oneshot(minimal_as4_post(b"<soap/>")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[cfg(feature = "as4")]
#[tokio::test]
async fn as4_wrong_content_type_returns_400() {
let app = as4_router(
Arc::new(EchoAcceptAs4),
"/as4/inbox",
RouterConfig::default(),
);
let req = axum::http::Request::builder()
.method("POST")
.uri("/as4/inbox")
.header("Content-Type", "application/json")
.body(Body::from("{}"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[cfg(feature = "as4")]
#[tokio::test]
async fn as4_handler_with_receipt_body_carries_soap_content_type() {
struct ReceiptAs4;
impl As4AxumHandler for ReceiptAs4 {
async fn handle(&self, _: As4HttpIngress) -> HandlerOutcome {
HandlerOutcome::ok_with_body(
b"<soap:Envelope/>".to_vec(),
"application/soap+xml; charset=UTF-8",
)
}
}
let app = as4_router(Arc::new(ReceiptAs4), "/as4/inbox", RouterConfig::default());
let resp = app.oneshot(minimal_as4_post(b"<soap/>")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("Content-Type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.starts_with("application/soap+xml"));
}
#[cfg(feature = "as4")]
#[tokio::test]
async fn as4_handler_extracts_action_from_content_type() {
use parking_lot::Mutex;
struct CaptureAs4Handler {
action: Mutex<Option<String>>,
}
impl As4AxumHandler for CaptureAs4Handler {
async fn handle(&self, ingress: As4HttpIngress) -> HandlerOutcome {
*self.action.lock() = ingress.action;
HandlerOutcome::ok()
}
}
let cap = Arc::new(CaptureAs4Handler {
action: Mutex::new(None),
});
let app = as4_router(cap.clone(), "/as4/inbox", RouterConfig::default());
let req = axum::http::Request::builder()
.method("POST")
.uri("/as4/inbox")
.header(
"Content-Type",
"application/soap+xml; charset=UTF-8; action=\"urn:acme:SubmitOrder\"",
)
.body(Body::from("<soap/>"))
.unwrap();
app.oneshot(req).await.unwrap();
assert_eq!(cap.action.lock().as_deref(), Some("urn:acme:SubmitOrder"),);
}
#[cfg(feature = "as4")]
#[tokio::test]
async fn as4_text_xml_content_type_rejected() {
let app = as4_router(
Arc::new(EchoAcceptAs4),
"/as4/inbox",
RouterConfig::default(),
);
let req = axum::http::Request::builder()
.method("POST")
.uri("/as4/inbox")
.header("Content-Type", "text/xml; charset=UTF-8")
.body(Body::from("<soap:Envelope/>"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn router_config_default_has_30s_timeout_and_256mib_limit() {
let cfg = RouterConfig::default();
assert_eq!(cfg.body_read_timeout, Duration::from_secs(30));
assert_eq!(cfg.max_body_bytes, DEFAULT_MAX_BODY_BYTES);
}
#[cfg(feature = "as2")]
#[tokio::test]
async fn as2_router_with_custom_config_applies_settings() {
let config = RouterConfig {
body_read_timeout: Duration::from_secs(120),
max_body_bytes: 1024,
};
let app = as2_router(Arc::new(EchoAcceptAs2), "/as2/inbox", config.clone());
let resp = app.oneshot(minimal_as2_post(b"small")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let oversized = vec![b'x'; 2048];
let app2 = as2_router(Arc::new(EchoAcceptAs2), "/as2/inbox", config);
let req = axum::http::Request::builder()
.method("POST")
.uri("/as2/inbox")
.header("AS2-From", "sender-id")
.header("AS2-To", "receiver-id")
.header(
"Content-Type",
"application/pkcs7-mime; smime-type=enveloped-data",
)
.body(Body::from(oversized))
.unwrap();
let resp2 = app2.oneshot(req).await.unwrap();
assert_eq!(resp2.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[test]
fn handler_outcome_constructors_build_expected_variants() {
assert!(matches!(
HandlerOutcome::ok(),
HandlerOutcome::Accepted { body: None, .. }
));
assert!(matches!(
HandlerOutcome::ok_with_body(b"x".to_vec(), "text/plain"),
HandlerOutcome::Accepted { body: Some(_), .. }
));
assert!(matches!(
HandlerOutcome::bad_request("msg"),
HandlerOutcome::Rejected { status: 400, .. }
));
assert!(matches!(
HandlerOutcome::server_error("err"),
HandlerOutcome::Rejected { status: 500, .. }
));
}
}