Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
72648c2
1
Parent(s): 1cb6ed0
Add openapi parsing utilities
Browse files- src/fastmcp/utilities/openapi.py +468 -0
- tests/utilities/__init__.py +1 -0
- tests/utilities/openapi/__init__.py +1 -0
- tests/utilities/openapi/conftest.py +1 -0
- tests/utilities/openapi/test_openapi.py +709 -0
- tests/utilities/openapi/test_openapi_advanced.py +594 -0
- tests/utilities/openapi/test_openapi_fastapi.py +437 -0
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
|