varyk GitHub

Varyk 0.7.0: routes the compiler checks

· by Vlad Mickevic

Varyk 0.7.0 is on crates.io. It is milestone 5b4 of the roadmap, the fifth part of the batteries for services. Milestone 5b3 gave a package's .rs facade what the database package varyk-sql needs. Milestone 5b4 does the same for HTTP: a handler is an ordinary async fn, a route names it, and varyk check checks the route against the handler's parameters before anything is built. With it, a users API on a database is varyk init, varyk add http sql, one file, and varyk run away.

The HTTP package itself, varyk-http, lives in its own repository and is released on its own; its first release, varyk-http 0.1.0, ships with this one, and has the server's settings, the client, WebSockets, and the rest. This release is the compiler side. varyk-sql 0.2.0 ships with it too: it moves to varyk-std 0.7, with nothing else changed, because a program and its packages share one varyk-std.

Why the compiler knows one package

Varyk has no function values, so a route table cannot be an ordinary call that takes a function: some part of it must be known to the compiler. A general mechanism, a facade parameter that takes a function, would need the same knowledge and a mechanism nothing else uses; attributes on handlers would scatter the route table across the program. So the compiler knows varyk-http by its crate name, as it knows varyk-std. When a build holds that package, under whatever key, the calls that add routes and hooks to its App are the compiler's own, and every other call on the app is an ordinary call of an imported struct. A package nobody designed with the compiler can still offer a plain function through the facade shapes every package has.

What is in it

Routes and handlers. This is the language reference's example, after varyk add http:

struct User {
    id: i64,
    name: string,
}

struct NewUser {
    name: string,
}

struct State {
    users: Vec<User>,
}

async fn get_user(id: i64, state: Shared<State>) -> Option<User> {
    for user in state.users {
        if user.id == id {
            return Some(user.clone());
        }
    }
    None
}

async fn create_user(user: NewUser, state: Shared<State>) -> Result<http::Response, Error> {
    if user.name.is_empty() {
        return Err(http::bad_request("a user needs a name"));
    }
    let created = User { id: state.users.len() as i64 + 1, name: user.name.clone() };
    let mut r = http::Response::json(created);
    r.set_status(201);
    Ok(r)
}

fn build_app(state: Shared<State>) -> http::App {
    let mut app = http::App::new(state.clone());
    app.get("/users/{id}", get_user);
    app.post("/users", create_user);
    app
}

and in async fn main():

let users = vec![User { id: 1, name: "Ada" }];
let app = build_app(Shared::new(State { users: users }));
let port: u16 = 3000;
if let Err(e) = app.serve(port).await {
    log::error("cannot serve: {}", e);
}

GET /users/1 is answered with a 200 and {"id":1,"name":"Ada"}, GET /users/2 with a 404, and GET /users/abc with a 400 saying that the path parameter id cannot be read from abc; get_user is not called for abc. POST /users with {"name": "Bo"} is answered with a 201.

A route is get, post, put, patch, or delete, with a path written in quotes, whose parts are either written out or a name in braces, {id}. Each parameter of the handler gets its value from the request by the first rule that fits: a parameter named after a {name} of the path is that part of the path; a Shared of the app's state is the state; an http::Request is the request itself, to read headers, cookies, or the body; on post, put, and patch, one parameter of a type JSON can hold is the body; and any other integer, bool, string, or Option of one is read by its name from the query string, so search(prefix: Option<string>, exact: bool) reads /search?prefix=A&exact=true.

Anything that fits no rule is an error at the route, one per problem: a {name} with no parameter, a body on get, two states, a parameter that gets no value from the request. So renaming a parameter cannot quietly stop it from getting its value. The request is read before the handler runs: a value that is not of its type, a missing query value, or a body that is not JSON of its type is a 400 naming the parameter, and the handler is not called, so inside a handler every parameter is a real value of its type.

Responses. What a handler returns is its answer: nothing is a 204, a value JSON can write a 200 with the value as JSON, an Option a 200 or, for None, a 404, and an http::Response is sent as built, for another status, a header, or plain text. A Result of one of these with Error gives the value's answer for Ok and the error's for Err.

Hooks. app.before(f) runs f before every request a route matches, app.before_on(prefix, f) before the routes under a prefix, and app.after(f) on every response the router makes. A before hook gives Ok(true) to let the request through, Ok(false) for a 403, or an Err; an after hook changes the response through a mut parameter:

