Expand description
§tapes-client
One client for the whole Tapes read surface.
A tapes deployment answers two kinds of question. Some operations are a published contract: sealed in the tapes repository, attached to releases as an OpenAPI document, and therefore known to a client at build time. Others belong to cassettes — independently built API extensions that core reverse-proxies — whose set is deployment configuration and is therefore discovered when a process starts.
Those are different facts about a server, and they used to be different crates. That was the mistake this crate corrects. Both surfaces need the same things — send a request, read a status, decode a body, follow a cursor, join a path onto a base — and every one of those answers that got written twice drifted: two error vocabularies for one API, two spellings of a URL failure, a non-success status that was rich on one side and absent on the other, and a conditional fetch one path could not express at all.
§The shape
transport ── the seam: one trait, request in, status + bytes out
http ─────── the HTTP engine; the credential half is a small hook
error ────── one taxonomy: Contract / Transport / ApiStatus / Decode
decode ───── one policy: bytes to document, document to caller's type
page ─────── one cursor convention
path ─────── one join, in the two modes deployments actually need
│
├── core/ ────── the SEALED surface: operation table AND models,
│ both reduced from the vendored contract
└── cassettes/ ─ the DISCOVERED surface, table from a live documentThe design rule: core and cassettes are thin method tables. Everything
that could drift lives once in the floor above them. A sealed call and a
discovered call go through the identical pipeline; the only difference is where
the operation table came from. When that stops being true, the crate has stopped
doing its job.
§Public seams
The floor — one implementation each, shared by both surfaces:
transport—TapesTransport: request in, status + headers + bytes out. AWireRequestcarries a contract-relative path, not a URL, so base resolution, authentication, retry policy, and TLS all live inside an implementation and there is nowhere for a caller to smuggle a host into one. A transport can cache, multiplex, or log; it structurally cannot grow a semantic verb, because the frame has no vocabulary for one.error— the single taxonomy.Contractis a refusal (nothing is sent),Transportcould not deliver,ApiStatusmeans the server refused and the body travels with the status,Decodemeans the bytes are not what was asked for.#[non_exhaustive].decode—json(bytes to document),typed(document to the caller’s type), andjson_typedfor both at once. Split so a consumer holding an already-decoded document reaches the same typed decode rather than a second one that rounds differently.page— the cursor walk. Absent,null, and""are three spellings of “last page”, which is exactly why reading them belongs in one place.path—call_urlwithPathMode::Direct(a server’s root) orPathMode::UnderBase(mounted under a gateway prefix). The caller says which; a builder that silently picks one is wrong for the other client.
The two surfaces:
core— the sealed contract.contractreduces the vendored document to an operation table,coverageis the gate that fails a build when a contract bump adds an operation the client neither exposes nor deliberately allow-lists,modelsis that same document’s response and request shapes as Rust types (with a gate of their own — see Typed by default), andmethodsisCoreClient, the call surface over a transport.cassettes— the discovered surface:discovery(with cache andIf-None-Matchrevalidation),spec(a cassette’s OpenAPI reduced to a method table), andinvoke.
Behind features:
cli— synthesizing clap commands from a discovered surface and resolving a parsed match back into a call. It stops atresolve_invocation; executing and printing stay with the consumer.http—HttpEngine, the HTTP half of a transport, andHttpAuth, the credential half a consumer supplies.DirectHttpis the engine withNoAuthin it: an unauthenticated transport for one tapes server. Redirects are refused rather than followed, in two layers: the engine’s own client is built withPolicy::none, and every response is checked to have come from the configured origin — the layer that still holds when the client was injected. See Authenticating.
§Features
| feature | default | what it does |
|---|---|---|
cli | on | The generated clap surfaces. Off, the operation tables and the transport seam are unchanged — only command synthesis goes away, and clap is never compiled. A consumer embedding this in a GUI takes --no-default-features. |
direct-http | on | The in-crate HTTP engine — HttpEngine, HttpAuth, DirectHttp. Off, the crate has no HTTP client at all and a consumer plugs its own into TapesTransport. |
Both are additive, and neither is load-bearing for the crate’s own logic.
§Typed by default, generic when you mean it
CoreClient’s named methods return the models in core::models:
let client = CoreClient::new(DirectHttp::new(base));
let page = client.list_sessions(&SessionListParams { limit: Some(25), ..Default::default() }).await?;
let all = client.list_all_sessions(&SessionListParams::default()).await?; // follows next_cursor
let one = client.get_session("s-1").await?;
let spans = client.get_session_traces("s-1", &SessionTracesParams { payload: Some(PayloadDetail::Preview) }).await?;The shape of a sealed response is not a consumer’s opinion — it is published, vendored here, and held to the document by a build-time gate. Every client that modelled it privately was keeping a second copy of a shared fact.
The escape hatch is still there, one layer down, and it is not the default:
let document: serde_json::Value = client.call("listSessions", vec![("limit", "25".into())]).await?;CoreClient::call is generic in its response type and reaches every operation
by operationId, including the ones no named method covers. Reach for it in two
places: an operation this crate has not typed yet, and the fidelity reads —
export, raw turns — where a typed decode would silently write an archive of the
fields this build happened to know about.
The models decode permissively on purpose. Unknown fields pass (a newer server
is not a malformed response), an absent field takes its default (the contract
requires nothing), and a null in an array, map, or nested object decodes as
empty rather than failing the whole document. What catches an added field is not
the runtime — it is core::models::coverage, at build time, where somebody can
decide about it: the gate synthesises a document from each schema, round-trips
it through the model, and names by path anything the model does not carry.
§Authenticating: a hook, not a transport
The tapes read API carries no authentication of its own. A consumer that holds a
credential does so because its edge demands one — and it used to pay for that
by implementing TapesTransport outright: verb parsing, path joining, header
copying, redirect policy, response splitting, error mapping, and the whole thing
again for streaming. The only part of that which was genuinely its own was the
credential.
So write the credential, not the transport:
struct MintedToken { /* whatever produces a credential */ }
impl HttpAuth for MintedToken {
async fn authorize(&self, _request: &WireRequest<'_>, _attempt: u32)
-> Result<Vec<(String, String)>, TransportError>
{
let token = self.mint().await.map_err(|e| TransportError::with_source("mint failed", e))?;
Ok(vec![("x-tapes-auth".into(), format!("Bearer {token}"))])
}
async fn on_unauthorized(&self, rejected: Rejected<'_>) -> Unauthorized {
// The policy is data; the loop is the engine's.
if rejected.attempt == 1 { Unauthorized::Retry } else { Unauthorized::Surface }
}
}
let engine = HttpEngine::with_auth(base, MintedToken { .. })
.under_base() // mounted behind a gateway prefix
.with_client(my_client); // your TLS, proxy, or connection poolauthorizeruns once per attempt, so a consumer that mints per request keeps doing exactly that — including on the retry.on_unauthorizedreturns a decision: retry, surface the 401 to the caller with the body that explains it, or fail with an error of the consumer’s own (“run the login command” is a fact a user can act on;401is not). The engine owns the loop and caps it, so a retry policy is data rather than awhileloop rewritten per consumer with its own answer to “how many times?”.- The HTTP client is a constructor argument, not a trait method, because TLS is a property of the client and not of the credential: a deployment that pins a root but sends no credential should not have to implement an auth trait to say so.
TapesTransport is unchanged and still open: a consumer whose transport is a
local socket, a test double, or anything that is not HTTP implements it directly,
and the engine is simply one implementation of it.
§What is not here
No notion of a tenant, and no opinion about how a response is rendered.
No credential, either — HttpAuth is the shape of the hole where one goes, and
the engine never holds, caches, or refreshes anything. What changed is that the
HTTP around a credential is no longer a consumer’s problem.
It also does not hold the operation coverage tables. Those describe one client’s surface, and sharing them would make the gate report on a union and protect nobody. The schema coverage tables are the opposite case and do ship here, because the models they describe ship here too.
§The vendored contract
contracts/tapes-api.yaml is a copy of a published release asset, pinned by
fingerprint in contracts/PROVENANCE.md. make contracts-check at the
repository root verifies it against both that fingerprint and the published
asset; CI runs it with TAPES_CONTRACT_STRICT=1, so a gate that cannot reach
its input fails rather than reporting a comparison it never made.
§Migrating from tapes-read-contract / tapes-cassette-client
This crate absorbed both of them. Each name survived for one step as a re-export shim so that a consumer pinning it compiled across the move; the shims are gone now, and this section is where their item-by-item tables live.
§From tapes-read-contract
| was | is |
|---|---|
contract | tapes_client::core::contract |
coverage | tapes_client::core::coverage |
invoke | tapes_client::path |
error | tapes_client::error |
transport | tapes_client::transport and tapes_client::core::methods |
invoke::call_url | tapes_client::path::call_url |
invoke::PathMode::RootAbsolute | tapes_client::path::PathMode::Direct |
invoke::PathMode::UnderBase | tapes_client::path::PathMode::UnderBase |
PathMode’s default variant was renamed: RootAbsolute is now Direct.
The behaviour is identical — still the default, still the join that drops any
path prefix the base carried — so this is one word at each call site, and it is
the only rename in either table.
The ReadTransport / ReadOperations pair did not survive the move. It was a
second seam describing the same thing as the cassette client’s, which is
precisely the duplication the merge exists to remove; its replacement is
tapes_client::transport::TapesTransport, with tapes_client::core::CoreClient
as the call surface over it. Nothing consumed the old pair.
§From tapes-cassette-client
| was | is |
|---|---|
cache | tapes_client::cassettes::cache |
command | tapes_client::cli |
discovery | tapes_client::cassettes::discovery |
spec | tapes_client::cassettes::spec |
invoke | tapes_client::path and tapes_client::cassettes::invoke |
transport | tapes_client::transport and tapes_client::http |
invoke::call_url(base, call) | tapes_client::path::call_url(base, call, PathMode::Direct) |
call_url gained its PathMode argument because a client mounted under a
gateway prefix and one addressed at a server’s root are not the same join. That
crate only ever performed the root-absolute one, so PathMode::Direct
reproduces its behaviour exactly.
§The one type that could not be re-exported
tapes_cassette_client::Error was preserved verbatim and inert rather than
aliased to the merged taxonomy, for a reason that is a property of Rust rather
than a preference: a consumer matching it exhaustively — no wildcard arm —
stops compiling the moment a variant is added or removed, and one taxonomy for
one API necessarily has both more variants and different ones.
There is now a single tapes_client::Error, described under
Public seams. A consumer moving across deletes its
From<tapes_cassette_client::Error> implementation and writes one for the
merged variants — the same work either way, at a time it chooses.
§Shedding an adapter
If you carry a TapesTransport implementation of your own, or response types
that mirror the sealed contract, both can now go. The move is mechanical, and
each step stands alone — nothing here has to be done in one change.
1. A hand-written HTTP transport becomes a hook. Keep whatever mints your credential; delete everything around it.
| what your adapter does today | where it goes |
|---|---|
| parse the verb, join the path, copy headers, set the content type | gone — HttpEngine |
PathMode::UnderBase because you sit behind a gateway | .under_base() |
a reqwest::Client you built (TLS, proxy, pool) | .with_client(client) |
attach Authorization / a bespoke auth header | HttpAuth::authorize |
| retry once on a 401, then surface your own error | HttpAuth::on_unauthorized returning Retry, then Fail(..) |
| split status/headers/bytes; map failures into the taxonomy | gone — HttpEngine |
| the whole thing again for streaming, plus “a non-success status is an error” | gone — HttpEngine implements StreamingTransport |
What you are left with is the impl HttpAuth above, and
HttpEngine::with_auth(base, hook) where the adapter used to be. The engine
implements TapesTransport and StreamingTransport, so everything downstream —
CoreClient, the cassette cache, Wire — takes it unchanged.
Two behaviours may be new to you, both deliberately: redirects are refused
(with a per-response origin check that also covers an injected client), and a
streamed non-success status becomes Error::ApiStatus instead of a readable
body. The second is what stops an export writing a JSON error page into the file
a user asked for.
2. Private response types become the shipped models. Replace your own
SessionItem/SpanItem/… with tapes_client::core::models, and your query
parameter builders with the *Params structs. Two things to expect:
- Response models are
#[non_exhaustive]: decode them, do not construct them. Request bodies (CreateSkillRequest,SessionUpdateRequest, …) are yours to build and are not marked, down to their nested components. - The partial-update bodies carry
Optionfields, and an unset one is absent from the bytes rather than sent empty. That is what makesUpdateSkillRequest { name: Some(..), ..Default::default() }a rename instead of a rename plus the erasure of everything it did not mention — the server applies the properties the body carries and leaves the rest alone. - Timestamps and enumerable strings stay
String. The contract declares them that way, and a typed decode that rejected an unparseable timestamp — or an unfamiliarstatus— would blank a whole page over an additive change.
Anything you decode as serde_json::Value today for fidelity should stay
that way. CoreClient::call is generic exactly so it can.
§Stability
This crate is supported public API, meant to be depended on directly. So
are its two siblings — tapes-capture (the
capture protocol) and tapes-harnesses (the
harness knowledge) — and all three version independently on crates.io. This one
sits on neither side of the repository’s single dependency edge, so its releases
are ordered against nothing.
Pre-1.0, 0.x versions carry the usual Cargo meaning: a breaking change bumps
the minor (0.2.0), anything compatible bumps the patch (0.1.1). What counts
as breaking is the boundary in the repository README,
not just the signatures: authentication, tenancy, transport, and rendering are
outside this crate by design, and growing one of them here would break the
promise while compiling cleanly.
Error is #[non_exhaustive], so a new variant is an additive change — match
it with a wildcard arm. The sealed surface moves when the vendored contract
under contracts/ is refreshed, which can add or change operations without a
line of Rust changing; those refreshes are versioned like any other change.
Changes are recorded in CHANGELOG.md.
§License
Dual-licensed under MIT OR Apache-2.0; see the repository root.
§Module map
The README above is the manual: the design rule, the two surfaces, the feature table, how to authenticate, and the migration tables from the two crates this one absorbed. This is the same set of seams as links.
The floor, one implementation each and shared by both surfaces:
transport (the seam), error (one taxonomy), decode (one policy),
page (one cursor convention), path (one join, two modes).
The two surfaces: core is the sealed contract — core::contract
reduces the vendored document to an operation table, core::coverage
gates operations, core::models holds the shapes with
core::models::coverage gating them, and core::CoreClient is the call
surface. cassettes is the discovered equivalent.
Behind features: cli synthesizes clap commands from a discovered
surface; http is the HTTP engine plus http::HttpAuth, the credential
hook that replaced whole-transport implementations.
Two gates are easy to conflate and fail differently. core::coverage
gates operations: it fails a build when a contract bump adds an operation
the client neither exposes nor allow-lists. core::models::coverage gates
shapes: it synthesises a document from each schema, round-trips it through
the model, and names by path anything the model drops. An operation gap is a
call you cannot make; a shape gap is a field you silently lose.
§Names
The repository is tapes-crates; this crate is one of its four members.
tapes is a different repository entirely — the server this client reads
from. Note also that core is a module here as well as a
Rust crate, and that cassettes::invoke
names both a module and a function inside it.
Re-exports§
pub use error::Error;pub use error::Result;pub use page::Page;pub use path::PathMode;pub use path::call_url;pub use transport::Call;pub use transport::SpecFetch;pub use transport::SpecTransport;pub use transport::StreamingTransport;pub use transport::TapesTransport;pub use transport::TransportError;pub use transport::Wire;pub use transport::WireRequest;pub use transport::WireResponse;pub use crate::cassettes::CacheConfig;pub use crate::cassettes::Cassette;pub use crate::cassettes::Discovery;pub use crate::cassettes::DiscoveryEntry;pub use crate::cassettes::Location;pub use crate::cassettes::Method;pub use crate::cassettes::Param;pub use crate::cassettes::ReducerConfig;pub use crate::cassettes::Surface;pub use crate::core::ContractModel;pub use crate::core::CoreClient;pub use crate::core::CoreSurface;pub use crate::core::TAPES_API_YAML;pub use crate::core::models;pub use crate::core::ops;pub use http::DirectHttp;pub use http::HttpAuth;pub use http::HttpEngine;pub use http::NoAuth;pub use http::Rejected;
Modules§
- cassettes
- The discovered surface: operations a server announces at runtime.
- cli
- Synthesizing clap commands from a cassette surface, and resolving them back into calls.
- core
- The sealed surface: operations known at build time.
- decode
- One decode policy, for both surfaces.
- error
- One taxonomy for everything that can go wrong between a named operation and a decoded response.
- http
- The HTTP engine, and the credential seam consumers plug into it.
- page
- One pagination convention.
- path
- Turning one contract-described call into a URL against a base.
- transport
- The seam — one trait both surfaces call through.