Skip to main content

tapes_client/core/
methods.rs

1//! Calling the sealed contract's operations over a transport.
2//!
3//! Every hand-written URL builder a client used to carry is one line here: the
4//! verb, the path template, and the parameter routing all come from
5//! `contracts/tapes-api.yaml`. A parameter the contract does not declare is
6//! refused before anything is sent, because a server that ignores an unknown
7//! query parameter would otherwise hide the drift a vendored contract exists to
8//! catch.
9//!
10//! # The typed surface is the default
11//!
12//! The named methods return the models in [`crate::core::models`], because the
13//! shape of a sealed response is not a consumer's opinion — it is published,
14//! vendored here, and held to the document by a build-time gate. A client that
15//! modelled it privately was keeping a second copy of a shared fact.
16//!
17//! The generic seam is still here, one layer down: [`CoreClient::call`] is
18//! generic in its response type and reaches every operation by `operationId`,
19//! including the ones no method below names. That is the **escape hatch**, and
20//! it is the right tool in two places — an operation this crate has not typed
21//! yet, and the fidelity reads where a typed decode would quietly truncate an
22//! archive of a newer server's data. It is not the default, and a call site
23//! that reaches for it should be able to say which of those two it is.
24//!
25//! The named methods remain conveniences over [`CoreClient::call`] and nothing
26//! more: the same operation table, the same routing, the same refusals. Anything
27//! else would be a second contract that can disagree with the first.
28
29use serde::Serialize;
30use serde::de::DeserializeOwned;
31use snafu::ResultExt;
32
33use crate::cassettes::discovery::Discovery;
34use crate::core::contract::{self, core, ops};
35use crate::core::models::params::ContractParams;
36use crate::core::models::{
37    CreateSkillRequest, ExportSessionParams, ExportSessionsParams, GenerateSkillRequest,
38    PublishSkillRequest, RawTurnListResponse, SearchSpansParams, SeedDemoRequest, SeedResult,
39    SessionDetailResponse, SessionItem, SessionListParams, SessionListResponse,
40    SessionSkillsResponse, SessionTracesParams, SessionTracesResponse, SessionUpdateRequest,
41    SkillResponse, SkillVersionResponse, SkillVersionsResponse, SkillsListParams,
42    SkillsListResponse, SpanItem, SpanSearchOutput, StatsParams, StatsResponse, TraceDetail,
43    TraceListParams, TraceListResponse, TraceParams, UpdateSkillRequest,
44};
45use crate::decode;
46use crate::error::{Result, error};
47use crate::page;
48use crate::transport::{StreamingTransport, TapesTransport, WireRequest};
49
50/// The sealed read surface, bound to one transport.
51#[derive(Debug, Clone, Copy)]
52pub struct CoreClient<T> {
53    transport: T,
54}
55
56impl<T> CoreClient<T> {
57    /// Bind the sealed surface to a transport.
58    #[must_use]
59    pub fn new(transport: T) -> Self {
60        Self { transport }
61    }
62
63    /// The transport this surface calls through.
64    #[must_use]
65    pub fn transport(&self) -> &T {
66        &self.transport
67    }
68
69    /// Take the transport back.
70    #[must_use]
71    pub fn into_transport(self) -> T {
72        self.transport
73    }
74}
75
76impl<T: TapesTransport> CoreClient<T> {
77    /// Resolve one operation in the sealed contract and call it, decoding into
78    /// a type the caller names.
79    ///
80    /// The escape hatch — see the module docs. Equivalent to
81    /// [`CoreClient::call_with_body`] with no body, which is what every read
82    /// operation wants. An operation whose `requestBody` the contract marks
83    /// required is refused rather than sent without one.
84    ///
85    /// # Errors
86    ///
87    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
88    pub async fn call<R: DeserializeOwned>(
89        &self,
90        operation_id: &str,
91        values: Vec<(&str, String)>,
92    ) -> Result<R> {
93        self.call_with_body(operation_id, values, None).await
94    }
95
96    /// Resolve one operation and call it with a request body.
97    ///
98    /// The body travels the same route as every other value: the contract
99    /// decides whether the operation accepts one, requires one, or takes none,
100    /// and a disagreement in either direction is refused before anything is
101    /// sent. Without this the capability would exist one layer down and be
102    /// unreachable from the facade callers actually use — which is exactly
103    /// where a payload goes missing quietly.
104    ///
105    /// # Errors
106    ///
107    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
108    pub async fn call_with_body<R: DeserializeOwned>(
109        &self,
110        operation_id: &str,
111        values: Vec<(&str, String)>,
112        body: Option<String>,
113    ) -> Result<R> {
114        let method = core()?.method(operation_id)?;
115        let request = contract::call_for_with_body(method, values, body)?;
116        let response = self
117            .transport
118            .send(&request)
119            .await
120            .context(error::TransportSnafu)?;
121        decode::json_typed(&response)
122    }
123
124    /// Build the request for one operation without sending it.
125    ///
126    /// For a caller that needs to inspect or decorate a request — a page walk
127    /// setting cursors, a test asserting a URL — without a second route to the
128    /// wire that could route values differently.
129    ///
130    /// # Errors
131    ///
132    /// Any contract failure; see [`crate::Error`].
133    pub fn request_for(
134        &self,
135        operation_id: &str,
136        values: Vec<(&str, String)>,
137    ) -> Result<WireRequest<'static>> {
138        contract::call_for(core()?.method(operation_id)?, values)
139    }
140
141    /// Call an operation with a typed parameter set.
142    async fn with_params<P: ContractParams, R: DeserializeOwned>(&self, params: &P) -> Result<R> {
143        self.call(P::OPERATION, params.values()).await
144    }
145
146    /// Call an operation with a typed parameter set and a path value.
147    async fn with_params_at<P: ContractParams, R: DeserializeOwned>(
148        &self,
149        params: &P,
150        path: Vec<(&str, String)>,
151    ) -> Result<R> {
152        let mut values: Vec<(&str, String)> = params.values();
153        values.extend(path);
154        self.call(P::OPERATION, values).await
155    }
156
157    /// Call an operation with a typed request body.
158    async fn with_body<B: Serialize, R: DeserializeOwned>(
159        &self,
160        operation_id: &str,
161        values: Vec<(&str, String)>,
162        body: &B,
163    ) -> Result<R> {
164        let rendered = serde_json::to_string(body).context(error::RenderBodySnafu)?;
165        self.call_with_body(operation_id, values, Some(rendered))
166            .await
167    }
168
169    /// `GET /v1/sessions` — one page of the sessions listing.
170    ///
171    /// # Errors
172    ///
173    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
174    pub async fn list_sessions(&self, params: &SessionListParams) -> Result<SessionListResponse> {
175        self.with_params(params).await
176    }
177
178    /// Every session the listing matches, following `next_cursor` to the end.
179    ///
180    /// The cursor convention is [`crate::page`]'s, so this walk and a cassette
181    /// listing's stop on the same three spellings of "no more pages" and share
182    /// the guard against a server that repeats a cursor.
183    ///
184    /// # Errors
185    ///
186    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
187    pub async fn list_all_sessions(&self, params: &SessionListParams) -> Result<Vec<SessionItem>> {
188        page::walk(|cursor| {
189            let mut params = params.clone();
190            params.cursor = cursor;
191            async move { Ok(self.list_sessions(&params).await?.into_page()) }
192        })
193        .await
194    }
195
196    /// `GET /v1/sessions/{id}` — one session record.
197    ///
198    /// # Errors
199    ///
200    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
201    pub async fn get_session(&self, id: &str) -> Result<SessionDetailResponse> {
202        self.call(ops::GET_SESSION, vec![("id", id.to_owned())])
203            .await
204    }
205
206    /// `PATCH /v1/sessions/{id}` — rename a session.
207    ///
208    /// # Errors
209    ///
210    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
211    pub async fn update_session(
212        &self,
213        id: &str,
214        body: &SessionUpdateRequest,
215    ) -> Result<SessionDetailResponse> {
216        self.with_body(ops::UPDATE_SESSION, vec![("id", id.to_owned())], body)
217            .await
218    }
219
220    /// `DELETE /v1/sessions/{id}` — delete a session and its subtree.
221    ///
222    /// # Errors
223    ///
224    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
225    pub async fn delete_session(&self, id: &str) -> Result<()> {
226        self.call(ops::DELETE_SESSION, vec![("id", id.to_owned())])
227            .await
228    }
229
230    /// `GET /v1/sessions/{id}/traces` — the derived span read model.
231    ///
232    /// # Errors
233    ///
234    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
235    pub async fn get_session_traces(
236        &self,
237        id: &str,
238        params: &SessionTracesParams,
239    ) -> Result<SessionTracesResponse> {
240        self.with_params_at(params, vec![("id", id.to_owned())])
241            .await
242    }
243
244    /// `GET /v1/sessions/{id}/raw_turns` — the wire log behind a derivation.
245    ///
246    /// # Errors
247    ///
248    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
249    pub async fn list_raw_turns(&self, id: &str) -> Result<RawTurnListResponse> {
250        self.call(ops::LIST_RAW_TURNS, vec![("id", id.to_owned())])
251            .await
252    }
253
254    /// `GET /v1/sessions/{id}/skills` — the skills attributed to one session.
255    ///
256    /// # Errors
257    ///
258    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
259    pub async fn list_session_skills(&self, id: &str) -> Result<SessionSkillsResponse> {
260        self.call(ops::LIST_SESSION_SKILLS, vec![("id", id.to_owned())])
261            .await
262    }
263
264    /// `GET /v1/traces` — the trace summaries for one session.
265    ///
266    /// # Errors
267    ///
268    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
269    pub async fn list_traces(&self, params: &TraceListParams) -> Result<TraceListResponse> {
270        self.with_params(params).await
271    }
272
273    /// `GET /v1/traces/{trace_id}` — one trace with its spans.
274    ///
275    /// # Errors
276    ///
277    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
278    pub async fn get_trace(&self, trace_id: &str, params: &TraceParams) -> Result<TraceDetail> {
279        self.with_params_at(params, vec![("trace_id", trace_id.to_owned())])
280            .await
281    }
282
283    /// `GET /v1/traces/{trace_id}/spans/{span_id}` — one span, in full.
284    ///
285    /// # Errors
286    ///
287    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
288    pub async fn get_span(&self, trace_id: &str, span_id: &str) -> Result<SpanItem> {
289        self.call(
290            ops::GET_SPAN,
291            vec![
292                ("trace_id", trace_id.to_owned()),
293                ("span_id", span_id.to_owned()),
294            ],
295        )
296        .await
297    }
298
299    /// `GET /v1/cassettes/search/spans` — semantic search over span
300    /// embeddings, served by the search cassette.
301    ///
302    /// The one route here that is not the sealed contract's own. Span search
303    /// was extracted from tapes core into the search cassette, which serves
304    /// the identical request and response shapes under `/v1/cassettes/search`;
305    /// core's `/v1/search/spans` is retirement-bound and no longer the copy
306    /// deployments keep current. The sealed operation still supplies all of
307    /// the parameter and response plumbing — only the path moves — so a
308    /// contract change to the search shape still lands here at vendor time.
309    ///
310    /// A deployment that does not serve the search cassette answers 404;
311    /// typed access over the *discovered* surface, which would make that a
312    /// first-class "not served here" instead, is the follow-on this literal
313    /// path is the bridge to.
314    ///
315    /// # Errors
316    ///
317    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
318    pub async fn search_spans(&self, params: &SearchSpansParams) -> Result<SpanSearchOutput> {
319        let mut request = self.request_for(ops::SEARCH_SPANS, params.values())?;
320        request.path = "/v1/cassettes/search/spans";
321        let response = self
322            .transport
323            .send(&request)
324            .await
325            .context(error::TransportSnafu)?;
326        decode::json_typed(&response)
327    }
328
329    /// `GET /v1/stats` — the aggregate rollups.
330    ///
331    /// # Errors
332    ///
333    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
334    pub async fn get_stats(&self, params: &StatsParams) -> Result<StatsResponse> {
335        self.with_params(params).await
336    }
337
338    /// `GET /v1/skills` — one page of the skills listing.
339    ///
340    /// # Errors
341    ///
342    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
343    pub async fn list_skills(&self, params: &SkillsListParams) -> Result<SkillsListResponse> {
344        self.with_params(params).await
345    }
346
347    /// Every skill the listing matches, following `next_cursor` to the end.
348    ///
349    /// # Errors
350    ///
351    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
352    pub async fn list_all_skills(&self, params: &SkillsListParams) -> Result<Vec<SkillResponse>> {
353        page::walk(|cursor| {
354            let mut params = params.clone();
355            params.cursor = cursor;
356            async move { Ok(self.list_skills(&params).await?.into_page()) }
357        })
358        .await
359    }
360
361    /// `GET /v1/skills/{id}` — one skill.
362    ///
363    /// # Errors
364    ///
365    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
366    pub async fn get_skill(&self, id: &str) -> Result<SkillResponse> {
367        self.call(ops::GET_SKILL, vec![("id", id.to_owned())]).await
368    }
369
370    /// `POST /v1/skills` — author a skill.
371    ///
372    /// # Errors
373    ///
374    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
375    pub async fn create_skill(&self, body: &CreateSkillRequest) -> Result<SkillResponse> {
376        self.with_body(ops::CREATE_SKILL, Vec::new(), body).await
377    }
378
379    /// `PUT /v1/skills/{id}` — apply the present fields onto a skill.
380    ///
381    /// # Errors
382    ///
383    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
384    pub async fn update_skill(&self, id: &str, body: &UpdateSkillRequest) -> Result<SkillResponse> {
385        self.with_body(ops::UPDATE_SKILL, vec![("id", id.to_owned())], body)
386            .await
387    }
388
389    /// `DELETE /v1/skills/{id}` — delete a skill.
390    ///
391    /// # Errors
392    ///
393    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
394    pub async fn delete_skill(&self, id: &str) -> Result<()> {
395        self.call(ops::DELETE_SKILL, vec![("id", id.to_owned())])
396            .await
397    }
398
399    /// `POST /v1/skills/{id}/duplicate` — fork a skill.
400    ///
401    /// # Errors
402    ///
403    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
404    pub async fn duplicate_skill(&self, id: &str) -> Result<SkillResponse> {
405        self.call(ops::DUPLICATE_SKILL, vec![("id", id.to_owned())])
406            .await
407    }
408
409    /// `GET /v1/skills/{id}/versions` — one skill's published history.
410    ///
411    /// # Errors
412    ///
413    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
414    pub async fn list_skill_versions(&self, id: &str) -> Result<SkillVersionsResponse> {
415        self.call(ops::LIST_SKILL_VERSIONS, vec![("id", id.to_owned())])
416            .await
417    }
418
419    /// `POST /v1/skills/{id}/versions` — publish an immutable snapshot.
420    ///
421    /// # Errors
422    ///
423    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
424    pub async fn publish_skill(
425        &self,
426        id: &str,
427        body: &PublishSkillRequest,
428    ) -> Result<SkillVersionResponse> {
429        self.with_body(ops::PUBLISH_SKILL, vec![("id", id.to_owned())], body)
430            .await
431    }
432
433    /// `POST /v1/skills/generate` — generate a skill from sessions.
434    ///
435    /// # Errors
436    ///
437    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
438    pub async fn generate_skill(&self, body: &GenerateSkillRequest) -> Result<SkillResponse> {
439        self.with_body(ops::GENERATE_SKILL, Vec::new(), body).await
440    }
441
442    /// `GET /v1/cassettes` — what this deployment serves.
443    ///
444    /// Decodes into the cassette surface's own model rather than a second copy
445    /// of it: [`crate::cassettes::discovery`] reads the fields the generated
446    /// command surface acts on, and modelling the document twice is the
447    /// duplication this crate exists to end.
448    ///
449    /// # Errors
450    ///
451    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
452    pub async fn list_cassettes(&self) -> Result<Discovery> {
453        self.call(ops::LIST_CASSETTES, Vec::new()).await
454    }
455
456    /// `POST /v1/admin/seed/demo` — replay the demo corpora.
457    ///
458    /// # Errors
459    ///
460    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
461    pub async fn seed_demo(&self, body: &SeedDemoRequest) -> Result<SeedResult> {
462        self.with_body(ops::SEED_DEMO, Vec::new(), body).await
463    }
464}
465
466impl<T: StreamingTransport> CoreClient<T> {
467    /// Resolve one operation and stream its response.
468    ///
469    /// Bodyless, and deliberately so: nothing in this contract both streams a
470    /// response and takes a request body. That is an observation about the
471    /// document rather than a rule, so it is not enforced here — an operation
472    /// that did take a required body would be refused with the same loud error
473    /// as anywhere else, which is a signal to add the body-bearing sibling
474    /// rather than a payload going missing.
475    ///
476    /// # Errors
477    ///
478    /// Any contract or transport failure; see [`crate::Error`].
479    pub async fn stream(&self, operation_id: &str, values: Vec<(&str, String)>) -> Result<T::Body> {
480        let method = core()?.method(operation_id)?;
481        let request = contract::call_for(method, values)?;
482        self.transport.send_stream(&request).await
483    }
484
485    /// `GET /v1/sessions/{id}/export`, streamed.
486    ///
487    /// An export can be far larger than a session's working set, and there is
488    /// no reason to hold it in memory on the way to a file. It stays untyped
489    /// for the same reason: an archive written through a typed decode is an
490    /// archive of the fields this build happened to know about.
491    ///
492    /// # Errors
493    ///
494    /// Any contract or transport failure; see [`crate::Error`].
495    pub async fn export_session(&self, id: &str, params: &ExportSessionParams) -> Result<T::Body> {
496        let mut values = params.values();
497        values.push(("id", id.to_owned()));
498        self.stream(ops::EXPORT_SESSION, values).await
499    }
500
501    /// `GET /v1/sessions/export`, streamed.
502    ///
503    /// # Errors
504    ///
505    /// Any contract or transport failure; see [`crate::Error`].
506    pub async fn export_sessions(&self, params: &ExportSessionsParams) -> Result<T::Body> {
507        self.stream(ops::EXPORT_SESSIONS, params.values()).await
508    }
509}
510
511#[cfg(test)]
512#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
513mod tests {
514    use super::*;
515    use crate::core::models::params::PayloadDetail;
516    use crate::path::{PathMode, call_url};
517    use crate::transport::{TransportError, WireResponse};
518    use serde::Deserialize;
519    use serde_json::Value;
520    use std::cell::RefCell;
521    use url::Url;
522
523    /// A transport that records what it was asked to send and answers with a
524    /// canned response — enough to prove the contract layer routed the values,
525    /// without a socket.
526    ///
527    /// It records the request body as well as the URL, because "the payload
528    /// arrived at the transport" is the only place a facade that dropped it
529    /// would be visible: every layer above still looks correct.
530    struct Recorder {
531        base: Url,
532        responses: RefCell<Vec<Value>>,
533        seen: RefCell<Vec<String>>,
534        bodies: RefCell<Vec<Option<String>>>,
535    }
536
537    impl Recorder {
538        fn new(base: &str, responses: Vec<Value>) -> Self {
539            Self {
540                base: Url::parse(base).unwrap(),
541                responses: RefCell::new(responses),
542                seen: RefCell::new(Vec::new()),
543                bodies: RefCell::new(Vec::new()),
544            }
545        }
546    }
547
548    impl TapesTransport for Recorder {
549        async fn send(
550            &self,
551            request: &WireRequest<'_>,
552        ) -> std::result::Result<WireResponse, TransportError> {
553            let url = call_url(&self.base, request, PathMode::UnderBase)
554                .map_err(|error| TransportError::new(error.to_string()))?;
555            self.seen.borrow_mut().push(url.to_string());
556            self.bodies.borrow_mut().push(request.body.clone());
557            let mut responses = self.responses.borrow_mut();
558            let body = if responses.len() > 1 {
559                responses.remove(0)
560            } else {
561                responses.first().cloned().unwrap_or(Value::Null)
562            };
563            Ok(WireResponse::new(
564                200,
565                url.to_string(),
566                Vec::new(),
567                body.to_string().into_bytes(),
568            ))
569        }
570    }
571
572    fn client(base: &str, response: Value) -> CoreClient<Recorder> {
573        CoreClient::new(Recorder::new(base, vec![response]))
574    }
575
576    #[tokio::test]
577    async fn an_operation_is_routed_through_the_contract_and_the_transport() {
578        let client = client(
579            "https://acme.example/primary/tapes/",
580            serde_json::json!({"traces": []}),
581        );
582        let _ = client
583            .get_session_traces("s-1", &SessionTracesParams::default())
584            .await
585            .unwrap();
586
587        assert_eq!(
588            client.transport().seen.borrow()[0],
589            "https://acme.example/primary/tapes/v1/sessions/s-1/traces",
590        );
591    }
592
593    #[tokio::test]
594    async fn a_typed_method_decodes_the_contracts_own_shape() {
595        // The default surface: the caller names no type, and the fields it
596        // reads are the ones the sealed document publishes.
597        let client = client(
598            "http://127.0.0.1:8081",
599            serde_json::json!({
600                "items": [{"id": "s1", "rollup": {"turn_count": 3}}],
601                "next_cursor": "abc",
602            }),
603        );
604        let listing = client
605            .list_sessions(&SessionListParams::default())
606            .await
607            .unwrap();
608
609        assert_eq!(listing.items[0].id, "s1");
610        assert_eq!(listing.items[0].rollup.turn_count, 3);
611        assert_eq!(listing.next_cursor, "abc");
612    }
613
614    #[tokio::test]
615    async fn a_typed_method_survives_a_field_it_has_never_heard_of() {
616        // The rule the models are built on, exercised end to end: a newer
617        // server is not a malformed response.
618        let client = client(
619            "http://127.0.0.1:8081",
620            serde_json::json!({"items": [{"id": "s1", "a_field_from_the_future": 7}]}),
621        );
622        let listing = client
623            .list_sessions(&SessionListParams::default())
624            .await
625            .unwrap();
626        assert_eq!(listing.items[0].id, "s1");
627    }
628
629    #[tokio::test]
630    async fn the_generic_seam_still_decodes_into_a_callers_own_type() {
631        // The escape hatch stays reachable, and stays untyped when a caller
632        // asks for a document rather than a model.
633        #[derive(Debug, Deserialize)]
634        struct Listing {
635            next_cursor: String,
636        }
637
638        let client = client(
639            "http://127.0.0.1:8081",
640            serde_json::json!({"items": [], "next_cursor": "abc"}),
641        );
642        let got: Listing = client.call(ops::LIST_SESSIONS, Vec::new()).await.unwrap();
643        assert_eq!(got.next_cursor, "abc");
644
645        let raw: Value = client.call(ops::LIST_SESSIONS, Vec::new()).await.unwrap();
646        assert_eq!(raw["next_cursor"], "abc");
647    }
648
649    #[tokio::test]
650    async fn a_typed_parameter_travels_under_the_contracts_own_name() {
651        let client = client("http://127.0.0.1:8081", serde_json::json!({"traces": []}));
652        let _ = client
653            .get_session_traces(
654                "s-1",
655                &SessionTracesParams {
656                    payload: Some(PayloadDetail::Preview),
657                },
658            )
659            .await
660            .unwrap();
661        assert!(
662            client.transport().seen.borrow()[0].ends_with("/traces?payload=preview"),
663            "got: {:?}",
664            client.transport().seen.borrow(),
665        );
666    }
667
668    #[tokio::test]
669    async fn a_listing_walk_follows_the_cursor_to_the_end() {
670        // The models and the crate's one pagination convention meet here: the
671        // envelope becomes a `Page`, and `page::walk` owns the loop.
672        let client = CoreClient::new(Recorder::new(
673            "http://127.0.0.1:8081",
674            vec![
675                serde_json::json!({"items": [{"id": "s1"}], "next_cursor": "c1"}),
676                serde_json::json!({"items": [{"id": "s2"}], "next_cursor": ""}),
677            ],
678        ));
679        let sessions = client
680            .list_all_sessions(&SessionListParams::default())
681            .await
682            .unwrap();
683
684        assert_eq!(
685            sessions.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
686            vec!["s1", "s2"],
687        );
688        assert!(
689            client.transport().seen.borrow()[1].contains("cursor=c1"),
690            "got: {:?}",
691            client.transport().seen.borrow(),
692        );
693    }
694
695    #[tokio::test]
696    async fn an_undeclared_parameter_is_refused_before_the_transport_is_reached() {
697        let client = client("http://127.0.0.1:8081", Value::Null);
698        let err = client
699            .call::<Value>(ops::GET_SESSION, vec![("payolad", "full".to_owned())])
700            .await
701            .unwrap_err();
702        assert!(err.to_string().contains("payolad"), "got: {err}");
703        assert!(
704            client.transport().seen.borrow().is_empty(),
705            "nothing may be sent for a call the contract refused",
706        );
707    }
708
709    #[tokio::test]
710    async fn a_typed_body_reaches_the_transport_as_the_contracts_own_json() {
711        // The gap this closes: the body capability exists one layer down, and
712        // a facade that routed around it would drop a payload passed here
713        // while still producing a request that looked correct.
714        let client = client("http://127.0.0.1:8081", serde_json::json!({"id": "sk-1"}));
715        let skill = client
716            .create_skill(&CreateSkillRequest {
717                name: "gum".to_owned(),
718                ..Default::default()
719            })
720            .await
721            .unwrap();
722
723        assert_eq!(skill.id, "sk-1");
724        let bodies = client.transport().bodies.borrow();
725        let sent: Value = serde_json::from_str(bodies[0].as_deref().unwrap()).unwrap();
726        assert_eq!(sent["name"], "gum");
727    }
728
729    #[tokio::test]
730    async fn the_bodyless_facade_refuses_an_operation_that_requires_a_body() {
731        // The whole point of the refusal is that it survives every route to
732        // the wire; a facade that quietly sent the request anyway would be the
733        // original silence with an extra layer on top.
734        let client = client("http://127.0.0.1:8081", Value::Null);
735        let err = client
736            .call::<Value>(ops::CREATE_SKILL, Vec::new())
737            .await
738            .unwrap_err();
739
740        assert!(
741            err.to_string().contains("requires a request body"),
742            "got: {err}",
743        );
744        assert!(client.transport().seen.borrow().is_empty());
745    }
746
747    #[tokio::test]
748    async fn the_facade_refuses_a_body_on_an_operation_that_declares_none() {
749        let client = client("http://127.0.0.1:8081", Value::Null);
750        let err = client
751            .call_with_body::<Value>(
752                ops::GET_SESSION,
753                vec![("id", "s-1".to_owned())],
754                Some("{}".to_owned()),
755            )
756            .await
757            .unwrap_err();
758
759        assert!(
760            err.to_string().contains("declares no request body"),
761            "got: {err}",
762        );
763        assert!(client.transport().seen.borrow().is_empty());
764    }
765
766    #[tokio::test]
767    async fn the_bodyless_facade_still_sends_no_body_for_an_ordinary_read() {
768        // Plumbing a body through must not start attaching one where none was
769        // asked for: every read operation goes out exactly as before.
770        let client = client("http://127.0.0.1:8081", serde_json::json!({"items": []}));
771        let _ = client
772            .list_sessions(&SessionListParams::default())
773            .await
774            .unwrap();
775        assert_eq!(client.transport().bodies.borrow().as_slice(), [None]);
776    }
777
778    #[tokio::test]
779    async fn search_spans_targets_the_search_cassette_route() {
780        // The one deliberate departure from the operation table: span search
781        // is served by the search cassette, and the sealed operation only
782        // supplies the parameter plumbing. If this URL ever reads
783        // /v1/search/spans again, the client has silently moved back to the
784        // retirement-bound core route.
785        let client = client(
786            "http://127.0.0.1:8081",
787            serde_json::json!({"query": "q", "results": []}),
788        );
789        let _ = client
790            .search_spans(&SearchSpansParams {
791                query: "retry backoff".to_owned(),
792                top_k: Some(3),
793            })
794            .await
795            .unwrap();
796
797        let seen = client.transport().seen.borrow();
798        assert_eq!(seen.len(), 1);
799        assert!(
800            seen[0].contains("/v1/cassettes/search/spans?"),
801            "expected the cassette route, got {}",
802            seen[0]
803        );
804        assert!(
805            seen[0].contains("query=retry+backoff") || seen[0].contains("query=retry%20backoff")
806        );
807        assert!(seen[0].contains("top_k=3"));
808    }
809
810    #[tokio::test]
811    async fn a_named_method_and_its_operation_id_build_the_same_request() {
812        // The named methods must stay conveniences. If one ever routed a value
813        // differently from the operation id it names, this crate would be back
814        // to two ways of building a request that can disagree.
815        let named = client("http://127.0.0.1:8081", serde_json::json!({}));
816        let _ = named.get_span("t-1", "sp-1").await.unwrap();
817
818        let raw = client("http://127.0.0.1:8081", serde_json::json!({}));
819        let _: Value = raw
820            .call(
821                ops::GET_SPAN,
822                vec![
823                    ("trace_id", "t-1".to_owned()),
824                    ("span_id", "sp-1".to_owned()),
825                ],
826            )
827            .await
828            .unwrap();
829
830        assert_eq!(
831            *named.transport().seen.borrow(),
832            *raw.transport().seen.borrow()
833        );
834    }
835}