Skip to main content

pmcp_server_toolkit/http/
schema.rs

1// Net-new code for Phase 90 OAPI-04 / OAPI-02a (D-03 — spec OPTIONAL at runtime).
2// BODY lifted from the pmcp-run OpenAPI reference
3// (`mcp-openapi-server-core::schema::parser`): the openapiv3 parse + serde_yaml
4// fallback + the per-location parameter extraction. SHAPE adapted to the
5// toolkit-owned `Operation` model (the authoritative request type, re-exported
6// from `http::mod`).
7
8//! OpenAPI schema parser — the AUTHORITATIVE home of [`Operation`] (OAPI-04).
9//!
10//! Parses an OpenAPI 3.0/3.1 document (JSON **or** YAML) into an indexed
11//! [`OpenApiSchema`] whose [`Operation`] values the single-call synthesizer
12//! (Plan 03) and the code-mode executor (Plan 04/05) consume. The parser is the
13//! producer of [`Operation`], so the canonical struct lives HERE and is
14//! re-exported from [`crate::http`] (mod.rs) — the type path
15//! `crate::http::Operation` stays stable across every plan (Codex MEDIUM: one
16//! home from day one).
17//!
18//! # Runtime-optional (D-03)
19//!
20//! A spec is OPTIONAL at runtime. [`OpenApiSchema::parse`] is never called unless
21//! the operator supplies a `--spec` document; the binary threads the result as an
22//! `Option<OpenApiSchema>`, and a curated-only server (single-call `[[tools]]`
23//! with explicit `path`/`method`) boots with `None`. Contrast the SQL `--schema`
24//! input which is effectively required. The spec, when present, surfaces two
25//! ways: (a) verbatim spec text for the code-mode `api_schema` resource, and
26//! (b) parsed [`Operation`] values for richer tool synthesis.
27
28// Why: HTTP method names ("GET", "POST") and product nouns ("OpenAPI") are
29// proper nouns / acronyms clippy::doc_markdown otherwise flags for back-ticks.
30#![allow(clippy::doc_markdown)]
31
32use std::collections::HashMap;
33use std::path::Path;
34
35use openapiv3::{OpenAPI, ReferenceOr};
36use serde::{Deserialize, Serialize};
37
38use super::HttpConnectorError;
39
40// Phase 128 D4(b). The RETURN TYPE of `Parameter::placeholder_rules` comes from a
41// feature-gated core module (`pmcp/schema-validation`, forwarded by the toolkit's
42// `input-validation`), and bare `http` does NOT enable it — so the import and the
43// method that names the type are both gated. Gating only the CALL SITE in
44// `http/client.rs` would leave `--no-default-features --features http` broken.
45#[cfg(feature = "input-validation")]
46use pmcp::server::schema_validation::PlaceholderRules;
47
48/// An extracted REST operation backed by an OpenAPI definition.
49///
50/// The AUTHORITATIVE request model the [`crate::http::HttpConnector::execute`]
51/// signature names (re-exported from [`crate::http`]). Plan 01 defined a minimal
52/// shape; Plan 03 makes this the canonical home and populates these values from
53/// an `openapiv3` parse. The shape mirrors the pmcp-run reference
54/// `mcp-openapi-server-core::schema::Operation`.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Operation {
57    /// HTTP method (`GET`, `POST`, ...).
58    pub method: String,
59
60    /// Path template, e.g. `"/users/{id}"`.
61    pub path: String,
62
63    /// Input parameters (path / query / header).
64    #[serde(default)]
65    pub parameters: Vec<Parameter>,
66
67    /// Whether this operation expects a request body.
68    #[serde(default)]
69    pub has_request_body: bool,
70
71    /// Per-tool base-URL override (D-06 / Codex MEDIUM). When `Some`, this
72    /// operation targets the given host instead of the configured `[backend]`
73    /// `base_url`. Carried so the synthesizer NEVER silently drops a per-tool
74    /// `base_url`; `None` means inherit the connector's configured base.
75    #[serde(default)]
76    pub base_url: Option<String>,
77}
78
79impl Operation {
80    /// Path parameters (the `{...}` segments of [`Operation::path`]).
81    #[must_use]
82    pub fn path_parameters(&self) -> Vec<&Parameter> {
83        self.parameters
84            .iter()
85            .filter(|p| p.location == ParameterLocation::Path)
86            .collect()
87    }
88
89    /// Query parameters.
90    #[must_use]
91    pub fn query_parameters(&self) -> Vec<&Parameter> {
92        self.parameters
93            .iter()
94            .filter(|p| p.location == ParameterLocation::Query)
95            .collect()
96    }
97
98    /// Header parameters.
99    #[must_use]
100    pub fn header_parameters(&self) -> Vec<&Parameter> {
101        self.parameters
102            .iter()
103            .filter(|p| p.location == ParameterLocation::Header)
104            .collect()
105    }
106
107    /// Body parameters — the declared parameters that travel as fields of the
108    /// JSON request body (Phase 128 CR-02).
109    ///
110    /// Non-empty only when [`Self::has_request_body`] is set, because
111    /// `crate::tools::build_operation` derives both from the one
112    /// `crate::config::method_carries_request_body` predicate.
113    #[must_use]
114    pub fn body_parameters(&self) -> Vec<&Parameter> {
115        self.parameters
116            .iter()
117            .filter(|p| p.location == ParameterLocation::Body)
118            .collect()
119    }
120}
121
122/// A single OpenAPI operation parameter.
123///
124/// # The three rule fields (Phase 128, D4(b))
125///
126/// [`Self::pattern`], [`Self::max_length`] and [`Self::allow_slash`] carry the
127/// DECLARED narrowing for this parameter to the substitution point in
128/// [`crate::http::HttpClient`], where
129/// `pmcp::server::schema_validation::validate_path_placeholder` reads them through
130/// `Parameter::placeholder_rules`. They are deliberately NOT feature-gated — they are
131/// plain scalars, they are `#[serde(default)]`, and gating them would make this
132/// struct's serialized shape feature-dependent, which is a worse break than gating
133/// the accessor that names a feature-gated type.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct Parameter {
136    /// Parameter name (matches the `{name}` placeholder for path params).
137    pub name: String,
138
139    /// Where the parameter is carried in the request.
140    pub location: ParameterLocation,
141
142    /// Whether the parameter is required.
143    #[serde(default)]
144    pub required: bool,
145
146    /// Declared regular expression the value must match (Phase 128, D4(b)).
147    ///
148    /// NARROWS the unconditional character floor; it never replaces it (D-10).
149    /// Populated from `[[tools.parameters]] pattern` for a curated tool and from a
150    /// spec parameter's own string schema for a spec-driven one.
151    #[serde(default)]
152    pub pattern: Option<String>,
153
154    /// Declared maximum length in Unicode code points (Phase 128, D4(b)).
155    ///
156    /// NARROWS the always-on `pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH`
157    /// cap; a declared value above that constant cannot widen it (D-08).
158    #[serde(default)]
159    pub max_length: Option<u64>,
160
161    /// Permit `/` inside this parameter's substituted value (Phase 128, D-11).
162    ///
163    /// The ONLY legitimate source is the server's own
164    /// `[[tools.parameters]] allow_slash`. A parsed OpenAPI document must never set
165    /// it — see `Parameter::placeholder_rules` and
166    /// `crate::config::ParamDecl::allow_slash` for why.
167    #[serde(default)]
168    pub allow_slash: bool,
169}
170
171impl Parameter {
172    /// Construct a parameter (test/parser convenience).
173    ///
174    /// The Phase 128 rule fields default to "no declared narrowing", so every
175    /// pre-Phase-128 call site keeps compiling unchanged and gets the
176    /// unconditional floor plus the always-on cap. Attach declared rules with
177    /// [`Self::with_rules`].
178    #[must_use]
179    pub fn new(name: impl Into<String>, location: ParameterLocation, required: bool) -> Self {
180        Self {
181            name: name.into(),
182            location,
183            required,
184            pattern: None,
185            max_length: None,
186            allow_slash: false,
187        }
188    }
189
190    /// Attach the declared placeholder rules (Phase 128, D4(b)).
191    ///
192    /// A consuming builder rather than three setters, so the three values that
193    /// travel together are set together.
194    #[must_use]
195    pub fn with_rules(
196        mut self,
197        pattern: Option<String>,
198        max_length: Option<u64>,
199        allow_slash: bool,
200    ) -> Self {
201        self.pattern = pattern;
202        self.max_length = max_length;
203        self.allow_slash = allow_slash;
204        self
205    }
206
207    /// This parameter's declared rules as a
208    /// `pmcp::server::schema_validation::PlaceholderRules` (Phase 128, D4(b)).
209    ///
210    /// # D-10 — the returned rules NARROW, they never replace
211    ///
212    /// `validate_path_placeholder` applies its unconditional character floor and
213    /// its always-on length cap FIRST and consults these rules afterwards, so a
214    /// permissive declared pattern such as `^.*$` cannot switch the injection
215    /// check off. A declared `max_length` above `PLACEHOLDER_MAX_LENGTH` likewise
216    /// cannot widen the constant.
217    ///
218    /// # D-11 — `allow_slash` is config-only
219    ///
220    /// The returned `allow_slash` comes from the server's own
221    /// `[[tools.parameters]]` declaration and from nowhere else. An OpenAPI
222    /// document's reserved-expansion keyword must NEVER be wired to it: a spec is
223    /// third-party content baked into a package, that keyword describes
224    /// percent-encoding latitude in the spec author's serialization rules rather
225    /// than permission to restructure the request target, and it is widely
226    /// copy-pasted without intent. The parser in this module therefore leaves
227    /// `allow_slash` false unconditionally.
228    ///
229    /// # The curated template parser recognizes WHOLE-SEGMENT placeholders only
230    ///
231    /// On the curated single-call surface a placeholder is a whole `/`-delimited
232    /// segment: `crate::config::path_placeholder_names` matches `{name}` spanning
233    /// an entire segment and nothing else. So `/search/{a}{b}` is NOT two
234    /// placeholders (it parses as the single name `a}{b`) and `/prefix-{id}` is not
235    /// recognized as carrying a placeholder at all. Attaching rules to a
236    /// `Parameter` does not change that parse. Both shapes are refused at CONFIG
237    /// time by `crate::config::ServerConfig::validate`, so neither can reach
238    /// runtime from a `[[tools]]` declaration — but a spec-derived operation is not
239    /// config-validated, which is why the composed-path check at the tail of
240    /// substitution is what closes a residual `{`/`}` there.
241    #[cfg(feature = "input-validation")]
242    #[must_use]
243    pub fn placeholder_rules(&self) -> PlaceholderRules<'_> {
244        PlaceholderRules::default()
245            .with_pattern(self.pattern.as_deref())
246            // A declared length that does not fit a `usize` contributes NO
247            // narrowing, which is the safe direction: the always-on module cap
248            // still applies.
249            .with_max_length(self.max_length.and_then(|m| usize::try_from(m).ok()))
250            .allowing_slash(self.allow_slash)
251    }
252}
253
254/// Where an [`Operation`] parameter is carried in the outgoing request.
255///
256/// Every variant has exactly one consumer in
257/// [`crate::http::HttpConnector::execute`], which is what makes this enum a routing
258/// decision rather than a label: [`Self::Path`] is read by `substitute_path`,
259/// [`Self::Query`] by `build_query`, [`Self::Header`] by `build_headers` and
260/// [`Self::Body`] by `build_body`. A parameter carrying a location whose consumer
261/// does not run is a parameter that is silently dropped, which is the defect
262/// Phase 128 CR-02 recorded.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "lowercase")]
265pub enum ParameterLocation {
266    /// Substituted into the path template (`/users/{id}`).
267    Path,
268    /// Appended to the query string.
269    Query,
270    /// Sent as a request header.
271    Header,
272    /// Carried as a field of the JSON request body (Phase 128 CR-02).
273    ///
274    /// Only an operation whose method carries a request body
275    /// (`POST` / `PUT` / `PATCH` — see
276    /// `crate::config::method_carries_request_body`) may place a parameter here;
277    /// `crate::tools::build_operation` reads that one predicate for BOTH this
278    /// assignment and [`Operation::has_request_body`], so a `Body`-located
279    /// parameter on a body-less request is not constructible.
280    ///
281    /// No OpenAPI `in:` value maps here — the spec models a request body as a
282    /// separate `requestBody` object, not as a parameter — so this variant is
283    /// reached only from a curated `[[tools]]` declaration.
284    Body,
285}
286
287/// A parsed OpenAPI document with its [`Operation`] values indexed by
288/// `(path, METHOD)` (OAPI-04 / D-03).
289///
290/// Runtime-OPTIONAL: the binary holds an `Option<OpenApiSchema>` and only parses
291/// when the operator supplies a spec. Retains the raw spec text so the code-mode
292/// `api_schema` resource (D-03 surface (a)) can serve it verbatim.
293#[derive(Debug, Clone)]
294pub struct OpenApiSchema {
295    /// Raw spec text, retained verbatim for the code-mode `api_schema` resource.
296    spec_text: String,
297
298    /// Extracted operations in document order.
299    operations: Vec<Operation>,
300
301    /// `(path, METHOD)` → index into [`Self::operations`].
302    by_path: HashMap<(String, String), usize>,
303}
304
305impl OpenApiSchema {
306    /// Parse an OpenAPI spec from JSON, falling back to YAML.
307    ///
308    /// Tries `serde_json` first (the common machine-emitted shape), then
309    /// `serde_yaml`. The retained spec text is `text` verbatim so the
310    /// `api_schema` resource serves exactly what the operator supplied.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`HttpConnectorError::Backend`] when the text is neither valid
315    /// OpenAPI JSON nor YAML. The error message carries a static reason only —
316    /// it does NOT echo the (admin-authored) spec body (T-90-03-03 discipline).
317    pub fn parse(text: &str) -> Result<Self, HttpConnectorError> {
318        let spec: OpenAPI = serde_json::from_str(text)
319            .or_else(|_| serde_yaml::from_str(text))
320            .map_err(|_| {
321                HttpConnectorError::Backend("OpenAPI spec is not valid JSON or YAML".to_string())
322            })?;
323        Self::from_spec(spec, text.to_string())
324    }
325
326    /// Read and parse an OpenAPI spec from a file path.
327    ///
328    /// # Errors
329    ///
330    /// Returns [`HttpConnectorError::Backend`] when the file cannot be read or
331    /// the contents do not parse. The error message carries a static reason and
332    /// never echoes the file path or spec body (T-90-03-03 discipline).
333    pub fn parse_path(path: &Path) -> Result<Self, HttpConnectorError> {
334        let text = std::fs::read_to_string(path).map_err(|_| {
335            HttpConnectorError::Backend("could not read OpenAPI spec file".to_string())
336        })?;
337        Self::parse(&text)
338    }
339
340    /// Build the indexed schema from an already-parsed `openapiv3` document.
341    fn from_spec(spec: OpenAPI, spec_text: String) -> Result<Self, HttpConnectorError> {
342        let mut operations = Vec::new();
343        let mut by_path = HashMap::new();
344
345        for (path, path_item) in &spec.paths.paths {
346            let item = match path_item {
347                ReferenceOr::Item(item) => item,
348                // $ref path items are skipped (reference resolution not required
349                // for the single-call surface — admin-authored specs inline).
350                ReferenceOr::Reference { .. } => continue,
351            };
352
353            let path_level: Vec<Parameter> = item
354                .parameters
355                .iter()
356                .filter_map(convert_parameter)
357                .collect();
358
359            let methods = [
360                ("GET", &item.get),
361                ("POST", &item.post),
362                ("PUT", &item.put),
363                ("PATCH", &item.patch),
364                ("DELETE", &item.delete),
365                ("HEAD", &item.head),
366                ("OPTIONS", &item.options),
367            ];
368
369            for (method, op_opt) in methods {
370                if let Some(op) = op_opt {
371                    let operation = extract_operation(path, method, op, &path_level);
372                    let idx = operations.len();
373                    by_path.insert((path.clone(), method.to_string()), idx);
374                    operations.push(operation);
375                }
376            }
377        }
378
379        Ok(Self {
380            spec_text,
381            operations,
382            by_path,
383        })
384    }
385
386    /// All extracted operations, in document order.
387    #[must_use]
388    pub fn operations(&self) -> &[Operation] {
389        &self.operations
390    }
391
392    /// Look up an operation by path template and HTTP method (case-insensitive
393    /// on the method).
394    #[must_use]
395    pub fn operation_for(&self, path: &str, method: &str) -> Option<&Operation> {
396        self.by_path
397            .get(&(path.to_string(), method.to_uppercase()))
398            .and_then(|&idx| self.operations.get(idx))
399    }
400
401    /// The raw spec text, for the code-mode `api_schema` resource (D-03 (a)).
402    #[must_use]
403    pub fn spec_text(&self) -> &str {
404        &self.spec_text
405    }
406}
407
408/// Merge path-level and operation-level parameters (operation-level wins on a
409/// name collision) into the toolkit [`Operation`] model.
410fn extract_operation(
411    path: &str,
412    method: &str,
413    op: &openapiv3::Operation,
414    path_level: &[Parameter],
415) -> Operation {
416    let mut parameters: Vec<Parameter> = path_level.to_vec();
417    for param_ref in &op.parameters {
418        if let Some(p) = convert_parameter(param_ref) {
419            if let Some(idx) = parameters.iter().position(|x| x.name == p.name) {
420                parameters[idx] = p;
421            } else {
422                parameters.push(p);
423            }
424        }
425    }
426
427    Operation {
428        method: method.to_string(),
429        path: path.to_string(),
430        parameters,
431        has_request_body: op.request_body.is_some(),
432        base_url: None,
433    }
434}
435
436/// Convert an `openapiv3` parameter into the toolkit [`Parameter`] model.
437///
438/// Cookie parameters and unresolved `$ref` parameters are dropped (the
439/// single-call surface carries path / query / header only); path parameters are
440/// always required.
441fn convert_parameter(param_ref: &ReferenceOr<openapiv3::Parameter>) -> Option<Parameter> {
442    let param = match param_ref {
443        ReferenceOr::Item(p) => p,
444        ReferenceOr::Reference { .. } => return None,
445    };
446    let (location, parameter_data, required) = match param {
447        openapiv3::Parameter::Query { parameter_data, .. } => (
448            ParameterLocation::Query,
449            parameter_data,
450            parameter_data.required,
451        ),
452        // A path parameter is always required, per the OpenAPI spec itself.
453        openapiv3::Parameter::Path { parameter_data, .. } => {
454            (ParameterLocation::Path, parameter_data, true)
455        },
456        openapiv3::Parameter::Header { parameter_data, .. } => (
457            ParameterLocation::Header,
458            parameter_data,
459            parameter_data.required,
460        ),
461        openapiv3::Parameter::Cookie { .. } => return None,
462    };
463    let (pattern, max_length) = declared_string_rules(parameter_data);
464    Some(
465        Parameter::new(parameter_data.name.clone(), location, required)
466            // Phase 128 D-11: the third argument is `false` UNCONDITIONALLY, and it
467            // must stay that way. `allow_slash` lifts the path-separator refusal, so
468            // the only legitimate source for it is the server operator's own
469            // `[[tools.parameters]]` declaration. NO keyword read from a parsed
470            // document may be wired here — including the one that grants latitude
471            // over reserved characters in a serialization, which describes
472            // percent-encoding rather than permission to restructure the request
473            // target and is widely copy-pasted without intent. A spec is
474            // third-party content baked into a package; it may NARROW the floor
475            // (the two values above) and may never widen it. See
476            // `crate::config::ParamDecl::allow_slash`, which names the keyword and
477            // states the rule in full.
478            .with_rules(pattern, max_length, false),
479    )
480}
481
482/// The `pattern` and `max_length` a spec parameter's OWN schema declares
483/// (Phase 128, D4(b)).
484///
485/// # Which schema shapes narrow, and which deliberately do not
486///
487/// ONLY a direct `ReferenceOr::Item` whose `SchemaKind` is
488/// `Type(Type::String(..))` contributes, and it contributes exactly that string
489/// type's own `pattern` and `max_length`. Every other shape yields `(None, None)`:
490/// an unresolved `$ref`, a `oneOf` / `allOf` / `anyOf` composition, a non-string
491/// type, and the `content` form (which is how a parameter with no direct `schema`
492/// is represented).
493///
494/// That is deliberately conservative and it is conservative in the SAFE direction
495/// per D-10: a shape this function does not understand contributes NO narrowing,
496/// which leaves the unconditional character floor and the always-on length cap
497/// fully intact. Guessing at a `$ref` target — this parser resolves no references —
498/// could narrow from the wrong schema, which is strictly worse than narrowing from
499/// nothing.
500fn declared_string_rules(
501    parameter_data: &openapiv3::ParameterData,
502) -> (Option<String>, Option<u64>) {
503    let openapiv3::ParameterSchemaOrContent::Schema(ReferenceOr::Item(schema)) =
504        &parameter_data.format
505    else {
506        return (None, None);
507    };
508    let openapiv3::SchemaKind::Type(openapiv3::Type::String(string_type)) = &schema.schema_kind
509    else {
510        return (None, None);
511    };
512    (
513        string_type.pattern.clone(),
514        // A declared length that does not fit a `u64` contributes nothing, which is
515        // the safe direction: the always-on module cap still applies.
516        string_type.max_length.and_then(|m| u64::try_from(m).ok()),
517    )
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    const SAMPLE_JSON: &str = r#"
525    {
526        "openapi": "3.0.0",
527        "info": { "title": "Test API", "version": "1.0.0" },
528        "paths": {
529            "/users/{id}": {
530                "get": {
531                    "operationId": "getUser",
532                    "parameters": [
533                        { "name": "id", "in": "path", "required": true,
534                          "schema": { "type": "string" } },
535                        { "name": "verbose", "in": "query", "required": false,
536                          "schema": { "type": "boolean" } }
537                    ],
538                    "responses": { "200": { "description": "OK" } }
539                }
540            }
541        }
542    }
543    "#;
544
545    const SAMPLE_YAML: &str = r#"
546openapi: 3.0.0
547info:
548  title: Test API
549  version: 1.0.0
550paths:
551  /users/{id}:
552    get:
553      operationId: getUser
554      parameters:
555        - name: id
556          in: path
557          required: true
558          schema:
559            type: string
560        - name: verbose
561          in: query
562          required: false
563          schema:
564            type: boolean
565      responses:
566        '200':
567          description: OK
568"#;
569
570    fn assert_get_user(schema: &OpenApiSchema) {
571        let op = schema
572            .operation_for("/users/{id}", "GET")
573            .expect("getUser operation present");
574        assert_eq!(op.method, "GET");
575        assert_eq!(op.path, "/users/{id}");
576        let path_params: Vec<&str> = op
577            .path_parameters()
578            .iter()
579            .map(|p| p.name.as_str())
580            .collect();
581        assert_eq!(path_params, vec!["id"]);
582        let query_params: Vec<&str> = op
583            .query_parameters()
584            .iter()
585            .map(|p| p.name.as_str())
586            .collect();
587        assert_eq!(query_params, vec!["verbose"]);
588    }
589
590    #[test]
591    fn schema_parse_json_extracts_operation_and_path_params() {
592        let schema = OpenApiSchema::parse(SAMPLE_JSON).expect("parse JSON");
593        assert_get_user(&schema);
594        assert_eq!(schema.operations().len(), 1);
595    }
596
597    #[test]
598    fn schema_parse_yaml_matches_json() {
599        let schema = OpenApiSchema::parse(SAMPLE_YAML).expect("parse YAML");
600        assert_get_user(&schema);
601    }
602
603    #[test]
604    fn schema_parse_retains_spec_text_for_resource() {
605        let schema = OpenApiSchema::parse(SAMPLE_JSON).expect("parse JSON");
606        // D-03 surface (a): the raw text is served verbatim by api_schema.
607        assert_eq!(schema.spec_text(), SAMPLE_JSON);
608    }
609
610    #[test]
611    fn schema_parse_method_case_insensitive_lookup() {
612        let schema = OpenApiSchema::parse(SAMPLE_JSON).expect("parse JSON");
613        assert!(schema.operation_for("/users/{id}", "get").is_some());
614        assert!(schema.operation_for("/users/{id}", "GET").is_some());
615        assert!(schema.operation_for("/users/{id}", "POST").is_none());
616    }
617
618    #[test]
619    fn schema_parse_malformed_returns_typed_error_no_panic() {
620        let err = OpenApiSchema::parse("this is neither json nor yaml: [unclosed").unwrap_err();
621        // Typed error, no panic.
622        assert!(matches!(err, HttpConnectorError::Backend(_)));
623    }
624
625    // -- Phase 128 D4(b): the declared placeholder rules on `Parameter` ---------
626
627    /// A spec document exercising every `openapiv3` parameter-schema SHAPE the
628    /// narrowing rule has to decide about: a direct string type (narrows), an
629    /// unresolved `$ref` (floor-only), a non-string type (floor-only), a composed
630    /// `oneOf` (floor-only), and the `content` form, which is the representable
631    /// stand-in for "no direct schema" — `ParameterData::format` is a required
632    /// flattened field, so a parameter carrying neither `schema` nor `content` does
633    /// not deserialize at all and cannot be exercised here.
634    // `r##"…"##`, not `r#"…"#`: the document contains the `$ref` value
635    // `"#/components/…`, whose `"#` would otherwise close the raw string.
636    const SHAPES_JSON: &str = r##"
637    {
638        "openapi": "3.0.0",
639        "info": { "title": "Shapes", "version": "1.0.0" },
640        "paths": {
641            "/content/{version}/CUI/{cui}": {
642                "get": {
643                    "operationId": "getContent",
644                    "parameters": [
645                        { "name": "version", "in": "path", "required": true,
646                          "schema": { "type": "string", "pattern": "^[0-9]{4}[A-Z]{2}$",
647                                      "maxLength": 64 } },
648                        { "name": "cui", "in": "path", "required": true,
649                          "schema": { "$ref": "#/components/schemas/Cui" } },
650                        { "name": "verbose", "in": "query", "required": false,
651                          "schema": { "type": "boolean" } },
652                        { "name": "composed", "in": "query", "required": false,
653                          "schema": { "oneOf": [ { "type": "string" },
654                                                 { "type": "integer" } ] } },
655                        { "name": "ctyped", "in": "query", "required": false,
656                          "content": { "application/json": { "schema": { "type": "string",
657                                                                         "maxLength": 8 } } } }
658                    ],
659                    "responses": { "200": { "description": "OK" } }
660                }
661            }
662        },
663        "components": {
664            "schemas": { "Cui": { "type": "string", "maxLength": 12 } }
665        }
666    }
667    "##;
668
669    fn shapes_param(name: &str) -> Parameter {
670        OpenApiSchema::parse(SHAPES_JSON)
671            .expect("parse shapes spec")
672            .operation_for("/content/{version}/CUI/{cui}", "GET")
673            .expect("getContent present")
674            .parameters
675            .iter()
676            .find(|p| p.name == name)
677            .cloned()
678            .unwrap_or_else(|| panic!("parameter {name} present"))
679    }
680
681    /// `Parameter::new` keeps its three-argument signature and defaults every rule
682    /// field to "no declared narrowing".
683    #[test]
684    fn schema_parameter_new_defaults_the_rule_fields() {
685        let p = Parameter::new("id", ParameterLocation::Path, true);
686        assert_eq!(p.name, "id");
687        assert!(p.required);
688        assert_eq!(p.pattern, None);
689        assert_eq!(p.max_length, None);
690        assert!(!p.allow_slash);
691    }
692
693    /// `with_rules` carries all three values, and `placeholder_rules` round-trips
694    /// them into the core type the substitution point reads.
695    #[cfg(feature = "input-validation")]
696    #[test]
697    fn schema_parameter_with_rules_round_trips_into_placeholder_rules() {
698        let p = Parameter::new("region", ParameterLocation::Path, true).with_rules(
699            Some("^C[0-9]+$".to_string()),
700            Some(64),
701            true,
702        );
703        let rules = p.placeholder_rules();
704        assert_eq!(rules.declared_pattern, Some("^C[0-9]+$"));
705        assert_eq!(rules.declared_max_length, Some(64));
706        assert!(rules.allow_slash);
707    }
708
709    /// A direct `ReferenceOr::Item` whose `SchemaKind` is a string type contributes
710    /// BOTH `pattern` and `max_length` — the only shape that narrows.
711    #[test]
712    fn schema_parser_narrows_from_a_direct_string_schema() {
713        let version = shapes_param("version");
714        assert_eq!(version.pattern.as_deref(), Some("^[0-9]{4}[A-Z]{2}$"));
715        assert_eq!(version.max_length, Some(64));
716    }
717
718    /// An unresolved `$ref` parameter schema yields FLOOR-ONLY rules. Deliberately
719    /// conservative: guessing at the `$ref` target could narrow from the wrong
720    /// schema, whereas contributing nothing leaves the unconditional floor and the
721    /// always-on cap intact.
722    #[test]
723    fn schema_parser_yields_floor_only_rules_for_a_ref_schema() {
724        let cui = shapes_param("cui");
725        assert_eq!(cui.pattern, None);
726        assert_eq!(
727            cui.max_length, None,
728            "the `$ref` target's maxLength (12) must NOT be read"
729        );
730    }
731
732    /// A non-string schema type yields floor-only rules.
733    #[test]
734    fn schema_parser_yields_floor_only_rules_for_a_non_string_schema() {
735        let verbose = shapes_param("verbose");
736        assert_eq!(verbose.pattern, None);
737        assert_eq!(verbose.max_length, None);
738    }
739
740    /// A composed (`oneOf`) schema yields floor-only rules.
741    #[test]
742    fn schema_parser_yields_floor_only_rules_for_a_composed_schema() {
743        let composed = shapes_param("composed");
744        assert_eq!(composed.pattern, None);
745        assert_eq!(composed.max_length, None);
746    }
747
748    /// The `content` form — the representable stand-in for a parameter with no
749    /// direct schema — yields floor-only rules.
750    #[test]
751    fn schema_parser_yields_floor_only_rules_for_a_content_form_parameter() {
752        let ctyped = shapes_param("ctyped");
753        assert_eq!(ctyped.pattern, None);
754        assert_eq!(
755            ctyped.max_length, None,
756            "the media-type schema's maxLength (8) must NOT be read"
757        );
758    }
759
760    /// D-11: `allow_slash` is false for EVERY spec-derived parameter, whatever the
761    /// document says. The end-to-end row that drives a document declaring the
762    /// reserved-expansion keyword lives in
763    /// `tests/curated_path_injection.rs`, because this file carries a
764    /// verification gate forbidding that keyword's name outside a comment.
765    #[test]
766    fn schema_parser_leaves_allow_slash_false_for_every_spec_parameter() {
767        let op = OpenApiSchema::parse(SHAPES_JSON)
768            .expect("parse shapes spec")
769            .operation_for("/content/{version}/CUI/{cui}", "GET")
770            .expect("getContent present")
771            .clone();
772        assert_eq!(op.parameters.len(), 5);
773        for p in &op.parameters {
774            assert!(
775                !p.allow_slash,
776                "a spec must never widen the path floor: {}",
777                p.name
778            );
779        }
780    }
781
782    /// T-90-03-03: the parser error MUST NOT echo the spec body (redaction
783    /// discipline kept consistent with the connector, though specs carry no
784    /// creds).
785    #[test]
786    fn test_schema_parse_error_display_no_secret() {
787        let secret_marker = "SUPER_SECRET_TOKEN_abc123";
788        let bad_spec = format!("not-a-spec {secret_marker} [");
789        let err = OpenApiSchema::parse(&bad_spec).unwrap_err();
790        let rendered = format!("{err}");
791        assert!(
792            !rendered.contains(secret_marker),
793            "parser error must not echo the spec body; got {rendered:?}"
794        );
795    }
796}