tapes_client/transport.rs
1//! The seam — one trait both surfaces call through.
2//!
3//! # Why a seam rather than a client
4//!
5//! The consumers of the tapes read API do not share a way of sending a request
6//! and should not be made to. One speaks to a tapes server directly with no
7//! credential at all; one mints a fresh token per request, sends it under a
8//! header chosen so `Authorization` stays free for upstream provider
9//! credentials, retries once on a 401, and pins its TLS; one carries opaque
10//! frames over a local control socket and never speaks HTTP in its own process
11//! at all. None of that is contract knowledge. [`TapesTransport`] is the line
12//! between them: this crate decides *which request*, the implementation decides
13//! *how it is sent*.
14//!
15//! Base resolution, authentication, retry policy, and TLS all live **inside**
16//! an implementation. That is what makes the line hold: a [`WireRequest`]
17//! carries a contract-relative path, not a URL, so there is no place for a
18//! caller to smuggle a host into one.
19//!
20//! # Why the transport cannot grow verbs
21//!
22//! [`WireResponse`] is a status, headers, and bytes. A transport can cache,
23//! multiplex, or log; it structurally cannot answer "list the sessions",
24//! because the frame has no vocabulary for it. Every semantic verb therefore
25//! stays in [`crate::core`] and [`crate::cassettes`], where the operation
26//! tables are — which is the property that keeps one implementation of the read
27//! surface rather than one per transport.
28
29use std::fmt;
30
31use serde_json::Value;
32
33use crate::error::Result;
34
35/// One call against an OpenAPI-described route — a runtime-discovered cassette
36/// operation, or a core operation from a sealed contract.
37///
38/// The path is the document's own template, `{name}` placeholders included, and
39/// is *contract-relative*: joining it onto a base is [`crate::path`]'s job, and
40/// which base is the transport's. A caller therefore cannot address a host by
41/// constructing one of these.
42#[derive(Debug, Default, Clone)]
43pub struct WireRequest<'a> {
44 /// The HTTP verb, uppercased.
45 pub method: &'a str,
46 /// The public path template, `{name}` placeholders included.
47 pub path: &'a str,
48 /// Values for those placeholders, by placeholder name.
49 pub path_params: Vec<(String, String)>,
50 /// Query parameters, under their wire names.
51 pub query: Vec<(String, String)>,
52 /// Header parameters, under their wire names.
53 pub headers: Vec<(String, String)>,
54 /// A JSON request body, when the operation takes one.
55 pub body: Option<String>,
56}
57
58/// The name this type has carried since it described only cassette calls.
59///
60/// Kept as an alias rather than a second struct: the sealed surface and the
61/// discovered surface describe a call identically, and the two names existing
62/// at all was a side effect of the two surfaces having lived in two crates.
63pub type Call<'a> = WireRequest<'a>;
64
65/// What a transport hands back: what the server said, and the bytes it said it
66/// with.
67///
68/// Deliberately not a decoded document. Status and headers are load-bearing
69/// above this layer — a 304 is the whole point of a conditional spec fetch, and
70/// an error body is what names the offending parameter — and a transport that
71/// decoded eagerly would have to throw one of them away.
72#[derive(Debug, Clone)]
73pub struct WireResponse {
74 /// The HTTP status, or the transport's equivalent.
75 pub status: u16,
76 /// What actually answered, for diagnostics.
77 ///
78 /// The transport resolves the base, so it is the only layer that knows the
79 /// full address a contract-relative path became — and an error message
80 /// naming `/v1/sessions` when three deployments were in play is the message
81 /// that costs an afternoon.
82 pub endpoint: String,
83 /// Response headers, in the order received.
84 pub headers: Vec<(String, String)>,
85 /// The response body, verbatim.
86 pub body: Vec<u8>,
87}
88
89impl WireResponse {
90 /// Build a response from its parts.
91 #[must_use]
92 pub fn new(
93 status: u16,
94 endpoint: String,
95 headers: Vec<(String, String)>,
96 body: Vec<u8>,
97 ) -> Self {
98 Self {
99 status,
100 endpoint,
101 headers,
102 body,
103 }
104 }
105
106 /// Whether the status is in the 2xx range.
107 #[must_use]
108 pub fn is_success(&self) -> bool {
109 (200..300).contains(&self.status)
110 }
111
112 /// Whether the status is a redirect.
113 #[must_use]
114 pub fn is_redirection(&self) -> bool {
115 (300..400).contains(&self.status)
116 }
117
118 /// The first value of a header, matched case-insensitively.
119 ///
120 /// Case-insensitive because the seam admits transports that never had an
121 /// HTTP library to normalise them: a hand-built frame may spell `ETag`
122 /// however its author did.
123 #[must_use]
124 pub fn header(&self, name: &str) -> Option<&str> {
125 self.headers
126 .iter()
127 .find(|(key, _)| key.eq_ignore_ascii_case(name))
128 .map(|(_, value)| value.as_str())
129 }
130}
131
132/// A transport-level failure, with its cause kept opaque.
133///
134/// Opaque on purpose: naming a transport's error type here would put that
135/// transport's dependency in every build of this crate, including the ones
136/// whose transport is a local socket or a test double. An implementation
137/// attaches its own error as a source, and a consumer that wants the detail
138/// walks [`std::error::Error::source`].
139#[derive(Debug)]
140pub struct TransportError {
141 message: String,
142 source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
143}
144
145impl TransportError {
146 /// A failure with no underlying error to attach.
147 #[must_use]
148 pub fn new(message: impl Into<String>) -> Self {
149 Self {
150 message: message.into(),
151 source: None,
152 }
153 }
154
155 /// A failure carrying the implementation's own error as its cause.
156 #[must_use]
157 pub fn with_source(
158 message: impl Into<String>,
159 source: impl std::error::Error + Send + Sync + 'static,
160 ) -> Self {
161 Self {
162 message: message.into(),
163 source: Some(Box::new(source)),
164 }
165 }
166}
167
168impl fmt::Display for TransportError {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 f.write_str(&self.message)
171 }
172}
173
174impl std::error::Error for TransportError {
175 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
176 self.source
177 .as_ref()
178 .map(|boxed| boxed.as_ref() as &(dyn std::error::Error + 'static))
179 }
180}
181
182/// Sending one described request and getting back what the server said.
183///
184/// The single required method is the whole seam. Everything a surface does —
185/// resolving an operation, routing values, decoding, paging — happens above it.
186pub trait TapesTransport {
187 /// Send one described request.
188 fn send(
189 &self,
190 request: &WireRequest<'_>,
191 ) -> impl Future<Output = std::result::Result<WireResponse, TransportError>>;
192}
193
194/// Sending a request whose response must not be buffered.
195///
196/// A separate trait, and generic in the body it yields, so that
197/// [`TapesTransport`] itself never names a streaming type: an export can be far
198/// larger than a session's working set, but a transport carrying frames over a
199/// socket has a different notion of "a stream" than an HTTP client does, and
200/// forcing one of them into the other's type is how the seam would acquire a
201/// dependency it exists to avoid.
202///
203/// An implementation must surface a non-success status as an error rather than
204/// as a readable body, so a caller streaming to a file can never write an error
205/// page into it.
206pub trait StreamingTransport: TapesTransport {
207 /// The streaming body this transport yields.
208 type Body;
209
210 /// Send one described request and hand back the live response.
211 fn send_stream(&self, request: &WireRequest<'_>) -> impl Future<Output = Result<Self::Body>>;
212}
213
214/// The outcome of a conditional fetch of a cassette's OpenAPI document.
215#[derive(Debug, Clone)]
216pub enum SpecFetch {
217 /// The server matched our `If-None-Match` and sent no body; the cached copy
218 /// is still current.
219 Unchanged,
220 /// A document, and the validator to revalidate it with next time.
221 Fetched {
222 /// The OpenAPI document, verbatim.
223 document: Value,
224 /// The response `ETag`, when the server sent one.
225 etag: Option<String>,
226 },
227}
228
229/// The narrow seam the cassette surface cache fetches through.
230///
231/// Retained alongside [`TapesTransport`] for consumers that implement fetching
232/// on their own client rather than handing this crate a transport — the shape
233/// the cassette machinery shipped with. [`Wire`] adapts any [`TapesTransport`]
234/// onto it, so there is one implementation of the cache's revalidation ladder
235/// regardless of which seam a consumer plugs in.
236///
237/// The associated error is only ever displayed: every failure here is
238/// survivable for the surface cache, which logs it and degrades. A consumer's
239/// own error type therefore plugs in without conversion.
240pub trait SpecTransport {
241 /// The transport's own error type.
242 type Error: fmt::Display;
243
244 /// Fetch the `/v1/cassettes` discovery document, raw.
245 fn fetch_discovery(&self) -> impl Future<Output = std::result::Result<Value, Self::Error>>;
246
247 /// Conditionally fetch one cassette's OpenAPI document.
248 ///
249 /// `path` is the server-relative `openapi_path` discovery published;
250 /// `etag` is the validator from a previous fetch.
251 fn fetch_spec(
252 &self,
253 path: &str,
254 etag: Option<&str>,
255 ) -> impl Future<Output = std::result::Result<SpecFetch, Self::Error>>;
256
257 /// Execute one described call and decode the JSON response.
258 fn execute(
259 &self,
260 call: &Call<'_>,
261 ) -> impl Future<Output = std::result::Result<Value, Self::Error>>;
262}
263
264/// Adapts any [`TapesTransport`] onto the [`SpecTransport`] the cassette cache
265/// fetches through.
266///
267/// This is where the two seams meet, and it is four request descriptions long
268/// — which is the point. The cache's freshness rules, its revalidation ladder,
269/// and its degradation behaviour are written once against [`SpecTransport`];
270/// what changes between a consumer's own client and a transport this crate
271/// drives is only how the bytes are fetched.
272#[derive(Debug, Clone, Copy)]
273pub struct Wire<T>(pub T);
274
275impl<T> Wire<T> {
276 /// Wrap a transport.
277 #[must_use]
278 pub fn new(transport: T) -> Self {
279 Self(transport)
280 }
281}
282
283impl<T: TapesTransport> TapesTransport for Wire<T> {
284 fn send(
285 &self,
286 request: &WireRequest<'_>,
287 ) -> impl Future<Output = std::result::Result<WireResponse, TransportError>> {
288 self.0.send(request)
289 }
290}
291
292impl<T: TapesTransport> SpecTransport for Wire<T> {
293 type Error = crate::error::Error;
294
295 async fn fetch_discovery(&self) -> Result<Value> {
296 crate::cassettes::fetch_discovery(&self.0).await
297 }
298
299 async fn fetch_spec(&self, path: &str, etag: Option<&str>) -> Result<SpecFetch> {
300 crate::cassettes::fetch_spec(&self.0, path, etag).await
301 }
302
303 async fn execute(&self, call: &Call<'_>) -> Result<Value> {
304 crate::cassettes::invoke(&self.0, call).await
305 }
306}
307
308#[cfg(test)]
309#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
310mod tests {
311 use super::*;
312
313 /// A transport that answers everything with one canned document.
314 struct Canned;
315
316 impl TapesTransport for Canned {
317 async fn send(
318 &self,
319 request: &WireRequest<'_>,
320 ) -> std::result::Result<WireResponse, TransportError> {
321 Ok(WireResponse::new(
322 200,
323 format!("http://tapes.test{}", request.path),
324 vec![("etag".to_owned(), "\"sha256:abc\"".to_owned())],
325 br#"{"cassettes":[]}"#.to_vec(),
326 ))
327 }
328 }
329
330 #[tokio::test]
331 async fn a_transport_drives_the_cassette_seam_through_the_bridge() {
332 // The claim this type exists to make good on: the surface cache's
333 // revalidation ladder is written once, against `SpecTransport`, and a
334 // consumer that plugs in a `TapesTransport` instead reaches the same
335 // implementation rather than a parallel one.
336 let bridged = Wire::new(Canned);
337
338 let discovery = SpecTransport::fetch_discovery(&bridged).await.unwrap();
339 assert_eq!(discovery["cassettes"], serde_json::json!([]));
340
341 let fetched = SpecTransport::fetch_spec(&bridged, "/v1/cassettes/x/openapi.json", None)
342 .await
343 .unwrap();
344 match fetched {
345 SpecFetch::Fetched { etag, .. } => {
346 assert_eq!(etag.as_deref(), Some("\"sha256:abc\""));
347 }
348 SpecFetch::Unchanged => panic!("expected a document"),
349 }
350 }
351
352 #[test]
353 fn a_header_is_found_however_the_transport_spelled_it() {
354 // A transport carrying hand-built frames has no HTTP library to
355 // normalise header names, and a conditional fetch that missed an
356 // `ETag` would silently re-download every spec forever.
357 let response = WireResponse::new(
358 200,
359 "http://tapes.test/v1/cassettes".to_owned(),
360 vec![("ETag".to_owned(), "\"x\"".to_owned())],
361 Vec::new(),
362 );
363 assert_eq!(response.header("etag"), Some("\"x\""));
364 assert_eq!(response.header("ETAG"), Some("\"x\""));
365 assert_eq!(response.header("if-none-match"), None);
366 }
367}