Skip to content

feat(rust): Toapi for Rust — server, OpenAPI and a generated path-tree client - #535

Draft
mismosmi wants to merge 22 commits into
mainfrom
claude/rust-toapi-redesign-abyykh
Draft

mismosmi wants to merge 22 commits into
mainfrom
claude/rust-toapi-redesign-abyykh

Conversation

@mismosmi

@mismosmi mismosmi commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Adds a Cargo workspace under rust/ that brings the Toapi concepts to Rust:

  • define the API once on the server
  • generate OpenAPI from it, and a typed client from that
  • authorize each route method, like the TypeScript server
  • cache responses on the server and in the client by tag
  • keep client caches fresh through tag-based invalidation and the invalidation stream

The client runs natively and in the browser (wasm32-unknown-unknown).

users-server (axum handlers + toapi-server)
   │  api().openapi()
   ▼
openapi.json  (committed; a test fails when it is out of date)
   │  toapi_client::generate!("../users-server/openapi.json")  (or toapi-codegen in build.rs)
   ▼
users-client  (api.users.id(1).get() path tree + request types + toapi-client runtime)
// server
Api::new("Users", "1.0.0")
    .cache(Arc::new(InMemoryCache::new()))
    .route("/users", get(public, list_users).post(signed_in, create_user))
    .route("/users/{id}", get(public, get_user).put(signed_in, update_user))

// client
toapi_client::generate!("../users-server/openapi.json");

let api = Api::new("http://localhost:3000/api");
let user  = api.users.id(123).get().await?;                 // Arc<User>, shared with the cache
let users = api.users.get().limit(10).await?;               // Arc<Vec<User>>
let user  = api.users.id(123).put().name("Alicia").role(Role::Member).await?;  // User
let user  = api.users.id(123).put().body(new_user).await?;
let mut live = api.users.id(123).get().watch();             // Stream<Item = Result<Arc<User>, Error>>

