fastmcp 2.1.0__py3-none-any.whl → 2.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,16 @@ from openapi_pydantic import (
14
14
  Response,
15
15
  Schema,
16
16
  )
17
+
18
+ # Import OpenAPI 3.0 models as well
19
+ from openapi_pydantic.v3.v3_0 import OpenAPI as OpenAPI_30
20
+ from openapi_pydantic.v3.v3_0 import Operation as Operation_30
21
+ from openapi_pydantic.v3.v3_0 import Parameter as Parameter_30
22
+ from openapi_pydantic.v3.v3_0 import PathItem as PathItem_30
23
+ from openapi_pydantic.v3.v3_0 import Reference as Reference_30
24
+ from openapi_pydantic.v3.v3_0 import RequestBody as RequestBody_30
25
+ from openapi_pydantic.v3.v3_0 import Response as Response_30
26
+ from openapi_pydantic.v3.v3_0 import Schema as Schema_30
17
27
  from pydantic import BaseModel, Field, ValidationError
18
28
 
19
29
  from fastmcp.utilities import openapi
@@ -176,131 +186,6 @@ def _convert_to_parameter_location(param_in: str) -> ParameterLocation:
176
186
  return "query"
177
187
 
178
188
 
179
- def _extract_parameters(
180
- operation_params: list[Parameter | Reference] | None,
181
- path_item_params: list[Parameter | Reference] | None,
182
- openapi: OpenAPI,
183
- ) -> list[ParameterInfo]:
184
- """Extracts and resolves parameters using corrected attribute names."""
185
- extracted_params: list[ParameterInfo] = []
186
- seen_params: dict[
187
- tuple[str, str], bool
188
- ] = {} # Use string keys to avoid type issues
189
- all_params_refs = (operation_params or []) + (path_item_params or [])
190
-
191
- for param_or_ref in all_params_refs:
192
- try:
193
- parameter = cast(Parameter, _resolve_ref(param_or_ref, openapi))
194
- if not isinstance(parameter, Parameter):
195
- # ... (error logging remains the same)
196
- continue
197
-
198
- # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
199
- param_in = parameter.param_in # CORRECTED: Use 'param_in'
200
- param_location = _convert_to_parameter_location(param_in)
201
- param_schema_obj = parameter.param_schema # CORRECTED: Use 'param_schema'
202
- # --- *** ---
203
-
204
- param_key = (parameter.name, param_in)
205
- if param_key in seen_params:
206
- continue
207
- seen_params[param_key] = True
208
-
209
- param_schema_dict = {}
210
- if param_schema_obj: # Check if schema exists
211
- param_schema_dict = _extract_schema_as_dict(param_schema_obj, openapi)
212
- elif parameter.content:
213
- # Handle complex parameters with 'content'
214
- first_media_type = next(iter(parameter.content.values()), None)
215
- if (
216
- first_media_type and first_media_type.media_type_schema
217
- ): # CORRECTED: Use 'media_type_schema'
218
- param_schema_dict = _extract_schema_as_dict(
219
- first_media_type.media_type_schema, openapi
220
- )
221
- logger.debug(
222
- f"Parameter '{parameter.name}' using schema from 'content' field."
223
- )
224
-
225
- # Manually create ParameterInfo instance using correct field names
226
- param_info = ParameterInfo(
227
- name=parameter.name,
228
- location=param_location, # Use converted parameter location
229
- required=parameter.required,
230
- schema=param_schema_dict, # Populate 'schema' field in IR
231
- description=parameter.description,
232
- )
233
- extracted_params.append(param_info)
234
-
235
- except (
236
- ValidationError,
237
- ValueError,
238
- AttributeError,
239
- TypeError,
240
- ) as e: # Added TypeError
241
- param_name = getattr(
242
- param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
243
- )
244
- logger.error(
245
- f"Failed to extract parameter '{param_name}': {e}", exc_info=False
246
- )
247
-
248
- return extracted_params
249
-
250
-
251
- def _extract_request_body(
252
- request_body_or_ref: RequestBody | Reference | None, openapi: OpenAPI
253
- ) -> RequestBodyInfo | None:
254
- """Extracts and resolves the request body using corrected attribute names."""
255
- if not request_body_or_ref:
256
- return None
257
- try:
258
- request_body = cast(RequestBody, _resolve_ref(request_body_or_ref, openapi))
259
- if not isinstance(request_body, RequestBody):
260
- # ... (error logging remains the same)
261
- return None
262
-
263
- content_schemas: dict[str, JsonSchema] = {}
264
- if request_body.content:
265
- for media_type_str, media_type_obj in request_body.content.items():
266
- # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
267
- if (
268
- isinstance(media_type_obj, MediaType)
269
- and media_type_obj.media_type_schema
270
- ): # CORRECTED: Use 'media_type_schema'
271
- # --- *** ---
272
- try:
273
- # Use the corrected attribute here as well
274
- schema_dict = _extract_schema_as_dict(
275
- media_type_obj.media_type_schema, openapi
276
- )
277
- content_schemas[media_type_str] = schema_dict
278
- except ValueError as schema_err:
279
- logger.error(
280
- f"Failed to extract schema for media type '{media_type_str}' in request body: {schema_err}"
281
- )
282
- elif not isinstance(media_type_obj, MediaType):
283
- logger.warning(
284
- f"Skipping invalid media type object for '{media_type_str}' (type: {type(media_type_obj)}) in request body."
285
- )
286
- elif not media_type_obj.media_type_schema: # Corrected check
287
- logger.warning(
288
- f"Skipping media type '{media_type_str}' in request body because it lacks a schema."
289
- )
290
-
291
- return RequestBodyInfo(
292
- required=request_body.required,
293
- content_schema=content_schemas,
294
- description=request_body.description,
295
- )
296
- except (ValidationError, ValueError, AttributeError) as e:
297
- ref_name = getattr(request_body_or_ref, "ref", "unknown")
298
- logger.error(
299
- f"Failed to extract request body '{ref_name}': {e}", exc_info=False
300
- )
301
- return None
302
-
303
-
304
189
  def _extract_responses(
305
190
  operation_responses: dict[str, Response | Reference] | None,
306
191
  openapi: OpenAPI,
@@ -358,194 +243,688 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
358
243
  """
359
244
  Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
360
245
  using the openapi-pydantic library.
246
+
247
+ Supports both OpenAPI 3.0.x and 3.1.x versions.
361
248
  """
362
- routes: list[HTTPRoute] = []
249
+ # Check OpenAPI version to use appropriate model
250
+ openapi_version = openapi_dict.get("openapi", "")
251
+
363
252
  try:
364
- openapi: OpenAPI = OpenAPI.model_validate(openapi_dict)
365
- logger.info(f"Successfully parsed OpenAPI schema version: {openapi.openapi}")
253
+ if openapi_version.startswith("3.0"):
254
+ # Use OpenAPI 3.0 models
255
+ openapi_30 = OpenAPI_30.model_validate(openapi_dict)
256
+ logger.info(
257
+ f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
258
+ )
259
+ parser = OpenAPI30Parser(openapi_30)
260
+ return parser.parse()
261
+ else:
262
+ # Default to OpenAPI 3.1 models
263
+ openapi_31 = OpenAPI.model_validate(openapi_dict)
264
+ logger.info(
265
+ f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
266
+ )
267
+ parser = OpenAPI31Parser(openapi_31)
268
+ return parser.parse()
366
269
  except ValidationError as e:
367
270
  logger.error(f"OpenAPI schema validation failed: {e}")
368
271
  error_details = e.errors()
369
272
  logger.error(f"Validation errors: {error_details}")
370
273
  raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
371
274
 
372
- if not openapi.paths:
373
- logger.warning("OpenAPI schema has no paths defined.")
374
- return []
375
275
 
376
- for path_str, path_item_obj in openapi.paths.items():
377
- if not isinstance(path_item_obj, PathItem):
276
+ # Base parser class for shared functionality
277
+ class BaseOpenAPIParser:
278
+ """Base class for OpenAPI parsers with common functionality."""
279
+
280
+ def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
281
+ """Convert string parameter location to our ParameterLocation type."""
282
+ if param_in == "path":
283
+ return "path"
284
+ elif param_in == "query":
285
+ return "query"
286
+ elif param_in == "header":
287
+ return "header"
288
+ elif param_in == "cookie":
289
+ return "cookie"
290
+ else:
378
291
  logger.warning(
379
- f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})"
292
+ f"Unknown parameter location: {param_in}, defaulting to 'query'"
380
293
  )
381
- continue
382
-
383
- path_level_params = path_item_obj.parameters
384
-
385
- # Iterate through possible HTTP methods defined in the PathItem model fields
386
- # Use model_fields from the class, not the instance
387
- for method_lower in PathItem.model_fields.keys():
388
- if method_lower not in [
389
- "get",
390
- "put",
391
- "post",
392
- "delete",
393
- "options",
394
- "head",
395
- "patch",
396
- "trace",
397
- ]:
294
+ return "query"
295
+
296
+
297
+ class OpenAPI31Parser(BaseOpenAPIParser):
298
+ """Parser for OpenAPI 3.1 schemas."""
299
+
300
+ def __init__(self, openapi: OpenAPI):
301
+ self.openapi = openapi
302
+
303
+ def parse(self) -> list[HTTPRoute]:
304
+ """Parse an OpenAPI 3.1 schema into HTTP routes."""
305
+ routes: list[HTTPRoute] = []
306
+
307
+ if not self.openapi.paths:
308
+ logger.warning("OpenAPI schema has no paths defined.")
309
+ return []
310
+
311
+ for path_str, path_item_obj in self.openapi.paths.items():
312
+ if not isinstance(path_item_obj, PathItem):
313
+ logger.warning(
314
+ f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})"
315
+ )
398
316
  continue
399
317
 
400
- operation: Operation | None = getattr(path_item_obj, method_lower, None)
318
+ path_level_params = path_item_obj.parameters
319
+
320
+ # Iterate through possible HTTP methods defined in the PathItem model fields
321
+ # Use model_fields from the class, not the instance
322
+ for method_lower in PathItem.model_fields.keys():
323
+ if method_lower not in [
324
+ "get",
325
+ "put",
326
+ "post",
327
+ "delete",
328
+ "options",
329
+ "head",
330
+ "patch",
331
+ "trace",
332
+ ]:
333
+ continue
334
+
335
+ operation: Operation | None = getattr(path_item_obj, method_lower, None)
336
+
337
+ if operation and isinstance(operation, Operation):
338
+ method_upper = cast(HttpMethod, method_lower.upper())
339
+ logger.debug(f"Processing operation: {method_upper} {path_str}")
340
+ try:
341
+ parameters = self._extract_parameters(
342
+ operation.parameters, path_level_params
343
+ )
344
+ request_body_info = self._extract_request_body(
345
+ operation.requestBody
346
+ )
347
+ responses = self._extract_responses(operation.responses)
348
+
349
+ route = HTTPRoute(
350
+ path=path_str,
351
+ method=method_upper,
352
+ operation_id=operation.operationId,
353
+ summary=operation.summary,
354
+ description=operation.description,
355
+ tags=operation.tags or [],
356
+ parameters=parameters,
357
+ request_body=request_body_info,
358
+ responses=responses,
359
+ )
360
+ routes.append(route)
361
+ logger.info(
362
+ f"Successfully extracted route: {method_upper} {path_str}"
363
+ )
364
+ except Exception as op_error:
365
+ op_id = operation.operationId or "unknown"
366
+ logger.error(
367
+ f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
368
+ exc_info=True,
369
+ )
401
370
 
