Skip to main content

turbomcp_openapi/
provider.rs

1//! OpenAPI provider for generating MCP components from OpenAPI specs.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::sync::Arc;
6
7use openapiv3::{
8    OpenAPI, Operation, Parameter, ParameterSchemaOrContent, ReferenceOr, Schema, SecurityScheme,
9};
10use serde_json::{Value, json};
11use url::Url;
12
13use crate::error::{OpenApiError, Result};
14use crate::handler::OpenApiHandler;
15use crate::mapping::{McpType, RouteMapping};
16use crate::parser::{fetch_from_url, load_from_file, parse_spec};
17
18/// An operation extracted from an OpenAPI spec.
19#[derive(Debug, Clone)]
20pub struct ExtractedOperation {
21    /// HTTP method (GET, POST, etc.)
22    pub method: String,
23    /// Path template (e.g., "/users/{id}")
24    pub path: String,
25    /// Operation ID (if specified)
26    pub operation_id: Option<String>,
27    /// Summary/description
28    pub summary: Option<String>,
29    /// Operation description
30    pub description: Option<String>,
31    /// Parameters
32    pub parameters: Vec<ExtractedParameter>,
33    /// Request body schema (if any)
34    pub request_body_schema: Option<Value>,
35    /// What MCP type this maps to
36    pub mcp_type: McpType,
37    /// Effective security requirements: a list of alternative
38    /// [`SecurityRequirement`](openapiv3::SecurityRequirement) objects. Each
39    /// entry maps a scheme name from `components.securitySchemes` to the
40    /// scopes that must be present. Satisfy any one alternative. Operation-level
41    /// `security` overrides the spec-level `security`; an explicit empty list
42    /// (`security: []`) on an operation disables auth.
43    pub security: Vec<HashMap<String, Vec<String>>>,
44    /// JSON Schema of the operation's primary success response (first 2xx
45    /// `application/json` response, with `$ref`s inlined). Surfaces in the
46    /// generated MCP `Tool::output_schema` for clients that consume MCP
47    /// 2025-11-25's `outputSchema`. `None` if the operation has no JSON
48    /// response or only `default` / non-2xx responses.
49    pub response_schema: Option<Value>,
50}
51
52/// A parameter extracted from an OpenAPI operation.
53#[derive(Debug, Clone)]
54pub struct ExtractedParameter {
55    /// Parameter name
56    pub name: String,
57    /// Where the parameter goes (path, query, header, cookie)
58    pub location: String,
59    /// Whether the parameter is required
60    pub required: bool,
61    /// Description
62    pub description: Option<String>,
63    /// JSON Schema for the parameter
64    pub schema: Option<Value>,
65}
66
67/// Default request timeout in seconds.
68const DEFAULT_TIMEOUT_SECS: u64 = 30;
69
70/// Hook for satisfying an operation's [`SecurityRequirement`](openapiv3::SecurityRequirement)s
71/// before the request is sent.
72///
73/// OpenAPI specifications declare auth via `securitySchemes` (`apiKey`,
74/// `http bearer`/`basic`, `oauth2`, `openIdConnect`) and per-operation/spec-level
75/// `security`. This crate parses both — `OpenApiProvider::security_schemes`
76/// returns the scheme definitions, and each [`ExtractedOperation::security`]
77/// holds the operation's effective requirements. Implement this trait to
78/// inject credentials matching one of the requirement alternatives.
79///
80/// # Example
81///
82/// ```rust,ignore
83/// use std::collections::HashMap;
84/// use std::sync::Arc;
85/// use turbomcp_openapi::{AuthProvider, OpenApiProvider};
86///
87/// #[derive(Debug)]
88/// struct StaticBearer(String);
89///
90/// impl AuthProvider for StaticBearer {
91///     fn apply(
92///         &self,
93///         request: reqwest::RequestBuilder,
94///         _requirements: &[HashMap<String, Vec<String>>],
95///         _schemes: &HashMap<String, openapiv3::SecurityScheme>,
96///     ) -> reqwest::RequestBuilder {
97///         request.bearer_auth(&self.0)
98///     }
99/// }
100///
101/// let provider = OpenApiProvider::from_string(spec)?
102///     .with_auth_provider(Arc::new(StaticBearer("token".into())));
103/// ```
104pub trait AuthProvider: Send + Sync + std::fmt::Debug {
105    /// Apply auth to an outgoing request.
106    ///
107    /// `requirements` is the list of alternative [`SecurityRequirement`](openapiv3::SecurityRequirement)
108    /// objects from the operation; satisfying any one alternative is sufficient.
109    /// Each entry maps a scheme name (from `components.securitySchemes`) to the
110    /// required scopes. `schemes` is the spec's `components.securitySchemes`
111    /// map, with references already resolved.
112    ///
113    /// Returning the unmodified `request` is acceptable for operations whose
114    /// requirements your implementation cannot satisfy — the request will then
115    /// fail with whatever auth error the upstream returns.
116    fn apply(
117        &self,
118        request: reqwest::RequestBuilder,
119        requirements: &[HashMap<String, Vec<String>>],
120        schemes: &HashMap<String, SecurityScheme>,
121    ) -> reqwest::RequestBuilder;
122}
123
124/// OpenAPI to MCP provider.
125///
126/// This provider parses OpenAPI specifications and converts them to MCP
127/// tools and resources that can be used with a TurboMCP server.
128///
129/// # Security
130///
131/// The provider includes built-in SSRF protection that blocks requests to:
132/// - Localhost and loopback addresses (127.0.0.0/8, ::1)
133/// - Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
134/// - Link-local addresses (169.254.0.0/16) including cloud metadata endpoints
135/// - Other reserved ranges
136///
137/// Requests have a default timeout of 30 seconds to prevent slowloris attacks.
138#[derive(Debug)]
139pub struct OpenApiProvider {
140    /// The parsed OpenAPI specification
141    spec: OpenAPI,
142    /// Base URL for API calls
143    base_url: Option<Url>,
144    /// Route mapping configuration
145    mapping: RouteMapping,
146    /// HTTP client for making API calls
147    client: reqwest::Client,
148    /// Extracted operations
149    operations: Vec<ExtractedOperation>,
150    /// Resolved security scheme definitions, keyed by scheme name.
151    security_schemes: HashMap<String, SecurityScheme>,
152    /// Request timeout
153    timeout: std::time::Duration,
154    /// Optional auth provider that satisfies each operation's `security` requirements.
155    auth_provider: Option<Arc<dyn AuthProvider>>,
156}
157
158impl OpenApiProvider {
159    /// Create a provider from a parsed OpenAPI specification.
160    ///
161    /// If `spec.servers` is non-empty, `base_url` is initialized from
162    /// `spec.servers[0].url` (with any default `variables` substituted in). Use
163    /// [`Self::with_base_url`] to override. Server URLs that fail to parse as
164    /// absolute leave `base_url` unset; `with_base_url` must then be called
165    /// before any tool/resource invocation.
166    pub fn from_spec(spec: OpenAPI) -> Self {
167        let mapping = RouteMapping::default_rules();
168        let timeout = std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS);
169        // `Client::builder().build()` only fails on egregious config (e.g.
170        // a missing TLS backend) — not silently downgrading to
171        // `Client::new()` (which would lose the configured timeout) is the
172        // correct stance: the user asked for a timeout, surface the error.
173        let client = reqwest::Client::builder()
174            .timeout(timeout)
175            .build()
176            .expect("reqwest::Client::builder() failed; check TLS backend / build features");
177
178        let base_url = spec
179            .servers
180            .first()
181            .and_then(|server| Self::resolve_server_url(server).ok());
182        let security_schemes = Self::collect_security_schemes(&spec);
183
184        let mut provider = Self {
185            spec,
186            base_url,
187            mapping,
188            client,
189            operations: Vec::new(),
190            security_schemes,
191            timeout,
192            auth_provider: None,
193        };
194        provider.extract_operations();
195        provider
196    }
197
198    /// Substitute the default values for any `{var}` placeholders in a server URL,
199    /// then parse the result into a [`Url`].
200    fn resolve_server_url(server: &openapiv3::Server) -> Result<Url> {
201        let mut url = server.url.clone();
202        if let Some(vars) = &server.variables {
203            for (name, var) in vars {
204                let placeholder = format!("{{{name}}}");
205                url = url.replace(&placeholder, &var.default);
206            }
207        }
208        Ok(Url::parse(&url)?)
209    }
210
211    /// Resolve all `securitySchemes` to inline `SecurityScheme` definitions.
212    /// `Reference` entries (`{"$ref": "..."}`) at the top level are skipped:
213    /// the OpenAPI spec allows them but they're rare in practice and would
214    /// require a separate dereference pass against `components`.
215    fn collect_security_schemes(spec: &OpenAPI) -> HashMap<String, SecurityScheme> {
216        spec.components
217            .as_ref()
218            .map(|c| {
219                c.security_schemes
220                    .iter()
221                    .filter_map(|(name, entry)| match entry {
222                        ReferenceOr::Item(scheme) => Some((name.clone(), scheme.clone())),
223                        ReferenceOr::Reference { .. } => None,
224                    })
225                    .collect()
226            })
227            .unwrap_or_default()
228    }
229
230    /// Create a provider from an OpenAPI specification string.
231    pub fn from_string(content: &str) -> Result<Self> {
232        let spec = parse_spec(content)?;
233        Ok(Self::from_spec(spec))
234    }
235
236    /// Create a provider by loading from a file.
237    pub fn from_file(path: &Path) -> Result<Self> {
238        let spec = load_from_file(path)?;
239        Ok(Self::from_spec(spec))
240    }
241
242    /// Create a provider by fetching from a URL.
243    pub async fn from_url(url: &str) -> Result<Self> {
244        let spec = fetch_from_url(url).await?;
245        Ok(Self::from_spec(spec))
246    }
247
248    /// Set the base URL for API calls.
249    pub fn with_base_url(mut self, base_url: &str) -> Result<Self> {
250        self.base_url = Some(Url::parse(base_url)?);
251        Ok(self)
252    }
253
254    /// Set a custom route mapping configuration.
255    #[must_use]
256    pub fn with_route_mapping(mut self, mapping: RouteMapping) -> Self {
257        self.mapping = mapping;
258        self.extract_operations(); // Re-extract with new mapping
259        self
260    }
261
262    /// Set a custom HTTP client.
263    ///
264    /// # Warning
265    ///
266    /// When using a custom client, ensure it has appropriate timeout settings.
267    /// The default client uses a 30-second timeout.
268    #[must_use]
269    pub fn with_client(mut self, client: reqwest::Client) -> Self {
270        self.client = client;
271        self
272    }
273
274    /// Install an [`AuthProvider`] that injects credentials matching each
275    /// operation's [`SecurityRequirement`](openapiv3::SecurityRequirement)s.
276    /// Without this, operations on authenticated upstream APIs will fail with
277    /// 401 unless the caller has installed equivalent auth via
278    /// [`Self::with_client`].
279    #[must_use]
280    pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
281        self.auth_provider = Some(provider);
282        self
283    }
284
285    /// Get the spec's security scheme definitions, keyed by scheme name.
286    /// Reference entries are silently dropped (rare in practice).
287    pub fn security_schemes(&self) -> &HashMap<String, SecurityScheme> {
288        &self.security_schemes
289    }
290
291    /// Get the installed auth provider, if any.
292    pub(crate) fn auth_provider(&self) -> Option<&Arc<dyn AuthProvider>> {
293        self.auth_provider.as_ref()
294    }
295
296    /// Set a custom request timeout.
297    ///
298    /// This rebuilds the HTTP client with the new timeout. The default timeout
299    /// is 30 seconds.
300    #[must_use]
301    pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
302        self.timeout = timeout;
303        // See `from_spec`: rebuilding without the configured timeout would
304        // silently regress to reqwest's default; expect on builder failure
305        // instead so the caller's intent isn't lost.
306        self.client = reqwest::Client::builder()
307            .timeout(timeout)
308            .build()
309            .expect("reqwest::Client::builder() failed in with_timeout");
310        self
311    }
312
313    /// Get the current request timeout.
314    pub fn timeout(&self) -> std::time::Duration {
315        self.timeout
316    }
317
318    /// Get the API title from the spec.
319    pub fn title(&self) -> &str {
320        &self.spec.info.title
321    }
322
323    /// Get the API version from the spec.
324    pub fn version(&self) -> &str {
325        &self.spec.info.version
326    }
327
328    /// Get all extracted operations.
329    pub fn operations(&self) -> &[ExtractedOperation] {
330        &self.operations
331    }
332
333    /// Get operations that map to MCP tools.
334    pub fn tools(&self) -> impl Iterator<Item = &ExtractedOperation> {
335        self.operations
336            .iter()
337            .filter(|op| op.mcp_type == McpType::Tool)
338    }
339
340    /// Get operations that map to MCP resources.
341    pub fn resources(&self) -> impl Iterator<Item = &ExtractedOperation> {
342        self.operations
343            .iter()
344            .filter(|op| op.mcp_type == McpType::Resource)
345    }
346
347    /// Convert this provider into an McpHandler.
348    pub fn into_handler(self) -> OpenApiHandler {
349        OpenApiHandler::new(Arc::new(self))
350    }
351
352    /// Extract operations from the OpenAPI spec.
353    fn extract_operations(&mut self) {
354        self.operations.clear();
355
356        for (path, path_item) in &self.spec.paths.paths {
357            let path_item = match path_item {
358                ReferenceOr::Item(item) => item,
359                ReferenceOr::Reference { .. } => continue, // Skip references for now
360            };
361
362            // Extract operations for each HTTP method
363            let methods = [
364                ("GET", &path_item.get),
365                ("POST", &path_item.post),
366                ("PUT", &path_item.put),
367                ("DELETE", &path_item.delete),
368                ("PATCH", &path_item.patch),
369            ];
370
371            for (method, operation) in methods {
372                if let Some(op) = operation {
373                    let mcp_type = self.mapping.get_mcp_type(method, path);
374                    if mcp_type == McpType::Skip {
375                        continue;
376                    }
377
378                    self.operations
379                        .push(self.extract_operation(method, path, op, mcp_type));
380                }
381            }
382        }
383    }
384
385    /// Extract a single operation.
386    fn extract_operation(
387        &self,
388        method: &str,
389        path: &str,
390        operation: &Operation,
391        mcp_type: McpType,
392    ) -> ExtractedOperation {
393        let parameters = operation
394            .parameters
395            .iter()
396            .filter_map(|p| match p {
397                ReferenceOr::Item(param) => Some(self.extract_parameter(param)),
398                ReferenceOr::Reference { .. } => None,
399            })
400            .collect();
401
402        let request_body_schema = operation.request_body.as_ref().and_then(|rb| match rb {
403            ReferenceOr::Item(body) => body
404                .content
405                .get("application/json")
406                .and_then(|mt| mt.schema.as_ref())
407                .and_then(|s| self.schema_to_json(s)),
408            ReferenceOr::Reference { .. } => None,
409        });
410
411        // Operation-level `security` overrides spec-level. An explicit empty
412        // list (`security: []`) on the operation disables auth and must NOT
413        // fall back to spec-level — we model that as the empty Vec.
414        let security = operation
415            .security
416            .as_ref()
417            .or(self.spec.security.as_ref())
418            .map(|reqs| {
419                reqs.iter()
420                    .map(|req| {
421                        req.iter()
422                            .map(|(name, scopes)| (name.clone(), scopes.clone()))
423                            .collect::<HashMap<_, _>>()
424                    })
425                    .collect()
426            })
427            .unwrap_or_default();
428
429        // Pick the first 2xx response with an `application/json` body and
430        // inline its schema. Falls back to whichever 2xx the iterator yields
431        // first if none expose a JSON body.
432        let response_schema = operation
433            .responses
434            .responses
435            .iter()
436            .filter_map(|(code, resp)| {
437                let code_str = code.to_string();
438                let is_2xx = code_str
439                    .strip_prefix('2')
440                    .map(|rest| {
441                        rest.len() == 2
442                            && rest
443                                .chars()
444                                .all(|c| c.is_ascii_digit() || c == 'X' || c == 'x')
445                    })
446                    .unwrap_or(false);
447                if !is_2xx {
448                    return None;
449                }
450                match resp {
451                    ReferenceOr::Item(r) => r
452                        .content
453                        .get("application/json")
454                        .and_then(|mt| mt.schema.as_ref())
455                        .and_then(|s| self.schema_to_json(s)),
456                    ReferenceOr::Reference { .. } => None,
457                }
458            })
459            .next();
460
461        ExtractedOperation {
462            method: method.to_string(),
463            path: path.to_string(),
464            operation_id: operation.operation_id.clone(),
465            summary: operation.summary.clone(),
466            description: operation.description.clone(),
467            parameters,
468            request_body_schema,
469            mcp_type,
470            security,
471            response_schema,
472        }
473    }
474
475    /// Extract a parameter definition.
476    fn extract_parameter(&self, param: &Parameter) -> ExtractedParameter {
477        let (name, location, required, description, schema) = match param {
478            Parameter::Query { parameter_data, .. } => (
479                parameter_data.name.clone(),
480                "query".to_string(),
481                parameter_data.required,
482                parameter_data.description.clone(),
483                self.extract_param_schema(&parameter_data.format),
484            ),
485            Parameter::Header { parameter_data, .. } => (
486                parameter_data.name.clone(),
487                "header".to_string(),
488                parameter_data.required,
489                parameter_data.description.clone(),
490                self.extract_param_schema(&parameter_data.format),
491            ),
492            Parameter::Path { parameter_data, .. } => (
493                parameter_data.name.clone(),
494                "path".to_string(),
495                true, // Path params are always required
496                parameter_data.description.clone(),
497                self.extract_param_schema(&parameter_data.format),
498            ),
499            Parameter::Cookie { parameter_data, .. } => (
500                parameter_data.name.clone(),
501                "cookie".to_string(),
502                parameter_data.required,
503                parameter_data.description.clone(),
504                self.extract_param_schema(&parameter_data.format),
505            ),
506        };
507
508        ExtractedParameter {
509            name,
510            location,
511            required,
512            description,
513            schema,
514        }
515    }
516
517    /// Extract schema from parameter format.
518    fn extract_param_schema(&self, format: &ParameterSchemaOrContent) -> Option<Value> {
519        match format {
520            ParameterSchemaOrContent::Schema(schema) => self.schema_to_json(schema),
521            ParameterSchemaOrContent::Content(_) => None,
522        }
523    }
524
525    /// Convert an OpenAPI schema to a JSON Schema value, inlining `$ref`s
526    /// against `components.schemas`.
527    ///
528    /// OpenAPI lets schemas reference each other through
529    /// `{"$ref": "#/components/schemas/Foo"}`. MCP tool-input schemas have
530    /// no cross-operation component dictionary to share, so we resolve those
531    /// refs inline. Cycles are broken by leaving the first re-visited
532    /// reference as a `$ref` literal rather than expanding it forever.
533    fn schema_to_json(&self, schema: &ReferenceOr<Schema>) -> Option<Value> {
534        let initial = match schema {
535            ReferenceOr::Item(s) => serde_json::to_value(s).ok()?,
536            ReferenceOr::Reference { reference } => {
537                json!({ "$ref": reference })
538            }
539        };
540        let mut visited = std::collections::HashSet::new();
541        Some(self.resolve_refs(initial, &mut visited))
542    }
543
544    /// Recursively inline `$ref` pointers that target `components.schemas`.
545    ///
546    /// `visited` tracks the ref path currently being expanded; re-encountering
547    /// the same pointer during expansion leaves the `$ref` in place so the
548    /// output stays finite on self-referential schemas (the default interpretation
549    /// consumers do — most JSON Schema validators understand internal `$ref`).
550    fn resolve_refs(&self, value: Value, visited: &mut std::collections::HashSet<String>) -> Value {
551        match value {
552            Value::Object(mut map) => {
553                if let Some(Value::String(reference)) = map.get("$ref").cloned()
554                    && map.len() == 1
555                {
556                    if !visited.insert(reference.clone()) {
557                        map.insert("$ref".to_string(), Value::String(reference));
558                        return Value::Object(map);
559                    }
560                    let expanded = self.lookup_ref(&reference).map(|target| {
561                        let target_json = serde_json::to_value(target).unwrap_or(Value::Null);
562                        self.resolve_refs(target_json, visited)
563                    });
564                    visited.remove(&reference);
565                    return expanded.unwrap_or(Value::Object({
566                        let mut fallback = serde_json::Map::new();
567                        fallback.insert("$ref".to_string(), Value::String(reference));
568                        fallback
569                    }));
570                }
571                let resolved = map
572                    .into_iter()
573                    .map(|(k, v)| (k, self.resolve_refs(v, visited)))
574                    .collect();
575                Value::Object(resolved)
576            }
577            Value::Array(items) => Value::Array(
578                items
579                    .into_iter()
580                    .map(|v| self.resolve_refs(v, visited))
581                    .collect(),
582            ),
583            other => other,
584        }
585    }
586
587    /// Look up a `#/components/schemas/Name` reference in the parsed spec.
588    /// Follows reference chains up to `MAX_DEPTH` levels deep with cycle detection,
589    /// so chains like `Foo -> Bar -> Baz` resolve correctly without unbounded recursion.
590    fn lookup_ref(&self, reference: &str) -> Option<&Schema> {
591        const PREFIX: &str = "#/components/schemas/";
592        const MAX_DEPTH: usize = 10;
593        let mut name = reference.strip_prefix(PREFIX)?;
594        let components = self.spec.components.as_ref()?;
595        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
596        for _ in 0..MAX_DEPTH {
597            if !seen.insert(name) {
598                // Cycle.
599                return None;
600            }
601            match components.schemas.get(name)? {
602                ReferenceOr::Item(schema) => return Some(schema),
603                ReferenceOr::Reference { reference } => {
604                    name = reference.strip_prefix(PREFIX)?;
605                }
606            }
607        }
608        None
609    }
610
611    /// Build the full URL for an operation.
612    pub(crate) fn build_url(
613        &self,
614        operation: &ExtractedOperation,
615        args: &HashMap<String, Value>,
616    ) -> Result<Url> {
617        let base = self.base_url.as_ref().ok_or(OpenApiError::NoBaseUrl)?;
618
619        // Replace path parameters
620        let mut path = operation.path.clone();
621        for param in &operation.parameters {
622            if param.location == "path" {
623                if let Some(value) = args.get(&param.name) {
624                    let value_str = match value {
625                        Value::String(s) => s.clone(),
626                        _ => value.to_string(),
627                    };
628                    path = path.replace(&format!("{{{}}}", param.name), &value_str);
629                } else if param.required {
630                    return Err(OpenApiError::MissingParameter(param.name.clone()));
631                }
632            }
633        }
634
635        let mut url = base.join(&path)?;
636
637        // Collect query parameters first
638        let mut query_params: Vec<(String, String)> = Vec::new();
639        for param in &operation.parameters {
640            if param.location == "query" {
641                if let Some(value) = args.get(&param.name) {
642                    let value_str = match value {
643                        Value::String(s) => s.clone(),
644                        Value::Bool(b) => b.to_string(),
645                        Value::Number(n) => n.to_string(),
646                        _ => value.to_string(),
647                    };
648                    query_params.push((param.name.clone(), value_str));
649                } else if param.required {
650                    return Err(OpenApiError::MissingParameter(param.name.clone()));
651                }
652            }
653        }
654
655        // Only add query string if there are parameters
656        if !query_params.is_empty() {
657            let mut query_pairs = url.query_pairs_mut();
658            for (key, value) in query_params {
659                query_pairs.append_pair(&key, &value);
660            }
661        }
662
663        Ok(url)
664    }
665
666    /// Get the HTTP client.
667    pub(crate) fn client(&self) -> &reqwest::Client {
668        &self.client
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    const TEST_SPEC: &str = r#"{
677        "openapi": "3.0.0",
678        "info": {
679            "title": "Test API",
680            "version": "1.0.0"
681        },
682        "paths": {
683            "/users": {
684                "get": {
685                    "operationId": "listUsers",
686                    "summary": "List all users",
687                    "responses": { "200": { "description": "Success" } }
688                },
689                "post": {
690                    "operationId": "createUser",
691                    "summary": "Create a user",
692                    "responses": { "201": { "description": "Created" } }
693                }
694            },
695            "/users/{id}": {
696                "get": {
697                    "operationId": "getUser",
698                    "summary": "Get a user by ID",
699                    "parameters": [
700                        {
701                            "name": "id",
702                            "in": "path",
703                            "required": true,
704                            "schema": { "type": "string" }
705                        }
706                    ],
707                    "responses": { "200": { "description": "Success" } }
708                },
709                "delete": {
710                    "operationId": "deleteUser",
711                    "summary": "Delete a user",
712                    "parameters": [
713                        {
714                            "name": "id",
715                            "in": "path",
716                            "required": true,
717                            "schema": { "type": "string" }
718                        }
719                    ],
720                    "responses": { "204": { "description": "Deleted" } }
721                }
722            }
723        }
724    }"#;
725
726    #[test]
727    fn test_provider_from_string() {
728        let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
729
730        assert_eq!(provider.title(), "Test API");
731        assert_eq!(provider.version(), "1.0.0");
732    }
733
734    #[test]
735    fn test_operation_extraction() {
736        let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
737
738        assert_eq!(provider.operations().len(), 4);
739
740        // Check GET /users is a resource
741        let list_users = provider
742            .operations()
743            .iter()
744            .find(|op| op.operation_id.as_deref() == Some("listUsers"))
745            .unwrap();
746        assert_eq!(list_users.mcp_type, McpType::Resource);
747        assert_eq!(list_users.method, "GET");
748
749        // Check POST /users is a tool
750        let create_user = provider
751            .operations()
752            .iter()
753            .find(|op| op.operation_id.as_deref() == Some("createUser"))
754            .unwrap();
755        assert_eq!(create_user.mcp_type, McpType::Tool);
756        assert_eq!(create_user.method, "POST");
757    }
758
759    #[test]
760    fn test_tools_and_resources() {
761        let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
762
763        let tools: Vec<_> = provider.tools().collect();
764        let resources: Vec<_> = provider.resources().collect();
765
766        // GET operations -> resources
767        assert_eq!(resources.len(), 2);
768        // POST, DELETE operations -> tools
769        assert_eq!(tools.len(), 2);
770    }
771
772    #[test]
773    fn test_build_url_with_path_params() {
774        let provider = OpenApiProvider::from_string(TEST_SPEC)
775            .unwrap()
776            .with_base_url("https://api.example.com")
777            .unwrap();
778
779        let get_user = provider
780            .operations()
781            .iter()
782            .find(|op| op.operation_id.as_deref() == Some("getUser"))
783            .unwrap();
784
785        let mut args = HashMap::new();
786        args.insert("id".to_string(), json!("123"));
787
788        let url = provider.build_url(get_user, &args).unwrap();
789        assert_eq!(url.as_str(), "https://api.example.com/users/123");
790    }
791
792    #[test]
793    fn test_ref_resolution_inlines_components() {
794        const REF_SPEC: &str = r##"{
795            "openapi": "3.0.0",
796            "info": { "title": "T", "version": "1.0.0" },
797            "paths": {
798                "/pets": {
799                    "post": {
800                        "operationId": "createPet",
801                        "requestBody": {
802                            "content": {
803                                "application/json": {
804                                    "schema": { "$ref": "#/components/schemas/Pet" }
805                                }
806                            }
807                        },
808                        "responses": { "201": { "description": "ok" } }
809                    }
810                }
811            },
812            "components": {
813                "schemas": {
814                    "Pet": {
815                        "type": "object",
816                        "properties": {
817                            "name": { "type": "string" },
818                            "owner": { "$ref": "#/components/schemas/Owner" }
819                        }
820                    },
821                    "Owner": {
822                        "type": "object",
823                        "properties": {
824                            "email": { "type": "string" }
825                        }
826                    }
827                }
828            }
829        }"##;
830
831        let provider = OpenApiProvider::from_string(REF_SPEC).unwrap();
832        let op = provider
833            .operations()
834            .iter()
835            .find(|o| o.operation_id.as_deref() == Some("createPet"))
836            .expect("createPet operation");
837        let body = op.request_body_schema.as_ref().expect("body schema");
838        let props = body.get("properties").expect("properties");
839        let owner = props.get("owner").expect("owner property");
840        // The owner $ref must have been replaced by the inlined Owner schema.
841        assert!(
842            owner.get("$ref").is_none(),
843            "owner $ref was not inlined: {owner}"
844        );
845        let owner_props = owner.get("properties").expect("owner inlined properties");
846        assert!(owner_props.get("email").is_some());
847    }
848
849    #[test]
850    fn test_ref_resolution_handles_cycles() {
851        const CYCLE_SPEC: &str = r##"{
852            "openapi": "3.0.0",
853            "info": { "title": "T", "version": "1.0.0" },
854            "paths": {
855                "/n": {
856                    "post": {
857                        "operationId": "makeNode",
858                        "requestBody": {
859                            "content": {
860                                "application/json": {
861                                    "schema": { "$ref": "#/components/schemas/Node" }
862                                }
863                            }
864                        },
865                        "responses": { "201": { "description": "ok" } }
866                    }
867                }
868            },
869            "components": {
870                "schemas": {
871                    "Node": {
872                        "type": "object",
873                        "properties": {
874                            "next": { "$ref": "#/components/schemas/Node" }
875                        }
876                    }
877                }
878            }
879        }"##;
880
881        let provider = OpenApiProvider::from_string(CYCLE_SPEC).unwrap();
882        let op = provider
883            .operations()
884            .iter()
885            .find(|o| o.operation_id.as_deref() == Some("makeNode"))
886            .unwrap();
887        // Must not infinite-loop or panic — resolver should have returned a finite value
888        // with the inner cycle preserved as a $ref.
889        let body = op.request_body_schema.as_ref().unwrap();
890        let next = body.pointer("/properties/next").expect("next property");
891        assert_eq!(
892            next.get("$ref").and_then(|v| v.as_str()),
893            Some("#/components/schemas/Node")
894        );
895    }
896
897    #[test]
898    fn test_base_url_defaults_from_servers() {
899        const SPEC: &str = r#"{
900            "openapi": "3.0.0",
901            "info": { "title": "T", "version": "1.0.0" },
902            "servers": [
903                { "url": "https://api.example.com/v1" }
904            ],
905            "paths": {}
906        }"#;
907        let provider = OpenApiProvider::from_string(SPEC).unwrap();
908        assert_eq!(
909            provider.base_url.as_ref().map(Url::as_str),
910            Some("https://api.example.com/v1")
911        );
912    }
913
914    #[test]
915    fn test_base_url_substitutes_server_variables() {
916        const SPEC: &str = r#"{
917            "openapi": "3.0.0",
918            "info": { "title": "T", "version": "1.0.0" },
919            "servers": [
920                {
921                    "url": "https://{host}/api",
922                    "variables": {
923                        "host": { "default": "api.example.com" }
924                    }
925                }
926            ],
927            "paths": {}
928        }"#;
929        let provider = OpenApiProvider::from_string(SPEC).unwrap();
930        assert_eq!(
931            provider.base_url.as_ref().map(Url::as_str),
932            Some("https://api.example.com/api")
933        );
934    }
935
936    #[test]
937    fn test_with_base_url_overrides_servers_default() {
938        const SPEC: &str = r#"{
939            "openapi": "3.0.0",
940            "info": { "title": "T", "version": "1.0.0" },
941            "servers": [{ "url": "https://default.example.com" }],
942            "paths": {}
943        }"#;
944        let provider = OpenApiProvider::from_string(SPEC)
945            .unwrap()
946            .with_base_url("https://override.example.com")
947            .unwrap();
948        assert_eq!(
949            provider.base_url.as_ref().map(Url::as_str),
950            Some("https://override.example.com/")
951        );
952    }
953
954    #[test]
955    fn test_security_propagated_to_extracted_operation() {
956        const SPEC: &str = r#"{
957            "openapi": "3.0.0",
958            "info": { "title": "T", "version": "1.0.0" },
959            "security": [{ "globalKey": [] }],
960            "components": {
961                "securitySchemes": {
962                    "globalKey": {
963                        "type": "apiKey",
964                        "name": "X-API-Key",
965                        "in": "header"
966                    },
967                    "perOpBearer": {
968                        "type": "http",
969                        "scheme": "bearer"
970                    }
971                }
972            },
973            "paths": {
974                "/admin": {
975                    "post": {
976                        "operationId": "adminOp",
977                        "security": [{ "perOpBearer": [] }],
978                        "responses": { "200": { "description": "ok" } }
979                    }
980                },
981                "/public": {
982                    "get": {
983                        "operationId": "publicOp",
984                        "responses": { "200": { "description": "ok" } }
985                    }
986                }
987            }
988        }"#;
989        let provider = OpenApiProvider::from_string(SPEC).unwrap();
990
991        let admin = provider
992            .operations()
993            .iter()
994            .find(|op| op.operation_id.as_deref() == Some("adminOp"))
995            .unwrap();
996        assert_eq!(admin.security.len(), 1);
997        assert!(admin.security[0].contains_key("perOpBearer"));
998
999        let public = provider
1000            .operations()
1001            .iter()
1002            .find(|op| op.operation_id.as_deref() == Some("publicOp"))
1003            .unwrap();
1004        // No operation-level security → falls back to spec-level
1005        assert_eq!(public.security.len(), 1);
1006        assert!(public.security[0].contains_key("globalKey"));
1007
1008        assert_eq!(provider.security_schemes().len(), 2);
1009    }
1010
1011    #[test]
1012    fn test_missing_required_param() {
1013        let provider = OpenApiProvider::from_string(TEST_SPEC)
1014            .unwrap()
1015            .with_base_url("https://api.example.com")
1016            .unwrap();
1017
1018        let get_user = provider
1019            .operations()
1020            .iter()
1021            .find(|op| op.operation_id.as_deref() == Some("getUser"))
1022            .unwrap();
1023
1024        let args = HashMap::new(); // Missing 'id'
1025
1026        let result = provider.build_url(get_user, &args);
1027        assert!(matches!(result, Err(OpenApiError::MissingParameter(_))));
1028    }
1029}