Crates

  • toapi-core: wire constants, the naming scheme and the server Cache trait (with CacheEntry, Lookup and Invalidation), so cache implementations don't depend on the server. For GET /users/{id} the naming scheme gives GetUsersByIdRequest, GetUsersByIdQuery, PutUsersByIdRequestBody, GetUsersByIdResponseBody, GetUsersByIdCall and UsersByIdRoute.
  • toapi-server: an Api builder on top of axum, created with Api::new(title, version) for the OpenAPI document.
    • GET handlers must return Cached<T> and mutations Invalidates<T>; anything else is a compile error.
    • Authorization: every route method takes an authorize first, like a TypeScript handler's authorize:
      • It's an async function of extractors that don't read the body, returning Result<A, E>. On Err, the error is the response.
      • public lets every request through.
      • Handlers read A with the Auth<A> extractor. route panics when a handler's Auth<A> isn't what its authorize returns.
    • Extractors implement OperationInput, which feeds the OpenAPI 3.1 document.
    • route panics if the {placeholders} don't match the handler's Path<T>.
    • Server-side cache, like the TypeScript server's Cache:
      • A GET runs authorize, then looks up its URL in the cache, then runs the handler on a miss.
      • A successful Cached<T> that explicitly set tags or a TTL is stored for its TTL, or .default_ttl(..) (14 days, like TS). Responses without either are never stored.
      • Mutations delete the entries carrying their tags before their response goes out, then notify the invalidation streams.
      • A response computed while one of its tags was invalidated isn't stored (lookups return a token that set checks).
      • PubSub (the default) caches nothing. toapi_cache::InMemoryCache caches within one process. Both are set with .cache(Arc::new(..)), and callers keep a clone to invalidate from background jobs.
      • Handlers invalidate imperatively through the RequestCache extractor, attributed to the calling client like req.invalidate.
    • The invalidation stream supports throttling, keepalive, a per-request tag filter, the session cookie and tag hashing.
  • toapi-cache: server cache implementations, currently InMemoryCache.
  • toapi-codegen: generates the client code from the OpenAPI document:
    • Path tree rooted at Api: static segments are fields, parameters are typed methods, and HTTP methods return call builders.
    • Call builders have one setter per query parameter and body field. Optional ones can be left out. Required ones are tracked in the builder's type, so leaving one out is a compile error that names the field (PostUsersCall<Set, Unset<required::role>> is not a future).
    • Whole-struct setters: .query(GetUsersQuery { .. }) and .body(NewUser { .. }) set all fields of a query or body struct at once, including the required ones.
    • Request structs (GetUsersByIdRequest) implement Request, with type Kind = Query for GET and Mutation otherwise. They can be built with .into_request() or run directly with client.call(..).
    • Name collisions and conflicting parameter names fail the build with an error naming both paths.
  • toapi-macros: generate!, which runs the codegen at compile time.
    • The path is relative to the crate's Cargo.toml.
    • The crate is rebuilt when the document changes.
    • Errors point at the path literal.
    • It's re-exported by toapi-client, so no build script is needed.
    • The build-script route (toapi_codegen::build) is still available.
  • toapi-client:
    • One Client::call(request), whose return type comes from the request's Kind:
      • GET requests return a QueryCall, which can be awaited or watched as a stream.
      • Mutations return a MutationCall, which resolves after the affected watched queries were refetched.
    • A cache that works like the TypeScript client's:
      • Stored in a DashMap, so reads only take a read lock on one shard.
      • The first query for a URL is cached immediately, as a shared future backed by its own task. Concurrent queries for the URL share that request, and it completes even if nobody waits for it anymore.
      • Each response is parsed once and kept as Arc<dyn Any + Send + Sync>. Queries and watch streams resolve to an Arc<T> of it, downcast without copying, so two queries for the same URL get the same allocation. Mutations aren't cached and resolve to plain values.
      • Invalidation deletes the entries whose tags (a HashSet) intersect the invalidated ones, and publishes their URLs to a pub-sub of callbacks, like the TypeScript client's PubSub. Publishing awaits all callbacks.
      • Failed requests are dropped after min_ttl, and responses with a TTL once it's over plus up to max_overdue_ttl. Both are set on ClientBuilder and match the TypeScript client's minTTL and maxOverdueTTL (defaults 100ms and 1s).
    • Watch streams subscribe a callback as soon as watch() is called:
      • When the stream's URL is invalidated, the callback refetches it through the cache and pushes the result into a tokio::sync::watch channel that the stream reads from.
      • The channel keeps only the latest result, so a stream nobody polls neither blocks invalidations nor piles up responses.
      • Dropping the stream, polled or not, unsubscribes the callback.
    • An invalidation stream listener that reconnects with backoff and drops the cache on reconnect.
    • WebAssembly: a small platform module covers what differs in the browser:
      • spawning and sleeping: wasm_bindgen_futures and gloo-timers instead of tokio
      • the clock: js_sys::Date::now()
      • Send bounds: MaybeSend, also used by the generated code
      • cookies: requests include credentials, and the browser keeps the session cookie
      • expiry jitter: a SplitMix64 counter, the same on all targets

The wire protocol (X-TAPI-Tags, X-TAPI-Expires-At, /__tapi/invalidations, /__tapi/openapi.json, the __tapi-session cookie, tag hashing) matches the TypeScript packages. The tag hash is pinned to the value @toapi/server produces.

Releasing the crates is tracked in #537 (switching to knope); this PR adds no changeset.

Not covered yet

Also listed in rust/README.md:

  • Redis/Postgres caches, for caching across server processes
  • NDJSON responses, multipart bodies and array query parameters

