lino-rest-api (Rust)
A REST API framework and client that speak Links Notation instead of JSON.
Everything the HTTP standards expect of a REST service — content negotiation, conditional requests, problem details, collection pagination, automatic HEAD/OPTIONS/405, CORS, a service description and an OpenAPI document — is provided out of the box, with LINO as the default wire format and JSON as a fallback.
The wire protocol is defined in docs/spec/README.md; tests/conformance.rs is its executable form. The JavaScript and Python packages speak byte-for-byte the same wire — tests/codec_parity.rs runs the shared cross-language fixtures — so the three implementations share entity tags.
Installation
cargo add lino-rest-api
Requires Rust 1.85 (edition 2024). The server shell is built on axum and the client on reqwest; the application itself is a plain request handler, so it can also be driven in process, without a socket.
Quick start
use std::sync::Arc;
use lino_rest_api::{
MemoryStore, ResourceOptions, ServiceInfo, boolean, create_lino_app, object, serve, string,
};
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut app = create_lino_app().with_info(ServiceInfo::new("Tasks API", "1.0.0"));
let tasks = Arc::new(MemoryStore::seeded([object([
("title", string("Write the specification")),
("done", boolean(true)),
])]));
// Registers list, create, get, replace, merge and delete, with entity tags,
// pagination, problem details, automatic HEAD/OPTIONS and 405.
app.resource("/tasks", tasks, ResourceOptions::new().with_name("task"));
app.get_fn("/health", "Liveness probe", |_request| {
Ok(object([("status", string("ok"))]))
});
serve(app, "0.0.0.0:8000").await
}
curl -H 'Accept: text/lino' http://localhost:8000/tasks/1
(
title "Write the specification"
done true
id 1
)
That service is examples/server.rs:
cargo run --example server
A full tour — negotiation, pagination, conditional requests, problem details — is in examples/basic_usage.rs, which starts a server on an ephemeral port and drives it through the client:
cargo run --example basic_usage
The client
use lino_rest_api::{LinoClient, RequestOptions, boolean, int, object, string};
let client = LinoClient::new("http://localhost:8000");
let plain = RequestOptions::new;
let created = client
.post(
"/tasks",
&object([("title", string("Ship it")), ("done", boolean(false))]),
&plain(),
)
.await?;
// → status 201, location "/tasks/2", etag "\"…\"", data Some(…)
let page = client
.list(
"/tasks",
&plain()
.with_query("done", &boolean(false))
.with_query_text("sort", "-id")
.with_query("limit", &int(10)),
)
.await?;
// → (items ( … ) page (limit 10 offset 0 total 1 count 1))
let fresh = client
.get("/tasks/2", &plain().if_none_match(created.etag.unwrap()))
.await?;
// → status 304, data None
match client.get("/tasks/999", &plain()).await {
Err(error) => {
error.status(); // Some(404)
error.problem(); // Some((type … title … status … detail … instance …))
}
Ok(_) => unreachable!(),
}
Requests are sent as text/lino and Accept: text/lino, application/json;q=0.5 by default; 4xx and 5xx responses become a LinoClientError::Status carrying the decoded problem details, and Display renders the detail member, so error.to_string() is the message the service sent.
with_accept, with_content_type, with_header and with_http_client configure a client; RequestOptions carries what a single call adds.
Representations
| Media type | Shape |
|---|---|
text/lino |
Readable, indented Links Notation. The default. |
text/lino-line |
The same value on a single line: (o: (a 1)). |
text/lino-compact |
Type-tagged base64, preserving object identity and cycles. |
application/json |
Fallback for clients that cannot speak LINO. |
Errors use application/problem+lino (or application/problem+json), carrying RFC 9457 problem details.
The response representation is chosen from Accept, including quality values; every response carries Vary: Accept, an unsatisfiable Accept is a 406, and an undecodable Content-Type is a 415.
Resources
app.resource(path, store, options) turns any store into a full collection:
| Request | Behaviour |
|---|---|
GET /tasks |
Envelope (items … page …) with Link pagination |
POST /tasks |
201 with Location and the created representation |
GET /tasks/:id |
The representation, with a strong ETag |
PUT /tasks/:id |
Replace (optionally upsert) |
PATCH /tasks/:id |
Merge |
DELETE /tasks/:id |
204 |
Collection queries support limit, offset, sort=-priority,title, fields=id,title and arbitrary field filters (?done=false&tag=a&tag=b).
A store is anything that implements the Store trait — list, get, create, update, patch and remove, each of them async and each free to fail with a LinoHttpError, so a database-backed store is written the same way as the built-in MemoryStore.
Options: with_name (used in problem details), with_id_param, with_id_field, with_operations (subset of ["list", "create", "get", "update", "patch", "remove"]), with_upsert, requiring_precondition.
Conditional requests
Every representation carries a strong ETag: the SHA-256 of the encoded body, so the tag is per representation — the LINO and JSON forms of the same value have different tags, which is why Vary: Accept is always present.
If-None-Matchon a safe method →304 Not ModifiedIf-Matchthat does not match →412 Precondition Failed- no
If-Matchwhere one is required →428 Precondition Required
Describing the service
| Path | Document |
|---|---|
/.well-known/lino-api |
Service description: routes, methods, media types, info |
/.well-known/openapi.json |
OpenAPI 3.1, every media type declared, problem responses |
app.describe() and app.openapi() return the same documents in process, and without_description() withdraws both routes.
Handlers
A handler receives a LinoRequest and returns a value, which is encoded as the negotiated representation:
use lino_objects_codec::LinoValue;
use lino_rest_api::{LinoHttpError, boolean, created, int, no_content, object, status};
app.post_fn("/echo", "Echo the body", |request| {
Ok(object([(
"echoed",
request.body.clone().unwrap_or(LinoValue::Null),
)]))
});
app.post_fn("/tasks", "Create a task", |_request| {
Ok(created(object([("id", int(7))]), "/tasks/7"))
});
app.delete_fn("/tasks/:id", "Delete a task", |_request| Ok(no_content()));
app.get_fn("/teapot", "Short and stout", |_request| {
Ok(status(418, object([("short", boolean(true))])))
});
app.get_fn("/guarded", "Never allowed", |_request| {
Err::<LinoValue, _>(LinoHttpError::new(403, Some("Not for you")).with_title("Forbidden"))
});
route, get, post, put, patch and delete take an async handler; the _fn variants take a plain one. Returning () sends 204. A returned LinoHttpError becomes problem details, and controls the status, title, headers and extension members.
The request carries method, path, headers, params, query, the decoded body and the negotiated media_type; collection queries are parsed on demand with request.collection_query().
Running the application
serve(app, address) binds and serves. BoundServer::bind(app, "127.0.0.1:0") binds first and reports the port that was chosen, which is how the test suite runs a real server on an ephemeral port. serve::router(Arc::new(app)) returns the axum Router, for mounting the service inside a larger application, and app.respond(request) answers a request in process, without a socket at all.
API reference
Generated with cargo doc, and published at docs.rs/lino-rest-api:
cargo doc --no-deps --open
Key exports: create_lino_app, LinoApp, RawRequest, LinoRequest, create_lino_client, LinoClient, LinoClientError, LinoResponse, RequestOptions, MemoryStore, Store, ResourceOptions, LinoHttpError, ServiceInfo, CorsPolicy, ResponseParts, ok, created, accepted, no_content, status, raw_response, serve, BoundServer, encode, decode, encode_for, decode_from, encode_single_line, encode_compact_notation, object, array, string, int, boolean.
Tests
cargo test
cargo fmt --check
cargo clippy --all-targets
License
Unlicense