Skip to main content

pmcp_server_toolkit/http/
client.rs

1//! reqwest-backed [`HttpConnector`] implementation (OAPI-01).
2//!
3//! Lifts the pmcp-run reference `HttpClient::execute_with_options` body into a
4//! toolkit-owned [`HttpClient`] that implements [`HttpConnector`]. The concrete
5//! shape mirrors `crate::sql::sqlite::SqliteConnector` (a concrete connector impl
6//! + constructor). Construction is LAZY — `new` parses the base URL but contacts
7//! no backend (CF-2). URL building uses the shared [`crate::http::join_url`]
8//! helper so an API-Gateway stage prefix (`/v1`) survives (Pitfall 2 — explicit
9//! path concatenation, never the RFC-3986 url-crate path merge). Error messages
10//! never echo the URL or a credential (Pitfall 5).
11
12use 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/// HTTP client configuration (OWNED here in `http`, mirroring [`super::AuthConfig`]
22/// ownership so Plan 02 re-exports it rather than redefining).
23#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct HttpConfig {
26    /// Request timeout in seconds.
27    #[serde(default = "default_timeout")]
28    pub timeout_seconds: u64,
29    /// Number of retry attempts on 5xx / connect / timeout.
30    #[serde(default = "default_retries")]
31    pub retries: u32,
32    /// Base backoff in milliseconds (exponential per attempt).
33    #[serde(default = "default_retry_backoff")]
34    pub retry_backoff_ms: u64,
35    /// `User-Agent` header for all requests.
36    #[serde(default = "default_user_agent")]
37    pub user_agent: String,
38    /// Extra headers applied to every request.
39    #[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
68/// reqwest-backed [`HttpConnector`].
69pub struct HttpClient {
70    client: reqwest::Client,
71    base_url: url::Url,
72    auth: Arc<dyn HttpAuthProvider>,
73    http_config: HttpConfig,
74    /// The E1 outbound-request policy, when one was registered (Phase 128).
75    ///
76    /// `None` on every client built by [`HttpClient::new`], which is what keeps
77    /// that constructor's signature unchanged and what keeps a server that
78    /// registers no policy allocating nothing extra on the request path.
79    policy: Option<Arc<dyn crate::policy::RequestPolicy>>,
80}
81
82impl HttpClient {
83    /// Construct a client. LAZY: parses `base_url` but contacts no backend (CF-2).
84    ///
85    /// # Errors
86    ///
87    /// Returns [`HttpConnectorError::Backend`] when `base_url` is unparseable or
88    /// the reqwest client cannot be built. The error message does NOT echo the URL.
89    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    /// Construct a client with an explicit [`HttpConfig`]. LAZY (CF-2).
98    ///
99    /// # Errors
100    ///
101    /// As [`HttpClient::new`].
102    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    /// Attach the E1 [`crate::policy::RequestPolicy`] consulted before every
120    /// outbound request (Phase 128).
121    ///
122    /// Cheap clone-with-builder, the same shape as
123    /// `HttpCodeExecutor::with_inbound_token`. Neither [`HttpClient::new`] nor
124    /// [`HttpClient::with_config`] changed signature.
125    #[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    /// Build a client from an [`HttpConfig`], constructing the reqwest client with
132    /// the configured timeout, user-agent, and default headers. LAZY (CF-2).
133    ///
134    /// # Errors
135    ///
136    /// As [`HttpClient::new`].
137    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        // T-128-39a (Phase 128 security audit, W3 / review WR-02). reqwest's DEFAULT
155        // is to follow up to 10 redirects, which would carry a governed request to an
156        // endpoint `RequestPolicy` already refused — the exact bypass
157        // `pmcp-openapi-server`'s `dispatch.rs` hardens against. This constructor is
158        // `pub`, so a downstream consumer reaching for the one that honours
159        // `[backend.http]` would otherwise get the un-hardened client. Keep these two
160        // in the same shape; a redirect must be an explicit, policy-checked new request.
161        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    /// Substitute path parameters into the operation path template.
171    ///
172    /// # Phase 128 D4 — the curated surface's path-injection floor
173    ///
174    /// Two passes, in this order, and the order is load-bearing:
175    ///
176    /// 1. every path parameter's value is rendered and then checked by
177    ///    `pmcp::server::schema_validation::validate_path_placeholder` against that
178    ///    parameter's declared rules (`Parameter::placeholder_rules`) — with
179    ///    NOTHING substituted yet, so a refusal on the second of two placeholders
180    ///    cannot leave a half-substituted path in existence anywhere;
181    /// 2. only once every value has passed are the replacements applied, and the
182    ///    COMPOSED result is then checked by
183    ///    `pmcp::server::schema_validation::validate_resolved_target` before the
184    ///    caller can dispatch it.
185    ///
186    /// Step 2 is not redundant with step 1. A composition belongs to no single
187    /// value, so only the composed check can see a segment that a template literal
188    /// and a passing value jointly push over the cap, traversal written into the
189    /// template itself, or a residual `{`/`}` left by a placeholder with no
190    /// supplied argument.
191    ///
192    /// ## The one narrowing: an operator-written `?` is permitted
193    ///
194    /// `validate_resolved_path` refuses a query separator anywhere, and core keeps
195    /// that strict rule for its other callers. Its sibling
196    /// `validate_resolved_target` — which `check_composed_path` calls — splits the
197    /// composed path at the FIRST `?` and applies the full, unmodified rule set to
198    /// each side, which exempts exactly that one separator and nothing else. It is safe
199    /// rather than a hole because step 1 already refuses `?` inside a substituted
200    /// value, in literal AND percent-encoded form with a decode-once pass — so a
201    /// `?` surviving into the composed string can only have come from the
202    /// `[[tools]]` `path` an operator authored, not from caller data. Refusing `..`
203    /// from that template catches a traversal bug; refusing `?` from it rejects
204    /// legitimate configuration. Same rule, different work. A SECOND `?`, a
205    /// dangling `?` with an empty query, a `#` fragment marker and anything else
206    /// stay refused, because the query portion faces the same rule.
207    ///
208    /// ## What counts as a placeholder here
209    ///
210    /// Substitution is textual, so a spec-derived operation's mid-segment
211    /// placeholder (`.../range(address='{address}')`) is substituted. A curated
212    /// `[[tools]]` `path`, by contrast, only ever reaches this function in the
213    /// whole-segment shape: a segment containing anything other than exactly one
214    /// `{name}` spanning the whole segment is refused at CONFIG time by
215    /// `crate::config::ServerConfig::validate`, because such a segment produces
216    /// either a parameter name no declaration can match or literal braces on the
217    /// wire.
218    ///
219    /// ## Without the `input-validation` feature
220    ///
221    /// Both checks are `#[cfg(feature = "input-validation")]`-gated and this
222    /// function behaves exactly as it did before Phase 128. `input-validation` is
223    /// in the toolkit's `default` feature set, so an unenforced build is an
224    /// explicit opt-out rather than an accident.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`HttpConnectorError::Backend`]:
229    ///
230    /// - via [`render_scalar`] when a path parameter value is a non-scalar
231    ///   (`Object`/`Array`) — such a value would otherwise be JSON-stringified
232    ///   into the URL (WR-03);
233    /// - when `validate_path_placeholder` refuses a rendered value (the character
234    ///   floor, the always-on length cap, or the declared `pattern`/`max_length`
235    ///   narrowing on top of them);
236    /// - when a declared path parameter has no supplied argument, naming that
237    ///   parameter and nothing else;
238    /// - when `validate_resolved_target` refuses the composed path on either side
239    ///   of an operator-written `?`.
240    ///
241    /// Every one of these messages names the declared parameter or the rule and
242    /// carries no byte of the rejected value and no fragment of the resolved path
243    /// (Pitfall 5, as [`render_scalar`] states it).
244    fn substitute_path(
245        operation: &Operation,
246        args: &serde_json::Map<String, serde_json::Value>,
247    ) -> Result<String, HttpConnectorError> {
248        // Pass 1 — render and CHECK every contribution. Nothing is applied yet.
249        let mut rendered: Vec<(String, String)> = Vec::new();
250        for param in operation.path_parameters() {
251            let Some(value) = args.get(&param.name) else {
252                refuse_missing_path_argument(&param.name)?;
253                continue;
254            };
255            let value_str = render_scalar(&param.name, value)?;
256            check_placeholder_value(param, &value_str)?;
257            rendered.push((format!("{{{}}}", param.name), value_str));
258        }
259        // Pass 2 — apply, then check the COMPOSED result.
260        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    /// Render one query value: a scalar passes through; an array-of-scalars is
269    /// comma-joined (OpenAPI `form`/`explode:false` style); an object or an array
270    /// with any non-scalar member is rejected (each member is checked through
271    /// [`render_scalar`]).
272    ///
273    /// # Errors
274    ///
275    /// Returns [`HttpConnectorError::Backend`] naming `param_name` when `value`
276    /// (or any array member) is a non-scalar.
277    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            // Comma-separate array members (OpenAPI `form`/`simple` style). A
283            // nested non-scalar member is rejected by render_scalar.
284            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    /// Build the query map from query-located params present in `args`.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`HttpConnectorError::Backend`] naming the offending parameter when
302    /// a query value is an object, or an array containing a non-scalar member
303    /// (WR-03). A scalar or an array-of-scalars behaves exactly as before.
304    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(&param.name) {
311                query.insert(
312                    param.name.clone(),
313                    Self::render_query_value(&param.name, value)?,
314                );
315            }
316        }
317        Ok(query)
318    }
319
320    /// Build the header map from header-located params present in `args`.
321    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(&param.name) {
328                let name = HeaderName::try_from(param.name.as_str()).map_err(|_| {
329                    HttpConnectorError::InvalidHeader("invalid header name".to_string())
330                })?;
331                // Reject a non-scalar header value (naming the param) before it
332                // can be JSON-stringified into the header (WR-03).
333                let rendered = render_scalar(&param.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    /// Collect the request body: every arg that is not routed somewhere else.
344    ///
345    /// An arg belongs in the payload when it is either a declared
346    /// [`ParameterLocation::Body`] parameter — which is what `build_operation`
347    /// assigns to a `POST`/`PUT`/`PATCH` tool's non-path parameters — or an
348    /// UNDECLARED key, which only reaches here when the tool's schema was built
349    /// with `[server.validation] additional_properties = true`. A `Path`, `Query`
350    /// or `Header`-located parameter is withheld, because it already travels in the
351    /// URL or the headers.
352    ///
353    /// # Phase 128 CR-02
354    ///
355    /// The `Body`-located half is the fix. Before it, `build_operation` marked every
356    /// non-path declared parameter `Query`, so the `declared` exclusion below
357    /// withheld ALL of them and the only route to a payload was an undeclared key —
358    /// which `additionalProperties: false`, enforced for the first time in this
359    /// release, refuses. A curated mutating tool could accept a payload and send
360    /// none of it.
361    ///
362    /// # The reserved `body` key (WR-10)
363    ///
364    /// `"body"` is a reserved argument name: when present it becomes the ENTIRE
365    /// payload verbatim rather than one field of it. It is no longer also appended
366    /// to the query string, because on a body-bearing method a declared parameter
367    /// named `body` is now `Body`-located and `build_query` only reads
368    /// `Query`-located ones.
369    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    /// Send the request, retrying on 5xx / connect / timeout with exponential backoff.
413    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                    // Redacted: never forward the reqwest error Display (echoes URL).
444                    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
456/// Render a JSON scalar for use in a path / query / header position, REJECTING
457/// non-scalar values (WR-03 / GAP 4).
458///
459/// # The decided rule (uniform)
460///
461/// The `http::schema::Parameter` model carries NO OpenAPI `style` / `explode` /
462/// `type` hint, so there is no per-parameter serialization directive to honor;
463/// the rule must therefore be uniform across every path / query / header
464/// position:
465///
466/// - A scalar (`String`, `Number`, `Bool`, `Null`) renders to a bare string
467///   (`Null` → `"null"`, matching the `code_mode::HttpCodeExecutor::scalar_str`
468///   counterpart so the two HTTP surfaces stay consistent).
469/// - A query parameter that is an **array of scalars** is comma-joined by the
470///   caller ([`build_query`]); each member is rendered through this function so a
471///   nested non-scalar member is rejected.
472/// - An `Object`, an array containing any non-scalar member, or ANY non-scalar
473///   in path / header position is **rejected** with a typed error that names the
474///   parameter — it is NEVER JSON-stringified into the URL/header (which would
475///   leak literal `{`/`[`/`"` that then percent-encode into a silently-wrong
476///   request).
477///
478/// # Errors
479///
480/// Returns [`HttpConnectorError::Backend`] naming `param_name` when `value` is a
481/// non-scalar (`Object` or `Array`). Per the module's redaction discipline
482/// (Pitfall 5) the message names the PARAMETER ONLY — never the value.
483fn 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        // Object OR Array: non-scalar in a path/query/header position is rejected
493        // rather than silently JSON-stringified. Name the param ONLY (Pitfall 5).
494        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// -----------------------------------------------------------------------------
504// Phase 128 D4 — the three checks `substitute_path` calls, each as a `cfg` PAIR.
505//
506// A written `cfg(not(...))` half rather than `#[cfg]` inside the function body:
507// the enforced and unenforced shapes are then both visible at a glance, and
508// `substitute_path` reads as one flow in either build. There is NO second copy of
509// any rule here — each enforced half calls the ONE core implementation in
510// `pmcp::server::schema_validation`, which is what keeps the curated surface and
511// the Code Mode surface on a single denylist (Q2).
512// -----------------------------------------------------------------------------
513
514/// Map a core placeholder refusal into this connector's error type.
515///
516/// `PlaceholderRefusal`'s own `Display` is value-free — it names the declared
517/// parameter and the declared expectation — so forwarding it verbatim keeps the two
518/// HTTP surfaces indistinguishable to a caller: they differ only in error TYPE
519/// (`HttpConnectorError::Backend` here, `ExecutionError::RuntimeError` on the Code
520/// Mode side), never in wording.
521#[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/// Check ONE rendered path-parameter value against the D4 floor, the always-on
529/// cap, and this parameter's declared narrowing.
530///
531/// # Errors
532///
533/// [`HttpConnectorError::Backend`] carrying the core refusal, which names the
534/// parameter and never the value.
535#[cfg(feature = "input-validation")]
536fn check_placeholder_value(param: &Parameter, value_str: &str) -> Result<(), HttpConnectorError> {
537    pmcp::server::schema_validation::validate_path_placeholder(
538        &param.name,
539        value_str,
540        &param.placeholder_rules(),
541    )
542    .map_err(|refusal| refusal_to_backend_error(&refusal))
543}
544
545/// The `input-validation`-off half: the pre-Phase-128 behaviour, which applied no
546/// character check to a path-parameter value at all.
547#[cfg(not(feature = "input-validation"))]
548fn check_placeholder_value(_param: &Parameter, _value_str: &str) -> Result<(), HttpConnectorError> {
549    Ok(())
550}
551
552/// Check the COMPOSED path, exempting one operator-written `?`.
553///
554/// The narrowing itself lives in core as
555/// `pmcp::server::schema_validation::validate_resolved_target` — a SIBLING of
556/// `validate_resolved_path`, which keeps the strict `?`-anywhere rule for its
557/// other callers. It lives there rather than here because the Code Mode surface
558/// (`pmcp_code_mode::ResolvedPath::from_checked`) needs the identical rule, and
559/// the two previously held a copy each: the one part of the floor that could
560/// drift between them. Both sides of the first `?` face the full, unmodified rule
561/// set, so a second `?`, a fragment marker, traversal on either side, an over-cap
562/// query and an empty query portion all stay refused for free. See
563/// `HttpClient::substitute_path` for why exempting exactly that one byte is safe.
564///
565/// # Errors
566///
567/// [`HttpConnectorError::Backend`] carrying the core refusal, which names the rule
568/// and never any byte of the path.
569#[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/// The `input-validation`-off half: no composed check, so a residual `{name}` from
576/// a path parameter with no supplied argument reaches the outbound URL exactly as
577/// it did before Phase 128.
578#[cfg(not(feature = "input-validation"))]
579fn check_composed_path(_path: &str) -> Result<(), HttpConnectorError> {
580    Ok(())
581}
582
583/// Refuse a declared path parameter that has no supplied argument.
584///
585/// Its own refusal rather than leaning on the composed check's residual-brace rule,
586/// for one reason: this is the only route that can name the PARAMETER. The composed
587/// refusal is param-agnostic by construction (`param: "path segment"`), because a
588/// composition belongs to no single parameter — so it would tell a caller that
589/// something in the path is wrong without saying which declaration to supply. This
590/// is a presence check, not a second copy of the character denylist.
591///
592/// D1's `required` keyword does not make this unreachable: it refuses only when the
593/// `[[tools.parameters]]` declaration says `required = true`, while
594/// `tools.rs::build_operation` marks every template path parameter required
595/// INDEPENDENTLY of any declaration — so the two can disagree, and measurably did.
596///
597/// # Errors
598///
599/// Always [`HttpConnectorError::Backend`], naming `param_name` and nothing else —
600/// never the template and never the partially-substituted path.
601#[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/// The `input-validation`-off half: the parameter is skipped, which is the
609/// pre-Phase-128 behaviour (the literal placeholder text stays in the path).
610#[cfg(not(feature = "input-validation"))]
611fn refuse_missing_path_argument(_param_name: &str) -> Result<(), HttpConnectorError> {
612    Ok(())
613}
614
615impl HttpClient {
616    /// Consult the registered E1 policy, if any, for one already-assembled
617    /// outbound request (Phase 128).
618    ///
619    /// Its own helper so the `execute` body keeps ONE added statement and stays
620    /// well under the cognitive-complexity 25 gate. Returns `Ok(())` immediately
621    /// when no policy is registered, which is the no-allocation empty case.
622    ///
623    /// The snapshot the policy sees is built HERE, AFTER that early return, so the
624    /// `policy == None` test exists exactly once. Building it at the call site
625    /// meant either paying for it on every request of every server — `query`'s keys
626    /// and values cloned, sorted, and the method uppercased, for the
627    /// overwhelmingly common no-policy case — or guarding the call site with a
628    /// SECOND copy of the same emptiness test, which then has to be kept in step
629    /// with this one. `query` is snapshotted SORTED so a policy sees a
630    /// deterministic order; the source is a `HashMap`.
631    ///
632    /// # Errors
633    ///
634    /// [`HttpConnectorError::PolicyRefused`] carrying the policy's OWN message.
635    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    /// The shared `execute` body, carrying the MCP tool name (Phase 128 E1).
658    ///
659    /// [`HttpConnector::execute`] passes `""` (no tool to name) and
660    /// [`HttpConnector::execute_for_tool`] passes the synthesized tool's own
661    /// name, so there is ONE request path rather than two that can drift.
662    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        // Build URL via the shared join_url helper (explicit concat, never the
672        // url-crate RFC-3986 path merge) — preserves a stage prefix like /v1
673        // (Pitfall 2 / T-90-01-05).
674        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        // Phase 128 E1 / D-12 — the outbound-policy hook, and its position is
684        // load-bearing rather than incidental.
685        //
686        // It sits AFTER `join_url` because the policy must see the URL as it will
687        // be sent (resolved placeholders, stage prefix applied) rather than the
688        // `[[tools]]` template. It sits BEFORE `self.auth.apply` because that call
689        // is the first moment a credential exists in `headers` / `query`, and
690        // D-12's guarantee is that third-party policy code cannot observe one. A
691        // refusal therefore returns before auth AND before the send: nothing is
692        // authenticated and nothing leaves.
693        //
694        // The snapshot `query` becomes (sorted, so a policy sees a deterministic
695        // order) is built inside `run_request_policy`, AFTER its `policy == None`
696        // early return — so the no-policy case, which is the overwhelmingly common
697        // one, pays nothing here and the emptiness test is not duplicated at this
698        // call site. The snapshot carries no auth pair for the reason above: an
699        // API-key-in-query credential is contributed by the call below.
700        self.run_request_policy(
701            tool,
702            &operation.method,
703            &joined,
704            &query,
705            request_body.as_ref(),
706        )
707        .await?;
708
709        // Single-call tools have no per-request passthrough token (Plan 04/06 carry
710        // it through HttpCodeExecutor); pass None here.
711        self.auth.apply(&mut headers, &mut query, None).await?;
712
713        // Why: reqwest 0.13 gates `RequestBuilder::query` behind a `query` feature
714        // (verified in reqwest-0.13.2 request.rs:`#[cfg(feature = "query")]`). The
715        // toolkit deliberately does NOT enable that feature (Pitfall 4 / lean
716        // build), so query params are appended to the URL via `url`'s built-in,
717        // percent-encoding query-pair serializer instead.
718        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    /// A clone of this client carrying `policy`.
753    ///
754    /// Backs the [`HttpConnector::governed`] override — the route a policy takes
755    /// to a connector that has ALREADY been erased to `Arc<dyn HttpConnector>` by
756    /// the time the hooks value is in scope, which is exactly the situation
757    /// `pmcp-openapi-server`'s `build_server` is in.
758    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        // No tool to name: a caller driving the connector directly rather than
777        // through a synthesized handler.
778        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// -----------------------------------------------------------------------------
807// Phase 128 D4 test support + the two sibling test modules.
808//
809// `mod placeholder_floor` and `mod query_separator` are SIBLINGS of `mod tests`
810// at the `client` module level, not children of it. Both are still selected by
811// this plan's `--lib http::client` verify filter (a module prefix), and the names
812// mirror `pmcp-code-mode`'s `executor::query_separator` so the two surfaces'
813// boundary suites read alike. Being children of `client` is what gives them
814// access to the private `HttpClient::substitute_path`.
815// -----------------------------------------------------------------------------
816
817/// Fixtures shared by the two Phase 128 D4 test modules.
818#[cfg(all(test, feature = "input-validation"))]
819mod d4_support {
820    use super::{HttpClient, HttpConnectorError, Operation};
821    use crate::http::{Parameter, ParameterLocation};
822
823    /// A `GET` operation on `path` carrying `parameters`.
824    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    /// A required path parameter with no declared narrowing (floor + cap only).
835    pub fn path_param(name: &str) -> Parameter {
836        Parameter::new(name, ParameterLocation::Path, true)
837    }
838
839    /// Substitute `pairs` into `path`, treating every named key as a path
840    /// parameter with no declared narrowing.
841    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    /// Substitute a single string `value` for `{name}` in `path`.
854    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/// The curated surface's D4 floor: every rendered placeholder value faces
867/// `validate_path_placeholder` before ANY substitution is applied, and the
868/// composed result faces `validate_resolved_target` before dispatch.
869#[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    /// Assert a refusal names the parameter and carries no byte of the value and
877    /// no fragment of the resolved path.
878    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    /// CR-01 row: a query separator inside a placeholder value.
896    #[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    /// CR-01 row: parent-directory traversal inside a placeholder value.
904    #[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    /// Percent-encoded traversal in UPPER-case hex — the decode-once pass is what
912    /// has to catch it, not an enumerated denylist of spellings.
913    #[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    /// Adjacency edge: a value that is EXACTLY a denied character. The check is a
920    /// character rule, not a substring-position heuristic.
921    #[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    /// Encoding edge: a literal NUL byte, and separately its percent-encoded form.
928    #[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    /// An empty value is refused — it would otherwise compose an empty segment.
935    #[test]
936    fn placeholder_floor_refuses_an_empty_value() {
937        assert!(substitute_one("/x/{v}", "v", "").is_err());
938    }
939
940    /// The always-on cap holds at exactly `PLACEHOLDER_MAX_LENGTH`.
941    #[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    /// A conforming value matching its DECLARED pattern is accepted and the path
953    /// is fully substituted.
954    #[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    /// D-10: the DECLARED pattern narrows on top of the floor.
971    #[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    /// Empty edge: a template with NO placeholders is returned unchanged and gains
988    /// zero new refusals. The composed check still runs, and passes.
989    #[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    /// Phase 128 CR-01 — a root operation is callable on the curated surface.
1000    ///
1001    /// `substitute_path` calls `check_composed_path` UNCONDITIONALLY, so before the
1002    /// core fix a spec declaring `paths: { "/": { get: … } }` — a health or index
1003    /// endpoint — failed every `tools/call` with `param 'path segment' must not be
1004    /// empty`. This is the caller-side row for the core exemption; the trailing-slash
1005    /// refusal it must not re-open is asserted immediately below.
1006    #[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        // An empty tail placeholder composes to a trailing `/`, which stays refused.
1013        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    /// A refusal on the SECOND of two placeholders aborts with no
1026    /// partially-substituted path in existence — the first value is rendered and
1027    /// checked but nothing is applied until every value has passed.
1028    #[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    /// A path parameter ABSENT from `args` is refused, naming the parameter only —
1047    /// rather than leaving the literal `{name}` in the outbound URL.
1048    #[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    /// The COMPOSED check is the only mechanism that can see this: a template
1068    /// literal prefix plus a value that each pass on their own compose a segment
1069    /// over the cap. Spec-derived operations carry mid-segment placeholders, so
1070    /// this shape is reachable without any curated config.
1071    #[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    /// The composed check also refuses a residual `{`/`}` arriving from a template
1080    /// the curated config parser would not recognize — the spec-derived route that
1081    /// config validation cannot reach.
1082    #[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    /// A template literal carrying traversal is refused by the composed check
1094    /// alone: no per-value check ever sees a literal, so this row isolates the
1095    /// composed mechanism.
1096    #[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/// The `?` narrowing inherited from plan 05, mirrored on the CURATED surface and
1105/// pinned in BOTH directions.
1106///
1107/// A `[[tools]]` `path` is operator-authored configuration, exactly as a Code Mode
1108/// script's literal path text is operator-authored script text — so the same
1109/// asymmetry applies: refusing `..` from it catches a traversal bug, while refusing
1110/// `?` from it rejects legitimate authoring. `substitute_path` therefore splits the
1111/// composed path at the FIRST `?` and applies the full, unmodified rule set to each
1112/// side. Nine of the twelve rows below assert what did NOT change, because a
1113/// narrowing pinned only by accept-rows is indistinguishable from a deleted check.
1114#[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    /// Substitute nothing — a placeholder-free template, checked as composed.
1121    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    // ---- ACCEPTED: the separator an operator wrote into the config ----
1135
1136    /// The row the narrowing exists for: a `?` in a curated `[[tools]]` path.
1137    #[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    /// The change-request's own curated shape: an author query alongside a floored
1146    /// placeholder. Both mechanisms coexist on one path.
1147    #[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    /// The Graph-style `$select` projection shape in-tree consumers author.
1157    #[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    // ---- STILL REFUSED: everything the split does not relax ----
1167
1168    #[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    /// THE row that proves the narrowing is not a hole: the template carries an
1215    /// author-written `?` (legal) AND a placeholder value carries an injected one
1216    /// (still refused by the per-value floor, which is the mechanism the narrowing
1217    /// relies on for its safety argument).
1218    #[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    /// The second value route: an injected TRAVERSAL alongside an author query.
1231    #[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        // Regression: an API-Gateway stage prefix /v1 survives via join_url.
1259        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    // -- WR-03 / GAP 4: fallible scalar renderer (reject non-scalar params) -----
1296
1297    /// An array-of-scalars query param comma-joins (unchanged OpenAPI
1298    /// `form`/`explode:false` behavior).
1299    #[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    /// A scalar query param renders bare (unchanged).
1307    #[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    /// `render_scalar` renders Null as the bare string `"null"` (matches the
1320    /// code_mode `scalar_str` counterpart).
1321    #[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    /// An OBJECT path param is rejected, naming the param; the error never echoes
1330    /// the value and never produces a JSON-stringified `{`/`[`/`"`.
1331    #[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        // Pitfall 5: never echo the value.
1350        assert!(
1351            !rendered.contains("nested"),
1352            "must not echo the value: {rendered}"
1353        );
1354    }
1355
1356    /// An OBJECT query param is rejected, naming the param.
1357    #[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    /// An array CONTAINING a non-scalar member is rejected (the scalar comma-join
1368    /// is preserved only for scalar-only arrays).
1369    #[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    /// A non-scalar HEADER param is rejected, naming the param.
1378    #[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        // An ARRAY in header position is non-scalar (arrays comma-join ONLY in
1388        // query position) and is rejected.
1389        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        // An OBJECT in header position is likewise rejected.
1395        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        // A scalar header value still succeeds.
1401        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        // Lazy: a bad URL fails synchronously without any network (CF-2).
1410        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    /// Phase 128 CR-02 — a DECLARED `Body`-located parameter reaches the JSON
1479    /// payload, and does NOT also reach the query string.
1480    ///
1481    /// The row above proves only the UNDECLARED route (`parameters: vec![]`), which
1482    /// is the route `additionalProperties: false` closed. This one declares the
1483    /// parameters, which is the shape `build_operation` actually synthesizes.
1484    #[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            // The value must travel ONCE. Before CR-02 a declared parameter was
1496            // `Query`-located, so it was appended here instead.
1497            .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    /// Phase 128 CR-02 — a `Query`-located parameter on a body-bearing method is
1521    /// still a query parameter and is still withheld from the payload, so the fix
1522    /// is a ROUTING change rather than "everything goes in the body now".
1523    #[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// -----------------------------------------------------------------------------
1577// Phase 128 E1 — the curated surface's outbound-policy seam.
1578//
1579// A SIBLING of `mod tests` at the `client` module level, like `placeholder_floor`
1580// and `query_separator` above, so the `--lib http::client` filter selects it while
1581// it keeps access to the private `HttpClient` internals.
1582// -----------------------------------------------------------------------------
1583
1584/// The E1 hook on the curated single-call surface: it must run before auth and
1585/// before the send.
1586#[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    /// An auth provider that RECORDS whether it was invoked, so a refusal test can
1599    /// prove the policy ran BEFORE auth rather than merely before the send.
1600    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    /// One recorded `OutboundRequest`: `(tool, path, query, body)`.
1619    type Seen = Arc<Mutex<Vec<(String, String, Vec<(String, String)>, Option<String>)>>>;
1620
1621    /// Records every request it is shown, then allows or refuses.
1622    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    /// A client over `base_url` carrying `policy`, a recording auth provider, and
1657    /// ZERO retries (so a refusal test cannot be confused by a retry loop).
1658    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        // No mock is mounted: any request that escapes the policy 404s, so a
1694        // false GREEN here cannot masquerade as success.
1695        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}