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/// An extracted REST operation backed by an OpenAPI definition.
41///
42/// The AUTHORITATIVE request model the [`crate::http::HttpConnector::execute`]
43/// signature names (re-exported from [`crate::http`]). Plan 01 defined a minimal
44/// shape; Plan 03 makes this the canonical home and populates these values from
45/// an `openapiv3` parse. The shape mirrors the pmcp-run reference
46/// `mcp-openapi-server-core::schema::Operation`.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Operation {
49    /// HTTP method (`GET`, `POST`, ...).
50    pub method: String,
51
52    /// Path template, e.g. `"/users/{id}"`.
53    pub path: String,
54
55    /// Input parameters (path / query / header).
56    #[serde(default)]
57    pub parameters: Vec<Parameter>,
58
59    /// Whether this operation expects a request body.
60    #[serde(default)]
61    pub has_request_body: bool,
62
63    /// Per-tool base-URL override (D-06 / Codex MEDIUM). When `Some`, this
64    /// operation targets the given host instead of the configured `[backend]`
65    /// `base_url`. Carried so the synthesizer NEVER silently drops a per-tool
66    /// `base_url`; `None` means inherit the connector's configured base.
67    #[serde(default)]
68    pub base_url: Option<String>,
69}
70
71impl Operation {
72    /// Path parameters (the `{...}` segments of [`Operation::path`]).
73    #[must_use]
74    pub fn path_parameters(&self) -> Vec<&Parameter> {
75        self.parameters
76            .iter()
77            .filter(|p| p.location == ParameterLocation::Path)
78            .collect()
79    }
80
81    /// Query parameters.
82    #[must_use]
83    pub fn query_parameters(&self) -> Vec<&Parameter> {
84        self.parameters
85            .iter()
86            .filter(|p| p.location == ParameterLocation::Query)
87            .collect()
88    }
89
90    /// Header parameters.
91    #[must_use]
92    pub fn header_parameters(&self) -> Vec<&Parameter> {
93        self.parameters
94            .iter()
95            .filter(|p| p.location == ParameterLocation::Header)
96            .collect()
97    }
98}
99
100/// A single OpenAPI operation parameter.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Parameter {
103    /// Parameter name (matches the `{name}` placeholder for path params).
104    pub name: String,
105
106    /// Where the parameter is carried in the request.
107    pub location: ParameterLocation,
108
109    /// Whether the parameter is required.
110    #[serde(default)]
111    pub required: bool,
112}
113
114impl Parameter {
115    /// Construct a parameter (test/parser convenience).
116    #[must_use]
117    pub fn new(name: impl Into<String>, location: ParameterLocation, required: bool) -> Self {
118        Self {
119            name: name.into(),
120            location,
121            required,
122        }
123    }
124}
125
126/// Where an [`Operation`] parameter is carried in the outgoing request.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum ParameterLocation {
130    /// Substituted into the path template (`/users/{id}`).
131    Path,
132    /// Appended to the query string.
133    Query,
134    /// Sent as a request header.
135    Header,
136}
137
138/// A parsed OpenAPI document with its [`Operation`] values indexed by
139/// `(path, METHOD)` (OAPI-04 / D-03).
140///
141/// Runtime-OPTIONAL: the binary holds an `Option<OpenApiSchema>` and only parses
142/// when the operator supplies a spec. Retains the raw spec text so the code-mode
143/// `api_schema` resource (D-03 surface (a)) can serve it verbatim.
144#[derive(Debug, Clone)]
145pub struct OpenApiSchema {
146    /// Raw spec text, retained verbatim for the code-mode `api_schema` resource.
147    spec_text: String,
148
149    /// Extracted operations in document order.
150    operations: Vec<Operation>,
151
152    /// `(path, METHOD)` → index into [`Self::operations`].
153    by_path: HashMap<(String, String), usize>,
154}
155
156impl OpenApiSchema {
157    /// Parse an OpenAPI spec from JSON, falling back to YAML.
158    ///
159    /// Tries `serde_json` first (the common machine-emitted shape), then
160    /// `serde_yaml`. The retained spec text is `text` verbatim so the
161    /// `api_schema` resource serves exactly what the operator supplied.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`HttpConnectorError::Backend`] when the text is neither valid
166    /// OpenAPI JSON nor YAML. The error message carries a static reason only —
167    /// it does NOT echo the (admin-authored) spec body (T-90-03-03 discipline).
168    pub fn parse(text: &str) -> Result<Self, HttpConnectorError> {
169        let spec: OpenAPI = serde_json::from_str(text)
170            .or_else(|_| serde_yaml::from_str(text))
171            .map_err(|_| {
172                HttpConnectorError::Backend("OpenAPI spec is not valid JSON or YAML".to_string())
173            })?;
174        Self::from_spec(spec, text.to_string())
175    }
176
177    /// Read and parse an OpenAPI spec from a file path.
178    ///
179    /// # Errors
180    ///
181    /// Returns [`HttpConnectorError::Backend`] when the file cannot be read or
182    /// the contents do not parse. The error message carries a static reason and
183    /// never echoes the file path or spec body (T-90-03-03 discipline).
184    pub fn parse_path(path: &Path) -> Result<Self, HttpConnectorError> {
185        let text = std::fs::read_to_string(path).map_err(|_| {
186            HttpConnectorError::Backend("could not read OpenAPI spec file".to_string())
187        })?;
188        Self::parse(&text)
189    }
190
191    /// Build the indexed schema from an already-parsed `openapiv3` document.
192    fn from_spec(spec: OpenAPI, spec_text: String) -> Result<Self, HttpConnectorError> {
193        let mut operations = Vec::new();
194        let mut by_path = HashMap::new();
195
196        for (path, path_item) in &spec.paths.paths {
197            let item = match path_item {
198                ReferenceOr::Item(item) => item,
199                // $ref path items are skipped (reference resolution not required
200                // for the single-call surface — admin-authored specs inline).
201                ReferenceOr::Reference { .. } => continue,
202            };
203
204            let path_level: Vec<Parameter> = item
205                .parameters
206                .iter()
207                .filter_map(convert_parameter)
208                .collect();
209
210            let methods = [
211                ("GET", &item.get),
212                ("POST", &item.post),
213                ("PUT", &item.put),
214                ("PATCH", &item.patch),
215                ("DELETE", &item.delete),
216                ("HEAD", &item.head),
217                ("OPTIONS", &item.options),
218            ];
219
220            for (method, op_opt) in methods {
221                if let Some(op) = op_opt {
222                    let operation = extract_operation(path, method, op, &path_level);
223                    let idx = operations.len();
224                    by_path.insert((path.clone(), method.to_string()), idx);
225                    operations.push(operation);
226                }
227            }
228        }
229
230        Ok(Self {
231            spec_text,
232            operations,
233            by_path,
234        })
235    }
236
237    /// All extracted operations, in document order.
238    #[must_use]
239    pub fn operations(&self) -> &[Operation] {
240        &self.operations
241    }
242
243    /// Look up an operation by path template and HTTP method (case-insensitive
244    /// on the method).
245    #[must_use]
246    pub fn operation_for(&self, path: &str, method: &str) -> Option<&Operation> {
247        self.by_path
248            .get(&(path.to_string(), method.to_uppercase()))
249            .and_then(|&idx| self.operations.get(idx))
250    }
251
252    /// The raw spec text, for the code-mode `api_schema` resource (D-03 (a)).
253    #[must_use]
254    pub fn spec_text(&self) -> &str {
255        &self.spec_text
256    }
257}
258
259/// Merge path-level and operation-level parameters (operation-level wins on a
260/// name collision) into the toolkit [`Operation`] model.
261fn extract_operation(
262    path: &str,
263    method: &str,
264    op: &openapiv3::Operation,
265    path_level: &[Parameter],
266) -> Operation {
267    let mut parameters: Vec<Parameter> = path_level.to_vec();
268    for param_ref in &op.parameters {
269        if let Some(p) = convert_parameter(param_ref) {
270            if let Some(idx) = parameters.iter().position(|x| x.name == p.name) {
271                parameters[idx] = p;
272            } else {
273                parameters.push(p);
274            }
275        }
276    }
277
278    Operation {
279        method: method.to_string(),
280        path: path.to_string(),
281        parameters,
282        has_request_body: op.request_body.is_some(),
283        base_url: None,
284    }
285}
286
287/// Convert an `openapiv3` parameter into the toolkit [`Parameter`] model.
288///
289/// Cookie parameters and unresolved `$ref` parameters are dropped (the
290/// single-call surface carries path / query / header only); path parameters are
291/// always required.
292fn convert_parameter(param_ref: &ReferenceOr<openapiv3::Parameter>) -> Option<Parameter> {
293    let param = match param_ref {
294        ReferenceOr::Item(p) => p,
295        ReferenceOr::Reference { .. } => return None,
296    };
297    match param {
298        openapiv3::Parameter::Query { parameter_data, .. } => Some(Parameter::new(
299            parameter_data.name.clone(),
300            ParameterLocation::Query,
301            parameter_data.required,
302        )),
303        openapiv3::Parameter::Path { parameter_data, .. } => Some(Parameter::new(
304            parameter_data.name.clone(),
305            ParameterLocation::Path,
306            true,
307        )),
308        openapiv3::Parameter::Header { parameter_data, .. } => Some(Parameter::new(
309            parameter_data.name.clone(),
310            ParameterLocation::Header,
311            parameter_data.required,
312        )),
313        openapiv3::Parameter::Cookie { .. } => None,
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    const SAMPLE_JSON: &str = r#"
322    {
323        "openapi": "3.0.0",
324        "info": { "title": "Test API", "version": "1.0.0" },
325        "paths": {
326            "/users/{id}": {
327                "get": {
328                    "operationId": "getUser",
329                    "parameters": [
330                        { "name": "id", "in": "path", "required": true,
331                          "schema": { "type": "string" } },
332                        { "name": "verbose", "in": "query", "required": false,
333                          "schema": { "type": "boolean" } }
334                    ],
335                    "responses": { "200": { "description": "OK" } }
336                }
337            }
338        }
339    }
340    "#;
341
342    const SAMPLE_YAML: &str = r#"
343openapi: 3.0.0
344info:
345  title: Test API
346  version: 1.0.0
347paths:
348  /users/{id}:
349    get:
350      operationId: getUser
351      parameters:
352        - name: id
353          in: path
354          required: true
355          schema:
356            type: string
357        - name: verbose
358          in: query
359          required: false
360          schema:
361            type: boolean
362      responses:
363        '200':
364          description: OK
365"#;
366
367    fn assert_get_user(schema: &OpenApiSchema) {
368        let op = schema
369            .operation_for("/users/{id}", "GET")
370            .expect("getUser operation present");
371        assert_eq!(op.method, "GET");
372        assert_eq!(op.path, "/users/{id}");
373        let path_params: Vec<&str> = op
374            .path_parameters()
375            .iter()
376            .map(|p| p.name.as_str())
377            .collect();
378        assert_eq!(path_params, vec!["id"]);
379        let query_params: Vec<&str> = op
380            .query_parameters()
381            .iter()
382            .map(|p| p.name.as_str())
383            .collect();
384        assert_eq!(query_params, vec!["verbose"]);
385    }
386
387    #[test]
388    fn schema_parse_json_extracts_operation_and_path_params() {
389        let schema = OpenApiSchema::parse(SAMPLE_JSON).expect("parse JSON");
390        assert_get_user(&schema);
391        assert_eq!(schema.operations().len(), 1);
392    }
393
394    #[test]
395    fn schema_parse_yaml_matches_json() {
396        let schema = OpenApiSchema::parse(SAMPLE_YAML).expect("parse YAML");
397        assert_get_user(&schema);
398    }
399
400    #[test]
401    fn schema_parse_retains_spec_text_for_resource() {
402        let schema = OpenApiSchema::parse(SAMPLE_JSON).expect("parse JSON");
403        // D-03 surface (a): the raw text is served verbatim by api_schema.
404        assert_eq!(schema.spec_text(), SAMPLE_JSON);
405    }
406
407    #[test]
408    fn schema_parse_method_case_insensitive_lookup() {
409        let schema = OpenApiSchema::parse(SAMPLE_JSON).expect("parse JSON");
410        assert!(schema.operation_for("/users/{id}", "get").is_some());
411        assert!(schema.operation_for("/users/{id}", "GET").is_some());
412        assert!(schema.operation_for("/users/{id}", "POST").is_none());
413    }
414
415    #[test]
416    fn schema_parse_malformed_returns_typed_error_no_panic() {
417        let err = OpenApiSchema::parse("this is neither json nor yaml: [unclosed").unwrap_err();
418        // Typed error, no panic.
419        assert!(matches!(err, HttpConnectorError::Backend(_)));
420    }
421
422    /// T-90-03-03: the parser error MUST NOT echo the spec body (redaction
423    /// discipline kept consistent with the connector, though specs carry no
424    /// creds).
425    #[test]
426    fn test_schema_parse_error_display_no_secret() {
427        let secret_marker = "SUPER_SECRET_TOKEN_abc123";
428        let bad_spec = format!("not-a-spec {secret_marker} [");
429        let err = OpenApiSchema::parse(&bad_spec).unwrap_err();
430        let rendered = format!("{err}");
431        assert!(
432            !rendered.contains(secret_marker),
433            "parser error must not echo the spec body; got {rendered:?}"
434        );
435    }
436}