Jeremiah Lowin commited on
Commit
74f10a8
·
1 Parent(s): b4e78d2

Unify openapi 3.0 and 3.1 parsing

Browse files
src/fastmcp/server/openapi.py CHANGED
@@ -173,7 +173,6 @@ class OpenAPITool(Tool):
173
  for param_name, param_value in path_params.items():
174
  # Handle array path parameters with style 'simple' (comma-separated)
175
  # In OpenAPI, 'simple' is the default style for path parameters
176
- # and explode=False behavior for arrays
177
  param_info = next(
178
  (p for p in self._route.parameters if p.name == param_name), None
179
  )
@@ -186,9 +185,49 @@ class OpenAPITool(Tool):
186
  if is_array:
187
  # Format array values as comma-separated string
188
  # This follows the OpenAPI 'simple' style (default for path)
189
- # and explode=False behavior for arrays
190
- param_value = ",".join(str(item) for item in param_value)
191
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  path = path.replace(f"{{{param_name}}}", str(param_value))
193
 
194
  # Prepare query parameters - filter out None and empty strings
 
173
  for param_name, param_value in path_params.items():
174
  # Handle array path parameters with style 'simple' (comma-separated)
175
  # In OpenAPI, 'simple' is the default style for path parameters
 
176
  param_info = next(
177
  (p for p in self._route.parameters if p.name == param_name), None
178
  )
 
185
  if is_array:
186
  # Format array values as comma-separated string
187
  # This follows the OpenAPI 'simple' style (default for path)
188
+ if all(
189
+ isinstance(item, str | int | float | bool)
190
+ for item in param_value
191
+ ):
192
+ # Handle simple array types
193
+ path = path.replace(
194
+ f"{{{param_name}}}", ",".join(str(v) for v in param_value)
195
+ )
196
+ else:
197
+ # Handle complex array types (containing objects/dicts)
198
+ try:
199
+ # Try to create a simple representation without Python syntax artifacts
200
+ formatted_parts = []
201
+ for item in param_value:
202
+ if isinstance(item, dict):
203
+ # For objects, serialize key-value pairs
204
+ item_parts = []
205
+ for k, v in item.items():
206
+ item_parts.append(f"{k}:{v}")
207
+ formatted_parts.append(".".join(item_parts))
208
+ else:
209
+ # Fallback for other complex types
210
+ formatted_parts.append(str(item))
211
+
212
+ # Join parts with commas
213
+ formatted_value = ",".join(formatted_parts)
214
+ path = path.replace(f"{{{param_name}}}", formatted_value)
215
+ except Exception as e:
216
+ logger.warning(
217
+ f"Failed to format complex array path parameter '{param_name}': {e}"
218
+ )
219
+ # Fallback to string representation, but remove Python syntax artifacts
220
+ str_value = (
221
+ str(param_value)
222
+ .replace("[", "")
223
+ .replace("]", "")
224
+ .replace("'", "")
225
+ .replace('"', "")
226
+ )
227
+ path = path.replace(f"{{{param_name}}}", str_value)
228
+ continue
229
+
230
+ # Default handling for non-array parameters or non-array schemas
231
  path = path.replace(f"{{{param_name}}}", str(param_value))
232
 
233
  # Prepare query parameters - filter out None and empty strings
src/fastmcp/utilities/openapi.py CHANGED
@@ -1,10 +1,8 @@
1
  import json
2
  import logging
3
- from typing import Any, Literal, cast
4
 
5
- # Using the recommended library: openapi-pydantic
6
  from openapi_pydantic import (
7
- MediaType,
8
  OpenAPI,
9
  Operation,
10
  Parameter,
@@ -26,7 +24,7 @@ from openapi_pydantic.v3.v3_0 import Response as Response_30
26
  from openapi_pydantic.v3.v3_0 import Schema as Schema_30
27
  from pydantic import BaseModel, Field, ValidationError
28
 
29
- from fastmcp.utilities import openapi
30
 
31
  logger = logging.getLogger(__name__)
32
 
@@ -49,8 +47,6 @@ class ParameterInfo(BaseModel):
49
  schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
50
  description: str | None = None
51
 
52
- # No model_config needed here if we populate manually after accessing 'in'
53
-
54
 
55
  class RequestBodyInfo(BaseModel):
56
  """Represents the request body for an HTTP operation in our IR."""
@@ -101,60 +97,17 @@ __all__ = [
101
  "parse_openapi_to_http_routes",
102
  ]
103
 
104
- # --- Helper Functions ---
 
 
 
 
 
 
 
 
105
 
106
 
107
- def _resolve_ref(
108
- item: Reference | Schema | Parameter | RequestBody | Any, openapi: OpenAPI
109
- ) -> Any:
110
- """Resolves a potential Reference object to its target definition (no changes needed here)."""
111
- if isinstance(item, Reference):
112
- ref_str = item.ref
113
- try:
114
- if not ref_str.startswith("#/"):
115
- raise ValueError(
116
- f"External or non-local reference not supported: {ref_str}"
117
- )
118
- parts = ref_str.strip("#/").split("/")
119
- target = openapi
120
- for part in parts:
121
- if part.isdigit() and isinstance(target, list):
122
- target = target[int(part)]
123
- elif isinstance(target, BaseModel):
124
- # Use model_extra for fields not explicitly defined (like components types)
125
- # Check class fields first, then model_extra
126
- if part in target.__class__.model_fields:
127
- target = getattr(target, part, None)
128
- elif target.model_extra and part in target.model_extra:
129
- target = target.model_extra[part]
130
- else:
131
- # Special handling for components sub-types common structure
132
- if part == "components" and hasattr(target, "components"):
133
- target = getattr(target, "components")
134
- elif hasattr(target, part): # Fallback check
135
- target = getattr(target, part, None)
136
- else:
137
- target = None # Part not found
138
- elif isinstance(target, dict):
139
- target = target.get(part)
140
- else:
141
- raise ValueError(
142
- f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
143
- )
144
- if target is None:
145
- raise ValueError(
146
- f"Reference part '{part}' not found in path '{ref_str}'"
147
- )
148
- if isinstance(target, Reference):
149
- return _resolve_ref(target, openapi)
150
- return target
151
- except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
152
- raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
153
- return item
154
-
155
-
156
- # --- Main Parsing Function ---
157
- # (No changes needed in the main loop logic, only in the helpers it calls)
158
  def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
159
  """
160
  Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
@@ -172,7 +125,16 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
172
  logger.info(
173
  f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
174
  )
175
- parser = OpenAPI30Parser(openapi_30)
 
 
 
 
 
 
 
 
 
176
  return parser.parse()
177
  else:
178
  # Default to OpenAPI 3.1 models
@@ -180,7 +142,16 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
180
  logger.info(
181
  f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
182
  )
183
- parser = OpenAPI31Parser(openapi_31)
 
 
 
 
 
 
 
 
 
184
  return parser.parse()
185
  except ValidationError as e:
186
  logger.error(f"OpenAPI schema validation failed: {e}")
@@ -189,151 +160,72 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
189
  raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
190
 
191
 
192
- # Base parser class for shared functionality
193
- class BaseOpenAPIParser:
194
- """Base class for OpenAPI parsers with common functionality."""
195
-
196
- def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
197
- """Convert string parameter location to our ParameterLocation type."""
198
- if param_in == "path":
199
- return "path"
200
- elif param_in == "query":
201
- return "query"
202
- elif param_in == "header":
203
- return "header"
204
- elif param_in == "cookie":
205
- return "cookie"
206
- else:
207
- logger.warning(
208
- f"Unknown parameter location: {param_in}, defaulting to 'query'"
209
- )
210
- return "query"
211
-
212
-
213
- class OpenAPI31Parser(BaseOpenAPIParser):
214
- """Parser for OpenAPI 3.1 schemas."""
215
 
216
- def __init__(self, openapi: OpenAPI):
 
 
 
 
 
 
 
 
 
 
 
217
  self.openapi = openapi
 
 
 
 
 
 
 
218
 
219
- def parse(self) -> list[HTTPRoute]:
220
- """Parse an OpenAPI 3.1 schema into HTTP routes."""
221
- routes: list[HTTPRoute] = []
222
-
223
- if not self.openapi.paths:
224
- logger.warning("OpenAPI schema has no paths defined.")
225
- return []
226
-
227
- # Extract component schemas to add to each route
228
- schema_definitions = {}
229
- if hasattr(self.openapi, "components") and self.openapi.components:
230
- components = self.openapi.components
231
- if hasattr(components, "schemas") and components.schemas:
232
- for name, schema in components.schemas.items():
233
- try:
234
- if isinstance(schema, Reference):
235
- resolved_schema = self._resolve_ref(schema)
236
- schema_definitions[name] = self._extract_schema_as_dict(
237
- resolved_schema
238
- )
239
- else:
240
- schema_definitions[name] = self._extract_schema_as_dict(
241
- schema
242
- )
243
- except Exception as e:
244
- logger.warning(
245
- f"Failed to extract schema definition '{name}': {e}"
246
- )
247
-
248
- for path_str, path_item_obj in self.openapi.paths.items():
249
- if not isinstance(path_item_obj, PathItem):
250
- logger.warning(
251
- f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})"
252
- )
253
- continue
254
-
255
- path_level_params = path_item_obj.parameters
256
-
257
- # Iterate through possible HTTP methods defined in the PathItem model fields
258
- # Use model_fields from the class, not the instance
259
- for method_lower in PathItem.model_fields.keys():
260
- if method_lower not in [
261
- "get",
262
- "put",
263
- "post",
264
- "delete",
265
- "options",
266
- "head",
267
- "patch",
268
- "trace",
269
- ]:
270
- continue
271
-
272
- operation: Operation | None = getattr(path_item_obj, method_lower, None)
273
-
274
- if operation and isinstance(operation, Operation):
275
- method_upper = cast(HttpMethod, method_lower.upper())
276
- logger.debug(f"Processing operation: {method_upper} {path_str}")
277
- try:
278
- parameters = self._extract_parameters(
279
- operation.parameters, path_level_params
280
- )
281
- request_body_info = self._extract_request_body(
282
- operation.requestBody
283
- )
284
- responses = self._extract_responses(operation.responses)
285
-
286
- route = HTTPRoute(
287
- path=path_str,
288
- method=method_upper,
289
- operation_id=operation.operationId,
290
- summary=operation.summary,
291
- description=operation.description,
292
- tags=operation.tags or [],
293
- parameters=parameters,
294
- request_body=request_body_info,
295
- responses=responses,
296
- schema_definitions=schema_definitions,
297
- )
298
- routes.append(route)
299
- logger.info(
300
- f"Successfully extracted route: {method_upper} {path_str}"
301
- )
302
- except Exception as op_error:
303
- op_id = operation.operationId or "unknown"
304
- logger.error(
305
- f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
306
- exc_info=True,
307
- )
308
-
309
- logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
310
- return routes
311
-
312
- def _resolve_ref(
313
- self, item: Reference | Schema | Parameter | RequestBody | Any
314
- ) -> Any:
315
- """Resolves a potential Reference object to its target definition."""
316
- if isinstance(item, Reference):
317
  ref_str = item.ref
318
  try:
319
  if not ref_str.startswith("#/"):
320
  raise ValueError(
321
  f"External or non-local reference not supported: {ref_str}"
322
  )
 
323
  parts = ref_str.strip("#/").split("/")
324
  target = self.openapi
 
325
  for part in parts:
326
  if part.isdigit() and isinstance(target, list):
327
  target = target[int(part)]
328
  elif isinstance(target, BaseModel):
329
- # Use model_extra for fields not explicitly defined (like components types)
330
  # Check class fields first, then model_extra
331
  if part in target.__class__.model_fields:
332
  target = getattr(target, part, None)
333
  elif target.model_extra and part in target.model_extra:
334
  target = target.model_extra[part]
335
  else:
336
- # Special handling for components sub-types common structure
337
  if part == "components" and hasattr(target, "components"):
338
  target = getattr(target, "components")
339
  elif hasattr(target, part): # Fallback check
@@ -344,123 +236,123 @@ class OpenAPI31Parser(BaseOpenAPIParser):
344
  target = target.get(part)
345
  else:
346
  raise ValueError(
347
- f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
348
  )
 
349
  if target is None:
350
  raise ValueError(
351
  f"Reference part '{part}' not found in path '{ref_str}'"
352
  )
353
- if isinstance(target, Reference):
 
 
354
  return self._resolve_ref(target)
 
355
  return target
356
  except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
357
  raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
 
358
  return item
359
 
360
- def _extract_schema_as_dict(self, schema_obj: Schema | Reference) -> JsonSchema:
361
- """Resolves a schema/reference and returns it as a dictionary."""
362
- resolved_schema = self._resolve_ref(schema_obj)
363
- if isinstance(resolved_schema, Schema):
364
- # Using exclude_none=True might be better than exclude_unset sometimes
365
- return resolved_schema.model_dump(
366
- mode="json", by_alias=True, exclude_none=True
367
- )
368
- elif isinstance(resolved_schema, dict):
369
- logger.warning(
370
- "Resolved schema reference resulted in a dict, not a Schema model."
371
- )
372
- return resolved_schema
373
- else:
374
- ref_str = getattr(schema_obj, "ref", "unknown")
375
- logger.warning(
376
- f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
377
- )
 
378
  return {}
379
 
380
  def _extract_parameters(
381
  self,
382
- operation_params: list[Parameter | Reference] | None,
383
- path_item_params: list[Parameter | Reference] | None,
384
  ) -> list[ParameterInfo]:
385
- """Extracts and resolves parameters using corrected attribute names."""
386
  extracted_params: list[ParameterInfo] = []
387
  seen_params: dict[
388
  tuple[str, str], bool
389
- ] = {} # Use string keys to avoid type issues
390
- all_params_refs = (operation_params or []) + (path_item_params or [])
391
 
392
- for param_or_ref in all_params_refs:
393
  try:
394
- parameter = cast(Parameter, self._resolve_ref(param_or_ref))
395
- if not isinstance(parameter, Parameter):
396
- # ... (error logging remains the same)
 
 
 
397
  continue
398
 
399
- # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
400
- param_in = parameter.param_in # CORRECTED: Use 'param_in'
401
  param_location = self._convert_to_parameter_location(param_in)
402
- param_schema_obj = (
403
- parameter.param_schema
404
- ) # CORRECTED: Use 'param_schema'
405
- # --- *** ---
406
 
 
407
  param_key = (parameter.name, param_in)
408
  if param_key in seen_params:
409
  continue
410
  seen_params[param_key] = True
411
 
 
412
  param_schema_dict = {}
413
- if param_schema_obj: # Check if schema exists
414
- # Resolve the schema if it's a reference
415
- resolved_schema = self._resolve_ref(param_schema_obj)
416
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
417
 
418
- # Ensure default value is preserved from resolved schema
 
419
  if (
420
- not isinstance(resolved_schema, Reference)
421
  and hasattr(resolved_schema, "default")
422
  and resolved_schema.default is not None
423
  ):
424
  param_schema_dict["default"] = resolved_schema.default
425
- elif parameter.content:
426
- # Handle complex parameters with 'content'
 
427
  first_media_type = next(iter(parameter.content.values()), None)
428
  if (
429
- first_media_type and first_media_type.media_type_schema
430
- ): # CORRECTED: Use 'media_type_schema'
431
- # Resolve the schema if it's a reference
 
432
  media_schema = first_media_type.media_type_schema
433
- resolved_media_schema = self._resolve_ref(media_schema)
434
  param_schema_dict = self._extract_schema_as_dict(media_schema)
435
 
436
- # Ensure default value is preserved from resolved schema
 
437
  if (
438
- not isinstance(resolved_media_schema, Reference)
439
  and hasattr(resolved_media_schema, "default")
440
  and resolved_media_schema.default is not None
441
  ):
442
  param_schema_dict["default"] = resolved_media_schema.default
443
 
444
- logger.debug(
445
- f"Parameter '{parameter.name}' using schema from 'content' field."
446
- )
447
-
448
- # Manually create ParameterInfo instance using correct field names
449
  param_info = ParameterInfo(
450
  name=parameter.name,
451
- location=param_location, # Use converted parameter location
452
  required=parameter.required,
453
- schema=param_schema_dict, # Populate 'schema' field in IR
454
  description=parameter.description,
455
  )
456
  extracted_params.append(param_info)
457
-
458
- except (
459
- ValidationError,
460
- ValueError,
461
- AttributeError,
462
- TypeError,
463
- ) as e: # Added TypeError
464
  param_name = getattr(
465
  param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
466
  )
@@ -470,52 +362,48 @@ class OpenAPI31Parser(BaseOpenAPIParser):
470
 
471
  return extracted_params
472
 
473
- def _extract_request_body(
474
- self, request_body_or_ref: RequestBody | Reference | None
475
- ) -> RequestBodyInfo | None:
476
- """Extracts and resolves the request body using corrected attribute names."""
477
  if not request_body_or_ref:
478
  return None
 
479
  try:
480
- request_body = cast(RequestBody, self._resolve_ref(request_body_or_ref))
481
- if not isinstance(request_body, RequestBody):
482
- # ... (error logging remains the same)
 
 
 
483
  return None
484
 
485
- content_schemas: dict[str, JsonSchema] = {}
486
- if request_body.content:
 
 
 
 
 
 
487
  for media_type_str, media_type_obj in request_body.content.items():
488
- # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
489
  if (
490
- isinstance(media_type_obj, MediaType)
 
491
  and media_type_obj.media_type_schema
492
- ): # CORRECTED: Use 'media_type_schema'
493
- # --- *** ---
494
  try:
495
- # Use the corrected attribute here as well
496
  schema_dict = self._extract_schema_as_dict(
497
  media_type_obj.media_type_schema
498
  )
499
- content_schemas[media_type_str] = schema_dict
500
- except ValueError as schema_err:
 
 
501
  logger.error(
502
- f"Failed to extract schema for media type '{media_type_str}' in request body: {schema_err}"
503
  )
504
- elif not isinstance(media_type_obj, MediaType):
505
- logger.warning(
506
- f"Skipping invalid media type object for '{media_type_str}' (type: {type(media_type_obj)}) in request body."
507
- )
508
- elif not media_type_obj.media_type_schema: # Corrected check
509
- logger.warning(
510
- f"Skipping media type '{media_type_str}' in request body because it lacks a schema."
511
- )
512
 
513
- return RequestBodyInfo(
514
- required=request_body.required,
515
- content_schema=content_schemas,
516
- description=request_body.description,
517
- )
518
- except (ValidationError, ValueError, AttributeError) as e:
519
  ref_name = getattr(request_body_or_ref, "ref", "unknown")
520
  logger.error(
521
  f"Failed to extract request body '{ref_name}': {e}", exc_info=False
@@ -523,47 +411,49 @@ class OpenAPI31Parser(BaseOpenAPIParser):
523
  return None
524
 
525
  def _extract_responses(
526
- self,
527
- operation_responses: dict[str, Response | Reference] | None,
528
  ) -> dict[str, ResponseInfo]:
529
- """Extracts and resolves response information for an operation."""
530
  extracted_responses: dict[str, ResponseInfo] = {}
 
531
  if not operation_responses:
532
  return extracted_responses
533
 
534
  for status_code, resp_or_ref in operation_responses.items():
535
  try:
536
- response = cast(Response, self._resolve_ref(resp_or_ref))
537
- if not isinstance(response, Response):
538
- ref_str = getattr(resp_or_ref, "ref", "unknown")
539
  logger.warning(
540
- f"Expected Response after resolving ref '{ref_str}' for status code {status_code}, got {type(response)}. Skipping."
 
541
  )
542
  continue
543
 
544
- content_schemas: dict[str, JsonSchema] = {}
545
- if response.content:
 
 
 
546
  for media_type_str, media_type_obj in response.content.items():
547
  if (
548
- isinstance(media_type_obj, MediaType)
 
549
  and media_type_obj.media_type_schema
550
  ):
551
  try:
552
  schema_dict = self._extract_schema_as_dict(
553
  media_type_obj.media_type_schema
554
  )
555
- content_schemas[media_type_str] = schema_dict
556
- except ValueError as schema_err:
557
  logger.error(
558
- f"Failed to extract schema for media type '{media_type_str}' in response {status_code}: {schema_err}"
 
559
  )
560
 
561
- resp_info = ResponseInfo(
562
- description=response.description, content_schema=content_schemas
563
- )
564
  extracted_responses[str(status_code)] = resp_info
565
-
566
- except (ValidationError, ValueError, AttributeError) as e:
567
  ref_name = getattr(resp_or_ref, "ref", "unknown")
568
  logger.error(
569
  f"Failed to extract response for status code {status_code} "
@@ -573,29 +463,22 @@ class OpenAPI31Parser(BaseOpenAPIParser):
573
 
574
  return extracted_responses
575
 
576
-
577
- class OpenAPI30Parser(BaseOpenAPIParser):
578
- """Parser for OpenAPI 3.0 schemas."""
579
-
580
- def __init__(self, openapi: OpenAPI_30):
581
- self.openapi = openapi
582
-
583
  def parse(self) -> list[HTTPRoute]:
584
- """Parse an OpenAPI 3.0 schema into HTTP routes."""
585
  routes: list[HTTPRoute] = []
586
 
587
- if not self.openapi.paths:
588
  logger.warning("OpenAPI schema has no paths defined.")
589
  return []
590
 
591
- # Extract component schemas to add to each route
592
  schema_definitions = {}
593
  if hasattr(self.openapi, "components") and self.openapi.components:
594
  components = self.openapi.components
595
  if hasattr(components, "schemas") and components.schemas:
596
  for name, schema in components.schemas.items():
597
  try:
598
- if isinstance(schema, Reference_30):
599
  resolved_schema = self._resolve_ref(schema)
600
  schema_definitions[name] = self._extract_schema_as_dict(
601
  resolved_schema
@@ -609,53 +492,58 @@ class OpenAPI30Parser(BaseOpenAPIParser):
609
  f"Failed to extract schema definition '{name}': {e}"
610
  )
611
 
 
612
  for path_str, path_item_obj in self.openapi.paths.items():
613
- if not isinstance(path_item_obj, PathItem_30):
614
  logger.warning(
615
- f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})"
616
  )
617
  continue
618
 
619
- path_level_params = path_item_obj.parameters
620
-
621
- # Iterate through possible HTTP methods defined in the PathItem model fields
622
- # Use model_fields from the class, not the instance
623
- for method_lower in PathItem_30.model_fields.keys():
624
- if method_lower not in [
625
- "get",
626
- "put",
627
- "post",
628
- "delete",
629
- "options",
630
- "head",
631
- "patch",
632
- "trace",
633
- ]:
634
- continue
635
 
636
- operation: Operation_30 | None = getattr(
637
- path_item_obj, method_lower, None
638
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
 
640
- if operation and isinstance(operation, Operation_30):
641
- method_upper = cast(HttpMethod, method_lower.upper())
642
- logger.debug(f"Processing operation: {method_upper} {path_str}")
643
  try:
644
  parameters = self._extract_parameters(
645
- operation.parameters, path_level_params
646
  )
 
647
  request_body_info = self._extract_request_body(
648
- operation.requestBody
 
 
 
 
649
  )
650
- responses = self._extract_responses(operation.responses)
651
 
652
  route = HTTPRoute(
653
  path=path_str,
654
- method=method_upper,
655
- operation_id=operation.operationId,
656
- summary=operation.summary,
657
- description=operation.description,
658
- tags=operation.tags or [],
659
  parameters=parameters,
660
  request_body=request_body_info,
661
  responses=responses,
@@ -666,7 +554,7 @@ class OpenAPI30Parser(BaseOpenAPIParser):
666
  f"Successfully extracted route: {method_upper} {path_str}"
667
  )
668
  except Exception as op_error:
669
- op_id = operation.operationId or "unknown"
670
  logger.error(
671
  f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
672
  exc_info=True,
@@ -675,257 +563,6 @@ class OpenAPI30Parser(BaseOpenAPIParser):
675
  logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
676
  return routes
677
 
678
- def _resolve_ref(
679
- self, item: Reference_30 | Schema_30 | Parameter_30 | RequestBody_30 | Any
680
- ) -> Any:
681
- """Resolves a potential Reference object to its target definition for OpenAPI 3.0."""
682
- if isinstance(item, Reference_30):
683
- ref_str = item.ref
684
- try:
685
- if not ref_str.startswith("#/"):
686
- raise ValueError(
687
- f"External or non-local reference not supported: {ref_str}"
688
- )
689
- parts = ref_str.strip("#/").split("/")
690
- target = self.openapi
691
- for part in parts:
692
- if part.isdigit() and isinstance(target, list):
693
- target = target[int(part)]
694
- elif isinstance(target, BaseModel):
695
- # Use model_extra for fields not explicitly defined (like components types)
696
- # Check class fields first, then model_extra
697
- if part in target.__class__.model_fields:
698
- target = getattr(target, part, None)
699
- elif target.model_extra and part in target.model_extra:
700
- target = target.model_extra[part]
701
- else:
702
- # Special handling for components sub-types common structure
703
- if part == "components" and hasattr(target, "components"):
704
- target = getattr(target, "components")
705
- elif hasattr(target, part): # Fallback check
706
- target = getattr(target, part, None)
707
- else:
708
- target = None # Part not found
709
- elif isinstance(target, dict):
710
- target = target.get(part)
711
- else:
712
- raise ValueError(
713
- f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
714
- )
715
- if target is None:
716
- raise ValueError(
717
- f"Reference part '{part}' not found in path '{ref_str}'"
718
- )
719
- if isinstance(target, Reference_30):
720
- return self._resolve_ref(target)
721
- return target
722
- except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
723
- raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
724
- return item
725
-
726
- def _extract_schema_as_dict(
727
- self, schema_obj: Schema_30 | Reference_30
728
- ) -> JsonSchema:
729
- """Resolves a schema/reference and returns it as a dictionary for OpenAPI 3.0."""
730
- resolved_schema = self._resolve_ref(schema_obj)
731
- if isinstance(resolved_schema, Schema_30):
732
- # Using exclude_none=True might be better than exclude_unset sometimes
733
- return resolved_schema.model_dump(
734
- mode="json", by_alias=True, exclude_none=True
735
- )
736
- elif isinstance(resolved_schema, dict):
737
- logger.warning(
738
- "Resolved schema reference resulted in a dict, not a Schema model."
739
- )
740
- return resolved_schema
741
- else:
742
- ref_str = getattr(schema_obj, "ref", "unknown")
743
- logger.warning(
744
- f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
745
- )
746
- return {}
747
-
748
- def _extract_parameters(
749
- self,
750
- operation_params: list[Parameter_30 | Reference_30] | None,
751
- path_item_params: list[Parameter_30 | Reference_30] | None,
752
- ) -> list[ParameterInfo]:
753
- """Extracts and resolves parameters for OpenAPI 3.0."""
754
- extracted_params: list[ParameterInfo] = []
755
- seen_params: dict[
756
- tuple[str, str], bool
757
- ] = {} # Use string keys to avoid type issues
758
- all_params_refs = (operation_params or []) + (path_item_params or [])
759
-
760
- for param_or_ref in all_params_refs:
761
- try:
762
- parameter = cast(Parameter_30, self._resolve_ref(param_or_ref))
763
- if not isinstance(parameter, Parameter_30):
764
- logger.warning(
765
- f"Expected Parameter after resolving reference, got {type(parameter)}. Skipping."
766
- )
767
- continue
768
-
769
- # OpenAPI 3.0 uses 'in' field for parameter location
770
- param_in = parameter.param_in
771
- param_location = self._convert_to_parameter_location(param_in)
772
- param_schema_obj = parameter.param_schema
773
-
774
- param_key = (parameter.name, param_in)
775
- if param_key in seen_params:
776
- continue
777
- seen_params[param_key] = True
778
-
779
- param_schema_dict = {}
780
- if param_schema_obj: # Check if schema exists
781
- # Resolve the schema if it's a reference
782
- resolved_schema = self._resolve_ref(param_schema_obj)
783
- param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
784
-
785
- # Ensure default value is preserved from resolved schema
786
- if (
787
- not isinstance(resolved_schema, Reference_30)
788
- and hasattr(resolved_schema, "default")
789
- and resolved_schema.default is not None
790
- ):
791
- param_schema_dict["default"] = resolved_schema.default
792
- elif parameter.content:
793
- # Handle complex parameters with 'content'
794
- first_media_type = next(iter(parameter.content.values()), None)
795
- if first_media_type and first_media_type.media_type_schema:
796
- # Resolve the schema if it's a reference
797
- media_schema = first_media_type.media_type_schema
798
- resolved_media_schema = self._resolve_ref(media_schema)
799
- param_schema_dict = self._extract_schema_as_dict(media_schema)
800
-
801
- # Ensure default value is preserved from resolved schema
802
- if (
803
- not isinstance(resolved_media_schema, Reference_30)
804
- and hasattr(resolved_media_schema, "default")
805
- and resolved_media_schema.default is not None
806
- ):
807
- param_schema_dict["default"] = resolved_media_schema.default
808
-
809
- logger.debug(
810
- f"Parameter '{parameter.name}' using schema from 'content' field."
811
- )
812
-
813
- # Manually create ParameterInfo instance using correct field names
814
- param_info = ParameterInfo(
815
- name=parameter.name,
816
- location=param_location, # Use converted parameter location
817
- required=parameter.required,
818
- schema=param_schema_dict, # Populate 'schema' field in IR
819
- description=parameter.description,
820
- )
821
- extracted_params.append(param_info)
822
-
823
- except (
824
- ValidationError,
825
- ValueError,
826
- AttributeError,
827
- TypeError,
828
- ) as e: # Added TypeError
829
- param_name = getattr(
830
- param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
831
- )
832
- logger.error(
833
- f"Failed to extract parameter '{param_name}': {e}", exc_info=False
834
- )
835
-
836
- return extracted_params
837
-
838
- def _extract_request_body(
839
- self, request_body_or_ref: RequestBody_30 | Reference_30 | None
840
- ) -> RequestBodyInfo | None:
841
- """Extracts request body information for OpenAPI 3.0 using correct attribute names."""
842
- if request_body_or_ref is None:
843
- return None
844
-
845
- try:
846
- request_body = cast(RequestBody_30, self._resolve_ref(request_body_or_ref))
847
-
848
- if not isinstance(request_body, RequestBody_30):
849
- logger.warning(
850
- f"Expected RequestBody after resolving reference, got {type(request_body)}. Returning None."
851
- )
852
- return None
853
-
854
- request_body_info = RequestBodyInfo(
855
- required=request_body.required,
856
- description=request_body.description,
857
- )
858
-
859
- # Process content field for request body schemas
860
- if request_body.content:
861
- for media_type_key, media_type_obj in request_body.content.items():
862
- if (
863
- media_type_obj and media_type_obj.media_type_schema
864
- ): # CORRECTED: Use 'media_type_schema'
865
- schema_dict = self._extract_schema_as_dict(
866
- media_type_obj.media_type_schema
867
- )
868
- request_body_info.content_schema[media_type_key] = schema_dict
869
-
870
- return request_body_info
871
-
872
- except (ValidationError, ValueError, AttributeError) as e:
873
- ref_str = getattr(request_body_or_ref, "ref", "unknown")
874
- logger.error(
875
- f"Failed to extract request body info from reference '{ref_str}': {e}",
876
- exc_info=False,
877
- )
878
- return None
879
-
880
- def _extract_responses(
881
- self,
882
- operation_responses: dict[str, Response_30 | Reference_30] | None,
883
- ) -> dict[str, ResponseInfo]:
884
- """Extracts response information from an OpenAPI 3.0 operation's responses."""
885
- extracted_responses: dict[str, ResponseInfo] = {}
886
- if not operation_responses:
887
- return extracted_responses
888
-
889
- for status_code, response_or_ref in operation_responses.items():
890
- try:
891
- # Skip 'default' response for simplicity if needed
892
- # if status_code == "default":
893
- # continue
894
-
895
- response = cast(Response_30, self._resolve_ref(response_or_ref))
896
-
897
- if not isinstance(response, Response_30):
898
- logger.warning(
899
- f"Expected Response after resolving reference for status code {status_code}, "
900
- f"got {type(response)}. Skipping."
901
- )
902
- continue
903
-
904
- response_info = ResponseInfo(description=response.description)
905
-
906
- # Extract content schemas if present
907
- if response.content:
908
- for media_type_key, media_type_obj in response.content.items():
909
- if (
910
- media_type_obj and media_type_obj.media_type_schema
911
- ): # CORRECTED: Use 'media_type_schema'
912
- schema_dict = self._extract_schema_as_dict(
913
- media_type_obj.media_type_schema
914
- )
915
- response_info.content_schema[media_type_key] = schema_dict
916
-
917
- extracted_responses[status_code] = response_info
918
-
919
- except (ValidationError, ValueError, AttributeError) as e:
920
- ref_str = getattr(response_or_ref, "ref", "unknown")
921
- logger.error(
922
- f"Failed to extract response info for status code {status_code} "
923
- f"from reference '{ref_str}': {e}",
924
- exc_info=False,
925
- )
926
-
927
- return extracted_responses
928
-
929
 
930
  def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
931
  """
@@ -956,6 +593,7 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
956
  # "multipleOf", "minItems", "maxItems", "uniqueItems",
957
  # "minProperties", "maxProperties"
958
  ]
 
959
  for field in fields_to_remove:
960
  if field in cleaned:
961
  cleaned.pop(field)
@@ -985,11 +623,6 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
985
  # Maybe keep 'true' or represent as 'Allows additional properties' text?
986
  pass # Keep simple boolean for now
987
 
988
- # Remove title if it just repeats the property name (heuristic)
989
- # This requires knowing the property name, so better done when formatting properties dict
990
-
991
- return cleaned
992
-
993
 
994
  def generate_example_from_schema(schema: JsonSchema | None) -> Any:
995
  """
@@ -1088,8 +721,8 @@ def format_description_with_responses(
1088
  responses: dict[
1089
  str, Any
1090
  ], # Changed from specific ResponseInfo type to avoid circular imports
1091
- parameters: list[openapi.ParameterInfo] | None = None, # Add parameters parameter
1092
- request_body: openapi.RequestBodyInfo | None = None, # Add request_body parameter
1093
  ) -> str:
1094
  """
1095
  Formats the base description string with response, parameter, and request body information.
@@ -1097,10 +730,10 @@ def format_description_with_responses(
1097
  Args:
1098
  base_description (str): The initial description to be formatted.
1099
  responses (dict[str, Any]): A dictionary of response information, keyed by status code.
1100
- parameters (list[openapi.ParameterInfo] | None, optional): A list of parameter information,
1101
  including path and query parameters. Each parameter includes details such as name,
1102
  location, whether it is required, and a description.
1103
- request_body (openapi.RequestBodyInfo | None, optional): Information about the request body,
1104
  including its description, whether it is required, and its content schema.
1105
 
1106
  Returns:
@@ -1239,7 +872,7 @@ def format_description_with_responses(
1239
  return "\n".join(desc_parts)
1240
 
1241
 
1242
- def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
1243
  """
1244
  Combines parameter and request body schemas into a single schema.
1245
 
@@ -1308,8 +941,6 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
1308
  result["$defs"] = route.schema_definitions
1309
 
1310
  # Use compress_schema to remove unused definitions
1311
- from fastmcp.utilities.json_schema import compress_schema
1312
-
1313
  result = compress_schema(result)
1314
 
1315
  return result
 
1
  import json
2
  import logging
3
+ from typing import Any, Generic, Literal, TypeVar
4
 
 
5
  from openapi_pydantic import (
 
6
  OpenAPI,
7
  Operation,
8
  Parameter,
 
24
  from openapi_pydantic.v3.v3_0 import Schema as Schema_30
25
  from pydantic import BaseModel, Field, ValidationError
26
 
27
+ from fastmcp.utilities.json_schema import compress_schema
28
 
29
  logger = logging.getLogger(__name__)
30
 
 
47
  schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
48
  description: str | None = None
49
 
 
 
50
 
51
  class RequestBodyInfo(BaseModel):
52
  """Represents the request body for an HTTP operation in our IR."""
 
97
  "parse_openapi_to_http_routes",
98
  ]
99
 
100
+ # Type variables for generic parser
101
+ TOpenAPI = TypeVar("TOpenAPI", OpenAPI, OpenAPI_30)
102
+ TSchema = TypeVar("TSchema", Schema, Schema_30)
103
+ TReference = TypeVar("TReference", Reference, Reference_30)
104
+ TParameter = TypeVar("TParameter", Parameter, Parameter_30)
105
+ TRequestBody = TypeVar("TRequestBody", RequestBody, RequestBody_30)
106
+ TResponse = TypeVar("TResponse", Response, Response_30)
107
+ TOperation = TypeVar("TOperation", Operation, Operation_30)
108
+ TPathItem = TypeVar("TPathItem", PathItem, PathItem_30)
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
112
  """
113
  Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
 
125
  logger.info(
126
  f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
127
  )
128
+ parser = OpenAPIParser(
129
+ openapi_30,
130
+ Reference_30,
131
+ Schema_30,
132
+ Parameter_30,
133
+ RequestBody_30,
134
+ Response_30,
135
+ Operation_30,
136
+ PathItem_30,
137
+ )
138
  return parser.parse()
139
  else:
140
  # Default to OpenAPI 3.1 models
 
142
  logger.info(
143
  f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
144
  )
145
+ parser = OpenAPIParser(
146
+ openapi_31,
147
+ Reference,
148
+ Schema,
149
+ Parameter,
150
+ RequestBody,
151
+ Response,
152
+ Operation,
153
+ PathItem,
154
+ )
155
  return parser.parse()
156
  except ValidationError as e:
157
  logger.error(f"OpenAPI schema validation failed: {e}")
 
160
  raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
161
 
162
 
163
+ class OpenAPIParser(
164
+ Generic[
165
+ TOpenAPI,
166
+ TReference,
167
+ TSchema,
168
+ TParameter,
169
+ TRequestBody,
170
+ TResponse,
171
+ TOperation,
172
+ TPathItem,
173
+ ]
174
+ ):
175
+ """Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1."""
 
 
 
 
 
 
 
 
 
 
176
 
177
+ def __init__(
178
+ self,
179
+ openapi: TOpenAPI,
180
+ reference_cls: type[TReference],
181
+ schema_cls: type[TSchema],
182
+ parameter_cls: type[TParameter],
183
+ request_body_cls: type[TRequestBody],
184
+ response_cls: type[TResponse],
185
+ operation_cls: type[TOperation],
186
+ path_item_cls: type[TPathItem],
187
+ ):
188
+ """Initialize the parser with the OpenAPI schema and type classes."""
189
  self.openapi = openapi
190
+ self.reference_cls = reference_cls
191
+ self.schema_cls = schema_cls
192
+ self.parameter_cls = parameter_cls
193
+ self.request_body_cls = request_body_cls
194
+ self.response_cls = response_cls
195
+ self.operation_cls = operation_cls
196
+ self.path_item_cls = path_item_cls
197
 
198
+ def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
199
+ """Convert string parameter location to our ParameterLocation type."""
200
+ if param_in in ["path", "query", "header", "cookie"]:
201
+ return param_in # type: ignore[return-value] # Safe cast since we checked values
202
+ logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'")
203
+ return "query" # type: ignore[return-value] # Safe cast to default value
204
+
205
+ def _resolve_ref(self, item: Any) -> Any:
206
+ """Resolves a reference to its target definition."""
207
+ if isinstance(item, self.reference_cls):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  ref_str = item.ref
209
  try:
210
  if not ref_str.startswith("#/"):
211
  raise ValueError(
212
  f"External or non-local reference not supported: {ref_str}"
213
  )
214
+
215
  parts = ref_str.strip("#/").split("/")
216
  target = self.openapi
217
+
218
  for part in parts:
219
  if part.isdigit() and isinstance(target, list):
220
  target = target[int(part)]
221
  elif isinstance(target, BaseModel):
 
222
  # Check class fields first, then model_extra
223
  if part in target.__class__.model_fields:
224
  target = getattr(target, part, None)
225
  elif target.model_extra and part in target.model_extra:
226
  target = target.model_extra[part]
227
  else:
228
+ # Special handling for components
229
  if part == "components" and hasattr(target, "components"):
230
  target = getattr(target, "components")
231
  elif hasattr(target, part): # Fallback check
 
236
  target = target.get(part)
237
  else:
238
  raise ValueError(
239
+ f"Cannot traverse part '{part}' in reference '{ref_str}'"
240
  )
241
+
242
  if target is None:
243
  raise ValueError(
244
  f"Reference part '{part}' not found in path '{ref_str}'"
245
  )
246
+
247
+ # Handle nested references
248
+ if isinstance(target, self.reference_cls):
249
  return self._resolve_ref(target)
250
+
251
  return target
252
  except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
253
  raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
254
+
255
  return item
256
 
257
+ def _extract_schema_as_dict(self, schema_obj: Any) -> JsonSchema:
258
+ """Resolves a schema and returns it as a dictionary."""
259
+ try:
260
+ resolved_schema = self._resolve_ref(schema_obj)
261
+
262
+ if isinstance(resolved_schema, (self.schema_cls)):
263
+ # Convert schema to dictionary
264
+ return resolved_schema.model_dump(
265
+ mode="json", by_alias=True, exclude_none=True
266
+ )
267
+ elif isinstance(resolved_schema, dict):
268
+ return resolved_schema
269
+ else:
270
+ logger.warning(
271
+ f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict."
272
+ )
273
+ return {}
274
+ except Exception as e:
275
+ logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
276
  return {}
277
 
278
  def _extract_parameters(
279
  self,
280
+ operation_params: list[Any] | None = None,
281
+ path_item_params: list[Any] | None = None,
282
  ) -> list[ParameterInfo]:
283
+ """Extract and resolve parameters from operation and path item."""
284
  extracted_params: list[ParameterInfo] = []
285
  seen_params: dict[
286
  tuple[str, str], bool
287
+ ] = {} # Use tuple of (name, location) as key
288
+ all_params = (operation_params or []) + (path_item_params or [])
289
 
290
+ for param_or_ref in all_params:
291
  try:
292
+ parameter = self._resolve_ref(param_or_ref)
293
+
294
+ if not isinstance(parameter, self.parameter_cls):
295
+ logger.warning(
296
+ f"Expected Parameter after resolving, got {type(parameter)}. Skipping."
297
+ )
298
  continue
299
 
300
+ # Extract parameter info - handle both 3.0 and 3.1 parameter models
301
+ param_in = parameter.param_in # Both use param_in
302
  param_location = self._convert_to_parameter_location(param_in)
303
+ param_schema_obj = parameter.param_schema # Both use param_schema
 
 
 
304
 
305
+ # Skip duplicate parameters (same name and location)
306
  param_key = (parameter.name, param_in)
307
  if param_key in seen_params:
308
  continue
309
  seen_params[param_key] = True
310
 
311
+ # Extract schema
312
  param_schema_dict = {}
313
+ if param_schema_obj:
314
+ # Process schema object
 
315
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
316
 
317
+ # Handle default value
318
+ resolved_schema = self._resolve_ref(param_schema_obj)
319
  if (
320
+ not isinstance(resolved_schema, self.reference_cls)
321
  and hasattr(resolved_schema, "default")
322
  and resolved_schema.default is not None
323
  ):
324
  param_schema_dict["default"] = resolved_schema.default
325
+
326
+ elif hasattr(parameter, "content") and parameter.content:
327
+ # Handle content-based parameters
328
  first_media_type = next(iter(parameter.content.values()), None)
329
  if (
330
+ first_media_type
331
+ and hasattr(first_media_type, "media_type_schema")
332
+ and first_media_type.media_type_schema
333
+ ):
334
  media_schema = first_media_type.media_type_schema
 
335
  param_schema_dict = self._extract_schema_as_dict(media_schema)
336
 
337
+ # Handle default value in content schema
338
+ resolved_media_schema = self._resolve_ref(media_schema)
339
  if (
340
+ not isinstance(resolved_media_schema, self.reference_cls)
341
  and hasattr(resolved_media_schema, "default")
342
  and resolved_media_schema.default is not None
343
  ):
344
  param_schema_dict["default"] = resolved_media_schema.default
345
 
346
+ # Create parameter info object
 
 
 
 
347
  param_info = ParameterInfo(
348
  name=parameter.name,
349
+ location=param_location,
350
  required=parameter.required,
351
+ schema=param_schema_dict,
352
  description=parameter.description,
353
  )
354
  extracted_params.append(param_info)
355
+ except Exception as e:
 
 
 
 
 
 
356
  param_name = getattr(
357
  param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
358
  )
 
362
 
363
  return extracted_params
364
 
365
+ def _extract_request_body(self, request_body_or_ref: Any) -> RequestBodyInfo | None:
366
+ """Extract and resolve request body information."""
 
 
367
  if not request_body_or_ref:
368
  return None
369
+
370
  try:
371
+ request_body = self._resolve_ref(request_body_or_ref)
372
+
373
+ if not isinstance(request_body, self.request_body_cls):
374
+ logger.warning(
375
+ f"Expected RequestBody after resolving, got {type(request_body)}. Returning None."
376
+ )
377
  return None
378
 
379
+ # Create request body info
380
+ request_body_info = RequestBodyInfo(
381
+ required=request_body.required,
382
+ description=request_body.description,
383
+ )
384
+
385
+ # Extract content schemas
386
+ if hasattr(request_body, "content") and request_body.content:
387
  for media_type_str, media_type_obj in request_body.content.items():
 
388
  if (
389
+ media_type_obj
390
+ and hasattr(media_type_obj, "media_type_schema")
391
  and media_type_obj.media_type_schema
392
+ ):
 
393
  try:
 
394
  schema_dict = self._extract_schema_as_dict(
395
  media_type_obj.media_type_schema
396
  )
397
+ request_body_info.content_schema[media_type_str] = (
398
+ schema_dict
399
+ )
400
+ except Exception as e:
401
  logger.error(
402
+ f"Failed to extract schema for media type '{media_type_str}': {e}"
403
  )
 
 
 
 
 
 
 
 
404
 
405
+ return request_body_info
406
+ except Exception as e:
 
 
 
 
407
  ref_name = getattr(request_body_or_ref, "ref", "unknown")
408
  logger.error(
409
  f"Failed to extract request body '{ref_name}': {e}", exc_info=False
 
411
  return None
412
 
413
  def _extract_responses(
414
+ self, operation_responses: dict[str, Any] | None
 
415
  ) -> dict[str, ResponseInfo]:
416
+ """Extract and resolve response information."""
417
  extracted_responses: dict[str, ResponseInfo] = {}
418
+
419
  if not operation_responses:
420
  return extracted_responses
421
 
422
  for status_code, resp_or_ref in operation_responses.items():
423
  try:
424
+ response = self._resolve_ref(resp_or_ref)
425
+
426
+ if not isinstance(response, self.response_cls):
427
  logger.warning(
428
+ f"Expected Response after resolving for status code {status_code}, "
429
+ f"got {type(response)}. Skipping."
430
  )
431
  continue
432
 
433
+ # Create response info
434
+ resp_info = ResponseInfo(description=response.description)
435
+
436
+ # Extract content schemas
437
+ if hasattr(response, "content") and response.content:
438
  for media_type_str, media_type_obj in response.content.items():
439
  if (
440
+ media_type_obj
441
+ and hasattr(media_type_obj, "media_type_schema")
442
  and media_type_obj.media_type_schema
443
  ):
444
  try:
445
  schema_dict = self._extract_schema_as_dict(
446
  media_type_obj.media_type_schema
447
  )
448
+ resp_info.content_schema[media_type_str] = schema_dict
449
+ except Exception as e:
450
  logger.error(
451
+ f"Failed to extract schema for media type '{media_type_str}' "
452
+ f"in response {status_code}: {e}"
453
  )
454
 
 
 
 
455
  extracted_responses[str(status_code)] = resp_info
456
+ except Exception as e:
 
457
  ref_name = getattr(resp_or_ref, "ref", "unknown")
458
  logger.error(
459
  f"Failed to extract response for status code {status_code} "
 
463
 
464
  return extracted_responses
465
 
 
 
 
 
 
 
 
466
  def parse(self) -> list[HTTPRoute]:
467
+ """Parse the OpenAPI schema into HTTP routes."""
468
  routes: list[HTTPRoute] = []
469
 
470
+ if not hasattr(self.openapi, "paths") or not self.openapi.paths:
471
  logger.warning("OpenAPI schema has no paths defined.")
472
  return []
473
 
474
+ # Extract component schemas
475
  schema_definitions = {}
476
  if hasattr(self.openapi, "components") and self.openapi.components:
477
  components = self.openapi.components
478
  if hasattr(components, "schemas") and components.schemas:
479
  for name, schema in components.schemas.items():
480
  try:
481
+ if isinstance(schema, self.reference_cls):
482
  resolved_schema = self._resolve_ref(schema)
483
  schema_definitions[name] = self._extract_schema_as_dict(
484
  resolved_schema
 
492
  f"Failed to extract schema definition '{name}': {e}"
493
  )
494
 
495
+ # Process paths and operations
496
  for path_str, path_item_obj in self.openapi.paths.items():
497
+ if not isinstance(path_item_obj, self.path_item_cls):
498
  logger.warning(
499
+ f"Skipping invalid path item for path '{path_str}' (type: {type(path_item_obj)})"
500
  )
501
  continue
502
 
503
+ path_level_params = (
504
+ path_item_obj.parameters
505
+ if hasattr(path_item_obj, "parameters")
506
+ else None
507
+ )
 
 
 
 
 
 
 
 
 
 
 
508
 
509
+ # Get HTTP methods from the path item class fields
510
+ http_methods = [
511
+ "get",
512
+ "put",
513
+ "post",
514
+ "delete",
515
+ "options",
516
+ "head",
517
+ "patch",
518
+ "trace",
519
+ ]
520
+ for method_lower in http_methods:
521
+ operation = getattr(path_item_obj, method_lower, None)
522
+
523
+ if operation and isinstance(operation, self.operation_cls):
524
+ # Cast method to HttpMethod - safe since we only use valid HTTP methods
525
+ method_upper = method_lower.upper()
526
 
 
 
 
527
  try:
528
  parameters = self._extract_parameters(
529
+ getattr(operation, "parameters", None), path_level_params
530
  )
531
+
532
  request_body_info = self._extract_request_body(
533
+ getattr(operation, "requestBody", None)
534
+ )
535
+
536
+ responses = self._extract_responses(
537
+ getattr(operation, "responses", None)
538
  )
 
539
 
540
  route = HTTPRoute(
541
  path=path_str,
542
+ method=method_upper, # type: ignore[arg-type] # Known valid HTTP method
543
+ operation_id=getattr(operation, "operationId", None),
544
+ summary=getattr(operation, "summary", None),
545
+ description=getattr(operation, "description", None),
546
+ tags=getattr(operation, "tags", []) or [],
547
  parameters=parameters,
548
  request_body=request_body_info,
549
  responses=responses,
 
554
  f"Successfully extracted route: {method_upper} {path_str}"
555
  )
556
  except Exception as op_error:
557
+ op_id = getattr(operation, "operationId", "unknown")
558
  logger.error(
559
  f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
560
  exc_info=True,
 
563
  logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
564
  return routes
565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
 
567
  def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
568
  """
 
593
  # "multipleOf", "minItems", "maxItems", "uniqueItems",
594
  # "minProperties", "maxProperties"
595
  ]
596
+
597
  for field in fields_to_remove:
598
  if field in cleaned:
599
  cleaned.pop(field)
 
623
  # Maybe keep 'true' or represent as 'Allows additional properties' text?
624
  pass # Keep simple boolean for now
625
 
 
 
 
 
 
626
 
627
  def generate_example_from_schema(schema: JsonSchema | None) -> Any:
628
  """
 
721
  responses: dict[
722
  str, Any
723
  ], # Changed from specific ResponseInfo type to avoid circular imports
724
+ parameters: list[ParameterInfo] | None = None, # Add parameters parameter
725
+ request_body: RequestBodyInfo | None = None, # Add request_body parameter
726
  ) -> str:
727
  """
728
  Formats the base description string with response, parameter, and request body information.
 
730
  Args:
731
  base_description (str): The initial description to be formatted.
732
  responses (dict[str, Any]): A dictionary of response information, keyed by status code.
733
+ parameters (list[ParameterInfo] | None, optional): A list of parameter information,
734
  including path and query parameters. Each parameter includes details such as name,
735
  location, whether it is required, and a description.
736
+ request_body (RequestBodyInfo | None, optional): Information about the request body,
737
  including its description, whether it is required, and its content schema.
738
 
739
  Returns:
 
872
  return "\n".join(desc_parts)
873
 
874
 
875
+ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
876
  """
877
  Combines parameter and request body schemas into a single schema.
878
 
 
941
  result["$defs"] = route.schema_definitions
942
 
943
  # Use compress_schema to remove unused definitions
 
 
944
  result = compress_schema(result)
945
 
946
  return result
tests/server/test_openapi_path_parameters.py CHANGED
@@ -188,3 +188,73 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client):
188
  json=None,
189
  timeout=None,
190
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  json=None,
189
  timeout=None,
190
  )
191
+
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_complex_nested_array_path_parameter(mock_client):
195
+ """Test handling of complex nested array path parameters."""
196
+ # Create a route with a path parameter that contains nested objects in an array
197
+ route = HTTPRoute(
198
+ path="/report/{filters}",
199
+ method="GET",
200
+ operation_id="test-complex-filters",
201
+ parameters=[
202
+ ParameterInfo(
203
+ name="filters",
204
+ location="path",
205
+ required=True,
206
+ schema={
207
+ "type": "array",
208
+ "items": {
209
+ "type": "object",
210
+ "properties": {
211
+ "field": {"type": "string"},
212
+ "value": {"type": "string"},
213
+ },
214
+ },
215
+ },
216
+ )
217
+ ],
218
+ )
219
+
220
+ # Create the tool
221
+ tool = OpenAPITool(
222
+ client=mock_client,
223
+ route=route,
224
+ name="test-complex-filters",
225
+ description="Test operation with complex filters",
226
+ parameters={},
227
+ )
228
+
229
+ # Test with a more complex path parameter
230
+ # This would typically be serialized as JSON or a more complex format
231
+ # But for path parameters with style=simple, it should be comma-separated
232
+ complex_filters = [
233
+ {"field": "status", "value": "active"},
234
+ {"field": "type", "value": "user"},
235
+ ]
236
+
237
+ # Execute the request with complex filters
238
+ await tool._execute_request(filters=complex_filters)
239
+
240
+ # The complex object should be properly serialized in the URL
241
+ # For path parameters, this would typically need a custom serialization strategy
242
+ # but our implementation should handle it safely
243
+ call_args = mock_client.request.call_args
244
+
245
+ # Verify the request was made
246
+ assert call_args is not None, "The request was not made"
247
+
248
+ # Get the called URL and verify it contains the serialized path parameter
249
+ called_url = call_args[1].get("url")
250
+
251
+ # Check that the path parameter is handled (we don't expect perfect serialization,
252
+ # but it should not cause errors and should maintain the array structure)
253
+ assert "/report/" in called_url, "The URL should contain the path prefix"
254
+
255
+ # Check that it didn't just convert the objects to string representations
256
+ # that include the Python object syntax
257
+ assert "status" in called_url, "The URL should contain filter field names"
258
+ assert "active" in called_url, "The URL should contain filter values"
259
+ assert "}" not in called_url, "The URL should not contain Python object syntax"
260
+ assert "{" not in called_url, "The URL should not contain Python object syntax"