Skip to main content

tapes_client/core/
contract.rs

1//! The vendored core contract, and the surface reduced from it.
2//!
3//! # One reducer, two document sources
4//!
5//! The generated cassette surface answers "what can this server do?" by
6//! reducing an OpenAPI document to callable methods — discovered at runtime,
7//! because the cassette set is deployment configuration. The core tapes API is
8//! the opposite kind of fact: it is a *published contract*, sealed in the tapes
9//! repository (`api/CONTRACT`) and attached to releases, so the right copy to
10//! build from is the vendored one in `contracts/tapes-api.yaml`, pinned by
11//! fingerprint (see `contracts/PROVENANCE.md`).
12//!
13//! Both feed [`crate::cassettes::spec::reduce_methods`]. What used to be a
14//! set of hand-written URL builders in each client is a lookup into this
15//! surface: the verb, the path template, and the set of declared parameters all
16//! come from the contract bytes, and a request naming a parameter the contract
17//! does not declare is refused before it is sent.
18//!
19//! # Why the contract is vendored rather than fetched
20//!
21//! Neither client builds against the tapes working tree; both build against a
22//! published release asset. Vendoring it here — once — is what stops two
23//! clients holding two copies that nothing checks for agreement.
24
25use std::sync::LazyLock;
26
27use crate::cassettes::spec::{self, Location, Method, ReducerConfig};
28use crate::transport::Call;
29use serde_json::Value;
30use snafu::OptionExt;
31
32use crate::error::{Result, error};
33
34/// The vendored read-API contract, byte-for-byte what
35/// `contracts/tapes-api.yaml` holds.
36pub const TAPES_API_YAML: &str = include_str!("../../contracts/tapes-api.yaml");
37
38/// Operation ids of the vendored contract, named once so client methods,
39/// coverage tables, and tests cannot drift apart on a string.
40pub mod ops {
41    /// `GET /v1/sessions`
42    pub const LIST_SESSIONS: &str = "listSessions";
43
44    /// The operations the sealed contract documents claim-gated filter
45    /// params on — today the sessions listing alone.
46    ///
47    /// A cassette's claims are per-surface on the live server, and the
48    /// vendored document records which surfaces carry the extension; this
49    /// set is the client's mirror of that fact. The claimed channel
50    /// ([`CoreClient::call_with_claimed`](crate::core::methods::CoreClient::call_with_claimed))
51    /// opens only on an operation named here, and the set grows exactly
52    /// when a re-pinned contract documents claim-gated params on another
53    /// operation — never ahead of the document.
54    pub const CLAIM_BEARING_OPS: &[&str] = &[LIST_SESSIONS];
55    /// `GET /v1/sessions/{id}`
56    pub const GET_SESSION: &str = "getSession";
57    /// `GET /v1/sessions/{id}/traces`
58    pub const GET_SESSION_TRACES: &str = "getSessionTraces";
59    /// `GET /v1/sessions/{id}/raw_turns`
60    pub const LIST_RAW_TURNS: &str = "listRawTurns";
61    /// `GET /v1/traces`
62    pub const LIST_TRACES: &str = "listTraces";
63    /// `GET /v1/traces/{trace_id}`
64    pub const GET_TRACE: &str = "getTrace";
65    /// `GET /v1/traces/{trace_id}/spans/{span_id}`
66    pub const GET_SPAN: &str = "getSpan";
67    /// `POST /v1/admin/seed/demo`
68    pub const SEED_DEMO: &str = "seedDemo";
69    /// `GET /v1/cassettes`
70    pub const LIST_CASSETTES: &str = "listCassettes";
71    /// `PATCH /v1/sessions/{id}`
72    pub const UPDATE_SESSION: &str = "updateSession";
73    /// `DELETE /v1/sessions/{id}`
74    pub const DELETE_SESSION: &str = "deleteSession";
75    /// `GET /v1/stats`
76    pub const GET_STATS: &str = "getStats";
77}
78
79/// The core read surface, reduced from the vendored contract.
80#[derive(Debug)]
81pub struct CoreSurface {
82    methods: Vec<Method>,
83}
84
85impl CoreSurface {
86    /// Reduce the vendored contract under a consumer's own reducer
87    /// configuration.
88    ///
89    /// The configuration only shapes the *presentation* names
90    /// ([`crate::cassettes::spec::Param::flag`]); wire names and
91    /// locations, which is all [`call_for`] reads, are the document's
92    /// regardless. A consumer that renders this surface on a command line
93    /// passes its reserved flags here; one that only calls operations can use
94    /// [`core`](crate::core::contract::core).
95    #[must_use]
96    pub fn reduce(reducer: &ReducerConfig<'_>) -> Option<Self> {
97        Self::from_yaml(TAPES_API_YAML, reducer)
98    }
99
100    /// Reduce a contract document from its YAML bytes.
101    fn from_yaml(yaml: &str, reducer: &ReducerConfig<'_>) -> Option<Self> {
102        let document: Value = serde_yaml::from_str(yaml).ok()?;
103        let methods = spec::reduce_methods(&document, reducer);
104        if methods.is_empty() {
105            // An empty surface means the bytes were YAML but not a contract;
106            // treat it exactly like a parse failure rather than serving a
107            // client where every operation lookup fails one at a time.
108            return None;
109        }
110        Some(Self { methods })
111    }
112
113    /// Look one operation up by the contract's own `operationId`.
114    pub fn method(&self, operation_id: &str) -> Result<&Method> {
115        self.methods
116            .iter()
117            .find(|method| method.operation_id.as_deref() == Some(operation_id))
118            .context(error::ContractOperationSnafu {
119                operation: operation_id,
120            })
121    }
122
123    /// Every `operationId` in the vendored document, for the coverage gate.
124    pub fn operation_ids(&self) -> impl Iterator<Item = &str> {
125        self.methods
126            .iter()
127            .filter_map(|method| method.operation_id.as_deref())
128    }
129}
130
131/// The surface, reduced once per process under the default reducer. `None`
132/// only for a build whose embedded document is corrupt, which this crate's
133/// contract tests fail long before.
134static CORE: LazyLock<Option<CoreSurface>> =
135    LazyLock::new(|| CoreSurface::from_yaml(TAPES_API_YAML, &ReducerConfig::default()));
136
137/// The core surface, or the build-defect error.
138pub fn core() -> Result<&'static CoreSurface> {
139    CORE.as_ref().context(error::VendoredContractSnafu {
140        surface: "tapes-api",
141    })
142}
143
144/// Build the [`Call`] for one operation from wire-named values.
145///
146/// Equivalent to [`call_for_with_body`] with no body, which is what every read
147/// operation wants. An operation whose `requestBody` the contract marks
148/// required is refused here rather than sent without one — use
149/// [`call_for_with_body`] for those.
150pub fn call_for<'m>(method: &'m Method, values: Vec<(&str, String)>) -> Result<Call<'m>> {
151    call_for_with_body(method, values, None)
152}
153
154/// Build the [`Call`] for one operation from wire-named values and a body.
155///
156/// This is where "drive through the contract" becomes enforceable. The verb
157/// and path template are the document's, and every value is routed by the
158/// document's declared location for that name. Four things are refused before
159/// anything is sent, and they are refusals rather than best-effort requests
160/// because each one produces a request that *looks* fine on the wire:
161///
162/// - a name the document does not declare — the drift a vendored contract
163///   exists to catch, which a server that ignores unknown query parameters
164///   would otherwise hide;
165/// - a path placeholder left without a value, which cannot produce a URL at
166///   all;
167/// - a query or header parameter the document marks **required** and that has
168///   no value. This one is the quietest: the URL is perfectly well-formed, and
169///   the server answers with a 400 in its own words — or, worse, on an
170///   operation whose required filter is what scopes the result, answers a
171///   different question than the caller believes it asked;
172/// - a body that disagrees with the operation's `requestBody` in either
173///   direction. A required body left absent arrives as a syntactically valid
174///   request that means nothing; a body sent to an operation declaring none is
175///   dropped somewhere before the handler. Both look correct at the call site.
176///
177/// Values are given under their wire names — the same names the hand-written
178/// builders this replaced used — so the call sites read as the requests they
179/// make.
180pub fn call_for_with_body<'m>(
181    method: &'m Method,
182    values: Vec<(&str, String)>,
183    body: Option<String>,
184) -> Result<Call<'m>> {
185    let operation = || {
186        method
187            .operation_id
188            .clone()
189            .unwrap_or_else(|| method.name.clone())
190    };
191
192    let mut call = Call {
193        method: &method.http_method,
194        path: &method.path,
195        ..Default::default()
196    };
197
198    for (wire, value) in values {
199        let declared = method
200            .params
201            .iter()
202            .find(|param| param.wire == wire)
203            .with_context(|| error::ContractParameterSnafu {
204                operation: operation(),
205                parameter: wire,
206            })?;
207        let pair = (declared.wire.clone(), value);
208        match declared.location {
209            Location::Path => call.path_params.push(pair),
210            Location::Query => call.query.push(pair),
211            Location::Header => call.headers.push(pair),
212        }
213    }
214
215    // Every declared parameter that must have a value, checked in one pass so
216    // the three locations cannot drift apart in what they enforce. Path is
217    // checked first by construction — the reducer orders path parameters ahead
218    // of the rest — and keeps its own error, because "no URL could be built"
219    // is a different problem from "this is not the request the contract
220    // describes".
221    for param in &method.params {
222        let supplied = match param.location {
223            Location::Path => &call.path_params,
224            Location::Query => &call.query,
225            Location::Header => &call.headers,
226        }
227        .iter()
228        .any(|(name, _)| *name == param.wire);
229        if supplied {
230            continue;
231        }
232        match param.location {
233            // A path placeholder without a value cannot produce a callable
234            // URL; the substitution would leave a literal `{id}` segment
235            // addressing nothing.
236            Location::Path => {
237                return error::ContractPathParameterSnafu {
238                    operation: operation(),
239                    parameter: param.wire.clone(),
240                }
241                .fail();
242            }
243            Location::Query if param.required => {
244                return error::ContractRequiredParameterSnafu {
245                    operation: operation(),
246                    parameter: param.wire.clone(),
247                    location: "query",
248                }
249                .fail();
250            }
251            Location::Header if param.required => {
252                return error::ContractRequiredParameterSnafu {
253                    operation: operation(),
254                    parameter: param.wire.clone(),
255                    location: "header",
256                }
257                .fail();
258            }
259            // An optional parameter left unset is the omit-when-unset rule:
260            // the server's own default applies, and this client never has to
261            // be updated when one of them changes.
262            Location::Query | Location::Header => {}
263        }
264    }
265
266    // `Method::body` is `Some(true)` when the contract requires a body,
267    // `Some(false)` when it accepts an optional one, and `None` when the
268    // operation takes none at all.
269    match (method.body, body) {
270        (Some(true), None) => {
271            return error::ContractBodySnafu {
272                operation: operation(),
273                detail: "requires a request body and none was supplied",
274            }
275            .fail();
276        }
277        (None, Some(_)) => {
278            return error::ContractBodySnafu {
279                operation: operation(),
280                detail: "declares no request body, so one cannot be sent",
281            }
282            .fail();
283        }
284        (_, supplied) => call.body = supplied,
285    }
286
287    Ok(call)
288}
289
290#[cfg(test)]
291#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn the_vendored_contract_parses_and_reduces() {
297        // The one place a corrupt vendored document is allowed to fail loudly.
298        let surface = core().expect("contracts/tapes-api.yaml must parse");
299        assert!(surface.operation_ids().count() > 0);
300    }
301
302    #[test]
303    fn an_unknown_operation_is_an_error_not_a_guessed_route() {
304        let err = core().unwrap().method("launchMissiles").unwrap_err();
305        assert!(err.to_string().contains("launchMissiles"), "got: {err}");
306    }
307
308    #[test]
309    fn a_value_is_routed_by_the_contracts_declared_location() {
310        let surface = core().unwrap();
311        let method = surface.method(ops::GET_SESSION_TRACES).unwrap();
312        let call = call_for(
313            method,
314            vec![("id", "s-1".to_owned()), ("payload", "preview".to_owned())],
315        )
316        .unwrap();
317
318        assert_eq!(call.method, "GET");
319        assert_eq!(call.path, "/v1/sessions/{id}/traces");
320        assert_eq!(call.path_params, vec![("id".to_owned(), "s-1".to_owned())]);
321        assert_eq!(
322            call.query,
323            vec![("payload".to_owned(), "preview".to_owned())]
324        );
325    }
326
327    #[test]
328    fn an_undeclared_parameter_is_refused_before_any_request() {
329        // Sending it anyway is exactly the drift the vendored contract exists
330        // to catch; the server ignoring an unknown query param would hide it.
331        let surface = core().unwrap();
332        let method = surface.method(ops::GET_SESSION).unwrap();
333        let err = call_for(
334            method,
335            vec![("id", "s-1".to_owned()), ("payolad", "full".to_owned())],
336        )
337        .unwrap_err();
338        assert!(err.to_string().contains("payolad"), "got: {err}");
339    }
340
341    #[test]
342    fn a_missing_path_parameter_is_refused_because_no_url_could_be_built() {
343        let surface = core().unwrap();
344        let method = surface.method(ops::GET_SPAN).unwrap();
345        let err = call_for(method, vec![("trace_id", "t-1".to_owned())]).unwrap_err();
346        assert!(err.to_string().contains("span_id"), "got: {err}");
347    }
348
349    #[test]
350    fn a_missing_required_query_parameter_is_refused_like_a_missing_path_one() {
351        // The asymmetry this closes: a missing path value cannot produce a
352        // URL, so it was always caught, while a missing required query value
353        // produces a perfectly well-formed URL that is not the request the
354        // contract describes. `listTraces` without `session_id` would have
355        // gone out and come back as the server's own 400.
356        let surface = core().unwrap();
357        let method = surface.method(ops::LIST_TRACES).unwrap();
358        let err = call_for(method, Vec::new()).unwrap_err();
359        assert!(err.to_string().contains("session_id"), "got: {err}");
360        assert!(
361            err.to_string().contains("query parameter"),
362            "the error must say where the parameter travels: {err}",
363        );
364    }
365
366    #[test]
367    fn supplying_a_required_query_parameter_is_all_that_is_asked() {
368        // The other half of the gate: enforcement may not start demanding
369        // optional parameters. `listTraces` requires `session_id` and nothing
370        // else, and every caller that sends the required one must still
371        // build.
372        let surface = core().unwrap();
373        let call = call_for(
374            surface.method(ops::LIST_TRACES).unwrap(),
375            vec![("session_id", "s-1".to_owned())],
376        )
377        .unwrap();
378        assert_eq!(call.path, "/v1/traces");
379        assert_eq!(
380            call.query,
381            vec![("session_id".to_owned(), "s-1".to_owned())]
382        );
383    }
384
385    #[test]
386    fn an_optional_parameter_left_unset_is_still_simply_omitted() {
387        // The omit-when-unset rule predates this gate and must survive it:
388        // an unset optional parameter is left out so the server's own default
389        // applies, rather than pinned to whatever today's default happens to
390        // be.
391        let surface = core().unwrap();
392        let call = call_for(surface.method(ops::LIST_SESSIONS).unwrap(), Vec::new()).unwrap();
393        assert!(call.query.is_empty(), "got: {:?}", call.query);
394    }
395
396    #[test]
397    fn an_operation_that_requires_a_body_is_refused_without_one() {
398        // Contract-invalid and invisible: the request is syntactically fine
399        // and means nothing, so the refusal has to happen before anything is
400        // sent.
401        let surface = core().unwrap();
402        let method = surface.method(ops::UPDATE_SESSION).unwrap();
403        let err = call_for(method, vec![("id", "s-1".to_owned())]).unwrap_err();
404        assert!(
405            err.to_string().contains("requires a request body"),
406            "got: {err}",
407        );
408    }
409
410    #[test]
411    fn an_operation_that_declares_no_body_refuses_one() {
412        let surface = core().unwrap();
413        let method = surface.method(ops::GET_SESSION).unwrap();
414        let err = call_for_with_body(
415            method,
416            vec![("id", "s-1".to_owned())],
417            Some("{}".to_owned()),
418        )
419        .unwrap_err();
420        assert!(
421            err.to_string().contains("declares no request body"),
422            "got: {err}",
423        );
424    }
425
426    #[test]
427    fn a_required_body_is_carried_on_the_call_when_it_is_supplied() {
428        let surface = core().unwrap();
429        let method = surface.method(ops::UPDATE_SESSION).unwrap();
430        let call = call_for_with_body(
431            method,
432            vec![("id", "s-1".to_owned())],
433            Some(r#"{"display_name":"x"}"#.to_owned()),
434        )
435        .unwrap();
436        assert_eq!(call.method, "PATCH");
437        assert_eq!(call.body.as_deref(), Some(r#"{"display_name":"x"}"#));
438    }
439
440    #[test]
441    fn an_optional_body_may_be_present_or_absent() {
442        // `seedDemo` is the one operation a consumer drives today that takes
443        // a body at all, and its body is optional — so both spellings have to
444        // keep working, or the seed command breaks on a rule meant for
445        // operations nobody calls yet.
446        let surface = core().unwrap();
447        let method = surface.method(ops::SEED_DEMO).unwrap();
448        assert_eq!(call_for(method, Vec::new()).unwrap().body, None);
449        assert_eq!(
450            call_for_with_body(method, Vec::new(), Some("{}".to_owned()))
451                .unwrap()
452                .body
453                .as_deref(),
454            Some("{}"),
455        );
456    }
457
458    #[test]
459    fn every_named_operation_id_resolves_in_the_vendored_contract() {
460        // The `ops` constants are the crate's own claim about the document;
461        // a contract bump that renamed one must fail here rather than at the
462        // first user who runs that command.
463        let surface = core().unwrap();
464        for id in [
465            ops::LIST_SESSIONS,
466            ops::GET_SESSION,
467            ops::GET_SESSION_TRACES,
468            ops::LIST_RAW_TURNS,
469            ops::LIST_TRACES,
470            ops::GET_TRACE,
471            ops::GET_SPAN,
472            ops::SEED_DEMO,
473            ops::LIST_CASSETTES,
474            ops::UPDATE_SESSION,
475            ops::DELETE_SESSION,
476            ops::GET_STATS,
477        ] {
478            assert!(surface.method(id).is_ok(), "{id:?} did not resolve");
479        }
480    }
481
482    #[test]
483    fn a_reducer_configuration_changes_presentation_without_moving_a_wire_name() {
484        // Consumers reduce this document under their own reserved-flag lists.
485        // `call_for` reads only wire names and locations, so two consumers
486        // with different reserved lists still build byte-identical requests —
487        // which is what lets `core()` serve a single cached reduction.
488        let reserved = ReducerConfig {
489            reserved_flags: &["limit", "id", "help"],
490        };
491        let mine = CoreSurface::reduce(&reserved).unwrap();
492        let theirs = core().unwrap();
493
494        let wires = |surface: &CoreSurface, id: &str| -> Vec<(String, Location)> {
495            surface
496                .method(id)
497                .unwrap()
498                .params
499                .iter()
500                .map(|p| (p.wire.clone(), p.location))
501                .collect()
502        };
503        assert_eq!(
504            wires(&mine, ops::LIST_SESSIONS),
505            wires(theirs, ops::LIST_SESSIONS),
506        );
507
508        // And the presentation really did move, so the test is not vacuous.
509        let flags: Vec<&str> = mine
510            .method(ops::LIST_SESSIONS)
511            .unwrap()
512            .params
513            .iter()
514            .map(|p| p.flag.as_str())
515            .collect();
516        assert!(flags.contains(&"param-limit"), "got: {flags:?}");
517    }
518}