1use super::auth::HttpAuthProvider;
13use super::{join_url, HttpConnector, HttpConnectorError, Operation, Parameter, ParameterLocation};
14use async_trait::async_trait;
15use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::sync::Arc;
19use std::time::Duration;
20
21#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct HttpConfig {
26 #[serde(default = "default_timeout")]
28 pub timeout_seconds: u64,
29 #[serde(default = "default_retries")]
31 pub retries: u32,
32 #[serde(default = "default_retry_backoff")]
34 pub retry_backoff_ms: u64,
35 #[serde(default = "default_user_agent")]
37 pub user_agent: String,
38 #[serde(default)]
40 pub default_headers: HashMap<String, String>,
41}
42
43fn default_timeout() -> u64 {
44 30
45}
46fn default_retries() -> u32 {
47 3
48}
49fn default_retry_backoff() -> u64 {
50 1000
51}
52fn default_user_agent() -> String {
53 format!("pmcp-server-toolkit/{}", env!("CARGO_PKG_VERSION"))
54}
55
56impl Default for HttpConfig {
57 fn default() -> Self {
58 Self {
59 timeout_seconds: default_timeout(),
60 retries: default_retries(),
61 retry_backoff_ms: default_retry_backoff(),
62 user_agent: default_user_agent(),
63 default_headers: HashMap::new(),
64 }
65 }
66}
67
68pub struct HttpClient {
70 client: reqwest::Client,
71 base_url: url::Url,
72 auth: Arc<dyn HttpAuthProvider>,
73 http_config: HttpConfig,
74 policy: Option<Arc<dyn crate::policy::RequestPolicy>>,
80}
81
82impl HttpClient {
83 pub fn new(
90 client: reqwest::Client,
91 base_url: String,
92 auth: Arc<dyn HttpAuthProvider>,
93 ) -> Result<Self, HttpConnectorError> {
94 Self::with_config(client, base_url, auth, HttpConfig::default())
95 }
96
97 pub fn with_config(
103 client: reqwest::Client,
104 base_url: String,
105 auth: Arc<dyn HttpAuthProvider>,
106 http_config: HttpConfig,
107 ) -> Result<Self, HttpConnectorError> {
108 let base_url = url::Url::parse(&base_url)
109 .map_err(|_| HttpConnectorError::Backend("invalid base URL".to_string()))?;
110 Ok(Self {
111 client,
112 base_url,
113 auth,
114 http_config,
115 policy: None,
116 })
117 }
118
119 #[must_use]
126 pub fn with_request_policy(mut self, policy: Arc<dyn crate::policy::RequestPolicy>) -> Self {
127 self.policy = Some(policy);
128 self
129 }
130
131 pub fn from_config(
138 base_url: String,
139 auth: Arc<dyn HttpAuthProvider>,
140 http_config: HttpConfig,
141 ) -> Result<Self, HttpConnectorError> {
142 let mut headers = HeaderMap::new();
143 if let Ok(ua) = HeaderValue::from_str(&http_config.user_agent) {
144 headers.insert(reqwest::header::USER_AGENT, ua);
145 }
146 for (key, value) in &http_config.default_headers {
147 if let (Ok(name), Ok(val)) = (
148 HeaderName::try_from(key.as_str()),
149 HeaderValue::try_from(value.as_str()),
150 ) {
151 headers.insert(name, val);
152 }
153 }
154 let client = reqwest::Client::builder()
162 .timeout(Duration::from_secs(http_config.timeout_seconds))
163 .redirect(reqwest::redirect::Policy::none())
164 .default_headers(headers)
165 .build()
166 .map_err(|_| HttpConnectorError::Backend("failed to build HTTP client".to_string()))?;
167 Self::with_config(client, base_url, auth, http_config)
168 }
169
170 fn substitute_path(
245 operation: &Operation,
246 args: &serde_json::Map<String, serde_json::Value>,
247 ) -> Result<String, HttpConnectorError> {
248 let mut rendered: Vec<(String, String)> = Vec::new();
250 for param in operation.path_parameters() {
251 let Some(value) = args.get(¶m.name) else {
252 refuse_missing_path_argument(¶m.name)?;
253 continue;
254 };
255 let value_str = render_scalar(¶m.name, value)?;
256 check_placeholder_value(param, &value_str)?;
257 rendered.push((format!("{{{}}}", param.name), value_str));
258 }
259 let mut path = operation.path.clone();
261 for (placeholder, value_str) in &rendered {
262 path = path.replace(placeholder, value_str);
263 }
264 check_composed_path(&path)?;
265 Ok(path)
266 }
267
268 fn render_query_value(
278 param_name: &str,
279 value: &serde_json::Value,
280 ) -> Result<String, HttpConnectorError> {
281 if let serde_json::Value::Array(arr) = value {
282 let mut csv = String::new();
285 for (i, member) in arr.iter().enumerate() {
286 if i > 0 {
287 csv.push(',');
288 }
289 csv.push_str(&render_scalar(param_name, member)?);
290 }
291 Ok(csv)
292 } else {
293 render_scalar(param_name, value)
294 }
295 }
296
297 fn build_query(
305 operation: &Operation,
306 args: &serde_json::Map<String, serde_json::Value>,
307 ) -> Result<HashMap<String, String>, HttpConnectorError> {
308 let mut query = HashMap::new();
309 for param in operation.query_parameters() {
310 if let Some(value) = args.get(¶m.name) {
311 query.insert(
312 param.name.clone(),
313 Self::render_query_value(¶m.name, value)?,
314 );
315 }
316 }
317 Ok(query)
318 }
319
320 fn build_headers(
322 operation: &Operation,
323 args: &serde_json::Map<String, serde_json::Value>,
324 ) -> Result<HeaderMap, HttpConnectorError> {
325 let mut headers = HeaderMap::new();
326 for param in operation.header_parameters() {
327 if let Some(value) = args.get(¶m.name) {
328 let name = HeaderName::try_from(param.name.as_str()).map_err(|_| {
329 HttpConnectorError::InvalidHeader("invalid header name".to_string())
330 })?;
331 let rendered = render_scalar(¶m.name, value)?;
334 let val = HeaderValue::try_from(rendered).map_err(|_| {
335 HttpConnectorError::InvalidHeader("invalid header value".to_string())
336 })?;
337 headers.insert(name, val);
338 }
339 }
340 Ok(headers)
341 }
342
343 fn build_body(
370 operation: &Operation,
371 args: &serde_json::Map<String, serde_json::Value>,
372 ) -> Option<serde_json::Value> {
373 if !operation.has_request_body {
374 return None;
375 }
376 if let Some(body) = args.get("body") {
377 return Some(body.clone());
378 }
379 let routed_elsewhere: std::collections::HashSet<&str> = operation
380 .parameters
381 .iter()
382 .filter(|p| p.location != ParameterLocation::Body)
383 .map(|p| p.name.as_str())
384 .collect();
385 let body: serde_json::Map<String, serde_json::Value> = args
386 .iter()
387 .filter(|(k, _)| !routed_elsewhere.contains(k.as_str()))
388 .map(|(k, v)| (k.clone(), v.clone()))
389 .collect();
390 if body.is_empty() {
391 None
392 } else {
393 Some(serde_json::Value::Object(body))
394 }
395 }
396
397 fn convert_method(method: &str) -> Result<reqwest::Method, HttpConnectorError> {
398 match method.to_uppercase().as_str() {
399 "GET" => Ok(reqwest::Method::GET),
400 "POST" => Ok(reqwest::Method::POST),
401 "PUT" => Ok(reqwest::Method::PUT),
402 "PATCH" => Ok(reqwest::Method::PATCH),
403 "DELETE" => Ok(reqwest::Method::DELETE),
404 "HEAD" => Ok(reqwest::Method::HEAD),
405 "OPTIONS" => Ok(reqwest::Method::OPTIONS),
406 _ => Err(HttpConnectorError::Backend(
407 "unknown HTTP method".to_string(),
408 )),
409 }
410 }
411
412 async fn send_with_retries(
414 &self,
415 request: reqwest::RequestBuilder,
416 ) -> Result<reqwest::Response, HttpConnectorError> {
417 let max_retries = self.http_config.retries;
418 let mut last_status: Option<u16> = None;
419 for attempt in 0..=max_retries {
420 if attempt > 0 {
421 let delay = self.http_config.retry_backoff_ms * (1u64 << (attempt - 1));
422 tokio::time::sleep(Duration::from_millis(delay)).await;
423 }
424 let Some(attempt_request) = request.try_clone() else {
425 return Err(HttpConnectorError::Request(
426 "request body is not retryable".to_string(),
427 ));
428 };
429 match attempt_request.send().await {
430 Ok(response) => {
431 let status = response.status();
432 if status.is_server_error() && attempt < max_retries {
433 last_status = Some(status.as_u16());
434 continue;
435 }
436 return Ok(response);
437 },
438 Err(e) => {
439 let retryable = e.is_connect() || e.is_timeout();
440 if retryable && attempt < max_retries {
441 continue;
442 }
443 return Err(HttpConnectorError::Request(
445 "transport error contacting backend".to_string(),
446 ));
447 },
448 }
449 }
450 Err(HttpConnectorError::Status {
451 status: last_status.unwrap_or(0),
452 })
453 }
454}
455
456fn render_scalar(
484 param_name: &str,
485 value: &serde_json::Value,
486) -> Result<String, HttpConnectorError> {
487 match value {
488 serde_json::Value::String(s) => Ok(s.clone()),
489 serde_json::Value::Number(n) => Ok(n.to_string()),
490 serde_json::Value::Bool(b) => Ok(b.to_string()),
491 serde_json::Value::Null => Ok("null".to_string()),
492 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
495 Err(HttpConnectorError::Backend(format!(
496 "param '{param_name}' must be a scalar (non-scalar values are \
497 not supported in path/query/header position)"
498 )))
499 },
500 }
501}
502
503#[cfg(feature = "input-validation")]
522fn refusal_to_backend_error(
523 refusal: &pmcp::server::schema_validation::PlaceholderRefusal,
524) -> HttpConnectorError {
525 HttpConnectorError::Backend(format!("{refusal}"))
526}
527
528#[cfg(feature = "input-validation")]
536fn check_placeholder_value(param: &Parameter, value_str: &str) -> Result<(), HttpConnectorError> {
537 pmcp::server::schema_validation::validate_path_placeholder(
538 ¶m.name,
539 value_str,
540 ¶m.placeholder_rules(),
541 )
542 .map_err(|refusal| refusal_to_backend_error(&refusal))
543}
544
545#[cfg(not(feature = "input-validation"))]
548fn check_placeholder_value(_param: &Parameter, _value_str: &str) -> Result<(), HttpConnectorError> {
549 Ok(())
550}
551
552#[cfg(feature = "input-validation")]
570fn check_composed_path(path: &str) -> Result<(), HttpConnectorError> {
571 pmcp::server::schema_validation::validate_resolved_target(path)
572 .map_err(|refusal| refusal_to_backend_error(&refusal))
573}
574
575#[cfg(not(feature = "input-validation"))]
579fn check_composed_path(_path: &str) -> Result<(), HttpConnectorError> {
580 Ok(())
581}
582
583#[cfg(feature = "input-validation")]
602fn refuse_missing_path_argument(param_name: &str) -> Result<(), HttpConnectorError> {
603 Err(HttpConnectorError::Backend(format!(
604 "param '{param_name}' is a declared path parameter and must be supplied"
605 )))
606}
607
608#[cfg(not(feature = "input-validation"))]
611fn refuse_missing_path_argument(_param_name: &str) -> Result<(), HttpConnectorError> {
612 Ok(())
613}
614
615impl HttpClient {
616 async fn run_request_policy(
636 &self,
637 tool: &str,
638 method: &str,
639 path: &str,
640 query: &std::collections::HashMap<String, String>,
641 body: Option<&serde_json::Value>,
642 ) -> Result<(), HttpConnectorError> {
643 let Some(policy) = self.policy.as_ref() else {
644 return Ok(());
645 };
646 let mut sorted: Vec<(String, String)> =
647 query.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
648 sorted.sort();
649 let method = method.to_uppercase();
650 let req = crate::policy::OutboundRequest::new(tool, &method, path, &sorted, body);
651 policy
652 .check(&req)
653 .await
654 .map_err(|refusal| HttpConnectorError::PolicyRefused(refusal.message().to_string()))
655 }
656
657 async fn execute_inner(
663 &self,
664 tool: &str,
665 operation: &Operation,
666 args: &serde_json::Value,
667 ) -> Result<serde_json::Value, HttpConnectorError> {
668 let empty = serde_json::Map::new();
669 let args_map = args.as_object().unwrap_or(&empty);
670
671 let substituted = Self::substitute_path(operation, args_map)?;
675 let joined = join_url(self.base_url.as_str(), &substituted);
676 let mut url = url::Url::parse(&joined)
677 .map_err(|_| HttpConnectorError::Backend("constructed URL is invalid".to_string()))?;
678
679 let mut query = Self::build_query(operation, args_map)?;
680 let mut headers = Self::build_headers(operation, args_map)?;
681 let request_body = Self::build_body(operation, args_map);
682
683 self.run_request_policy(
701 tool,
702 &operation.method,
703 &joined,
704 &query,
705 request_body.as_ref(),
706 )
707 .await?;
708
709 self.auth.apply(&mut headers, &mut query, None).await?;
712
713 if !query.is_empty() {
719 let mut pairs = url.query_pairs_mut();
720 for (key, value) in &query {
721 pairs.append_pair(key, value);
722 }
723 drop(pairs);
724 }
725
726 let method = Self::convert_method(&operation.method)?;
727 let mut request = self.client.request(method, url);
728 request = request.headers(headers);
729 if let Some(body) = request_body {
730 request = request.json(&body);
731 }
732
733 let response = self.send_with_retries(request).await?;
734 let status = response.status();
735 if !status.is_success() {
736 return Err(HttpConnectorError::Status {
737 status: status.as_u16(),
738 });
739 }
740 let body = response
741 .text()
742 .await
743 .map_err(|_| HttpConnectorError::Request("failed to read response body".to_string()))?;
744 if body.is_empty() {
745 return Ok(serde_json::Value::Null);
746 }
747 serde_json::from_str(&body).map_err(|_| {
748 HttpConnectorError::Backend("response body was not valid JSON".to_string())
749 })
750 }
751
752 fn cloned_with_policy(&self, policy: Arc<dyn crate::policy::RequestPolicy>) -> Self {
759 Self {
760 client: self.client.clone(),
761 base_url: self.base_url.clone(),
762 auth: Arc::clone(&self.auth),
763 http_config: self.http_config.clone(),
764 policy: Some(policy),
765 }
766 }
767}
768
769#[async_trait]
770impl HttpConnector for HttpClient {
771 async fn execute(
772 &self,
773 operation: &Operation,
774 args: &serde_json::Value,
775 ) -> Result<serde_json::Value, HttpConnectorError> {
776 self.execute_inner("", operation, args).await
779 }
780
781 async fn execute_for_tool(
782 &self,
783 tool: &str,
784 operation: &Operation,
785 args: &serde_json::Value,
786 ) -> Result<serde_json::Value, HttpConnectorError> {
787 self.execute_inner(tool, operation, args).await
788 }
789
790 fn has_request_policy(&self) -> bool {
791 self.policy.is_some()
792 }
793
794 fn governed(
795 &self,
796 policy: Arc<dyn crate::policy::RequestPolicy>,
797 ) -> Option<Arc<dyn HttpConnector>> {
798 Some(Arc::new(self.cloned_with_policy(policy)))
799 }
800
801 fn base_url(&self) -> &str {
802 self.base_url.as_str()
803 }
804}
805
806#[cfg(all(test, feature = "input-validation"))]
819mod d4_support {
820 use super::{HttpClient, HttpConnectorError, Operation};
821 use crate::http::{Parameter, ParameterLocation};
822
823 pub fn op(path: &str, parameters: Vec<Parameter>) -> Operation {
825 Operation {
826 method: "GET".to_string(),
827 path: path.to_string(),
828 parameters,
829 has_request_body: false,
830 base_url: None,
831 }
832 }
833
834 pub fn path_param(name: &str) -> Parameter {
836 Parameter::new(name, ParameterLocation::Path, true)
837 }
838
839 pub fn substitute(
842 path: &str,
843 pairs: &[(&str, serde_json::Value)],
844 ) -> Result<String, HttpConnectorError> {
845 let parameters = pairs.iter().map(|(k, _)| path_param(k)).collect();
846 let mut args = serde_json::Map::new();
847 for (k, v) in pairs {
848 args.insert((*k).to_string(), v.clone());
849 }
850 HttpClient::substitute_path(&op(path, parameters), &args)
851 }
852
853 pub fn substitute_one(
855 path: &str,
856 name: &str,
857 value: &str,
858 ) -> Result<String, HttpConnectorError> {
859 substitute(
860 path,
861 &[(name, serde_json::Value::String(value.to_string()))],
862 )
863 }
864}
865
866#[cfg(all(test, feature = "input-validation"))]
870mod placeholder_floor {
871 use super::d4_support::{op, path_param, substitute, substitute_one};
872 use super::{HttpClient, HttpConnectorError};
873 use crate::http::{Parameter, ParameterLocation};
874 use pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH;
875
876 fn assert_value_free(err: &HttpConnectorError, param: &str, value: &str, path_fragment: &str) {
879 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
880 let rendered = err.to_string();
881 assert!(
882 rendered.contains(param),
883 "the refusal must name the declared parameter: {rendered}"
884 );
885 assert!(
886 !rendered.contains(value),
887 "the refusal must carry no byte of the value: {rendered}"
888 );
889 assert!(
890 !rendered.contains(path_fragment),
891 "the refusal must never contain the resolved path: {rendered}"
892 );
893 }
894
895 #[test]
897 fn placeholder_floor_refuses_a_query_separator_in_a_value() {
898 let value = "current?string=x";
899 let err = substitute_one("/content/{version}/CUI", "version", value).unwrap_err();
900 assert_value_free(&err, "version", value, "/content/");
901 }
902
903 #[test]
905 fn placeholder_floor_refuses_traversal_in_a_value() {
906 let value = "current/../../search/current";
907 let err = substitute_one("/content/{version}/CUI", "version", value).unwrap_err();
908 assert_value_free(&err, "version", value, "/content/");
909 }
910
911 #[test]
914 fn placeholder_floor_refuses_upper_case_encoded_traversal() {
915 let err = substitute_one("/content/{version}/CUI", "version", "a%2E%2Eb").unwrap_err();
916 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
917 }
918
919 #[test]
922 fn placeholder_floor_refuses_a_value_that_is_exactly_a_denied_character() {
923 let err = substitute_one("/content/{version}/CUI", "version", "?").unwrap_err();
924 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
925 }
926
927 #[test]
929 fn placeholder_floor_refuses_a_nul_byte_in_both_forms() {
930 assert!(substitute_one("/x/{v}", "v", "a\u{0}b").is_err());
931 assert!(substitute_one("/x/{v}", "v", "a%00b").is_err());
932 }
933
934 #[test]
936 fn placeholder_floor_refuses_an_empty_value() {
937 assert!(substitute_one("/x/{v}", "v", "").is_err());
938 }
939
940 #[test]
942 fn placeholder_floor_accepts_the_cap_and_refuses_one_more() {
943 let at_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH);
944 assert_eq!(
945 substitute_one("/x/{v}", "v", &at_cap).expect("at the cap"),
946 format!("/x/{at_cap}")
947 );
948 let over_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH + 1);
949 assert!(substitute_one("/x/{v}", "v", &over_cap).is_err());
950 }
951
952 #[test]
955 fn placeholder_floor_accepts_a_value_matching_its_declared_pattern() {
956 let parameters = vec![
957 Parameter::new("cui", ParameterLocation::Path, true).with_rules(
958 Some("^C[0-9]+$".to_string()),
959 Some(32),
960 false,
961 ),
962 ];
963 let mut args = serde_json::Map::new();
964 args.insert("cui".to_string(), serde_json::json!("C0018787"));
965 let resolved = HttpClient::substitute_path(&op("/CUI/{cui}/content", parameters), &args)
966 .expect("a conforming value must be accepted");
967 assert_eq!(resolved, "/CUI/C0018787/content");
968 }
969
970 #[test]
972 fn placeholder_floor_refuses_a_value_failing_its_declared_pattern() {
973 let parameters = vec![
974 Parameter::new("cui", ParameterLocation::Path, true).with_rules(
975 Some("^C[0-9]+$".to_string()),
976 None,
977 false,
978 ),
979 ];
980 let mut args = serde_json::Map::new();
981 args.insert("cui".to_string(), serde_json::json!("notacui"));
982 let err = HttpClient::substitute_path(&op("/CUI/{cui}", parameters), &args).unwrap_err();
983 assert!(err.to_string().contains("cui"), "{err}");
984 assert!(!err.to_string().contains("notacui"), "{err}");
985 }
986
987 #[test]
990 fn placeholder_floor_leaves_a_placeholder_free_template_untouched() {
991 let resolved = HttpClient::substitute_path(
992 &op("/Line/Mode/tube/Status", vec![]),
993 &serde_json::Map::new(),
994 )
995 .expect("a placeholder-free template must be unaffected");
996 assert_eq!(resolved, "/Line/Mode/tube/Status");
997 }
998
999 #[test]
1007 fn placeholder_floor_accepts_the_root_path_and_still_refuses_a_trailing_slash() {
1008 let resolved = HttpClient::substitute_path(&op("/", vec![]), &serde_json::Map::new())
1009 .expect("a `GET /` operation must be callable — the root is the shortest legal path");
1010 assert_eq!(resolved, "/");
1011
1012 let err = substitute_one("/search/{v}", "v", "")
1014 .expect_err("an empty tail placeholder must stay refused");
1015 assert!(
1016 matches!(err, HttpConnectorError::Backend(_)),
1017 "the refusal is a Backend error naming the position: {err}"
1018 );
1019 assert!(
1020 HttpClient::substitute_path(&op("/search/", vec![]), &serde_json::Map::new()).is_err(),
1021 "a literal trailing slash in the template stays refused by decision"
1022 );
1023 }
1024
1025 #[test]
1029 fn placeholder_floor_refuses_the_second_of_two_placeholders_without_substituting() {
1030 let err = substitute(
1031 "/a/{first}/b/{second}",
1032 &[
1033 ("first", serde_json::json!("ok")),
1034 ("second", serde_json::json!("../escape")),
1035 ],
1036 )
1037 .unwrap_err();
1038 let rendered = err.to_string();
1039 assert!(rendered.contains("second"), "{rendered}");
1040 assert!(
1041 !rendered.contains("/a/ok/b/"),
1042 "no partially-substituted path may appear anywhere: {rendered}"
1043 );
1044 }
1045
1046 #[test]
1049 fn placeholder_floor_refuses_an_absent_path_argument() {
1050 let err = HttpClient::substitute_path(
1051 &op("/users/{id}/profile", vec![path_param("id")]),
1052 &serde_json::Map::new(),
1053 )
1054 .unwrap_err();
1055 let rendered = err.to_string();
1056 assert!(rendered.contains("id"), "{rendered}");
1057 assert!(
1058 !rendered.contains('{') && !rendered.contains('}'),
1059 "the refusal must not echo the template: {rendered}"
1060 );
1061 assert!(
1062 !rendered.contains("/users/"),
1063 "the refusal must not echo the path: {rendered}"
1064 );
1065 }
1066
1067 #[test]
1072 fn placeholder_floor_refuses_a_composed_segment_over_the_cap() {
1073 let prefix = "p".repeat(100);
1074 let value = "v".repeat(200);
1075 let err = substitute_one(&format!("/x/{prefix}{{id}}"), "id", &value).unwrap_err();
1076 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
1077 }
1078
1079 #[test]
1083 fn placeholder_floor_refuses_a_residual_brace_from_an_unrecognized_template() {
1084 let err = HttpClient::substitute_path(&op("/x/{a}/y/{b}", vec![path_param("a")]), &{
1085 let mut args = serde_json::Map::new();
1086 args.insert("a".to_string(), serde_json::json!("ok"));
1087 args
1088 })
1089 .unwrap_err();
1090 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
1091 }
1092
1093 #[test]
1097 fn placeholder_floor_refuses_traversal_written_into_the_template_literal() {
1098 let err = HttpClient::substitute_path(&op("/a/../b", vec![]), &serde_json::Map::new())
1099 .unwrap_err();
1100 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
1101 }
1102}
1103
1104#[cfg(all(test, feature = "input-validation"))]
1115mod query_separator {
1116 use super::d4_support::substitute_one;
1117 use super::{HttpClient, Operation};
1118 use pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH;
1119
1120 fn literal(path: &str) -> Result<String, super::HttpConnectorError> {
1122 HttpClient::substitute_path(
1123 &Operation {
1124 method: "GET".to_string(),
1125 path: path.to_string(),
1126 parameters: vec![],
1127 has_request_body: false,
1128 base_url: None,
1129 },
1130 &serde_json::Map::new(),
1131 )
1132 }
1133
1134 #[test]
1138 fn query_separator_accepts_an_author_written_query_string() {
1139 assert_eq!(
1140 literal("/Line/Mode/tube/Status?detail=true").expect("author query accepted"),
1141 "/Line/Mode/tube/Status?detail=true"
1142 );
1143 }
1144
1145 #[test]
1148 fn query_separator_accepts_a_literal_query_alongside_a_floored_placeholder() {
1149 assert_eq!(
1150 substitute_one("/content/{version}/CUI?string=x", "version", "current")
1151 .expect("author query plus conforming placeholder accepted"),
1152 "/content/current/CUI?string=x"
1153 );
1154 }
1155
1156 #[test]
1158 fn query_separator_accepts_a_graph_style_dollar_projection() {
1159 let resolved = literal(
1160 "/drives/D/items/I/workbook/worksheets/C/range(address='A2:D7')?$select=values",
1161 )
1162 .expect("a Graph $select projection must be accepted");
1163 assert!(resolved.ends_with("?$select=values"), "{resolved}");
1164 }
1165
1166 #[test]
1169 fn query_separator_still_refuses_traversal_in_the_path_portion() {
1170 assert!(
1171 literal("/a/../b?x=1").is_err(),
1172 "appending a query must not launder a traversal"
1173 );
1174 }
1175
1176 #[test]
1177 fn query_separator_still_refuses_traversal_in_the_query_portion() {
1178 let err = literal("/search?next=../../etc/passwd").unwrap_err();
1179 assert!(!err.to_string().contains("passwd"), "{err}");
1180 }
1181
1182 #[test]
1183 fn query_separator_still_refuses_a_control_byte_in_the_query_portion() {
1184 assert!(literal("/search?x=a%00b").is_err());
1185 }
1186
1187 #[test]
1188 fn query_separator_still_refuses_an_over_cap_query_portion() {
1189 let long = "z".repeat(PLACEHOLDER_MAX_LENGTH + 1);
1190 assert!(literal(&format!("/search?q={long}")).is_err());
1191 }
1192
1193 #[test]
1194 fn query_separator_still_refuses_a_second_question_mark() {
1195 assert!(
1196 literal("/search?a=1?b=2").is_err(),
1197 "only the FIRST `?` is split off; one exemption, not a licence"
1198 );
1199 }
1200
1201 #[test]
1202 fn query_separator_still_refuses_an_empty_query_portion() {
1203 assert!(
1204 literal("/search?").is_err(),
1205 "a dangling `?` is the same class as a trailing `/`"
1206 );
1207 }
1208
1209 #[test]
1210 fn query_separator_still_refuses_a_fragment_marker() {
1211 assert!(literal("/search#frag").is_err());
1212 }
1213
1214 #[test]
1219 fn query_separator_still_refuses_an_injected_separator_from_a_value() {
1220 let payload = "2026AA?string=x";
1221 let err = substitute_one("/search/{v}?detail=true", "v", payload).unwrap_err();
1222 let rendered = err.to_string();
1223 assert!(rendered.contains('v'), "{rendered}");
1224 assert!(
1225 !rendered.contains("2026AA") && !rendered.contains('?'),
1226 "the refusal must carry no byte of the value: {rendered}"
1227 );
1228 }
1229
1230 #[test]
1232 fn query_separator_still_refuses_an_injected_traversal_from_a_value() {
1233 assert!(substitute_one("/search/{v}?detail=true", "v", "../../etc/passwd").is_err());
1234 }
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240 use crate::http::auth::NoAuth;
1241 use crate::http::{Parameter, ParameterLocation};
1242
1243 fn get_user_op() -> Operation {
1244 Operation {
1245 method: "GET".to_string(),
1246 path: "/users/{id}".to_string(),
1247 parameters: vec![
1248 Parameter::new("id", ParameterLocation::Path, true),
1249 Parameter::new("verbose", ParameterLocation::Query, false),
1250 ],
1251 has_request_body: false,
1252 base_url: None,
1253 }
1254 }
1255
1256 #[test]
1257 fn test_build_url_with_path_prefix() {
1258 let client = HttpClient::new(
1260 reqwest::Client::new(),
1261 "https://xxx.execute-api.eu-west-1.amazonaws.com/v1/".to_string(),
1262 Arc::new(NoAuth),
1263 )
1264 .unwrap();
1265 let op = get_user_op();
1266 let mut args = serde_json::Map::new();
1267 args.insert("id".to_string(), serde_json::json!("42"));
1268 let substituted = HttpClient::substitute_path(&op, &args).unwrap();
1269 let joined = join_url(client.base_url(), &substituted);
1270 assert_eq!(
1271 joined,
1272 "https://xxx.execute-api.eu-west-1.amazonaws.com/v1/users/42"
1273 );
1274 }
1275
1276 #[test]
1277 fn test_substitute_path_replaces_placeholder() {
1278 let op = get_user_op();
1279 let mut args = serde_json::Map::new();
1280 args.insert("id".to_string(), serde_json::json!(7));
1281 assert_eq!(HttpClient::substitute_path(&op, &args).unwrap(), "/users/7");
1282 }
1283
1284 #[test]
1285 fn test_build_query_skips_path_params() {
1286 let op = get_user_op();
1287 let mut args = serde_json::Map::new();
1288 args.insert("id".to_string(), serde_json::json!("42"));
1289 args.insert("verbose".to_string(), serde_json::json!(true));
1290 let query = HttpClient::build_query(&op, &args).unwrap();
1291 assert_eq!(query.get("verbose"), Some(&"true".to_string()));
1292 assert!(!query.contains_key("id"));
1293 }
1294
1295 #[test]
1300 fn render_query_value_comma_joins_scalar_array() {
1301 let rendered =
1302 HttpClient::render_query_value("tags", &serde_json::json!(["a", 2, true])).unwrap();
1303 assert_eq!(rendered, "a,2,true");
1304 }
1305
1306 #[test]
1308 fn render_query_value_scalar_passthrough() {
1309 assert_eq!(
1310 HttpClient::render_query_value("q", &serde_json::json!("hi")).unwrap(),
1311 "hi"
1312 );
1313 assert_eq!(
1314 HttpClient::render_query_value("n", &serde_json::json!(7)).unwrap(),
1315 "7"
1316 );
1317 }
1318
1319 #[test]
1322 fn render_scalar_null_is_bare_null() {
1323 assert_eq!(
1324 render_scalar("x", &serde_json::Value::Null).unwrap(),
1325 "null"
1326 );
1327 }
1328
1329 #[test]
1332 fn substitute_path_rejects_object_param() {
1333 let op = get_user_op();
1334 let mut args = serde_json::Map::new();
1335 args.insert("id".to_string(), serde_json::json!({"nested": "x"}));
1336 let err = HttpClient::substitute_path(&op, &args).unwrap_err();
1337 assert!(matches!(err, HttpConnectorError::Backend(_)));
1338 let rendered = err.to_string();
1339 assert!(
1340 rendered.contains("id"),
1341 "error must name the param: {rendered}"
1342 );
1343 for forbidden in ['{', '[', '"'] {
1344 assert!(
1345 !rendered.contains(forbidden),
1346 "must not echo JSON: {rendered}"
1347 );
1348 }
1349 assert!(
1351 !rendered.contains("nested"),
1352 "must not echo the value: {rendered}"
1353 );
1354 }
1355
1356 #[test]
1358 fn build_query_rejects_object_param() {
1359 let op = get_user_op();
1360 let mut args = serde_json::Map::new();
1361 args.insert("verbose".to_string(), serde_json::json!({"k": "v"}));
1362 let err = HttpClient::build_query(&op, &args).unwrap_err();
1363 assert!(matches!(err, HttpConnectorError::Backend(_)));
1364 assert!(err.to_string().contains("verbose"));
1365 }
1366
1367 #[test]
1370 fn render_query_value_rejects_array_with_object_member() {
1371 let err = HttpClient::render_query_value("tags", &serde_json::json!(["ok", {"bad": 1}]))
1372 .unwrap_err();
1373 assert!(matches!(err, HttpConnectorError::Backend(_)));
1374 assert!(err.to_string().contains("tags"));
1375 }
1376
1377 #[test]
1379 fn build_headers_rejects_non_scalar_param() {
1380 let op = Operation {
1381 method: "GET".to_string(),
1382 path: "/x".to_string(),
1383 parameters: vec![Parameter::new("x-trace", ParameterLocation::Header, false)],
1384 has_request_body: false,
1385 base_url: None,
1386 };
1387 let mut args = serde_json::Map::new();
1390 args.insert("x-trace".to_string(), serde_json::json!(["a", "b"]));
1391 let err = HttpClient::build_headers(&op, &args).unwrap_err();
1392 assert!(matches!(err, HttpConnectorError::Backend(_)));
1393 assert!(err.to_string().contains("x-trace"));
1394 let mut args2 = serde_json::Map::new();
1396 args2.insert("x-trace".to_string(), serde_json::json!({"k": "v"}));
1397 let err2 = HttpClient::build_headers(&op, &args2).unwrap_err();
1398 assert!(matches!(err2, HttpConnectorError::Backend(_)));
1399 assert!(err2.to_string().contains("x-trace"));
1400 let mut args3 = serde_json::Map::new();
1402 args3.insert("x-trace".to_string(), serde_json::json!("abc"));
1403 let headers = HttpClient::build_headers(&op, &args3).unwrap();
1404 assert_eq!(headers.get("x-trace").unwrap(), "abc");
1405 }
1406
1407 #[test]
1408 fn test_new_is_lazy_and_rejects_bad_url() {
1409 let err = HttpClient::new(
1411 reqwest::Client::new(),
1412 "not a url".to_string(),
1413 Arc::new(NoAuth),
1414 )
1415 .err()
1416 .expect("bad URL should error");
1417 assert!(matches!(err, HttpConnectorError::Backend(_)));
1418 let rendered = err.to_string();
1419 assert!(!rendered.contains("not a url"), "must not echo the bad URL");
1420 }
1421
1422 #[tokio::test]
1423 async fn http_connector_get_returns_json() {
1424 use wiremock::matchers::{method, path};
1425 use wiremock::{Mock, MockServer, ResponseTemplate};
1426
1427 let server = MockServer::start().await;
1428 Mock::given(method("GET"))
1429 .and(path("/users/42"))
1430 .respond_with(
1431 ResponseTemplate::new(200)
1432 .set_body_json(serde_json::json!({"id": 42, "name": "Ada"})),
1433 )
1434 .mount(&server)
1435 .await;
1436
1437 let client =
1438 HttpClient::new(reqwest::Client::new(), server.uri(), Arc::new(NoAuth)).unwrap();
1439 let op = get_user_op();
1440 let args = serde_json::json!({"id": "42"});
1441 let result = client.execute(&op, &args).await.unwrap();
1442 assert_eq!(result["id"], 42);
1443 assert_eq!(result["name"], "Ada");
1444 }
1445
1446 #[tokio::test]
1447 async fn http_connector_post_sends_body_and_auth() {
1448 use wiremock::matchers::{body_json, header, method, path};
1449 use wiremock::{Mock, MockServer, ResponseTemplate};
1450
1451 let server = MockServer::start().await;
1452 Mock::given(method("POST"))
1453 .and(path("/items"))
1454 .and(header("authorization", "Bearer tok"))
1455 .and(body_json(serde_json::json!({"name": "widget"})))
1456 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"ok": true})))
1457 .mount(&server)
1458 .await;
1459
1460 let auth = crate::http::auth::create_auth_provider(&crate::http::AuthConfig::Bearer {
1461 token: "tok".to_string(),
1462 required: true,
1463 })
1464 .unwrap();
1465 let client = HttpClient::new(reqwest::Client::new(), server.uri(), auth).unwrap();
1466 let op = Operation {
1467 method: "POST".to_string(),
1468 path: "/items".to_string(),
1469 parameters: vec![],
1470 has_request_body: true,
1471 base_url: None,
1472 };
1473 let args = serde_json::json!({"name": "widget"});
1474 let result = client.execute(&op, &args).await.unwrap();
1475 assert_eq!(result["ok"], true);
1476 }
1477
1478 #[tokio::test]
1485 async fn http_connector_post_sends_declared_body_parameters_as_the_payload() {
1486 use wiremock::matchers::{body_json, method, path, query_param_is_missing};
1487 use wiremock::{Mock, MockServer, ResponseTemplate};
1488
1489 let server = MockServer::start().await;
1490 Mock::given(method("POST"))
1491 .and(path("/items"))
1492 .and(body_json(
1493 serde_json::json!({"name": "widget", "note": "free text"}),
1494 ))
1495 .and(query_param_is_missing("name"))
1498 .and(query_param_is_missing("note"))
1499 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"ok": true})))
1500 .mount(&server)
1501 .await;
1502
1503 let client =
1504 HttpClient::new(reqwest::Client::new(), server.uri(), Arc::new(NoAuth)).unwrap();
1505 let op = Operation {
1506 method: "POST".to_string(),
1507 path: "/items".to_string(),
1508 parameters: vec![
1509 Parameter::new("name", ParameterLocation::Body, true),
1510 Parameter::new("note", ParameterLocation::Body, false),
1511 ],
1512 has_request_body: true,
1513 base_url: None,
1514 };
1515 let args = serde_json::json!({"name": "widget", "note": "free text"});
1516 let result = client.execute(&op, &args).await.unwrap();
1517 assert_eq!(result["ok"], true);
1518 }
1519
1520 #[test]
1524 fn build_body_withholds_a_query_located_parameter_on_a_post() {
1525 let op = Operation {
1526 method: "POST".to_string(),
1527 path: "/items".to_string(),
1528 parameters: vec![
1529 Parameter::new("dry_run", ParameterLocation::Query, false),
1530 Parameter::new("name", ParameterLocation::Body, true),
1531 ],
1532 has_request_body: true,
1533 base_url: None,
1534 };
1535 let args = serde_json::json!({"dry_run": "true", "name": "widget"})
1536 .as_object()
1537 .expect("object")
1538 .clone();
1539 let body = HttpClient::build_body(&op, &args).expect("a body is built");
1540 assert_eq!(body, serde_json::json!({"name": "widget"}));
1541 let query = HttpClient::build_query(&op, &args).expect("a query is built");
1542 assert_eq!(query.get("dry_run").map(String::as_str), Some("true"));
1543 assert!(
1544 !query.contains_key("name"),
1545 "a Body-located parameter must not reach the query string: {query:?}"
1546 );
1547 }
1548
1549 #[tokio::test]
1550 async fn http_connector_maps_non_2xx_to_status_without_url() {
1551 use wiremock::matchers::{method, path};
1552 use wiremock::{Mock, MockServer, ResponseTemplate};
1553
1554 let server = MockServer::start().await;
1555 Mock::given(method("GET"))
1556 .and(path("/users/42"))
1557 .respond_with(ResponseTemplate::new(404))
1558 .mount(&server)
1559 .await;
1560
1561 let client =
1562 HttpClient::new(reqwest::Client::new(), server.uri(), Arc::new(NoAuth)).unwrap();
1563 let op = get_user_op();
1564 let args = serde_json::json!({"id": "42"});
1565 let err = client.execute(&op, &args).await.unwrap_err();
1566 assert!(matches!(err, HttpConnectorError::Status { status: 404 }));
1567 let rendered = err.to_string();
1568 assert!(rendered.contains("404"));
1569 assert!(
1570 !rendered.contains("http://"),
1571 "status error must not echo the URL"
1572 );
1573 }
1574}
1575
1576#[cfg(test)]
1587mod request_policy_seam {
1588 use super::{HttpClient, HttpConfig, HttpConnectorError};
1589 use crate::http::auth::HttpAuthProvider;
1590 use crate::http::{HttpConnector, Operation, Parameter, ParameterLocation};
1591 use crate::policy::{OutboundRequest, PolicyRefusal, RequestPolicy};
1592 use async_trait::async_trait;
1593 use reqwest::header::{HeaderMap, HeaderValue};
1594 use std::collections::HashMap;
1595 use std::sync::atomic::{AtomicUsize, Ordering};
1596 use std::sync::{Arc, Mutex};
1597
1598 struct RecordingAuth {
1601 calls: Arc<AtomicUsize>,
1602 }
1603
1604 #[async_trait]
1605 impl HttpAuthProvider for RecordingAuth {
1606 async fn apply(
1607 &self,
1608 headers: &mut HeaderMap,
1609 _query: &mut HashMap<String, String>,
1610 _inbound_token: Option<&str>,
1611 ) -> Result<(), HttpConnectorError> {
1612 self.calls.fetch_add(1, Ordering::SeqCst);
1613 headers.insert("authorization", HeaderValue::from_static("Bearer tok"));
1614 Ok(())
1615 }
1616 }
1617
1618 type Seen = Arc<Mutex<Vec<(String, String, Vec<(String, String)>, Option<String>)>>>;
1620
1621 struct Recorder {
1623 seen: Seen,
1624 refuse: Option<&'static str>,
1625 }
1626
1627 #[async_trait]
1628 impl RequestPolicy for Recorder {
1629 async fn check(&self, req: &OutboundRequest<'_>) -> Result<(), PolicyRefusal> {
1630 self.seen.lock().expect("lock").push((
1631 req.tool.to_string(),
1632 req.path.to_string(),
1633 req.query.to_vec(),
1634 req.body.map(ToString::to_string),
1635 ));
1636 match self.refuse {
1637 Some(msg) => Err(PolicyRefusal::new(msg)),
1638 None => Ok(()),
1639 }
1640 }
1641 }
1642
1643 fn op() -> Operation {
1644 Operation {
1645 method: "GET".to_string(),
1646 path: "/users/{id}".to_string(),
1647 parameters: vec![
1648 Parameter::new("id", ParameterLocation::Path, true),
1649 Parameter::new("q", ParameterLocation::Query, false),
1650 ],
1651 has_request_body: false,
1652 base_url: None,
1653 }
1654 }
1655
1656 fn client(
1659 base_url: String,
1660 policy: Option<Arc<dyn RequestPolicy>>,
1661 ) -> (HttpClient, Arc<AtomicUsize>) {
1662 let calls = Arc::new(AtomicUsize::new(0));
1663 let auth = Arc::new(RecordingAuth {
1664 calls: Arc::clone(&calls),
1665 });
1666 let cfg = HttpConfig {
1667 retries: 0,
1668 ..HttpConfig::default()
1669 };
1670 let c = HttpClient::with_config(reqwest::Client::new(), base_url, auth, cfg)
1671 .expect("client builds");
1672 let c = match policy {
1673 Some(p) => c.with_request_policy(p),
1674 None => c,
1675 };
1676 (c, calls)
1677 }
1678
1679 fn recorder(refuse: Option<&'static str>) -> (Arc<Recorder>, Seen) {
1680 let seen: Seen = Arc::new(Mutex::new(Vec::new()));
1681 (
1682 Arc::new(Recorder {
1683 seen: Arc::clone(&seen),
1684 refuse,
1685 }),
1686 seen,
1687 )
1688 }
1689
1690 #[tokio::test]
1691 async fn a_refusing_policy_stops_the_request_before_auth_and_before_the_send() {
1692 use wiremock::MockServer;
1693 let server = MockServer::start().await;
1696 let (policy, _seen) = recorder(Some("refused by test policy"));
1697 let (client, auth_calls) = client(server.uri(), Some(policy));
1698
1699 let err = client
1700 .execute(&op(), &serde_json::json!({ "id": "42" }))
1701 .await
1702 .expect_err("the policy refuses");
1703
1704 assert_eq!(
1705 err.to_string(),
1706 "outbound request refused by policy: refused by test policy",
1707 "the refusal must carry the policy's own message"
1708 );
1709 assert_eq!(
1710 auth_calls.load(Ordering::SeqCst),
1711 0,
1712 "the auth provider must NOT have been invoked — the hook is before auth"
1713 );
1714 let requests = server
1715 .received_requests()
1716 .await
1717 .expect("wiremock records requests");
1718 assert!(requests.is_empty(), "a refusal must send nothing");
1719 }
1720
1721 #[tokio::test]
1722 async fn the_same_refused_call_twice_is_identical_and_sends_nothing() {
1723 use wiremock::MockServer;
1724 let server = MockServer::start().await;
1725 let (policy, _seen) = recorder(Some("refused by test policy"));
1726 let (client, _auth) = client(server.uri(), Some(policy));
1727
1728 let first = client
1729 .execute(&op(), &serde_json::json!({ "id": "42" }))
1730 .await
1731 .expect_err("refuses");
1732 let second = client
1733 .execute(&op(), &serde_json::json!({ "id": "42" }))
1734 .await
1735 .expect_err("refuses again");
1736 assert_eq!(first.to_string(), second.to_string());
1737 assert!(server
1738 .received_requests()
1739 .await
1740 .expect("recorded")
1741 .is_empty());
1742 }
1743
1744 #[tokio::test]
1745 async fn an_allowing_policy_lets_the_request_through_and_auth_is_applied() {
1746 use wiremock::matchers::{header, method, path};
1747 use wiremock::{Mock, MockServer, ResponseTemplate};
1748
1749 let server = MockServer::start().await;
1750 Mock::given(method("GET"))
1751 .and(path("/users/42"))
1752 .and(header("authorization", "Bearer tok"))
1753 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1754 .mount(&server)
1755 .await;
1756
1757 let (policy, _seen) = recorder(None);
1758 let (client, auth_calls) = client(server.uri(), Some(policy));
1759 let out = client
1760 .execute(&op(), &serde_json::json!({ "id": "42" }))
1761 .await
1762 .expect("allowed");
1763 assert_eq!(out["ok"], true);
1764 assert_eq!(auth_calls.load(Ordering::SeqCst), 1);
1765 assert_eq!(server.received_requests().await.expect("recorded").len(), 1);
1766 }
1767
1768 #[tokio::test]
1769 async fn the_policy_sees_the_resolved_path_and_the_query_pairs() {
1770 use wiremock::matchers::{method, path};
1771 use wiremock::{Mock, MockServer, ResponseTemplate};
1772
1773 let server = MockServer::start().await;
1774 Mock::given(method("GET"))
1775 .and(path("/users/42"))
1776 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1777 .mount(&server)
1778 .await;
1779
1780 let (policy, seen) = recorder(None);
1781 let (client, _auth) = client(server.uri(), Some(policy));
1782 client
1783 .execute(&op(), &serde_json::json!({ "id": "42", "q": "hay" }))
1784 .await
1785 .expect("allowed");
1786
1787 let seen = seen.lock().expect("lock");
1788 assert_eq!(seen.len(), 1, "exactly one invocation per logical request");
1789 let (_tool, observed_path, query, body) = &seen[0];
1790 assert!(
1791 observed_path.ends_with("/users/42"),
1792 "the policy must see the SUBSTITUTED path, got {observed_path}"
1793 );
1794 assert!(
1795 !observed_path.contains('{'),
1796 "the policy must never see the template"
1797 );
1798 assert_eq!(query.as_slice(), &[("q".to_string(), "hay".to_string())]);
1799 assert!(body.is_none(), "a GET carries no body");
1800 }
1801
1802 #[tokio::test]
1803 async fn no_policy_behaves_exactly_as_before() {
1804 use wiremock::matchers::{method, path};
1805 use wiremock::{Mock, MockServer, ResponseTemplate};
1806
1807 let server = MockServer::start().await;
1808 Mock::given(method("GET"))
1809 .and(path("/users/42"))
1810 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1811 .mount(&server)
1812 .await;
1813
1814 let (client, auth_calls) = client(server.uri(), None);
1815 assert!(!client.has_request_policy());
1816 let out = client
1817 .execute(&op(), &serde_json::json!({ "id": "42" }))
1818 .await
1819 .expect("succeeds");
1820 assert_eq!(out["ok"], true);
1821 assert_eq!(auth_calls.load(Ordering::SeqCst), 1);
1822 }
1823
1824 #[tokio::test]
1825 async fn the_policy_is_told_which_tool_the_call_came_from() {
1826 use wiremock::matchers::{method, path};
1827 use wiremock::{Mock, MockServer, ResponseTemplate};
1828
1829 let server = MockServer::start().await;
1830 Mock::given(method("GET"))
1831 .and(path("/users/42"))
1832 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1833 .mount(&server)
1834 .await;
1835
1836 let (policy, seen) = recorder(None);
1837 let (client, _auth) = client(server.uri(), Some(policy));
1838 client
1839 .execute_for_tool("get_user", &op(), &serde_json::json!({ "id": "42" }))
1840 .await
1841 .expect("allowed");
1842
1843 let seen = seen.lock().expect("lock");
1844 assert_eq!(seen[0].0, "get_user");
1845 }
1846
1847 #[test]
1848 fn a_governed_connector_reports_its_policy_through_the_dyn_trait() {
1849 let (policy, _seen) = recorder(None);
1850 let (client, _auth) = client("https://example.test".to_string(), None);
1851 let bare: Arc<dyn HttpConnector> = Arc::new(client);
1852 assert!(
1853 !bare.has_request_policy(),
1854 "a bare connector carries no policy"
1855 );
1856 let governed = bare
1857 .governed(policy)
1858 .expect("HttpClient supports policy attachment");
1859 assert!(
1860 governed.has_request_policy(),
1861 "a registered policy must be observable on the dyn connector, or it could \
1862 look registered while never running"
1863 );
1864 }
1865}