Testing

  • Unit tests for:

    • naming
    • the server caches: PubSub and RequestCache (in toapi-server), and InMemoryCache (in toapi-cache: storing, invalidating, expiry, skipping responses older than an invalidation)
    • the client cache
    • codegen, including the tree, the whole-struct setters and conflict errors
    • tag hashing, the clock and the jitter
  • Server caching tests (crates/toapi-server/tests/caching.rs), driven through the router:

    • tagged responses are served from the cache, with their tags and content type, keyed by URL including the query
    • responses without tags or a TTL, and errors, are never cached
    • TTL expiry, with the original X-TAPI-Expires-At kept on cache hits
    • mutations invalidate cached responses
    • authorize runs before the cache, so a rejected request never sees a cached response
    • handlers read the authorize result through Auth<A>
    • a mismatched Auth<A> panics when the route is added
  • Client cache integration tests (crates/toapi-client/tests/cache.rs) against a counting server:

    • five concurrent queries make one HTTP request and one parse
    • later queries don't parse again and return the same Arc (Arc::ptr_eq)
    • invalidation refetches
    • a watched refetch is shared with the next query and done when invalidate resolves
    • a watch stream nobody polls doesn't block an invalidation, and its callback still refetches
    • dropped watch streams unsubscribe, whether polled or not
    • failed requests are retried after min_ttl, and min_ttl is configurable
  • Browser tests (crates/toapi-client/tests/wasm.rs): the client compiled for wasm32-unknown-unknown, run in Node through wasm-bindgen-test-runner against an HTTP server written in JavaScript:

    • concurrent queries share one request and value
    • invalidation refetches watch streams
    • failed requests are retried after min_ttl
    • a watch stream follows another client's mutation through the invalidation stream
  • A test that fails when openapi.json is out of date.

  • End-to-end tests (examples/users-client/tests/e2e.rs) against the real server, which uses InMemoryCache, with the client generated by generate!:

    • queries and mutations through the tree, with field setters and with whole query/body structs
    • the request structs run with client.call
    • into_request() builds the expected structs
    • an unauthorized mutation returns 401
    • watch() follows the client's own mutations, and another client's mutation through the invalidation stream
  • compile_fail doctests in users-client for:

    • a missing required body field
    • a wrongly typed path parameter
    • a method the route doesn't have
    • watch() on a mutation, through the tree and through client.call
  • Checked manually:

    • touching openapi.json marks users-client dirty
    • a wrong path gives could not read …/openapi.jsn, pointing at the macro argument
  • CI (rust job in ci.yml) runs:

    • cargo fmt --check, cargo clippy -D warnings and cargo test --workspace
    • clippy for wasm32-unknown-unknown on the client and the example client
    • the browser tests

    All of these pass locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb

…quest macros

Adds a Cargo workspace under rust/ with the Toapi concepts in Rust:

- toapi-server: define the API once on axum. GET handlers return Cached<T>,
  mutations return Invalidates<T>; the handler signatures produce the
  OpenAPI 3.1 document. Includes the invalidation stream (throttling,
  keepalive, per-request tag filter, session cookie, tag hashing) with the
  same wire protocol as @toapi/server.
- toapi-codegen: generates request types from the OpenAPI document,
  named deterministically from method and path (GetUsersById,
  GetUsersByIdQuery, PutUsersByIdRequestBody, GetUsersByIdResponseBody).
- toapi-client: client.query(..) / client.mutate(..) with a tag cache,
  watch() streams and the invalidation stream listener.
- toapi-macros: get!/post!/put!/patch!/delete! build requests from a
  route template, deriving the type names through toapi-core.
- users-server / users-client examples with an OpenAPI drift test and
  end-to-end tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
@changeset-bot

changeset-bot Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5d813be

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

…t macros

The generated client is now called through a path tree:

    api.users.id(123).get().await
    api.users.get().limit(10).await
    api.users.id(123).put().name("Alicia").role(Role::Member).await

Static segments are fields, parameters are methods, and HTTP methods
return call builders with one setter per query parameter and body field.
Required ones are tracked in the builder's type, so a call missing one
doesn't compile and the error names the field.

- Remove the toapi-macros crate (get!/post!/put!/patch!/delete!).
- Rename the request structs to `{Method}{Path}Request`
  (e.g. GetUsersByIdRequest); they still implement Request and can be
  run with client.query/mutate or built with `.into_request()`.
- Add compile_fail doctests for missing required fields, wrong parameter
  types, missing methods and watch() on mutations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
