use actus::prelude::*;
use serde_json::{Value as JsonValue, json};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
struct Store {
hits: Arc<AtomicUsize>,
}
#[controller(max_body_bytes = 64)]
impl Store {
routes! {
PUT "" => put(params: &Params),
GET "" => get(),
POST "echo" => echo(params: &Params),
POST "json" => json_echo(data: JsonValue),
}
pub async fn put(&self, params: &Params) -> Reply {
self.hits.fetch_add(1, Ordering::Relaxed);
reply!(json!({ "stored": params.body_bytes().len() }))
}
pub async fn get(&self) -> Reply {
self.hits.fetch_add(1, Ordering::Relaxed);
reply!(json!({ "ok": true }))
}
pub async fn echo(&self, params: &Params) -> Reply {
reply!(json!({
"body": String::from_utf8_lossy(params.body_bytes()),
"content_encoding": params.header("content-encoding"),
"content_length": params.header("content-length"),
}))
}
pub async fn json_echo(&self, data: JsonValue) -> Reply {
reply!(data)
}
}
app_routes! {
deps(hits: Arc<AtomicUsize>) {}
routes {
"store" => Store { hits },
}
}
struct StampAfter;
#[async_trait]
impl Middleware for StampAfter {
async fn after(&self, _request: &Request, response: &mut ReplyData) -> Result<(), WebError> {
response.add_header("X-After", "ran");
Ok(())
}
}
async fn spawn() -> (SocketAddr, Arc<AtomicUsize>, oneshot::Sender<()>) {
spawn_with(|server| server).await
}
async fn spawn_with(
configure: impl FnOnce(Server) -> Server,
) -> (SocketAddr, Arc<AtomicUsize>, oneshot::Sender<()>) {
let hits = Arc::new(AtomicUsize::new(0));
let server =
configure(Server::new(init(hits.clone()).await.unwrap()).with_middleware(StampAfter));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
server
.run_with_shutdown_listener(listener, async move {
let _ = rx.await;
})
.await
.unwrap();
});
(addr, hits, tx)
}
fn request(head: &str, body: &[u8]) -> Vec<u8> {
let mut raw = format!("{head}Host: x\r\nConnection: close\r\n").into_bytes();
if !body.is_empty() {
raw.extend(format!("Content-Length: {}\r\n", body.len()).into_bytes());
}
raw.extend(b"\r\n");
raw.extend(body);
raw
}
async fn send(addr: SocketAddr, raw: &[u8]) -> (u16, String) {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
stream.write_all(raw).await.unwrap();
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf).to_ascii_lowercase();
let status = response
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.expect("a status code");
(status, response)
}
const GZIP_BYTES: &[u8] = b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03";
const PUT_BYTES: &str = "PUT /store HTTP/1.1\r\nContent-Type: application/octet-stream\r\n";
#[tokio::test]
async fn a_gzip_body_is_refused_before_the_handler_runs() {
let (addr, hits, _stop) = spawn().await;
let head = format!("{PUT_BYTES}Content-Encoding: gzip\r\n");
let (status, response) = send(addr, &request(&head, GZIP_BYTES)).await;
assert_eq!(status, 415, "{response}");
assert!(response.contains("accept-encoding: identity"), "{response}");
assert!(response.contains("application/problem+json"), "{response}");
assert!(
response.contains("x-after: ran"),
"the 415 flows through the after-chain:\n{response}"
);
assert_eq!(hits.load(Ordering::Relaxed), 0, "the handler never ran");
}
#[tokio::test]
async fn every_content_coding_but_identity_is_refused() {
let (addr, hits, _stop) = spawn().await;
for coding in ["br", "deflate", "zstd", "x-gzip", "GZIP", "identity, gzip"] {
let head = format!("{PUT_BYTES}Content-Encoding: {coding}\r\n");
let (status, _) = send(addr, &request(&head, b"abc")).await;
assert_eq!(status, 415, "Content-Encoding: {coding}");
}
let head = format!("{PUT_BYTES}Content-Encoding: identity\r\nContent-Encoding: br\r\n");
let (status, _) = send(addr, &request(&head, b"abc")).await;
assert_eq!(status, 415);
assert_eq!(hits.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn an_unencoded_or_identity_body_reaches_the_handler() {
let (addr, hits, _stop) = spawn().await;
for coding in [
"",
"Content-Encoding: identity\r\n",
"Content-Encoding: Identity\r\n",
] {
let head = format!("{PUT_BYTES}{coding}");
let (status, response) = send(addr, &request(&head, b"abc")).await;
assert_eq!(status, 200, "{response}");
assert!(response.contains(r#""stored":3"#), "{response}");
}
assert_eq!(hits.load(Ordering::Relaxed), 3);
}
#[tokio::test]
async fn a_request_without_a_body_is_not_refused_over_the_header() {
let (addr, hits, _stop) = spawn().await;
let head = "GET /store HTTP/1.1\r\nContent-Encoding: gzip\r\n";
let (status, _) = send(addr, &request(head, b"")).await;
assert_eq!(status, 200);
let head = "PUT /store HTTP/1.1\r\nContent-Encoding: gzip\r\nContent-Length: 0\r\n";
let (status, _) = send(addr, &request(head, b"")).await;
assert_eq!(status, 200);
assert_eq!(hits.load(Ordering::Relaxed), 2);
}
#[tokio::test]
async fn routing_comes_first_and_an_encoded_body_is_never_read() {
let (addr, _hits, _stop) = spawn().await;
let head = "PUT /elsewhere HTTP/1.1\r\nContent-Type: application/octet-stream\r\n\
Content-Encoding: gzip\r\n";
let (status, _) = send(addr, &request(head, GZIP_BYTES)).await;
assert_eq!(status, 404);
let big = vec![0u8; 1024];
let (status, _) = send(addr, &request(PUT_BYTES, &big)).await;
assert_eq!(status, 413);
let head = format!("{PUT_BYTES}Content-Encoding: gzip\r\n");
let (status, _) = send(addr, &request(&head, &big)).await;
assert_eq!(status, 415);
}
#[tokio::test]
async fn a_transfer_coding_besides_chunked_is_not_implemented() {
let (addr, hits, _stop) = spawn().await;
let raw = format!(
"{PUT_BYTES}Transfer-Encoding: gzip, chunked\r\nHost: x\r\nConnection: close\r\n\r\n\
3\r\nabc\r\n0\r\n\r\n"
);
let (status, response) = send(addr, raw.as_bytes()).await;
assert_eq!(status, 501, "{response}");
assert!(
!response.contains("accept-encoding"),
"Accept-Encoding belongs only on a content-coding 415:\n{response}"
);
let raw = format!(
"{PUT_BYTES}Transfer-Encoding: chunked\r\nHost: x\r\nConnection: close\r\n\r\n\
3\r\nabc\r\n0\r\n\r\n"
);
let (status, response) = send(addr, raw.as_bytes()).await;
assert_eq!(status, 200, "{response}");
assert!(response.contains(r#""stored":3"#), "{response}");
assert_eq!(hits.load(Ordering::Relaxed), 1);
}
#[cfg(feature = "compression")]
mod gzip_bodies {
use super::*;
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
fn gzip(data: &[u8]) -> Vec<u8> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).unwrap();
encoder.finish().unwrap()
}
async fn spawn() -> (SocketAddr, Arc<AtomicUsize>, oneshot::Sender<()>) {
spawn_with(Server::with_gzip_request_bodies).await
}
#[tokio::test]
async fn a_gzip_body_reaches_the_handler_decoded() {
let (addr, _hits, _stop) = spawn().await;
let head = "POST /store/json HTTP/1.1\r\nContent-Type: application/json\r\n\
Content-Encoding: gzip\r\n";
let (status, response) = send(addr, &request(head, &gzip(br#"{"name":"site"}"#))).await;
assert_eq!(status, 200, "{response}");
assert!(response.contains(r#"{"name":"site"}"#), "{response}");
for coding in ["gzip", "x-gzip"] {
let head = format!(
"POST /store/echo HTTP/1.1\r\nContent-Type: application/octet-stream\r\n\
Content-Encoding: {coding}\r\n"
);
let (status, response) = send(addr, &request(&head, &gzip(b"hello, world"))).await;
assert_eq!(status, 200, "{coding}: {response}");
assert!(response.contains(r#""body":"hello, world""#), "{response}");
assert!(
response.contains(r#""content_encoding":null"#),
"{response}"
);
assert!(response.contains(r#""content_length":null"#), "{response}");
}
}
#[tokio::test]
async fn the_body_cap_holds_the_decoded_size() {
let (addr, hits, _stop) = spawn().await;
let coded = gzip(&[0u8; 1024]);
assert!(coded.len() < 64, "coded to {} bytes", coded.len());
let head = format!("{PUT_BYTES}Content-Encoding: gzip\r\n");
let (status, response) = send(addr, &request(&head, &coded)).await;
assert_eq!(status, 413, "{response}");
assert_eq!(hits.load(Ordering::Relaxed), 0, "the handler never ran");
}
#[tokio::test]
async fn a_body_that_is_not_valid_gzip_is_400() {
let (addr, hits, _stop) = spawn().await;
let head = format!("{PUT_BYTES}Content-Encoding: gzip\r\n");
let (status, response) = send(addr, &request(&head, b"plainly not gzip")).await;
assert_eq!(status, 400, "{response}");
assert!(response.contains("not valid gzip"), "{response}");
assert_eq!(hits.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn other_codings_are_still_refused_now_naming_gzip() {
let (addr, hits, _stop) = spawn().await;
for coding in ["br", "gzip, gzip"] {
let head = format!("{PUT_BYTES}Content-Encoding: {coding}\r\n");
let (status, response) = send(addr, &request(&head, b"abc")).await;
assert_eq!(status, 415, "Content-Encoding: {coding}");
assert!(
response.contains("accept-encoding: gzip, identity"),
"{response}"
);
}
assert_eq!(hits.load(Ordering::Relaxed), 0);
}
}