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