A small, strongly typed HTTP/WebSocket server toolkit for Rust, built on hyper 1.x.
screw is not a "batteries included" framework — it is a thin, explicit layer over hyper that gives you:
- a router with actix-style path patterns, method matching, and query/path extraction;
- middlewares that can change the request and response types as the chain nests, so a handler's signature says exactly what it receives;
- JSON/XML API layers that turn a raw
hyper::Requestinto a typed request struct and a typed response enum into a serialized HTTP response; - a WebSocket layer with typed send/receive channels over the same routing and middleware machinery.
Everything is opt-in via Cargo features, and every stage of construction is a distinct type, so a half-built router will not compile.
| Crate | What it is |
|---|---|
screw-core |
Router, routes, middleware trait, request/response types, hyper service glue. The only crate you always need. |
screw-api |
Typed request/response API layer with JSON and XML converters, plus typed WebSocket channels. |
screw-ws |
WebSocket upgrade handling as a middleware, generic over the stream type. |
screw-components |
Tiny shared aliases: DFn, DFnOnce, DFuture, DResult, DError. |
The crates are consumed from git (or as path dependencies inside this workspace):
[dependencies]
screw-core = { git = "https://github.com/tikitko/screw" }
screw-api = { git = "https://github.com/tikitko/screw", features = ["json"] }
screw-components = { git = "https://github.com/tikitko/screw" }
hyper = { version = "1", features = ["full"] }
hyper-util = { version = "0.1", features = ["tokio"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }The crates carry no version field — versioning is done through git, so pin a tag, branch, or rev when you need a fixed state:
screw-core = { git = "https://github.com/tikitko/screw", rev = "…" }screw does not pick a runtime or an accept loop for you — you bring your own tokio listener and hand each connection to a SessionService.
| Feature | Enables |
|---|---|
json |
JsonApiMiddlewareConverter — JSON request parsing and response serialization. |
xml |
XmlApiMiddlewareConverter — the same, over quick-xml. |
ws |
channel::ApiChannel, used with screw-ws. |
No features are on by default. The stream converters that bridge the two worlds — json::JsonApiStreamConverter and xml::XmlApiWebSocketConverter — need ws plus the matching format feature.
Four runnable servers live in the workspace. Each listens on 127.0.0.1:8080 and its header comment lists the curl commands to try.
| Example | Run it | Shows |
|---|---|---|
hello |
cargo run -p screw-core --example hello |
The router on its own — path params, query, Extensions, a function middleware, 404 vs 405. |
json_api |
cargo run -p screw-api --example json_api --features json |
Typed JSON requests and responses, per-variant status codes, malformed bodies, Infallible failures, body-size limits. |
typed_middleware |
cargo run -p screw-api --example typed_middleware --features json |
Middlewares that change the request type: ApiRequest → Authed → AdminOnly, with short-circuited 401/403, and both handler signature styles. |
websocket_chat |
cargo run -p screw-api --example websocket_chat --features json,ws |
The WebSocket upgrade as a middleware and a typed ApiChannel; open the served page in two tabs to chat. |
A complete JSON echo endpoint at POST /api/echo/{id}:
use hyper::{Method, StatusCode};
use hyper_util::rt::TokioIo;
use screw_api::json::JsonApiMiddlewareConverter;
use screw_api::request::{ApiRequest, ApiRequestContent, ApiRequestOriginContent};
use screw_api::response::{
ApiResponse, ApiResponseContentBase, ApiResponseContentFailure, ApiResponseContentSuccess,
};
use screw_components::dyn_result::DResult;
use screw_core::request::Request;
use screw_core::response::Response;
use screw_core::responder_factory::ResponderFactory;
use screw_core::routing::route::first::Route;
use screw_core::routing::router::{self, RoutedRequest};
use screw_core::server::ServerService;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
// Shared application state, available to every handler.
struct Extensions {
greeting: String,
}
// ---- request ----
#[derive(Deserialize)]
struct EchoData {
message: String,
}
// What the handler actually gets. You decide which parts of the raw
// request are worth carrying, and in what shape.
struct EchoRequestContent {
id: String,
data: DResult<EchoData>,
greeting: String,
}
impl ApiRequestContent<Extensions, EchoFailure> for EchoRequestContent {
type Data = EchoData;
async fn create(
origin: ApiRequestOriginContent<Self::Data, Extensions>,
) -> Result<Self, EchoFailure> {
Ok(Self {
id: origin.path.get("id").unwrap_or_default().to_owned(),
data: origin.data_result,
greeting: origin.extensions.greeting.clone(),
})
}
}
// ---- response ----
#[derive(Serialize)]
struct EchoPayload {
id: String,
message: String,
}
enum EchoSuccess {
Echoed(EchoPayload),
}
impl ApiResponseContentBase for EchoSuccess {
fn status_code(&self) -> StatusCode {
StatusCode::OK
}
}
impl ApiResponseContentSuccess for EchoSuccess {
type Data = EchoPayload;
fn identifier(&self) -> &'static str {
"ECHOED"
}
fn description(&self) -> Option<String> {
Some("Message echoed back".to_owned())
}
fn data(&self) -> &Self::Data {
let Self::Echoed(payload) = self;
payload
}
}
enum EchoFailure {
BadRequest,
}
impl ApiResponseContentBase for EchoFailure {
fn status_code(&self) -> StatusCode {
StatusCode::BAD_REQUEST
}
}
impl ApiResponseContentFailure for EchoFailure {
fn identifier(&self) -> &'static str {
"BAD_REQUEST"
}
fn reason(&self) -> Option<String> {
Some("Body is not a valid echo request".to_owned())
}
}
// ---- handlers ----
async fn echo(
request: ApiRequest<EchoRequestContent, Extensions>,
) -> ApiResponse<EchoSuccess, EchoFailure> {
let content = request.content;
let Ok(data) = content.data else {
return ApiResponse::failure(EchoFailure::BadRequest);
};
ApiResponse::success(EchoSuccess::Echoed(EchoPayload {
id: content.id,
message: format!("{}, {}", content.greeting, data.message),
}))
}
// Runs when nothing matched. `allowed_methods` is non-empty when the path
// matched but the method did not, which is exactly a 405 with `Allow`.
async fn fallback(request: RoutedRequest<Request<Extensions>>) -> Response {
let mut builder = hyper::Response::builder();
if request.allowed_methods.is_empty() {
builder = builder.status(StatusCode::NOT_FOUND);
} else {
let allow = request
.allowed_methods
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(", ");
builder = builder
.status(StatusCode::METHOD_NOT_ALLOWED)
.header(hyper::header::ALLOW, allow);
}
Response {
http: builder.body(screw_core::body::empty()).unwrap(),
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let router = router::first::Router::with_fallback_handler(fallback).and_routes(|r| {
r.scoped_middleware("/api", JsonApiMiddlewareConverter::default(), |r| {
r.route(
Route::with_method(&Method::POST)
.and_path("/echo/{id}")
.and_handler(echo),
)
})
});
let responder_factory = ResponderFactory::with_router(router).and_extensions(Extensions {
greeting: "hello".to_owned(),
});
let server_service = Arc::new(ServerService::with_responder_factory(responder_factory));
let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 8080))).await?;
loop {
let (stream, remote_addr) = listener.accept().await?;
let session_service = server_service.make_session_service(remote_addr);
tokio::task::spawn(async move {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), session_service)
.with_upgrades() // only needed if you also serve WebSockets
.await;
});
}
}curl -s -X POST localhost:8080/api/echo/42 -H 'Content-Type: application/json' -d '{"message":"world"}'{"success":{"identifier":"ECHOED","description":"Message echoed back","data":{"id":"42","message":"hello, world"}}}A handler returning a failure produces the other variant, with that failure's status code:
{"failure":{"identifier":"BAD_REQUEST","reason":"Body is not a valid echo request"}}TcpListener
└── ServerService — one per process, holds the router + extensions
└── SessionService — one per connection, implements hyper::service::Service
└── Responder — knows the remote address
└── Router — matches method + path, builds RoutedRequest
└── middleware chain (outermost → innermost)
└── handler
Types are built in named stages (first, second, third), each one returning the next. You cannot skip a step or reorder it:
Route::with_method(&Method::GET) // route::first::Route
.and_path("/post/{id}") // route::second::Route
.and_handler(handler); // route::third::Route
router::first::Router::with_fallback_handler(fallback)
.and_routes(|r| r); // router::second::Router
ResponderFactory::with_router(router)
.and_extensions(extensions); // ready for ServerService- Path patterns come from
actix-router:/post/{id},/files/{path}*, and so on. Matched segments are read viapath.get("id"). Route::with_method,with_methods, andwith_any_methodcontrol method matching;with_any_methodmatches every method.- The query string arrives as
RoutedRequest::query, aQuery. It is parsed on the first call that needs the pairs --get,iter,is_emptyoras_map-- and the result is cached, so a handler that ignores the query does not pay for parsing it. Repeated keys keep the last value. - When two registered patterns match the same request, the one registered first wins.
- Patterns are indexed by the literal segments they start with, so a request is only matched against the patterns that could possibly match it. A table of patterns starting with distinct literals therefore costs the same to search however large it grows. The index is only as good as those literals: patterns that start with a dynamic segment —
/{tenant}/dashboard— all share one bucket and are still scanned in order, so a table made mostly of those is back to a linear scan. - When a path matches but the method does not, the fallback handler runs with
allowed_methodsfilled in from every route whose pattern matches that path — the complete set for anAllowheader, deduplicated and in registration order. Every matching pattern gets a say, since a route further down the table may be the only one serving the method the client should have used. For an unknown path,allowed_methodsis empty. - Percent-escapes in the path are decoded, except
%2F,%2Band%25, which stay encoded so an escaped slash cannot forge an extra path segment. A malformed escape such as%zzis passed through as written; one that decodes to invalid UTF-8 becomesU+FFFD, so/post/%FFand/post/%FEreach a handler as the same path. Neither empties the path.
Routes is built with a closure per nesting level. scoped adds a path prefix, middleware adds a middleware, and scoped_middleware does both:
.and_routes(|r| {
r.scoped_middleware("/api", JsonApiMiddlewareConverter::default(), |r| {
r.middleware(Auth, |r| {
r.route(/* … */)
})
.scoped("/v2", |r| {
r.route(/* … */)
})
})
})A middleware implements Middleware<Rq, Rs>, where Rq/Rs are what it hands inward and the associated Request/Response types are what it accepts from outside:
pub trait Middleware<Rq, Rs> {
type Request;
type Response;
fn respond(
&self,
request: Self::Request,
next: DFnOnce<Rq, Rs>,
) -> impl Future<Output = Self::Response> + Send;
}Because the inward and outward types are independent, each nesting level can change them. That is how JsonApiMiddlewareConverter turns a RoutedRequest<Request<Extensions>> into a typed ApiRequest<Content, Extensions> — and how your own middleware can, say, wrap an ApiRequest into an Authed<…> that only authenticated handlers accept:
impl<Content, Ext, Success, Failure>
Middleware<Authed<Content, Ext>, ApiResponse<Success, Failure>> for Auth
where /* … */
{
type Request = ApiRequest<Content, Ext>;
type Response = ApiResponse<Success, Failure>;
async fn respond(
&self,
request: ApiRequest<Content, Ext>,
next: DFnOnce<Authed<Content, Ext>, ApiResponse<Success, Failure>>,
) -> ApiResponse<Success, Failure> {
next(Authed { request }).await
}
}Middlewares nest arbitrarily deep, and each is entered outermost-first and left innermost-first. An async fn(Rq, DFnOnce<Rq, Rs>) -> Rs is also a Middleware when it does not change types, so simple logging or timing layers need no struct:
async fn logging(request: Mid, next: DFnOnce<Mid, MidRs>) -> MidRs {
next(request).await
}Extensions is your own type, threaded through the whole stack as Arc<Extensions> and reachable from ApiRequestOriginContent::extensions (or Request::extensions at the core level). Put your database pool, config, and clients there. It is shared, so interior mutability is up to you.
ApiRequestContent<Extensions, Failure> is the bridge from a raw request to your handler's argument. Its create gets everything the server knows:
pub struct ApiRequestOriginContent<Data, Extensions> {
pub path: Path<String>,
pub query: Query,
pub http_parts: Parts,
pub remote_addr: SocketAddr,
pub extensions: Arc<Extensions>,
pub data_result: DResult<Data>,
}Body parsing is not fatal: data_result is a DResult<Data>, so a bad body reaches you as an Err and you decide the response — carry it into the handler, or map_err it into a failure right in create. () implements ApiRequestContent, which is convenient for endpoints that ignore the request entirely.
create returns Result<Self, Failure>, and Failure is the failure half of the response the handler answers with, so a content that cannot be built — an id that will not parse, a missing header, a token nothing knows about — refuses the request there and then: the handler is never called, and the client sees an ordinary failure response. It is async, so that decision may involve a lookup and is not limited to what the request itself carries.
A content written for one endpoint just names that endpoint's failure type; one meant to be reused stays generic over it, adding From<…> if it has an error of its own to convert:
impl<Extensions, Failure> ApiRequestContent<Extensions, Failure> for Authed
where
Extensions: SessionStore + Send + Sync,
Failure: ApiResponseContentFailure + From<AuthError>,
{
type Data = ();
async fn create(
origin: ApiRequestOriginContent<Self::Data, Extensions>,
) -> Result<Self, Failure> {
let token = bearer_token(&origin.http_parts).ok_or(AuthError::Missing)?;
let user = origin.extensions.user_for(token).await.ok_or(AuthError::Unknown)?;
Ok(Self { user })
}
}On the way out, ApiResponse<Success, Failure> is an enum of two content traits:
ApiResponseContentSuccess—identifier,description, and a serializabledata;ApiResponseContentFailure—identifierandreason;- both extend
ApiResponseContentBase, which supplies the HTTP status code.
Infallible implements all three, so ApiResponse<Success, Infallible> is the type for an endpoint that cannot fail.
Serialization failures never leak: if the response cannot be written, the middleware answers 500 with an empty body.
Both API converters, before deserializing:
- require a matching
Content-Type(application/json/application/xml), comparing the media type case-insensitively and ignoring parameters like; charset=utf-8; a missing or empty header is rejected; - cap the body at
max_body_size, defaulting toDEFAULT_MAX_BODY_SIZE(2 MiB), so an unbounded upload cannot exhaust memory.
Both are plain public fields, so you can raise or lower them per scope:
JsonApiMiddlewareConverter {
pretty_printed: false,
max_body_size: 512 * 1024,
}Every rejection above lands in data_result as an Err, which your ApiRequestContent can inspect. A bad body never fails the request on its own — the handler decides what it means for that endpoint.
Two more guarantees hold below the API layer, for any route:
- a panic in a handler or middleware is caught and answered as
500, leaving the connection usable. Without this a panic takes down the whole connection, including any other in-flight requests on it. The default panic hook still prints the message and backtrace; - registering two routes with the same pattern and the same method panics when the router is built — at startup, not per request — as does any route registered after a
with_any_methodroute on that pattern. Overlap between different patterns stays legal and resolves in registration order.
The XML layer is the same shape: swap JsonApiMiddlewareConverter for XmlApiMiddlewareConverter and nothing else in the example changes, since Serialize/Deserialize do the work either way.
WebSocketMiddlewareConverter performs the upgrade handshake and is generic over a stream converter, which decides what the handler's stream looks like. Pairing it with screw-api's JsonApiStreamConverter yields an ApiChannel<Send, Receive> — a typed sender and receiver of JSON messages:
use screw_api::channel::ApiChannel;
use screw_api::json::JsonApiStreamConverter;
use screw_ws::{
WebSocketContent, WebSocketMiddlewareConverter, WebSocketOriginContent, WebSocketRequest,
WebSocketResponse,
};
#[derive(Deserialize)]
struct Incoming { text: String }
#[derive(Serialize)]
struct Outgoing { text: String }
struct ChatContent { room: String }
impl WebSocketContent<Extensions> for ChatContent {
fn create(origin: WebSocketOriginContent<Extensions>) -> Self {
Self { room: origin.path.get("room").unwrap_or_default().to_owned() }
}
}
async fn chat(
request: WebSocketRequest<ChatContent, ApiChannel<Outgoing, Incoming>, Extensions>,
) -> WebSocketResponse {
let (content, upgrade) = request.split();
// The handler returns immediately; this closure runs after the upgrade completes.
upgrade.on(move |mut channel| async move {
while let Ok(message) = channel.receiver.receive().await {
let echoed = Outgoing { text: format!("[{}] {}", content.room, message.text) };
if channel.sender.send(echoed).await.is_err() {
break;
}
}
})
}Registered like any other route:
r.scoped_middleware(
"/ws",
WebSocketMiddlewareConverter::with_stream_converter(JsonApiStreamConverter {
pretty_printed: false,
})
.and_config(None), // Option<WebSocketConfig> from tokio-tungstenite
|r| {
r.route(
Route::with_method(&Method::GET)
.and_path("/chat/{room}")
.and_handler(chat),
)
},
)Notes:
- Serve connections with
.with_upgrades(), or the handshake will succeed and the upgrade will never arrive. - The middleware validates the handshake before anything else:
GET, HTTP/1.1 or newer,Connection: Upgrade,Upgrade: websocket,Sec-WebSocket-Version: 13, and aSec-WebSocket-Key. Any violation gets400, except a non-GETrequest, which gets405withAllow: GET— though a route registered asGET-only never reaches the middleware for other methods, since the router sends those to the fallback first. ApiChannelReceiver::receivereports control and binary frames asUnsupportedMessageand a close frame asClosed, so the loop above ends when the peer disconnects.WebSocketStreamConverteris a public trait — implement it for a protocol that is not JSON or XML, and the rest of the machinery is unchanged.
cargo build --all-featurescargo test --all-featurescargo test --all-features also compiles every example, so they cannot drift from the crates.
CI additionally enforces formatting and clippy -D warnings, and builds screw-api under each feature combination separately — no feature is on by default, so --all-features alone would leave most of them never compiled.
MIT — see LICENSE.