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