1use 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#[derive(Debug, Clone, Copy)]
52pub struct CoreClient<T> {
53 transport: T,
54}
55
56impl<T> CoreClient<T> {
57 #[must_use]
59 pub fn new(transport: T) -> Self {
60 Self { transport }
61 }
62
63 #[must_use]
65 pub fn transport(&self) -> &T {
66 &self.transport
67 }
68
69 #[must_use]
71 pub fn into_transport(self) -> T {
72 self.transport
73 }
74}
75
76fn reroute_to_cassette(operation_id: &str, request: &mut WireRequest<'static>) {
99 request.path = match operation_id {
100 ops::SEARCH_SPANS => "/v1/cassettes/search/spans",
101 ops::EXPORT_SESSION => "/v1/cassettes/export/sessions/{id}",
102 ops::EXPORT_SESSIONS => "/v1/cassettes/export/sessions",
103 ops::LIST_SKILLS | ops::CREATE_SKILL => "/v1/cassettes/skills",
104 ops::GET_SKILL | ops::UPDATE_SKILL | ops::DELETE_SKILL => "/v1/cassettes/skills/{id}",
105 ops::DUPLICATE_SKILL => "/v1/cassettes/skills/{id}/duplicate",
106 ops::GET_SKILL_MARKDOWN => "/v1/cassettes/skills/{id}/skill.md",
107 ops::LIST_SKILL_VERSIONS | ops::PUBLISH_SKILL => "/v1/cassettes/skills/{id}/versions",
108 ops::GENERATE_SKILL => "/v1/cassettes/skills/generate",
109 ops::LIST_SESSION_SKILLS => {
110 let session = request
111 .path_params
112 .iter()
113 .position(|(name, _)| name == "id")
114 .map(|index| request.path_params.remove(index));
115 if let Some((_, id)) = session {
116 request.query.push(("session_id".to_owned(), id));
117 }
118 "/v1/cassettes/skills"
119 }
120 _ => return,
121 };
122}
123
124impl<T: TapesTransport> CoreClient<T> {
125 pub async fn call<R: DeserializeOwned>(
137 &self,
138 operation_id: &str,
139 values: Vec<(&str, String)>,
140 ) -> Result<R> {
141 self.call_with_body(operation_id, values, None).await
142 }
143
144 pub async fn call_with_body<R: DeserializeOwned>(
157 &self,
158 operation_id: &str,
159 values: Vec<(&str, String)>,
160 body: Option<String>,
161 ) -> Result<R> {
162 let method = core()?.method(operation_id)?;
163 let mut request = contract::call_for_with_body(method, values, body)?;
164 reroute_to_cassette(operation_id, &mut request);
165 let response = self
166 .transport
167 .send(&request)
168 .await
169 .context(error::TransportSnafu)?;
170 decode::json_typed(&response)
171 }
172
173 pub fn request_for(
183 &self,
184 operation_id: &str,
185 values: Vec<(&str, String)>,
186 ) -> Result<WireRequest<'static>> {
187 let mut request = contract::call_for(core()?.method(operation_id)?, values)?;
188 reroute_to_cassette(operation_id, &mut request);
189 Ok(request)
190 }
191
192 async fn with_params<P: ContractParams, R: DeserializeOwned>(&self, params: &P) -> Result<R> {
194 self.call(P::OPERATION, params.values()).await
195 }
196
197 async fn with_params_at<P: ContractParams, R: DeserializeOwned>(
199 &self,
200 params: &P,
201 path: Vec<(&str, String)>,
202 ) -> Result<R> {
203 let mut values: Vec<(&str, String)> = params.values();
204 values.extend(path);
205 self.call(P::OPERATION, values).await
206 }
207
208 async fn with_body<B: Serialize, R: DeserializeOwned>(
210 &self,
211 operation_id: &str,
212 values: Vec<(&str, String)>,
213 body: &B,
214 ) -> Result<R> {
215 let rendered = serde_json::to_string(body).context(error::RenderBodySnafu)?;
216 self.call_with_body(operation_id, values, Some(rendered))
217 .await
218 }
219
220 pub async fn list_sessions(&self, params: &SessionListParams) -> Result<SessionListResponse> {
226 self.with_params(params).await
227 }
228
229 pub async fn list_all_sessions(&self, params: &SessionListParams) -> Result<Vec<SessionItem>> {
239 page::walk(|cursor| {
240 let mut params = params.clone();
241 params.cursor = cursor;
242 async move { Ok(self.list_sessions(¶ms).await?.into_page()) }
243 })
244 .await
245 }
246
247 pub async fn get_session(&self, id: &str) -> Result<SessionDetailResponse> {
253 self.call(ops::GET_SESSION, vec![("id", id.to_owned())])
254 .await
255 }
256
257 pub async fn update_session(
263 &self,
264 id: &str,
265 body: &SessionUpdateRequest,
266 ) -> Result<SessionDetailResponse> {
267 self.with_body(ops::UPDATE_SESSION, vec![("id", id.to_owned())], body)
268 .await
269 }
270
271 pub async fn delete_session(&self, id: &str) -> Result<()> {
277 self.call(ops::DELETE_SESSION, vec![("id", id.to_owned())])
278 .await
279 }
280
281 pub async fn get_session_traces(
287 &self,
288 id: &str,
289 params: &SessionTracesParams,
290 ) -> Result<SessionTracesResponse> {
291 self.with_params_at(params, vec![("id", id.to_owned())])
292 .await
293 }
294
295 pub async fn list_raw_turns(&self, id: &str) -> Result<RawTurnListResponse> {
301 self.call(ops::LIST_RAW_TURNS, vec![("id", id.to_owned())])
302 .await
303 }
304
305 pub async fn list_session_skills(&self, id: &str) -> Result<SessionSkillsResponse> {
312 self.call(ops::LIST_SESSION_SKILLS, vec![("id", id.to_owned())])
313 .await
314 }
315
316 pub async fn list_traces(&self, params: &TraceListParams) -> Result<TraceListResponse> {
322 self.with_params(params).await
323 }
324
325 pub async fn get_trace(&self, trace_id: &str, params: &TraceParams) -> Result<TraceDetail> {
331 self.with_params_at(params, vec![("trace_id", trace_id.to_owned())])
332 .await
333 }
334
335 pub async fn get_span(&self, trace_id: &str, span_id: &str) -> Result<SpanItem> {
341 self.call(
342 ops::GET_SPAN,
343 vec![
344 ("trace_id", trace_id.to_owned()),
345 ("span_id", span_id.to_owned()),
346 ],
347 )
348 .await
349 }
350
351 pub async fn search_spans(&self, params: &SearchSpansParams) -> Result<SpanSearchOutput> {
359 self.with_params(params).await
360 }
361
362 pub async fn get_stats(&self, params: &StatsParams) -> Result<StatsResponse> {
368 self.with_params(params).await
369 }
370
371 pub async fn list_skills(&self, params: &SkillsListParams) -> Result<SkillsListResponse> {
377 self.with_params(params).await
378 }
379
380 pub async fn list_all_skills(&self, params: &SkillsListParams) -> Result<Vec<SkillResponse>> {
386 page::walk(|cursor| {
387 let mut params = params.clone();
388 params.cursor = cursor;
389 async move { Ok(self.list_skills(¶ms).await?.into_page()) }
390 })
391 .await
392 }
393
394 pub async fn get_skill(&self, id: &str) -> Result<SkillResponse> {
400 self.call(ops::GET_SKILL, vec![("id", id.to_owned())]).await
401 }
402
403 pub async fn create_skill(&self, body: &CreateSkillRequest) -> Result<SkillResponse> {
409 self.with_body(ops::CREATE_SKILL, Vec::new(), body).await
410 }
411
412 pub async fn update_skill(&self, id: &str, body: &UpdateSkillRequest) -> Result<SkillResponse> {
418 self.with_body(ops::UPDATE_SKILL, vec![("id", id.to_owned())], body)
419 .await
420 }
421
422 pub async fn delete_skill(&self, id: &str) -> Result<()> {
428 self.call(ops::DELETE_SKILL, vec![("id", id.to_owned())])
429 .await
430 }
431
432 pub async fn duplicate_skill(&self, id: &str) -> Result<SkillResponse> {
438 self.call(ops::DUPLICATE_SKILL, vec![("id", id.to_owned())])
439 .await
440 }
441
442 pub async fn list_skill_versions(&self, id: &str) -> Result<SkillVersionsResponse> {
448 self.call(ops::LIST_SKILL_VERSIONS, vec![("id", id.to_owned())])
449 .await
450 }
451
452 pub async fn publish_skill(
458 &self,
459 id: &str,
460 body: &PublishSkillRequest,
461 ) -> Result<SkillVersionResponse> {
462 self.with_body(ops::PUBLISH_SKILL, vec![("id", id.to_owned())], body)
463 .await
464 }
465
466 pub async fn generate_skill(&self, body: &GenerateSkillRequest) -> Result<SkillResponse> {
472 self.with_body(ops::GENERATE_SKILL, Vec::new(), body).await
473 }
474
475 pub async fn list_cassettes(&self) -> Result<Discovery> {
486 self.call(ops::LIST_CASSETTES, Vec::new()).await
487 }
488
489 pub async fn seed_demo(&self, body: &SeedDemoRequest) -> Result<SeedResult> {
495 self.with_body(ops::SEED_DEMO, Vec::new(), body).await
496 }
497}
498
499impl<T: StreamingTransport> CoreClient<T> {
500 pub async fn stream(&self, operation_id: &str, values: Vec<(&str, String)>) -> Result<T::Body> {
513 let method = core()?.method(operation_id)?;
514 let mut request = contract::call_for(method, values)?;
515 reroute_to_cassette(operation_id, &mut request);
516 self.transport.send_stream(&request).await
517 }
518
519 pub async fn export_session(&self, id: &str, params: &ExportSessionParams) -> Result<T::Body> {
530 let mut values = params.values();
531 values.push(("id", id.to_owned()));
532 self.stream(ops::EXPORT_SESSION, values).await
533 }
534
535 pub async fn export_sessions(&self, params: &ExportSessionsParams) -> Result<T::Body> {
541 self.stream(ops::EXPORT_SESSIONS, params.values()).await
542 }
543}
544
545#[cfg(test)]
546#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
547mod tests {
548 use super::*;
549 use crate::cassettes::spec::Location;
550 use crate::core::models::params::PayloadDetail;
551 use crate::path::{PathMode, call_url};
552 use crate::transport::{TransportError, WireResponse};
553 use serde::Deserialize;
554 use serde_json::Value;
555 use std::cell::RefCell;
556 use url::Url;
557
558 struct Recorder {
566 base: Url,
567 responses: RefCell<Vec<Value>>,
568 seen: RefCell<Vec<String>>,
569 bodies: RefCell<Vec<Option<String>>>,
570 }
571
572 impl Recorder {
573 fn new(base: &str, responses: Vec<Value>) -> Self {
574 Self {
575 base: Url::parse(base).unwrap(),
576 responses: RefCell::new(responses),
577 seen: RefCell::new(Vec::new()),
578 bodies: RefCell::new(Vec::new()),
579 }
580 }
581 }
582
583 impl TapesTransport for Recorder {
584 async fn send(
585 &self,
586 request: &WireRequest<'_>,
587 ) -> std::result::Result<WireResponse, TransportError> {
588 let url = call_url(&self.base, request, PathMode::UnderBase)
589 .map_err(|error| TransportError::new(error.to_string()))?;
590 self.seen.borrow_mut().push(url.to_string());
591 self.bodies.borrow_mut().push(request.body.clone());
592 let mut responses = self.responses.borrow_mut();
593 let body = if responses.len() > 1 {
594 responses.remove(0)
595 } else {
596 responses.first().cloned().unwrap_or(Value::Null)
597 };
598 Ok(WireResponse::new(
599 200,
600 url.to_string(),
601 Vec::new(),
602 body.to_string().into_bytes(),
603 ))
604 }
605 }
606
607 fn client(base: &str, response: Value) -> CoreClient<Recorder> {
608 CoreClient::new(Recorder::new(base, vec![response]))
609 }
610
611 #[tokio::test]
612 async fn an_operation_is_routed_through_the_contract_and_the_transport() {
613 let client = client(
614 "https://acme.example/primary/tapes/",
615 serde_json::json!({"traces": []}),
616 );
617 let _ = client
618 .get_session_traces("s-1", &SessionTracesParams::default())
619 .await
620 .unwrap();
621
622 assert_eq!(
623 client.transport().seen.borrow()[0],
624 "https://acme.example/primary/tapes/v1/sessions/s-1/traces",
625 );
626 }
627
628 #[tokio::test]
629 async fn a_typed_method_decodes_the_contracts_own_shape() {
630 let client = client(
633 "http://127.0.0.1:8081",
634 serde_json::json!({
635 "items": [{"id": "s1", "rollup": {"turn_count": 3}}],
636 "next_cursor": "abc",
637 }),
638 );
639 let listing = client
640 .list_sessions(&SessionListParams::default())
641 .await
642 .unwrap();
643
644 assert_eq!(listing.items[0].id, "s1");
645 assert_eq!(listing.items[0].rollup.turn_count, 3);
646 assert_eq!(listing.next_cursor, "abc");
647 }
648
649 #[tokio::test]
650 async fn a_typed_method_survives_a_field_it_has_never_heard_of() {
651 let client = client(
654 "http://127.0.0.1:8081",
655 serde_json::json!({"items": [{"id": "s1", "a_field_from_the_future": 7}]}),
656 );
657 let listing = client
658 .list_sessions(&SessionListParams::default())
659 .await
660 .unwrap();
661 assert_eq!(listing.items[0].id, "s1");
662 }
663
664 #[tokio::test]
665 async fn the_generic_seam_still_decodes_into_a_callers_own_type() {
666 #[derive(Debug, Deserialize)]
669 struct Listing {
670 next_cursor: String,
671 }
672
673 let client = client(
674 "http://127.0.0.1:8081",
675 serde_json::json!({"items": [], "next_cursor": "abc"}),
676 );
677 let got: Listing = client.call(ops::LIST_SESSIONS, Vec::new()).await.unwrap();
678 assert_eq!(got.next_cursor, "abc");
679
680 let raw: Value = client.call(ops::LIST_SESSIONS, Vec::new()).await.unwrap();
681 assert_eq!(raw["next_cursor"], "abc");
682 }
683
684 #[tokio::test]
685 async fn a_typed_parameter_travels_under_the_contracts_own_name() {
686 let client = client("http://127.0.0.1:8081", serde_json::json!({"traces": []}));
687 let _ = client
688 .get_session_traces(
689 "s-1",
690 &SessionTracesParams {
691 payload: Some(PayloadDetail::Preview),
692 },
693 )
694 .await
695 .unwrap();
696 assert!(
697 client.transport().seen.borrow()[0].ends_with("/traces?payload=preview"),
698 "got: {:?}",
699 client.transport().seen.borrow(),
700 );
701 }
702
703 #[tokio::test]
704 async fn a_listing_walk_follows_the_cursor_to_the_end() {
705 let client = CoreClient::new(Recorder::new(
708 "http://127.0.0.1:8081",
709 vec![
710 serde_json::json!({"items": [{"id": "s1"}], "next_cursor": "c1"}),
711 serde_json::json!({"items": [{"id": "s2"}], "next_cursor": ""}),
712 ],
713 ));
714 let sessions = client
715 .list_all_sessions(&SessionListParams::default())
716 .await
717 .unwrap();
718
719 assert_eq!(
720 sessions.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
721 vec!["s1", "s2"],
722 );
723 assert!(
724 client.transport().seen.borrow()[1].contains("cursor=c1"),
725 "got: {:?}",
726 client.transport().seen.borrow(),
727 );
728 }
729
730 #[tokio::test]
731 async fn an_undeclared_parameter_is_refused_before_the_transport_is_reached() {
732 let client = client("http://127.0.0.1:8081", Value::Null);
733 let err = client
734 .call::<Value>(ops::GET_SESSION, vec![("payolad", "full".to_owned())])
735 .await
736 .unwrap_err();
737 assert!(err.to_string().contains("payolad"), "got: {err}");
738 assert!(
739 client.transport().seen.borrow().is_empty(),
740 "nothing may be sent for a call the contract refused",
741 );
742 }
743
744 #[tokio::test]
745 async fn a_typed_body_reaches_the_transport_as_the_contracts_own_json() {
746 let client = client("http://127.0.0.1:8081", serde_json::json!({"id": "sk-1"}));
750 let skill = client
751 .create_skill(&CreateSkillRequest {
752 name: "gum".to_owned(),
753 ..Default::default()
754 })
755 .await
756 .unwrap();
757
758 assert_eq!(skill.id, "sk-1");
759 let bodies = client.transport().bodies.borrow();
760 let sent: Value = serde_json::from_str(bodies[0].as_deref().unwrap()).unwrap();
761 assert_eq!(sent["name"], "gum");
762 }
763
764 #[tokio::test]
765 async fn the_bodyless_facade_refuses_an_operation_that_requires_a_body() {
766 let client = client("http://127.0.0.1:8081", Value::Null);
770 let err = client
771 .call::<Value>(ops::CREATE_SKILL, Vec::new())
772 .await
773 .unwrap_err();
774
775 assert!(
776 err.to_string().contains("requires a request body"),
777 "got: {err}",
778 );
779 assert!(client.transport().seen.borrow().is_empty());
780 }
781
782 #[tokio::test]
783 async fn the_facade_refuses_a_body_on_an_operation_that_declares_none() {
784 let client = client("http://127.0.0.1:8081", Value::Null);
785 let err = client
786 .call_with_body::<Value>(
787 ops::GET_SESSION,
788 vec![("id", "s-1".to_owned())],
789 Some("{}".to_owned()),
790 )
791 .await
792 .unwrap_err();
793
794 assert!(
795 err.to_string().contains("declares no request body"),
796 "got: {err}",
797 );
798 assert!(client.transport().seen.borrow().is_empty());
799 }
800
801 #[tokio::test]
802 async fn the_bodyless_facade_still_sends_no_body_for_an_ordinary_read() {
803 let client = client("http://127.0.0.1:8081", serde_json::json!({"items": []}));
806 let _ = client
807 .list_sessions(&SessionListParams::default())
808 .await
809 .unwrap();
810 assert_eq!(client.transport().bodies.borrow().as_slice(), [None]);
811 }
812
813 impl crate::transport::StreamingTransport for Recorder {
814 type Body = Vec<u8>;
815
816 async fn send_stream(&self, request: &WireRequest<'_>) -> Result<Self::Body> {
817 let url = call_url(&self.base, request, PathMode::UnderBase).map_err(|error| {
818 crate::Error::Transport {
819 source: TransportError::new(error.to_string()),
820 }
821 })?;
822 self.seen.borrow_mut().push(url.to_string());
823 Ok(Vec::new())
824 }
825 }
826
827 #[tokio::test]
833 async fn extracted_operations_target_their_cassette_routes() {
834 let client = CoreClient::new(Recorder::new(
835 "http://127.0.0.1:8081",
836 vec![serde_json::json!({})],
837 ));
838 type Case = (&'static str, Vec<(&'static str, String)>, &'static str);
839 let id = ("id", "x-1".to_owned());
840 let cases: &[Case] = &[
841 (
842 ops::SEARCH_SPANS,
843 vec![("query", "q".to_owned())],
844 "/v1/cassettes/search/spans",
845 ),
846 (
847 ops::EXPORT_SESSION,
848 vec![id.clone()],
849 "/v1/cassettes/export/sessions/x-1",
850 ),
851 (
852 ops::EXPORT_SESSIONS,
853 vec![],
854 "/v1/cassettes/export/sessions",
855 ),
856 (ops::LIST_SKILLS, vec![], "/v1/cassettes/skills"),
857 (ops::GET_SKILL, vec![id.clone()], "/v1/cassettes/skills/x-1"),
858 (
859 ops::DELETE_SKILL,
860 vec![id.clone()],
861 "/v1/cassettes/skills/x-1",
862 ),
863 (
864 ops::DUPLICATE_SKILL,
865 vec![id.clone()],
866 "/v1/cassettes/skills/x-1/duplicate",
867 ),
868 (
869 ops::GET_SKILL_MARKDOWN,
870 vec![id.clone()],
871 "/v1/cassettes/skills/x-1/skill.md",
872 ),
873 (
874 ops::LIST_SKILL_VERSIONS,
875 vec![id.clone()],
876 "/v1/cassettes/skills/x-1/versions",
877 ),
878 ];
879 for (operation, values, expected) in cases {
880 let request = client
881 .request_for(operation, values.clone())
882 .unwrap_or_else(|e| panic!("{operation}: {e}"));
883 let url = call_url(
884 &Url::parse("http://127.0.0.1:8081").unwrap(),
885 &request,
886 PathMode::UnderBase,
887 )
888 .unwrap_or_else(|e| panic!("{operation}: {e}"));
889 assert!(
890 url.path().ends_with(expected.trim_start_matches('/')) || url.path() == *expected,
891 "{operation}: expected {expected}, got {}",
892 url.path()
893 );
894 assert!(
895 !url.path().contains("/v1/skills")
896 && !url.path().contains("/v1/search")
897 && !url.path().contains("/v1/sessions"),
898 "{operation}: still targets a core route: {}",
899 url.path()
900 );
901 }
902
903 let _: std::result::Result<Value, _> = client
907 .call_with_body(ops::GENERATE_SKILL, Vec::new(), Some("{}".to_owned()))
908 .await;
909 let _: std::result::Result<Value, _> = client
910 .call_with_body(ops::CREATE_SKILL, Vec::new(), Some("{}".to_owned()))
911 .await;
912 let _: std::result::Result<Value, _> = client
913 .call_with_body(
914 ops::PUBLISH_SKILL,
915 vec![("id", "x-1".to_owned())],
916 Some("{}".to_owned()),
917 )
918 .await;
919 let seen = client.transport().seen.borrow();
920 let tail: Vec<&String> = seen.iter().rev().take(3).collect();
921 assert!(
922 tail[2].contains("/v1/cassettes/skills/generate"),
923 "got {}",
924 tail[2]
925 );
926 assert!(tail[1].ends_with("/v1/cassettes/skills"), "got {}", tail[1]);
927 assert!(
928 tail[0].contains("/v1/cassettes/skills/x-1/versions"),
929 "got {}",
930 tail[0]
931 );
932 }
933
934 #[test]
935 fn session_skills_becomes_a_session_id_filter_on_the_skills_cassette() {
936 let client = CoreClient::new(Recorder::new(
941 "http://127.0.0.1:8081",
942 vec![serde_json::json!({})],
943 ));
944 let request = client
945 .request_for(ops::LIST_SESSION_SKILLS, vec![("id", "ses-9".to_owned())])
946 .unwrap();
947 let url = call_url(
948 &Url::parse("http://127.0.0.1:8081").unwrap(),
949 &request,
950 PathMode::UnderBase,
951 )
952 .unwrap();
953 assert!(
954 url.path().ends_with("/v1/cassettes/skills"),
955 "got {}",
956 url.path()
957 );
958 assert!(
959 url.query_pairs()
960 .any(|(k, v)| k == "session_id" && v == "ses-9"),
961 "session_id must survive as a query parameter, got {:?}",
962 url.query()
963 );
964 }
965
966 #[tokio::test]
967 async fn the_stream_escape_hatch_reroutes_like_the_typed_surface() {
968 let client = CoreClient::new(Recorder::new(
971 "http://127.0.0.1:8081",
972 vec![serde_json::json!({})],
973 ));
974 let _ = client
975 .stream(ops::GET_SKILL_MARKDOWN, vec![("id", "skl-1".to_owned())])
976 .await
977 .unwrap();
978 let seen = client.transport().seen.borrow();
979 assert!(
980 seen[0].contains("/v1/cassettes/skills/skl-1/skill.md"),
981 "got {}",
982 seen[0]
983 );
984 }
985
986 #[test]
987 fn every_operation_on_an_extracted_route_is_rerouted() {
988 let surface = core().unwrap();
996 let extracted = |path: &str| {
997 path.starts_with("/v1/search")
998 || path.starts_with("/v1/skills")
999 || path == "/v1/sessions/export"
1000 || path == "/v1/sessions/{id}/export"
1001 || path == "/v1/sessions/{id}/skills"
1002 };
1003
1004 let ids: Vec<&str> = surface.operation_ids().collect();
1005 let mut checked = 0;
1006 for id in ids {
1007 let method = surface.method(id).unwrap();
1008 if !extracted(&method.path) {
1009 continue;
1010 }
1011 checked += 1;
1012
1013 let values: Vec<(&str, String)> = method
1014 .params
1015 .iter()
1016 .filter(|param| param.required || matches!(param.location, Location::Path))
1017 .map(|param| (param.wire.as_str(), "x".to_owned()))
1018 .collect();
1019 let body = (method.body == Some(true)).then(|| "{}".to_owned());
1020 let mut request = contract::call_for_with_body(method, values, body)
1021 .unwrap_or_else(|error| panic!("{id}: {error}"));
1022 reroute_to_cassette(id, &mut request);
1023 assert!(
1024 request.path.starts_with("/v1/cassettes/"),
1025 "{id} still targets {} — add it to reroute_to_cassette",
1026 request.path
1027 );
1028 }
1029 assert_eq!(
1030 checked, 14,
1031 "the census of operations on extracted routes moved; route the newcomer above and update this count"
1032 );
1033 }
1034
1035 #[tokio::test]
1036 async fn search_spans_targets_the_search_cassette_route() {
1037 let client = client(
1043 "http://127.0.0.1:8081",
1044 serde_json::json!({"query": "q", "results": []}),
1045 );
1046 let _ = client
1047 .search_spans(&SearchSpansParams {
1048 query: "retry backoff".to_owned(),
1049 top_k: Some(3),
1050 })
1051 .await
1052 .unwrap();
1053
1054 let seen = client.transport().seen.borrow();
1055 assert_eq!(seen.len(), 1);
1056 assert!(
1057 seen[0].contains("/v1/cassettes/search/spans?"),
1058 "expected the cassette route, got {}",
1059 seen[0]
1060 );
1061 assert!(
1062 seen[0].contains("query=retry+backoff") || seen[0].contains("query=retry%20backoff")
1063 );
1064 assert!(seen[0].contains("top_k=3"));
1065 }
1066
1067 #[tokio::test]
1068 async fn a_named_method_and_its_operation_id_build_the_same_request() {
1069 let named = client("http://127.0.0.1:8081", serde_json::json!({}));
1073 let _ = named.get_span("t-1", "sp-1").await.unwrap();
1074
1075 let raw = client("http://127.0.0.1:8081", serde_json::json!({}));
1076 let _: Value = raw
1077 .call(
1078 ops::GET_SPAN,
1079 vec![
1080 ("trace_id", "t-1".to_owned()),
1081 ("span_id", "sp-1".to_owned()),
1082 ],
1083 )
1084 .await
1085 .unwrap();
1086
1087 assert_eq!(
1088 *named.transport().seen.borrow(),
1089 *raw.transport().seen.borrow()
1090 );
1091 }
1092}