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(¶ms).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(¶ms).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}