# lino-rest-api (Rust)
A REST API framework and client that speak [Links Notation](https://github.com/link-foundation/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`](../docs/spec/README.md); [`tests/conformance.rs`](tests/conformance.rs) is its executable form. The JavaScript and Python packages speak byte-for-byte the same wire — [`tests/codec_parity.rs`](tests/codec_parity.rs) runs the shared cross-language fixtures — so the three implementations share entity tags.
## Installation
```bash
cargo add lino-rest-api
```
Requires Rust 1.85 (edition 2024). The server shell is built on [axum](https://github.com/tokio-rs/axum) and the client on [reqwest](https://github.com/seanmonstar/reqwest); the application itself is a plain request handler, so it can also be driven in process, without a socket.
## Quick start
```rust
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
}
```
```bash
curl -H 'Accept: text/lino' http://localhost:8000/tasks/1
```
```lino
(
title "Write the specification"
done true
id 1
)
```
That service is [`examples/server.rs`](examples/server.rs):
```bash
cargo run --example server
```
A full tour — negotiation, pagination, conditional requests, problem details — is in [`examples/basic_usage.rs`](examples/basic_usage.rs), which starts a server on an ephemeral port and drives it through the client:
```bash
cargo run --example basic_usage
```
## The client
```rust
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
| `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](https://www.rfc-editor.org/rfc/rfc9457) 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:
| `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-Match` on a safe method → `304 Not Modified`
- `If-Match` that does not match → `412 Precondition Failed`
- no `If-Match` where one is required → `428 Precondition Required`
## Describing the service
| `/.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:
```rust
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),
)]))
});
## Tests
```bash
cargo test
cargo fmt --check
cargo clippy --all-targets
```
## License
Unlicense