Jeremiah Lowin commited on
Commit
72648c2
·
1 Parent(s): 1cb6ed0

Add openapi parsing utilities

Browse files
src/fastmcp/utilities/openapi.py ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast
3
+
4
+ # Using the recommended library: openapi-pydantic
5
+ from openapi_pydantic import (
6
+ MediaType,
7
+ OpenAPI,
8
+ Operation,
9
+ Parameter,
10
+ PathItem,
11
+ Reference,
12
+ RequestBody,
13
+ Schema,
14
+ )
15
+ from pydantic import BaseModel, Field, ValidationError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # --- Intermediate Representation (IR) Definition ---
20
+ # (IR models remain the same)
21
+
22
+ HttpMethod = Literal[
23
+ "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE"
24
+ ]
25
+ ParameterLocation = Literal["path", "query", "header", "cookie"]
26
+ JsonSchema = Dict[str, Any]
27
+
28
+
29
+ class ParameterInfo(BaseModel):
30
+ """Represents a single parameter for an HTTP operation in our IR."""
31
+
32
+ name: str
33
+ location: ParameterLocation # Mapped from 'in' field of openapi-pydantic Parameter
34
+ required: bool = False
35
+ schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
36
+ description: Optional[str] = None
37
+
38
+ # No model_config needed here if we populate manually after accessing 'in'
39
+
40
+
41
+ class RequestBodyInfo(BaseModel):
42
+ """Represents the request body for an HTTP operation in our IR."""
43
+
44
+ required: bool = False
45
+ content_schema: Dict[str, JsonSchema] = Field(
46
+ default_factory=dict
47
+ ) # Key: media type
48
+ description: Optional[str] = None
49
+
50
+
51
+ class HTTPRoute(BaseModel):
52
+ """Intermediate Representation for a single OpenAPI operation."""
53
+
54
+ path: str
55
+ method: HttpMethod
56
+ operation_id: Optional[str] = None
57
+ summary: Optional[str] = None
58
+ description: Optional[str] = None
59
+ tags: List[str] = Field(default_factory=list)
60
+ parameters: List[ParameterInfo] = Field(default_factory=list)
61
+ request_body: Optional[RequestBodyInfo] = None
62
+
63
+
64
+ # --- Helper Functions ---
65
+
66
+
67
+ def _resolve_ref(
68
+ item: Union[Reference, Schema, Parameter, RequestBody, Any], openapi: OpenAPI
69
+ ) -> Any:
70
+ """Resolves a potential Reference object to its target definition (no changes needed here)."""
71
+ if isinstance(item, Reference):
72
+ ref_str = item.ref
73
+ try:
74
+ if not ref_str.startswith("#/"):
75
+ raise ValueError(
76
+ f"External or non-local reference not supported: {ref_str}"
77
+ )
78
+ parts = ref_str.strip("#/").split("/")
79
+ target = openapi
80
+ for part in parts:
81
+ if part.isdigit() and isinstance(target, list):
82
+ target = target[int(part)]
83
+ elif isinstance(target, BaseModel):
84
+ # Use model_extra for fields not explicitly defined (like components types)
85
+ # Check class fields first, then model_extra
86
+ if part in target.model_fields: # Access class attribute here
87
+ target = getattr(target, part, None)
88
+ elif target.model_extra and part in target.model_extra:
89
+ target = target.model_extra[part]
90
+ else:
91
+ # Special handling for components sub-types common structure
92
+ if part == "components" and hasattr(target, "components"):
93
+ target = getattr(target, "components")
94
+ elif hasattr(target, part): # Fallback check
95
+ target = getattr(target, part, None)
96
+ else:
97
+ target = None # Part not found
98
+ elif isinstance(target, dict):
99
+ target = target.get(part)
100
+ else:
101
+ raise ValueError(
102
+ f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
103
+ )
104
+ if target is None:
105
+ raise ValueError(
106
+ f"Reference part '{part}' not found in path '{ref_str}'"
107
+ )
108
+ if isinstance(target, Reference):
109
+ return _resolve_ref(target, openapi)
110
+ return target
111
+ except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
112
+ raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
113
+ return item
114
+
115
+
116
+ def _extract_schema_as_dict(
117
+ schema_obj: Union[Schema, Reference], openapi: OpenAPI
118
+ ) -> JsonSchema:
119
+ """Resolves a schema/reference and returns it as a dictionary."""
120
+ resolved_schema = _resolve_ref(schema_obj, openapi)
121
+ if isinstance(resolved_schema, Schema):
122
+ # Using exclude_none=True might be better than exclude_unset sometimes
123
+ return resolved_schema.model_dump(mode="json", by_alias=True, exclude_none=True)
124
+ elif isinstance(resolved_schema, dict):
125
+ logger.warning(
126
+ "Resolved schema reference resulted in a dict, not a Schema model."
127
+ )
128
+ return resolved_schema
129
+ else:
130
+ ref_str = getattr(schema_obj, "ref", "unknown")
131
+ logger.warning(
132
+ f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
133
+ )
134
+ return {}
135
+
136
+
137
+ def _convert_to_parameter_location(param_in: str) -> ParameterLocation:
138
+ """Convert string parameter location to our ParameterLocation type."""
139
+ if param_in == "path":
140
+ return "path"
141
+ elif param_in == "query":
142
+ return "query"
143
+ elif param_in == "header":
144
+ return "header"
145
+ elif param_in == "cookie":
146
+ return "cookie"
147
+ else:
148
+ logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'")
149
+ return "query"
150
+
151
+
152
+ def _extract_parameters(
153
+ operation_params: Optional[List[Union[Parameter, Reference]]],
154
+ path_item_params: Optional[List[Union[Parameter, Reference]]],
155
+ openapi: OpenAPI,
156
+ ) -> List[ParameterInfo]:
157
+ """Extracts and resolves parameters using corrected attribute names."""
158
+ extracted_params: List[ParameterInfo] = []
159
+ seen_params: Dict[
160
+ Tuple[str, str], bool
161
+ ] = {} # Use string keys to avoid type issues
162
+ all_params_refs = (operation_params or []) + (path_item_params or [])
163
+
164
+ for param_or_ref in all_params_refs:
165
+ try:
166
+ parameter = cast(Parameter, _resolve_ref(param_or_ref, openapi))
167
+ if not isinstance(parameter, Parameter):
168
+ # ... (error logging remains the same)
169
+ continue
170
+
171
+ # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
172
+ param_in = parameter.param_in # CORRECTED: Use 'param_in'
173
+ param_location = _convert_to_parameter_location(param_in)
174
+ param_schema_obj = parameter.param_schema # CORRECTED: Use 'param_schema'
175
+ # --- *** ---
176
+
177
+ param_key = (parameter.name, param_in)
178
+ if param_key in seen_params:
179
+ continue
180
+ seen_params[param_key] = True
181
+
182
+ param_schema_dict = {}
183
+ if param_schema_obj: # Check if schema exists
184
+ param_schema_dict = _extract_schema_as_dict(param_schema_obj, openapi)
185
+ elif parameter.content:
186
+ # Handle complex parameters with 'content'
187
+ first_media_type = next(iter(parameter.content.values()), None)
188
+ if (
189
+ first_media_type and first_media_type.media_type_schema
190
+ ): # CORRECTED: Use 'media_type_schema'
191
+ param_schema_dict = _extract_schema_as_dict(
192
+ first_media_type.media_type_schema, openapi
193
+ )
194
+ logger.debug(
195
+ f"Parameter '{parameter.name}' using schema from 'content' field."
196
+ )
197
+
198
+ # Manually create ParameterInfo instance using correct field names
199
+ param_info = ParameterInfo(
200
+ name=parameter.name,
201
+ location=param_location, # Use converted parameter location
202
+ required=parameter.required,
203
+ schema=param_schema_dict, # Populate 'schema' field in IR
204
+ description=parameter.description,
205
+ )
206
+ extracted_params.append(param_info)
207
+
208
+ except (
209
+ ValidationError,
210
+ ValueError,
211
+ AttributeError,
212
+ TypeError,
213
+ ) as e: # Added TypeError
214
+ param_name = getattr(
215
+ param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
216
+ )
217
+ logger.error(
218
+ f"Failed to extract parameter '{param_name}': {e}", exc_info=False
219
+ )
220
+
221
+ return extracted_params
222
+
223
+
224
+ def _extract_request_body(
225
+ request_body_or_ref: Optional[Union[RequestBody, Reference]], openapi: OpenAPI
226
+ ) -> Optional[RequestBodyInfo]:
227
+ """Extracts and resolves the request body using corrected attribute names."""
228
+ if not request_body_or_ref:
229
+ return None
230
+ try:
231
+ request_body = cast(RequestBody, _resolve_ref(request_body_or_ref, openapi))
232
+ if not isinstance(request_body, RequestBody):
233
+ # ... (error logging remains the same)
234
+ return None
235
+
236
+ content_schemas: Dict[str, JsonSchema] = {}
237
+ if request_body.content:
238
+ for media_type_str, media_type_obj in request_body.content.items():
239
+ # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
240
+ if (
241
+ isinstance(media_type_obj, MediaType)
242
+ and media_type_obj.media_type_schema
243
+ ): # CORRECTED: Use 'media_type_schema'
244
+ # --- *** ---
245
+ try:
246
+ # Use the corrected attribute here as well
247
+ schema_dict = _extract_schema_as_dict(
248
+ media_type_obj.media_type_schema, openapi
249
+ )
250
+ content_schemas[media_type_str] = schema_dict
251
+ except ValueError as schema_err:
252
+ logger.error(
253
+ f"Failed to extract schema for media type '{media_type_str}' in request body: {schema_err}"
254
+ )
255
+ elif not isinstance(media_type_obj, MediaType):
256
+ logger.warning(
257
+ f"Skipping invalid media type object for '{media_type_str}' (type: {type(media_type_obj)}) in request body."
258
+ )
259
+ elif not media_type_obj.media_type_schema: # Corrected check
260
+ logger.warning(
261
+ f"Skipping media type '{media_type_str}' in request body because it lacks a schema."
262
+ )
263
+
264
+ return RequestBodyInfo(
265
+ required=request_body.required,
266
+ content_schema=content_schemas,
267
+ description=request_body.description,
268
+ )
269
+ except (ValidationError, ValueError, AttributeError) as e:
270
+ ref_name = getattr(request_body_or_ref, "ref", "unknown")
271
+ logger.error(
272
+ f"Failed to extract request body '{ref_name}': {e}", exc_info=False
273
+ )
274
+ return None
275
+
276
+
277
+ # --- Main Parsing Function ---
278
+ # (No changes needed in the main loop logic, only in the helpers it calls)
279
+ def parse_openapi_to_http_routes(openapi_dict: Dict[str, Any]) -> List[HTTPRoute]:
280
+ """
281
+ Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
282
+ using the openapi-pydantic library.
283
+ """
284
+ routes: List[HTTPRoute] = []
285
+ try:
286
+ openapi: OpenAPI = OpenAPI.model_validate(openapi_dict)
287
+ logger.info(f"Successfully parsed OpenAPI schema version: {openapi.openapi}")
288
+ except ValidationError as e:
289
+ logger.error(f"OpenAPI schema validation failed: {e}")
290
+ error_details = e.errors()
291
+ logger.error(f"Validation errors: {error_details}")
292
+ raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
293
+
294
+ if not openapi.paths:
295
+ logger.warning("OpenAPI schema has no paths defined.")
296
+ return []
297
+
298
+ for path_str, path_item_obj in openapi.paths.items():
299
+ if not isinstance(path_item_obj, PathItem):
300
+ logger.warning(
301
+ f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})"
302
+ )
303
+ continue
304
+
305
+ path_level_params = path_item_obj.parameters
306
+
307
+ # Iterate through possible HTTP methods defined in the PathItem model fields
308
+ # Use model_fields from the class, not the instance
309
+ for method_lower in PathItem.model_fields.keys():
310
+ if method_lower not in [
311
+ "get",
312
+ "put",
313
+ "post",
314
+ "delete",
315
+ "options",
316
+ "head",
317
+ "patch",
318
+ "trace",
319
+ ]:
320
+ continue
321
+
322
+ operation: Optional[Operation] = getattr(path_item_obj, method_lower, None)
323
+
324
+ if operation and isinstance(operation, Operation):
325
+ method_upper = cast(HttpMethod, method_lower.upper())
326
+ logger.debug(f"Processing operation: {method_upper} {path_str}")
327
+ try:
328
+ parameters = _extract_parameters(
329
+ operation.parameters, path_level_params, openapi
330
+ )
331
+ request_body_info = _extract_request_body(
332
+ operation.requestBody, openapi
333
+ )
334
+
335
+ route = HTTPRoute(
336
+ path=path_str,
337
+ method=method_upper,
338
+ operation_id=operation.operationId,
339
+ summary=operation.summary,
340
+ description=operation.description,
341
+ tags=operation.tags or [],
342
+ parameters=parameters,
343
+ request_body=request_body_info,
344
+ )
345
+ routes.append(route)
346
+ logger.info(
347
+ f"Successfully extracted route: {method_upper} {path_str}"
348
+ )
349
+ except Exception as op_error:
350
+ op_id = operation.operationId or "unknown"
351
+ logger.error(
352
+ f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
353
+ exc_info=True,
354
+ )
355
+
356
+ logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
357
+ return routes
358
+
359
+
360
+ # --- Example Usage (Optional) ---
361
+ if __name__ == "__main__":
362
+ import json
363
+
364
+ logging.basicConfig(
365
+ level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s"
366
+ ) # Set to INFO
367
+
368
+ petstore_schema = {
369
+ "openapi": "3.1.0", # Keep corrected version
370
+ "info": {"title": "Simple Pet Store API", "version": "1.0.0"},
371
+ "paths": {
372
+ "/pets": {
373
+ "get": {
374
+ "summary": "List all pets",
375
+ "operationId": "listPets",
376
+ "tags": ["pets"],
377
+ "parameters": [
378
+ {
379
+ "name": "limit",
380
+ "in": "query",
381
+ "description": "How many items to return",
382
+ "required": False,
383
+ "schema": {"type": "integer", "format": "int32"},
384
+ }
385
+ ],
386
+ "responses": {"200": {"description": "A paged array of pets"}},
387
+ },
388
+ "post": {
389
+ "summary": "Create a pet",
390
+ "operationId": "createPet",
391
+ "tags": ["pets"],
392
+ "requestBody": {"$ref": "#/components/requestBodies/PetBody"},
393
+ "responses": {"201": {"description": "Null response"}},
394
+ },
395
+ },
396
+ "/pets/{petId}": {
397
+ "get": {
398
+ "summary": "Info for a specific pet",
399
+ "operationId": "showPetById",
400
+ "tags": ["pets"],
401
+ "parameters": [
402
+ {
403
+ "name": "petId",
404
+ "in": "path",
405
+ "required": True,
406
+ "description": "The id of the pet",
407
+ "schema": {"type": "string"},
408
+ },
409
+ {
410
+ "name": "X-Request-ID",
411
+ "in": "header",
412
+ "required": False,
413
+ "schema": {"type": "string", "format": "uuid"},
414
+ },
415
+ ],
416
+ "responses": {"200": {"description": "Information about the pet"}},
417
+ },
418
+ "parameters": [ # Path level parameter example
419
+ {
420
+ "name": "traceId",
421
+ "in": "header",
422
+ "description": "Common trace ID",
423
+ "required": False,
424
+ "schema": {"type": "string"},
425
+ }
426
+ ],
427
+ },
428
+ },
429
+ "components": {
430
+ "schemas": {
431
+ "Pet": {
432
+ "type": "object",
433
+ "required": ["id", "name"],
434
+ "properties": {
435
+ "id": {"type": "integer", "format": "int64"},
436
+ "name": {"type": "string"},
437
+ "tag": {"type": "string"},
438
+ },
439
+ }
440
+ },
441
+ "requestBodies": {
442
+ "PetBody": {
443
+ "description": "Pet object",
444
+ "required": True,
445
+ "content": {
446
+ "application/json": {
447
+ "schema": {"$ref": "#/components/schemas/Pet"}
448
+ }
449
+ },
450
+ }
451
+ },
452
+ },
453
+ }
454
+
455
+ print("--- Parsing Pet Store Schema using openapi-pydantic (Corrected) ---")
456
+ try:
457
+ http_routes = parse_openapi_to_http_routes(petstore_schema)
458
+ print(f"\n--- Extracted {len(http_routes)} Routes ---")
459
+ for i, route in enumerate(http_routes):
460
+ print(f"\nRoute {i + 1}:")
461
+ # Use model_dump for clean JSON-like output, show aliases from IR model
462
+ print(
463
+ json.dumps(route.model_dump(by_alias=True, exclude_none=True), indent=2)
464
+ ) # exclude_none is often cleaner
465
+ except ValueError as e:
466
+ print(f"\nError parsing schema: {e}")
467
+ except Exception as e:
468
+ print(f"\nAn unexpected error occurred: {e}")
tests/utilities/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for utilities in the fastmcp package."""
tests/utilities/openapi/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for the OpenAPI utilities."""
tests/utilities/openapi/conftest.py ADDED
@@ -0,0 +1 @@
 
 
1
+
tests/utilities/openapi/test_openapi.py ADDED
@@ -0,0 +1,709 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the OpenAPI parsing utilities."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ import pytest
6
+ from fastapi import Body, FastAPI, Path, Query
7
+ from pydantic import BaseModel, Field
8
+
9
+ from fastmcp.utilities.openapi import parse_openapi_to_http_routes
10
+
11
+ # --- Test Data: Static OpenAPI Schema Dictionaries --- #
12
+
13
+
14
+ @pytest.fixture
15
+ def petstore_schema() -> Dict[str, Any]:
16
+ """Fixture that returns a simple Pet Store API schema."""
17
+ return {
18
+ "openapi": "3.1.0",
19
+ "info": {"title": "Simple Pet Store API", "version": "1.0.0"},
20
+ "paths": {
21
+ "/pets": {
22
+ "get": {
23
+ "summary": "List all pets",
24
+ "operationId": "listPets",
25
+ "tags": ["pets"],
26
+ "parameters": [
27
+ {
28
+ "name": "limit",
29
+ "in": "query",
30
+ "description": "How many items to return",
31
+ "required": False,
32
+ "schema": {"type": "integer", "format": "int32"},
33
+ }
34
+ ],
35
+ "responses": {"200": {"description": "A paged array of pets"}},
36
+ },
37
+ "post": {
38
+ "summary": "Create a pet",
39
+ "operationId": "createPet",
40
+ "tags": ["pets"],
41
+ "requestBody": {"$ref": "#/components/requestBodies/PetBody"},
42
+ "responses": {"201": {"description": "Null response"}},
43
+ },
44
+ },
45
+ "/pets/{petId}": {
46
+ "get": {
47
+ "summary": "Info for a specific pet",
48
+ "operationId": "showPetById",
49
+ "tags": ["pets"],
50
+ "parameters": [
51
+ {
52
+ "name": "petId",
53
+ "in": "path",
54
+ "required": True,
55
+ "description": "The id of the pet",
56
+ "schema": {"type": "string"},
57
+ },
58
+ {
59
+ "name": "X-Request-ID",
60
+ "in": "header",
61
+ "required": False,
62
+ "schema": {"type": "string", "format": "uuid"},
63
+ },
64
+ ],
65
+ "responses": {"200": {"description": "Information about the pet"}},
66
+ },
67
+ "parameters": [ # Path level parameter example
68
+ {
69
+ "name": "traceId",
70
+ "in": "header",
71
+ "description": "Common trace ID",
72
+ "required": False,
73
+ "schema": {"type": "string"},
74
+ }
75
+ ],
76
+ },
77
+ },
78
+ "components": {
79
+ "schemas": {
80
+ "Pet": {
81
+ "type": "object",
82
+ "required": ["id", "name"],
83
+ "properties": {
84
+ "id": {"type": "integer", "format": "int64"},
85
+ "name": {"type": "string"},
86
+ "tag": {"type": "string"},
87
+ },
88
+ }
89
+ },
90
+ "requestBodies": {
91
+ "PetBody": {
92
+ "description": "Pet object",
93
+ "required": True,
94
+ "content": {
95
+ "application/json": {
96
+ "schema": {"$ref": "#/components/schemas/Pet"}
97
+ }
98
+ },
99
+ }
100
+ },
101
+ },
102
+ }
103
+
104
+
105
+ @pytest.fixture
106
+ def parsed_petstore_routes(petstore_schema):
107
+ """Return parsed routes from the PetStore schema."""
108
+ return parse_openapi_to_http_routes(petstore_schema)
109
+
110
+
111
+ @pytest.fixture
112
+ def bookstore_schema() -> Dict[str, Any]:
113
+ """Fixture that returns a Book Store API schema with different parameter types."""
114
+ return {
115
+ "openapi": "3.1.0",
116
+ "info": {"title": "Book Store API", "version": "1.0.0"},
117
+ "paths": {
118
+ "/books": {
119
+ "get": {
120
+ "summary": "List all books",
121
+ "operationId": "listBooks",
122
+ "tags": ["books"],
123
+ "parameters": [
124
+ {
125
+ "name": "genre",
126
+ "in": "query",
127
+ "description": "Filter by genre",
128
+ "required": False,
129
+ "schema": {"type": "string"},
130
+ },
131
+ {
132
+ "name": "published_after",
133
+ "in": "query",
134
+ "description": "Filter by publication date",
135
+ "required": False,
136
+ "schema": {"type": "string", "format": "date"},
137
+ },
138
+ {
139
+ "name": "limit",
140
+ "in": "query",
141
+ "description": "Maximum number of results",
142
+ "required": False,
143
+ "schema": {"type": "integer", "default": 10},
144
+ },
145
+ ],
146
+ "responses": {"200": {"description": "A list of books"}},
147
+ },
148
+ "post": {
149
+ "summary": "Create a new book",
150
+ "operationId": "createBook",
151
+ "tags": ["books"],
152
+ "requestBody": {
153
+ "required": True,
154
+ "content": {
155
+ "application/json": {
156
+ "schema": {
157
+ "type": "object",
158
+ "required": ["title", "author"],
159
+ "properties": {
160
+ "title": {"type": "string"},
161
+ "author": {"type": "string"},
162
+ "isbn": {"type": "string"},
163
+ "published": {
164
+ "type": "string",
165
+ "format": "date",
166
+ },
167
+ "genre": {"type": "string"},
168
+ },
169
+ }
170
+ }
171
+ },
172
+ },
173
+ "responses": {"201": {"description": "Book created"}},
174
+ },
175
+ },
176
+ "/books/{isbn}": {
177
+ "get": {
178
+ "summary": "Get book by ISBN",
179
+ "operationId": "getBook",
180
+ "tags": ["books"],
181
+ "parameters": [
182
+ {
183
+ "name": "isbn",
184
+ "in": "path",
185
+ "required": True,
186
+ "description": "ISBN of the book",
187
+ "schema": {"type": "string"},
188
+ }
189
+ ],
190
+ "responses": {"200": {"description": "Book details"}},
191
+ },
192
+ "delete": {
193
+ "summary": "Delete a book",
194
+ "operationId": "deleteBook",
195
+ "tags": ["books"],
196
+ "parameters": [
197
+ {
198
+ "name": "isbn",
199
+ "in": "path",
200
+ "required": True,
201
+ "description": "ISBN of the book to delete",
202
+ "schema": {"type": "string"},
203
+ }
204
+ ],
205
+ "responses": {"204": {"description": "Book deleted"}},
206
+ },
207
+ },
208
+ },
209
+ }
210
+
211
+
212
+ @pytest.fixture
213
+ def parsed_bookstore_routes(bookstore_schema):
214
+ """Return parsed routes from the BookStore schema."""
215
+ return parse_openapi_to_http_routes(bookstore_schema)
216
+
217
+
218
+ # --- FastAPI App Fixtures --- #
219
+
220
+
221
+ class Item(BaseModel):
222
+ """Example pydantic model for API testing."""
223
+
224
+ name: str
225
+ description: str | None = None
226
+ price: float
227
+ tax: float | None = None
228
+ tags: list[str] = Field(default_factory=list)
229
+
230
+
231
+ @pytest.fixture
232
+ def fastapi_app() -> FastAPI:
233
+ """Fixture that returns a FastAPI app with various types of endpoints."""
234
+ app = FastAPI(title="Test API", version="1.0.0")
235
+
236
+ @app.get("/items/", operation_id="list_items")
237
+ async def list_items(skip: int = 0, limit: int = 10):
238
+ """List all items with pagination."""
239
+ return [
240
+ {"name": f"Item {i}", "price": float(i)} for i in range(skip, skip + limit)
241
+ ]
242
+
243
+ @app.post("/items/", operation_id="create_item")
244
+ async def create_item(item: Item):
245
+ """Create a new item."""
246
+ return item
247
+
248
+ @app.get("/items/{item_id}", operation_id="get_item")
249
+ async def get_item(
250
+ item_id: int = Path(..., description="The ID of the item to get"),
251
+ q: str | None = Query(None, description="Optional query string"),
252
+ ):
253
+ """Get an item by ID."""
254
+ return {"item_id": item_id, "q": q}
255
+
256
+ @app.put("/items/{item_id}", operation_id="update_item")
257
+ async def update_item(
258
+ item_id: int = Path(..., description="The ID of the item to update"),
259
+ item: Item = Body(..., description="The updated item data"),
260
+ ):
261
+ """Update an existing item."""
262
+ return {"item_id": item_id, **item.model_dump()}
263
+
264
+ @app.delete("/items/{item_id}", operation_id="delete_item")
265
+ async def delete_item(
266
+ item_id: int = Path(..., description="The ID of the item to delete"),
267
+ ):
268
+ """Delete an item by ID."""
269
+ return {"item_id": item_id, "deleted": True}
270
+
271
+ @app.get("/items/{item_id}/tags/{tag_id}", operation_id="get_item_tag")
272
+ async def get_item_tag(
273
+ item_id: int = Path(..., description="The ID of the item"),
274
+ tag_id: str = Path(..., description="The ID of the tag"),
275
+ ):
276
+ """Get a specific tag for an item."""
277
+ return {"item_id": item_id, "tag_id": tag_id}
278
+
279
+ @app.post("/upload/", operation_id="upload_file")
280
+ async def upload_file(
281
+ file_name: str = Query(..., description="Name of the file to upload"),
282
+ content_type: str = Query(..., description="Content type of the file"),
283
+ ):
284
+ """Upload a file (dummy endpoint for testing query params with POST)."""
285
+ return {
286
+ "file_name": file_name,
287
+ "content_type": content_type,
288
+ "status": "uploaded",
289
+ }
290
+
291
+ return app
292
+
293
+
294
+ @pytest.fixture
295
+ def fastapi_openapi_schema(fastapi_app) -> Dict[str, Any]:
296
+ """Fixture that returns the OpenAPI schema of the FastAPI app."""
297
+ return fastapi_app.openapi()
298
+
299
+
300
+ @pytest.fixture
301
+ def parsed_fastapi_routes(fastapi_openapi_schema):
302
+ """Return parsed routes from a FastAPI OpenAPI schema."""
303
+ return parse_openapi_to_http_routes(fastapi_openapi_schema)
304
+
305
+
306
+ @pytest.fixture
307
+ def fastapi_route_map(parsed_fastapi_routes):
308
+ """Return a dictionary of routes by operation ID."""
309
+ return {
310
+ r.operation_id: r for r in parsed_fastapi_routes if r.operation_id is not None
311
+ }
312
+
313
+
314
+ # --- Tests for PetStore schema --- #
315
+
316
+
317
+ def test_petstore_route_count(parsed_petstore_routes):
318
+ """Test that parsing the PetStore schema correctly identifies the number of routes."""
319
+ assert len(parsed_petstore_routes) == 3
320
+
321
+
322
+ def test_petstore_get_pets_operation_id(parsed_petstore_routes):
323
+ """Test that GET /pets operation_id is correctly parsed."""
324
+ get_pets = next(
325
+ (r for r in parsed_petstore_routes if r.method == "GET" and r.path == "/pets"),
326
+ None,
327
+ )
328
+ assert get_pets is not None
329
+ assert get_pets.operation_id == "listPets"
330
+
331
+
332
+ def test_petstore_query_parameter(parsed_petstore_routes):
333
+ """Test that query parameter 'limit' is correctly parsed from the schema."""
334
+ get_pets = next(
335
+ (r for r in parsed_petstore_routes if r.method == "GET" and r.path == "/pets"),
336
+ None,
337
+ )
338
+
339
+ assert get_pets is not None
340
+ assert len(get_pets.parameters) == 1
341
+ param = get_pets.parameters[0]
342
+ assert param.name == "limit"
343
+ assert param.location == "query"
344
+ assert param.required is False
345
+ assert param.schema_.get("type") == "integer"
346
+ assert param.schema_.get("format") == "int32"
347
+
348
+
349
+ def test_petstore_path_parameter(parsed_petstore_routes):
350
+ """Test that path parameter 'petId' is correctly parsed from the schema."""
351
+ get_pet = next(
352
+ (
353
+ r
354
+ for r in parsed_petstore_routes
355
+ if r.method == "GET" and r.path == "/pets/{petId}"
356
+ ),
357
+ None,
358
+ )
359
+
360
+ assert get_pet is not None
361
+ path_param = next((p for p in get_pet.parameters if p.name == "petId"), None)
362
+ assert path_param is not None
363
+ assert path_param.location == "path"
364
+ assert path_param.required is True
365
+ assert path_param.schema_.get("type") == "string"
366
+
367
+
368
+ def test_petstore_header_parameters(parsed_petstore_routes):
369
+ """Test that header parameters are correctly parsed from the schema."""
370
+ get_pet = next(
371
+ (
372
+ r
373
+ for r in parsed_petstore_routes
374
+ if r.method == "GET" and r.path == "/pets/{petId}"
375
+ ),
376
+ None,
377
+ )
378
+
379
+ assert get_pet is not None
380
+ header_params = [p for p in get_pet.parameters if p.location == "header"]
381
+ assert len(header_params) == 2
382
+
383
+
384
+ def test_petstore_header_parameter_names(parsed_petstore_routes):
385
+ """Test that header parameter names are correctly parsed."""
386
+ get_pet = next(
387
+ (
388
+ r
389
+ for r in parsed_petstore_routes
390
+ if r.method == "GET" and r.path == "/pets/{petId}"
391
+ ),
392
+ None,
393
+ )
394
+
395
+ assert get_pet is not None
396
+ header_params = [p for p in get_pet.parameters if p.location == "header"]
397
+ header_names = [p.name for p in header_params]
398
+ assert "X-Request-ID" in header_names
399
+ assert "traceId" in header_names
400
+
401
+
402
+ def test_petstore_path_level_parameters(parsed_petstore_routes):
403
+ """Test that path-level parameters are correctly merged into the operation."""
404
+ get_pet = next(
405
+ (
406
+ r
407
+ for r in parsed_petstore_routes
408
+ if r.method == "GET" and r.path == "/pets/{petId}"
409
+ ),
410
+ None,
411
+ )
412
+
413
+ assert get_pet is not None
414
+ trace_param = next((p for p in get_pet.parameters if p.name == "traceId"), None)
415
+ assert trace_param is not None
416
+ assert trace_param.location == "header"
417
+ assert trace_param.required is False
418
+
419
+
420
+ def test_petstore_request_body_reference_resolution(parsed_petstore_routes):
421
+ """Test that request body references are correctly resolved."""
422
+ create_pet = next(
423
+ (r for r in parsed_petstore_routes if r.method == "POST" and r.path == "/pets"),
424
+ None,
425
+ )
426
+
427
+ assert create_pet is not None
428
+ assert create_pet.request_body is not None
429
+ assert create_pet.request_body.required is True
430
+ assert "application/json" in create_pet.request_body.content_schema
431
+
432
+
433
+ def test_petstore_schema_reference_resolution(parsed_petstore_routes):
434
+ """Test that schema references in request bodies are correctly resolved."""
435
+ create_pet = next(
436
+ (r for r in parsed_petstore_routes if r.method == "POST" and r.path == "/pets"),
437
+ None,
438
+ )
439
+
440
+ assert create_pet is not None
441
+ assert create_pet.request_body is not None
442
+ json_schema = create_pet.request_body.content_schema["application/json"]
443
+ properties = json_schema.get("properties", {})
444
+
445
+ assert "id" in properties
446
+ assert "name" in properties
447
+ assert "tag" in properties
448
+
449
+
450
+ def test_petstore_required_fields_resolution(parsed_petstore_routes):
451
+ """Test that required fields are correctly resolved from referenced schemas."""
452
+ create_pet = next(
453
+ (r for r in parsed_petstore_routes if r.method == "POST" and r.path == "/pets"),
454
+ None,
455
+ )
456
+
457
+ assert create_pet is not None
458
+ assert create_pet.request_body is not None
459
+ json_schema = create_pet.request_body.content_schema["application/json"]
460
+ assert json_schema.get("required") == ["id", "name"]
461
+
462
+
463
+ # --- Tests for BookStore schema --- #
464
+
465
+
466
+ def test_bookstore_route_count(parsed_bookstore_routes):
467
+ """Test that parsing the BookStore schema correctly identifies the number of routes."""
468
+ assert len(parsed_bookstore_routes) == 4
469
+
470
+
471
+ def test_bookstore_query_parameter_count(parsed_bookstore_routes):
472
+ """Test that the correct number of query parameters are parsed."""
473
+ list_books = next(
474
+ (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None
475
+ )
476
+
477
+ assert list_books is not None
478
+ assert len(list_books.parameters) == 3
479
+
480
+
481
+ def test_bookstore_query_parameter_names(parsed_bookstore_routes):
482
+ """Test that query parameter names are correctly parsed."""
483
+ list_books = next(
484
+ (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None
485
+ )
486
+
487
+ assert list_books is not None
488
+ param_map = {p.name: p for p in list_books.parameters}
489
+ assert "genre" in param_map
490
+ assert "published_after" in param_map
491
+ assert "limit" in param_map
492
+
493
+
494
+ def test_bookstore_query_parameter_formats(parsed_bookstore_routes):
495
+ """Test that query parameter formats are correctly parsed."""
496
+ list_books = next(
497
+ (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None
498
+ )
499
+
500
+ assert list_books is not None
501
+ param_map = {p.name: p for p in list_books.parameters}
502
+ assert param_map["published_after"].schema_.get("format") == "date"
503
+
504
+
505
+ def test_bookstore_query_parameter_defaults(parsed_bookstore_routes):
506
+ """Test that query parameter default values are correctly parsed."""
507
+ list_books = next(
508
+ (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None
509
+ )
510
+
511
+ assert list_books is not None
512
+ param_map = {p.name: p for p in list_books.parameters}
513
+ assert param_map["limit"].schema_.get("default") == 10
514
+
515
+
516
+ def test_bookstore_inline_request_body_presence(parsed_bookstore_routes):
517
+ """Test that request bodies with inline schemas are present."""
518
+ create_book = next(
519
+ (r for r in parsed_bookstore_routes if r.operation_id == "createBook"), None
520
+ )
521
+
522
+ assert create_book is not None
523
+ assert create_book.request_body is not None
524
+ assert create_book.request_body.required is True
525
+ assert "application/json" in create_book.request_body.content_schema
526
+
527
+
528
+ def test_bookstore_inline_request_body_properties(parsed_bookstore_routes):
529
+ """Test that request body properties are correctly parsed from inline schemas."""
530
+ create_book = next(
531
+ (r for r in parsed_bookstore_routes if r.operation_id == "createBook"), None
532
+ )
533
+
534
+ assert create_book is not None
535
+ assert create_book.request_body is not None
536
+
537
+ json_schema = create_book.request_body.content_schema["application/json"]
538
+ properties = json_schema.get("properties", {})
539
+
540
+ assert "title" in properties
541
+ assert "author" in properties
542
+ assert "isbn" in properties
543
+ assert "published" in properties
544
+ assert "genre" in properties
545
+
546
+
547
+ def test_bookstore_inline_request_body_required_fields(parsed_bookstore_routes):
548
+ """Test that required fields in inline schema are correctly parsed."""
549
+ create_book = next(
550
+ (r for r in parsed_bookstore_routes if r.operation_id == "createBook"), None
551
+ )
552
+
553
+ assert create_book is not None
554
+ assert create_book.request_body is not None
555
+
556
+ json_schema = create_book.request_body.content_schema["application/json"]
557
+ assert json_schema.get("required") == ["title", "author"]
558
+
559
+
560
+ def test_bookstore_delete_method(parsed_bookstore_routes):
561
+ """Test that DELETE method is correctly parsed from the schema."""
562
+ delete_book = next(
563
+ (r for r in parsed_bookstore_routes if r.method == "DELETE"), None
564
+ )
565
+
566
+ assert delete_book is not None
567
+ assert delete_book.operation_id == "deleteBook"
568
+ assert delete_book.path == "/books/{isbn}"
569
+
570
+
571
+ def test_bookstore_delete_method_parameters(parsed_bookstore_routes):
572
+ """Test that parameters for DELETE method are correctly parsed."""
573
+ delete_book = next(
574
+ (r for r in parsed_bookstore_routes if r.method == "DELETE"), None
575
+ )
576
+
577
+ assert delete_book is not None
578
+ assert len(delete_book.parameters) == 1
579
+ assert delete_book.parameters[0].name == "isbn"
580
+
581
+
582
+ # --- Tests for FastAPI Generated Schema --- #
583
+
584
+
585
+ def test_fastapi_route_count(parsed_fastapi_routes):
586
+ """Test that parsing a FastAPI-generated schema correctly identifies the number of routes."""
587
+ assert len(parsed_fastapi_routes) == 7
588
+
589
+
590
+ def test_fastapi_parameter_default_values(fastapi_route_map):
591
+ """Test that default parameter values are correctly parsed from the schema."""
592
+ list_items = fastapi_route_map["list_items"]
593
+
594
+ param_map = {p.name: p for p in list_items.parameters}
595
+ assert "skip" in param_map
596
+ assert "limit" in param_map
597
+
598
+
599
+ def test_fastapi_skip_parameter_default(fastapi_route_map):
600
+ """Test that skip parameter default value is correctly parsed."""
601
+ list_items = fastapi_route_map["list_items"]
602
+
603
+ param_map = {p.name: p for p in list_items.parameters}
604
+ assert param_map["skip"].schema_.get("default") == 0
605
+
606
+
607
+ def test_fastapi_limit_parameter_default(fastapi_route_map):
608
+ """Test that limit parameter default value is correctly parsed."""
609
+ list_items = fastapi_route_map["list_items"]
610
+
611
+ param_map = {p.name: p for p in list_items.parameters}
612
+ assert param_map["limit"].schema_.get("default") == 10
613
+
614
+
615
+ def test_fastapi_request_body_from_pydantic(fastapi_route_map):
616
+ """Test that request bodies from Pydantic models are present."""
617
+ create_item = fastapi_route_map["create_item"]
618
+
619
+ assert create_item.request_body is not None
620
+ assert "application/json" in create_item.request_body.content_schema
621
+
622
+
623
+ def test_fastapi_request_body_properties(fastapi_route_map):
624
+ """Test that request body properties from Pydantic models are correctly parsed."""
625
+ create_item = fastapi_route_map["create_item"]
626
+
627
+ json_schema = create_item.request_body.content_schema["application/json"]
628
+ properties = json_schema.get("properties", {})
629
+
630
+ assert "name" in properties
631
+ assert "description" in properties
632
+ assert "price" in properties
633
+ assert "tax" in properties
634
+ assert "tags" in properties
635
+
636
+
637
+ def test_fastapi_request_body_required_fields(fastapi_route_map):
638
+ """Test that required fields from Pydantic models are correctly parsed."""
639
+ create_item = fastapi_route_map["create_item"]
640
+
641
+ json_schema = create_item.request_body.content_schema["application/json"]
642
+ required = json_schema.get("required", [])
643
+
644
+ assert "name" in required
645
+ assert "price" in required
646
+
647
+
648
+ def test_fastapi_path_parameter_presence(fastapi_route_map):
649
+ """Test that path parameters are present in FastAPI schema."""
650
+ get_item = fastapi_route_map["get_item"]
651
+
652
+ path_params = [p for p in get_item.parameters if p.location == "path"]
653
+ assert len(path_params) == 1
654
+
655
+
656
+ def test_fastapi_path_parameter_properties(fastapi_route_map):
657
+ """Test that path parameters properties are correctly parsed."""
658
+ get_item = fastapi_route_map["get_item"]
659
+
660
+ path_params = [p for p in get_item.parameters if p.location == "path"]
661
+ assert path_params[0].name == "item_id"
662
+ assert path_params[0].required is True
663
+
664
+
665
+ def test_fastapi_optional_query_parameter(fastapi_route_map):
666
+ """Test that optional query parameters are correctly parsed."""
667
+ get_item = fastapi_route_map["get_item"]
668
+
669
+ query_params = [p for p in get_item.parameters if p.location == "query"]
670
+ assert len(query_params) == 1
671
+ assert query_params[0].name == "q"
672
+ assert query_params[0].required is False
673
+
674
+
675
+ def test_fastapi_multiple_path_parameter_count(fastapi_route_map):
676
+ """Test that multiple path parameters count is correct."""
677
+ get_item_tag = fastapi_route_map["get_item_tag"]
678
+
679
+ path_params = [p for p in get_item_tag.parameters if p.location == "path"]
680
+ assert len(path_params) == 2
681
+
682
+
683
+ def test_fastapi_multiple_path_parameter_names(fastapi_route_map):
684
+ """Test that multiple path parameter names are correctly parsed."""
685
+ get_item_tag = fastapi_route_map["get_item_tag"]
686
+
687
+ path_params = [p for p in get_item_tag.parameters if p.location == "path"]
688
+ param_names = [p.name for p in path_params]
689
+ assert "item_id" in param_names
690
+ assert "tag_id" in param_names
691
+
692
+
693
+ def test_fastapi_post_with_query_parameters(fastapi_route_map):
694
+ """Test that query parameters for POST methods are correctly parsed."""
695
+ upload_file = fastapi_route_map["upload_file"]
696
+
697
+ assert upload_file.method == "POST"
698
+ query_params = [p for p in upload_file.parameters if p.location == "query"]
699
+ assert len(query_params) == 2
700
+
701
+
702
+ def test_fastapi_post_query_parameter_names(fastapi_route_map):
703
+ """Test that query parameter names for POST methods are correctly parsed."""
704
+ upload_file = fastapi_route_map["upload_file"]
705
+
706
+ query_params = [p for p in upload_file.parameters if p.location == "query"]
707
+ param_names = [p.name for p in query_params]
708
+ assert "file_name" in param_names
709
+ assert "content_type" in param_names
tests/utilities/openapi/test_openapi_advanced.py ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for advanced features of the OpenAPI utilities."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ import pytest
6
+
7
+ from fastmcp.utilities.openapi import parse_openapi_to_http_routes
8
+
9
+
10
+ @pytest.fixture
11
+ def complex_schema() -> Dict[str, Any]:
12
+ """Fixture that returns a complex OpenAPI schema with nested references."""
13
+ return {
14
+ "openapi": "3.1.0",
15
+ "info": {"title": "Complex API", "version": "1.0.0"},
16
+ "paths": {
17
+ "/users": {
18
+ "get": {
19
+ "summary": "List all users",
20
+ "operationId": "listUsers",
21
+ "parameters": [
22
+ {"$ref": "#/components/parameters/PageLimit"},
23
+ {"$ref": "#/components/parameters/PageOffset"},
24
+ ],
25
+ "responses": {"200": {"description": "A list of users"}},
26
+ }
27
+ },
28
+ "/users/{userId}": {
29
+ "get": {
30
+ "summary": "Get user by ID",
31
+ "operationId": "getUser",
32
+ "parameters": [
33
+ {"$ref": "#/components/parameters/UserId"},
34
+ {"$ref": "#/components/parameters/IncludeInactive"},
35
+ ],
36
+ "responses": {"200": {"description": "User details"}},
37
+ }
38
+ },
39
+ "/users/{userId}/orders": {
40
+ "post": {
41
+ "summary": "Create order for user",
42
+ "operationId": "createOrder",
43
+ "parameters": [{"$ref": "#/components/parameters/UserId"}],
44
+ "requestBody": {"$ref": "#/components/requestBodies/OrderRequest"},
45
+ "responses": {"201": {"description": "Order created"}},
46
+ }
47
+ },
48
+ },
49
+ "components": {
50
+ "parameters": {
51
+ "UserId": {
52
+ "name": "userId",
53
+ "in": "path",
54
+ "required": True,
55
+ "schema": {"type": "string", "format": "uuid"},
56
+ },
57
+ "PageLimit": {
58
+ "name": "limit",
59
+ "in": "query",
60
+ "schema": {"type": "integer", "default": 20, "maximum": 100},
61
+ },
62
+ "PageOffset": {
63
+ "name": "offset",
64
+ "in": "query",
65
+ "schema": {"type": "integer", "default": 0},
66
+ },
67
+ "IncludeInactive": {
68
+ "name": "include_inactive",
69
+ "in": "query",
70
+ "schema": {"type": "boolean", "default": False},
71
+ },
72
+ },
73
+ "schemas": {
74
+ "User": {
75
+ "type": "object",
76
+ "properties": {
77
+ "id": {"type": "string", "format": "uuid"},
78
+ "name": {"type": "string"},
79
+ "email": {"type": "string", "format": "email"},
80
+ "role": {"$ref": "#/components/schemas/Role"},
81
+ "address": {"$ref": "#/components/schemas/Address"},
82
+ },
83
+ },
84
+ "Role": {
85
+ "type": "string",
86
+ "enum": ["admin", "user", "guest"],
87
+ },
88
+ "Address": {
89
+ "type": "object",
90
+ "properties": {
91
+ "street": {"type": "string"},
92
+ "city": {"type": "string"},
93
+ "zip": {"type": "string"},
94
+ "country": {"type": "string"},
95
+ },
96
+ },
97
+ "Order": {
98
+ "type": "object",
99
+ "properties": {
100
+ "id": {"type": "string", "format": "uuid"},
101
+ "items": {
102
+ "type": "array",
103
+ "items": {"$ref": "#/components/schemas/OrderItem"},
104
+ },
105
+ "total": {"type": "number"},
106
+ "status": {"$ref": "#/components/schemas/OrderStatus"},
107
+ },
108
+ },
109
+ "OrderItem": {
110
+ "type": "object",
111
+ "properties": {
112
+ "product_id": {"type": "string", "format": "uuid"},
113
+ "quantity": {"type": "integer"},
114
+ "price": {"type": "number"},
115
+ },
116
+ },
117
+ "OrderStatus": {
118
+ "type": "string",
119
+ "enum": [
120
+ "pending",
121
+ "processing",
122
+ "shipped",
123
+ "delivered",
124
+ "cancelled",
125
+ ],
126
+ },
127
+ },
128
+ "requestBodies": {
129
+ "OrderRequest": {
130
+ "description": "Order to create",
131
+ "required": True,
132
+ "content": {
133
+ "application/json": {
134
+ "schema": {
135
+ "type": "object",
136
+ "required": ["items"],
137
+ "properties": {
138
+ "items": {
139
+ "type": "array",
140
+ "items": {
141
+ "$ref": "#/components/schemas/OrderItem"
142
+ },
143
+ },
144
+ "notes": {"type": "string"},
145
+ },
146
+ }
147
+ }
148
+ },
149
+ }
150
+ },
151
+ },
152
+ }
153
+
154
+
155
+ @pytest.fixture
156
+ def parsed_complex_routes(complex_schema):
157
+ """Return parsed routes from the complex schema."""
158
+ return parse_openapi_to_http_routes(complex_schema)
159
+
160
+
161
+ @pytest.fixture
162
+ def complex_route_map(parsed_complex_routes):
163
+ """Return a dictionary of routes by operation ID."""
164
+ return {
165
+ r.operation_id: r for r in parsed_complex_routes if r.operation_id is not None
166
+ }
167
+
168
+
169
+ @pytest.fixture
170
+ def schema_with_invalid_reference() -> Dict[str, Any]:
171
+ """Fixture that returns a schema with an invalid reference."""
172
+ return {
173
+ "openapi": "3.1.0",
174
+ "info": {"title": "Invalid Reference API", "version": "1.0.0"},
175
+ "paths": {
176
+ "/broken-ref": {
177
+ "get": {
178
+ "summary": "Endpoint with broken reference",
179
+ "operationId": "brokenRef",
180
+ "parameters": [
181
+ {"$ref": "#/components/parameters/NonExistentParam"}
182
+ ],
183
+ "responses": {"200": {"description": "Something"}},
184
+ }
185
+ }
186
+ },
187
+ "components": {
188
+ "parameters": {} # Empty parameters object to ensure the reference is broken
189
+ },
190
+ }
191
+
192
+
193
+ @pytest.fixture
194
+ def schema_with_content_params() -> Dict[str, Any]:
195
+ """Fixture that returns a schema with content-based parameters (complex parameters)."""
196
+ return {
197
+ "openapi": "3.1.0",
198
+ "info": {"title": "Content Params API", "version": "1.0.0"},
199
+ "paths": {
200
+ "/complex-params": {
201
+ "post": {
202
+ "summary": "Endpoint with complex parameter",
203
+ "operationId": "complexParams",
204
+ "parameters": [
205
+ {
206
+ "name": "filter",
207
+ "in": "query",
208
+ "content": {
209
+ "application/json": {
210
+ "schema": {
211
+ "type": "object",
212
+ "properties": {
213
+ "field": {"type": "string"},
214
+ "operator": {
215
+ "type": "string",
216
+ "enum": ["eq", "gt", "lt"],
217
+ },
218
+ "value": {"type": "string"},
219
+ },
220
+ }
221
+ }
222
+ },
223
+ }
224
+ ],
225
+ "responses": {"200": {"description": "Results"}},
226
+ }
227
+ },
228
+ },
229
+ }
230
+
231
+
232
+ @pytest.fixture
233
+ def parsed_content_param_routes(schema_with_content_params):
234
+ """Return parsed routes from the schema with content parameters."""
235
+ return parse_openapi_to_http_routes(schema_with_content_params)
236
+
237
+
238
+ @pytest.fixture
239
+ def schema_all_http_methods() -> Dict[str, Any]:
240
+ """Fixture that returns a schema with all HTTP methods."""
241
+ return {
242
+ "openapi": "3.1.0",
243
+ "info": {"title": "All Methods API", "version": "1.0.0"},
244
+ "paths": {
245
+ "/resource": {
246
+ "get": {
247
+ "operationId": "getResource",
248
+ "responses": {"200": {"description": "Success"}},
249
+ },
250
+ "post": {
251
+ "operationId": "createResource",
252
+ "responses": {"201": {"description": "Created"}},
253
+ },
254
+ "put": {
255
+ "operationId": "updateResource",
256
+ "responses": {"200": {"description": "Updated"}},
257
+ },
258
+ "delete": {
259
+ "operationId": "deleteResource",
260
+ "responses": {"204": {"description": "Deleted"}},
261
+ },
262
+ "patch": {
263
+ "operationId": "patchResource",
264
+ "responses": {"200": {"description": "Patched"}},
265
+ },
266
+ "head": {
267
+ "operationId": "headResource",
268
+ "responses": {"200": {"description": "Headers only"}},
269
+ },
270
+ "options": {
271
+ "operationId": "optionsResource",
272
+ "responses": {"200": {"description": "Options"}},
273
+ },
274
+ "trace": {
275
+ "operationId": "traceResource",
276
+ "responses": {"200": {"description": "Trace"}},
277
+ },
278
+ },
279
+ },
280
+ }
281
+
282
+
283
+ @pytest.fixture
284
+ def parsed_http_methods_routes(schema_all_http_methods):
285
+ """Return parsed routes from the schema with all HTTP methods."""
286
+ return parse_openapi_to_http_routes(schema_all_http_methods)
287
+
288
+
289
+ # --- Tests for complex schemas with references --- #
290
+
291
+
292
+ def test_complex_schema_route_count(parsed_complex_routes):
293
+ """Test that parsing a schema with references successfully extracts all routes."""
294
+ assert len(parsed_complex_routes) == 3
295
+
296
+
297
+ def test_complex_schema_list_users_query_param_limit(complex_route_map):
298
+ """Test that a reference to a limit query parameter is correctly resolved."""
299
+ list_users = complex_route_map["listUsers"]
300
+
301
+ limit_param = next((p for p in list_users.parameters if p.name == "limit"), None)
302
+ assert limit_param is not None
303
+ assert limit_param.location == "query"
304
+ assert limit_param.schema_.get("default") == 20
305
+
306
+
307
+ def test_complex_schema_list_users_query_param_limit_maximum(complex_route_map):
308
+ """Test that a limit parameter's maximum value is correctly resolved."""
309
+ list_users = complex_route_map["listUsers"]
310
+
311
+ limit_param = next((p for p in list_users.parameters if p.name == "limit"), None)
312
+ assert limit_param is not None
313
+ assert limit_param.schema_.get("maximum") == 100
314
+
315
+
316
+ def test_complex_schema_get_user_path_param_existence(complex_route_map):
317
+ """Test that a reference to a path parameter exists."""
318
+ get_user = complex_route_map["getUser"]
319
+
320
+ user_id_param = next((p for p in get_user.parameters if p.name == "userId"), None)
321
+ assert user_id_param is not None
322
+ assert user_id_param.location == "path"
323
+
324
+
325
+ def test_complex_schema_get_user_path_param_required(complex_route_map):
326
+ """Test that a path parameter is correctly marked as required."""
327
+ get_user = complex_route_map["getUser"]
328
+
329
+ user_id_param = next((p for p in get_user.parameters if p.name == "userId"), None)
330
+ assert user_id_param is not None
331
+ assert user_id_param.required is True
332
+
333
+
334
+ def test_complex_schema_get_user_path_param_format(complex_route_map):
335
+ """Test that a path parameter format is correctly resolved."""
336
+ get_user = complex_route_map["getUser"]
337
+
338
+ user_id_param = next((p for p in get_user.parameters if p.name == "userId"), None)
339
+ assert user_id_param is not None
340
+ assert user_id_param.schema_.get("format") == "uuid"
341
+
342
+
343
+ def test_complex_schema_create_order_request_body_presence(complex_route_map):
344
+ """Test that a reference to a request body is resolved correctly."""
345
+ create_order = complex_route_map["createOrder"]
346
+
347
+ assert create_order.request_body is not None
348
+ assert create_order.request_body.required is True
349
+
350
+
351
+ def test_complex_schema_create_order_request_body_content_type(complex_route_map):
352
+ """Test that request body content type is correctly resolved."""
353
+ create_order = complex_route_map["createOrder"]
354
+
355
+ assert create_order.request_body is not None
356
+ assert "application/json" in create_order.request_body.content_schema
357
+
358
+
359
+ def test_complex_schema_create_order_request_body_properties(complex_route_map):
360
+ """Test that request body properties are correctly resolved."""
361
+ create_order = complex_route_map["createOrder"]
362
+
363
+ assert create_order.request_body is not None
364
+ json_schema = create_order.request_body.content_schema["application/json"]
365
+ assert "items" in json_schema.get("properties", {})
366
+
367
+
368
+ def test_complex_schema_create_order_request_body_required_fields(complex_route_map):
369
+ """Test that request body required fields are correctly resolved."""
370
+ create_order = complex_route_map["createOrder"]
371
+
372
+ assert create_order.request_body is not None
373
+ json_schema = create_order.request_body.content_schema["application/json"]
374
+ assert json_schema.get("required") == ["items"]
375
+
376
+
377
+ # --- Tests for schema reference resolution errors --- #
378
+
379
+
380
+ def test_parser_handles_broken_references(schema_with_invalid_reference):
381
+ """Test that parser handles broken references gracefully."""
382
+ # We're just checking that the function doesn't throw an exception
383
+ routes = parse_openapi_to_http_routes(schema_with_invalid_reference)
384
+
385
+ # Should still return routes list (may be empty)
386
+ assert isinstance(routes, list)
387
+
388
+ # Verify that the route with broken parameter reference is still included
389
+ # though it may not have the parameter properly
390
+ broken_route = next(
391
+ (r for r in routes if r.path == "/broken-ref" and r.method == "GET"), None
392
+ )
393
+
394
+ # The route should still be present
395
+ assert broken_route is not None
396
+ assert broken_route.operation_id == "brokenRef"
397
+
398
+
399
+ # --- Tests for content-based parameters --- #
400
+
401
+
402
+ def test_content_param_parameter_name(parsed_content_param_routes):
403
+ """Test that parser correctly extracts name for content-based parameters."""
404
+ complex_params = parsed_content_param_routes[0]
405
+
406
+ assert len(complex_params.parameters) == 1
407
+ param = complex_params.parameters[0]
408
+ assert param.name == "filter"
409
+
410
+
411
+ def test_content_param_parameter_location(parsed_content_param_routes):
412
+ """Test that parser correctly extracts location for content-based parameters."""
413
+ complex_params = parsed_content_param_routes[0]
414
+
415
+ assert len(complex_params.parameters) == 1
416
+ param = complex_params.parameters[0]
417
+ assert param.location == "query"
418
+
419
+
420
+ def test_content_param_schema_properties_presence(parsed_content_param_routes):
421
+ """Test that parser extracts schema properties from content-based parameter."""
422
+ complex_params = parsed_content_param_routes[0]
423
+
424
+ param = complex_params.parameters[0]
425
+ properties = param.schema_.get("properties", {})
426
+
427
+ assert "field" in properties
428
+ assert "operator" in properties
429
+ assert "value" in properties
430
+
431
+
432
+ def test_content_param_schema_enum_presence(parsed_content_param_routes):
433
+ """Test that parser extracts enum values from content-based parameter."""
434
+ complex_params = parsed_content_param_routes[0]
435
+
436
+ param = complex_params.parameters[0]
437
+ properties = param.schema_.get("properties", {})
438
+
439
+ assert "enum" in properties.get("operator", {})
440
+
441
+
442
+ # --- Tests for HTTP methods --- #
443
+
444
+
445
+ def test_http_get_method_presence(parsed_http_methods_routes):
446
+ """Test that GET method is correctly extracted."""
447
+ get_route = next((r for r in parsed_http_methods_routes if r.method == "GET"), None)
448
+
449
+ assert get_route is not None
450
+ assert get_route.operation_id == "getResource"
451
+
452
+
453
+ def test_http_get_method_path(parsed_http_methods_routes):
454
+ """Test that GET method path is correctly extracted."""
455
+ get_route = next((r for r in parsed_http_methods_routes if r.method == "GET"), None)
456
+
457
+ assert get_route is not None
458
+ assert get_route.path == "/resource"
459
+
460
+
461
+ def test_http_post_method_presence(parsed_http_methods_routes):
462
+ """Test that POST method is correctly extracted."""
463
+ post_route = next(
464
+ (r for r in parsed_http_methods_routes if r.method == "POST"), None
465
+ )
466
+
467
+ assert post_route is not None
468
+ assert post_route.operation_id == "createResource"
469
+
470
+
471
+ def test_http_post_method_path(parsed_http_methods_routes):
472
+ """Test that POST method path is correctly extracted."""
473
+ post_route = next(
474
+ (r for r in parsed_http_methods_routes if r.method == "POST"), None
475
+ )
476
+
477
+ assert post_route is not None
478
+ assert post_route.path == "/resource"
479
+
480
+
481
+ def test_http_put_method_presence(parsed_http_methods_routes):
482
+ """Test that PUT method is correctly extracted."""
483
+ put_route = next((r for r in parsed_http_methods_routes if r.method == "PUT"), None)
484
+
485
+ assert put_route is not None
486
+ assert put_route.operation_id == "updateResource"
487
+
488
+
489
+ def test_http_put_method_path(parsed_http_methods_routes):
490
+ """Test that PUT method path is correctly extracted."""
491
+ put_route = next((r for r in parsed_http_methods_routes if r.method == "PUT"), None)
492
+
493
+ assert put_route is not None
494
+ assert put_route.path == "/resource"
495
+
496
+
497
+ def test_http_delete_method_presence(parsed_http_methods_routes):
498
+ """Test that DELETE method is correctly extracted."""
499
+ delete_route = next(
500
+ (r for r in parsed_http_methods_routes if r.method == "DELETE"), None
501
+ )
502
+
503
+ assert delete_route is not None
504
+ assert delete_route.operation_id == "deleteResource"
505
+
506
+
507
+ def test_http_delete_method_path(parsed_http_methods_routes):
508
+ """Test that DELETE method path is correctly extracted."""
509
+ delete_route = next(
510
+ (r for r in parsed_http_methods_routes if r.method == "DELETE"), None
511
+ )
512
+
513
+ assert delete_route is not None
514
+ assert delete_route.path == "/resource"
515
+
516
+
517
+ def test_http_patch_method_presence(parsed_http_methods_routes):
518
+ """Test that PATCH method is correctly extracted."""
519
+ patch_route = next(
520
+ (r for r in parsed_http_methods_routes if r.method == "PATCH"), None
521
+ )
522
+
523
+ assert patch_route is not None
524
+ assert patch_route.operation_id == "patchResource"
525
+
526
+
527
+ def test_http_patch_method_path(parsed_http_methods_routes):
528
+ """Test that PATCH method path is correctly extracted."""
529
+ patch_route = next(
530
+ (r for r in parsed_http_methods_routes if r.method == "PATCH"), None
531
+ )
532
+
533
+ assert patch_route is not None
534
+ assert patch_route.path == "/resource"
535
+
536
+
537
+ def test_http_head_method_presence(parsed_http_methods_routes):
538
+ """Test that HEAD method is correctly extracted."""
539
+ head_route = next(
540
+ (r for r in parsed_http_methods_routes if r.method == "HEAD"), None
541
+ )
542
+
543
+ assert head_route is not None
544
+ assert head_route.operation_id == "headResource"
545
+
546
+
547
+ def test_http_head_method_path(parsed_http_methods_routes):
548
+ """Test that HEAD method path is correctly extracted."""
549
+ head_route = next(
550
+ (r for r in parsed_http_methods_routes if r.method == "HEAD"), None
551
+ )
552
+
553
+ assert head_route is not None
554
+ assert head_route.path == "/resource"
555
+
556
+
557
+ def test_http_options_method_presence(parsed_http_methods_routes):
558
+ """Test that OPTIONS method is correctly extracted."""
559
+ options_route = next(
560
+ (r for r in parsed_http_methods_routes if r.method == "OPTIONS"), None
561
+ )
562
+
563
+ assert options_route is not None
564
+ assert options_route.operation_id == "optionsResource"
565
+
566
+
567
+ def test_http_options_method_path(parsed_http_methods_routes):
568
+ """Test that OPTIONS method path is correctly extracted."""
569
+ options_route = next(
570
+ (r for r in parsed_http_methods_routes if r.method == "OPTIONS"), None
571
+ )
572
+
573
+ assert options_route is not None
574
+ assert options_route.path == "/resource"
575
+
576
+
577
+ def test_http_trace_method_presence(parsed_http_methods_routes):
578
+ """Test that TRACE method is correctly extracted."""
579
+ trace_route = next(
580
+ (r for r in parsed_http_methods_routes if r.method == "TRACE"), None
581
+ )
582
+
583
+ assert trace_route is not None
584
+ assert trace_route.operation_id == "traceResource"
585
+
586
+
587
+ def test_http_trace_method_path(parsed_http_methods_routes):
588
+ """Test that TRACE method path is correctly extracted."""
589
+ trace_route = next(
590
+ (r for r in parsed_http_methods_routes if r.method == "TRACE"), None
591
+ )
592
+
593
+ assert trace_route is not None
594
+ assert trace_route.path == "/resource"
tests/utilities/openapi/test_openapi_fastapi.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for FastAPI integration with the OpenAPI utilities."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ import pytest
6
+ from fastapi import FastAPI
7
+
8
+ from fastmcp.utilities.openapi import parse_openapi_to_http_routes
9
+
10
+
11
+ @pytest.fixture
12
+ def fastapi_server() -> FastAPI:
13
+ """Fixture that returns a FastAPI app for live OpenAPI schema testing."""
14
+ from enum import Enum
15
+ from typing import List, Optional
16
+
17
+ from fastapi import Body, Depends, Header, HTTPException, Path, Query
18
+ from pydantic import BaseModel, Field
19
+
20
+ class ItemStatus(str, Enum):
21
+ available = "available"
22
+ pending = "pending"
23
+ sold = "sold"
24
+
25
+ class Tag(BaseModel):
26
+ id: int
27
+ name: str
28
+
29
+ class Item(BaseModel):
30
+ """Example pydantic model for testing OpenAPI schema generation."""
31
+
32
+ name: str
33
+ description: Optional[str] = None
34
+ price: float
35
+ tax: Optional[float] = None
36
+ tags: List[str] = Field(default_factory=list)
37
+ status: ItemStatus = ItemStatus.available
38
+ dimensions: Optional[Dict[str, float]] = None
39
+
40
+ # Create a FastAPI app with comprehensive features
41
+ app = FastAPI(
42
+ title="Comprehensive Test API",
43
+ description="A test API with various OpenAPI features",
44
+ version="1.0.0",
45
+ )
46
+
47
+ def get_token_header(
48
+ x_token: str = Header(..., description="Authentication token"),
49
+ ):
50
+ """Example dependency function for header validation."""
51
+ if x_token != "fake-super-secret-token":
52
+ raise HTTPException(status_code=400, detail="X-Token header invalid")
53
+ return x_token
54
+
55
+ TokenDep = Depends(get_token_header)
56
+
57
+ @app.get(
58
+ "/items/",
59
+ operation_id="list_items",
60
+ summary="List all items",
61
+ description="Get a list of all items with optional filtering",
62
+ tags=["items"],
63
+ )
64
+ async def list_items(
65
+ skip: int = Query(0, description="Number of items to skip"),
66
+ limit: int = Query(10, description="Max number of items to return"),
67
+ status: Optional[ItemStatus] = Query(
68
+ None, description="Filter items by status"
69
+ ),
70
+ ):
71
+ """List all items with pagination and optional status filtering."""
72
+ fake_items = [
73
+ {"name": f"Item {i}", "price": float(i)} for i in range(skip, skip + limit)
74
+ ]
75
+ if status:
76
+ fake_items = [item for item in fake_items if item.get("status") == status]
77
+ return fake_items
78
+
79
+ @app.post(
80
+ "/items/",
81
+ operation_id="create_item",
82
+ summary="Create a new item",
83
+ tags=["items"],
84
+ status_code=201,
85
+ )
86
+ async def create_item(
87
+ item: Item = Body(..., description="Item to create"),
88
+ x_token: str = TokenDep,
89
+ ):
90
+ """Create a new item (requires authentication)."""
91
+ return item
92
+
93
+ @app.get(
94
+ "/items/{item_id}",
95
+ operation_id="get_item",
96
+ summary="Get a specific item by ID",
97
+ tags=["items"],
98
+ )
99
+ async def get_item(
100
+ item_id: int = Path(..., description="The ID of the item to retrieve"),
101
+ include_tax: bool = Query(
102
+ False, description="Whether to include tax information"
103
+ ),
104
+ ):
105
+ """Get details about a specific item."""
106
+ item = {
107
+ "id": item_id,
108
+ "name": f"Item {item_id}",
109
+ "price": float(item_id) * 10.0,
110
+ }
111
+ if include_tax:
112
+ item["tax"] = item["price"] * 0.2
113
+ return item
114
+
115
+ @app.put(
116
+ "/items/{item_id}",
117
+ operation_id="update_item",
118
+ summary="Update an existing item",
119
+ tags=["items"],
120
+ )
121
+ async def update_item(
122
+ item_id: int = Path(..., description="The ID of the item to update"),
123
+ item: Item = Body(..., description="Updated item data"),
124
+ x_token: str = TokenDep,
125
+ ):
126
+ """Update an existing item (requires authentication)."""
127
+ return {"item_id": item_id, **item.model_dump()}
128
+
129
+ @app.delete(
130
+ "/items/{item_id}",
131
+ operation_id="delete_item",
132
+ summary="Delete an item",
133
+ tags=["items"],
134
+ )
135
+ async def delete_item(
136
+ item_id: int = Path(..., description="The ID of the item to delete"),
137
+ x_token: str = TokenDep,
138
+ ):
139
+ """Delete an item (requires authentication)."""
140
+ return {"item_id": item_id, "deleted": True}
141
+
142
+ @app.patch(
143
+ "/items/{item_id}/tags",
144
+ operation_id="update_item_tags",
145
+ summary="Update item tags",
146
+ tags=["items", "tags"],
147
+ )
148
+ async def update_item_tags(
149
+ item_id: int = Path(..., description="The ID of the item"),
150
+ tags: List[str] = Body(..., description="Updated tags"),
151
+ ):
152
+ """Update just the tags of an item."""
153
+ return {"item_id": item_id, "tags": tags}
154
+
155
+ @app.get(
156
+ "/items/{item_id}/tags/{tag_id}",
157
+ operation_id="get_item_tag",
158
+ summary="Get a specific tag for an item",
159
+ tags=["items", "tags"],
160
+ )
161
+ async def get_item_tag(
162
+ item_id: int = Path(..., description="The ID of the item"),
163
+ tag_id: str = Path(..., description="The ID of the tag"),
164
+ ):
165
+ """Get a specific tag for an item."""
166
+ return {"item_id": item_id, "tag_id": tag_id}
167
+
168
+ @app.post(
169
+ "/upload/",
170
+ operation_id="upload_file",
171
+ summary="Upload a file",
172
+ tags=["files"],
173
+ )
174
+ async def upload_file(
175
+ file_name: str = Query(..., description="Name of the file"),
176
+ content_type: str = Query(..., description="Content type of the file"),
177
+ ):
178
+ """Upload a file (dummy endpoint for testing query params)."""
179
+ return {
180
+ "file_name": file_name,
181
+ "content_type": content_type,
182
+ "status": "uploaded",
183
+ }
184
+
185
+ # Add a callback route for testing complex documentation
186
+ @app.post(
187
+ "/webhook",
188
+ operation_id="register_webhook",
189
+ summary="Register a webhook",
190
+ tags=["webhooks"],
191
+ callbacks={ # type: ignore
192
+ "itemProcessed": {
193
+ "{$request.body.callbackUrl}": {
194
+ "post": {
195
+ "summary": "Callback for when an item is processed",
196
+ "requestBody": {
197
+ "required": True,
198
+ "content": {
199
+ "application/json": {
200
+ "schema": {
201
+ "type": "object",
202
+ "properties": {
203
+ "item_id": {"type": "integer"},
204
+ "status": {"type": "string"},
205
+ "timestamp": {
206
+ "type": "string",
207
+ "format": "date-time",
208
+ },
209
+ },
210
+ }
211
+ }
212
+ },
213
+ },
214
+ "responses": {
215
+ "200": {"description": "Webhook processed successfully"}
216
+ },
217
+ }
218
+ }
219
+ }
220
+ },
221
+ )
222
+ async def register_webhook(
223
+ callback_url: str = Body(
224
+ ..., embed=True, description="URL to call when processing completes"
225
+ ),
226
+ ):
227
+ """Register a webhook for processing notifications."""
228
+ return {"registered": True, "callback_url": callback_url}
229
+
230
+ return app
231
+
232
+
233
+ @pytest.fixture
234
+ def fastapi_openapi_schema(fastapi_server) -> Dict[str, Any]:
235
+ """Fixture that returns the OpenAPI schema from a live FastAPI server."""
236
+ return fastapi_server.openapi()
237
+
238
+
239
+ @pytest.fixture
240
+ def parsed_routes(fastapi_openapi_schema):
241
+ """Return parsed routes from a FastAPI OpenAPI schema."""
242
+ return parse_openapi_to_http_routes(fastapi_openapi_schema)
243
+
244
+
245
+ @pytest.fixture
246
+ def route_map(parsed_routes):
247
+ """Return a dictionary of routes by operation ID."""
248
+ return {r.operation_id: r for r in parsed_routes if r.operation_id is not None}
249
+
250
+
251
+ def test_parse_fastapi_schema_route_count(parsed_routes):
252
+ """Test that all routes are parsed from the FastAPI schema."""
253
+ assert len(parsed_routes) == 9 # 8 endpoints + 1 callback
254
+
255
+
256
+ def test_parse_fastapi_schema_operation_ids(route_map):
257
+ """Test that all expected operation IDs are present in the parsed schema."""
258
+ expected_operations = [
259
+ "list_items",
260
+ "create_item",
261
+ "get_item",
262
+ "update_item",
263
+ "delete_item",
264
+ "update_item_tags",
265
+ "get_item_tag",
266
+ "upload_file",
267
+ "register_webhook",
268
+ ]
269
+
270
+ for op_id in expected_operations:
271
+ assert op_id in route_map, f"Operation ID '{op_id}' not found in parsed routes"
272
+
273
+
274
+ def test_path_parameter_parsing(route_map):
275
+ """Test that path parameters are correctly parsed."""
276
+ get_item = route_map["get_item"]
277
+ path_params = [p for p in get_item.parameters if p.location == "path"]
278
+
279
+ assert len(path_params) == 1
280
+ assert path_params[0].name == "item_id"
281
+ assert path_params[0].required is True
282
+
283
+
284
+ def test_query_parameter_parsing(route_map):
285
+ """Test that query parameters are correctly parsed."""
286
+ list_items = route_map["list_items"]
287
+ query_params = [p for p in list_items.parameters if p.location == "query"]
288
+
289
+ assert len(query_params) == 3 # skip, limit, status
290
+ param_names = [p.name for p in query_params]
291
+ assert "skip" in param_names
292
+ assert "limit" in param_names
293
+ assert "status" in param_names
294
+
295
+
296
+ def test_header_parameter_parsing(route_map):
297
+ """Test that header parameters from dependencies are correctly parsed."""
298
+ create_item = route_map["create_item"]
299
+ header_params = [p for p in create_item.parameters if p.location == "header"]
300
+
301
+ assert len(header_params) == 1
302
+ assert header_params[0].name == "x-token"
303
+ assert header_params[0].required is True
304
+
305
+
306
+ def test_request_body_content_type(route_map):
307
+ """Test that request body content types are correctly parsed."""
308
+ create_item = route_map["create_item"]
309
+
310
+ assert create_item.request_body is not None
311
+ assert "application/json" in create_item.request_body.content_schema
312
+
313
+
314
+ def test_request_body_properties(route_map):
315
+ """Test that request body properties are correctly parsed."""
316
+ create_item = route_map["create_item"]
317
+ json_schema = create_item.request_body.content_schema["application/json"]
318
+ properties = json_schema.get("properties", {})
319
+
320
+ assert "name" in properties
321
+ assert "price" in properties
322
+ assert "description" in properties
323
+ assert "tags" in properties
324
+ assert "status" in properties
325
+
326
+
327
+ def test_request_body_status_schema(route_map):
328
+ """Test that the status schema in request body is correctly handled."""
329
+ create_item = route_map["create_item"]
330
+ json_schema = create_item.request_body.content_schema["application/json"]
331
+ properties = json_schema.get("properties", {})
332
+ status_schema = properties.get("status", {})
333
+
334
+ # FastAPI may represent enums as references or directly include enum values
335
+ assert "$ref" in status_schema or "enum" in status_schema
336
+
337
+
338
+ def test_route_with_items_tag(parsed_routes):
339
+ """Test that routes with 'items' tag are correctly parsed."""
340
+ item_routes = [r for r in parsed_routes if "items" in r.tags]
341
+
342
+ assert len(item_routes) >= 6 # At least 6 endpoints with "items" tag
343
+
344
+
345
+ def test_routes_with_multiple_tags(parsed_routes):
346
+ """Test that routes with multiple tags are correctly parsed."""
347
+ multi_tag_routes = [r for r in parsed_routes if len(r.tags) > 1]
348
+
349
+ assert len(multi_tag_routes) >= 2 # At least 2 endpoints with multiple tags
350
+
351
+
352
+ def test_specific_route_tags(route_map):
353
+ """Test that specific routes have the expected tags."""
354
+ assert "items" in route_map["list_items"].tags
355
+ assert "items" in route_map["update_item_tags"].tags
356
+ assert "tags" in route_map["update_item_tags"].tags
357
+ assert "webhooks" in route_map["register_webhook"].tags
358
+
359
+
360
+ def test_operation_summary(route_map):
361
+ """Test that operation summary is correctly parsed."""
362
+ list_items = route_map["list_items"]
363
+
364
+ assert list_items.summary == "List all items"
365
+
366
+
367
+ def test_operation_description(route_map):
368
+ """Test that operation description is correctly parsed."""
369
+ list_items = route_map["list_items"]
370
+
371
+ assert list_items.description is not None
372
+ assert "optional filtering" in list_items.description
373
+
374
+
375
+ def test_path_with_route_parameters(route_map):
376
+ """Test that paths with route parameters are correctly parsed."""
377
+ get_item = route_map["get_item"]
378
+
379
+ assert get_item.path == "/items/{item_id}"
380
+
381
+
382
+ def test_complex_nested_paths(route_map):
383
+ """Test that complex nested paths are correctly parsed."""
384
+ get_item_tag = route_map["get_item_tag"]
385
+
386
+ assert get_item_tag.path == "/items/{item_id}/tags/{tag_id}"
387
+
388
+
389
+ def test_http_methods(route_map):
390
+ """Test that HTTP methods are correctly parsed."""
391
+ assert route_map["list_items"].method == "GET"
392
+ assert route_map["create_item"].method == "POST"
393
+ assert route_map["update_item"].method == "PUT"
394
+ assert route_map["delete_item"].method == "DELETE"
395
+ assert route_map["update_item_tags"].method == "PATCH"
396
+
397
+
398
+ def test_item_schema_properties(route_map):
399
+ """Test that Item schema properties are correctly resolved."""
400
+ create_item = route_map["create_item"]
401
+ json_schema = create_item.request_body.content_schema["application/json"]
402
+ properties = json_schema.get("properties", {})
403
+
404
+ assert "name" in properties
405
+ assert properties["name"]["type"] == "string"
406
+ assert "price" in properties
407
+ assert properties["price"]["type"] == "number"
408
+
409
+
410
+ def test_webhook_endpoint(route_map):
411
+ """Test parsing of webhook registration endpoint."""
412
+ webhook = route_map["register_webhook"]
413
+
414
+ assert webhook.method == "POST"
415
+ assert webhook.path == "/webhook"
416
+
417
+
418
+ def test_webhook_request_body(route_map):
419
+ """Test that webhook request body is correctly parsed."""
420
+ webhook = route_map["register_webhook"]
421
+
422
+ assert webhook.request_body is not None
423
+ assert "application/json" in webhook.request_body.content_schema
424
+ json_schema = webhook.request_body.content_schema["application/json"]
425
+ assert "callback_url" in json_schema.get("properties", {})
426
+
427
+
428
+ def test_token_dependency_handling(route_map):
429
+ """Test that token dependencies are correctly handled in parsed endpoints."""
430
+ token_endpoints = ["create_item", "update_item", "delete_item"]
431
+
432
+ for op_id in token_endpoints:
433
+ route = route_map[op_id]
434
+ header_params = [p for p in route.parameters if p.location == "header"]
435
+ token_headers = [p for p in header_params if p.name == "x-token"]
436
+ assert len(token_headers) == 1, f"Expected x-token header in {op_id}"
437
+ assert token_headers[0].required is True