lino-rest-api (JavaScript)
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.test.js is its executable form.
Installation
npm install lino-rest-api
# or
bun add lino-rest-api
Requires Node 20+, Bun or Deno — the client is built on the platform fetch, so it also runs in browsers.
Quick start
import { createLinoApp, MemoryStore } from "lino-rest-api";
const app = createLinoApp({ title: "Tasks API", version: "1.0.0", cors: true });
const tasks = new MemoryStore();
tasks.create({ title: "Write the specification", done: true });
// Registers list, create, get, replace, merge and delete, with entity tags,
// pagination, problem details, automatic HEAD/OPTIONS and 405.
app.resource("/tasks", tasks, { name: "task" });
app.get("/health", () => ({ status: "ok" }));
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
curl -H 'Accept: text/lino' http://localhost:3000/tasks/1
(
title "Write the specification"
done true
id 1
)
A full tour — negotiation, pagination, conditional requests, problem details — is in examples/basic_usage.js:
npm run example
The client
import { createLinoClient } from "lino-rest-api";
const client = createLinoClient("http://localhost:3000");
const created = await client.post("/tasks", { title: "Ship it", done: false });
// → { status: 201, location: "/tasks/2", etag: '"…"', data: { … } }
const page = await client.list("/tasks", {
done: false,
sort: "-id",
limit: 10,
});
// → { items: [ … ], page: { limit: 10, offset: 0, total: 1, count: 1 } }
const fresh = await client.get("/tasks/2", { ifNoneMatch: created.etag });
// → { status: 304, data: undefined }
try {
await client.get("/tasks/999");
} catch (error) {
error.status; // 404
error.problem; // { type, title, status, detail, instance }
}
Requests are sent as text/lino and Accept: text/lino, application/json;q=0.5 by default; 4xx and 5xx responses become a LinoClientError carrying the decoded problem details.
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 any object with list, get, create, update, patch and remove; MemoryStore is the built-in implementation, and it may be replaced with anything that fulfils the same contract (including async database access).
Options: name (used in problem details), operations (subset of ["list", "create", "get", "update", "patch", "remove"]), upsert, requirePrecondition.
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.
Handlers
A handler receives the Express request and returns a value, which is encoded as the negotiated representation:
import { created, noContent, status, LinoHttpError } from "lino-rest-api";
app.post("/echo", (req) => ({ echoed: req.body }));
app.post("/tasks", (req) => created({ id: 7 }, "/tasks/7"));
app.delete("/tasks/:id", () => noContent());
app.get("/teapot", () => status(418, { short: true, stout: true }));
app.get("/guarded", () => {
throw new LinoHttpError(403, "Not for you", { title: "Forbidden" });
});
Returning undefined sends 204. Any thrown value becomes problem details; LinoHttpError controls the status, title, headers and extension members.
Collection queries are parsed on demand with req.collectionQuery().
API reference
Generated with JSDoc:
npm run docs
Key exports: createLinoApp, LinoApp, createLinoClient, LinoClient, LinoClientError, MemoryStore, registerResource, LinoHttpError, ok, created, accepted, noContent, status, encode, decode, encodeFor, decodeFrom, encodeSingleLine, encodeCompactNotation, linoMiddleware, linoBodyParser, linoNegotiation, linoErrorHandler, linoCors, computeETag, evaluatePreconditions, parseCollectionQuery, applyCollectionQuery, collectionEnvelope, paginationLinkHeader, serviceDescription, openApiDocument.
Tests
npm test # node --test tests/*.test.js
bun test # the same suites under Bun
License
Unlicense