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 call_id = crate::policy::next_call_id();
654 let req = crate::policy::OutboundRequest::new(tool, &method, path, &sorted, body)
655 .with_call_id(&call_id);
656 policy
657 .check(&req)
658 .await
659 .map_err(|refusal| HttpConnectorError::PolicyRefused(refusal.message().to_string()))
660 }
661
662 async fn execute_inner(
668 &self,
669 tool: &str,
670 operation: &Operation,
671 args: &serde_json::Value,
672 ) -> Result<serde_json::Value, HttpConnectorError> {
673 let empty = serde_json::Map::new();
674 let args_map = args.as_object().unwrap_or(&empty);
675
676 let substituted = Self::substitute_path(operation, args_map)?;
680 let joined = join_url(self.base_url.as_str(), &substituted);
681 let mut url = url::Url::parse(&joined)
682 .map_err(|_| HttpConnectorError::Backend("constructed URL is invalid".to_string()))?;
683
684 let mut query = Self::build_query(operation, args_map)?;
685 let mut headers = Self::build_headers(operation, args_map)?;
686 let request_body = Self::build_body(operation, args_map);
687
688 self.run_request_policy(
706 tool,
707 &operation.method,
708 &joined,
709 &query,
710 request_body.as_ref(),
711 )
712 .await?;
713
714 self.auth.apply(&mut headers, &mut query, None).await?;
717
718 if !query.is_empty() {
724 let mut pairs = url.query_pairs_mut();
725 for (key, value) in &query {
726 pairs.append_pair(key, value);
727 }
728 drop(pairs);
729 }
730
731 let method = Self::convert_method(&operation.method)?;
732 let mut request = self.client.request(method, url);
733 request = request.headers(headers);
734 if let Some(body) = request_body {
735 request = request.json(&body);
736 }
737
738 let response = self.send_with_retries(request).await?;
739 let status = response.status();
740 if !status.is_success() {
741 return Err(HttpConnectorError::Status {
742 status: status.as_u16(),
743 });
744 }
745 let body = response
746 .text()
747 .await
748 .map_err(|_| HttpConnectorError::Request("failed to read response body".to_string()))?;
749 if body.is_empty() {
750 return Ok(serde_json::Value::Null);
751 }
752 serde_json::from_str(&body).map_err(|_| {
753 HttpConnectorError::Backend("response body was not valid JSON".to_string())
754 })
755 }
756
757 fn cloned_with_policy(&self, policy: Arc<dyn crate::policy::RequestPolicy>) -> Self {
764 Self {
765 client: self.client.clone(),
766 base_url: self.base_url.clone(),
767 auth: Arc::clone(&self.auth),
768 http_config: self.http_config.clone(),
769 policy: Some(policy),
770 }
771 }
772}
773
774#[async_trait]
775impl HttpConnector for HttpClient {
776 async fn execute(
777 &self,
778 operation: &Operation,
779 args: &serde_json::Value,
780 ) -> Result<serde_json::Value, HttpConnectorError> {
781 self.execute_inner("", operation, args).await
784 }
785
786 async fn execute_for_tool(
787 &self,
788 tool: &str,
789 operation: &Operation,
790 args: &serde_json::Value,
791 ) -> Result<serde_json::Value, HttpConnectorError> {
792 self.execute_inner(tool, operation, args).await
793 }
794
795 fn has_request_policy(&self) -> bool {
796 self.policy.is_some()
797 }
798
799 fn governed(
800 &self,
801 policy: Arc<dyn crate::policy::RequestPolicy>,
802 ) -> Option<Arc<dyn HttpConnector>> {
803 Some(Arc::new(self.cloned_with_policy(policy)))
804 }
805
806 fn base_url(&self) -> &str {
807 self.base_url.as_str()
808 }
809}
810
811#[cfg(all(test, feature = "input-validation"))]
824mod d4_support {
825 use super::{HttpClient, HttpConnectorError, Operation};
826 use crate::http::{Parameter, ParameterLocation};
827
828 pub fn op(path: &str, parameters: Vec<Parameter>) -> Operation {
830 Operation {
831 method: "GET".to_string(),
832 path: path.to_string(),
833 parameters,
834 has_request_body: false,
835 base_url: None,
836 }
837 }
838
839 pub fn path_param(name: &str) -> Parameter {
841 Parameter::new(name, ParameterLocation::Path, true)
842 }
843
844 pub fn substitute(
847 path: &str,
848 pairs: &[(&str, serde_json::Value)],
849 ) -> Result<String, HttpConnectorError> {
850 let parameters = pairs.iter().map(|(k, _)| path_param(k)).collect();
851 let mut args = serde_json::Map::new();
852 for (k, v) in pairs {
853 args.insert((*k).to_string(), v.clone());
854 }
855 HttpClient::substitute_path(&op(path, parameters), &args)
856 }
857
858 pub fn substitute_one(
860 path: &str,
861 name: &str,
862 value: &str,
863 ) -> Result<String, HttpConnectorError> {
864 substitute(
865 path,
866 &[(name, serde_json::Value::String(value.to_string()))],
867 )
868 }
869}
870
871#[cfg(all(test, feature = "input-validation"))]
875mod placeholder_floor {
876 use super::d4_support::{op, path_param, substitute, substitute_one};
877 use super::{HttpClient, HttpConnectorError};
878 use crate::http::{Parameter, ParameterLocation};
879 use pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH;
880
881 fn assert_value_free(err: &HttpConnectorError, param: &str, value: &str, path_fragment: &str) {
884 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
885 let rendered = err.to_string();
886 assert!(
887 rendered.contains(param),
888 "the refusal must name the declared parameter: {rendered}"
889 );
890 assert!(
891 !rendered.contains(value),
892 "the refusal must carry no byte of the value: {rendered}"
893 );
894 assert!(
895 !rendered.contains(path_fragment),
896 "the refusal must never contain the resolved path: {rendered}"
897 );
898 }
899
900 #[test]
902 fn placeholder_floor_refuses_a_query_separator_in_a_value() {
903 let value = "current?string=x";
904 let err = substitute_one("/content/{version}/CUI", "version", value).unwrap_err();
905 assert_value_free(&err, "version", value, "/content/");
906 }
907
908 #[test]
910 fn placeholder_floor_refuses_traversal_in_a_value() {
911 let value = "current/../../search/current";
912 let err = substitute_one("/content/{version}/CUI", "version", value).unwrap_err();
913 assert_value_free(&err, "version", value, "/content/");
914 }
915
916 #[test]
919 fn placeholder_floor_refuses_upper_case_encoded_traversal() {
920 let err = substitute_one("/content/{version}/CUI", "version", "a%2E%2Eb").unwrap_err();
921 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
922 }
923
924 #[test]
927 fn placeholder_floor_refuses_a_value_that_is_exactly_a_denied_character() {
928 let err = substitute_one("/content/{version}/CUI", "version", "?").unwrap_err();
929 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
930 }
931
932 #[test]
934 fn placeholder_floor_refuses_a_nul_byte_in_both_forms() {
935 assert!(substitute_one("/x/{v}", "v", "a\u{0}b").is_err());
936 assert!(substitute_one("/x/{v}", "v", "a%00b").is_err());
937 }
938
939 #[test]
941 fn placeholder_floor_refuses_an_empty_value() {
942 assert!(substitute_one("/x/{v}", "v", "").is_err());
943 }
944
945 #[test]
947 fn placeholder_floor_accepts_the_cap_and_refuses_one_more() {
948 let at_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH);
949 assert_eq!(
950 substitute_one("/x/{v}", "v", &at_cap).expect("at the cap"),
951 format!("/x/{at_cap}")
952 );
953 let over_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH + 1);
954 assert!(substitute_one("/x/{v}", "v", &over_cap).is_err());
955 }
956
957 #[test]
960 fn placeholder_floor_accepts_a_value_matching_its_declared_pattern() {
961 let parameters = vec![
962 Parameter::new("cui", ParameterLocation::Path, true).with_rules(
963 Some("^C[0-9]+$".to_string()),
964 Some(32),
965 false,
966 ),
967 ];
968 let mut args = serde_json::Map::new();
969 args.insert("cui".to_string(), serde_json::json!("C0018787"));
970 let resolved = HttpClient::substitute_path(&op("/CUI/{cui}/content", parameters), &args)
971 .expect("a conforming value must be accepted");
972 assert_eq!(resolved, "/CUI/C0018787/content");
973 }
974
975 #[test]
977 fn placeholder_floor_refuses_a_value_failing_its_declared_pattern() {
978 let parameters = vec![
979 Parameter::new("cui", ParameterLocation::Path, true).with_rules(
980 Some("^C[0-9]+$".to_string()),
981 None,
982 false,
983 ),
984 ];
985 let mut args = serde_json::Map::new();
986 args.insert("cui".to_string(), serde_json::json!("notacui"));
987 let err = HttpClient::substitute_path(&op("/CUI/{cui}", parameters), &args).unwrap_err();
988 assert!(err.to_string().contains("cui"), "{err}");
989 assert!(!err.to_string().contains("notacui"), "{err}");
990 }
991
992 #[test]
995 fn placeholder_floor_leaves_a_placeholder_free_template_untouched() {
996 let resolved = HttpClient::substitute_path(
997 &op("/Line/Mode/tube/Status", vec![]),
998 &serde_json::Map::new(),
999 )
1000 .expect("a placeholder-free template must be unaffected");
1001 assert_eq!(resolved, "/Line/Mode/tube/Status");
1002 }
1003
1004 #[test]
1012 fn placeholder_floor_accepts_the_root_path_and_still_refuses_a_trailing_slash() {
1013 let resolved = HttpClient::substitute_path(&op("/", vec![]), &serde_json::Map::new())
1014 .expect("a `GET /` operation must be callable — the root is the shortest legal path");
1015 assert_eq!(resolved, "/");
1016
1017 let err = substitute_one("/search/{v}", "v", "")
1019 .expect_err("an empty tail placeholder must stay refused");
1020 assert!(
1021 matches!(err, HttpConnectorError::Backend(_)),
1022 "the refusal is a Backend error naming the position: {err}"
1023 );
1024 assert!(
1025 HttpClient::substitute_path(&op("/search/", vec![]), &serde_json::Map::new()).is_err(),
1026 "a literal trailing slash in the template stays refused by decision"
1027 );
1028 }
1029
1030 #[test]
1034 fn placeholder_floor_refuses_the_second_of_two_placeholders_without_substituting() {
1035 let err = substitute(
1036 "/a/{first}/b/{second}",
1037 &[
1038 ("first", serde_json::json!("ok")),
1039 ("second", serde_json::json!("../escape")),
1040 ],
1041 )
1042 .unwrap_err();
1043 let rendered = err.to_string();
1044 assert!(rendered.contains("second"), "{rendered}");
1045 assert!(
1046 !rendered.contains("/a/ok/b/"),
1047 "no partially-substituted path may appear anywhere: {rendered}"
1048 );
1049 }
1050
1051 #[test]
1054 fn placeholder_floor_refuses_an_absent_path_argument() {
1055 let err = HttpClient::substitute_path(
1056 &op("/users/{id}/profile", vec![path_param("id")]),
1057 &serde_json::Map::new(),
1058 )
1059 .unwrap_err();
1060 let rendered = err.to_string();
1061 assert!(rendered.contains("id"), "{rendered}");
1062 assert!(
1063 !rendered.contains('{') && !rendered.contains('}'),
1064 "the refusal must not echo the template: {rendered}"
1065 );
1066 assert!(
1067 !rendered.contains("/users/"),
1068 "the refusal must not echo the path: {rendered}"
1069 );
1070 }
1071
1072 #[test]
1077 fn placeholder_floor_refuses_a_composed_segment_over_the_cap() {
1078 let prefix = "p".repeat(100);
1079 let value = "v".repeat(200);
1080 let err = substitute_one(&format!("/x/{prefix}{{id}}"), "id", &value).unwrap_err();
1081 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
1082 }
1083
1084 #[test]
1088 fn placeholder_floor_refuses_a_residual_brace_from_an_unrecognized_template() {
1089 let err = HttpClient::substitute_path(&op("/x/{a}/y/{b}", vec![path_param("a")]), &{
1090 let mut args = serde_json::Map::new();
1091 args.insert("a".to_string(), serde_json::json!("ok"));
1092 args
1093 })
1094 .unwrap_err();
1095 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
1096 }
1097
1098 #[test]
1102 fn placeholder_floor_refuses_traversal_written_into_the_template_literal() {
1103 let err = HttpClient::substitute_path(&op("/a/../b", vec![]), &serde_json::Map::new())
1104 .unwrap_err();
1105 assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
1106 }
1107}
1108
1109#[cfg(all(test, feature = "input-validation"))]
1120mod query_separator {
1121 use super::d4_support::substitute_one;
1122 use super::{HttpClient, Operation};
1123 use pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH;
1124
1125 fn literal(path: &str) -> Result<String, super::HttpConnectorError> {
1127 HttpClient::substitute_path(
1128 &Operation {
1129 method: "GET".to_string(),
1130 path: path.to_string(),
1131 parameters: vec![],
1132 has_request_body: false,
1133 base_url: None,
1134 },
1135 &serde_json::Map::new(),
1136 )
1137 }
1138
1139 #[test]
1143 fn query_separator_accepts_an_author_written_query_string() {
1144 assert_eq!(
1145 literal("/Line/Mode/tube/Status?detail=true").expect("author query accepted"),
1146 "/Line/Mode/tube/Status?detail=true"
1147 );
1148 }
1149
1150 #[test]
1153 fn query_separator_accepts_a_literal_query_alongside_a_floored_placeholder() {
1154 assert_eq!(
1155 substitute_one("/content/{version}/CUI?string=x", "version", "current")
1156 .expect("author query plus conforming placeholder accepted"),
1157 "/content/current/CUI?string=x"
1158 );
1159 }
1160
1161 #[test]
1163 fn query_separator_accepts_a_graph_style_dollar_projection() {
1164 let resolved = literal(
1165 "/drives/D/items/I/workbook/worksheets/C/range(address='A2:D7')?$select=values",
1166 )
1167 .expect("a Graph $select projection must be accepted");
1168 assert!(resolved.ends_with("?$select=values"), "{resolved}");
1169 }
1170
1171 #[test]
1174 fn query_separator_still_refuses_traversal_in_the_path_portion() {
1175 assert!(
1176 literal("/a/../b?x=1").is_err(),
1177 "appending a query must not launder a traversal"
1178 );
1179 }
1180
1181 #[test]
1182 fn query_separator_still_refuses_traversal_in_the_query_portion() {
1183 let err = literal("/search?next=../../etc/passwd").unwrap_err();
1184 assert!(!err.to_string().contains("passwd"), "{err}");
1185 }
1186
1187 #[test]
1188 fn query_separator_still_refuses_a_control_byte_in_the_query_portion() {
1189 assert!(literal("/search?x=a%00b").is_err());
1190 }
1191
1192 #[test]
1193 fn query_separator_still_refuses_an_over_cap_query_portion() {
1194 let long = "z".repeat(PLACEHOLDER_MAX_LENGTH + 1);
1195 assert!(literal(&format!("/search?q={long}")).is_err());
1196 }
1197
1198 #[test]
1199 fn query_separator_still_refuses_a_second_question_mark() {
1200 assert!(
1201 literal("/search?a=1?b=2").is_err(),
1202 "only the FIRST `?` is split off; one exemption, not a licence"
1203 );
1204 }
1205
1206 #[test]
1207 fn query_separator_still_refuses_an_empty_query_portion() {
1208 assert!(
1209 literal("/search?").is_err(),
1210 "a dangling `?` is the same class as a trailing `/`"
1211 );
1212 }
1213
1214 #[test]
1215 fn query_separator_still_refuses_a_fragment_marker() {
1216 assert!(literal("/search#frag").is_err());
1217 }
1218
1219 #[test]
1224 fn query_separator_still_refuses_an_injected_separator_from_a_value() {
1225 let payload = "2026AA?string=x";
1226 let err = substitute_one("/search/{v}?detail=true", "v", payload).unwrap_err();
1227 let rendered = err.to_string();
1228 assert!(rendered.contains('v'), "{rendered}");
1229 assert!(
1230 !rendered.contains("2026AA") && !rendered.contains('?'),
1231 "the refusal must carry no byte of the value: {rendered}"
1232 );
1233 }
1234
1235 #[test]
1237 fn query_separator_still_refuses_an_injected_traversal_from_a_value() {
1238 assert!(substitute_one("/search/{v}?detail=true", "v", "../../etc/passwd").is_err());
1239 }
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244 use super::*;
1245 use crate::http::auth::NoAuth;
1246 use crate::http::{Parameter, ParameterLocation};
1247
1248 fn get_user_op() -> Operation {
1249 Operation {
1250 method: "GET".to_string(),
1251 path: "/users/{id}".to_string(),
1252 parameters: vec![
1253 Parameter::new("id", ParameterLocation::Path, true),
1254 Parameter::new("verbose", ParameterLocation::Query, false),
1255 ],
1256 has_request_body: false,
1257 base_url: None,
1258 }
1259 }
1260
1261 #[test]
1262 fn test_build_url_with_path_prefix() {
1263 let client = HttpClient::new(
1265 reqwest::Client::new(),
1266 "https://xxx.execute-api.eu-west-1.amazonaws.com/v1/".to_string(),
1267 Arc::new(NoAuth),
1268 )
1269 .unwrap();
1270 let op = get_user_op();
1271 let mut args = serde_json::Map::new();
1272 args.insert("id".to_string(), serde_json::json!("42"));
1273 let substituted = HttpClient::substitute_path(&op, &args).unwrap();
1274 let joined = join_url(client.base_url(), &substituted);
1275 assert_eq!(
1276 joined,
1277 "https://xxx.execute-api.eu-west-1.amazonaws.com/v1/users/42"
1278 );
1279 }
1280
1281 #[test]
1282 fn test_substitute_path_replaces_placeholder() {
1283 let op = get_user_op();
1284 let mut args = serde_json::Map::new();
1285 args.insert("id".to_string(), serde_json::json!(7));
1286 assert_eq!(HttpClient::substitute_path(&op, &args).unwrap(), "/users/7");
1287 }
1288
1289 #[test]
1290 fn test_build_query_skips_path_params() {
1291 let op = get_user_op();
1292 let mut args = serde_json::Map::new();
1293 args.insert("id".to_string(), serde_json::json!("42"));
1294 args.insert("verbose".to_string(), serde_json::json!(true));
1295 let query = HttpClient::build_query(&op, &args).unwrap();
1296 assert_eq!(query.get("verbose"), Some(&"true".to_string()));
1297 assert!(!query.contains_key("id"));
1298 }
1299
1300 #[test]
1305 fn render_query_value_comma_joins_scalar_array() {
1306 let rendered =
1307 HttpClient::render_query_value("tags", &serde_json::json!(["a", 2, true])).unwrap();
1308 assert_eq!(rendered, "a,2,true");
1309 }
1310
1311 #[test]
1313 fn render_query_value_scalar_passthrough() {
1314 assert_eq!(
1315 HttpClient::render_query_value("q", &serde_json::json!("hi")).unwrap(),
1316 "hi"
1317 );
1318 assert_eq!(
1319 HttpClient::render_query_value("n", &serde_json::json!(7)).unwrap(),
1320 "7"
1321 );
1322 }
1323
1324 #[test]
1327 fn render_scalar_null_is_bare_null() {
1328 assert_eq!(
1329 render_scalar("x", &serde_json::Value::Null).unwrap(),
1330 "null"
1331 );
1332 }
1333
1334 #[test]
1337 fn substitute_path_rejects_object_param() {
1338 let op = get_user_op();
1339 let mut args = serde_json::Map::new();
1340 args.insert("id".to_string(), serde_json::json!({"nested": "x"}));
1341 let err = HttpClient::substitute_path(&op, &args).unwrap_err();
1342 assert!(matches!(err, HttpConnectorError::Backend(_)));
1343 let rendered = err.to_string();
1344 assert!(
1345 rendered.contains("id"),
1346 "error must name the param: {rendered}"
1347 );
1348 for forbidden in ['{', '[', '"'] {
1349 assert!(
1350 !rendered.contains(forbidden),
1351 "must not echo JSON: {rendered}"
1352 );
1353 }
1354 assert!(
1356 !rendered.contains("nested"),
1357 "must not echo the value: {rendered}"
1358 );
1359 }
1360
1361 #[test]
1363 fn build_query_rejects_object_param() {
1364 let op = get_user_op();
1365 let mut args = serde_json::Map::new();
1366 args.insert("verbose".to_string(), serde_json::json!({"k": "v"}));
1367 let err = HttpClient::build_query(&op, &args).unwrap_err();
1368 assert!(matches!(err, HttpConnectorError::Backend(_)));
1369 assert!(err.to_string().contains("verbose"));
1370 }
1371
1372 #[test]
1375 fn render_query_value_rejects_array_with_object_member() {
1376 let err = HttpClient::render_query_value("tags", &serde_json::json!(["ok", {"bad": 1}]))
1377 .unwrap_err();
1378 assert!(matches!(err, HttpConnectorError::Backend(_)));
1379 assert!(err.to_string().contains("tags"));
1380 }
1381
1382 #[test]
1384 fn build_headers_rejects_non_scalar_param() {
1385 let op = Operation {
1386 method: "GET".to_string(),
1387 path: "/x".to_string(),
1388 parameters: vec![Parameter::new("x-trace", ParameterLocation::Header, false)],
1389 has_request_body: false,
1390 base_url: None,
1391 };
1392 let mut args = serde_json::Map::new();
1395 args.insert("x-trace".to_string(), serde_json::json!(["a", "b"]));
1396 let err = HttpClient::build_headers(&op, &args).unwrap_err();
1397 assert!(matches!(err, HttpConnectorError::Backend(_)));
1398 assert!(err.to_string().contains("x-trace"));
1399 let mut args2 = serde_json::Map::new();
1401 args2.insert("x-trace".to_string(), serde_json::json!({"k": "v"}));
1402 let err2 = HttpClient::build_headers(&op, &args2).unwrap_err();
1403 assert!(matches!(err2, HttpConnectorError::Backend(_)));
1404 assert!(err2.to_string().contains("x-trace"));
1405 let mut args3 = serde_json::Map::new();
1407 args3.insert("x-trace".to_string(), serde_json::json!("abc"));
1408 let headers = HttpClient::build_headers(&op, &args3).unwrap();
1409 assert_eq!(headers.get("x-trace").unwrap(), "abc");
1410 }
1411
1412 #[test]
1413 fn test_new_is_lazy_and_rejects_bad_url() {
1414 let err = HttpClient::new(
1416 reqwest::Client::new(),
1417 "not a url".to_string(),
1418 Arc::new(NoAuth),
1419 )
1420 .err()
1421 .expect("bad URL should error");
1422 assert!(matches!(err, HttpConnectorError::Backend(_)));
1423 let rendered = err.to_string();
1424 assert!(!rendered.contains("not a url"), "must not echo the bad URL");
1425 }
1426
1427 #[tokio::test]
1428 async fn http_connector_get_returns_json() {
1429 use wiremock::matchers::{method, path};
1430 use wiremock::{Mock, MockServer, ResponseTemplate};
1431
1432 let server = MockServer::start().await;
1433 Mock::given(method("GET"))
1434 .and(path("/users/42"))
1435 .respond_with(
1436 ResponseTemplate::new(200)
1437 .set_body_json(serde_json::json!({"id": 42, "name": "Ada"})),
1438 )
1439 .mount(&server)
1440 .await;
1441
1442 let client =
1443 HttpClient::new(reqwest::Client::new(), server.uri(), Arc::new(NoAuth)).unwrap();
1444 let op = get_user_op();
1445 let args = serde_json::json!({"id": "42"});
1446 let result = client.execute(&op, &args).await.unwrap();
1447 assert_eq!(result["id"], 42);
1448 assert_eq!(result["name"], "Ada");
1449 }
1450
1451 #[tokio::test]
1452 async fn http_connector_post_sends_body_and_auth() {
1453 use wiremock::matchers::{body_json, header, method, path};
1454 use wiremock::{Mock, MockServer, ResponseTemplate};
1455
1456 let server = MockServer::start().await;
1457 Mock::given(method("POST"))
1458 .and(path("/items"))
1459 .and(header("authorization", "Bearer tok"))
1460 .and(body_json(serde_json::json!({"name": "widget"})))
1461 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"ok": true})))
1462 .mount(&server)
1463 .await;
1464
1465 let auth = crate::http::auth::create_auth_provider(&crate::http::AuthConfig::Bearer {
1466 token: "tok".to_string(),
1467 required: true,
1468 })
1469 .unwrap();
1470 let client = HttpClient::new(reqwest::Client::new(), server.uri(), auth).unwrap();
1471 let op = Operation {
1472 method: "POST".to_string(),
1473 path: "/items".to_string(),
1474 parameters: vec![],
1475 has_request_body: true,
1476 base_url: None,
1477 };
1478 let args = serde_json::json!({"name": "widget"});
1479 let result = client.execute(&op, &args).await.unwrap();
1480 assert_eq!(result["ok"], true);
1481 }
1482
1483 #[tokio::test]
1490 async fn http_connector_post_sends_declared_body_parameters_as_the_payload() {
1491 use wiremock::matchers::{body_json, method, path, query_param_is_missing};
1492 use wiremock::{Mock, MockServer, ResponseTemplate};
1493
1494 let server = MockServer::start().await;
1495 Mock::given(method("POST"))
1496 .and(path("/items"))
1497 .and(body_json(
1498 serde_json::json!({"name": "widget", "note": "free text"}),
1499 ))
1500 .and(query_param_is_missing("name"))
1503 .and(query_param_is_missing("note"))
1504 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"ok": true})))
1505 .mount(&server)
1506 .await;
1507
1508 let client =
1509 HttpClient::new(reqwest::Client::new(), server.uri(), Arc::new(NoAuth)).unwrap();
1510 let op = Operation {
1511 method: "POST".to_string(),
1512 path: "/items".to_string(),
1513 parameters: vec![
1514 Parameter::new("name", ParameterLocation::Body, true),
1515 Parameter::new("note", ParameterLocation::Body, false),
1516 ],
1517 has_request_body: true,
1518 base_url: None,
1519 };
1520 let args = serde_json::json!({"name": "widget", "note": "free text"});
1521 let result = client.execute(&op, &args).await.unwrap();
1522 assert_eq!(result["ok"], true);
1523 }
1524
1525 #[test]
1529 fn build_body_withholds_a_query_located_parameter_on_a_post() {
1530 let op = Operation {
1531 method: "POST".to_string(),
1532 path: "/items".to_string(),
1533 parameters: vec![
1534 Parameter::new("dry_run", ParameterLocation::Query, false),
1535 Parameter::new("name", ParameterLocation::Body, true),
1536 ],
1537 has_request_body: true,
1538 base_url: None,
1539 };
1540 let args = serde_json::json!({"dry_run": "true", "name": "widget"})
1541 .as_object()
1542 .expect("object")
1543 .clone();
1544 let body = HttpClient::build_body(&op, &args).expect("a body is built");
1545 assert_eq!(body, serde_json::json!({"name": "widget"}));
1546 let query = HttpClient::build_query(&op, &args).expect("a query is built");
1547 assert_eq!(query.get("dry_run").map(String::as_str), Some("true"));
1548 assert!(
1549 !query.contains_key("name"),
1550 "a Body-located parameter must not reach the query string: {query:?}"
1551 );
1552 }
1553
1554 #[tokio::test]
1555 async fn http_connector_maps_non_2xx_to_status_without_url() {
1556 use wiremock::matchers::{method, path};
1557 use wiremock::{Mock, MockServer, ResponseTemplate};
1558
1559 let server = MockServer::start().await;
1560 Mock::given(method("GET"))
1561 .and(path("/users/42"))
1562 .respond_with(ResponseTemplate::new(404))
1563 .mount(&server)
1564 .await;
1565
1566 let client =
1567 HttpClient::new(reqwest::Client::new(), server.uri(), Arc::new(NoAuth)).unwrap();
1568 let op = get_user_op();
1569 let args = serde_json::json!({"id": "42"});
1570 let err = client.execute(&op, &args).await.unwrap_err();
1571 assert!(matches!(err, HttpConnectorError::Status { status: 404 }));
1572 let rendered = err.to_string();
1573 assert!(rendered.contains("404"));
1574 assert!(
1575 !rendered.contains("http://"),
1576 "status error must not echo the URL"
1577 );
1578 }
1579}
1580
1581#[cfg(test)]
1592mod request_policy_seam {
1593 use super::{HttpClient, HttpConfig, HttpConnectorError};
1594 use crate::http::auth::HttpAuthProvider;
1595 use crate::http::{HttpConnector, Operation, Parameter, ParameterLocation};
1596 use crate::policy::{OutboundRequest, PolicyRefusal, RequestPolicy};
1597 use async_trait::async_trait;
1598 use reqwest::header::{HeaderMap, HeaderValue};
1599 use std::collections::HashMap;
1600 use std::sync::atomic::{AtomicUsize, Ordering};
1601 use std::sync::{Arc, Mutex};
1602
1603 struct RecordingAuth {
1606 calls: Arc<AtomicUsize>,
1607 }
1608
1609 #[async_trait]
1610 impl HttpAuthProvider for RecordingAuth {
1611 async fn apply(
1612 &self,
1613 headers: &mut HeaderMap,
1614 _query: &mut HashMap<String, String>,
1615 _inbound_token: Option<&str>,
1616 ) -> Result<(), HttpConnectorError> {
1617 self.calls.fetch_add(1, Ordering::SeqCst);
1618 headers.insert("authorization", HeaderValue::from_static("Bearer tok"));
1619 Ok(())
1620 }
1621 }
1622
1623 type Seen = Arc<Mutex<Vec<(String, String, Vec<(String, String)>, Option<String>)>>>;
1625
1626 struct Recorder {
1628 seen: Seen,
1629 refuse: Option<&'static str>,
1630 }
1631
1632 #[async_trait]
1633 impl RequestPolicy for Recorder {
1634 async fn check(&self, req: &OutboundRequest<'_>) -> Result<(), PolicyRefusal> {
1635 self.seen.lock().expect("lock").push((
1636 req.tool.to_string(),
1637 req.path.to_string(),
1638 req.query.to_vec(),
1639 req.body.map(ToString::to_string),
1640 ));
1641 match self.refuse {
1642 Some(msg) => Err(PolicyRefusal::new(msg)),
1643 None => Ok(()),
1644 }
1645 }
1646 }
1647
1648 fn op() -> Operation {
1649 Operation {
1650 method: "GET".to_string(),
1651 path: "/users/{id}".to_string(),
1652 parameters: vec![
1653 Parameter::new("id", ParameterLocation::Path, true),
1654 Parameter::new("q", ParameterLocation::Query, false),
1655 ],
1656 has_request_body: false,
1657 base_url: None,
1658 }
1659 }
1660
1661 fn client(
1664 base_url: String,
1665 policy: Option<Arc<dyn RequestPolicy>>,
1666 ) -> (HttpClient, Arc<AtomicUsize>) {
1667 let calls = Arc::new(AtomicUsize::new(0));
1668 let auth = Arc::new(RecordingAuth {
1669 calls: Arc::clone(&calls),
1670 });
1671 let cfg = HttpConfig {
1672 retries: 0,
1673 ..HttpConfig::default()
1674 };
1675 let c = HttpClient::with_config(reqwest::Client::new(), base_url, auth, cfg)
1676 .expect("client builds");
1677 let c = match policy {
1678 Some(p) => c.with_request_policy(p),
1679 None => c,
1680 };
1681 (c, calls)
1682 }
1683
1684 fn recorder(refuse: Option<&'static str>) -> (Arc<Recorder>, Seen) {
1685 let seen: Seen = Arc::new(Mutex::new(Vec::new()));
1686 (
1687 Arc::new(Recorder {
1688 seen: Arc::clone(&seen),
1689 refuse,
1690 }),
1691 seen,
1692 )
1693 }
1694
1695 #[tokio::test]
1696 async fn a_refusing_policy_stops_the_request_before_auth_and_before_the_send() {
1697 use wiremock::MockServer;
1698 let server = MockServer::start().await;
1701 let (policy, _seen) = recorder(Some("refused by test policy"));
1702 let (client, auth_calls) = client(server.uri(), Some(policy));
1703
1704 let err = client
1705 .execute(&op(), &serde_json::json!({ "id": "42" }))
1706 .await
1707 .expect_err("the policy refuses");
1708
1709 assert_eq!(
1710 err.to_string(),
1711 "outbound request refused by policy: refused by test policy",
1712 "the refusal must carry the policy's own message"
1713 );
1714 assert_eq!(
1715 auth_calls.load(Ordering::SeqCst),
1716 0,
1717 "the auth provider must NOT have been invoked — the hook is before auth"
1718 );
1719 let requests = server
1720 .received_requests()
1721 .await
1722 .expect("wiremock records requests");
1723 assert!(requests.is_empty(), "a refusal must send nothing");
1724 }
1725
1726 #[tokio::test]
1727 async fn the_same_refused_call_twice_is_identical_and_sends_nothing() {
1728 use wiremock::MockServer;
1729 let server = MockServer::start().await;
1730 let (policy, _seen) = recorder(Some("refused by test policy"));
1731 let (client, _auth) = client(server.uri(), Some(policy));
1732
1733 let first = client
1734 .execute(&op(), &serde_json::json!({ "id": "42" }))
1735 .await
1736 .expect_err("refuses");
1737 let second = client
1738 .execute(&op(), &serde_json::json!({ "id": "42" }))
1739 .await
1740 .expect_err("refuses again");
1741 assert_eq!(first.to_string(), second.to_string());
1742 assert!(server
1743 .received_requests()
1744 .await
1745 .expect("recorded")
1746 .is_empty());
1747 }
1748
1749 #[tokio::test]
1750 async fn an_allowing_policy_lets_the_request_through_and_auth_is_applied() {
1751 use wiremock::matchers::{header, method, path};
1752 use wiremock::{Mock, MockServer, ResponseTemplate};
1753
1754 let server = MockServer::start().await;
1755 Mock::given(method("GET"))
1756 .and(path("/users/42"))
1757 .and(header("authorization", "Bearer tok"))
1758 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1759 .mount(&server)
1760 .await;
1761
1762 let (policy, _seen) = recorder(None);
1763 let (client, auth_calls) = client(server.uri(), Some(policy));
1764 let out = client
1765 .execute(&op(), &serde_json::json!({ "id": "42" }))
1766 .await
1767 .expect("allowed");
1768 assert_eq!(out["ok"], true);
1769 assert_eq!(auth_calls.load(Ordering::SeqCst), 1);
1770 assert_eq!(server.received_requests().await.expect("recorded").len(), 1);
1771 }
1772
1773 #[tokio::test]
1774 async fn the_policy_sees_the_resolved_path_and_the_query_pairs() {
1775 use wiremock::matchers::{method, path};
1776 use wiremock::{Mock, MockServer, ResponseTemplate};
1777
1778 let server = MockServer::start().await;
1779 Mock::given(method("GET"))
1780 .and(path("/users/42"))
1781 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1782 .mount(&server)
1783 .await;
1784
1785 let (policy, seen) = recorder(None);
1786 let (client, _auth) = client(server.uri(), Some(policy));
1787 client
1788 .execute(&op(), &serde_json::json!({ "id": "42", "q": "hay" }))
1789 .await
1790 .expect("allowed");
1791
1792 let seen = seen.lock().expect("lock");
1793 assert_eq!(seen.len(), 1, "exactly one invocation per logical request");
1794 let (_tool, observed_path, query, body) = &seen[0];
1795 assert!(
1796 observed_path.ends_with("/users/42"),
1797 "the policy must see the SUBSTITUTED path, got {observed_path}"
1798 );
1799 assert!(
1800 !observed_path.contains('{'),
1801 "the policy must never see the template"
1802 );
1803 assert_eq!(query.as_slice(), &[("q".to_string(), "hay".to_string())]);
1804 assert!(body.is_none(), "a GET carries no body");
1805 }
1806
1807 #[tokio::test]
1808 async fn no_policy_behaves_exactly_as_before() {
1809 use wiremock::matchers::{method, path};
1810 use wiremock::{Mock, MockServer, ResponseTemplate};
1811
1812 let server = MockServer::start().await;
1813 Mock::given(method("GET"))
1814 .and(path("/users/42"))
1815 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1816 .mount(&server)
1817 .await;
1818
1819 let (client, auth_calls) = client(server.uri(), None);
1820 assert!(!client.has_request_policy());
1821 let out = client
1822 .execute(&op(), &serde_json::json!({ "id": "42" }))
1823 .await
1824 .expect("succeeds");
1825 assert_eq!(out["ok"], true);
1826 assert_eq!(auth_calls.load(Ordering::SeqCst), 1);
1827 }
1828
1829 #[tokio::test]
1830 async fn the_policy_is_told_which_tool_the_call_came_from() {
1831 use wiremock::matchers::{method, path};
1832 use wiremock::{Mock, MockServer, ResponseTemplate};
1833
1834 let server = MockServer::start().await;
1835 Mock::given(method("GET"))
1836 .and(path("/users/42"))
1837 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1838 .mount(&server)
1839 .await;
1840
1841 let (policy, seen) = recorder(None);
1842 let (client, _auth) = client(server.uri(), Some(policy));
1843 client
1844 .execute_for_tool("get_user", &op(), &serde_json::json!({ "id": "42" }))
1845 .await
1846 .expect("allowed");
1847
1848 let seen = seen.lock().expect("lock");
1849 assert_eq!(seen[0].0, "get_user");
1850 }
1851
1852 #[test]
1853 fn a_governed_connector_reports_its_policy_through_the_dyn_trait() {
1854 let (policy, _seen) = recorder(None);
1855 let (client, _auth) = client("https://example.test".to_string(), None);
1856 let bare: Arc<dyn HttpConnector> = Arc::new(client);
1857 assert!(
1858 !bare.has_request_policy(),
1859 "a bare connector carries no policy"
1860 );
1861 let governed = bare
1862 .governed(policy)
1863 .expect("HttpClient supports policy attachment");
1864 assert!(
1865 governed.has_request_policy(),
1866 "a registered policy must be observable on the dyn connector, or it could \
1867 look registered while never running"
1868 );
1869 }
1870}