Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Skyzen
Skyzen is an HTTP framework for Rust whose infrastructure is portable, not just its handlers.
A handler asks for Kv, Storage, Queue or Db and gets a capability, not a vendor SDK. The
same function body runs against Redis and Postgres on a server, Cloudflare KV and D1 at the edge,
DynamoDB and SQS on AWS, Cosmos DB and Blob Storage on Azure — and against in-process fakes in your
tests, with no sockets, no containers and no wrangler running.
use ;
use ;
// This signature is the whole point. Nothing here names a provider.
async
Table of Contents
- Portable Services
- Testing Without Infrastructure
- One Binary, Four Deployments
- Coming From axum
- Quick Start
- Routing
- Handlers, Extractors & Responders
- Error Handling
- Middleware & State
- SQL & Migrations
- WebSockets
- Static Files & SPA Support
- OpenAPI & Documentation
- Skyzen CLI
- Crates Overview
- Guides & Examples
- License
Portable Services
skyzen-services defines four capabilities. Each is a trait a backend implements, a type-erased
wrapper that is both Middleware (it injects itself) and Extractor (it pulls itself back out),
and a set of convenience methods on top.
| Capability | Wrapper | What the trait covers |
|---|---|---|
| Key–value | Kv |
get/put/put_with_ttl/delete/exists, paginated list, and the atomics: put_if_absent, compare_and_swap, increment, expire |
| Object storage | Storage |
get/put/put_with/head/delete, paginated list, plus get_stream, put_stream, get_range, presign_get, presign_put |
| Message queue | Queue |
produce with send/send_batch/send_with, consume with receive/ack/nack |
| SQL | Db |
query(..).bind(..).fetch_one/fetch_all/fetch_optional/fetch_scalar/execute, typed rows through #[derive(FromRow)], begin transactions, execute_batch, and migrate |
Provider Matrix
| In-memory (tests) | Native server | Cloudflare Workers | AWS | Azure | |
|---|---|---|---|---|---|
Kv |
InMemoryKv |
Redis |
CfKv |
DynamoKv |
CosmosKv |
Storage |
InMemoryStorage |
S3Storage |
CfR2 |
S3Storage |
AzureBlob |
Queue (produce) |
InMemoryQueue |
SqsQueue |
CfQueue |
SqsQueue |
ServiceBusQueue, AzureStorageQueue |
Queue (consume) |
driven by the test | Skyzen polls — [[native.queue_consumer]] |
the platform pushes | SQS event source mapping | Functions queue trigger |
Db |
InMemoryDb (SQLite) |
sqlx — Postgres, MySQL, SQLite | CfD1 |
RdsDataDb (Aurora Data API) |
AzureSqlDb (Azure SQL, dialect Mssql) |
Db::begin (transactions) |
yes | yes | no — use execute_batch |
yes | yes |
Db::migrate / skyzen migrate |
yes | yes | yes (wrangler d1 migrations) |
in-app runner only | in-app runner only |
Read the columns honestly:
- Native server is a runtime, not a separate set of backends. Every backend that is a plain
HTTP client —
SqsQueue,DynamoKv,S3Storage,AzureBlob,CosmosKv,ServiceBusQueue,AzureStorageQueue,RdsDataDb— works from a native binary too, and every one of them can be declared inSkyzen.toml's[native.service.*]/[native.database.*]wiring rather than constructed by hand. The column names the one this table's row is about, not the limit of what a native binary can reach. - Which
Dbyou want on Azure depends on which database it is. Azure Database for PostgreSQL and for MySQL speak the wire protocols sqlx already speaks, so they are an ordinary native[[database]]and need nothing fromskyzen-azure. Azure SQL speaks T-SQL over TDS, which sqlx has no driver for, and is whatAzureSqlDbexists for — real transactions included. Cosmos DB is not SQL here at all: it is wired as a key–value store. - Cloudflare has no interactive transactions, because D1 has none.
Db::beginreturnsDbError::TransactionsUnsupportedthere;Db::execute_batchis D1's atomic unit and is supported everywhere. - A capability a backend genuinely lacks returns
Unsupportedand fails loudly. Nothing silently degrades to a racy read-modify-write.
Wiring a Backend
In code:
use ;
// Native
let kv = new;
let storage = new;
let db = connect_postgres.await?;
// Azure SQL speaks T-SQL over TDS, which sqlx has no driver for, so it has a backend of its own.
let db = new;
// AWS / Azure — plain HTTP clients, so these work from any runtime
let kv = new;
let kv = new;
let storage = new;
// Cloudflare Workers, from the Worker's env bindings
let kv = new;
let storage = new;
// Tests — no external dependency at all
let kv = new;
let memory = with_migrations.await?;
let db: Db = memory.db.clone;
Or declaratively, once, in Skyzen.toml — #[skyzen::main] reads
it at compile time and generates a named extractor per entry:
[[]]
= "cache"
= "kv"
[]
= "redis"
= "CACHE_URL"
[]
= "CACHE"
Any backend goes in that backend = … — dynamodb, cosmos, blob, servicebus,
storage-queue, rds-data, azure-sql and the rest — each with its own keys, and unknown ones
rejected where they are written. skyzen add <backend> installs the crate it needs, and
skyzen dev refuses to start when a variable one of them reads is set nowhere.
// `[[service]] name = "cache"` generates `pub struct Cache(Kv)` with `Deref<Target = Kv>`;
// `[[database]] name = "journal"` generates `JournalDb`. Two KV namespaces are therefore
// ordinary — name the one you mean.
async
See the Services Guide.
Testing Without Infrastructure
Because the capability is the interface, a test swaps the backend rather than the code. TestClient
drives the router in process — no TCP socket, no background server, no wrangler.
use json;
use Kv;
use ;
async
TestContext has a slot for every capability — with_kv, with_storage, with_queue, with_db,
and the Durable Object ones (with_durable_kv, with_durable_db, with_alarm) — so a Workers
application is testable on a native cargo test. #[skyzen::test] goes further and fills those
slots from your Skyzen.toml automatically, optionally applying your migrations first:
async
See the Testing Guide.
One Binary, Four Deployments
Nothing in an application is annotated for a platform. The native binary reads its environment
before it binds anything, and the wasm32 build is the Worker:
| Target | How it is selected | Entry point |
|---|---|---|
| Native server | nothing else matched | Tokio + Hyper, --port / --host / --listen |
| AWS Lambda | AWS_LAMBDA_RUNTIME_API is set (needs the lambda feature) |
lambda_http for HTTP, partial-batch responses for SQS |
| Azure Functions | FUNCTIONS_CUSTOMHANDLER_PORT is set |
custom handler; HTTP triggers reach the router, queue triggers reach #[skyzen::queue] |
| Cloudflare Workers | compiled to wasm32-unknown-unknown |
the WinterCG fetch export |
One #[skyzen::queue] handler is driven four ways: by Skyzen's own polling loop natively, by the
platform on Workers and Lambda, and by the Functions host on Azure. See the
Deployment Guide.
Coming From axum
Most of what you reach for has a direct equivalent:
| axum | Skyzen | Notes |
|---|---|---|
axum::extract::Path<T> |
skyzen::extract::Path<T> |
Same deserialization into a tuple, struct or primitive; Params remains the runtime-keyed escape hatch |
axum::extract::Query<T> |
skyzen::extract::Query<T> |
Backed by serde_html_form, so ?tag=a&tag=b fills a Vec<String> — which serde_urlencoded cannot |
axum::Form<T> |
skyzen::utils::Form<T> |
Same crate underneath |
axum::Json<T> |
skyzen::utils::Json<T> |
|
axum::extract::State<T> |
skyzen::utils::State<T> |
Attached with .with(State(value)); Route::build() fails if a handler extracts state no ancestor provides |
axum_extra::TypedHeader<H> |
skyzen::extract::TypedHeader<H> |
The same headers crate (typed-header feature) |
http::HeaderMap, Uri, Method |
same types, same use | All three implement Extractor |
axum::middleware::from_fn |
skyzen::middleware::from_fn |
Closure returns a boxed future |
Router::layer |
Route::layer / Router::layer |
Covers the whole router including its 404 and 405 paths |
Router::fallback |
Route::fallback |
Plus Route::method_not_allowed, which can read AllowedMethods |
Router::nest |
"/api".nest(router) |
Mounts an already-built Router under a path |
tower_http::cors::CorsLayer |
skyzen::middleware::Cors |
|
tower_http::limit::RequestBodyLimitLayer |
skyzen::middleware::BodyLimit |
On by default at 2 MiB — see below |
tower_http::compression |
skyzen::middleware::CompressionMiddleware |
|
tower_http::timeout |
skyzen::middleware::Timeout |
Native targets only |
axum::response::sse |
skyzen::responder::Sse |
With keep-alives |
axum::extract::ws |
.ws(handler) on a route |
One API over async-tungstenite natively and WebSocketPair on wasm |
What is genuinely different:
- No
tower.Middlewareis Skyzen's own trait taking&self, so the wholetower/tower-httpecosystem is unavailable. The shipped middleware above is what there is; anything else you write yourself, which is ~10 lines. - Routing is a tree of values, not a builder chain.
Route::new((..))takes a tuple of nodes, andRoute::build()validates the wiring before the first request rather than 500-ing on it. - Extraction is
&mut Request, notFromRequest/FromRequestParts. There is one trait, and only one extractor per handler may consume the body — a second one is a loud500naming both, instead of silently seeing an empty body. - Handlers cap at 15 arguments.
Two defaults are safer than axum's, and worth knowing before you port:
- 5xx bodies are redacted. A
500returns"Internal server error"to the client while the full message and its wholesource()chain go to the log. 4xx messages are returned verbatim, because they are about the caller's request. - Request bodies are capped at 2 MiB by default, enforced by every buffering extractor, both
from
Content-Lengthand mid-stream for a chunked body. Raise it withBodyLimit, or lift it withRequestBodyLimit::disabled()on a route that streams.
Quick Start
[]
= "0.1"
= { = "1.0", = ["derive"] }
use ;
use ;
// A payload carried by `Json`, `Form` or `Query` derives `ToSchema` alongside its serde derive:
// one says how it goes on the wire, the other how the OpenAPI document describes it.
async
async
Routing
Routing trees are built from string path literals through the CreateRouteNode trait, and matched
by a radix tree (matchit).
use ;
Path Parameters and Wildcards
{name} matches one segment; {*path} matches the rest of the path.
Path<T> deserializes the captured segments into a tuple, a struct, or a single primitive, so a
malformed segment is a 400 naming it rather than a .parse() in every handler. Params is the
escape hatch for names only known at runtime.
use Path;
use ;
use Result;
async : )
async
let routes = new;
Grouping and Nesting
.route(..) groups sub-paths under a node — the child paths are relative to it, so a child
repeating the parent's segment registers it twice:
let api = new;
"/api".nest(router) mounts an already-built Router under a path, and Router::routes() lists
every registered (method, path) so a wrongly nested tree is one dbg! away.
Handlers, Extractors & Responders
A handler is an async fn whose arguments implement Extractor and whose return type implements
Responder. No macro, no registration.
use ;
async
Built-in Extractors
| Extractor | Path | Description |
|---|---|---|
Json<T> |
skyzen::utils::Json |
Deserializes a JSON request body (T: ToSchema) |
Query<T> |
skyzen::extract::Query |
Deserializes the query string, repeated keys included (T: ToSchema) |
Form<T> |
skyzen::utils::Form |
Deserializes application/x-www-form-urlencoded (T: ToSchema) |
Path<T> |
skyzen::extract::Path |
Deserializes the captured {name} segments into a struct, tuple or primitive |
Params |
skyzen::routing::Params |
Path parameters by name at runtime (params.get("id")?) |
Multipart |
skyzen::utils::Multipart |
Streams multipart form data and file uploads |
State<T> |
skyzen::utils::State |
Shared state attached with .with(State(..)) |
CookieJar |
skyzen::utils::CookieJar |
Reads request cookies; also a Responder for setting them |
BearerToken |
skyzen::extract::BearerToken |
The bearer token from Authorization |
ClientIp |
skyzen::extract::ClientIp |
Client IP, honouring X-Forwarded-For and CF-Connecting-IP |
TypedHeader<H> |
skyzen::extract::TypedHeader |
One RFC-typed header via headers (typed-header feature) |
Kv, Storage, Queue, Db |
skyzen_services::* |
Portable services injected into the request |
String, Bytes, ByteStr, Body |
skyzen::http_kit::* |
The request body, buffered or streamed |
HeaderMap, Uri, Method |
skyzen::http_kit::* |
Request metadata |
RequestBodyLimit |
skyzen::RequestBodyLimit |
The body cap in force, so a handler can size its own reads |
Option<T>, Result<T, _> |
— | Wrap any extractor to make its failure recoverable |
A body payload carries T: ToSchema so that what the endpoint documents is what it actually
serializes — the bound is what makes the generated document trustworthy rather than best-effort.
#[derive(ToSchema)] beside the serde derive is the whole cost, and every primitive, collection
and serde_json::Value already has one. The derive expands to ::utoipa::… paths, so an
application declares utoipa = "5" alongside skyzen (skyzen new does this for you);
skyzen::ToSchema re-exports the trait the bound is written against.
Path<T> is the exception and takes no bound: the route pattern names its parameters, and
Path<(String, u32)> for a multi-segment route has no schema to give. #[skyzen::openapi] types
those parameters by probing the payload at the handler's own call site instead.
Built-in Responders
| Responder | Description |
|---|---|
&'static str, String, Bytes |
Plain text or raw bytes |
Json<T> / PrettyJson<T> |
Serializes T to application/json (T: ToSchema) |
Html<T> |
Sends its payload as text/html; charset=utf-8 |
StatusCode |
An empty response with that status |
Redirect |
to (302), see_other (303), temporary (307), permanent (308), or with_status |
HeaderMap, (HeaderName, HeaderValue) |
Sets response headers |
Sse |
Streams Server-Sent Events, with keep-alives |
CookieJar |
Emits the Set-Cookie headers it accumulated |
(StatusCode, T), (HeaderMap, T), tuples |
Compose an explicit status or headers with any responder |
Result<T, E> |
T on Ok; E: HttpError becomes an HTTP error response |
Error Handling
#[skyzen::error] implements Display, std::error::Error (including source() for
#[from]/#[source] fields) and HttpError, mapping each variant to a status:
use StatusCode;
Mixing Error Types with skyzen::Result
A handler that fails in more than one way returns skyzen::Result<T>. Anything implementing
HttpError — a route-parameter rejection, a Json rejection, a KvError, your own
#[skyzen::error] enum — converts with ? and keeps its own status:
use ;
use Kv;
async
Errors with no HTTP meaning of their own do not convert implicitly — guessing a status is how a client error becomes a 500. State one:
use ;
async
ResultExt::status_msg, and Option::status/status_msg, cover the case with no error value.
What the Client Sees
- 4xx — the formatted message, as JSON:
404 {"error":"item with id 42 was not found"}. - 5xx —
"Internal server error", so a database or system detail cannot leak, while the full message and itssource()chain are logged server-side.
Middleware & State
use Arc;
use ;
async
Writing Middleware
Middleware takes &self and is stored once as Arc<dyn MiddlewareObj>, never cloned per
request — so state kept in an atomic or a channel really persists:
use ;
use ;
For one-off behaviour, middleware::from_fn takes a closure returning a boxed future:
use from_fn;
let log = from_fn;
Attachment Scopes
| Call | Covers |
|---|---|
RouteNode::with(m) |
one path node's endpoints |
Route::with(m) / Route::middleware(m) |
every endpoint in the subtree |
Route::layer(m) / Router::layer(m) |
the entire router, including its 404 and 405 responses |
CORS, tracing and request-id middleware belong on layer: a preflight OPTIONS arrives at a path
whose registered methods are GET/POST, so it has to be answered before the router synthesizes a
405.
Shipped Middleware
| Middleware | Purpose |
|---|---|
Cors |
Answers preflights and decorates cross-origin responses; rejects credentials + wildcard origin at construction |
CompressionMiddleware |
gzip/deflate negotiation, skipping HEAD and unknown-length streams |
BodyLimit |
Sets the request body cap for the routes it covers (2 MiB applies with no middleware at all) |
Timeout |
Abandons a request that outruns its budget with 408 (native targets only) |
ErrorHandlingMiddleware |
Renders endpoint errors into responses |
AuthMiddleware |
Authenticates the request and injects AuthUser<U> |
State<T>, Kv, Storage, Queue, Db |
Inject a value the matching extractor reads back |
Custom 404 and 405 Responses
use ;
async
async
let router = new
.fallback
.method_not_allowed
.build;
Both run inside the router's layers.
Wiring Checked at Build Time
Route::build() walks the tree and fails if a handler extracts a State<T> or AuthUser<U> that
no middleware on its route provides, naming the path and the call that would fix it — instead of
returning a 500 on the first request that reaches the endpoint. Route::try_build() returns the
RouteBuildError rather than panicking.
SQL & Migrations
Schema lives in plain .sql files, embedded at compile time and applied by a runner that works on
every backend Db works on:
use embed_migrations;
use Migrations;
static MIGRATIONS: Migrations = embed_migrations!;
let report = db.migrate.await?;
The same files are what skyzen migrate applies from the CLI, and what
#[skyzen::test(migrations = MIGRATIONS)] applies to a test database. Each applied file's checksum
is recorded, so an edited migration is refused rather than skipped. See the
Migrations Guide.
Typed Rows
Binding is typed — DbValue carries timestamps, UUIDs, exact decimals and JSON documents — and
reading is typed the same way. #[derive(FromRow)] reads one column per field, through the field's
own type:
use ;
/// A newtype and a state machine, each stored in one column, in both directions.
;
// "awaiting_payment", "shipped", "cancelled"
let order: Order = db.query.bind.fetch_one.await?;
A Uuid column is a string on PostgreSQL and sixteen bytes on SQLite; a NUMERIC is a string
everywhere. The field's type is what decides how the column is read, so the same struct decodes on
every backend. A missing column, or one holding something the field's type does not accept, is an
error naming both — never a default.
Single-column queries need no struct at all:
let orders: i64 = db.query.fetch_scalar.await?;
let ids: = db.query.fetch_scalars.await?;
OrderState::TOKENS is the list of tokens the enum stores, so a CHECK (state IN (…)) constraint
can be checked against the type instead of drifting from it. For a type whose Deserialize someone
else wrote, JsonRow<T> hands the whole row to serde.
Durable Objects get their own object-scoped SQLite through DurableDb:
use DurableObject;
use ;
use ;
use DurableDb;
;
async
NativeDurableNamespace simulates Durable Objects and their SQLite state on native targets, so the
same code is testable without wasm. See the Durable Object + SQL Guide.
WebSockets
One API compiling to async-tungstenite natively and WebSocketPair on wasm:
use StreamExt;
use ;
use ;
A session handler may return () or a Result, and returning a Result is what makes ? usable:
a failed send is logged and closes the connection with websocket::INTERNAL_ERROR rather than
being discarded.
WASM WebSockets enforce a 1 MiB maximum message size and have no manual ping/pong control.
Static Files & SPA Support
use Route;
use ;
// From disk (native only). Streamed, with ETag, Last-Modified, Range and 304/206 handling.
let disk_routes = new;
// Embedded at compile time — works on native and wasm alike.
static ASSETS: Dir = include_dir!;
let embedded_routes = new;
OpenAPI & Documentation
#[skyzen::openapi] builds the specification at compile time from the handler's extractors,
responders and doc comments:
use Serialize;
use ;
/// Retrieve an item by unique identifier.
async
Schema generation is gated to debug builds and native targets, so release binaries and edge wasm bundles stay small.
Skyzen CLI
The generator and the wasm optimizer are linked into the binary, so that one install is the whole
toolchain — there is no wasm-bindgen or wasm-opt to add.
--env <name> selects a [cloudflare.env.<name>] overlay, --dry-run prints the plan without
running it, and --manifest points at a Skyzen.toml elsewhere.
Crates Overview
| Crate | Path | Description |
|---|---|---|
skyzen |
Root | Routing, extractors, responders, middleware, static files, WebSockets, runtime |
skyzen-core |
core/ |
Extractor, Responder, Middleware, Server, the error types; no_std-capable |
skyzen-macros |
macros/ |
#[skyzen::main], #[skyzen::error], #[skyzen::openapi], #[skyzen::queue], #[skyzen::test], embed_migrations!, … |
skyzen-manifest |
manifest/ |
The one typed Skyzen.toml schema, shared by the macros and the CLI |
skyzen-services |
services/ |
The portable capabilities: Kv, Storage, Queue, Db, migrations, durable variants |
skyzen-test |
test/ |
TestClient, TestContext, in-memory backends, assertions, insta snapshots |
skyzen-hyper |
hyper/ |
The Hyper Server implementation for native runtimes |
skyzen-lambda |
lambda/ |
AWS Lambda adapter — HTTP invocations and SQS batches (root crate's lambda feature) |
skyzen-redis |
redis/ |
Redis KeyValueStore |
skyzen-s3 |
s3/ |
S3-compatible ObjectStorage |
skyzen-cloudflare |
cloudflare/ |
Workers KV, R2, Queues, D1, Durable Objects, secrets store, request.cf (wasm32 only) |
skyzen-cloudflare-admin |
cloudflare-admin/ |
Cloudflare REST client, used by skyzen provision |
skyzen-aws |
aws/ |
DynamoKv, SqsQueue, RdsDataDb, and S3Storage re-exported |
skyzen-azure |
azure/ |
CosmosKv, AzureBlob, ServiceBusQueue, AzureStorageQueue, AzureSqlDb |
skyzen-cli |
cli/ |
The skyzen binary: scaffolding, local emulation, provisioning, migrations, deployment |
Guides & Examples
- Using Portable Services
- SQL Migrations
- Testing Guide
- Deployment Guide
- Durable Objects & SQL Guide
- Skyzen.toml Reference
- Runnable Code Examples
License
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT License (LICENSE-MIT)
at your option.