async fn check_key(req: http::Request, state: Shared<State>) -> Result<bool, Error> {
    match req.header("x-key") {
        Some(key) => Ok(key == state.key),
        None => Err(http::unauthorized("an `x-key` header is needed")),
    }
}

async fn stamp(req: http::Request, mut res: http::Response) {
    res.set_header("x-served-by", "users");
}

These run with app.before_on("/admin", check_key); and app.after(stamp); in build_app, for a State with a key field. before_on is matched against the path of the route the request matched, as the program wrote it, on whole parts: "/admin" covers /admin/users/{id}, not /administrators, and no spelling of a request's path skips it. Each hook covers every route of the app, wherever its line is. A handler that needs to know who is asking calls a function of the program, let user = current_user(req, state)?;; a hook hands it nothing.

Requests in a test. app.request(req).await sends one request through the app, hooks included, without a port, and gives the response the server would have sent:

#[test]
async fn gets_a_user() {
    let users = vec![User { id: 1, name: "Ada" }];
    let app = build_app(Shared::new(State { users: users }));
    let r = app.request(http::Request::new("GET", "/users/1")).await;
    assert_eq(r.status(), 200);
    assert_eq(r.body(), "{\"id\":1,\"name\":\"Ada\"}");
}

build_app is the function main uses too, so a test sends its requests to the same app the service serves. A program that makes an app starts logging in main and in every async test, so the message of a 500 is on the screen under varyk test too.

Error with a status. Error gains an optional HTTP status. Error::with_status(status, text) makes one from Varyk, and e.status() reads it, an Option<u16>. varyk-http's constructors are Varyk functions over it: http::bad_request(text) (400), http::unauthorized(text) (401), http::forbidden(text) (403), http::not_found(text) (404), http::conflict(text) (409), and http::error(status, text) for any other.

How an error is sent is fixed by the compiler's design, because it is the rule that keeps a service's internals from its clients. An error with a status from 400 to 599 is sent with that status and the body {"error":"<message>"}. Any other error, one made by Error::new or passed on with ? from a database query, or one with a status outside 400 to 599, is a 500 with the fixed body {"error":"internal error"}, and its message is logged with the request's method and path. A status therefore means the message was written for the client, and only Varyk code sets one: no official package's Rust does. A database message, a file path, or an address inside an error stays in the log.

Two facade shapes. A .rs facade function may take a value JSON can write, as &T with T: serde::Serialize + ?Sized (or varyk_std::serde::Serialize + ?Sized), the half of the type parameter that milestone 5b3 left for this one. At a call it takes any type json::stringify writes, with the same checks, and the value is read, not given away. varyk-http's Response::json(value) and its client's post(url, body) take their values this way. The reference's in-memory store gains a method of this shape:

pub fn save<T: varyk_std::serde::Serialize + ?Sized>(
    &mut self,
    key: &'static str,
    value: &T,
) -> Result<u64, varyk_std::Error> {
    // stores `value` under `key` with `serde_json::to_value`
}

which Varyk code calls with a struct the facade has never seen, db.save("user/2", bo)?;, and bo stays usable after the call. A facade may also return a bare varyk_std::Error, for a function that makes an error of its own.

varyk add http sql. varyk add http runs cargo add varyk-http --rename http, so code writes http::App, as varyk add sql does for the database. Name several and each runs its own cargo add, in order, stopping at the first failure; the full crate names work too (varyk add varyk-http varyk-sql). That form takes no other argument; with one official name, any later arguments go to cargo as written (varyk add sql --features postgres). A short name with --rename is refused, since http and sql are also names of unrelated crates: write the full name, varyk add varyk-http --rename web.

New diagnostic codes. V0219 is a route whose path and handler do not fit. V0220 is a handler or hook of the wrong shape: not an async function of the package, a return type outside the list, a hook's parameters out of order. V0221 is a route or hook added to an app that is not a local made with http::App::new in the same function, inside a loop, or an assignment to a name holding an app. V0222 is a path or prefix that is not valid, or a method and path already taken. V0407 is a varyk-http that does not match this compiler. Others gain a case: V0901 names a route whose handler, or an app whose state, holds a Rust value that cannot go to another thread, and V0214 a route or hook that leads back to the function adding it. The reference has the new HTTP section, and the error codes the full table.

There is no new example program in the compiler's examples/ this time. Its test suite proves the routes with a stub varyk-http, an in-memory router with no network, and a program that uses it; the real package's own tests and its users demo run on axum.

