lino-rest-api

Case study: issue #5 — a full REST standard with Links Notation instead of JSON

Issue #5 asks this repository to become the library somebody reaches for when they want a REST API whose wire format is Links Notation rather than JSON, in JavaScript, Python and Rust, with the documentation to prove it. This document collects the data behind that request, states every requirement it contains, surveys what already exists, and records the plan and the outcome for each requirement.

The raw material is in raw-data/: the issue and its comments, the five upstream link-cli issues it points at, and the registry versions of the dependencies, all captured with gh and the public registry APIs.

1. The request, in the words of the issue

We need to make sure we support full REST API standard (with Links Notation instead of JSON) for any API here as library, including for use cases at https://github.com/link-foundation/link-cli/issues/32

Based on https://github.com/link-foundation/rust-ai-driven-development-pipeline-template we should add full support for Rust server and client libraries, as well as make sure we have all the features for python and javascript.

We must also have automated docs generation and GitHub Web Pages website, to show it all. So whoever needs REST API with links notation instead of JSON can make some.

Also we need to make sure we update exactly all our dependencies to the latest version […] We can pause if some of these repositories do not have some features we require, yet we should fully implement workarounds if possible here […]

— raw-data/issue-5.json. The issue carries no comments (raw-data/issue-5-comments.json is an empty list), so the body is the whole brief.

The upstream use case

link-cli issue #32, LINO API (REST), is two sentences long: “Make a REST style API, but use LINO instead of JSON.” (raw-data/link-cli-issue-32.json). It sits in a family of sibling issues that share one parent idea — expose the CLI’s link store over a network in a Links-Notation-native way:

Upstream issue Title Body
#30 LiNo protocol server mode (LINO API) “Add --server option to start server listening on a port. That can be implemented as websocket server.”
#31 Benchmark CLI access vs LiNo protocol server access (empty)
#32 LINO API (REST) “Make a REST style API, but use LINO instead of JSON.”
#33 LINO API (GRPC) “Make a GRPC style API, but use LINO instead of JSON.”
#34 LINO API (GraphQL) “Make a GraphQL style API, but use LINO instead of JSON.”

Two readings of the brief follow from this table, and the difference matters.

The narrow reading is “write a REST server for link-cli”. That is not what issue #5 asks for: it says “for any API here as library”, and link-cli is a separate repository written in Rust. The wide reading — the one taken here — is that this repository must offer a library that link-cli (and anyone else) can depend on to get a standards-complete REST layer for free, in whichever of the three languages they are working in. link-cli is a Rust program, which is exactly why the issue asks for Rust server and client support: without a Rust crate, the primary consumer of this work could not consume it at all.

The gRPC (#33) and GraphQL (#34) siblings are explicitly not in scope for this repository’s issue #5, which names only #32. They do, however, set a design constraint: the value model and the codec must not be entangled with HTTP, so that a future gRPC or GraphQL layer can reuse them.

2. What “full REST API standard” has to mean

“REST” is an architectural style, not a specification one can conform to mechanically, so “full REST API standard” needs a concrete reading before it can be implemented or tested. The reading adopted here is: everything the current HTTP RFCs expect of a well-behaved resource-oriented HTTP API, with LINO in the place JSON usually occupies. That resolves to a checkable list:

Concern Standard What it demands
Uniform interface RFC 9110 §9 GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS with their defined semantics; safe and idempotent methods behave as such
Method not allowed RFC 9110 §15.5.6 405 carries Allow
Content negotiation RFC 9110 §12 Accept with quality values, 406, Vary
Unsupported request bodies RFC 9110 §15.5.16 415 for an unknown Content-Type
Entity tags and caching RFC 9110 §8.8 strong ETag, Last-Modified
Conditional requests RFC 9110 §13 If-None-Match → 304, If-Match → 412, 428 when an edit is unconditional
Errors RFC 9457 problem details with type, title, status, detail, instance
Pagination RFC 8288 Link with first/prev/next/last
Cross-origin access Fetch, §CORS protocol preflight, allow-listed origins, Vary: Origin
Self-description OpenAPI 3.1 a machine-readable document; plus a home document for humans

The LINO substitution then adds requirements of its own. A media type has to exist for each notation flavour, JSON has to remain reachable as a fallback so that ordinary HTTP tooling still works, and — the subtle one — all three implementations must serialise identically. Strong entity tags are a hash of the encoded body; if the Python encoder emits one byte that the JavaScript encoder does not, a client that fetches from a Python server and revalidates against a Rust server gets a spurious 200 instead of a 304. Byte parity is therefore not a nicety, it is a correctness requirement that falls directly out of RFC 9110 §8.8.1’s definition of a strong validator.

3. Requirements extracted from the issue

Every requirement the issue states, numbered for reference, with its status in this pull request.

# Requirement Source sentence Status
R1 Support the full REST standard, with LINO in place of JSON, as a library “support full REST API standard (with Links Notation instead of JSON) for any API here as library” Done — specified in docs/spec/README.md, implemented three times
R2 Cover the link-cli #32 use case “including for use cases at …/link-cli/issues/32” Done — analysed in §1; the Rust crate is the consumable form
R3 Add a full Rust server library “add full support for Rust server … libraries” Done — rust/src/app.rs, serve.rs on axum
R4 Add a full Rust client library “… and client libraries” Done — rust/src/client.rs on reqwest
R5 Follow the Rust pipeline template “Based on …/rust-ai-driven-development-pipeline-template” Done — fmt/clippy -D warnings/test/package in CI, rustdoc published
R6 Make sure Python has all the features “make sure we have all the features for python” Done — parity table in §6
R7 Make sure JavaScript has all the features “… and javascript” Done — parity table in §6
R8 Automated documentation generation “We must also have automated docs generation” Done — scripts/build-docs.mjs drives JSDoc, pdoc and rustdoc
R9 A GitHub Pages website that shows it all “and GitHub Web Pages website, to show it all” Done — .github/workflows/docs.yml
R10 Be usable by a stranger who needs such an API “So whoever needs REST API with links notation instead of JSON can make some.” Done — quickstarts in all three READMEs, runnable examples per language
R11 Update exactly all dependencies to the latest version “we need to make sure we update exactly all our dependencies to the latest version” Done with two documented ceilings — §7
R12 Specifically links-notation and lino-objects-codec the two listed URLs Done — links-notation 0.20.0 everywhere; codec at each registry’s maximum, §7
R13 Work around missing upstream features rather than stopping “we should fully implement workarounds if possible here” Done — the vendored Python codec, §7.2
R14 Compile the collected data into ./docs/case-studies/issue-5 “make sure we compile that data to ./docs/case-studies/issue-{id} folder” Done — this folder
R15 Deep case study: requirements, solution plans, existing-component survey, online facts “use it to do deep case study analysis … list of each and all requirements … propose possible solutions and solution plans … check known existing components/libraries” Done — this document
R16 One pull request for everything “plan and execute everything in this single pull request” Done — #6

4. Existing components surveyed

Before writing anything, the obvious question is whether an existing library already does this. The survey below is why each piece was written, reused, or deliberately not used.

4.1 Could an existing framework be configured into it?

Candidate Why it does not solve R1 by itself How it is used here
FastAPI (Python) Its request/response model, validation and OpenAPI generation are built around JSON and Pydantic; a custom media type can be bolted on per route, but negotiation, conditional requests and problem details remain the author’s problem Kept as an optional adapter, fastapi_adapter.py, so existing FastAPI applications can add LINO routes; the core no longer depends on it
Express 5 (JavaScript) A routing and middleware layer, no opinion about representation, negotiation or caching Used as an optional mount target; the core app is framework-free
axum 0.8 (Rust) Excellent HTTP plumbing, Json<T> extractor; nothing about LINO, problem details or ETags Used as the server shell in serve.rs; the application itself is transport-agnostic
problem-details / http-problem-details crates and packages Cover RFC 9457 only, and only in JSON Not used — the problem document must serialise as application/problem+lino through the same codec as everything else, so it shares the value model instead
content-type / negotiator (npm), accept parsers Solve one row of the table in §2 Not used — a 90-line negotiator that the three languages can implement identically is cheaper than three different third-party parsers that disagree at the edges
hal, siren, jsonapi toolkits Hypermedia formats defined in terms of JSON Not used; Link headers (RFC 8288) carry the hypermedia affordances instead, which is format-neutral

The conclusion is the design: reuse transport libraries (axum, Express, ASGI, reqwest, httpx, fetch) and the notation libraries, and write the REST semantics once as a specification that all three implementations satisfy.

4.2 Reused wholesale

4.3 The precedent inside the Link Foundation

The issue points at rust-ai-driven-development-pipeline-template as the model for the Rust side (R5). What that template establishes, and what this pull request adopts, is the check set: cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test, and a packaging check, run in CI on every change, with the crate’s documentation published rather than merely built.

5. Solution plans, requirement by requirement

R1 — the standard, as a specification first

Options considered. (a) Implement three libraries and hope they agree. (b) Write a specification, then implement it three times, with a conformance suite derived from the specification. (c) Generate three implementations from a single source of truth.

Chosen: (b). Option (a) has no defence against drift — and drift here is observable by users as broken ETags. Option (c) buys consistency at the cost of idiomatic code in each language, which is precisely what R6/R7/R10 ask for.

Plan. Write docs/spec/README.md as eleven numbered sections — rationale, media types, value model, uniform interface, errors, collections, conditional requests, CORS, service description, client obligations, and a conformance checklist. Then implement each section in each language, and give each language a conformance test suite that walks the checklist in order. The checklist is the contract; js/tests/conformance.test.js, python/tests/test_conformance.py and rust/tests/conformance.rs are its three witnesses.

Byte parity. A shared fixture file, readable-format-cases.json, is checked into the Python and Rust test trees and exercised by test_codec_parity.py and codec_parity.rs; the JavaScript codec suite covers the same cases. Any encoder change that moves a byte breaks a test in at least one language.

R2 — the link-cli use case

Plan. Do not add a --server mode to anything; instead make the Rust crate the thing link-cli can depend on, with a Store trait it can implement over its own link storage, and an axum shell it can mount. rust/src/store.rs defines the trait and ships a MemoryStore; rust/examples/server.rs is the twenty-line program that turns a store into a standards-complete REST service. That is the smallest possible surface for issue #32 to consume.

R3 and R4 — the Rust server and client

Plan. Port the existing module structure one-for-one, so the three libraries stay legible side by side: app, client, codec, collection, cors, description, etag, headers, media_type, middleware, problem, query, request, resource, response, router, serve, store, value. Keep the application transport-free (LinoApp::respond(RawRequest) -> ResponseParts) so it can be tested in process and served over a socket by the same code, and re-export the JavaScript/Python surface at the crate root so the three libraries read alike.

Notable consequence. Every fallible operation returns LinoHttpError, because that is what becomes a problem document. It exceeds clippy’s result_large_err threshold, so the crate allows the lint at its root with the reasoning written down — and, because each integration test and example is its own crate, the allow has to be repeated in those files. That is a real and easily-missed property of Cargo’s build model, recorded here so the next person does not rediscover it.

R5 — the Rust pipeline

Plan. A test-rust job in release.yml running cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test and cargo package --allow-dirty, cached with Swatinem/rust-cache@v2, with the release job gated on it.

R6 and R7 — Python and JavaScript feature parity

Plan. Treat the specification’s checklist as the parity definition and hold all three to it, rather than comparing the libraries to each other informally. Where a language had a gap, close it (see §6).

R8 and R9 — documentation and the website

Options considered. (a) A documentation framework (Docusaurus, MkDocs, mdBook). (b) Three separate reference sites, linked from the repository README. (c) One small generator that renders the repository’s own markdown and embeds the three native reference builds.

Chosen: (c). (a) adds a large dependency and a second copy of the prose, which then rots against the READMEs GitHub shows. (b) leaves no place where a visitor sees “it all”, which is what R9 asks for. (c) keeps a single source per document: js/README.md is both what GitHub renders and what the website serves.

Plan. scripts/build-docs.mjs renders the prose pages with marked into a shared template, rewriting relative links so that a link which works on GitHub also works on the site (targets that are rendered pages become site-relative; everything else points back at the repository), then invokes JSDoc, pdoc and rustdoc and copies their output under api/. .nojekyll is written into the site root, because rustdoc emits _-prefixed directories that Jekyll would otherwise discard. docs.yml builds on every push and pull request and deploys to Pages outside pull requests.

R11, R12, R13 — dependencies

Plan. Check every direct dependency against its registry, raise it to the maximum published version, and where the maximum is not the upstream repository’s latest, say so explicitly rather than silently pinning. See §7.

R14, R15 — the case study

Plan. Capture the sources with gh and the registry APIs into raw-data/, then write this analysis against them, with every factual claim traceable to a file in that folder or to a cited specification.

6. Feature parity across the three languages

The specification’s conformance checklist, and where each requirement is met. Every row is covered by a test in all three suites.

Capability JavaScript Python Rust
text/lino, text/lino-line, text/lino-compact, application/json src/media-type.js media_type.py media_type.rs
Negotiation with quality values, 406, Vary ✓ ✓ ✓
415 on an unknown request media type ✓ ✓ ✓
Automatic HEAD, OPTIONS, 405 + Allow src/router.js router.py router.rs
Strong ETag (SHA-256 of the encoded body), Last-Modified src/etag.js etag.py etag.rs
If-None-Match → 304, If-Match → 412, 428 src/middleware.js middleware.py middleware.rs
RFC 9457 problem details as application/problem+lino src/problem.js problem.py problem.rs
Collections: filter, sort, paginate, sparse fields src/collection.js, src/query.js collection.py, query.py collection.rs, query.rs
RFC 8288 Link pagination ✓ ✓ ✓
CORS incl. preflight and Vary: Origin src/cors.js cors.py cors.rs
Service description + OpenAPI 3.1 src/description.js description.py description.rs
Resource registration over a pluggable store src/resource.js, src/store.js resource.py, store.py resource.rs, store.rs
HTTP client honouring the client obligations src/client.js client.py client.rs
Server shell Express / node:http ASGI / uvicorn axum
Runnable example js/examples/basic_usage.js python/examples/ rust/examples/basic_usage.rs, server.rs
Conformance suite tests/conformance.test.js tests/test_conformance.py tests/conformance.rs
Cross-language byte parity codec suite test_codec_parity.py codec_parity.rs

Test counts at the time of writing: 141 JavaScript tests, 344 Python tests, and the Rust suite’s 17 integration and unit binaries plus 25 documentation tests.

7. The dependency audit

Registry state as collected on 2026-09-16 (raw-data/registry-versions.json):

Package npm PyPI crates.io
links-notation 0.20.0 0.20.0 0.20.0
lino-objects-codec 0.8.0 absent 0.7.0
lino-rest-api (this project) unpublished unpublished unpublished

7.1 What is at its maximum

links-notation is at 0.20.0 in all three ecosystems and this repository requires 0.20.0 in all three. Every other direct dependency — Express 5.2.1, httpx, axum 0.8.9, reqwest 0.13.5, tokio 1.53.1, serde_json 1.0.151, sha2 0.11.0, async-trait 0.1.92 — is at its latest published version; cargo update --dry-run --verbose reports no available upgrade for any direct dependency. Ten transitive crates (encoding_rs, the icu_* family, idna_adapter, matchit) are held one version back by the crate’s rust-version = "1.85". Raising the MSRV to take them would trade a documented compatibility guarantee for nothing the library uses; the ceiling is recorded here instead.

7.2 Where upstream is the ceiling — and the workarounds

Two gaps exist, and R13 says to work around them rather than stop.

lino-objects-codec is not on PyPI at all. The Python package therefore carries a verbatim copy of the upstream Python implementation under python/src/lino_rest_api/vendor/lino_objects_codec, with the pinned upstream commit recorded in VENDORED.md and a refresh script, scripts/sync-vendored-codec.mjs, that re-syncs it. The vendored tree is exercised by the same parity fixtures as the other two languages, so a divergence between the copy and its upstream shows up as a failing parity test rather than as a silently different wire format. When the package appears on PyPI, the vendor directory can be deleted and a dependency added — the import surface in vendor/__init__.py is already shaped like the upstream module’s.

crates.io stops at 0.7.0 while npm has 0.8.0. The Rust crate therefore depends on lino-objects-codec = "0.7.0", the maximum that exists for it. This is not a pin chosen for convenience: 0.8.0 cannot be depended on from Cargo until it is published. The parity fixtures confirm that 0.7.0 produces the same bytes as npm 0.8.0 for every case in readable-format-cases.json, so the version skew is currently invisible on the wire. If a future codec release changes the encoding, the parity suites are what will detect it.

Neither gap is a reason to pause under R13’s test — both have working workarounds, and all the code that does not depend on the missing publications is complete.

8. What a future contributor should know

9. Sources