Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
target
|
||||||
|
Cargo.lock
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "urus"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.95"
|
||||||
|
description = "Cowboy/bandit-style HTTP library for the smarm actor runtime"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
smarm = { path = "../smarm" }
|
||||||
|
httparse = "1.9"
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
smarm-trace = ["smarm/smarm-trace"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
panic = "unwind"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
panic = "unwind"
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "crud"
|
||||||
|
path = "examples/crud.rs"
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
//! CRUD example: a tiny user database with JSON persistence.
|
||||||
|
//!
|
||||||
|
//! Demonstrates urus and the actor model together:
|
||||||
|
//! - The pipeline is shared (Arc) across all connection actors.
|
||||||
|
//! - Handlers do NOT take a lock or share mutable state directly.
|
||||||
|
//! - A single "store" actor owns the data; handlers send it a request
|
||||||
|
//! via a channel and block on the reply. Serialization is structural —
|
||||||
|
//! the store processes one request at a time, no Mutex needed.
|
||||||
|
//! - On every mutating request the store writes the JSON file. Read
|
||||||
|
//! requests don't touch disk.
|
||||||
|
//!
|
||||||
|
//! Endpoints:
|
||||||
|
//! GET /users — list
|
||||||
|
//! POST /users — create
|
||||||
|
//! GET /users/:id — fetch
|
||||||
|
//! PUT /users/:id — replace
|
||||||
|
//! DELETE /users/:id — delete
|
||||||
|
//!
|
||||||
|
//! Try it:
|
||||||
|
//! cargo run --example crud
|
||||||
|
//! curl -s http://localhost:8080/users
|
||||||
|
//! curl -s -X POST -d '{"name":"alice","email":"a@x"}' http://localhost:8080/users
|
||||||
|
//! curl -s http://localhost:8080/users/1
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use smarm::{channel, Sender};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use urus::{serve_with, Config, Conn, Next, Pipeline, Router};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Domain
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
struct User {
|
||||||
|
id: u64,
|
||||||
|
name: String,
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NewUser {
|
||||||
|
name: String,
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Store actor protocol
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// One enum per request kind. Each carries a `reply` Sender for the response.
|
||||||
|
// Reply types are kept simple: most are JSON byte vectors + HTTP status. The
|
||||||
|
// store does the serialisation; the handler just writes the bytes.
|
||||||
|
|
||||||
|
enum Request {
|
||||||
|
List { reply: Sender<(u16, Vec<u8>)> },
|
||||||
|
Get { id: u64, reply: Sender<(u16, Vec<u8>)> },
|
||||||
|
Create { body: Vec<u8>, reply: Sender<(u16, Vec<u8>)> },
|
||||||
|
Update { id: u64, body: Vec<u8>, reply: Sender<(u16, Vec<u8>)> },
|
||||||
|
Delete { id: u64, reply: Sender<(u16, Vec<u8>)> },
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_PATH: &str = "/tmp/urus-crud.json";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Store actor body
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn store_loop(rx: smarm::Receiver<Request>) {
|
||||||
|
// Load on start. Missing file = empty store. Corrupt file = panic; we
|
||||||
|
// don't auto-rebuild because silently losing data is worse than failing
|
||||||
|
// loud.
|
||||||
|
let mut users: Vec<User> = match std::fs::read(DB_PATH) {
|
||||||
|
Ok(bytes) if !bytes.is_empty() =>
|
||||||
|
serde_json::from_slice(&bytes).expect("urus-crud: db file is not valid JSON"),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
let mut next_id: u64 = users.iter().map(|u| u.id).max().unwrap_or(0) + 1;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let req = match rx.recv() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return, // all senders dropped
|
||||||
|
};
|
||||||
|
match req {
|
||||||
|
Request::List { reply } => {
|
||||||
|
let body = serde_json::to_vec(&users).unwrap();
|
||||||
|
let _ = reply.send((200, body));
|
||||||
|
}
|
||||||
|
Request::Get { id, reply } => {
|
||||||
|
match users.iter().find(|u| u.id == id) {
|
||||||
|
Some(u) => {
|
||||||
|
let body = serde_json::to_vec(u).unwrap();
|
||||||
|
let _ = reply.send((200, body));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::Create { body, reply } => {
|
||||||
|
match serde_json::from_slice::<NewUser>(&body) {
|
||||||
|
Ok(nu) => {
|
||||||
|
let u = User { id: next_id, name: nu.name, email: nu.email };
|
||||||
|
next_id += 1;
|
||||||
|
users.push(u.clone());
|
||||||
|
persist(&users);
|
||||||
|
let _ = reply.send((201, serde_json::to_vec(&u).unwrap()));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::Update { id, body, reply } => {
|
||||||
|
match serde_json::from_slice::<NewUser>(&body) {
|
||||||
|
Ok(nu) => match users.iter_mut().find(|u| u.id == id) {
|
||||||
|
Some(u) => {
|
||||||
|
u.name = nu.name;
|
||||||
|
u.email = nu.email;
|
||||||
|
let snapshot = u.clone();
|
||||||
|
persist(&users);
|
||||||
|
let _ = reply.send((200, serde_json::to_vec(&snapshot).unwrap()));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::Delete { id, reply } => {
|
||||||
|
let before = users.len();
|
||||||
|
users.retain(|u| u.id != id);
|
||||||
|
if users.len() < before {
|
||||||
|
persist(&users);
|
||||||
|
let _ = reply.send((204, Vec::new()));
|
||||||
|
} else {
|
||||||
|
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist(users: &[User]) {
|
||||||
|
let json = serde_json::to_vec_pretty(users).unwrap();
|
||||||
|
// Atomic-ish: write to temp then rename. Avoids half-written files on
|
||||||
|
// crash. /tmp is on the same filesystem so rename is atomic.
|
||||||
|
let tmp = format!("{DB_PATH}.tmp");
|
||||||
|
std::fs::write(&tmp, &json).expect("urus-crud: write tmp failed");
|
||||||
|
std::fs::rename(&tmp, DB_PATH).expect("urus-crud: rename failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handler helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Once-cell trick: the store actor is spawned the first time a handler
|
||||||
|
// runs (smarm requires `spawn` to be called from inside an actor — which
|
||||||
|
// connection actors are). After that all handlers share the same Sender.
|
||||||
|
// Simpler than threading the Sender through the pipeline at startup.
|
||||||
|
|
||||||
|
static STORE_TX: OnceLock<Sender<Request>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn store() -> &'static Sender<Request> {
|
||||||
|
STORE_TX.get_or_init(|| {
|
||||||
|
let (tx, rx) = channel::<Request>();
|
||||||
|
smarm::spawn(move || store_loop(rx));
|
||||||
|
tx
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json(conn: Conn, status: u16, body: Vec<u8>) -> Conn {
|
||||||
|
conn.put_status(status)
|
||||||
|
.put_header("content-type", "application/json")
|
||||||
|
.put_body(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_id(s: &str) -> Option<u64> {
|
||||||
|
s.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn list(conn: Conn, _next: Next) -> Conn {
|
||||||
|
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||||
|
store().send(Request::List { reply: tx }).ok();
|
||||||
|
let (status, body) = rx.recv().expect("store dropped");
|
||||||
|
json(conn, status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(conn: Conn, _next: Next) -> Conn {
|
||||||
|
let body = conn.body.as_bytes().to_vec();
|
||||||
|
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||||
|
store().send(Request::Create { body, reply: tx }).ok();
|
||||||
|
let (status, body) = rx.recv().expect("store dropped");
|
||||||
|
json(conn, status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_one(conn: Conn, _next: Next) -> Conn {
|
||||||
|
let id = match conn.params.get("id").and_then(parse_id) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||||
|
};
|
||||||
|
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||||
|
store().send(Request::Get { id, reply: tx }).ok();
|
||||||
|
let (status, body) = rx.recv().expect("store dropped");
|
||||||
|
json(conn, status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(conn: Conn, _next: Next) -> Conn {
|
||||||
|
let id = match conn.params.get("id").and_then(parse_id) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||||
|
};
|
||||||
|
let body = conn.body.as_bytes().to_vec();
|
||||||
|
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||||
|
store().send(Request::Update { id, body, reply: tx }).ok();
|
||||||
|
let (status, body) = rx.recv().expect("store dropped");
|
||||||
|
json(conn, status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(conn: Conn, _next: Next) -> Conn {
|
||||||
|
let id = match conn.params.get("id").and_then(parse_id) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||||
|
};
|
||||||
|
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||||
|
store().send(Request::Delete { id, reply: tx }).ok();
|
||||||
|
let (status, body) = rx.recv().expect("store dropped");
|
||||||
|
json(conn, status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal request logger
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn logger(conn: Conn, next: Next) -> Conn {
|
||||||
|
let method = conn.method.as_str().to_string();
|
||||||
|
let path = conn.path.clone();
|
||||||
|
let conn = next.run(conn);
|
||||||
|
println!("{method} {path} -> {}", conn.status.unwrap_or(0));
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let pipeline = Pipeline::new()
|
||||||
|
.plug(logger)
|
||||||
|
.plug(
|
||||||
|
Router::new()
|
||||||
|
.get( "/users", list)
|
||||||
|
.post( "/users", create)
|
||||||
|
.get( "/users/:id", get_one)
|
||||||
|
.put( "/users/:id", update)
|
||||||
|
.delete("/users/:id", delete),
|
||||||
|
);
|
||||||
|
|
||||||
|
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
|
||||||
|
println!("urus-crud: DB at {DB_PATH}");
|
||||||
|
serve_with(cfg, pipeline).unwrap();
|
||||||
|
}
|
||||||
+328
@@ -0,0 +1,328 @@
|
|||||||
|
//! `Conn` — the value that flows through the entire plug pipeline.
|
||||||
|
//!
|
||||||
|
//! Carries request data, response state being built, and arbitrary user
|
||||||
|
//! data. Owned and moved at each step. Plugs are unaware of the underlying
|
||||||
|
//! HTTP version (v1.1 today, /2 later).
|
||||||
|
|
||||||
|
use std::any::{Any, TypeId};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Method
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Method {
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Patch,
|
||||||
|
Head,
|
||||||
|
Options,
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Method {
|
||||||
|
pub fn parse(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"GET" => Method::Get,
|
||||||
|
"POST" => Method::Post,
|
||||||
|
"PUT" => Method::Put,
|
||||||
|
"DELETE" => Method::Delete,
|
||||||
|
"PATCH" => Method::Patch,
|
||||||
|
"HEAD" => Method::Head,
|
||||||
|
"OPTIONS" => Method::Options,
|
||||||
|
_ => Method::Other(s.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Method::Get => "GET",
|
||||||
|
Method::Post => "POST",
|
||||||
|
Method::Put => "PUT",
|
||||||
|
Method::Delete => "DELETE",
|
||||||
|
Method::Patch => "PATCH",
|
||||||
|
Method::Head => "HEAD",
|
||||||
|
Method::Options => "OPTIONS",
|
||||||
|
Method::Other(s) => s.as_str(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HttpVersion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HttpVersion {
|
||||||
|
Http10,
|
||||||
|
Http11,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpVersion {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
HttpVersion::Http10 => "HTTP/1.0",
|
||||||
|
HttpVersion::Http11 => "HTTP/1.1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HeaderMap — small-vec of (name, value) string pairs.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Per spec: most requests have <20 headers, so a linear-scan Vec is cheaper
|
||||||
|
// than a hash map. Names are case-insensitive (HTTP/1.1), normalised to
|
||||||
|
// lowercase on insert.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HeaderMap {
|
||||||
|
inner: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HeaderMap {
|
||||||
|
pub fn new() -> Self { Self::default() }
|
||||||
|
|
||||||
|
pub fn with_capacity(n: usize) -> Self {
|
||||||
|
Self { inner: Vec::with_capacity(n) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a header. Does not deduplicate (HTTP allows repeats; e.g.
|
||||||
|
/// `Set-Cookie`).
|
||||||
|
pub fn append(&mut self, name: &str, value: impl Into<String>) {
|
||||||
|
self.inner.push((name.to_ascii_lowercase(), value.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace any existing values for `name` with a single value.
|
||||||
|
pub fn set(&mut self, name: &str, value: impl Into<String>) {
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
self.inner.retain(|(n, _)| n != &lower);
|
||||||
|
self.inner.push((lower, value.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First value for `name`, if any.
|
||||||
|
pub fn get(&self, name: &str) -> Option<&str> {
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
self.inner.iter()
|
||||||
|
.find(|(n, _)| n == &lower)
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over (name, value) pairs in insertion order.
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||||
|
self.inner.iter().map(|(n, v)| (n.as_str(), v.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize { self.inner.len() }
|
||||||
|
pub fn is_empty(&self) -> bool { self.inner.is_empty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Body — request body, exposed as owned bytes.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// v1: bodies are read in full by the connection actor before the pipeline
|
||||||
|
// runs, when `Content-Length` is set. Streaming and chunked encoding will
|
||||||
|
// come later; for now this is the simplest correct thing.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Body {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Body {
|
||||||
|
pub fn empty() -> Self { Self::default() }
|
||||||
|
pub fn from_bytes(b: Vec<u8>) -> Self { Self { bytes: b } }
|
||||||
|
pub fn as_bytes(&self) -> &[u8] { &self.bytes }
|
||||||
|
pub fn into_bytes(self) -> Vec<u8> { self.bytes }
|
||||||
|
pub fn len(&self) -> usize { self.bytes.len() }
|
||||||
|
pub fn is_empty(&self) -> bool { self.bytes.is_empty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RespBody — what plugs put on the wire.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Enum, not a trait, so the connection actor can pattern-match. Leaves room
|
||||||
|
// for future variants like Sse, Stream, Chunked without touching the plug
|
||||||
|
// API.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum RespBody {
|
||||||
|
Empty,
|
||||||
|
Bytes(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RespBody {
|
||||||
|
pub fn len_hint(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
RespBody::Empty => 0,
|
||||||
|
RespBody::Bytes(b) => b.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RespBody {
|
||||||
|
fn default() -> Self { RespBody::Empty }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Params — path parameters extracted by the router.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Small-vec semantics: routes have a handful of params; a Vec is the right
|
||||||
|
// shape. Empty until the router plug populates it.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Params {
|
||||||
|
inner: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Params {
|
||||||
|
pub fn new() -> Self { Self::default() }
|
||||||
|
|
||||||
|
pub fn put(&mut self, name: impl Into<String>, value: impl Into<String>) {
|
||||||
|
self.inner.push((name.into(), value.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, name: &str) -> Option<&str> {
|
||||||
|
self.inner.iter()
|
||||||
|
.find(|(n, _)| n == name)
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||||
|
self.inner.iter().map(|(n, v)| (n.as_str(), v.as_str()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assigns — type-erased map for arbitrary user data.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Same pattern as Phoenix's `conn.assigns`. The `Option<HashMap>` keeps the
|
||||||
|
// common case (no assigns) at zero allocation cost.
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Assigns {
|
||||||
|
inner: Option<HashMap<TypeId, Box<dyn Any + Send>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Assigns {
|
||||||
|
pub fn new() -> Self { Self::default() }
|
||||||
|
|
||||||
|
pub fn put<T: Any + Send>(&mut self, value: T) {
|
||||||
|
self.inner
|
||||||
|
.get_or_insert_with(HashMap::new)
|
||||||
|
.insert(TypeId::of::<T>(), Box::new(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get<T: Any + Send>(&self) -> Option<&T> {
|
||||||
|
self.inner
|
||||||
|
.as_ref()?
|
||||||
|
.get(&TypeId::of::<T>())?
|
||||||
|
.downcast_ref::<T>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take<T: Any + Send>(&mut self) -> Option<T> {
|
||||||
|
let boxed = self.inner.as_mut()?.remove(&TypeId::of::<T>())?;
|
||||||
|
boxed.downcast::<T>().ok().map(|b| *b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conn — the pipeline value.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Conn {
|
||||||
|
// Request (populated before the pipeline runs).
|
||||||
|
pub method: Method,
|
||||||
|
pub path: String,
|
||||||
|
pub query: Option<String>,
|
||||||
|
pub version: HttpVersion,
|
||||||
|
pub headers: HeaderMap,
|
||||||
|
pub body: Body,
|
||||||
|
|
||||||
|
// Response (built up by plugs, written after the pipeline returns).
|
||||||
|
pub status: Option<u16>,
|
||||||
|
pub resp_headers: HeaderMap,
|
||||||
|
pub resp_body: RespBody,
|
||||||
|
|
||||||
|
// Pipeline state.
|
||||||
|
pub params: Params,
|
||||||
|
pub assigns: Assigns,
|
||||||
|
pub halted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Conn {
|
||||||
|
/// Construct an empty `Conn`. Tests use this; the connection actor uses
|
||||||
|
/// `From<RequestParts>` (see parser.rs).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
method: Method::Get,
|
||||||
|
path: String::new(),
|
||||||
|
query: None,
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HeaderMap::new(),
|
||||||
|
body: Body::empty(),
|
||||||
|
|
||||||
|
status: None,
|
||||||
|
resp_headers: HeaderMap::new(),
|
||||||
|
resp_body: RespBody::Empty,
|
||||||
|
|
||||||
|
params: Params::new(),
|
||||||
|
assigns: Assigns::new(),
|
||||||
|
halted: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Fluent builders. Most handler code uses these. -----
|
||||||
|
|
||||||
|
pub fn put_status(mut self, status: u16) -> Self {
|
||||||
|
self.status = Some(status);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_header(mut self, name: &str, value: impl Into<String>) -> Self {
|
||||||
|
self.resp_headers.set(name, value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_body(mut self, body: impl Into<RespBody>) -> Self {
|
||||||
|
self.resp_body = body.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_params(mut self, params: Params) -> Self {
|
||||||
|
self.params = params;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn halt(mut self) -> Self {
|
||||||
|
self.halted = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Conn {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Ergonomic conversions into RespBody. -----
|
||||||
|
|
||||||
|
impl From<Vec<u8>> for RespBody {
|
||||||
|
fn from(v: Vec<u8>) -> Self { RespBody::Bytes(v) }
|
||||||
|
}
|
||||||
|
impl From<String> for RespBody {
|
||||||
|
fn from(s: String) -> Self { RespBody::Bytes(s.into_bytes()) }
|
||||||
|
}
|
||||||
|
impl From<&'static str> for RespBody {
|
||||||
|
fn from(s: &'static str) -> Self { RespBody::Bytes(s.as_bytes().to_vec()) }
|
||||||
|
}
|
||||||
|
impl From<&[u8]> for RespBody {
|
||||||
|
fn from(s: &[u8]) -> Self { RespBody::Bytes(s.to_vec()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
//! The connection actor — one per accepted TCP connection.
|
||||||
|
//!
|
||||||
|
//! Runs the HTTP/1.1 request loop:
|
||||||
|
//!
|
||||||
|
//! loop {
|
||||||
|
//! read bytes → parse → build Conn
|
||||||
|
//! pipeline.run(conn) // inline; no spawn
|
||||||
|
//! write response
|
||||||
|
//! if !keep_alive { break }
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! Everything in here happens in one smarm green thread. The actor parks on
|
||||||
|
//! `wait_readable` between bytes and `wait_writable` during slow writes;
|
||||||
|
//! during those parks, other connection actors progress freely.
|
||||||
|
|
||||||
|
use crate::conn::{Body, Conn, RespBody};
|
||||||
|
use crate::net::OwnedFd;
|
||||||
|
use crate::parser::{self, ParseError};
|
||||||
|
use crate::plug::Pipeline;
|
||||||
|
|
||||||
|
use std::io::{self, ErrorKind};
|
||||||
|
use std::os::fd::RawFd;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Limits
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Per-connection settings the connection actor needs to honour.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct ConnLimits {
|
||||||
|
pub max_headers: usize,
|
||||||
|
pub initial_read_buf: usize,
|
||||||
|
/// Hard cap on the request head to bound buffer growth. 64 KiB is
|
||||||
|
/// well over Apache's 8 KiB default; protects against pathological
|
||||||
|
/// clients streaming headers forever.
|
||||||
|
pub max_head_bytes: usize,
|
||||||
|
/// Hard cap on Content-Length we'll accept. 16 MiB is enough for a CRUD
|
||||||
|
/// example; configurable in `Config`.
|
||||||
|
pub max_body_bytes: usize,
|
||||||
|
pub keep_alive_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConnLimits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_headers: 64,
|
||||||
|
initial_read_buf: 8 * 1024,
|
||||||
|
max_head_bytes: 64 * 1024,
|
||||||
|
max_body_bytes: 16 * 1024 * 1024,
|
||||||
|
keep_alive_timeout: Duration::from_secs(60),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// run_connection — entry point spawned by the listener actor.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn run_connection(fd: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||||
|
// The OwnedFd cleans up via Drop on any exit path (panic, error, or
|
||||||
|
// normal close). No explicit close calls below.
|
||||||
|
let raw = fd.as_raw();
|
||||||
|
let mut buf: Vec<u8> = Vec::with_capacity(limits.initial_read_buf);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// ----- 1. Read until we have a full request head. -----
|
||||||
|
let parsed = match read_head(raw, &mut buf, &limits) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(ReadHeadErr::ClientClosed) => {
|
||||||
|
// Clean EOF between requests (or before any request). Normal.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(ReadHeadErr::Io(_)) => {
|
||||||
|
// Network error or timeout. Best-effort close; we're done.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(ReadHeadErr::Parse(e)) => {
|
||||||
|
emit_error_response(raw, &e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----- 2. Read body. -----
|
||||||
|
let body_len = parsed.content_length.unwrap_or(0);
|
||||||
|
if body_len > limits.max_body_bytes {
|
||||||
|
let _ = write_all(raw, b"HTTP/1.1 413 Payload Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If client sent `Expect: 100-continue`, emit it before reading the
|
||||||
|
// body. RFC 7231 §5.1.1. We don't gate on app logic here; v1 always
|
||||||
|
// accepts.
|
||||||
|
if parsed.expect_100 {
|
||||||
|
if write_all(raw, b"HTTP/1.1 100 Continue\r\n\r\n").is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = match read_body(raw, &mut buf, parsed.head_len, body_len) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let keep_alive = parsed.keep_alive;
|
||||||
|
let version = parsed.version;
|
||||||
|
let head_len = parsed.head_len;
|
||||||
|
let conn = parser::build_conn(parsed, Body::from_bytes(body));
|
||||||
|
|
||||||
|
// ----- 3. Run the pipeline. -----
|
||||||
|
// Catch panics at the actor boundary — a panicking handler should
|
||||||
|
// not take down the whole connection silently with no response.
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
pipeline.run(conn)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mut response_conn = match result {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
// Compose a 500 manually; the original Conn was moved into
|
||||||
|
// the closure.
|
||||||
|
let mut c = Conn::new();
|
||||||
|
c.version = version;
|
||||||
|
c.put_status(500).put_header("content-length", "0")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no plug touched status, that's a configuration error (no router
|
||||||
|
// matched, no default handler). Emit 404.
|
||||||
|
if response_conn.status.is_none() {
|
||||||
|
response_conn = response_conn.put_status(404)
|
||||||
|
.put_body(RespBody::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 4. Write the response. -----
|
||||||
|
let bytes = parser::serialise_response(&response_conn, keep_alive);
|
||||||
|
if write_all(raw, &bytes).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 5. Loop or close. -----
|
||||||
|
if !keep_alive {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the request bytes (head + body) from `buf`; anything past
|
||||||
|
// them is the start of the next pipelined request.
|
||||||
|
let consumed = head_len + body_len;
|
||||||
|
buf.drain(..consumed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// read_head
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[allow(dead_code)] // io::Error is captured for future logging
|
||||||
|
enum ReadHeadErr {
|
||||||
|
ClientClosed,
|
||||||
|
Io(io::Error),
|
||||||
|
Parse(ParseError),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read until `parse_head` succeeds or fails definitively. `buf` may already
|
||||||
|
/// contain leftover bytes from a previous keep-alive cycle; we try to parse
|
||||||
|
/// those before reading more from the socket.
|
||||||
|
fn read_head(
|
||||||
|
fd: RawFd,
|
||||||
|
buf: &mut Vec<u8>,
|
||||||
|
limits: &ConnLimits,
|
||||||
|
) -> Result<parser::ParsedHead, ReadHeadErr> {
|
||||||
|
loop {
|
||||||
|
// Try to parse what we already have. On the first iteration of a
|
||||||
|
// fresh keep-alive cycle, `buf` may already hold the next request.
|
||||||
|
if !buf.is_empty() {
|
||||||
|
match parser::parse_head(buf, limits.max_headers) {
|
||||||
|
Ok(h) => return Ok(h),
|
||||||
|
Err(ParseError::Incomplete) => {} // need more bytes
|
||||||
|
Err(e) => return Err(ReadHeadErr::Parse(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.len() >= limits.max_head_bytes {
|
||||||
|
return Err(ReadHeadErr::Parse(ParseError::TooManyHeaders));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read more.
|
||||||
|
match read_some(fd, buf, limits.initial_read_buf) {
|
||||||
|
Ok(0) => return Err(ReadHeadErr::ClientClosed),
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(e) => return Err(ReadHeadErr::Io(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// read_body
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn read_body(
|
||||||
|
fd: RawFd,
|
||||||
|
buf: &mut Vec<u8>,
|
||||||
|
head_len: usize,
|
||||||
|
body_len: usize,
|
||||||
|
) -> io::Result<Vec<u8>> {
|
||||||
|
// Bytes already in `buf` past the head belong to the body.
|
||||||
|
let already = buf.len().saturating_sub(head_len);
|
||||||
|
let need = body_len.saturating_sub(already);
|
||||||
|
|
||||||
|
if need == 0 {
|
||||||
|
// We have the full body in `buf` already. Extract a copy; `buf` is
|
||||||
|
// drained later in the connection loop.
|
||||||
|
return Ok(buf[head_len..head_len + body_len].to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read until we have the rest.
|
||||||
|
let mut total_read = already;
|
||||||
|
while total_read < body_len {
|
||||||
|
match read_some(fd, buf, 8 * 1024) {
|
||||||
|
Ok(0) => return Err(io::Error::new(ErrorKind::UnexpectedEof, "client closed during body")),
|
||||||
|
Ok(n) => total_read += n,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(buf[head_len..head_len + body_len].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// read_some — single epoll-park + read loop.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Appends what it reads onto `buf`. Returns bytes read, 0 for EOF, or the
|
||||||
|
// last io error.
|
||||||
|
|
||||||
|
fn read_some(fd: RawFd, buf: &mut Vec<u8>, chunk: usize) -> io::Result<usize> {
|
||||||
|
// Loop to absorb EAGAIN: a readable wakeup followed by EAGAIN is
|
||||||
|
// possible (signal race, etc). Re-park and retry rather than returning
|
||||||
|
// 0 (which would be confused with EOF by callers).
|
||||||
|
loop {
|
||||||
|
smarm::wait_readable(fd)?;
|
||||||
|
|
||||||
|
let start = buf.len();
|
||||||
|
buf.resize(start + chunk, 0);
|
||||||
|
|
||||||
|
let n = unsafe {
|
||||||
|
libc::read(fd, buf.as_mut_ptr().add(start) as *mut _, chunk)
|
||||||
|
};
|
||||||
|
|
||||||
|
if n < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
buf.truncate(start);
|
||||||
|
if err.kind() == ErrorKind::WouldBlock || err.kind() == ErrorKind::Interrupted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = n as usize;
|
||||||
|
buf.truncate(start + n);
|
||||||
|
return Ok(n); // n == 0 here is real EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// write_all — robust write loop.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn write_all(fd: RawFd, mut buf: &[u8]) -> io::Result<()> {
|
||||||
|
while !buf.is_empty() {
|
||||||
|
// Park on writability before each syscall.
|
||||||
|
smarm::wait_writable(fd)?;
|
||||||
|
|
||||||
|
let n = unsafe {
|
||||||
|
libc::write(fd, buf.as_ptr() as *const _, buf.len())
|
||||||
|
};
|
||||||
|
if n < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
if err.kind() == ErrorKind::WouldBlock {
|
||||||
|
continue; // spurious wake; retry
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return Err(io::Error::new(ErrorKind::WriteZero, "write returned 0"));
|
||||||
|
}
|
||||||
|
buf = &buf[n as usize..];
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error responses for unparseable / malformed requests.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn emit_error_response(fd: RawFd, err: &ParseError) {
|
||||||
|
let resp: &[u8] = match err {
|
||||||
|
ParseError::TooManyHeaders =>
|
||||||
|
b"HTTP/1.1 431 Request Header Fields Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||||
|
ParseError::BadContentLength =>
|
||||||
|
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||||
|
ParseError::Unsupported =>
|
||||||
|
b"HTTP/1.1 411 Length Required\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||||
|
// Incomplete and Malformed both lead here; Incomplete shouldn't
|
||||||
|
// appear (read_head loops on it).
|
||||||
|
_ =>
|
||||||
|
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||||
|
};
|
||||||
|
let _ = write_all(fd, resp);
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
//! # urus — a cowboy/bandit-style HTTP library for the smarm actor runtime.
|
||||||
|
//!
|
||||||
|
//! v1 covers HTTP/1.1, the plug pipeline, and a built-in router. See
|
||||||
|
//! `urus-spec.md` for the design.
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use urus::{Pipeline, Router, Conn, Next, serve};
|
||||||
|
//!
|
||||||
|
//! let pipeline = Pipeline::new().plug(
|
||||||
|
//! Router::new()
|
||||||
|
//! .get("/", |c: Conn, _n: Next| c.put_status(200).put_body("hello"))
|
||||||
|
//! .get("/users/:id", |c: Conn, _n: Next| {
|
||||||
|
//! let id = c.params.get("id").unwrap_or("").to_string();
|
||||||
|
//! c.put_status(200).put_body(id)
|
||||||
|
//! })
|
||||||
|
//! );
|
||||||
|
//!
|
||||||
|
//! serve("0.0.0.0:8080", pipeline).unwrap();
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod conn;
|
||||||
|
pub mod plug;
|
||||||
|
pub mod router;
|
||||||
|
pub mod parser;
|
||||||
|
pub mod net;
|
||||||
|
pub mod conn_actor;
|
||||||
|
pub mod serve;
|
||||||
|
|
||||||
|
// Re-exports — what most users want at the crate root.
|
||||||
|
pub use conn::{Assigns, Body, Conn, HeaderMap, HttpVersion, Method, Params, RespBody};
|
||||||
|
pub use plug::{Next, Pipeline, Plug};
|
||||||
|
pub use router::Router;
|
||||||
|
pub use serve::{serve, serve_with, Config};
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
//! Thin TCP socket layer.
|
||||||
|
//!
|
||||||
|
//! We don't use `std::net::TcpListener` because we need non-blocking accept
|
||||||
|
//! integrated with smarm's epoll loop. So this drops down to libc: socket,
|
||||||
|
//! bind, listen, accept4. All fds carry `O_NONBLOCK | O_CLOEXEC` so smarm's
|
||||||
|
//! readiness primitives work as documented.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::os::fd::RawFd;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OwnedFd — a tiny RAII wrapper that closes on drop.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Sent through smarm channels (which require `Send`) for zero-copy fd
|
||||||
|
// handoff between listener and connection actor. `Send` is safe: fd values
|
||||||
|
// are just integers, and ownership semantics — exactly-one closer — are
|
||||||
|
// enforced by the type itself.
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OwnedFd {
|
||||||
|
fd: RawFd,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OwnedFd {
|
||||||
|
/// Wrap a raw fd. The wrapper now owns the fd and will close it on drop.
|
||||||
|
pub fn from_raw(fd: RawFd) -> Self {
|
||||||
|
Self { fd }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_raw(&self) -> RawFd { self.fd }
|
||||||
|
|
||||||
|
/// Release ownership without closing. The caller must close the fd.
|
||||||
|
pub fn into_raw(self) -> RawFd {
|
||||||
|
let fd = self.fd;
|
||||||
|
std::mem::forget(self);
|
||||||
|
fd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for OwnedFd {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.fd >= 0 {
|
||||||
|
unsafe { libc::close(self.fd); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fd handoff: SAFETY: RawFd is an integer; sending one across threads
|
||||||
|
// transfers ownership in the same way moving an i32 would. The recipient
|
||||||
|
// becomes the unique closer.
|
||||||
|
unsafe impl Send for OwnedFd {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// bind_and_listen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const LISTEN_BACKLOG: i32 = 1024;
|
||||||
|
|
||||||
|
/// Create a non-blocking, SO_REUSEADDR TCP listener bound to `addr`. The
|
||||||
|
/// returned fd is ready for `accept4` calls; the caller registers it with
|
||||||
|
/// smarm via `wait_readable` between accepts.
|
||||||
|
pub fn bind_and_listen(addr: SocketAddr) -> io::Result<OwnedFd> {
|
||||||
|
let family = match addr {
|
||||||
|
SocketAddr::V4(_) => libc::AF_INET,
|
||||||
|
SocketAddr::V6(_) => libc::AF_INET6,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fd = unsafe {
|
||||||
|
libc::socket(
|
||||||
|
family,
|
||||||
|
libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let owned = OwnedFd::from_raw(fd);
|
||||||
|
|
||||||
|
// SO_REUSEADDR — standard for servers; avoids TIME_WAIT bind failures
|
||||||
|
// on restart.
|
||||||
|
let opt: libc::c_int = 1;
|
||||||
|
let r = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
fd,
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
libc::SO_REUSEADDR,
|
||||||
|
&opt as *const _ as *const libc::c_void,
|
||||||
|
std::mem::size_of_val(&opt) as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if r < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
// bind().
|
||||||
|
match addr {
|
||||||
|
SocketAddr::V4(a) => {
|
||||||
|
let sa = libc::sockaddr_in {
|
||||||
|
sin_family: libc::AF_INET as u16,
|
||||||
|
sin_port: a.port().to_be(),
|
||||||
|
sin_addr: libc::in_addr { s_addr: u32::from_ne_bytes(a.ip().octets()) },
|
||||||
|
sin_zero: [0; 8],
|
||||||
|
};
|
||||||
|
let r = unsafe {
|
||||||
|
libc::bind(
|
||||||
|
fd,
|
||||||
|
&sa as *const _ as *const libc::sockaddr,
|
||||||
|
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if r < 0 { return Err(io::Error::last_os_error()); }
|
||||||
|
}
|
||||||
|
SocketAddr::V6(a) => {
|
||||||
|
let sa = libc::sockaddr_in6 {
|
||||||
|
sin6_family: libc::AF_INET6 as u16,
|
||||||
|
sin6_port: a.port().to_be(),
|
||||||
|
sin6_flowinfo: a.flowinfo(),
|
||||||
|
sin6_addr: libc::in6_addr { s6_addr: a.ip().octets() },
|
||||||
|
sin6_scope_id: a.scope_id(),
|
||||||
|
};
|
||||||
|
let r = unsafe {
|
||||||
|
libc::bind(
|
||||||
|
fd,
|
||||||
|
&sa as *const _ as *const libc::sockaddr,
|
||||||
|
std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if r < 0 { return Err(io::Error::last_os_error()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listen().
|
||||||
|
let r = unsafe { libc::listen(fd, LISTEN_BACKLOG) };
|
||||||
|
if r < 0 { return Err(io::Error::last_os_error()); }
|
||||||
|
|
||||||
|
Ok(owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// accept_nonblocking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One non-blocking `accept4`. Returns the new fd on success,
|
||||||
|
/// `Err(WouldBlock)` if no connection is pending (caller should park on
|
||||||
|
/// `wait_readable(listener)` and retry), or other errors directly.
|
||||||
|
pub fn accept_nonblocking(listener: RawFd) -> io::Result<OwnedFd> {
|
||||||
|
let mut addr: libc::sockaddr_storage = unsafe { std::mem::zeroed() };
|
||||||
|
let mut len: libc::socklen_t = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
|
||||||
|
|
||||||
|
let fd = unsafe {
|
||||||
|
libc::accept4(
|
||||||
|
listener,
|
||||||
|
&mut addr as *mut _ as *mut libc::sockaddr,
|
||||||
|
&mut len,
|
||||||
|
libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(OwnedFd::from_raw(fd))
|
||||||
|
}
|
||||||
+379
@@ -0,0 +1,379 @@
|
|||||||
|
//! HTTP/1.1 wire protocol — request parsing and response serialisation.
|
||||||
|
//!
|
||||||
|
//! Parsing uses `httparse` for the request line + headers (zero-alloc, zero-
|
||||||
|
//! copy, battle-tested). Body framing, keep-alive logic and response writing
|
||||||
|
//! are ours.
|
||||||
|
//!
|
||||||
|
//! v1 body framing:
|
||||||
|
//! - `Content-Length: N` — read exactly N bytes.
|
||||||
|
//! - No body header — empty body.
|
||||||
|
//! - `Transfer-Encoding: chunked` — deferred. Returns ParseError::Unsupported
|
||||||
|
//! and the connection actor responds 411 Length Required + close.
|
||||||
|
//!
|
||||||
|
//! Keep it stupid simple. Chunked decoding lands when something actually
|
||||||
|
//! requests it.
|
||||||
|
|
||||||
|
use crate::conn::{Body, Conn, HeaderMap, HttpVersion, Method, RespBody};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ParseError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ParseError {
|
||||||
|
/// Request bytes are well-formed but incomplete; need more from the
|
||||||
|
/// socket.
|
||||||
|
Incomplete,
|
||||||
|
/// Request is malformed; respond 400 and close.
|
||||||
|
Malformed,
|
||||||
|
/// Header count exceeded the configured max; respond 431 and close.
|
||||||
|
TooManyHeaders,
|
||||||
|
/// `Content-Length` header could not be parsed as an integer.
|
||||||
|
BadContentLength,
|
||||||
|
/// A wire feature we haven't implemented yet (e.g. chunked encoding).
|
||||||
|
/// Connection actor responds 411 + close.
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ParsedHead — what `parse_head` returns on success.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// `head_len` is how many bytes from the start of the buffer the head
|
||||||
|
// occupied — the body starts at `&buf[head_len..]`.
|
||||||
|
|
||||||
|
pub struct ParsedHead {
|
||||||
|
pub head_len: usize,
|
||||||
|
pub method: Method,
|
||||||
|
pub path: String,
|
||||||
|
pub query: Option<String>,
|
||||||
|
pub version: HttpVersion,
|
||||||
|
pub headers: HeaderMap,
|
||||||
|
pub content_length: Option<usize>,
|
||||||
|
pub keep_alive: bool,
|
||||||
|
pub expect_100: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to parse a request head from `buf`. Returns `Incomplete` if more
|
||||||
|
/// bytes are needed; the caller should read more and retry with the *same*
|
||||||
|
/// buffer.
|
||||||
|
pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseError> {
|
||||||
|
// httparse needs a header array; size it from config. 64 is the spec
|
||||||
|
// default. Stack allocation; if a request has more than max_headers
|
||||||
|
// they get TooManyHeaders.
|
||||||
|
let mut header_buf = vec![httparse::EMPTY_HEADER; max_headers];
|
||||||
|
let mut req = httparse::Request::new(&mut header_buf);
|
||||||
|
|
||||||
|
let head_len = match req.parse(buf) {
|
||||||
|
Ok(httparse::Status::Complete(n)) => n,
|
||||||
|
Ok(httparse::Status::Partial) => return Err(ParseError::Incomplete),
|
||||||
|
Err(httparse::Error::TooManyHeaders) => return Err(ParseError::TooManyHeaders),
|
||||||
|
Err(_) => return Err(ParseError::Malformed),
|
||||||
|
};
|
||||||
|
|
||||||
|
let method_str = req.method.ok_or(ParseError::Malformed)?;
|
||||||
|
let raw_path = req.path.ok_or(ParseError::Malformed)?;
|
||||||
|
let v = req.version.ok_or(ParseError::Malformed)?;
|
||||||
|
|
||||||
|
let version = match v {
|
||||||
|
0 => HttpVersion::Http10,
|
||||||
|
1 => HttpVersion::Http11,
|
||||||
|
_ => return Err(ParseError::Malformed),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Split path and query. We don't percent-decode the path here — the
|
||||||
|
// spec calls for it, but for v1 raw is fine; user code can decode if
|
||||||
|
// they need to. Same with the query.
|
||||||
|
let (path, query) = match raw_path.split_once('?') {
|
||||||
|
Some((p, q)) => (p.to_string(), Some(q.to_string())),
|
||||||
|
None => (raw_path.to_string(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk headers, building HeaderMap and pulling out the few we need
|
||||||
|
// ourselves (Content-Length, Connection, Transfer-Encoding, Expect).
|
||||||
|
let mut headers = HeaderMap::with_capacity(req.headers.len());
|
||||||
|
let mut content_length = None;
|
||||||
|
let mut connection_hdr = None;
|
||||||
|
let mut chunked = false;
|
||||||
|
let mut expect_100 = false;
|
||||||
|
|
||||||
|
for h in req.headers.iter() {
|
||||||
|
let name_lower = h.name.to_ascii_lowercase();
|
||||||
|
let value = std::str::from_utf8(h.value).map_err(|_| ParseError::Malformed)?;
|
||||||
|
|
||||||
|
match name_lower.as_str() {
|
||||||
|
"content-length" => {
|
||||||
|
content_length = Some(
|
||||||
|
value.trim()
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| ParseError::BadContentLength)?
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"transfer-encoding" => {
|
||||||
|
// We only care whether it includes "chunked". Multiple codings
|
||||||
|
// can appear; chunked is the only one we'd need to decode.
|
||||||
|
if value.to_ascii_lowercase().split(',').any(|t| t.trim() == "chunked") {
|
||||||
|
chunked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"connection" => {
|
||||||
|
connection_hdr = Some(value.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
"expect" => {
|
||||||
|
if value.eq_ignore_ascii_case("100-continue") {
|
||||||
|
expect_100 = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
headers.append(&name_lower, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunked {
|
||||||
|
return Err(ParseError::Unsupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep-alive logic, RFC 7230 §6.3:
|
||||||
|
// HTTP/1.1: keep-alive by default; "Connection: close" overrides.
|
||||||
|
// HTTP/1.0: close by default; "Connection: keep-alive" overrides.
|
||||||
|
let keep_alive = match version {
|
||||||
|
HttpVersion::Http11 => connection_hdr.as_deref() != Some("close"),
|
||||||
|
HttpVersion::Http10 => connection_hdr.as_deref() == Some("keep-alive"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ParsedHead {
|
||||||
|
head_len,
|
||||||
|
method: Method::parse(method_str),
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
version,
|
||||||
|
headers,
|
||||||
|
content_length,
|
||||||
|
keep_alive,
|
||||||
|
expect_100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conn assembly
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn build_conn(head: ParsedHead, body: Body) -> Conn {
|
||||||
|
let mut c = Conn::new();
|
||||||
|
c.method = head.method;
|
||||||
|
c.path = head.path;
|
||||||
|
c.query = head.query;
|
||||||
|
c.version = head.version;
|
||||||
|
c.headers = head.headers;
|
||||||
|
c.body = body;
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response serialisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// We emit:
|
||||||
|
// <version> <status> <reason>\r\n
|
||||||
|
// <header>: <value>\r\n
|
||||||
|
// ...
|
||||||
|
// \r\n
|
||||||
|
// <body bytes>
|
||||||
|
//
|
||||||
|
// Headers we always inject (unless the user already set them):
|
||||||
|
// - Content-Length: from resp_body.len_hint()
|
||||||
|
// - Connection: close (if keep-alive is off this request)
|
||||||
|
// - Date: skipped in v1; not required by the standard and adds complexity.
|
||||||
|
|
||||||
|
pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
|
||||||
|
// Pre-size: status line ~30 + headers ~50/each + body. Good enough.
|
||||||
|
let body_len = conn.resp_body.len_hint();
|
||||||
|
let mut out = Vec::with_capacity(64 + conn.resp_headers.len() * 40 + body_len);
|
||||||
|
|
||||||
|
let status = conn.status.unwrap_or(200);
|
||||||
|
let reason = reason_phrase(status);
|
||||||
|
|
||||||
|
// Status line.
|
||||||
|
out.extend_from_slice(conn.version.as_str().as_bytes());
|
||||||
|
out.push(b' ');
|
||||||
|
out.extend_from_slice(status.to_string().as_bytes());
|
||||||
|
out.push(b' ');
|
||||||
|
out.extend_from_slice(reason.as_bytes());
|
||||||
|
out.extend_from_slice(b"\r\n");
|
||||||
|
|
||||||
|
// User headers — written first so subsequent injection can skip them.
|
||||||
|
let mut wrote_content_length = false;
|
||||||
|
let mut wrote_connection = false;
|
||||||
|
|
||||||
|
for (name, value) in conn.resp_headers.iter() {
|
||||||
|
match name {
|
||||||
|
"content-length" => wrote_content_length = true,
|
||||||
|
"connection" => wrote_connection = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
out.extend_from_slice(name.as_bytes());
|
||||||
|
out.extend_from_slice(b": ");
|
||||||
|
out.extend_from_slice(value.as_bytes());
|
||||||
|
out.extend_from_slice(b"\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !wrote_content_length {
|
||||||
|
out.extend_from_slice(b"content-length: ");
|
||||||
|
out.extend_from_slice(body_len.to_string().as_bytes());
|
||||||
|
out.extend_from_slice(b"\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !wrote_connection && !keep_alive {
|
||||||
|
out.extend_from_slice(b"connection: close\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
out.extend_from_slice(b"\r\n");
|
||||||
|
|
||||||
|
// Body.
|
||||||
|
match &conn.resp_body {
|
||||||
|
RespBody::Empty => {}
|
||||||
|
RespBody::Bytes(b) => out.extend_from_slice(b),
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP/1.1 reason phrases. Required by RFC 7230 §3.1.2 — clients may
|
||||||
|
/// display them.
|
||||||
|
pub fn reason_phrase(status: u16) -> &'static str {
|
||||||
|
match status {
|
||||||
|
200 => "OK",
|
||||||
|
201 => "Created",
|
||||||
|
202 => "Accepted",
|
||||||
|
204 => "No Content",
|
||||||
|
301 => "Moved Permanently",
|
||||||
|
302 => "Found",
|
||||||
|
303 => "See Other",
|
||||||
|
304 => "Not Modified",
|
||||||
|
307 => "Temporary Redirect",
|
||||||
|
308 => "Permanent Redirect",
|
||||||
|
400 => "Bad Request",
|
||||||
|
401 => "Unauthorized",
|
||||||
|
403 => "Forbidden",
|
||||||
|
404 => "Not Found",
|
||||||
|
405 => "Method Not Allowed",
|
||||||
|
409 => "Conflict",
|
||||||
|
411 => "Length Required",
|
||||||
|
413 => "Payload Too Large",
|
||||||
|
414 => "URI Too Long",
|
||||||
|
415 => "Unsupported Media Type",
|
||||||
|
422 => "Unprocessable Entity",
|
||||||
|
429 => "Too Many Requests",
|
||||||
|
431 => "Request Header Fields Too Large",
|
||||||
|
500 => "Internal Server Error",
|
||||||
|
501 => "Not Implemented",
|
||||||
|
502 => "Bad Gateway",
|
||||||
|
503 => "Service Unavailable",
|
||||||
|
_ => "OK", // best-effort fallback; spec allows any phrase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_simple_get() {
|
||||||
|
let req = b"GET /hello HTTP/1.1\r\nHost: x\r\n\r\n";
|
||||||
|
let head = parse_head(req, 64).unwrap();
|
||||||
|
assert_eq!(head.method, Method::Get);
|
||||||
|
assert_eq!(head.path, "/hello");
|
||||||
|
assert_eq!(head.version, HttpVersion::Http11);
|
||||||
|
assert!(head.keep_alive);
|
||||||
|
assert_eq!(head.content_length, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_with_query() {
|
||||||
|
let req = b"GET /users?id=42&active=true HTTP/1.1\r\nHost: x\r\n\r\n";
|
||||||
|
let head = parse_head(req, 64).unwrap();
|
||||||
|
assert_eq!(head.path, "/users");
|
||||||
|
assert_eq!(head.query.as_deref(), Some("id=42&active=true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_post_with_content_length() {
|
||||||
|
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello";
|
||||||
|
let head = parse_head(req, 64).unwrap();
|
||||||
|
assert_eq!(head.content_length, Some(5));
|
||||||
|
// head_len is bytes before the body; the remainder is the body.
|
||||||
|
assert_eq!(&req[head.head_len..], b"hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_connection_close() {
|
||||||
|
let req = b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n";
|
||||||
|
let head = parse_head(req, 64).unwrap();
|
||||||
|
assert!(!head.keep_alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_http10_default_close() {
|
||||||
|
let req = b"GET / HTTP/1.0\r\nHost: x\r\n\r\n";
|
||||||
|
let head = parse_head(req, 64).unwrap();
|
||||||
|
assert!(!head.keep_alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_http10_keepalive_opt_in() {
|
||||||
|
let req = b"GET / HTTP/1.0\r\nHost: x\r\nConnection: keep-alive\r\n\r\n";
|
||||||
|
let head = parse_head(req, 64).unwrap();
|
||||||
|
assert!(head.keep_alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_partial() {
|
||||||
|
let req = b"GET /hello HTTP/1.1\r\nHost: x\r\n"; // no terminator yet
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::Incomplete) => {}
|
||||||
|
_ => panic!("expected Incomplete"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_chunked_unsupported() {
|
||||||
|
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::Unsupported) => {}
|
||||||
|
_ => panic!("expected Unsupported for chunked"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialise_basic_200() {
|
||||||
|
let conn = Conn::new().put_status(200).put_body("hi");
|
||||||
|
let bytes = serialise_response(&conn, true);
|
||||||
|
let s = std::str::from_utf8(&bytes).unwrap();
|
||||||
|
assert!(s.starts_with("HTTP/1.1 200 OK\r\n"));
|
||||||
|
assert!(s.contains("content-length: 2"));
|
||||||
|
assert!(s.ends_with("\r\n\r\nhi"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialise_connection_close_when_not_keepalive() {
|
||||||
|
let conn = Conn::new().put_status(204);
|
||||||
|
let bytes = serialise_response(&conn, false);
|
||||||
|
let s = std::str::from_utf8(&bytes).unwrap();
|
||||||
|
assert!(s.contains("connection: close"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialise_user_content_length_is_respected() {
|
||||||
|
let conn = Conn::new().put_status(200)
|
||||||
|
.put_header("content-length", "999")
|
||||||
|
.put_body("hi"); // mismatch on purpose — user wins
|
||||||
|
let bytes = serialise_response(&conn, true);
|
||||||
|
let s = std::str::from_utf8(&bytes).unwrap();
|
||||||
|
assert!(s.contains("content-length: 999"));
|
||||||
|
assert!(!s.contains("content-length: 2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
//! The plug pipeline — universal abstraction for everything in urus.
|
||||||
|
//!
|
||||||
|
//! A `Plug` is anything that takes a `Conn`, optionally calls `next.run(conn)`
|
||||||
|
//! to delegate to the rest of the pipeline, and returns a `Conn`. Routing,
|
||||||
|
//! middleware, handlers — all the same shape.
|
||||||
|
//!
|
||||||
|
//! The halt is structural: not calling `next.run(conn)` *is* the halt. Setting
|
||||||
|
//! `conn.halted = true` is only a hint to outer plugs that the pipeline was
|
||||||
|
//! short-circuited (e.g. a logger that wraps `next`).
|
||||||
|
|
||||||
|
use crate::conn::Conn;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plug trait
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub trait Plug: Send + Sync + 'static {
|
||||||
|
fn call(&self, conn: Conn, next: Next) -> Conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blanket impl for closures. Both `Send + Sync` so plug values can be shared
|
||||||
|
// across connection actors via the Arc'd pipeline.
|
||||||
|
impl<F> Plug for F
|
||||||
|
where
|
||||||
|
F: Fn(Conn, Next) -> Conn + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
fn call(&self, conn: Conn, next: Next) -> Conn {
|
||||||
|
self(conn, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Next — a handle to the remainder of the pipeline.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Opaque wrapper around a slice of the remaining plugs. `run(conn)` consumes
|
||||||
|
// the handle (no double-calling) and dispatches to the head plug. Empty
|
||||||
|
// remainder is a no-op — the conn passes through unchanged.
|
||||||
|
|
||||||
|
pub struct Next<'a> {
|
||||||
|
plugs: &'a [Arc<dyn Plug>],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Next<'a> {
|
||||||
|
pub(crate) fn new(plugs: &'a [Arc<dyn Plug>]) -> Self {
|
||||||
|
Self { plugs }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Continue down the pipeline. Returns the `Conn` produced by the rest.
|
||||||
|
pub fn run(self, conn: Conn) -> Conn {
|
||||||
|
match self.plugs.split_first() {
|
||||||
|
None => conn,
|
||||||
|
Some((head, tail)) => head.call(conn, Next { plugs: tail }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Built once at startup, cloned cheaply (Arc bump) into each connection
|
||||||
|
// actor. Read-only after construction; no per-request allocation in here.
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Pipeline {
|
||||||
|
plugs: Arc<Vec<Arc<dyn Plug>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pipeline {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { plugs: Arc::new(Vec::new()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a plug. Builder pattern — consumes and returns self.
|
||||||
|
///
|
||||||
|
/// Mutates the underlying Vec via `Arc::make_mut` when possible (it's
|
||||||
|
/// unique during construction), which is allocation-free after the first
|
||||||
|
/// `plug()` until clones are taken.
|
||||||
|
pub fn plug(mut self, p: impl Plug) -> Self {
|
||||||
|
let v = Arc::make_mut(&mut self.plugs);
|
||||||
|
v.push(Arc::new(p));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the pipeline against a `Conn`. The connection actor calls this
|
||||||
|
/// once per request.
|
||||||
|
pub fn run(&self, conn: Conn) -> Conn {
|
||||||
|
Next::new(self.plugs.as_slice()).run(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of plugs in the pipeline. Mostly for tests.
|
||||||
|
pub fn len(&self) -> usize { self.plugs.len() }
|
||||||
|
pub fn is_empty(&self) -> bool { self.plugs.is_empty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Pipeline {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
//! The router — itself a plug.
|
||||||
|
//!
|
||||||
|
//! v1: linear scan over a Vec of compiled route patterns. The spec calls for
|
||||||
|
//! a radix trie eventually; for "good enough to build a CRUD app" with
|
||||||
|
//! typical route counts (<50), the linear scan is dominant noise next to
|
||||||
|
//! everything else on the request path. Replace with a trie when there's
|
||||||
|
//! evidence it matters.
|
||||||
|
//!
|
||||||
|
//! Pattern syntax:
|
||||||
|
//! - `/users` — literal
|
||||||
|
//! - `/users/:id` — `id` param
|
||||||
|
//! - `/users/:id/posts/:n` — multiple params
|
||||||
|
//!
|
||||||
|
//! No wildcards in v1. They are not needed for CRUD.
|
||||||
|
|
||||||
|
use crate::conn::{Conn, Method, Params};
|
||||||
|
use crate::plug::{Next, Plug};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pattern
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A compiled URL pattern: a list of segments, each either a literal or a
|
||||||
|
/// named parameter. Segment compilation happens at `Router::get(...)` time;
|
||||||
|
/// matching at request time is a single-pass loop.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Pattern {
|
||||||
|
segments: Vec<Segment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Segment {
|
||||||
|
Literal(String),
|
||||||
|
Param(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pattern {
|
||||||
|
fn compile(path: &str) -> Self {
|
||||||
|
// Treat empty path the same as "/" — single empty segment.
|
||||||
|
let trimmed = path.trim_start_matches('/');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Pattern { segments: Vec::new() };
|
||||||
|
}
|
||||||
|
let segments = trimmed
|
||||||
|
.split('/')
|
||||||
|
.map(|s| {
|
||||||
|
if let Some(name) = s.strip_prefix(':') {
|
||||||
|
Segment::Param(name.to_string())
|
||||||
|
} else {
|
||||||
|
Segment::Literal(s.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Pattern { segments }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match `path` against this pattern. Returns extracted params on
|
||||||
|
/// success, `None` on miss. Strict path equality — trailing slashes
|
||||||
|
/// matter; that's standard HTTP behaviour and we don't paper over it.
|
||||||
|
fn match_path(&self, path: &str) -> Option<Params> {
|
||||||
|
let trimmed = path.trim_start_matches('/');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return if self.segments.is_empty() { Some(Params::new()) } else { None };
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = trimmed.split('/').collect();
|
||||||
|
if parts.len() != self.segments.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut params = Params::new();
|
||||||
|
for (seg, part) in self.segments.iter().zip(parts.iter()) {
|
||||||
|
match seg {
|
||||||
|
Segment::Literal(lit) => {
|
||||||
|
if lit != part {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Segment::Param(name) => {
|
||||||
|
params.put(name.clone(), (*part).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Route
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Route {
|
||||||
|
method: Method,
|
||||||
|
pattern: Pattern,
|
||||||
|
handler: Arc<dyn Plug>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Router {
|
||||||
|
routes: Vec<Route>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Router {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { routes: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(mut self, method: Method, path: &str, plug: impl Plug) -> Self {
|
||||||
|
self.routes.push(Route {
|
||||||
|
method,
|
||||||
|
pattern: Pattern::compile(path),
|
||||||
|
handler: Arc::new(plug),
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Get, path, plug) }
|
||||||
|
pub fn post(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Post, path, plug) }
|
||||||
|
pub fn put(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Put, path, plug) }
|
||||||
|
pub fn delete(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Delete, path, plug) }
|
||||||
|
pub fn patch(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Patch, path, plug) }
|
||||||
|
pub fn head(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Head, path, plug) }
|
||||||
|
pub fn options(self, path: &str, plug: impl Plug) -> Self { self.add(Method::Options, path, plug) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Router {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plug for Router {
|
||||||
|
fn call(&self, conn: Conn, next: Next) -> Conn {
|
||||||
|
// Two-pass dispatch: first match on method+path; if no method-and-
|
||||||
|
// path matches but a same-path-different-method does, return 405.
|
||||||
|
// Otherwise pass through to `next` so outer pipelines can layer a
|
||||||
|
// 404 handler (or skip and let the connection actor emit a default).
|
||||||
|
let mut path_seen = false;
|
||||||
|
for route in &self.routes {
|
||||||
|
if let Some(params) = route.pattern.match_path(&conn.path) {
|
||||||
|
if route.method == conn.method {
|
||||||
|
let conn = conn.put_params(params);
|
||||||
|
return route.handler.call(conn, next);
|
||||||
|
}
|
||||||
|
path_seen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path_seen {
|
||||||
|
// Path is known, method isn't — RFC 7231 §6.5.5.
|
||||||
|
conn.put_status(405)
|
||||||
|
.put_header("content-length", "0")
|
||||||
|
.halt()
|
||||||
|
} else {
|
||||||
|
// No route matched at all; let outer plugs / default handler
|
||||||
|
// deal with it.
|
||||||
|
next.run(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::plug::Pipeline;
|
||||||
|
|
||||||
|
fn ok_handler(_label: &'static str) -> impl Plug {
|
||||||
|
move |conn: Conn, _next: Next| conn.put_status(200).put_body(_label)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn literal_match() {
|
||||||
|
let r = Router::new().get("/ping", ok_handler("pong"));
|
||||||
|
let mut c = Conn::new();
|
||||||
|
c.method = Method::Get;
|
||||||
|
c.path = "/ping".into();
|
||||||
|
let out = Pipeline::new().plug(r).run(c);
|
||||||
|
assert_eq!(out.status, Some(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn param_match() {
|
||||||
|
let r = Router::new().get("/users/:id", |conn: Conn, _n: Next| {
|
||||||
|
let id = conn.params.get("id").unwrap_or("").to_string();
|
||||||
|
conn.put_status(200).put_body(id)
|
||||||
|
});
|
||||||
|
let mut c = Conn::new();
|
||||||
|
c.method = Method::Get;
|
||||||
|
c.path = "/users/42".into();
|
||||||
|
let out = Pipeline::new().plug(r).run(c);
|
||||||
|
assert_eq!(out.status, Some(200));
|
||||||
|
match &out.resp_body {
|
||||||
|
crate::conn::RespBody::Bytes(b) => assert_eq!(b, b"42"),
|
||||||
|
_ => panic!("expected bytes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn method_mismatch_yields_405() {
|
||||||
|
let r = Router::new().get("/users", ok_handler("list"));
|
||||||
|
let mut c = Conn::new();
|
||||||
|
c.method = Method::Post;
|
||||||
|
c.path = "/users".into();
|
||||||
|
let out = Pipeline::new().plug(r).run(c);
|
||||||
|
assert_eq!(out.status, Some(405));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_route_falls_through() {
|
||||||
|
// Router returns conn unchanged; next plug (a 404 default) handles it.
|
||||||
|
let r = Router::new().get("/users", ok_handler("list"));
|
||||||
|
let default_404 = |conn: Conn, _n: Next|
|
||||||
|
conn.put_status(404).put_body("not found");
|
||||||
|
let mut c = Conn::new();
|
||||||
|
c.method = Method::Get;
|
||||||
|
c.path = "/nowhere".into();
|
||||||
|
let out = Pipeline::new().plug(r).plug(default_404).run(c);
|
||||||
|
assert_eq!(out.status, Some(404));
|
||||||
|
}
|
||||||
|
}
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
//! Listener pool and the `serve` entry point.
|
||||||
|
//!
|
||||||
|
//! A small fixed pool of listener actors share the same TCP listen fd (via
|
||||||
|
//! `dup`) — each blocks in non-blocking `accept4` + `wait_readable` on its
|
||||||
|
//! own copy. When a connection arrives the listener spawns a connection
|
||||||
|
//! actor with the `OwnedFd` and immediately returns to `accept`. No
|
||||||
|
//! coordination needed; the kernel serialises `accept` calls across the fds.
|
||||||
|
//!
|
||||||
|
//! Sharing via `dup` rather than the same fd is deliberate — Linux's
|
||||||
|
//! `accept4` is thread-safe on a single fd, but dup'ing per-listener keeps
|
||||||
|
//! each actor's epoll registration local to its own RawFd value (so smarm's
|
||||||
|
//! `waiters: HashMap<RawFd, Pid>` doesn't see collisions between listeners
|
||||||
|
//! waiting on "the same fd").
|
||||||
|
|
||||||
|
use crate::conn_actor::{run_connection, ConnLimits};
|
||||||
|
use crate::net::{accept_nonblocking, bind_and_listen, OwnedFd};
|
||||||
|
use crate::plug::Pipeline;
|
||||||
|
|
||||||
|
use std::io::{self, ErrorKind};
|
||||||
|
use std::net::{SocketAddr, ToSocketAddrs};
|
||||||
|
use std::os::fd::RawFd;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
pub listener_pool: usize,
|
||||||
|
pub keep_alive_timeout: Duration,
|
||||||
|
pub max_header_count: usize,
|
||||||
|
pub read_buf_size: usize,
|
||||||
|
pub request_timeout: Duration,
|
||||||
|
pub max_body_bytes: usize,
|
||||||
|
/// Number of smarm scheduler OS threads. `None` means smarm's default
|
||||||
|
/// (one per CPU). Set this to a small fixed number in tests so multiple
|
||||||
|
/// concurrent test servers don't oversubscribe the host.
|
||||||
|
pub scheduler_threads: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn new(addr: SocketAddr) -> Self {
|
||||||
|
let pool = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(2)
|
||||||
|
.max(2);
|
||||||
|
Self {
|
||||||
|
addr,
|
||||||
|
listener_pool: pool,
|
||||||
|
keep_alive_timeout: Duration::from_secs(60),
|
||||||
|
max_header_count: 64,
|
||||||
|
read_buf_size: 8 * 1024,
|
||||||
|
request_timeout: Duration::from_secs(30),
|
||||||
|
max_body_bytes: 16 * 1024 * 1024,
|
||||||
|
scheduler_threads: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_conn_limits(&self) -> ConnLimits {
|
||||||
|
ConnLimits {
|
||||||
|
max_headers: self.max_header_count,
|
||||||
|
initial_read_buf: self.read_buf_size,
|
||||||
|
max_head_bytes: 64 * 1024,
|
||||||
|
max_body_bytes: self.max_body_bytes,
|
||||||
|
keep_alive_timeout: self.keep_alive_timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// dup helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn dup_fd(fd: RawFd) -> io::Result<OwnedFd> {
|
||||||
|
let new_fd = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||||
|
if new_fd < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(OwnedFd::from_raw(new_fd))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// listener actor body
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn listener_loop(listener: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||||
|
let fd = listener.as_raw();
|
||||||
|
loop {
|
||||||
|
match accept_nonblocking(fd) {
|
||||||
|
Ok(client) => {
|
||||||
|
// Hand the fd off to a new connection actor. spawn() is
|
||||||
|
// cheap on smarm — it's a single Vec push under the
|
||||||
|
// shared lock.
|
||||||
|
let p = pipeline.clone();
|
||||||
|
let l = limits;
|
||||||
|
smarm::spawn(move || run_connection(client, p, l));
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == ErrorKind::WouldBlock => {
|
||||||
|
// No pending connection. Park until the listener is
|
||||||
|
// readable again, then retry.
|
||||||
|
if let Err(we) = smarm::wait_readable(fd) {
|
||||||
|
// epoll registration failed — fatal for this listener.
|
||||||
|
eprintln!("urus: listener wait_readable failed: {we}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == ErrorKind::Interrupted => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// EMFILE / ENFILE / ECONNABORTED etc. Log and continue;
|
||||||
|
// the system may recover.
|
||||||
|
eprintln!("urus: accept error: {e}");
|
||||||
|
// Small backoff via smarm's sleep to avoid spinning if
|
||||||
|
// the error is sticky.
|
||||||
|
smarm::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// listener OwnedFd drops here, closing the dup'd fd.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// serve_with — main entry. Boots smarm, spawns listeners, blocks.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Boots an smarm runtime (one OS thread per CPU by default — see smarm's
|
||||||
|
// `Config::default()`) and runs until externally killed. We don't wire a
|
||||||
|
// graceful-shutdown signal in v1; the runtime exits when all actors exit,
|
||||||
|
// which they don't (listener loops are infinite). Ctrl-C is your friend.
|
||||||
|
|
||||||
|
pub fn serve_with(config: Config, pipeline: Pipeline) -> io::Result<()> {
|
||||||
|
let listener = bind_and_listen(config.addr)?;
|
||||||
|
println!("urus: listening on {}", config.addr);
|
||||||
|
|
||||||
|
// We want one connection-actor-spawning loop per listener pool slot.
|
||||||
|
// Each gets its own dup'd fd so epoll registrations don't collide.
|
||||||
|
let mut listener_fds = Vec::with_capacity(config.listener_pool);
|
||||||
|
listener_fds.push(listener); // primary keeps the original
|
||||||
|
|
||||||
|
for _ in 1..config.listener_pool {
|
||||||
|
let dup = dup_fd(listener_fds[0].as_raw())?;
|
||||||
|
listener_fds.push(dup);
|
||||||
|
}
|
||||||
|
|
||||||
|
let limits = config.to_conn_limits();
|
||||||
|
|
||||||
|
// smarm's runtime API: init(Config) then run(f). The closure is the
|
||||||
|
// root actor; from there we spawn one listener per fd in the pool.
|
||||||
|
let smarm_cfg = match config.scheduler_threads {
|
||||||
|
Some(n) => smarm::Config::exact(n),
|
||||||
|
None => smarm::Config::default(),
|
||||||
|
};
|
||||||
|
let rt = smarm::init(smarm_cfg);
|
||||||
|
rt.run(move || {
|
||||||
|
let n = listener_fds.len();
|
||||||
|
let mut handles = Vec::with_capacity(n);
|
||||||
|
for (i, lfd) in listener_fds.into_iter().enumerate() {
|
||||||
|
let p = pipeline.clone();
|
||||||
|
let h = smarm::spawn(move || {
|
||||||
|
println!("urus: listener {} starting", i);
|
||||||
|
listener_loop(lfd, p, limits);
|
||||||
|
});
|
||||||
|
handles.push(h);
|
||||||
|
}
|
||||||
|
// Block forever (until ctrl-C) by joining the listeners. They
|
||||||
|
// never exit on their own in v1.
|
||||||
|
for h in handles {
|
||||||
|
let _ = h.join();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// serve — convenience over serve_with.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn serve(addr: impl ToSocketAddrs, pipeline: Pipeline) -> io::Result<()> {
|
||||||
|
let addr = addr
|
||||||
|
.to_socket_addrs()?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "no addresses resolved"))?;
|
||||||
|
serve_with(Config::new(addr), pipeline)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! End-to-end integration tests.
|
||||||
|
//!
|
||||||
|
//! Each test spins up the server in a background OS thread on an
|
||||||
|
//! ephemeral port, opens a regular `std::net::TcpStream` against it, and
|
||||||
|
//! exercises the wire. Single thread for the smarm runtime per test so
|
||||||
|
//! teardown happens cleanly when the test thread is dropped (the process
|
||||||
|
//! exits at the end of the test binary).
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use urus::{serve_with, Conn, Config, Next, Pipeline, Router};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reserve a free localhost port by binding briefly and reading back the
|
||||||
|
/// assigned port number. The port is released the instant we drop the
|
||||||
|
/// listener; there's a tiny race window before the urus server claims it
|
||||||
|
/// but it's well below the threshold for flakiness in practice.
|
||||||
|
fn free_port() -> u16 {
|
||||||
|
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
l.local_addr().unwrap().port()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_server(pipeline: Pipeline) -> u16 {
|
||||||
|
let port = free_port();
|
||||||
|
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let cfg = Config {
|
||||||
|
listener_pool: 2,
|
||||||
|
scheduler_threads: Some(2),
|
||||||
|
..Config::new(addr)
|
||||||
|
};
|
||||||
|
serve_with(cfg, pipeline).unwrap();
|
||||||
|
});
|
||||||
|
// Wait for the server to actually be listening.
|
||||||
|
for _ in 0..50 {
|
||||||
|
if TcpStream::connect(addr).is_ok() {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
panic!("server didn't come up on {addr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_request(port: u16, req: &[u8]) -> Vec<u8> {
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
|
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||||
|
s.write_all(req).unwrap();
|
||||||
|
s.shutdown(std::net::Shutdown::Write).ok();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
s.read_to_end(&mut buf).unwrap();
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_status(resp: &[u8]) -> u16 {
|
||||||
|
let s = std::str::from_utf8(&resp[..resp.len().min(64)]).unwrap();
|
||||||
|
let parts: Vec<&str> = s.splitn(3, ' ').collect();
|
||||||
|
parts[1].parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_body(resp: &[u8]) -> &[u8] {
|
||||||
|
// Split on the first \r\n\r\n.
|
||||||
|
for i in 0..resp.len().saturating_sub(3) {
|
||||||
|
if &resp[i..i + 4] == b"\r\n\r\n" {
|
||||||
|
return &resp[i + 4..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_world() {
|
||||||
|
let pipe = Pipeline::new().plug(
|
||||||
|
Router::new().get("/", |c: Conn, _n: Next| {
|
||||||
|
c.put_status(200).put_body("hello urus")
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let port = spawn_server(pipe);
|
||||||
|
let resp = send_request(port, b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
|
||||||
|
assert_eq!(http_status(&resp), 200);
|
||||||
|
assert_eq!(http_body(&resp), b"hello urus");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn echo_body() {
|
||||||
|
let pipe = Pipeline::new().plug(
|
||||||
|
Router::new().post("/echo", |c: Conn, _n: Next| {
|
||||||
|
let body = c.body.as_bytes().to_vec();
|
||||||
|
c.put_status(200).put_body(body)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let port = spawn_server(pipe);
|
||||||
|
let resp = send_request(
|
||||||
|
port,
|
||||||
|
b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello",
|
||||||
|
);
|
||||||
|
assert_eq!(http_status(&resp), 200);
|
||||||
|
assert_eq!(http_body(&resp), b"hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_params() {
|
||||||
|
let pipe = Pipeline::new().plug(
|
||||||
|
Router::new().get("/users/:id", |c: Conn, _n: Next| {
|
||||||
|
let id = c.params.get("id").unwrap_or("?").to_string();
|
||||||
|
c.put_status(200).put_body(format!("user={id}"))
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let port = spawn_server(pipe);
|
||||||
|
let resp = send_request(port, b"GET /users/42 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
|
||||||
|
assert_eq!(http_status(&resp), 200);
|
||||||
|
assert_eq!(http_body(&resp), b"user=42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn method_not_allowed() {
|
||||||
|
let pipe = Pipeline::new().plug(
|
||||||
|
Router::new().get("/only-get", |c: Conn, _n: Next| c.put_status(200))
|
||||||
|
);
|
||||||
|
let port = spawn_server(pipe);
|
||||||
|
let resp = send_request(port, b"POST /only-get HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
|
||||||
|
assert_eq!(http_status(&resp), 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_route_falls_to_404() {
|
||||||
|
// Router falls through; connection actor's default emits 404.
|
||||||
|
let pipe = Pipeline::new().plug(
|
||||||
|
Router::new().get("/known", |c: Conn, _n: Next| c.put_status(200))
|
||||||
|
);
|
||||||
|
let port = spawn_server(pipe);
|
||||||
|
let resp = send_request(port, b"GET /missing HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
|
||||||
|
assert_eq!(http_status(&resp), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_alive_two_requests_on_one_connection() {
|
||||||
|
let pipe = Pipeline::new().plug(
|
||||||
|
Router::new().get("/", |c: Conn, _n: Next| c.put_status(200).put_body("ok"))
|
||||||
|
);
|
||||||
|
let port = spawn_server(pipe);
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
|
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||||
|
|
||||||
|
// First request, no Connection header (HTTP/1.1 default = keep-alive).
|
||||||
|
s.write_all(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
|
||||||
|
|
||||||
|
// Read the first response (Content-Length: 2 -> body "ok").
|
||||||
|
let mut buf = vec![0u8; 1024];
|
||||||
|
let mut total = 0;
|
||||||
|
let mut first_end = None;
|
||||||
|
while first_end.is_none() {
|
||||||
|
let n = s.read(&mut buf[total..]).unwrap();
|
||||||
|
assert!(n > 0, "server hung up early");
|
||||||
|
total += n;
|
||||||
|
// First response ends at header-terminator + 2 bytes of body.
|
||||||
|
for i in 0..total.saturating_sub(3) {
|
||||||
|
if &buf[i..i + 4] == b"\r\n\r\n" {
|
||||||
|
let body_start = i + 4;
|
||||||
|
if total >= body_start + 2 {
|
||||||
|
first_end = Some(body_start + 2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let first = &buf[..first_end.unwrap()];
|
||||||
|
assert_eq!(http_status(first), 200);
|
||||||
|
assert_eq!(http_body(first), b"ok");
|
||||||
|
|
||||||
|
// Second request on the same socket — proves keep-alive works.
|
||||||
|
s.write_all(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n").unwrap();
|
||||||
|
let mut rest = Vec::new();
|
||||||
|
s.read_to_end(&mut rest).unwrap();
|
||||||
|
assert_eq!(http_status(&rest), 200);
|
||||||
|
assert_eq!(http_body(&rest), b"ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panicking_handler_yields_500() {
|
||||||
|
let pipe = Pipeline::new().plug(
|
||||||
|
Router::new().get("/boom", |_c: Conn, _n: Next| -> Conn {
|
||||||
|
panic!("handler explosion")
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let port = spawn_server(pipe);
|
||||||
|
let resp = send_request(port, b"GET /boom HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
|
||||||
|
assert_eq!(http_status(&resp), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn middleware_can_short_circuit() {
|
||||||
|
// Middleware that requires `X-Auth: secret`; otherwise returns 401
|
||||||
|
// without calling `next.run(conn)`.
|
||||||
|
let auth = |c: Conn, n: Next| {
|
||||||
|
if c.headers.get("x-auth").map(|s| s == "secret").unwrap_or(false) {
|
||||||
|
n.run(c)
|
||||||
|
} else {
|
||||||
|
c.put_status(401).halt()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pipe = Pipeline::new()
|
||||||
|
.plug(auth)
|
||||||
|
.plug(Router::new().get("/secret", |c: Conn, _n: Next| c.put_status(200).put_body("ok")));
|
||||||
|
|
||||||
|
let port = spawn_server(pipe);
|
||||||
|
|
||||||
|
// Without auth → 401.
|
||||||
|
let resp = send_request(port, b"GET /secret HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
|
||||||
|
assert_eq!(http_status(&resp), 401);
|
||||||
|
|
||||||
|
// With auth → 200 + body.
|
||||||
|
let resp = send_request(
|
||||||
|
port,
|
||||||
|
b"GET /secret HTTP/1.1\r\nHost: x\r\nX-Auth: secret\r\nConnection: close\r\n\r\n",
|
||||||
|
);
|
||||||
|
assert_eq!(http_status(&resp), 200);
|
||||||
|
assert_eq!(http_body(&resp), b"ok");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user