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