@mismosmi mismosmi changed the title feat(rust): Toapi for Rust — server, OpenAPI, generated client and request macros feat(rust): Toapi for Rust — server, OpenAPI and a generated path-tree client Oct 26, 2026
Request gains a `Kind` associated type (Query or Mutation), set by the
codegen from the HTTP method. Client::call dispatches on it and returns
QueryCall (awaitable and watchable) or MutationCall (awaitable, waits for
the refetch of invalidated queries), so watch() on a mutation still
doesn't compile. The QueryRequest/MutationRequest marker traits are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Call builders of routes with query parameters get `.query(QueryStruct)`,
and routes with an object body get `.body(BodyStruct)`. They set all of
the struct's fields at once and mark its required fields as set in the
typestate, so `api.users.post().body(new_user).await` compiles.
Individual setters still work afterwards. Field setters that would be
named `query` or `body` get a `query_`/`body_` prefix instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
claude added 10 commits October 26, 2026 20:40
toapi_client::generate!("../server/openapi.json") reads the OpenAPI
document at compile time (relative to the crate's Cargo.toml) and expands
to the generated client. It tracks the file through include_bytes!, so the
crate is rebuilt when the document changes. The macro lives in a new
toapi-macros proc-macro crate, re-exported by toapi-client, and uses the
new toapi_codegen::generate_tokens.

The users-client example now uses the macro instead of build.rs; the
build-script route is still available and documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
… values

The client cache now works like the TypeScript client's:

- The first query for a URL stores its cache entry immediately, as a
  shared future backed by a spawned task; concurrent queries for the URL
  await the same request, and it completes even if nobody waits anymore.
- The response is parsed once and stored as Arc<dyn Any + Send + Sync>;
  queries get a clone of the parsed value instead of re-parsing bytes.
- Invalidation deletes the matching entries and publishes their URLs to
  the watch streams. Watched URLs are refetched before the message goes
  out, so the streams pick up those requests from the cache, and
  mutations resolve once the refetches are done.
- Failed requests are dropped after 100ms, responses with a TTL once it's
  over (with up to 1s of jitter), like the TypeScript client.

Error is now Clone (shared by everyone awaiting a request) and gains an
Internal variant. Adds integration tests against a counting server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Awaiting a query and watch() streams now yield Arc<Response>: the parsed
value in the cache, downcast with Arc::downcast instead of cloned. Two
queries for the same URL get the same allocation. Response types no
longer need to be Clone. Mutations aren't cached and still resolve to
plain values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Replaces the broadcast channel and the watcher bookkeeping (counts and
reload closures in the cache) with a PubSub of callbacks, like the
TypeScript client:

- watch() subscribes a callback right away. When its URL is invalidated,
  the callback refetches it through the cache and pushes the result into
  a tokio watch channel the stream reads from.
- publish() calls every callback and awaits them all, so invalidations
  (and mutations) resolve once the watched queries were refetched.
- Pushing doesn't depend on the stream being polled, and the channel
  keeps only the latest result, so an unpolled stream neither blocks
  invalidations nor piles up responses.
- Dropping the stream unsubscribes its callback.

Adds a test for an unpolled stream and for unsubscribing on drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
The Subscription guard is captured by the stream's async block, which
drops it even if the stream was never polled. Covers that case next to
the existing test for dropping a polled stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Invalidating tags now looks the URLs up in a tag index instead of
scanning every entry. Entries keep their tags so they're removed from
the index together with the entry (on invalidation, expiry, failure or
replacement), so the index only ever holds current entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Cache entries keep their tags in a HashSet, and invalidation matches
entries with a set intersection check (`!entry.tags.is_disjoint(&tags)`)
instead of comparing every tag pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Replaces the hardcoded ERROR_TTL and MAX_OVERDUE constants with
ClientBuilder::min_ttl and ClientBuilder::max_overdue_ttl, matching the
TypeScript client's minTTL and maxOverdueTTL options and their defaults
(100ms and 1s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
The client cache is read far more often than written, so it is now a
DashMap instead of a HashMap behind one Mutex: reads take a read lock on
a single shard and don't block each other. Getting or creating an entry
reads first and only falls back to entry().or_insert_with() on a miss,
which stays atomic, so concurrent first queries still share one request.
Entry ids come from an AtomicU64.

The subscription list is read on every invalidation and only written
when watch streams subscribe or unsubscribe, so it is now an RwLock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Comment thread rust/crates/toapi-client/src/client.rs Resolved
Tags are collected into a HashSet<String> where they enter the client:
the X-TAPI-Tags header, invalidation stream lines and
Client::invalidate. Inner::invalidate_tags, Cache::invalidate_tags and
Cache::set_tags take the set as is instead of converting a Vec again.
settle now takes the response metadata by value, so the tags are moved
into the cache entry instead of cloned; failed requests are handled by
a separate drop_failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
A platform module (rt.rs) covers what differs between native targets and
the browser:

- spawn/sleep: tokio natively, wasm_bindgen_futures::spawn_local and
  gloo-timers in the browser. Request tasks hand their result over a
  oneshot channel and the invalidation stream listener is cancelled with
  futures' abortable, so neither needs tokio's JoinHandle/AbortHandle.
- MaybeSend and BoxFuture: Send on native targets only, since futures
  holding JavaScript values aren't Send. The generated watch() uses
  MaybeSend too.
- now_ms(): SystemTime natively, js_sys::Date::now() in the browser.
  Expiry is kept as the header's milliseconds since the epoch.
- jitter(): a SplitMix64 counter instead of the clock.
- Cookies: reqwest's cookie store natively; in the browser, requests
  include credentials and the browser keeps the session cookie.

tokio is only a `sync` dependency of toapi-client in the browser; the
runtime and timers are native-only. Browser tests (tests/wasm.rs) run in
Node through wasm-bindgen-test-runner against an HTTP server written in
JavaScript, covering request sharing, watch refetches, min_ttl and the
invalidation stream. CI lints the client and the example client for
wasm32 and runs those tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
- InvalidationBus becomes the Cache trait, mirroring the TypeScript
  server's Cache: invalidate(tags, origin) and subscribe(). get/set are
  left out until there is a server-side response cache.
- InMemoryBus becomes PubSub, the default Cache, like the TypeScript
  server's PubSub.
- The Invalidator extractor becomes RequestCache: the cache as seen from
  one request, attributing invalidations to the calling client like
  req.invalidate.
- Api::invalidation_bus becomes Api::cache; Api::invalidator becomes
  Api::invalidate (like api.invalidate) and Api::cache_handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
There's no meaningful default for them, so Api::new(title, version)
takes both and the info() builder method and the Default impl (which
filled in "API" and "0.0.0") are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Api::cache now takes an Arc<C: Cache>, so the caller keeps a clone and
invalidates through it directly (e.g. from a background job). The
cache_handle() getter is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Callers keep the Arc they pass to Api::cache and invalidate through it,
so the Api doesn't need its own invalidate method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
Route methods take an `authorize` first, like the `authorize` of a
TypeScript handler: get(public, list_users).post(signed_in, create_user).

- Authorize is implemented for async functions of FromRequestParts
  extractors returning Result<A, E>; on Err the error is the response.
  `public` lets every request through. Handlers read A with the Auth<A>
  extractor, and Api::route panics when a handler's Auth<A> isn't what
  its authorize returns.
- Each route method runs authorize, then (for GET) looks up the request
  URL in the Cache, then the handler on a miss. A successful Cached<T>
  that explicitly set tags or a TTL is stored for its TTL or the API's
  default_ttl (14 days, like the TypeScript server's DEFAULT_TTL);
  responses without either are never stored.
- The Cache trait becomes async with get/set like the TypeScript Cache.
  Misses return a token, and set skips responses whose tags were
  invalidated since the lookup. Invalidation deletes matching entries
  before the mutation's response goes out, then notifies subscribers.
- InMemoryCache caches within one process; PubSub stays the default and
  caches nothing. RequestCache::invalidate is now async.
- The example server authorizes mutations with signed_in and uses
  InMemoryCache, so the end-to-end tests run against the server cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb
The Cache trait and its types (CacheEntry, Lookup, Invalidation) live in
toapi-core so cache implementations don't depend on the server.
InMemoryCache moves to the new toapi-cache crate; toapi-server keeps
PubSub (the default) and RequestCache and re-exports the trait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants