1use 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#[derive(Debug, Clone)]
20pub struct ExtractedOperation {
21 pub method: String,
23 pub path: String,
25 pub operation_id: Option<String>,
27 pub summary: Option<String>,
29 pub description: Option<String>,
31 pub parameters: Vec<ExtractedParameter>,
33 pub request_body_schema: Option<Value>,
35 pub mcp_type: McpType,
37 pub security: Vec<HashMap<String, Vec<String>>>,
44 pub response_schema: Option<Value>,
50}
51
52#[derive(Debug, Clone)]
54pub struct ExtractedParameter {
55 pub name: String,
57 pub location: String,
59 pub required: bool,
61 pub description: Option<String>,
63 pub schema: Option<Value>,
65}
66
67const DEFAULT_TIMEOUT_SECS: u64 = 30;
69
70pub trait AuthProvider: Send + Sync + std::fmt::Debug {
105 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#[derive(Debug)]
139pub struct OpenApiProvider {
140 spec: OpenAPI,
142 base_url: Option<Url>,
144 mapping: RouteMapping,
146 client: reqwest::Client,
148 operations: Vec<ExtractedOperation>,
150 security_schemes: HashMap<String, SecurityScheme>,
152 timeout: std::time::Duration,
154 auth_provider: Option<Arc<dyn AuthProvider>>,
156}
157
158impl OpenApiProvider {
159 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 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 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 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 pub fn from_string(content: &str) -> Result<Self> {
232 let spec = parse_spec(content)?;
233 Ok(Self::from_spec(spec))
234 }
235
236 pub fn from_file(path: &Path) -> Result<Self> {
238 let spec = load_from_file(path)?;
239 Ok(Self::from_spec(spec))
240 }
241
242 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 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 #[must_use]
256 pub fn with_route_mapping(mut self, mapping: RouteMapping) -> Self {
257 self.mapping = mapping;
258 self.extract_operations(); self
260 }
261
262 #[must_use]
269 pub fn with_client(mut self, client: reqwest::Client) -> Self {
270 self.client = client;
271 self
272 }
273
274 #[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 pub fn security_schemes(&self) -> &HashMap<String, SecurityScheme> {
288 &self.security_schemes
289 }
290
291 pub(crate) fn auth_provider(&self) -> Option<&Arc<dyn AuthProvider>> {
293 self.auth_provider.as_ref()
294 }
295
296 #[must_use]
301 pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
302 self.timeout = timeout;
303 self.client = reqwest::Client::builder()
307 .timeout(timeout)
308 .build()
309 .expect("reqwest::Client::builder() failed in with_timeout");
310 self
311 }
312
313 pub fn timeout(&self) -> std::time::Duration {
315 self.timeout
316 }
317
318 pub fn title(&self) -> &str {
320 &self.spec.info.title
321 }
322
323 pub fn version(&self) -> &str {
325 &self.spec.info.version
326 }
327
328 pub fn operations(&self) -> &[ExtractedOperation] {
330 &self.operations
331 }
332
333 pub fn tools(&self) -> impl Iterator<Item = &ExtractedOperation> {
335 self.operations
336 .iter()
337 .filter(|op| op.mcp_type == McpType::Tool)
338 }
339
340 pub fn resources(&self) -> impl Iterator<Item = &ExtractedOperation> {
342 self.operations
343 .iter()
344 .filter(|op| op.mcp_type == McpType::Resource)
345 }
346
347 pub fn into_handler(self) -> OpenApiHandler {
349 OpenApiHandler::new(Arc::new(self))
350 }
351
352 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, };
361
362 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 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 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 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 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(¶meter_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(¶meter_data.format),
491 ),
492 Parameter::Path { parameter_data, .. } => (
493 parameter_data.name.clone(),
494 "path".to_string(),
495 true, parameter_data.description.clone(),
497 self.extract_param_schema(¶meter_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(¶meter_data.format),
505 ),
506 };
507
508 ExtractedParameter {
509 name,
510 location,
511 required,
512 description,
513 schema,
514 }
515 }
516
517 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 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 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 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 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 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 let mut path = operation.path.clone();
621 for param in &operation.parameters {
622 if param.location == "path" {
623 if let Some(value) = args.get(¶m.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 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(¶m.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 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 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 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 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 assert_eq!(resources.len(), 2);
768 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 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 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 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(); let result = provider.build_url(get_user, &args);
1027 assert!(matches!(result, Err(OpenApiError::MissingParameter(_))));
1028 }
1029}