Wisp is in pre-release, self-hosted since GitHub suspended us. Install it, or see the changelog.

Wisp

APIs and Platforms

An API is a folder of +server.rs files beside pages, built into one binary. Start one with wisp new my-api --api and wisp dev, then open /_wisp/docs. A page can also keep endpoints in its --- block as mod server { … } (Pages). Template: examples/api, also run in the browser.

An Endpoint

// src/routes/api/notes/+server.rs
#[derive(FromJson)]
struct NewNote {
    #[validate(min_len = 1, max_len = 200)]
    title: String,
    tags: Option<Vec<String>>,
}

fn get(q: Option<String>) -> Vec<Note> {
    db::notes(q.as_deref())
}

fn post(body: NewNote) -> Result<Response> {
    Ok(Response::created(&db::add(body.title, body.tags.unwrap_or_default())?))
}
  • The fn name is the method (get post put patch delete). HEAD is get.
  • OPTIONS is a 204 + Allow. A method the route lacks is a 405 + Allow.
  • A handler can take cx: &mut Cx and return Result<Response>.

A Folder and Its [id]

A handler with a param id, in a folder whose URL has none, answers at /[id] below it ([id=int] when numeric). list is the folder's GET:

// src/routes/api/notes/+server.rs
// GET /api/notes
fn list() -> Vec<Note> {
    db::notes()
}
fn post(body: New) -> Result<Response> {
    Ok(Response::created(&db::add(body)?))
}
// GET /api/notes/7
fn get(id: u64) -> Option<Note> {
    db::note(id)
}
fn patch(id: u64, body: Changes) -> Option<Note> {
    db::change(id, body)
}
// 204, or 404
fn delete(id: u64) -> Option<()> {
    db::remove(id)
}

Two files that answer the same URLs are a build error.

A Resource

#[derive(Rest)]
#[rest(write = "API_KEY")]
struct Note {
    #[validate(len = 1..=200)]
    title: String,
    done: bool,
}
RequestAnswer
GET /api/notesrows by id
POST /api/notes201 + row + Location, 422 if invalid. An array makes several, all or none
GET /api/notes/1row with etag, or 404
PUT /api/notes/1replaced by the body
PATCH /api/notes/1body's members replace the row's (null empties an Option), 422 if the result fails
DELETE /api/notes/1204 or 404
  • Rest = Json + FromJson + a table Note::table() other routes read too.
  • A handler the file writes (fn delete(id: u64)) replaces that one. fn before(cx: &mut Cx) runs before each.
  • A field left out: bool false, Vec [], Option None. Others need a value.
  • Wisp sets created_at/updated_at (RFC 3339 String, or whole seconds).
#[rest(...)]Does
write = "API_KEY"writes 401 unless Authorization: Bearer <value of API_KEY>
key = "KEY"same for every request
admin = "ADMIN_KEY"same for DELETE
table = "notes"store name (default: type name lowercased)
ids = "random"random ids below 2^53
memorymemory only

Queries

QueryGives
?done=truerows with that value
?points.gte=3.ne .gt .gte .lt .lte
?title.has=teatext containing it (any case), or a list containing the item
?sort=-created_at,title- descending
?limit=20&after=4020 rows after id 40 (stable cursor)
?limit=20&offset=40skip 40
?fields=title,doneonly those and id
  • An unknown field is a 400 listing the known ones.
  • A sorted page selects its rows first and sorts only the first offset + limit of them. A query takes at most 16 sort keys and 16 filters. More is a 400 (code bad_query). A field sorted twice counts once in the sort.
  • A list has 100 rows without ?limit, 1000 at most. x-total-count has the match count, link: <…?limit=20&after=60>; rel="next" the next page (with WISP_BASE). accept: application/x-ndjson gives a line per row.
  • Every row and list has an etag. if-none-match on a read gets a 304. A write with if-match is a 412 (code changed) if the row changed, checked before any before_ hook. A write's if-none-match naming the row (or *) is a 412 (code matched) and changes nothing. A PATCH, or a PUT of a type with #[json(skip)] fields, is a 409 (code conflict) when the row changes between its read and its write. A before_update hook must not write the row that it updates: the update then fails with a 409.
  • A Password field is hashed as it comes in and shows null in every answer. A POST array of rows with a Password takes at most 16 rows. More is a 413 (code too_many), because each hash costs the worker about a fifth of a second. A PATCH that leaves it out keeps the hash. A .live() table tells its pages about every write.
  • const CACHE: u32 = 5; in a +server.rs caches each GET per path and query per worker (rules: Limits and caching).

Hooks

fn before_create(note: &mut Note) -> Result {
    note.title = note.title.trim().to_string();
    if note.title == "admin" {
        return invalid("title", "is reserved");
    }
    Ok(())
}
fn after_update(note: &Row<Note>) {
    wisp::channel("notes").send(wisp::to_json(note));
}
  • before_create/before_update take the value (&mut Note or &Note, and for update also id: u64).
  • before_delete, after_create, after_update, after_delete take the row (&Row<Note>, or &Note and id: u64).
  • Any may take cx and return nothing or Result. A before_ error stops the change (422 from invalid names the field). Hooks run outside the table lock.

A type with a field named user, in users/[user=int]/notes/+server.rs, holds each user's own rows: the route's user filters every request and is set on every row written there.

Storage, custom stores and paging: Where rows are kept.

Is This Page Useful?

View This Page's Source

Type to search every docs page and heading.