For Rust readers

For each route the compiler writes one small function with concrete types, in the module of the route call. This is the one it writes for app.get("/users/{id}", get_user), from the users API at the top of the home page, as it is in the generated src/main.rs:

#[allow(warnings, arithmetic_overflow, unconditional_panic)]
async fn varyk_route_0(varyk_req: ::http::Request) -> ::http::Response {
    let varyk_0 = match varyk_req.param::<i64>("id") {
        Ok(v) => v,
        Err(r) => return r,
    };
    let varyk_1 = match varyk_req.state::<State>() {
        Ok(v) => v,
        Err(r) => return r,
    };
    match get_user(varyk_0, &varyk_1).await {
        Ok(v) => ::http::respond_option(v),
        Err(e) => ::http::respond_error(e),
    }
}

and the route becomes app.get("/users/{id}", ::http::route(varyk_route_0));. The program names only the package's items, never axum, so axum's Handler trait and its errors never reach a writer; --emit-rust shows every adapter, and a rustc error inside one is reported at its route.

What is not yet

From HTTP: a hook that wraps the handler and calls it itself; groups of routes as values; a handler reading a header or cookie by name without taking http::Request; a body read as anything but JSON or a multipart upload; headers for one client request beyond the client's defaults; adding a route to an app a function was given rather than one it made; a handler that is a method, a function of a .rs file, or a function of another package; TLS in the server, which is the job of the proxy in front of it; request and response bodies sent in pieces; an OpenAPI document written from the routes; and types for dates, times, UUIDs, and bytes, which come in milestone 5c. From facades: two type parameters, both kinds at once, a where clause, or another bound, and varyk_std::Error in a parameter or a field. The reference lists them all.

Upgrading

The three crates, varyk, varyk-syntax, and varyk-std, are released together as 0.7.0; varyk-std's Error gains its status. Nothing in 0.7.0 breaks an existing Varyk program; before 1.0, a new feature bumps the minor version, as a breaking change does. After cargo install varyk, run varyk check in a package, and it says what to change: the varyk-std line in Cargo.toml, or cargo update -p varyk-std.

A program that uses varyk-sql moves to varyk-sql 0.2 at the same time: run varyk add sql again, which rewrites the sql line to varyk-sql 0.2.0. Until it does, varyk check reports V0404 at varyk-sql 0.1's own manifest, not at the program's. The two releases differ only in the varyk-std they need: a program and its packages resolve to one varyk-std, so varyk-sql 0.2 works with Varyk 0.7, and varyk-sql 0.1 stays with Varyk 0.6. varyk-sql's README has the table.

Varyk 0.7.1, on 7 October, is a patch release: varyk init also writes a .dockerignore with target and .env, so a container build copies neither, and V0219's note for a path parameter of the wrong type names the types that fit. Nothing else changed. After cargo install varyk --locked, varyk check in a package made with 0.7.0 asks for cargo update -p varyk-std.

Varyk 0.7.2, the same day, is another patch release from the same walkthrough. main may return a Result of something and Error: ? then works in it, and an Err is logged, or printed on stderr in a program that does not log, and the program exits with code 1, so a service that cannot start says so to whatever runs it. A main that returns nothing works as before. A Result used where its value is wanted now suggests ?; http:: or sql:: used before varyk add http sql says how to add the package; and text that is not JSON at all, such as a form sent where a JSON body is expected, is reported as "it is not JSON".

Try it

You need a stable Rust toolchain installed through rustup.

cargo install varyk
varyk init hello
cd hello
varyk run

The getting started page goes from there, and the language reference describes everything the compiler accepts. For a service, varyk add http sql and the varyk-http post take it from there.

What is next

First, varyk-http 0.1.0, which ships with this release and has its own post: the server on axum with safe defaults, the client, WebSockets, server-sent events, uploads, metrics, and the users API as its demo.

Then milestone 5c, time, ids, and bytes: a date-time type, a UUID type, and a bytes type, so the users API does not send a date as a string, and varyk-http can read binary bodies. Milestone 5's bar, the golden path, is met when 5b4 and 5c are done. Beside them, as its own work, an agent evaluation: the examples written by a model from the language reference alone, with pass rates published, before any page claims that agents write Varyk well. The roadmap has every item, and nothing there is a date.

Varyk is experimental and pre-1.0. Write a small service with it and tell us where it hurts: the issues are the place.