Repository navigation
Conversation
…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
|
…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
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
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
This reverts commit 6b9c5ec.
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
mismosmi
commented
Oct 26, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a Cargo workspace under
rust/that brings the Toapi concepts to Rust:The client runs natively and in the browser (
wasm32-unknown-unknown).Crates
toapi-core: wire constants, the naming scheme and the serverCachetrait (withCacheEntry,LookupandInvalidation), so cache implementations don't depend on the server. ForGET /users/{id}the naming scheme givesGetUsersByIdRequest,GetUsersByIdQuery,PutUsersByIdRequestBody,GetUsersByIdResponseBody,GetUsersByIdCallandUsersByIdRoute.toapi-server: anApibuilder on top of axum, created withApi::new(title, version)for the OpenAPI document.Cached<T>and mutationsInvalidates<T>; anything else is a compile error.authorizefirst, like a TypeScript handler'sauthorize:Result<A, E>. OnErr, the error is the response.publiclets every request through.Awith theAuth<A>extractor.routepanics when a handler'sAuth<A>isn't what itsauthorizereturns.OperationInput, which feeds the OpenAPI 3.1 document.routepanics if the{placeholders}don't match the handler'sPath<T>.Cache:authorize, then looks up its URL in the cache, then runs the handler on a miss.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.setchecks).PubSub(the default) caches nothing.toapi_cache::InMemoryCachecaches within one process. Both are set with.cache(Arc::new(..)), and callers keep a clone to invalidate from background jobs.RequestCacheextractor, attributed to the calling client likereq.invalidate.toapi-cache: server cache implementations, currentlyInMemoryCache.toapi-codegen: generates the client code from the OpenAPI document:Api: static segments are fields, parameters are typed methods, and HTTP methods return call builders.PostUsersCall<Set, Unset<required::role>>is not a future)..query(GetUsersQuery { .. })and.body(NewUser { .. })set all fields of a query or body struct at once, including the required ones.GetUsersByIdRequest) implementRequest, withtype Kind = Queryfor GET andMutationotherwise. They can be built with.into_request()or run directly withclient.call(..).toapi-macros:generate!, which runs the codegen at compile time.Cargo.toml.toapi-client, so no build script is needed.toapi_codegen::build) is still available.toapi-client:Client::call(request), whose return type comes from the request'sKind:QueryCall, which can be awaited or watched as a stream.MutationCall, which resolves after the affected watched queries were refetched.DashMap, so reads only take a read lock on one shard.Arc<dyn Any + Send + Sync>. Queries and watch streams resolve to anArc<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.HashSet) intersect the invalidated ones, and publishes their URLs to a pub-sub of callbacks, like the TypeScript client'sPubSub. Publishing awaits all callbacks.min_ttl, and responses with a TTL once it's over plus up tomax_overdue_ttl. Both are set onClientBuilderand match the TypeScript client'sminTTLandmaxOverdueTTL(defaults 100ms and 1s).watch()is called:tokio::sync::watchchannel that the stream reads from.wasm_bindgen_futuresandgloo-timersinstead of tokiojs_sys::Date::now()Sendbounds:MaybeSend, also used by the generated codeThe wire protocol (
X-TAPI-Tags,X-TAPI-Expires-At,/__tapi/invalidations,/__tapi/openapi.json, the__tapi-sessioncookie, tag hashing) matches the TypeScript packages. The tag hash is pinned to the value@toapi/serverproduces.Releasing the crates is tracked in #537 (switching to knope); this PR adds no changeset.
Not covered yet
Also listed in
rust/README.md:Testing
Unit tests for:
PubSubandRequestCache(intoapi-server), andInMemoryCache(intoapi-cache: storing, invalidating, expiry, skipping responses older than an invalidation)Server caching tests (
crates/toapi-server/tests/caching.rs), driven through the router:X-TAPI-Expires-Atkept on cache hitsauthorizeruns before the cache, so a rejected request never sees a cached responseauthorizeresult throughAuth<A>Auth<A>panics when the route is addedClient cache integration tests (
crates/toapi-client/tests/cache.rs) against a counting server:Arc(Arc::ptr_eq)invalidateresolvesmin_ttl, andmin_ttlis configurableBrowser tests (
crates/toapi-client/tests/wasm.rs): the client compiled forwasm32-unknown-unknown, run in Node throughwasm-bindgen-test-runneragainst an HTTP server written in JavaScript:min_ttlA test that fails when
openapi.jsonis out of date.End-to-end tests (
examples/users-client/tests/e2e.rs) against the real server, which usesInMemoryCache, with the client generated bygenerate!:client.callinto_request()builds the expected structswatch()follows the client's own mutations, and another client's mutation through the invalidation streamcompile_faildoctests inusers-clientfor:watch()on a mutation, through the tree and throughclient.callChecked manually:
openapi.jsonmarksusers-clientdirtycould not read …/openapi.jsn, pointing at the macro argumentCI (
rustjob inci.yml) runs:cargo fmt --check,cargo clippy -D warningsandcargo test --workspacewasm32-unknown-unknownon the client and the example clientAll of these pass locally.
🤖 Generated with Claude Code
https://claude.ai/code/session_01QC3KeukVBedvsgLE1jWoGb