402
- if operation and isinstance(operation, Operation):
403
- method_upper = cast(HttpMethod, method_lower.upper())
404
- logger.debug(f"Processing operation: {method_upper} {path_str}")
405
- try:
406
- parameters = _extract_parameters(
407
- operation.parameters, path_level_params, openapi
371
+ logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
372
+ return routes
373
+
374
+ def _resolve_ref(
375
+ self, item: Reference | Schema | Parameter | RequestBody | Any
376
+ ) -> Any:
377
+ """Resolves a potential Reference object to its target definition."""
378
+ if isinstance(item, Reference):
379
+ ref_str = item.ref
380
+ try:
381
+ if not ref_str.startswith("#/"):
382
+ raise ValueError(
383
+ f"External or non-local reference not supported: {ref_str}"
408
384
  )
409
- request_body_info = _extract_request_body(
410
- operation.requestBody, openapi
385
+ parts = ref_str.strip("#/").split("/")
386
+ target = self.openapi
387
+ for part in parts:
388
+ if part.isdigit() and isinstance(target, list):
389
+ target = target[int(part)]
390
+ elif isinstance(target, BaseModel):
391
+ # Use model_extra for fields not explicitly defined (like components types)
392
+ # Check class fields first, then model_extra
393
+ if part in target.__class__.model_fields:
394
+ target = getattr(target, part, None)
395
+ elif target.model_extra and part in target.model_extra:
396
+ target = target.model_extra[part]
397
+ else:
398
+ # Special handling for components sub-types common structure
399
+ if part == "components" and hasattr(target, "components"):
400
+ target = getattr(target, "components")
401
+ elif hasattr(target, part): # Fallback check
402
+ target = getattr(target, part, None)
403
+ else:
404
+ target = None # Part not found
405
+ elif isinstance(target, dict):
406
+ target = target.get(part)
407
+ else:
408
+ raise ValueError(
409
+ f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
410
+ )
411
+ if target is None:
412
+ raise ValueError(
413
+ f"Reference part '{part}' not found in path '{ref_str}'"
414
+ )
415
+ if isinstance(target, Reference):
416
+ return self._resolve_ref(target)
417
+ return target
418
+ except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
419
+ raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
420
+ return item
421
+
422
+ def _extract_schema_as_dict(self, schema_obj: Schema | Reference) -> JsonSchema:
423
+ """Resolves a schema/reference and returns it as a dictionary."""
424
+ resolved_schema = self._resolve_ref(schema_obj)
425
+ if isinstance(resolved_schema, Schema):
426
+ # Using exclude_none=True might be better than exclude_unset sometimes
427
+ return resolved_schema.model_dump(
428
+ mode="json", by_alias=True, exclude_none=True
429
+ )
430
+ elif isinstance(resolved_schema, dict):
431
+ logger.warning(
432
+ "Resolved schema reference resulted in a dict, not a Schema model."
433
+ )
434
+ return resolved_schema
435
+ else:
436
+ ref_str = getattr(schema_obj, "ref", "unknown")
437
+ logger.warning(
438
+ f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
439
+ )
440
+ return {}
441
+
442
+ def _extract_parameters(
443
+ self,
444
+ operation_params: list[Parameter | Reference] | None,
445
+ path_item_params: list[Parameter | Reference] | None,
446
+ ) -> list[ParameterInfo]:
447
+ """Extracts and resolves parameters using corrected attribute names."""
448
+ extracted_params: list[ParameterInfo] = []
449
+ seen_params: dict[
450
+ tuple[str, str], bool
451
+ ] = {} # Use string keys to avoid type issues
452
+ all_params_refs = (operation_params or []) + (path_item_params or [])
453
+
454
+ for param_or_ref in all_params_refs:
455
+ try:
456
+ parameter = cast(Parameter, self._resolve_ref(param_or_ref))
457
+ if not isinstance(parameter, Parameter):
458
+ # ... (error logging remains the same)
459
+ continue
460
+
461
+ # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
462
+ param_in = parameter.param_in # CORRECTED: Use 'param_in'
463
+ param_location = self._convert_to_parameter_location(param_in)
464
+ param_schema_obj = (
465
+ parameter.param_schema
466
+ ) # CORRECTED: Use 'param_schema'
467
+ # --- *** ---
468
+
469
+ param_key = (parameter.name, param_in)
470
+ if param_key in seen_params:
471
+ continue
472
+ seen_params[param_key] = True
473
+
474
+ param_schema_dict = {}
475
+ if param_schema_obj: # Check if schema exists
476
+ param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
477
+ elif parameter.content:
478
+ # Handle complex parameters with 'content'
479
+ first_media_type = next(iter(parameter.content.values()), None)
480
+ if (
481
+ first_media_type and first_media_type.media_type_schema
482
+ ): # CORRECTED: Use 'media_type_schema'
483
+ param_schema_dict = self._extract_schema_as_dict(
484
+ first_media_type.media_type_schema
485
+ )
486
+ logger.debug(
487
+ f"Parameter '{parameter.name}' using schema from 'content' field."
488
+ )
489
+
490
+ # Manually create ParameterInfo instance using correct field names
491
+ param_info = ParameterInfo(
492
+ name=parameter.name,
493
+ location=param_location, # Use converted parameter location
494
+ required=parameter.required,
495
+ schema=param_schema_dict, # Populate 'schema' field in IR
496
+ description=parameter.description,
497
+ )
498
+ extracted_params.append(param_info)
499
+
500
+ except (
501
+ ValidationError,
502
+ ValueError,
503
+ AttributeError,
504
+ TypeError,
505
+ ) as e: # Added TypeError
506
+ param_name = getattr(
507
+ param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
508
+ )
509
+ logger.error(
510
+ f"Failed to extract parameter '{param_name}': {e}", exc_info=False
511
+ )
512
+
513
+ return extracted_params
514
+
515
+ def _extract_request_body(
516
+ self, request_body_or_ref: RequestBody | Reference | None
517
+ ) -> RequestBodyInfo | None:
518
+ """Extracts and resolves the request body using corrected attribute names."""
519
+ if not request_body_or_ref:
520
+ return None
521
+ try:
522
+ request_body = cast(RequestBody, self._resolve_ref(request_body_or_ref))
523
+ if not isinstance(request_body, RequestBody):
524
+ # ... (error logging remains the same)
525
+ return None
526
+
527
+ content_schemas: dict[str, JsonSchema] = {}
528
+ if request_body.content:
529
+ for media_type_str, media_type_obj in request_body.content.items():
530
+ # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
531
+ if (
532
+ isinstance(media_type_obj, MediaType)
533
+ and media_type_obj.media_type_schema
534
+ ): # CORRECTED: Use 'media_type_schema'
535
+ # --- *** ---
536
+ try:
537
+ # Use the corrected attribute here as well
538
+ schema_dict = self._extract_schema_as_dict(
539
+ media_type_obj.media_type_schema
540
+ )
541
+ content_schemas[media_type_str] = schema_dict
542
+ except ValueError as schema_err:
543
+ logger.error(
544
+ f"Failed to extract schema for media type '{media_type_str}' in request body: {schema_err}"
545
+ )
546
+ elif not isinstance(media_type_obj, MediaType):
547
+ logger.warning(
548
+ f"Skipping invalid media type object for '{media_type_str}' (type: {type(media_type_obj)}) in request body."
549
+ )
550
+ elif not media_type_obj.media_type_schema: # Corrected check
551
+ logger.warning(
552
+ f"Skipping media type '{media_type_str}' in request body because it lacks a schema."
553
+ )
554
+
555
+ return RequestBodyInfo(
556
+ required=request_body.required,
557
+ content_schema=content_schemas,
558
+ description=request_body.description,
559
+ )
560
+ except (ValidationError, ValueError, AttributeError) as e:
561
+ ref_name = getattr(request_body_or_ref, "ref", "unknown")
562
+ logger.error(
563
+ f"Failed to extract request body '{ref_name}': {e}", exc_info=False
564
+ )
565
+ return None
566
+
567
+ def _extract_responses(
568
+ self,
569
+ operation_responses: dict[str, Response | Reference] | None,
570
+ ) -> dict[str, ResponseInfo]:
571
+ """Extracts and resolves response information for an operation."""
572
+ extracted_responses: dict[str, ResponseInfo] = {}
573
+ if not operation_responses:
574
+ return extracted_responses
575
+
576
+ for status_code, resp_or_ref in operation_responses.items():
577
+ try:
578
+ response = cast(Response, self._resolve_ref(resp_or_ref))
579
+ if not isinstance(response, Response):
580
+ ref_str = getattr(resp_or_ref, "ref", "unknown")
581
+ logger.warning(
582
+ f"Expected Response after resolving ref '{ref_str}' for status code {status_code}, got {type(response)}. Skipping."
411
583
  )
412
- responses = _extract_responses(operation.responses, openapi)
413
-
414
- route = HTTPRoute(
415
- path=path_str,
416
- method=method_upper,
417
- operation_id=operation.operationId,
418
- summary=operation.summary,
419
- description=operation.description,
420
- tags=operation.tags or [],
421
- parameters=parameters,
422
- request_body=request_body_info,
423
- responses=responses,
584
+ continue
585
+
586
+ content_schemas: dict[str, JsonSchema] = {}
587
+ if response.content:
588
+ for media_type_str, media_type_obj in response.content.items():
589
+ if (
590
+ isinstance(media_type_obj, MediaType)
591
+ and media_type_obj.media_type_schema
592
+ ):
593
+ try:
594
+ schema_dict = self._extract_schema_as_dict(
595
+ media_type_obj.media_type_schema
596
+ )
597
+ content_schemas[media_type_str] = schema_dict
598
+ except ValueError as schema_err:
599
+ logger.error(
600
+ f"Failed to extract schema for media type '{media_type_str}' in response {status_code}: {schema_err}"
601
+ )
602
+
603
+ resp_info = ResponseInfo(
604
+ description=response.description, content_schema=content_schemas
605
+ )
606
+ extracted_responses[str(status_code)] = resp_info
607
+
608
+ except (ValidationError, ValueError, AttributeError) as e:
609
+ ref_name = getattr(resp_or_ref, "ref", "unknown")
610
+ logger.error(
611
+ f"Failed to extract response for status code {status_code} "
612
+ f"from reference '{ref_name}': {e}",
613
+ exc_info=False,
614
+ )
615
+
616
+ return extracted_responses
617
+
618
+
619
+ class OpenAPI30Parser(BaseOpenAPIParser):
620
+ """Parser for OpenAPI 3.0 schemas."""
621
+
622
+ def __init__(self, openapi: OpenAPI_30):
623
+ self.openapi = openapi
624
+
625
+ def parse(self) -> list[HTTPRoute]:
626
+ """Parse an OpenAPI 3.0 schema into HTTP routes."""
627
+ routes: list[HTTPRoute] = []
628
+
629
+ if not self.openapi.paths:
630
+ logger.warning("OpenAPI schema has no paths defined.")
631
+ return []
632
+
633
+ for path_str, path_item_obj in self.openapi.paths.items():
634
+ if not isinstance(path_item_obj, PathItem_30):
635
+ logger.warning(
636
+ f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})"
637
+ )
638
+ continue
639
+
640
+ path_level_params = path_item_obj.parameters
641
+
642
+ # Iterate through possible HTTP methods defined in the PathItem model fields
643
+ # Use model_fields from the class, not the instance
644
+ for method_lower in PathItem_30.model_fields.keys():
645
+ if method_lower not in [
646
+ "get",
647
+ "put",
648
+ "post",
649
+ "delete",
650
+ "options",
651
+ "head",
652
+ "patch",
653
+ "trace",
654
+ ]:
655
+ continue
656
+
657
+ operation: Operation_30 | None = getattr(
658
+ path_item_obj, method_lower, None
659
+ )
660
+
661
+ if operation and isinstance(operation, Operation_30):
662
+ method_upper = cast(HttpMethod, method_lower.upper())
663
+ logger.debug(f"Processing operation: {method_upper} {path_str}")
664
+ try:
665
+ parameters = self._extract_parameters(
666
+ operation.parameters, path_level_params
667
+ )
668
+ request_body_info = self._extract_request_body(
669
+ operation.requestBody
670
+ )
671
+ responses = self._extract_responses(operation.responses)
672
+
673
+ route = HTTPRoute(
674
+ path=path_str,
675
+ method=method_upper,
676
+ operation_id=operation.operationId,
677
+ summary=operation.summary,
678
+ description=operation.description,
679
+ tags=operation.tags or [],
680
+ parameters=parameters,
681
+ request_body=request_body_info,
682
+ responses=responses,
683
+ )
684
+ routes.append(route)
685
+ logger.info(
686
+ f"Successfully extracted route: {method_upper} {path_str}"
687
+ )
688
+ except Exception as op_error:
689
+ op_id = operation.operationId or "unknown"
690
+ logger.error(
691
+ f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
692
+ exc_info=True,
693
+ )
694
+
695
+ logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
696
+ return routes
697
+
698
+ def _resolve_ref(
699
+ self, item: Reference_30 | Schema_30 | Parameter_30 | RequestBody_30 | Any
700
+ ) -> Any:
701
+ """Resolves a potential Reference object to its target definition for OpenAPI 3.0."""
702
+ if isinstance(item, Reference_30):
703
+ ref_str = item.ref
704
+ try:
705
+ if not ref_str.startswith("#/"):
706
+ raise ValueError(
707
+ f"External or non-local reference not supported: {ref_str}"
424
708
  )
425
- routes.append(route)
426
- logger.info(
427
- f"Successfully extracted route: {method_upper} {path_str}"
709
+ parts = ref_str.strip("#/").split("/")
710
+ target = self.openapi
711
+ for part in parts:
712
+ if part.isdigit() and isinstance(target, list):
713
+ target = target[int(part)]
714
+ elif isinstance(target, BaseModel):
715
+ # Use model_extra for fields not explicitly defined (like components types)
716
+ # Check class fields first, then model_extra
717
+ if part in target.__class__.model_fields:
718
+ target = getattr(target, part, None)
719
+ elif target.model_extra and part in target.model_extra:
720
+ target = target.model_extra[part]
721
+ else:
722
+ # Special handling for components sub-types common structure
723
+ if part == "components" and hasattr(target, "components"):
724
+ target = getattr(target, "components")
725
+ elif hasattr(target, part): # Fallback check
726
+ target = getattr(target, part, None)
727
+ else:
728
+ target = None # Part not found
729
+ elif isinstance(target, dict):
730
+ target = target.get(part)
731
+ else:
732
+ raise ValueError(
733
+ f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
734
+ )
735
+ if target is None:
736
+ raise ValueError(
737
+ f"Reference part '{part}' not found in path '{ref_str}'"
738
+ )
739
+ if isinstance(target, Reference_30):
740
+ return self._resolve_ref(target)
741
+ return target
742
+ except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
743
+ raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
744
+ return item
745
+
746
+ def _extract_schema_as_dict(
747
+ self, schema_obj: Schema_30 | Reference_30
748
+ ) -> JsonSchema:
749
+ """Resolves a schema/reference and returns it as a dictionary for OpenAPI 3.0."""
750
+ resolved_schema = self._resolve_ref(schema_obj)
751
+ if isinstance(resolved_schema, Schema_30):
752
+ # Using exclude_none=True might be better than exclude_unset sometimes
753
+ return resolved_schema.model_dump(
754
+ mode="json", by_alias=True, exclude_none=True
755
+ )
756
+ elif isinstance(resolved_schema, dict):
757
+ logger.warning(
758
+ "Resolved schema reference resulted in a dict, not a Schema model."
759
+ )
760
+ return resolved_schema
761
+ else:
762
+ ref_str = getattr(schema_obj, "ref", "unknown")
763
+ logger.warning(
764
+ f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
765
+ )
766
+ return {}
767
+
768
+ def _extract_parameters(
769
+ self,
770
+ operation_params: list[Parameter_30 | Reference_30] | None,
771
+ path_item_params: list[Parameter_30 | Reference_30] | None,
772
+ ) -> list[ParameterInfo]:
773
+ """Extracts and resolves parameters for OpenAPI 3.0."""
774
+ extracted_params: list[ParameterInfo] = []
775
+ seen_params: dict[
776
+ tuple[str, str], bool
777
+ ] = {} # Use string keys to avoid type issues
778
+ all_params_refs = (operation_params or []) + (path_item_params or [])
779
+
780
+ for param_or_ref in all_params_refs:
781
+ try:
782
+ parameter = cast(Parameter_30, self._resolve_ref(param_or_ref))
783
+ if not isinstance(parameter, Parameter_30):
784
+ logger.warning(
785
+ f"Expected Parameter after resolving reference, got {type(parameter)}. Skipping."
428
786
  )
429
- except Exception as op_error:
430
- op_id = operation.operationId or "unknown"
431
- logger.error(
432
- f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
433
- exc_info=True,
787
+ continue
788
+
789
+ # OpenAPI 3.0 uses 'in' field for parameter location
790
+ param_in = parameter.param_in
791
+ param_location = self._convert_to_parameter_location(param_in)
792
+ param_schema_obj = parameter.param_schema
793
+
794
+ param_key = (parameter.name, param_in)
795
+ if param_key in seen_params:
796
+ continue
797
+ seen_params[param_key] = True
798
+
799
+ param_schema_dict = {}
800
+ if param_schema_obj: # Check if schema exists
801
+ param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
802
+ elif parameter.content:
803
+ # Handle complex parameters with 'content'
804
+ first_media_type = next(iter(parameter.content.values()), None)
805
+ if first_media_type and first_media_type.media_type_schema:
806
+ param_schema_dict = self._extract_schema_as_dict(
807
+ first_media_type.media_type_schema
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."
434
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
435
916
 
436
- logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
437
- return routes
438
-
439
-
440
- # --- Example Usage (Optional) ---
441
- if __name__ == "__main__":
442
- import json
443
-
444
- logging.basicConfig(
445
- level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s"
446
- ) # Set to INFO
447
-
448
- petstore_schema = {
449
- "openapi": "3.1.0", # Keep corrected version
450
- "info": {"title": "Simple Pet Store API", "version": "1.0.0"},
451
- "paths": {
452
- "/pets": {
453
- "get": {
454
- "summary": "list all pets",
455
- "operationId": "listPets",
456
- "tags": ["pets"],
457
- "parameters": [
458
- {
459
- "name": "limit",
460
- "in": "query",
461
- "description": "How many items to return",
462
- "required": False,
463
- "schema": {"type": "integer", "format": "int32"},
464
- }
465
- ],
466
- "responses": {"200": {"description": "A paged array of pets"}},
467
- },
468
- "post": {
469
- "summary": "Create a pet",
470
- "operationId": "createPet",
471
- "tags": ["pets"],
472
- "requestBody": {"$ref": "#/components/requestBodies/PetBody"},
473
- "responses": {"201": {"description": "Null response"}},
474
- },
475
- },
476
- "/pets/{petId}": {
477
- "get": {
478
- "summary": "Info for a specific pet",
479
- "operationId": "showPetById",
480
- "tags": ["pets"],
481
- "parameters": [
482
- {
483
- "name": "petId",
484
- "in": "path",
485
- "required": True,
486
- "description": "The id of the pet",
487
- "schema": {"type": "string"},
488
- },
489
- {
490
- "name": "X-Request-ID",
491
- "in": "header",
492
- "required": False,
493
- "schema": {"type": "string", "format": "uuid"},
494
- },
495
- ],
496
- "responses": {"200": {"description": "Information about the pet"}},
497
- },
498
- "parameters": [ # Path level parameter example
499
- {
500
- "name": "traceId",
501
- "in": "header",
502
- "description": "Common trace ID",
503
- "required": False,
504
- "schema": {"type": "string"},
505
- }
506
- ],
507
- },
508
- },
509
- "components": {
510
- "schemas": {
511
- "Pet": {
512
- "type": "object",
513
- "required": ["id", "name"],
514
- "properties": {
515
- "id": {"type": "integer", "format": "int64"},
516
- "name": {"type": "string"},
517
- "tag": {"type": "string"},
518
- },
519
- }
520
- },
521
- "requestBodies": {
522
- "PetBody": {
523
- "description": "Pet object",
524
- "required": True,
525
- "content": {
526
- "application/json": {
527
- "schema": {"$ref": "#/components/schemas/Pet"}
528
- }
529
- },
530
- }
531
- },
532
- },
533
- }
917
+ extracted_responses[status_code] = response_info
534
918
 
535
- print("--- Parsing Pet Store Schema using openapi-pydantic (Corrected) ---")
536
- try:
537
- http_routes = parse_openapi_to_http_routes(petstore_schema)
538
- print(f"\n--- Extracted {len(http_routes)} Routes ---")
539
- for i, route in enumerate(http_routes):
540
- print(f"\nRoute {i + 1}:")
541
- # Use model_dump for clean JSON-like output, show aliases from IR model
542
- print(
543
- json.dumps(route.model_dump(by_alias=True, exclude_none=True), indent=2)
544
- ) # exclude_none is often cleaner
545
- except ValueError as e:
546
- print(f"\nError parsing schema: {e}")
547
- except Exception as e:
548
- print(f"\nAn unexpected error occurred: {e}")
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
549
928
 
550
929
 
551
930
  def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None: