ServerKit
ServerKit is a portable Rust HTTP router with an Ohkami-inspired routing API.
The core stays runtime-independent; serverkit-hyper provides native HTTP/1.0,
HTTP/1.1, and HTTP/2 serving while serverkit-worker connects the same Router,
routes, handlers, and extractors to Cloudflare Workers.
Installation
[]
= { = "0.3", = ["json", "websocket"] }
= { = "1", = ["derive"] }
= { = "0.3", = ["tokio", "websocket"] }
= { = "1", = ["net", "rt"] }
# Use these instead of serverkit-hyper on Cloudflare Workers.
= { = "0.3", = ["websocket"] }
= "0.8.5"
The json and websocket features are optional. Each runtime adapter keeps its
runtime dependencies out of the serverkit core crate.
Complete native server
The same Schema derive decodes and validates path parameters, query
parameters, and headers by name.
use *;
async
async
The tokio driver automatically detects HTTP/1.0, HTTP/1.1, and HTTP/2 after
accepting a connection. It uses the caller's Tokio runtime and listener rather
than creating either one. TLS and HTTP/3 are separate transport concerns and
are not provided by this adapter.
Router::new accepts one route or a convenience tuple. .route() can then be
called any number of times, so the number of routes in a router is not
bounded by tuple arity. Handler functions may have zero through sixteen
extractor arguments. Metadata and buffered extractors may appear in any order.
A streaming extractor such as Body or Multipart, when present, must be the
final argument.
use ;
async
async
let router = new
.route;
HTTP methods
Routes support GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS,
CONNECT, and TRACE. The same path can register a different handler for each
method. These methods are also available as allocation-free Method constants;
other registered or custom methods retain their exact name through
Method::from_bytes or str::parse.
use ;
async
async
Method names use the HTTP token grammar, are case-sensitive, and reject empty,
non-ASCII, whitespace, or separator-containing values. CONNECT and arbitrary
methods are routable but omitted from generated OpenAPI documents because
OpenAPI Path Item Objects do not define operation fields for them.
An unsupported method on a matching path returns 405 Method Not Allowed with
an Allow header. If no explicit HEAD route exists, ServerKit executes the
matching GET handler, preserves its status and representation headers, and
removes the body. If no explicit OPTIONS route exists, ServerKit generates a
204 No Content response with Allow. Static routes retain precedence over
parameter routes before method selection.
Path extraction
Parameters can occur at any path segment, and multiple parameters are matched by name rather than struct-field order.
use *;
async
A scalar schema is a convenience for routes containing exactly one parameter.
use *;
async
Static routes take precedence over parameter routes. Path values are percent-decoded before validation.
The final segment can capture the remainder of the path with *name:
use *;
async
let router = new;
Matching is deterministic from left to right: static segments precede
parameters, and parameters precede wildcards. Equivalent patterns such as
/users/:id and /users/:name for the same method are rejected when the router
is built. Empty parameter names, duplicate parameter names, non-terminal
wildcards, queries, fragments, duplicate slashes, and trailing slashes are
also rejected.
Config::prefix gives a router its own static prefix. .at() adds a mount
outside that prefix, and a child router is registered with the same .route()
method used for individual routes:
use ;
async
async
let api = new
.at;
let router = new
.route
.fallback;
The resulting route is /root/service/v1/users. Prefixes always compose in
this order: parent Config::prefix, child .at(), child Config::prefix, and
the route path. Prefixes are static, start with /, and cannot end with /.
Config::new() is required even when no options are set so router construction
keeps one stable shape as configuration grows.
Middleware
Middleware can be attached to a router scope or to one route. Parent router middleware wraps child router middleware, which wraps route middleware and the handler. The response unwinds in reverse order.
use ;
;
;
;
async
async
let api = new
.middleware;
let router = new
.middleware
.middleware
.route;
Route::without_middleware::<M>() skips inherited middleware with the exact
concrete type M for that route. It does not remove middleware attached
directly to the route. Scoped middleware also runs for a scoped fallback and
for generated responses such as 404, 405, and automatic OPTIONS within that
scope; route middleware only runs after a route is selected.
Request::method, Request::path, Request::query, and Request::headers are
public fields, so middleware can replace request metadata before extraction.
Routing and path-parameter capture have already completed before middleware
runs; changing method or path affects downstream middleware and extractors
but does not select a different route or recalculate path parameters. Request
body replacement remains internal until its streaming transformation API is
defined.
Query extraction
Query schemas ignore undeclared fields by default. Repeated names decode into
Vec<T>, optional names decode into Option<T>, and defaults apply when a
name is absent.
use *;
async
For example, ?q=rust&tag=web&tag=server&debug=true is valid and debug is
ignored. Names and values use form-style percent decoding, including + as a
space.
Header extraction
Headers use the same schema decoder but compare names case-insensitively and
allow undeclared fields. This permits normal protocol headers such as Host,
Accept, and User-Agent while continuing to validate every declared header.
use *;
async
rename_all = "kebab-case" maps x_request_id to X-Request-Id. An
individual field can override its input name with #[schema(rename = "...")].
Unknown fields
The source defaults are:
| Extractor | Default behavior |
|---|---|
Path<T> |
reject |
Query<T> |
ignore |
Header<T> |
ignore |
A schema can override its source default without changing the extractor type.
use Schema;
reject reports each unmatched name as an UnknownField validation issue.
ignore accepts and discards unmatched values. To retain them instead, add one
ExtraFields rest field:
use ;
async
ExtraFields preserves input order and repeated names. get, get_all, and
iter return decoded byte slices; len counts entries, including duplicates.
Path and query names remain case-sensitive, while captured header names are
looked up case-insensitively. A rest field cannot be combined with an explicit
unknown_fields policy because capture already defines how unmatched values
are handled.
use serverkit::{ExtraFields, Schema};
#[derive(Schema)]
#[schema(unknown_fields = "ignore")]
struct ConflictingPolicy {
#[schema(rest)]
extra: ExtraFields,
}
Schemaval rules
The built-in scalar types are String, Vec<u8>, bool, all standard integer
types, f32, f64, Ipv4Addr, Ipv6Addr, and IpAddr. Struct fields support:
- required
Tvalues; - optional
Option<T>values; - repeated
Vec<T>values (Vec<u8>remains a single byte value); - one
#[schema(rest)] ExtraFieldsfield; #[schema(default)]and#[schema(default = expression)];minimum,maximum,min_length, andmax_length;- field and whole-struct custom validation.
- nested schemas through dotted input names;
- repeated nested schemas through indexed dotted input names;
- generic schemas, string enums, and tagged data enums;
- OpenAPI formats through
#[schema(format = "...")]; - metadata used by the OpenAPI generator.
use ;
use ;
Direct Schema::decode calls accept DecodeOptions::reject_unknown() or
DecodeOptions::ignore_unknown(). Extractors start with their source default
and apply #[schema(unknown_fields = "...")] when it is present.
Failures are aggregated in ValidationErrors. Each ValidationIssue exposes
its optional field name, stable code, ValidationRule, and message. Use
ValidationIssue::coded when a custom validator needs an application-specific
code. Extractors preserve validation failures in the router's Error.
use ValidationIssue;
Custom value sources can implement Values and call the same schema directly.
use ;
let values = OneValue ;
let identifier = decode.unwrap;
assert_eq!;
Error responses
Every request-time error is represented as an Error until middleware has
finished. Router::handle then renders it once. The default renderer is a
dependency-free JSON envelope, including when the json feature is disabled:
Path, query, header, and form validation errors populate fields from the
original Schemaval issues. Each field contains field, code, and message;
field is null for a request-wide issue.
Application handlers can use predefined errors without repeating status codes, error codes, or messages:
use Error;
# async
async
bad_request, unauthorized, forbidden, not_found, conflict,
unprocessable_content, and too_many_requests provide the common HTTP
failures. with_message changes only the public message. Use Error::new when
an application-specific code is required.
Any std::error::Error + Send + Sync + 'static converts into an internal error
through ?. ServerKit uses the standard Result<T, Error> rather than defining
another result alias:
use Error;
async
The default JSON format exposes the original internal error message under the
stable internal_error code. Production applications can hide it with the
existing formatter hook while retaining the source for logging:
use ;
let config = new.error_format;
Configure a different representation once for the whole router. The formatter
chooses the body and representation headers; ServerKit preserves the original
status and protocol headers such as Allow and WWW-Authenticate.
use ;
let config = new.error_format;
Raw 4xx and 5xx Response values are normalized through the same formatter
with the fallback code http.{status}. When routers are nested, the outer
router owns the final error format, keeping one response contract across the
composed application. Errors after an HTTP response stream starts or after a
WebSocket upgrade cannot be rendered as a new HTTP response.
Enums decode from their external string representation. All common rename
rules are supported: lowercase, UPPERCASE, camelCase, PascalCase,
snake_case, SCREAMING_SNAKE_CASE, kebab-case, and
SCREAMING-KEBAB-CASE.
use ;
;
assert_eq!;
Nested schemas use dotted names such as filter.name. Option<T> makes the
entire nested object optional. Repeated nested schemas use names such as
filters.0.name and filters.1.name. A default applies when no value under the
nested prefix is present.
use Schema;
OpenAPI documents repeated nested leaves with an index placeholder such as
filters.{index}.name and marks them with x-serverkit-indexed: true. Tagged
enums are expanded into their discriminator and variant fields for path, query,
and header parameters; fields that only belong to some variants are optional.
Enums containing data use an explicit discriminator. Unit-only enums keep the single string representation shown above.
use Schema;
type=range&start=1&end=10 decodes to Selection::Range. OpenAPI emits a
oneOf schema with type as its discriminator.
format changes OpenAPI metadata; it does not by itself validate a string.
Combine it with validate for values such as UUIDs. The built-in IP address
types perform real parsing and emit ipv4 or ipv6 formats automatically.
Generic fields receive the required ValueSchema or Schema bounds from the
derive automatically:
use Schema;
Custom scalar types implement ValueSchema; no derive or registration table is
required.
use ;
;
Streaming request bodies
Body is the streaming extractor. Its next method borrows one body chunk at
a time directly from the runtime adapter. The slice remains valid until the
next mutable access to that Body.
use *;
async
Only one streaming extractor is permitted in a handler, and it must be last.
The handler implementations enforce this when a route is registered. If any
earlier extractor is buffered, ServerKit reads the incoming stream once, shares
the resulting slice with all buffered extractors, and then moves the same bytes
into a replay stream for Body. With no buffered extractor, Body receives the
runtime's original stream without pre-reading it.
use serverkit::{Body, Config, Method, RouteMethods, Router};
async fn invalid_order(_body: Body, _method: Method) {}
fn router() -> Router {
Router::new(Config::new(), ("/upload".GET(invalid_order),))
}
Runtime adapters implement RequestStream to supply chunks:
use ;
use ;
;
Buffered JSON
Enable the json feature to deserialize the complete request body. Invalid
JSON returns HTTP 400.
use Deserialize;
use *;
async
Json<T> requires Content-Type: application/json or a media type ending in
+json. Unsupported media types return 415, malformed JSON returns 400, and
the router body limit is checked before deserialization. Returning
Json<T> serializes a JSON response with the matching content type. T also
implements Schema, allowing request and response types to be emitted into
OpenAPI components/schemas and referenced with $ref.
Text, bytes, and forms
Text and Bytes buffer the request body once. Text validates UTF-8, while
Bytes preserves the bytes unchanged.
use ;
async
async
Form<T> uses the same name-based Schema validation as query extraction and
requires application/x-www-form-urlencoded.
use ;
async
Set a limit once on the router. Buffered extractors enforce it while collecting, and streaming extractors enforce it as chunks are read. With no configured limit, request bodies remain unlimited.
use ;
let router = new.body_limit;
Multipart
Multipart is a final streaming extractor. Parsing begins only when next()
is called, boundaries may span runtime chunks, and field contents are exposed
one chunk at a time without buffering an entire file. The configured body limit
remains active across the complete body.
use ;
async
Each MultipartField exposes headers, name, file_name, content_type,
and streaming next accessors. bytes().await and text().await remain
available when a small field should be collected. Dropping a field before it is
fully read causes Multipart to discard its remaining contents before parsing
the next field.
State, extensions, connection information, and cookies
Router state is stored once and extracted as State<T>, which contains an
Arc<T>.
use ;
async
let router = new.state;
Runtime-specific values can be inserted into a Request and cloned with
Extension<T>. The Hyper adapter automatically provides the peer SocketAddr
through ConnectInfo<SocketAddr>.
use SocketAddr;
use ConnectInfo;
async
Cookies parses all incoming Cookie headers without hiding repeated names.
use Cookies;
async
Custom extractors
Metadata and buffered extractors implement FromRequest<(&Request, &[u8])>.
Set BUFFERED only when the extractor needs the complete body; otherwise the
slice is empty and the runtime stream remains untouched.
use ;
;
async
Composite extractors can reuse State, Extension, and ConnectInfo with
?. Missing runtime values keep their original status, code, and message when
they are converted into Error.
use ;
;
A buffered extractor uses the same signature:
use Infallible;
use ;
;
Body is the owned-request extractor supplied by ServerKit. Keeping the owned
form internal to streaming extraction prevents two handler arguments from
taking the same request stream.
Cloudflare Workers
Add serverkit-worker. The adapter converts the host request before dispatch
and converts the ServerKit response afterward; the router itself stays
runtime-independent.
use LazyLock;
use ;
use ;
use ;
static ROUTER: = new;
async
async
async
serverkit_worker::from_request preserves method, path, query, headers, body stream,
Env, fetch Context, and Cf. WorkerContext is a normal non-buffering
extractor. Its env, context, and cf accessors expose host data, while
wait_until schedules work without delaying the response. The complete
Wrangler package is in examples/cloudflare-worker.
Responses
Handlers may return any IntoResponse implementation. ServerKit provides
implementations for Response, (), String, &str, Vec<u8>,
Infallible, and Result<T, E> when both sides implement IntoResponse.
use Response;
async
async
async
Response::new, Response::empty, Response::text, and Response::bytes
construct buffered responses. Content-Type lives in the same Headers
collection as every other header; there is no second content-type field.
use ;
async
Header names are case-insensitive. set replaces every existing value,
append preserves repeated fields such as Set-Cookie, and remove removes
all values of a name. Public writes validate header names and reject CR/LF/NUL
in values.
Response::stream accepts a runtime-neutral ResponseStream and is forwarded
without buffering by both native HTTP and Cloudflare Workers.
poll_next transfers an owned Chunk to the runtime adapter. Chunk::from
moves a generated Vec<u8> without copying it, while Chunk::shared reuses
cached bytes through an Arc. Hyper forwards both forms without copying at the
adapter boundary. Cloudflare Workers still perform their required host-boundary
copy into a JavaScript Uint8Array.
use ;
use ;
async
Redirects have explicit status semantics:
use Redirect;
async
Server-sent events
Sse<S> encodes typed SseEvent values and sets the required response
headers. The source implements the same poll-based shape as other streams.
use ;
use ;
;
async
WebSockets
Enable the websocket feature. The same upgrade handler and message API works
with native HTTP/1.1 and Cloudflare Workers.
use ;
async
WebSocketUpgrade::protocol selects only a protocol present in the client's
Sec-WebSocket-Protocol request. The native adapter performs the HTTP upgrade
and WebSocket handshake; the Workers adapter creates and accepts a
WebSocketPair. Workers manages ping and pong control frames itself.
OpenAPI
Router::openapi takes the serving path first, generates OpenAPI 3.1 from
registered routes, extractors, Schemaval metadata, validation constraints,
request media types, and response types, then serves a Scalar API Reference at
that path.
use ;
async
let route = "/items/:id"
.GET
.summary
.description
.tag
.operation_id
.openapi;
let document = new
.server
.security_scheme
.security
.scalar_config;
let router = new.openapi;
assert!;
The serving path must be static. The page loads the pinned Scalar browser
bundle @scalar/api-reference@1.63.0 from jsDelivr and embeds the OpenAPI document generated from the
router's current routes and schemas directly into Scalar's content
configuration. It does not read a file or fetch a separate document endpoint.
The page supports GET, HEAD, and OPTIONS; other methods return 405 with an
Allow header. Router::openapi_document provides direct access to the generated
JSON in memory.
Named Schemaval types, including Json<T> request and response bodies, are
deduplicated under components/schemas and referenced with $ref. Route
builders expose summary, description, tags, operation IDs, and a custom
openapi modifier. OpenApi supports servers, API key, HTTP bearer, OAuth2,
and OpenID Connect security schemes. Operation supports request/response
examples and response header schemas. Examples preserve JSON value types:
use ExampleValue;
let _example = object;
Pass an ExampleValue to Operation::request_example or
Operation::response_example. String inputs remain accepted directly.
Runtime adapters
The core serverkit crate ends at Router::handle and does not depend on a
listener or async runtime. serverkit-hyper adds its own Run<L> extension
trait and re-exports the core prelude, so one import exposes both the framework
API and .run(listener):
use *;
let listener = bind?;
let router = new;
router.run?;
Enable serverkit-hyper/std to pass a std::net::TcpListener. This blocking
driver serves HTTP/1.0 and HTTP/1.1 without a Tokio runtime. Enable
serverkit-hyper/tokio to pass a tokio::net::TcpListener; this driver serves
HTTP/1.0, HTTP/1.1, and HTTP/2 on the caller's Tokio runtime. The websocket
feature selects the Tokio driver because upgrades need its asynchronous I/O.
An external adapter follows the same boundary: implement RequestStream,
construct Request::from_parts, call Router::handle, and consume the result
with Response::into_parts. The adapter can expose its own execution extension
trait without adding runtime types to the core crate.