Skip to main content

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}