1use std::collections::HashMap;
4use std::path::Path;
5use std::sync::Arc;
6
7use openapiv3::{OpenAPI, Operation, Parameter, ParameterSchemaOrContent, ReferenceOr, Schema};
8use serde_json::{Value, json};
9use url::Url;
10
11use crate::error::{OpenApiError, Result};
12use crate::handler::OpenApiHandler;
13use crate::mapping::{McpType, RouteMapping};
14use crate::parser::{fetch_from_url, load_from_file, parse_spec};
15
16#[derive(Debug, Clone)]
18pub struct ExtractedOperation {
19 pub method: String,
21 pub path: String,
23 pub operation_id: Option<String>,
25 pub summary: Option<String>,
27 pub description: Option<String>,
29 pub parameters: Vec<ExtractedParameter>,
31 pub request_body_schema: Option<Value>,
33 pub mcp_type: McpType,
35}
36
37#[derive(Debug, Clone)]
39pub struct ExtractedParameter {
40 pub name: String,
42 pub location: String,
44 pub required: bool,
46 pub description: Option<String>,
48 pub schema: Option<Value>,
50}
51
52const DEFAULT_TIMEOUT_SECS: u64 = 30;
54
55#[derive(Debug)]
70pub struct OpenApiProvider {
71 spec: OpenAPI,
73 base_url: Option<Url>,
75 mapping: RouteMapping,
77 client: reqwest::Client,
79 operations: Vec<ExtractedOperation>,
81 timeout: std::time::Duration,
83}
84
85impl OpenApiProvider {
86 pub fn from_spec(spec: OpenAPI) -> Self {
88 let mapping = RouteMapping::default_rules();
89 let timeout = std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS);
90 let client = reqwest::Client::builder()
91 .timeout(timeout)
92 .build()
93 .unwrap_or_else(|_| reqwest::Client::new());
94
95 let mut provider = Self {
96 spec,
97 base_url: None,
98 mapping,
99 client,
100 operations: Vec::new(),
101 timeout,
102 };
103 provider.extract_operations();
104 provider
105 }
106
107 pub fn from_string(content: &str) -> Result<Self> {
109 let spec = parse_spec(content)?;
110 Ok(Self::from_spec(spec))
111 }
112
113 pub fn from_file(path: &Path) -> Result<Self> {
115 let spec = load_from_file(path)?;
116 Ok(Self::from_spec(spec))
117 }
118
119 pub async fn from_url(url: &str) -> Result<Self> {
121 let spec = fetch_from_url(url).await?;
122 Ok(Self::from_spec(spec))
123 }
124
125 pub fn with_base_url(mut self, base_url: &str) -> Result<Self> {
127 self.base_url = Some(Url::parse(base_url)?);
128 Ok(self)
129 }
130
131 #[must_use]
133 pub fn with_route_mapping(mut self, mapping: RouteMapping) -> Self {
134 self.mapping = mapping;
135 self.extract_operations(); self
137 }
138
139 #[must_use]
146 pub fn with_client(mut self, client: reqwest::Client) -> Self {
147 self.client = client;
148 self
149 }
150
151 #[must_use]
156 pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
157 self.timeout = timeout;
158 self.client = reqwest::Client::builder()
159 .timeout(timeout)
160 .build()
161 .unwrap_or_else(|_| reqwest::Client::new());
162 self
163 }
164
165 pub fn timeout(&self) -> std::time::Duration {
167 self.timeout
168 }
169
170 pub fn title(&self) -> &str {
172 &self.spec.info.title
173 }
174
175 pub fn version(&self) -> &str {
177 &self.spec.info.version
178 }
179
180 pub fn operations(&self) -> &[ExtractedOperation] {
182 &self.operations
183 }
184
185 pub fn tools(&self) -> impl Iterator<Item = &ExtractedOperation> {
187 self.operations
188 .iter()
189 .filter(|op| op.mcp_type == McpType::Tool)
190 }
191
192 pub fn resources(&self) -> impl Iterator<Item = &ExtractedOperation> {
194 self.operations
195 .iter()
196 .filter(|op| op.mcp_type == McpType::Resource)
197 }
198
199 pub fn into_handler(self) -> OpenApiHandler {
201 OpenApiHandler::new(Arc::new(self))
202 }
203
204 fn extract_operations(&mut self) {
206 self.operations.clear();
207
208 for (path, path_item) in &self.spec.paths.paths {
209 let path_item = match path_item {
210 ReferenceOr::Item(item) => item,
211 ReferenceOr::Reference { .. } => continue, };
213
214 let methods = [
216 ("GET", &path_item.get),
217 ("POST", &path_item.post),
218 ("PUT", &path_item.put),
219 ("DELETE", &path_item.delete),
220 ("PATCH", &path_item.patch),
221 ];
222
223 for (method, operation) in methods {
224 if let Some(op) = operation {
225 let mcp_type = self.mapping.get_mcp_type(method, path);
226 if mcp_type == McpType::Skip {
227 continue;
228 }
229
230 self.operations
231 .push(self.extract_operation(method, path, op, mcp_type));
232 }
233 }
234 }
235 }
236
237 fn extract_operation(
239 &self,
240 method: &str,
241 path: &str,
242 operation: &Operation,
243 mcp_type: McpType,
244 ) -> ExtractedOperation {
245 let parameters = operation
246 .parameters
247 .iter()
248 .filter_map(|p| match p {
249 ReferenceOr::Item(param) => Some(self.extract_parameter(param)),
250 ReferenceOr::Reference { .. } => None,
251 })
252 .collect();
253
254 let request_body_schema = operation.request_body.as_ref().and_then(|rb| match rb {
255 ReferenceOr::Item(body) => body
256 .content
257 .get("application/json")
258 .and_then(|mt| mt.schema.as_ref())
259 .and_then(|s| self.schema_to_json(s)),
260 ReferenceOr::Reference { .. } => None,
261 });
262
263 ExtractedOperation {
264 method: method.to_string(),
265 path: path.to_string(),
266 operation_id: operation.operation_id.clone(),
267 summary: operation.summary.clone(),
268 description: operation.description.clone(),
269 parameters,
270 request_body_schema,
271 mcp_type,
272 }
273 }
274
275 fn extract_parameter(&self, param: &Parameter) -> ExtractedParameter {
277 let (name, location, required, description, schema) = match param {
278 Parameter::Query { parameter_data, .. } => (
279 parameter_data.name.clone(),
280 "query".to_string(),
281 parameter_data.required,
282 parameter_data.description.clone(),
283 self.extract_param_schema(¶meter_data.format),
284 ),
285 Parameter::Header { parameter_data, .. } => (
286 parameter_data.name.clone(),
287 "header".to_string(),
288 parameter_data.required,
289 parameter_data.description.clone(),
290 self.extract_param_schema(¶meter_data.format),
291 ),
292 Parameter::Path { parameter_data, .. } => (
293 parameter_data.name.clone(),
294 "path".to_string(),
295 true, parameter_data.description.clone(),
297 self.extract_param_schema(¶meter_data.format),
298 ),
299 Parameter::Cookie { parameter_data, .. } => (
300 parameter_data.name.clone(),
301 "cookie".to_string(),
302 parameter_data.required,
303 parameter_data.description.clone(),
304 self.extract_param_schema(¶meter_data.format),
305 ),
306 };
307
308 ExtractedParameter {
309 name,
310 location,
311 required,
312 description,
313 schema,
314 }
315 }
316
317 fn extract_param_schema(&self, format: &ParameterSchemaOrContent) -> Option<Value> {
319 match format {
320 ParameterSchemaOrContent::Schema(schema) => self.schema_to_json(schema),
321 ParameterSchemaOrContent::Content(_) => None,
322 }
323 }
324
325 fn schema_to_json(&self, schema: &ReferenceOr<Schema>) -> Option<Value> {
334 let initial = match schema {
335 ReferenceOr::Item(s) => serde_json::to_value(s).ok()?,
336 ReferenceOr::Reference { reference } => {
337 json!({ "$ref": reference })
338 }
339 };
340 let mut visited = std::collections::HashSet::new();
341 Some(self.resolve_refs(initial, &mut visited))
342 }
343
344 fn resolve_refs(&self, value: Value, visited: &mut std::collections::HashSet<String>) -> Value {
351 match value {
352 Value::Object(mut map) => {
353 if let Some(Value::String(reference)) = map.get("$ref").cloned()
354 && map.len() == 1
355 {
356 if !visited.insert(reference.clone()) {
357 map.insert("$ref".to_string(), Value::String(reference));
358 return Value::Object(map);
359 }
360 let expanded = self.lookup_ref(&reference).map(|target| {
361 let target_json = serde_json::to_value(target).unwrap_or(Value::Null);
362 self.resolve_refs(target_json, visited)
363 });
364 visited.remove(&reference);
365 return expanded.unwrap_or(Value::Object({
366 let mut fallback = serde_json::Map::new();
367 fallback.insert("$ref".to_string(), Value::String(reference));
368 fallback
369 }));
370 }
371 let resolved = map
372 .into_iter()
373 .map(|(k, v)| (k, self.resolve_refs(v, visited)))
374 .collect();
375 Value::Object(resolved)
376 }
377 Value::Array(items) => Value::Array(
378 items
379 .into_iter()
380 .map(|v| self.resolve_refs(v, visited))
381 .collect(),
382 ),
383 other => other,
384 }
385 }
386
387 fn lookup_ref(&self, reference: &str) -> Option<&Schema> {
389 const PREFIX: &str = "#/components/schemas/";
390 let name = reference.strip_prefix(PREFIX)?;
391 let components = self.spec.components.as_ref()?;
392 let entry = components.schemas.get(name)?;
393 match entry {
394 ReferenceOr::Item(schema) => Some(schema),
395 ReferenceOr::Reference { reference } => {
396 let nested_name = reference.strip_prefix(PREFIX)?;
398 match components.schemas.get(nested_name)? {
399 ReferenceOr::Item(schema) => Some(schema),
400 ReferenceOr::Reference { .. } => None,
401 }
402 }
403 }
404 }
405
406 pub(crate) fn build_url(
408 &self,
409 operation: &ExtractedOperation,
410 args: &HashMap<String, Value>,
411 ) -> Result<Url> {
412 let base = self.base_url.as_ref().ok_or(OpenApiError::NoBaseUrl)?;
413
414 let mut path = operation.path.clone();
416 for param in &operation.parameters {
417 if param.location == "path" {
418 if let Some(value) = args.get(¶m.name) {
419 let value_str = match value {
420 Value::String(s) => s.clone(),
421 _ => value.to_string(),
422 };
423 path = path.replace(&format!("{{{}}}", param.name), &value_str);
424 } else if param.required {
425 return Err(OpenApiError::MissingParameter(param.name.clone()));
426 }
427 }
428 }
429
430 let mut url = base.join(&path)?;
431
432 let mut query_params: Vec<(String, String)> = Vec::new();
434 for param in &operation.parameters {
435 if param.location == "query" {
436 if let Some(value) = args.get(¶m.name) {
437 let value_str = match value {
438 Value::String(s) => s.clone(),
439 Value::Bool(b) => b.to_string(),
440 Value::Number(n) => n.to_string(),
441 _ => value.to_string(),
442 };
443 query_params.push((param.name.clone(), value_str));
444 } else if param.required {
445 return Err(OpenApiError::MissingParameter(param.name.clone()));
446 }
447 }
448 }
449
450 if !query_params.is_empty() {
452 let mut query_pairs = url.query_pairs_mut();
453 for (key, value) in query_params {
454 query_pairs.append_pair(&key, &value);
455 }
456 }
457
458 Ok(url)
459 }
460
461 pub(crate) fn client(&self) -> &reqwest::Client {
463 &self.client
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 const TEST_SPEC: &str = r#"{
472 "openapi": "3.0.0",
473 "info": {
474 "title": "Test API",
475 "version": "1.0.0"
476 },
477 "paths": {
478 "/users": {
479 "get": {
480 "operationId": "listUsers",
481 "summary": "List all users",
482 "responses": { "200": { "description": "Success" } }
483 },
484 "post": {
485 "operationId": "createUser",
486 "summary": "Create a user",
487 "responses": { "201": { "description": "Created" } }
488 }
489 },
490 "/users/{id}": {
491 "get": {
492 "operationId": "getUser",
493 "summary": "Get a user by ID",
494 "parameters": [
495 {
496 "name": "id",
497 "in": "path",
498 "required": true,
499 "schema": { "type": "string" }
500 }
501 ],
502 "responses": { "200": { "description": "Success" } }
503 },
504 "delete": {
505 "operationId": "deleteUser",
506 "summary": "Delete a user",
507 "parameters": [
508 {
509 "name": "id",
510 "in": "path",
511 "required": true,
512 "schema": { "type": "string" }
513 }
514 ],
515 "responses": { "204": { "description": "Deleted" } }
516 }
517 }
518 }
519 }"#;
520
521 #[test]
522 fn test_provider_from_string() {
523 let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
524
525 assert_eq!(provider.title(), "Test API");
526 assert_eq!(provider.version(), "1.0.0");
527 }
528
529 #[test]
530 fn test_operation_extraction() {
531 let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
532
533 assert_eq!(provider.operations().len(), 4);
534
535 let list_users = provider
537 .operations()
538 .iter()
539 .find(|op| op.operation_id.as_deref() == Some("listUsers"))
540 .unwrap();
541 assert_eq!(list_users.mcp_type, McpType::Resource);
542 assert_eq!(list_users.method, "GET");
543
544 let create_user = provider
546 .operations()
547 .iter()
548 .find(|op| op.operation_id.as_deref() == Some("createUser"))
549 .unwrap();
550 assert_eq!(create_user.mcp_type, McpType::Tool);
551 assert_eq!(create_user.method, "POST");
552 }
553
554 #[test]
555 fn test_tools_and_resources() {
556 let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
557
558 let tools: Vec<_> = provider.tools().collect();
559 let resources: Vec<_> = provider.resources().collect();
560
561 assert_eq!(resources.len(), 2);
563 assert_eq!(tools.len(), 2);
565 }
566
567 #[test]
568 fn test_build_url_with_path_params() {
569 let provider = OpenApiProvider::from_string(TEST_SPEC)
570 .unwrap()
571 .with_base_url("https://api.example.com")
572 .unwrap();
573
574 let get_user = provider
575 .operations()
576 .iter()
577 .find(|op| op.operation_id.as_deref() == Some("getUser"))
578 .unwrap();
579
580 let mut args = HashMap::new();
581 args.insert("id".to_string(), json!("123"));
582
583 let url = provider.build_url(get_user, &args).unwrap();
584 assert_eq!(url.as_str(), "https://api.example.com/users/123");
585 }
586
587 #[test]
588 fn test_ref_resolution_inlines_components() {
589 const REF_SPEC: &str = r##"{
590 "openapi": "3.0.0",
591 "info": { "title": "T", "version": "1.0.0" },
592 "paths": {
593 "/pets": {
594 "post": {
595 "operationId": "createPet",
596 "requestBody": {
597 "content": {
598 "application/json": {
599 "schema": { "$ref": "#/components/schemas/Pet" }
600 }
601 }
602 },
603 "responses": { "201": { "description": "ok" } }
604 }
605 }
606 },
607 "components": {
608 "schemas": {
609 "Pet": {
610 "type": "object",
611 "properties": {
612 "name": { "type": "string" },
613 "owner": { "$ref": "#/components/schemas/Owner" }
614 }
615 },
616 "Owner": {
617 "type": "object",
618 "properties": {
619 "email": { "type": "string" }
620 }
621 }
622 }
623 }
624 }"##;
625
626 let provider = OpenApiProvider::from_string(REF_SPEC).unwrap();
627 let op = provider
628 .operations()
629 .iter()
630 .find(|o| o.operation_id.as_deref() == Some("createPet"))
631 .expect("createPet operation");
632 let body = op.request_body_schema.as_ref().expect("body schema");
633 let props = body.get("properties").expect("properties");
634 let owner = props.get("owner").expect("owner property");
635 assert!(
637 owner.get("$ref").is_none(),
638 "owner $ref was not inlined: {owner}"
639 );
640 let owner_props = owner.get("properties").expect("owner inlined properties");
641 assert!(owner_props.get("email").is_some());
642 }
643
644 #[test]
645 fn test_ref_resolution_handles_cycles() {
646 const CYCLE_SPEC: &str = r##"{
647 "openapi": "3.0.0",
648 "info": { "title": "T", "version": "1.0.0" },
649 "paths": {
650 "/n": {
651 "post": {
652 "operationId": "makeNode",
653 "requestBody": {
654 "content": {
655 "application/json": {
656 "schema": { "$ref": "#/components/schemas/Node" }
657 }
658 }
659 },
660 "responses": { "201": { "description": "ok" } }
661 }
662 }
663 },
664 "components": {
665 "schemas": {
666 "Node": {
667 "type": "object",
668 "properties": {
669 "next": { "$ref": "#/components/schemas/Node" }
670 }
671 }
672 }
673 }
674 }"##;
675
676 let provider = OpenApiProvider::from_string(CYCLE_SPEC).unwrap();
677 let op = provider
678 .operations()
679 .iter()
680 .find(|o| o.operation_id.as_deref() == Some("makeNode"))
681 .unwrap();
682 let body = op.request_body_schema.as_ref().unwrap();
685 let next = body.pointer("/properties/next").expect("next property");
686 assert_eq!(
687 next.get("$ref").and_then(|v| v.as_str()),
688 Some("#/components/schemas/Node")
689 );
690 }
691
692 #[test]
693 fn test_missing_required_param() {
694 let provider = OpenApiProvider::from_string(TEST_SPEC)
695 .unwrap()
696 .with_base_url("https://api.example.com")
697 .unwrap();
698
699 let get_user = provider
700 .operations()
701 .iter()
702 .find(|op| op.operation_id.as_deref() == Some("getUser"))
703 .unwrap();
704
705 let args = HashMap::new(); let result = provider.build_url(get_user, &args);
708 assert!(matches!(result, Err(OpenApiError::MissingParameter(_))));
709 }
710}