Skip to content
You are reading next, which describes the unreleased compiler. Khora is unstable before v1: syntax, standard-library APIs and behavior may change. The current release is documented under v0.4.

Build a typed JSON API

Use typed request and response records at the HTTP boundary. std::json parses wire text into Json, a derived schema reads a Khora type out of it and reports every problem, and Encode writes a typed value back for the response.

This service accepts POST /users with a JSON body such as {"name":"Ada"} and returns a typed user as JSON:

module main;
import std::core::{ChildFailed, Result, SharedFn, Validated};
import std::json::{parse};
import std::net::http::{HttpError, Request, Response, Router};
import std::schema::{Decode, Encode, Raw};
derive(Decode)
pub type CreateUser = {
pub name: String,
};
derive(Encode)
pub type User = {
id: Int,
name: String,
};
fn create_user(request: Request) -> Response {
let document = match parse(request.body) {
Result::Err(_) =>
return Response::text(400, "body is not valid JSON"),
Result::Ok(value) => value,
};
let input = match Schema::decode(CreateUser::schema(), Raw::of_json(document)) {
Validated::Invalid(problems) =>
return Response::json(422, problems),
Validated::Valid(input) => input,
};
let user: User = {
id: 1,
name: input.name,
};
Response::json(201, user)
}
pub fn main()
raises HttpError + ChildFailed
{
Router::new()
|> Router::post("/users", SharedFn::of(create_user))
|> Router::listen(8080)!
}

The boundary handles two distinct failures separately. parse returns Result::Err when the body is not JSON at all, so the handler answers 400. decode answers Validated::Invalid when the document is valid JSON but has the wrong shape for CreateUser, so the handler answers 422 — with every problem, because a Rejection encodes as an object with its path and its message, and a client wants the list rather than the first line of it:

[{"path":"name","message":"name should be text, and is 7"}]

A body over the limit never reaches the handler

Section titled “A body over the limit never reaches the handler”

create_user distinguishes two client mistakes. There is a third it never sees: a request over the router’s size limit is answered 413 and the connection closed before anything is parsed, so neither parse nor decode runs and none of the above applies. The default is 8 KB for headers and body together — the right answer to “how much may an unauthenticated client make a server hold”, and the wrong one for an API that accepts a document.

Say so in the pipeline:

Router::new()
|> Router::holding(1048576)
|> Router::post("/users", SharedFn::of(create_user))
|> Router::listen(8080)!

What that number costs a full server is worked out in an HTTP service.

Derive when the wire shape matches the type

Section titled “Derive when the wire shape matches the type”

These declarations:

derive(Decode)
pub type CreateUser = {
pub name: String,
};

ask the compiler to write the schema from the type. This is the right default when the API representation intentionally matches the Khora record.

When the external API has different field names, a rule that is part of the wire contract, or a legacy shape, implement Decode for that record by hand with a struct({ .. }) literal, and every schema that contains it picks the impl up through the trait. Decode untrusted input has the whole story.

Parsing and decoding are separate operations

Section titled “Parsing and decoding are separate operations”

Keep this distinction visible:

std::json::parse String -> Result<Json, JsonError>
Schema::decode Raw -> Validated<A, Rejection>
Raw::of_json Json -> Raw

Two different answers, and Raw::of_json sits between them: it takes a Json, not the Result that parse hands back, so the Result has to be resolved first. create_user above does it in two match expressions for exactly that reason.

A malformed document and a well-formed document with the wrong fields are different client mistakes. Keeping them separate also gives an API boundary enough information to choose different responses without string-parsing an error message.

Response::json takes anything that implements Encode: a derived record, a Json built by hand, a list of rejections.

Response::json(201, user)

A record holding a Redacted has no Encode, so it cannot be sent by accident; the build stops at the derive line.

A derived record’s keys go out in the order the type declares them, and an Option field holding None is left out. A Map or a Json object goes out sorted by key.

For more complex response policies, std::schema::json_text(value) gives the same text Response::json sends, and Response::of sends it with the status and content type you choose. std::json::encode(Raw::to_json(value.encode())) gives the same document built as a Json value first, with every object’s keys sorted.

For the complete JSON surface, see the JSON API reference. For HTTP routing and request data, see HTTP service. For state a handler keeps between requests — and why the answer is Shared<Dict<..>> rather than the Map a Request hands you — see State a handler shares between requests.

Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource