Jeremiah Lowin commited on
Commit
e0cfef2
·
unverified ·
2 Parent(s): f0f3ec09d6fa16

Merge pull request #147 from jlowin/openapi

Browse files
docs/patterns/openapi.mdx CHANGED
@@ -7,6 +7,8 @@ icon: code-branch
7
 
8
  If you have existing REST APIs documented with the OpenAPI Specification (OAS), FastMCP can automatically generate MCP tools, resources, and resource templates directly from that specification. This provides a quick way to make your existing HTTP APIs accessible to MCP clients and LLMs.
9
 
 
 
10
  ## The Goal: API -> MCP Server
11
 
12
  The core idea is to map OpenAPI paths and operations (like `GET /users/{id}` or `POST /orders`) to their corresponding MCP components:
 
7
 
8
  If you have existing REST APIs documented with the OpenAPI Specification (OAS), FastMCP can automatically generate MCP tools, resources, and resource templates directly from that specification. This provides a quick way to make your existing HTTP APIs accessible to MCP clients and LLMs.
9
 
10
+ FastMCP supports both OpenAPI 3.0 and 3.1 specifications for maximum compatibility with existing API definitions.
11
+
12
  ## The Goal: API -> MCP Server
13
 
14
  The core idea is to map OpenAPI paths and operations (like `GET /users/{id}` or `POST /orders`) to their corresponding MCP components:
src/fastmcp/utilities/openapi.py CHANGED
@@ -14,6 +14,16 @@ from openapi_pydantic import (
14
  Response,
15
  Schema,
16
  )
 
 
 
 
 
 
 
 
 
 
17
  from pydantic import BaseModel, Field, ValidationError
18
 
19
  from fastmcp.utilities import openapi
@@ -176,131 +186,6 @@ def _convert_to_parameter_location(param_in: str) -> ParameterLocation:
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
- # --- *** ---
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
  def _extract_responses(
305
  operation_responses: dict[str, Response | Reference] | None,
306
  openapi: OpenAPI,
@@ -358,194 +243,688 @@ 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
 
372
- if not openapi.paths:
373
- logger.warning("OpenAPI schema has no paths defined.")
374
- return []
375
 
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
- ]:
 
 
 
 
 
398
  continue
399
 
400
- operation: Operation | None = getattr(path_item_obj, method_lower, None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
 
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,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  )
425
- routes.append(route)
426
- logger.info(
427
- f"Successfully extracted route: {method_upper} {path_str}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  )
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,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
 
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
- }
534
 
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}")
549
 
550
 
551
  def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
 
14
  Response,
15
  Schema,
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
27
  from pydantic import BaseModel, Field, ValidationError
28
 
29
  from fastmcp.utilities import openapi
 
186
  return "query"
187
 
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  def _extract_responses(
190
  operation_responses: dict[str, Response | Reference] | None,
191
  openapi: OpenAPI,
 
243
  """
244
  Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
245
  using the openapi-pydantic library.
246
+
247
+ Supports both OpenAPI 3.0.x and 3.1.x versions.
248
  """
249
+ # Check OpenAPI version to use appropriate model
250
+ openapi_version = openapi_dict.get("openapi", "")
251
+
252
  try:
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()
269
  except ValidationError as e:
270
  logger.error(f"OpenAPI schema validation failed: {e}")
271
  error_details = e.errors()
272
  logger.error(f"Validation errors: {error_details}")
273
  raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
274
 
 
 
 
275
 
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:
291
  logger.warning(
292
+ f"Unknown parameter location: {param_in}, defaulting to 'query'"
293
  )
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
+ )
316
  continue
317
 
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
+ )
370
 
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}"
384
  )
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."
583
  )
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}"
708
  )
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."
786
  )
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."
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:
tests/utilities/openapi/test_openapi.py CHANGED
@@ -311,6 +311,172 @@ def fastapi_route_map(parsed_fastapi_routes):
311
  }
312
 
313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  # --- Tests for PetStore schema --- #
315
 
316
 
@@ -764,3 +930,166 @@ def test_fastapi_post_query_parameter_names(fastapi_route_map):
764
  param_names = [p.name for p in query_params]
765
  assert "file_name" in param_names
766
  assert "content_type" in param_names
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  }
312
 
313
 
314
+ @pytest.fixture
315
+ def openapi_30_schema() -> dict[str, Any]:
316
+ """Fixture that returns a simple OpenAPI 3.0.0 schema."""
317
+ return {
318
+ "openapi": "3.0.0",
319
+ "info": {"title": "Simple API (OpenAPI 3.0)", "version": "1.0.0"},
320
+ "paths": {
321
+ "/items": {
322
+ "get": {
323
+ "summary": "List all items",
324
+ "operationId": "listItems",
325
+ "parameters": [
326
+ {
327
+ "name": "limit",
328
+ "in": "query",
329
+ "description": "How many items to return",
330
+ "required": False,
331
+ "schema": {"type": "integer"},
332
+ }
333
+ ],
334
+ "responses": {"200": {"description": "A list of items"}},
335
+ }
336
+ }
337
+ },
338
+ }
339
+
340
+
341
+ @pytest.fixture
342
+ def openapi_31_schema() -> dict[str, Any]:
343
+ """Fixture that returns a simple OpenAPI 3.1.0 schema."""
344
+ return {
345
+ "openapi": "3.1.0",
346
+ "info": {"title": "Simple API (OpenAPI 3.1)", "version": "1.0.0"},
347
+ "paths": {
348
+ "/items": {
349
+ "get": {
350
+ "summary": "List all items",
351
+ "operationId": "listItems",
352
+ "parameters": [
353
+ {
354
+ "name": "limit",
355
+ "in": "query",
356
+ "description": "How many items to return",
357
+ "required": False,
358
+ "schema": {"type": "integer"},
359
+ }
360
+ ],
361
+ "responses": {"200": {"description": "A list of items"}},
362
+ }
363
+ }
364
+ },
365
+ }
366
+
367
+
368
+ @pytest.fixture
369
+ def openapi_30_with_references() -> dict[str, Any]:
370
+ """OpenAPI 3.0 schema with references to test resolution."""
371
+ return {
372
+ "openapi": "3.0.0",
373
+ "info": {"title": "API with References (3.0)", "version": "1.0.0"},
374
+ "paths": {
375
+ "/products": {
376
+ "post": {
377
+ "summary": "Create product",
378
+ "operationId": "createProduct",
379
+ "requestBody": {
380
+ "content": {
381
+ "application/json": {
382
+ "schema": {"$ref": "#/components/schemas/Product"}
383
+ }
384
+ },
385
+ "required": True,
386
+ },
387
+ "responses": {
388
+ "201": {
389
+ "description": "Product created",
390
+ "content": {
391
+ "application/json": {
392
+ "schema": {"$ref": "#/components/schemas/Product"}
393
+ }
394
+ },
395
+ }
396
+ },
397
+ }
398
+ }
399
+ },
400
+ "components": {
401
+ "schemas": {
402
+ "Product": {
403
+ "type": "object",
404
+ "required": ["name", "price"],
405
+ "properties": {
406
+ "id": {"type": "string", "format": "uuid"},
407
+ "name": {"type": "string"},
408
+ "price": {"type": "number"},
409
+ "category": {"$ref": "#/components/schemas/Category"},
410
+ },
411
+ },
412
+ "Category": {
413
+ "type": "object",
414
+ "properties": {
415
+ "id": {"type": "integer"},
416
+ "name": {"type": "string"},
417
+ },
418
+ },
419
+ }
420
+ },
421
+ }
422
+
423
+
424
+ @pytest.fixture
425
+ def openapi_31_with_references() -> dict[str, Any]:
426
+ """OpenAPI 3.1 schema with references to test resolution."""
427
+ return {
428
+ "openapi": "3.1.0",
429
+ "info": {"title": "API with References (3.1)", "version": "1.0.0"},
430
+ "paths": {
431
+ "/products": {
432
+ "post": {
433
+ "summary": "Create product",
434
+ "operationId": "createProduct",
435
+ "requestBody": {
436
+ "content": {
437
+ "application/json": {
438
+ "schema": {"$ref": "#/components/schemas/Product"}
439
+ }
440
+ },
441
+ "required": True,
442
+ },
443
+ "responses": {
444
+ "201": {
445
+ "description": "Product created",
446
+ "content": {
447
+ "application/json": {
448
+ "schema": {"$ref": "#/components/schemas/Product"}
449
+ }
450
+ },
451
+ }
452
+ },
453
+ }
454
+ }
455
+ },
456
+ "components": {
457
+ "schemas": {
458
+ "Product": {
459
+ "type": "object",
460
+ "required": ["name", "price"],
461
+ "properties": {
462
+ "id": {"type": "string", "format": "uuid"},
463
+ "name": {"type": "string"},
464
+ "price": {"type": "number"},
465
+ "category": {"$ref": "#/components/schemas/Category"},
466
+ },
467
+ },
468
+ "Category": {
469
+ "type": "object",
470
+ "properties": {
471
+ "id": {"type": "integer"},
472
+ "name": {"type": "string"},
473
+ },
474
+ },
475
+ }
476
+ },
477
+ }
478
+
479
+
480
  # --- Tests for PetStore schema --- #
481
 
482
 
 
930
  param_names = [p.name for p in query_params]
931
  assert "file_name" in param_names
932
  assert "content_type" in param_names
933
+
934
+
935
+ def test_openapi_30_compatibility(openapi_30_schema):
936
+ """Test that OpenAPI 3.0 schemas can be parsed correctly."""
937
+ # This will raise an exception if the parser doesn't support 3.0.0
938
+ routes = parse_openapi_to_http_routes(openapi_30_schema)
939
+
940
+ # Verify the route was parsed correctly
941
+ assert len(routes) == 1
942
+ route = routes[0]
943
+ assert route.method == "GET"
944
+ assert route.path == "/items"
945
+ assert route.operation_id == "listItems"
946
+ assert len(route.parameters) == 1
947
+ assert route.parameters[0].name == "limit"
948
+
949
+
950
+ def test_openapi_31_compatibility(openapi_31_schema):
951
+ """Test that OpenAPI 3.1 schemas can be parsed correctly."""
952
+ routes = parse_openapi_to_http_routes(openapi_31_schema)
953
+
954
+ # Verify the route was parsed correctly
955
+ assert len(routes) == 1
956
+ route = routes[0]
957
+ assert route.method == "GET"
958
+ assert route.path == "/items"
959
+ assert route.operation_id == "listItems"
960
+ assert len(route.parameters) == 1
961
+ assert route.parameters[0].name == "limit"
962
+
963
+
964
+ def test_version_detection_logic():
965
+ """Test that the version detection logic correctly identifies 3.0 vs 3.1 schemas."""
966
+ # Test 3.0 variations
967
+ for version in ["3.0.0", "3.0.1", "3.0.3"]:
968
+ schema = {
969
+ "openapi": version,
970
+ "info": {"title": "Test", "version": "1.0.0"},
971
+ "paths": {},
972
+ }
973
+ try:
974
+ parse_openapi_to_http_routes(schema)
975
+ # Expect no error
976
+ except Exception as e:
977
+ pytest.fail(f"Failed to parse OpenAPI {version} schema: {e}")
978
+
979
+ # Test 3.1 variations
980
+ for version in ["3.1.0", "3.1.1"]:
981
+ schema = {
982
+ "openapi": version,
983
+ "info": {"title": "Test", "version": "1.0.0"},
984
+ "paths": {},
985
+ }
986
+ try:
987
+ parse_openapi_to_http_routes(schema)
988
+ # Expect no error
989
+ except Exception as e:
990
+ pytest.fail(f"Failed to parse OpenAPI {version} schema: {e}")
991
+
992
+
993
+ def test_openapi_30_reference_resolution(openapi_30_with_references):
994
+ """Test that references are correctly resolved in OpenAPI 3.0 schemas."""
995
+ routes = parse_openapi_to_http_routes(openapi_30_with_references)
996
+
997
+ assert len(routes) == 1
998
+ route = routes[0]
999
+ assert route.method == "POST"
1000
+ assert route.path == "/products"
1001
+
1002
+ # Check request body
1003
+ assert route.request_body is not None
1004
+ assert route.request_body.required is True
1005
+ assert "application/json" in route.request_body.content_schema
1006
+
1007
+ # Check schema structure
1008
+ json_schema = route.request_body.content_schema["application/json"]
1009
+ assert json_schema["type"] == "object"
1010
+ assert "properties" in json_schema
1011
+ assert set(json_schema["required"]) == {"name", "price"}
1012
+
1013
+ # Check primary fields are properly resolved
1014
+ props = json_schema["properties"]
1015
+ assert "id" in props
1016
+ assert "name" in props
1017
+ assert "price" in props
1018
+ assert "category" in props
1019
+
1020
+ # The category might be a reference or resolved object
1021
+ category = props["category"]
1022
+ # Either it's directly resolved with properties
1023
+ # or it still has a $ref field
1024
+ assert "properties" in category or "$ref" in category
1025
+
1026
+
1027
+ def test_openapi_31_reference_resolution(openapi_31_with_references):
1028
+ """Test that references are correctly resolved in OpenAPI 3.1 schemas."""
1029
+ routes = parse_openapi_to_http_routes(openapi_31_with_references)
1030
+
1031
+ assert len(routes) == 1
1032
+ route = routes[0]
1033
+ assert route.method == "POST"
1034
+ assert route.path == "/products"
1035
+
1036
+ # Check request body
1037
+ assert route.request_body is not None
1038
+ assert route.request_body.required is True
1039
+ assert "application/json" in route.request_body.content_schema
1040
+
1041
+ # Check schema structure
1042
+ json_schema = route.request_body.content_schema["application/json"]
1043
+ assert json_schema["type"] == "object"
1044
+ assert "properties" in json_schema
1045
+ assert set(json_schema["required"]) == {"name", "price"}
1046
+
1047
+ # Check primary fields are properly resolved
1048
+ props = json_schema["properties"]
1049
+ assert "id" in props
1050
+ assert "name" in props
1051
+ assert "price" in props
1052
+ assert "category" in props
1053
+
1054
+ # The category might be a reference or resolved object
1055
+ category = props["category"]
1056
+ # Either it's directly resolved with properties
1057
+ # or it still has a $ref field
1058
+ assert "properties" in category or "$ref" in category
1059
+
1060
+
1061
+ def test_consistent_output_across_versions(
1062
+ openapi_30_with_references, openapi_31_with_references
1063
+ ):
1064
+ """Test that both parsers produce equivalent output for equivalent schemas."""
1065
+ routes_30 = parse_openapi_to_http_routes(openapi_30_with_references)
1066
+ routes_31 = parse_openapi_to_http_routes(openapi_31_with_references)
1067
+
1068
+ # Convert to dict for easier comparison
1069
+ route_30_dict = routes_30[0].model_dump(exclude_none=True)
1070
+ route_31_dict = routes_31[0].model_dump(exclude_none=True)
1071
+
1072
+ # They should be identical except for version-specific differences
1073
+ # Compare path
1074
+ assert route_30_dict["path"] == route_31_dict["path"]
1075
+ # Compare method
1076
+ assert route_30_dict["method"] == route_31_dict["method"]
1077
+ # Compare operation_id
1078
+ assert route_30_dict["operation_id"] == route_31_dict["operation_id"]
1079
+ # Compare parameters
1080
+ assert len(route_30_dict["parameters"]) == len(route_31_dict["parameters"])
1081
+ # Compare request body
1082
+ assert (
1083
+ route_30_dict["request_body"]["required"]
1084
+ == route_31_dict["request_body"]["required"]
1085
+ )
1086
+ # Compare response structure
1087
+ assert "201" in route_30_dict["responses"] and "201" in route_31_dict["responses"]
1088
+ # The schemas should contain the same essential fields
1089
+ schema_30 = route_30_dict["request_body"]["content_schema"]["application/json"][
1090
+ "properties"
1091
+ ]
1092
+ schema_31 = route_31_dict["request_body"]["content_schema"]["application/json"][
1093
+ "properties"
1094
+ ]
1095
+ assert set(schema_30.keys()) == set(schema_31.keys())