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 ¶meter_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}