Claude Code skill
This page is the Claude Code skill that ships with Kerniq, shown as it is written for Claude. Once installed, Claude loads it automatically when you work on a Kerniq service, for example "create an orders service with Kerniq" or "add an admin-only endpoint", and follows the conventions below. Install it from a kerniq-server checkout:
cp -r skills/kerniq-server ~/.claude/skills/ # every project
cp -r skills/kerniq-server <your-project>/.claude/skills/ # one projectIt is also a compact checklist of Kerniq conventions for humans. For a guided walkthrough of the same structure, see the tutorial.
Kerniq is a thin, Flask-style layer over Axum, Tower, Hyper and Tokio.
Handlers are plain Axum handlers, middleware is Tower middleware, and
App::into_axum_router() gives you an axum::Router. Prefer Kerniq's API
(App, Router, Error, built-in middleware) and fall back to Axum/Tower
only for things Kerniq does not wrap.
Requires Rust 1.88+ (edition 2024). Always start from
use kerniq_server::prelude::*;.
#1. Create the project
Prefer the CLI; every template compiles, passes lint and passes its tests.
kerniq new my-service --template modular # basic | rest | modular | production
cd my-service && kerniq dev # run with auto-restart| Template | Pick it for |
|---|---|
basic |
One endpoint, a spike or demo |
rest |
Small CRUD API in a few files |
modular |
Most real services: config, state, error, models, services, routes modules |
production |
Deployable service: TOML + env config, JSON logs, request IDs, security headers, CORS, compression, metrics, health probes, background task, Dockerfile |
Install the CLI with cargo install kerniq-cli. Generated projects depend on
kerniq-server from crates.io; add --kerniq-path /path/to/kerniq-server or
--kerniq-git <url> only to build against unreleased changes. Without the
CLI, add:
[dependencies]
kerniq-server = { version = "0.1", features = [] } # see feature table
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] }
[dev-dependencies]
http-body-util = "0.1"
serde_json = "1"
tower = { version = "0.5", features = ["util"] }| Feature | Default | Enables |
|---|---|---|
json |
yes | Json<T>, Created<T> |
tracing |
yes | Logger middleware |
cors |
Cors |
|
compression |
Compression |
|
validation |
ValidatedJson, ValidatedQuery (validator crate) |
|
openapi |
kerniq_server::openapi (Utoipa + Swagger UI) |
|
websocket |
kerniq_server::extract::ws |
|
config-file |
ServerConfig::from_file (TOML) |
|
macros |
#[authorize(..)] |
|
full |
everything above (not observability) | |
observability-metrics / -logs / -tracing |
Kerniq Observe signals (section 12) | |
prometheus / otlp / otlp-tls / runtime-metrics |
exporters and runtime metrics | |
full-observability |
all observability features except otlp-tls |
Enable only what the service uses.
#2. Structure
For anything beyond a demo, follow the modular layout:
src/
main.rs # load config, build App, run
config.rs # config from env/file
state.rs # #[derive(Clone)] AppState
error.rs # domain errors + From<DomainError> for kerniq_server::Error
models/ # serde types
services/ # business logic, no HTTP types
routes/ # one module per feature, each returning Router<AppState>Keep an app(config) -> App<AppState> function separate from main so
tests can build the app without binding a port.
use kerniq_server::prelude::*;
#[tokio::main]
async fn main() -> Result<()> {
let config = ServerConfig::from_env()?; // defaults + KERNIQ_* overrides
let address = config.address();
app(config).run(address).await // graceful shutdown on Ctrl+C / SIGTERM
}
pub fn app(config: ServerConfig) -> App<AppState> {
App::new()
.config(config)
.state(AppState::default())
.merge(routes::router())
}#3. Routes and handlers
pub fn router() -> Router<AppState> {
Router::new()
.get("/health", health)
.mount("/api/v1/users", users::router()) // prefix + scoped middleware
}
// users.rs
pub fn router() -> Router<AppState> {
Router::new()
.get("/", list)
.post("/", create)
.get("/{id}", show) // `:id` also works
.delete("/{id}", remove)
}
async fn show(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Json<User>> {
Ok(Json(state.users.get(id).await?))
}
async fn create(State(state): State<AppState>, Json(input): Json<NewUser>) -> Result<Created<User>> {
Ok(Created(state.users.create(input).await?))
}- Methods:
get,post,put,patch,delete,head,options,trace, pluson/routefor anything else.group(|r| { .. })registers several routes on a router;mergecombines routers at their own prefix. - Return
Result<T>(Kerniq'sResult<T, Error>) whereT: IntoResponse:Json<T>,Created<T>(201),NoContent(204),Html,Redirect,&'static str,String,StatusCode, tuples,Response. - Extractors:
Path,Query,Json,State,Header<T>(e.g.BearerToken,UserAgent),HeaderMap,Extension,ConnectInfo,Bytes,Body, andValidatedJson/ValidatedQuery(featurevalidation, with#[derive(Validate)]). The body extractor must be the last parameter. - Bad input (unparseable path, malformed JSON, failed validation) is rejected automatically with a JSON error; don't hand-validate types.
#4. Errors
Every error renders as {"error":{"code":"...","message":"...","details":...}}.
Err(Error::not_found("The requested user does not exist").with_code("USER_NOT_FOUND"))Constructors: bad_request, unauthorized, forbidden, not_found,
conflict, unprocessable, too_many_requests, service_unavailable,
validation(details), internal(source), Error::new(status, code, msg).
Builders: with_code, with_message, with_details, with_header.
Rules:
- Map domain errors once with
impl From<MyError> for Error, keep services free of HTTP types, and use?in handlers. - Unexpected failures (DB, IO, third-party):
.or_internal()?. It logs the cause and returns a generic 500, so internals never leak. Never put raw error strings from dependencies in a client-facing message. - Prefer the prelude helpers over
matchboilerplate:result.or_error(Error::...),option.or_not_found("..."),option.or_error(Error::...).
#5. State
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool, // pools/clients are cheap Arc handles
pub users: UserService, // services hold Arc<...> internally
}State is cloned per request: every field must be cheap to clone (Arc,
pools, clients). Use tokio::sync::RwLock/Mutex inside an Arc for
in-memory mutable data. App::new().state(s) turns App into
App<AppState>; routers that use State<AppState> are Router<AppState>.
#6. Middleware
Middleware applies to every route of the app or router it is declared on, regardless of where routes are declared. The first declared is the outermost. A typical production order:
app.middleware(RequestId::new())
.middleware(Logger::new())
.middleware(metrics) // Metrics::new(); serve metrics.registry()
.middleware(SecurityHeaders::new())
.middleware(Cors::default()) // feature `cors`
.middleware(Compression::new()) // feature `compression`
.middleware(Timeout::seconds(30))
.middleware(RateLimit::per_second(50))With Kerniq Observe (section 12), drop Logger and Metrics: App::observability
already logs, measures and traces every request as the outermost layer.
Also available: BodyLimit, ConcurrencyLimit. Router-level
.middleware(..) scopes to that router's routes only. Any Tower layer works
via .layer(..).
#7. Authentication and authorization
Kerniq provides the plumbing, not a provider: validate tokens with your own JWT library, session store or IdP.
use kerniq_server::middleware::auth::{Auth, Authorities, Identity, Require};
#[derive(Clone)]
struct User { id: u64, roles: Vec<String>, permissions: Vec<String> }
impl Authorities for User {
fn has_role(&self, role: &str) -> bool { self.roles.iter().any(|r| r == role) }
fn has_permission(&self, p: &str) -> bool { self.permissions.iter().any(|x| x == p) }
}
let auth = Auth::bearer(|token: String| async move {
verify_jwt(&token).await.map_err(|_| Error::unauthorized("Invalid token"))
});
async fn me(Identity(user): Identity<User>) -> String { format!("user {}", user.id) }- Put
Authon a router that holds only protected routes, thenmountit; keep public routes in another router. For other schemes implementAuthenticator. Identity<T>returns 401 if no identity is present (fails closed);Option<Identity<T>>for optional auth.- Per router (role/permission):
.middleware(Require::<User>::role("ADMIN")), alsoany_role([..]),all_roles,permission,any_permission,all_permissions,check(|u: &User| ..),policy::<P>(), combined with.and,.or,.message(..). Declare it afterAuth. This replaces Spring-stylerequestMatchers(..).hasRole(..): group routes by requirement instead of listing path patterns. - Per handler (feature
macros):#[authorize(has_any_role("USER", "ADMIN"))]on a freeasync fn; ruleshas_role,has_any_role,has_all_roles,has_permission,has_any_permission,has_all_permissions,authenticated(),check(fn)with&&,||,!. Addidentity = Userif the handler has noIdentity<User>parameter. - Without macros: implement
Policy<User>and takeAuthorized<User, MyPolicy>as a parameter. - Missing identity → 401, failed rule → 403
FORBIDDEN.
#8. Configuration
ServerConfig covers host, port, workers, request timeout, max body size,
connection/concurrency limits, shutdown timeout and header read timeout.
Load with ServerConfig::from_env() (KERNIQ_HOST, KERNIQ_PORT,
KERNIQ_REQUEST_TIMEOUT, KERNIQ_MAX_BODY_SIZE, …) or
ServerConfig::from_file("kerniq.toml")?.apply_env()? (feature
config-file). Keep app-specific settings (database URL, secrets) in your
own config struct read from the environment; never put secrets in the TOML
file.
#9. Databases, brokers and background work
Kerniq has no DB layer; use sqlx, redis, mongodb, rdkafka, lapin,
etc. (see docs/integrations.md in the Kerniq repo):
- Connect once at startup, before
run; fail fast on bad config. - Put the pool/client in
AppState. - Map expected client errors (not found, unique violation) to 4xx;
everything else
.or_internal(). - Run consumers/pollers as
.background_task("name", |shutdown| async move { .. })and exit whenshutdown.wait()completes (usetokio::select!). - Close connections after
run(..).awaitreturns. By then requests have drained and background tasks have stopped.
Add a readiness probe (/health/ready) that pings dependencies and returns
Error::service_unavailable(..) when they are down.
#10. Testing
Test through the router without binding a port:
#[cfg(test)]
mod tests {
use http_body_util::BodyExt;
use kerniq_server::axum::body::Body;
use kerniq_server::http::Request;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn creates_user() {
let router = app(ServerConfig::default()).into_axum_router();
let request = Request::post("/api/v1/users")
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"Ada","email":"ada@example.com"}"#))
.unwrap();
let response = router.oneshot(request).await.unwrap();
assert_eq!(response.status(), 201);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "Ada");
}
}Assert on status and the error code for failure cases
(json["error"]["code"]). Cover: success, validation failure, not found,
conflict, and 401/403 for protected routes.
#11. Optional features
- OpenAPI (
openapi): annotate handlers with#[utoipa::path(..)], deriveOpenApifor a doc struct andToSchemafor models, then.merge(OpenApiDocs::new(ApiDoc::openapi()).swagger_ui("/docs").into_router())(use kerniq_server::openapi::OpenApiDocs;). Serves/openapi.jsonand/docs. - WebSockets (
websocket):kerniq_server::extract::ws. - Axum interop:
.mount_axum(path, axum_router)to embed existing Axum routers; any Axum extractor or Tower layer works unchanged.
#12. Observability (Kerniq Observe)
Metrics, structured logs and traces through one builder, on OpenTelemetry,
tracing and Prometheus. Enable features = ["full-observability"] (or
only the signals you need) and import
use kerniq_server::observability::prelude::*;.
let observability = Observability::builder()
.service_name("payment-api") // else OTEL_SERVICE_NAME
.service_version(env!("CARGO_PKG_VERSION"))
.environment("production")
.metrics(MetricsConfig::prometheus()) // GET /metrics
.logs(LogsConfig::json()) // JSON lines on stdout
.traces(TracesConfig::otlp().sampling_ratio(0.10))
.build()
.await?; // inside the Tokio runtime
App::new()
.observability(observability) // HTTP layer, outermost
// ...routes...
.run(address)
.await // flushes telemetry on shutdownEach request then gets http.server.request.duration (by route template),
active requests, body sizes, time to first byte, a span {METHOD} {route}
continuing the caller's W3C traceparent, and trace_id on every log line
inside it. Pass a clone of observability into AppState if handlers need
meter(..) or propagator().
Choosing an architecture.
| Setup | Config |
|---|---|
| Option A: Prometheus scrapes the app | MetricsConfig::prometheus(); in production .prometheus_listener(addr) for a separate, network-restricted port |
| Option B (recommended for production): OTLP to a Collector | MetricsConfig::otlp(), LogsConfig::otlp() or json().with_otlp(..), TracesConfig::otlp(); endpoint via .endpoint(..) or OTEL_EXPORTER_OTLP_ENDPOINT (default http://localhost:4317) |
Both can run together and produce the same metric names. Keep vendor credentials and fan-out in the Collector, not in the app.
Logs. Use tracing macros with structured fields, never interpolated
strings: info!(amount_minor, currency, "payment received").
LogsConfig::json() for production, pretty()/compact() for development;
.filter("info,my_service=debug") (default RUST_LOG, else info),
.otlp_filter("warn") to export less than you print. Use
#[instrument(skip(password))]/skip_all; never log bodies, tokens,
authorization headers, card numbers or secrets. build() installs the
global subscriber and fails with SubscriberAlreadySet if one exists — do
not also call tracing_subscriber::fmt::init(); to compose, use
.install_subscriber(false) and add observability.tracing_layer().
Custom metrics. Use the OpenTelemetry Meter API directly; it is a
no-op when metrics are disabled.
use kerniq_server::observability::{BoundedLabel, KeyValue};
static CURRENCY: BoundedLabel = BoundedLabel::new(&["USD", "EUR", "KES"], "other");
let meter = observability.meter("payments");
let payments = meter.u64_counter("payments.processed").with_unit("{payment}").build();
let amount = meter.u64_histogram("payment.amount").with_unit("{minor_unit}").build();
// store `payments` / `amount` in AppState; per request:
payments.add(1, &[KeyValue::new("currency", CURRENCY.get(&input.currency)),
KeyValue::new("outcome", "accepted")]);- Create instruments once at startup and keep them in state.
- Attributes must be bounded: route templates and enums only. Never
user/account/request ids, emails, raw URLs, error messages or prompts —
put those on spans or log fields. Map untrusted input through
BoundedLabel. Record amounts as histogram values, not labels. - Prefer semantic-convention names (
db.client.operation.duration,http.client.request.duration,messaging.*). - Read-at-collection values (queue depth, pool size): observable gauges
with
.with_callback(..). AI/streaming generation:kerniq_server::observability::telemetry::inference::InferenceMetrics.
Traces. Spans are tracing spans. Add #[instrument] at I/O
boundaries (DB, outbound HTTP, queues, model calls), not on trivial
functions. Record failures with tracing::error!(error = %err, "...").
For outbound calls, propagate context:
inject_current_context(observability.propagator().as_ref(), request.headers_mut())
(kerniq_server::observability::propagation). Sample at high volume
(sampling_ratio(0.01..0.1), parent-based by default); never sample
metrics.
Testing telemetry. Add kerniq-observability-testing as a
dev-dependency; it wires a real Observability to in-memory exporters.
#[tokio::test]
async fn records_payment_metrics() {
let telemetry = kerniq_observability_testing::TestTelemetry::new().await;
let router = app_with(telemetry.observability().clone()).into_axum_router();
// ...send requests with `oneshot`...
let metrics = telemetry.metrics();
assert_eq!(metrics.total("payments.processed", &[("outcome", "accepted")]), 1.0);
assert!(telemetry.logs().iter().any(|l| l.body == "payment accepted"));
let _span = telemetry.span("POST /payments");
telemetry.assert_bounded_metric_labels(); // fails on id/email-like keys
}Make the app constructor accept the Observability so tests can inject
TestTelemetry's instance (or Observability::disabled()).
Production. Set service_name, service_version, environment; let
the platform add k8s.*/cloud.* via OTEL_RESOURCE_ATTRIBUTES. Give the
Collector a memory_limiter. Set the termination grace period above the
server drain timeout plus the observability shutdown_timeout (5 s). Queues
are bounded and drop (counted) when full: watch
kerniq_telemetry_items_total / observability.stats().
#13. Before finishing
Run kerniq check (fmt, check, clippy -D warnings) and kerniq test, or
the equivalent cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test.
Then verify:
- Handlers return
Result<..>with domain errors mapped viaFrom; no dependency error text reaches clients (.or_internal()). - Shared state is
Cloneand cheap to clone. - Protected routes sit behind
Auth; role rules useRequireor#[authorize]; public routes are in a separate router. - Middleware order is intentional (first declared = outermost).
- Config comes from env/file; no hard-coded secrets or addresses.
- Background tasks stop on shutdown; connections close after
run. - Tests exercise the router via
into_axum_router()+oneshot. - If observability is enabled:
service_nameset, metric attributes bounded (no ids, emails, raw paths), instruments created once, no secrets in log fields or span attributes,/metricsnot publicly exposed, and no secondtracingsubscriber installed.
#Reference
The Kerniq repository's docs/ folder has the full guides: getting-started,
routing, handlers, extractors, state-management, middleware,
authorization, configuration, runtime, integrations, openapi,
websockets, axum-interop, performance, architecture. Observability
guides are in docs/observability/: overview, configuration, logging,
tracing, custom-metrics, prometheus, otlp, grafana,
vendor-integrations, performance, troubleshooting. Runnable examples:
examples/observability-minimal and examples/observability-demo
(local stack: observability/docker-compose.yml).