pmcp_server_toolkit/http/
schema.rs1#![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#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Operation {
49 pub method: String,
51
52 pub path: String,
54
55 #[serde(default)]
57 pub parameters: Vec<Parameter>,
58
59 #[serde(default)]
61 pub has_request_body: bool,
62
63 #[serde(default)]
68 pub base_url: Option<String>,
69}
70
71impl Operation {
72 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Parameter {
103 pub name: String,
105
106 pub location: ParameterLocation,
108
109 #[serde(default)]
111 pub required: bool,
112}
113
114impl Parameter {
115 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum ParameterLocation {
130 Path,
132 Query,
134 Header,
136}
137
138#[derive(Debug, Clone)]
145pub struct OpenApiSchema {
146 spec_text: String,
148
149 operations: Vec<Operation>,
151
152 by_path: HashMap<(String, String), usize>,
154}
155
156impl OpenApiSchema {
157 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 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 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 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 #[must_use]
239 pub fn operations(&self) -> &[Operation] {
240 &self.operations
241 }
242
243 #[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 #[must_use]
254 pub fn spec_text(&self) -> &str {
255 &self.spec_text
256 }
257}
258
259fn 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
287fn 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 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 assert!(matches!(err, HttpConnectorError::Backend(_)));
420 }
421
422 #[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}