Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
74f10a8
1
Parent(s): b4e78d2
Unify openapi 3.0 and 3.1 parsing
Browse files
src/fastmcp/server/openapi.py
CHANGED
|
@@ -173,7 +173,6 @@ class OpenAPITool(Tool):
|
|
| 173 |
for param_name, param_value in path_params.items():
|
| 174 |
# Handle array path parameters with style 'simple' (comma-separated)
|
| 175 |
# In OpenAPI, 'simple' is the default style for path parameters
|
| 176 |
-
# and explode=False behavior for arrays
|
| 177 |
param_info = next(
|
| 178 |
(p for p in self._route.parameters if p.name == param_name), None
|
| 179 |
)
|
|
@@ -186,9 +185,49 @@ class OpenAPITool(Tool):
|
|
| 186 |
if is_array:
|
| 187 |
# Format array values as comma-separated string
|
| 188 |
# This follows the OpenAPI 'simple' style (default for path)
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
path = path.replace(f"{{{param_name}}}", str(param_value))
|
| 193 |
|
| 194 |
# Prepare query parameters - filter out None and empty strings
|
|
|
|
| 173 |
for param_name, param_value in path_params.items():
|
| 174 |
# Handle array path parameters with style 'simple' (comma-separated)
|
| 175 |
# In OpenAPI, 'simple' is the default style for path parameters
|
|
|
|
| 176 |
param_info = next(
|
| 177 |
(p for p in self._route.parameters if p.name == param_name), None
|
| 178 |
)
|
|
|
|
| 185 |
if is_array:
|
| 186 |
# Format array values as comma-separated string
|
| 187 |
# This follows the OpenAPI 'simple' style (default for path)
|
| 188 |
+
if all(
|
| 189 |
+
isinstance(item, str | int | float | bool)
|
| 190 |
+
for item in param_value
|
| 191 |
+
):
|
| 192 |
+
# Handle simple array types
|
| 193 |
+
path = path.replace(
|
| 194 |
+
f"{{{param_name}}}", ",".join(str(v) for v in param_value)
|
| 195 |
+
)
|
| 196 |
+
else:
|
| 197 |
+
# Handle complex array types (containing objects/dicts)
|
| 198 |
+
try:
|
| 199 |
+
# Try to create a simple representation without Python syntax artifacts
|
| 200 |
+
formatted_parts = []
|
| 201 |
+
for item in param_value:
|
| 202 |
+
if isinstance(item, dict):
|
| 203 |
+
# For objects, serialize key-value pairs
|
| 204 |
+
item_parts = []
|
| 205 |
+
for k, v in item.items():
|
| 206 |
+
item_parts.append(f"{k}:{v}")
|
| 207 |
+
formatted_parts.append(".".join(item_parts))
|
| 208 |
+
else:
|
| 209 |
+
# Fallback for other complex types
|
| 210 |
+
formatted_parts.append(str(item))
|
| 211 |
+
|
| 212 |
+
# Join parts with commas
|
| 213 |
+
formatted_value = ",".join(formatted_parts)
|
| 214 |
+
path = path.replace(f"{{{param_name}}}", formatted_value)
|
| 215 |
+
except Exception as e:
|
| 216 |
+
logger.warning(
|
| 217 |
+
f"Failed to format complex array path parameter '{param_name}': {e}"
|
| 218 |
+
)
|
| 219 |
+
# Fallback to string representation, but remove Python syntax artifacts
|
| 220 |
+
str_value = (
|
| 221 |
+
str(param_value)
|
| 222 |
+
.replace("[", "")
|
| 223 |
+
.replace("]", "")
|
| 224 |
+
.replace("'", "")
|
| 225 |
+
.replace('"', "")
|
| 226 |
+
)
|
| 227 |
+
path = path.replace(f"{{{param_name}}}", str_value)
|
| 228 |
+
continue
|
| 229 |
+
|
| 230 |
+
# Default handling for non-array parameters or non-array schemas
|
| 231 |
path = path.replace(f"{{{param_name}}}", str(param_value))
|
| 232 |
|
| 233 |
# Prepare query parameters - filter out None and empty strings
|
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -1,10 +1,8 @@
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
-
from typing import Any, Literal,
|
| 4 |
|
| 5 |
-
# Using the recommended library: openapi-pydantic
|
| 6 |
from openapi_pydantic import (
|
| 7 |
-
MediaType,
|
| 8 |
OpenAPI,
|
| 9 |
Operation,
|
| 10 |
Parameter,
|
|
@@ -26,7 +24,7 @@ from openapi_pydantic.v3.v3_0 import Response as Response_30
|
|
| 26 |
from openapi_pydantic.v3.v3_0 import Schema as Schema_30
|
| 27 |
from pydantic import BaseModel, Field, ValidationError
|
| 28 |
|
| 29 |
-
from fastmcp.utilities import
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
|
|
@@ -49,8 +47,6 @@ class ParameterInfo(BaseModel):
|
|
| 49 |
schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
|
| 50 |
description: str | None = None
|
| 51 |
|
| 52 |
-
# No model_config needed here if we populate manually after accessing 'in'
|
| 53 |
-
|
| 54 |
|
| 55 |
class RequestBodyInfo(BaseModel):
|
| 56 |
"""Represents the request body for an HTTP operation in our IR."""
|
|
@@ -101,60 +97,17 @@ __all__ = [
|
|
| 101 |
"parse_openapi_to_http_routes",
|
| 102 |
]
|
| 103 |
|
| 104 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
-
def _resolve_ref(
|
| 108 |
-
item: Reference | Schema | Parameter | RequestBody | Any, openapi: OpenAPI
|
| 109 |
-
) -> Any:
|
| 110 |
-
"""Resolves a potential Reference object to its target definition (no changes needed here)."""
|
| 111 |
-
if isinstance(item, Reference):
|
| 112 |
-
ref_str = item.ref
|
| 113 |
-
try:
|
| 114 |
-
if not ref_str.startswith("#/"):
|
| 115 |
-
raise ValueError(
|
| 116 |
-
f"External or non-local reference not supported: {ref_str}"
|
| 117 |
-
)
|
| 118 |
-
parts = ref_str.strip("#/").split("/")
|
| 119 |
-
target = openapi
|
| 120 |
-
for part in parts:
|
| 121 |
-
if part.isdigit() and isinstance(target, list):
|
| 122 |
-
target = target[int(part)]
|
| 123 |
-
elif isinstance(target, BaseModel):
|
| 124 |
-
# Use model_extra for fields not explicitly defined (like components types)
|
| 125 |
-
# Check class fields first, then model_extra
|
| 126 |
-
if part in target.__class__.model_fields:
|
| 127 |
-
target = getattr(target, part, None)
|
| 128 |
-
elif target.model_extra and part in target.model_extra:
|
| 129 |
-
target = target.model_extra[part]
|
| 130 |
-
else:
|
| 131 |
-
# Special handling for components sub-types common structure
|
| 132 |
-
if part == "components" and hasattr(target, "components"):
|
| 133 |
-
target = getattr(target, "components")
|
| 134 |
-
elif hasattr(target, part): # Fallback check
|
| 135 |
-
target = getattr(target, part, None)
|
| 136 |
-
else:
|
| 137 |
-
target = None # Part not found
|
| 138 |
-
elif isinstance(target, dict):
|
| 139 |
-
target = target.get(part)
|
| 140 |
-
else:
|
| 141 |
-
raise ValueError(
|
| 142 |
-
f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
|
| 143 |
-
)
|
| 144 |
-
if target is None:
|
| 145 |
-
raise ValueError(
|
| 146 |
-
f"Reference part '{part}' not found in path '{ref_str}'"
|
| 147 |
-
)
|
| 148 |
-
if isinstance(target, Reference):
|
| 149 |
-
return _resolve_ref(target, openapi)
|
| 150 |
-
return target
|
| 151 |
-
except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
|
| 152 |
-
raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
|
| 153 |
-
return item
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
# --- Main Parsing Function ---
|
| 157 |
-
# (No changes needed in the main loop logic, only in the helpers it calls)
|
| 158 |
def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
|
| 159 |
"""
|
| 160 |
Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
|
|
@@ -172,7 +125,16 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 172 |
logger.info(
|
| 173 |
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
|
| 174 |
)
|
| 175 |
-
parser =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
return parser.parse()
|
| 177 |
else:
|
| 178 |
# Default to OpenAPI 3.1 models
|
|
@@ -180,7 +142,16 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 180 |
logger.info(
|
| 181 |
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
|
| 182 |
)
|
| 183 |
-
parser =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
return parser.parse()
|
| 185 |
except ValidationError as e:
|
| 186 |
logger.error(f"OpenAPI schema validation failed: {e}")
|
|
@@ -189,151 +160,72 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 189 |
raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
|
| 190 |
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
return "cookie"
|
| 206 |
-
else:
|
| 207 |
-
logger.warning(
|
| 208 |
-
f"Unknown parameter location: {param_in}, defaulting to 'query'"
|
| 209 |
-
)
|
| 210 |
-
return "query"
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
class OpenAPI31Parser(BaseOpenAPIParser):
|
| 214 |
-
"""Parser for OpenAPI 3.1 schemas."""
|
| 215 |
|
| 216 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
self.openapi = openapi
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
-
def
|
| 220 |
-
"""
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
if hasattr(self.openapi, "components") and self.openapi.components:
|
| 230 |
-
components = self.openapi.components
|
| 231 |
-
if hasattr(components, "schemas") and components.schemas:
|
| 232 |
-
for name, schema in components.schemas.items():
|
| 233 |
-
try:
|
| 234 |
-
if isinstance(schema, Reference):
|
| 235 |
-
resolved_schema = self._resolve_ref(schema)
|
| 236 |
-
schema_definitions[name] = self._extract_schema_as_dict(
|
| 237 |
-
resolved_schema
|
| 238 |
-
)
|
| 239 |
-
else:
|
| 240 |
-
schema_definitions[name] = self._extract_schema_as_dict(
|
| 241 |
-
schema
|
| 242 |
-
)
|
| 243 |
-
except Exception as e:
|
| 244 |
-
logger.warning(
|
| 245 |
-
f"Failed to extract schema definition '{name}': {e}"
|
| 246 |
-
)
|
| 247 |
-
|
| 248 |
-
for path_str, path_item_obj in self.openapi.paths.items():
|
| 249 |
-
if not isinstance(path_item_obj, PathItem):
|
| 250 |
-
logger.warning(
|
| 251 |
-
f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})"
|
| 252 |
-
)
|
| 253 |
-
continue
|
| 254 |
-
|
| 255 |
-
path_level_params = path_item_obj.parameters
|
| 256 |
-
|
| 257 |
-
# Iterate through possible HTTP methods defined in the PathItem model fields
|
| 258 |
-
# Use model_fields from the class, not the instance
|
| 259 |
-
for method_lower in PathItem.model_fields.keys():
|
| 260 |
-
if method_lower not in [
|
| 261 |
-
"get",
|
| 262 |
-
"put",
|
| 263 |
-
"post",
|
| 264 |
-
"delete",
|
| 265 |
-
"options",
|
| 266 |
-
"head",
|
| 267 |
-
"patch",
|
| 268 |
-
"trace",
|
| 269 |
-
]:
|
| 270 |
-
continue
|
| 271 |
-
|
| 272 |
-
operation: Operation | None = getattr(path_item_obj, method_lower, None)
|
| 273 |
-
|
| 274 |
-
if operation and isinstance(operation, Operation):
|
| 275 |
-
method_upper = cast(HttpMethod, method_lower.upper())
|
| 276 |
-
logger.debug(f"Processing operation: {method_upper} {path_str}")
|
| 277 |
-
try:
|
| 278 |
-
parameters = self._extract_parameters(
|
| 279 |
-
operation.parameters, path_level_params
|
| 280 |
-
)
|
| 281 |
-
request_body_info = self._extract_request_body(
|
| 282 |
-
operation.requestBody
|
| 283 |
-
)
|
| 284 |
-
responses = self._extract_responses(operation.responses)
|
| 285 |
-
|
| 286 |
-
route = HTTPRoute(
|
| 287 |
-
path=path_str,
|
| 288 |
-
method=method_upper,
|
| 289 |
-
operation_id=operation.operationId,
|
| 290 |
-
summary=operation.summary,
|
| 291 |
-
description=operation.description,
|
| 292 |
-
tags=operation.tags or [],
|
| 293 |
-
parameters=parameters,
|
| 294 |
-
request_body=request_body_info,
|
| 295 |
-
responses=responses,
|
| 296 |
-
schema_definitions=schema_definitions,
|
| 297 |
-
)
|
| 298 |
-
routes.append(route)
|
| 299 |
-
logger.info(
|
| 300 |
-
f"Successfully extracted route: {method_upper} {path_str}"
|
| 301 |
-
)
|
| 302 |
-
except Exception as op_error:
|
| 303 |
-
op_id = operation.operationId or "unknown"
|
| 304 |
-
logger.error(
|
| 305 |
-
f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
|
| 306 |
-
exc_info=True,
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
|
| 310 |
-
return routes
|
| 311 |
-
|
| 312 |
-
def _resolve_ref(
|
| 313 |
-
self, item: Reference | Schema | Parameter | RequestBody | Any
|
| 314 |
-
) -> Any:
|
| 315 |
-
"""Resolves a potential Reference object to its target definition."""
|
| 316 |
-
if isinstance(item, Reference):
|
| 317 |
ref_str = item.ref
|
| 318 |
try:
|
| 319 |
if not ref_str.startswith("#/"):
|
| 320 |
raise ValueError(
|
| 321 |
f"External or non-local reference not supported: {ref_str}"
|
| 322 |
)
|
|
|
|
| 323 |
parts = ref_str.strip("#/").split("/")
|
| 324 |
target = self.openapi
|
|
|
|
| 325 |
for part in parts:
|
| 326 |
if part.isdigit() and isinstance(target, list):
|
| 327 |
target = target[int(part)]
|
| 328 |
elif isinstance(target, BaseModel):
|
| 329 |
-
# Use model_extra for fields not explicitly defined (like components types)
|
| 330 |
# Check class fields first, then model_extra
|
| 331 |
if part in target.__class__.model_fields:
|
| 332 |
target = getattr(target, part, None)
|
| 333 |
elif target.model_extra and part in target.model_extra:
|
| 334 |
target = target.model_extra[part]
|
| 335 |
else:
|
| 336 |
-
# Special handling for components
|
| 337 |
if part == "components" and hasattr(target, "components"):
|
| 338 |
target = getattr(target, "components")
|
| 339 |
elif hasattr(target, part): # Fallback check
|
|
@@ -344,123 +236,123 @@ class OpenAPI31Parser(BaseOpenAPIParser):
|
|
| 344 |
target = target.get(part)
|
| 345 |
else:
|
| 346 |
raise ValueError(
|
| 347 |
-
f"Cannot traverse part '{part}' in reference '{ref_str}'
|
| 348 |
)
|
|
|
|
| 349 |
if target is None:
|
| 350 |
raise ValueError(
|
| 351 |
f"Reference part '{part}' not found in path '{ref_str}'"
|
| 352 |
)
|
| 353 |
-
|
|
|
|
|
|
|
| 354 |
return self._resolve_ref(target)
|
|
|
|
| 355 |
return target
|
| 356 |
except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
|
| 357 |
raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
|
|
|
|
| 358 |
return item
|
| 359 |
|
| 360 |
-
def _extract_schema_as_dict(self, schema_obj:
|
| 361 |
-
"""Resolves a schema
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
|
|
|
| 378 |
return {}
|
| 379 |
|
| 380 |
def _extract_parameters(
|
| 381 |
self,
|
| 382 |
-
operation_params: list[
|
| 383 |
-
path_item_params: list[
|
| 384 |
) -> list[ParameterInfo]:
|
| 385 |
-
"""
|
| 386 |
extracted_params: list[ParameterInfo] = []
|
| 387 |
seen_params: dict[
|
| 388 |
tuple[str, str], bool
|
| 389 |
-
] = {} # Use
|
| 390 |
-
|
| 391 |
|
| 392 |
-
for param_or_ref in
|
| 393 |
try:
|
| 394 |
-
parameter =
|
| 395 |
-
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
| 397 |
continue
|
| 398 |
|
| 399 |
-
# -
|
| 400 |
-
param_in = parameter.param_in #
|
| 401 |
param_location = self._convert_to_parameter_location(param_in)
|
| 402 |
-
param_schema_obj =
|
| 403 |
-
parameter.param_schema
|
| 404 |
-
) # CORRECTED: Use 'param_schema'
|
| 405 |
-
# --- *** ---
|
| 406 |
|
|
|
|
| 407 |
param_key = (parameter.name, param_in)
|
| 408 |
if param_key in seen_params:
|
| 409 |
continue
|
| 410 |
seen_params[param_key] = True
|
| 411 |
|
|
|
|
| 412 |
param_schema_dict = {}
|
| 413 |
-
if param_schema_obj:
|
| 414 |
-
#
|
| 415 |
-
resolved_schema = self._resolve_ref(param_schema_obj)
|
| 416 |
param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
|
| 417 |
|
| 418 |
-
#
|
|
|
|
| 419 |
if (
|
| 420 |
-
not isinstance(resolved_schema,
|
| 421 |
and hasattr(resolved_schema, "default")
|
| 422 |
and resolved_schema.default is not None
|
| 423 |
):
|
| 424 |
param_schema_dict["default"] = resolved_schema.default
|
| 425 |
-
|
| 426 |
-
|
|
|
|
| 427 |
first_media_type = next(iter(parameter.content.values()), None)
|
| 428 |
if (
|
| 429 |
-
first_media_type
|
| 430 |
-
|
| 431 |
-
|
|
|
|
| 432 |
media_schema = first_media_type.media_type_schema
|
| 433 |
-
resolved_media_schema = self._resolve_ref(media_schema)
|
| 434 |
param_schema_dict = self._extract_schema_as_dict(media_schema)
|
| 435 |
|
| 436 |
-
#
|
|
|
|
| 437 |
if (
|
| 438 |
-
not isinstance(resolved_media_schema,
|
| 439 |
and hasattr(resolved_media_schema, "default")
|
| 440 |
and resolved_media_schema.default is not None
|
| 441 |
):
|
| 442 |
param_schema_dict["default"] = resolved_media_schema.default
|
| 443 |
|
| 444 |
-
|
| 445 |
-
f"Parameter '{parameter.name}' using schema from 'content' field."
|
| 446 |
-
)
|
| 447 |
-
|
| 448 |
-
# Manually create ParameterInfo instance using correct field names
|
| 449 |
param_info = ParameterInfo(
|
| 450 |
name=parameter.name,
|
| 451 |
-
location=param_location,
|
| 452 |
required=parameter.required,
|
| 453 |
-
schema=param_schema_dict,
|
| 454 |
description=parameter.description,
|
| 455 |
)
|
| 456 |
extracted_params.append(param_info)
|
| 457 |
-
|
| 458 |
-
except (
|
| 459 |
-
ValidationError,
|
| 460 |
-
ValueError,
|
| 461 |
-
AttributeError,
|
| 462 |
-
TypeError,
|
| 463 |
-
) as e: # Added TypeError
|
| 464 |
param_name = getattr(
|
| 465 |
param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
|
| 466 |
)
|
|
@@ -470,52 +362,48 @@ class OpenAPI31Parser(BaseOpenAPIParser):
|
|
| 470 |
|
| 471 |
return extracted_params
|
| 472 |
|
| 473 |
-
def _extract_request_body(
|
| 474 |
-
|
| 475 |
-
) -> RequestBodyInfo | None:
|
| 476 |
-
"""Extracts and resolves the request body using corrected attribute names."""
|
| 477 |
if not request_body_or_ref:
|
| 478 |
return None
|
|
|
|
| 479 |
try:
|
| 480 |
-
request_body =
|
| 481 |
-
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
| 483 |
return None
|
| 484 |
|
| 485 |
-
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
for media_type_str, media_type_obj in request_body.content.items():
|
| 488 |
-
# --- *** CORRECTED ATTRIBUTE ACCESS HERE *** ---
|
| 489 |
if (
|
| 490 |
-
|
|
|
|
| 491 |
and media_type_obj.media_type_schema
|
| 492 |
-
):
|
| 493 |
-
# --- *** ---
|
| 494 |
try:
|
| 495 |
-
# Use the corrected attribute here as well
|
| 496 |
schema_dict = self._extract_schema_as_dict(
|
| 497 |
media_type_obj.media_type_schema
|
| 498 |
)
|
| 499 |
-
|
| 500 |
-
|
|
|
|
|
|
|
| 501 |
logger.error(
|
| 502 |
-
f"Failed to extract schema for media type '{media_type_str}'
|
| 503 |
)
|
| 504 |
-
elif not isinstance(media_type_obj, MediaType):
|
| 505 |
-
logger.warning(
|
| 506 |
-
f"Skipping invalid media type object for '{media_type_str}' (type: {type(media_type_obj)}) in request body."
|
| 507 |
-
)
|
| 508 |
-
elif not media_type_obj.media_type_schema: # Corrected check
|
| 509 |
-
logger.warning(
|
| 510 |
-
f"Skipping media type '{media_type_str}' in request body because it lacks a schema."
|
| 511 |
-
)
|
| 512 |
|
| 513 |
-
return
|
| 514 |
-
|
| 515 |
-
content_schema=content_schemas,
|
| 516 |
-
description=request_body.description,
|
| 517 |
-
)
|
| 518 |
-
except (ValidationError, ValueError, AttributeError) as e:
|
| 519 |
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
| 520 |
logger.error(
|
| 521 |
f"Failed to extract request body '{ref_name}': {e}", exc_info=False
|
|
@@ -523,47 +411,49 @@ class OpenAPI31Parser(BaseOpenAPIParser):
|
|
| 523 |
return None
|
| 524 |
|
| 525 |
def _extract_responses(
|
| 526 |
-
self,
|
| 527 |
-
operation_responses: dict[str, Response | Reference] | None,
|
| 528 |
) -> dict[str, ResponseInfo]:
|
| 529 |
-
"""
|
| 530 |
extracted_responses: dict[str, ResponseInfo] = {}
|
|
|
|
| 531 |
if not operation_responses:
|
| 532 |
return extracted_responses
|
| 533 |
|
| 534 |
for status_code, resp_or_ref in operation_responses.items():
|
| 535 |
try:
|
| 536 |
-
response =
|
| 537 |
-
|
| 538 |
-
|
| 539 |
logger.warning(
|
| 540 |
-
f"Expected Response after resolving
|
|
|
|
| 541 |
)
|
| 542 |
continue
|
| 543 |
|
| 544 |
-
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
| 546 |
for media_type_str, media_type_obj in response.content.items():
|
| 547 |
if (
|
| 548 |
-
|
|
|
|
| 549 |
and media_type_obj.media_type_schema
|
| 550 |
):
|
| 551 |
try:
|
| 552 |
schema_dict = self._extract_schema_as_dict(
|
| 553 |
media_type_obj.media_type_schema
|
| 554 |
)
|
| 555 |
-
|
| 556 |
-
except
|
| 557 |
logger.error(
|
| 558 |
-
f"Failed to extract schema for media type '{media_type_str}'
|
|
|
|
| 559 |
)
|
| 560 |
|
| 561 |
-
resp_info = ResponseInfo(
|
| 562 |
-
description=response.description, content_schema=content_schemas
|
| 563 |
-
)
|
| 564 |
extracted_responses[str(status_code)] = resp_info
|
| 565 |
-
|
| 566 |
-
except (ValidationError, ValueError, AttributeError) as e:
|
| 567 |
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
| 568 |
logger.error(
|
| 569 |
f"Failed to extract response for status code {status_code} "
|
|
@@ -573,29 +463,22 @@ class OpenAPI31Parser(BaseOpenAPIParser):
|
|
| 573 |
|
| 574 |
return extracted_responses
|
| 575 |
|
| 576 |
-
|
| 577 |
-
class OpenAPI30Parser(BaseOpenAPIParser):
|
| 578 |
-
"""Parser for OpenAPI 3.0 schemas."""
|
| 579 |
-
|
| 580 |
-
def __init__(self, openapi: OpenAPI_30):
|
| 581 |
-
self.openapi = openapi
|
| 582 |
-
|
| 583 |
def parse(self) -> list[HTTPRoute]:
|
| 584 |
-
"""Parse
|
| 585 |
routes: list[HTTPRoute] = []
|
| 586 |
|
| 587 |
-
if not self.openapi.paths:
|
| 588 |
logger.warning("OpenAPI schema has no paths defined.")
|
| 589 |
return []
|
| 590 |
|
| 591 |
-
# Extract component schemas
|
| 592 |
schema_definitions = {}
|
| 593 |
if hasattr(self.openapi, "components") and self.openapi.components:
|
| 594 |
components = self.openapi.components
|
| 595 |
if hasattr(components, "schemas") and components.schemas:
|
| 596 |
for name, schema in components.schemas.items():
|
| 597 |
try:
|
| 598 |
-
if isinstance(schema,
|
| 599 |
resolved_schema = self._resolve_ref(schema)
|
| 600 |
schema_definitions[name] = self._extract_schema_as_dict(
|
| 601 |
resolved_schema
|
|
@@ -609,53 +492,58 @@ class OpenAPI30Parser(BaseOpenAPIParser):
|
|
| 609 |
f"Failed to extract schema definition '{name}': {e}"
|
| 610 |
)
|
| 611 |
|
|
|
|
| 612 |
for path_str, path_item_obj in self.openapi.paths.items():
|
| 613 |
-
if not isinstance(path_item_obj,
|
| 614 |
logger.warning(
|
| 615 |
-
f"Skipping invalid path item
|
| 616 |
)
|
| 617 |
continue
|
| 618 |
|
| 619 |
-
path_level_params =
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
if method_lower not in [
|
| 625 |
-
"get",
|
| 626 |
-
"put",
|
| 627 |
-
"post",
|
| 628 |
-
"delete",
|
| 629 |
-
"options",
|
| 630 |
-
"head",
|
| 631 |
-
"patch",
|
| 632 |
-
"trace",
|
| 633 |
-
]:
|
| 634 |
-
continue
|
| 635 |
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
|
| 640 |
-
if operation and isinstance(operation, Operation_30):
|
| 641 |
-
method_upper = cast(HttpMethod, method_lower.upper())
|
| 642 |
-
logger.debug(f"Processing operation: {method_upper} {path_str}")
|
| 643 |
try:
|
| 644 |
parameters = self._extract_parameters(
|
| 645 |
-
operation
|
| 646 |
)
|
|
|
|
| 647 |
request_body_info = self._extract_request_body(
|
| 648 |
-
operation
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
)
|
| 650 |
-
responses = self._extract_responses(operation.responses)
|
| 651 |
|
| 652 |
route = HTTPRoute(
|
| 653 |
path=path_str,
|
| 654 |
-
method=method_upper,
|
| 655 |
-
operation_id=operation
|
| 656 |
-
summary=operation
|
| 657 |
-
description=operation
|
| 658 |
-
tags=operation
|
| 659 |
parameters=parameters,
|
| 660 |
request_body=request_body_info,
|
| 661 |
responses=responses,
|
|
@@ -666,7 +554,7 @@ class OpenAPI30Parser(BaseOpenAPIParser):
|
|
| 666 |
f"Successfully extracted route: {method_upper} {path_str}"
|
| 667 |
)
|
| 668 |
except Exception as op_error:
|
| 669 |
-
op_id = operation
|
| 670 |
logger.error(
|
| 671 |
f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
|
| 672 |
exc_info=True,
|
|
@@ -675,257 +563,6 @@ class OpenAPI30Parser(BaseOpenAPIParser):
|
|
| 675 |
logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
|
| 676 |
return routes
|
| 677 |
|
| 678 |
-
def _resolve_ref(
|
| 679 |
-
self, item: Reference_30 | Schema_30 | Parameter_30 | RequestBody_30 | Any
|
| 680 |
-
) -> Any:
|
| 681 |
-
"""Resolves a potential Reference object to its target definition for OpenAPI 3.0."""
|
| 682 |
-
if isinstance(item, Reference_30):
|
| 683 |
-
ref_str = item.ref
|
| 684 |
-
try:
|
| 685 |
-
if not ref_str.startswith("#/"):
|
| 686 |
-
raise ValueError(
|
| 687 |
-
f"External or non-local reference not supported: {ref_str}"
|
| 688 |
-
)
|
| 689 |
-
parts = ref_str.strip("#/").split("/")
|
| 690 |
-
target = self.openapi
|
| 691 |
-
for part in parts:
|
| 692 |
-
if part.isdigit() and isinstance(target, list):
|
| 693 |
-
target = target[int(part)]
|
| 694 |
-
elif isinstance(target, BaseModel):
|
| 695 |
-
# Use model_extra for fields not explicitly defined (like components types)
|
| 696 |
-
# Check class fields first, then model_extra
|
| 697 |
-
if part in target.__class__.model_fields:
|
| 698 |
-
target = getattr(target, part, None)
|
| 699 |
-
elif target.model_extra and part in target.model_extra:
|
| 700 |
-
target = target.model_extra[part]
|
| 701 |
-
else:
|
| 702 |
-
# Special handling for components sub-types common structure
|
| 703 |
-
if part == "components" and hasattr(target, "components"):
|
| 704 |
-
target = getattr(target, "components")
|
| 705 |
-
elif hasattr(target, part): # Fallback check
|
| 706 |
-
target = getattr(target, part, None)
|
| 707 |
-
else:
|
| 708 |
-
target = None # Part not found
|
| 709 |
-
elif isinstance(target, dict):
|
| 710 |
-
target = target.get(part)
|
| 711 |
-
else:
|
| 712 |
-
raise ValueError(
|
| 713 |
-
f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}"
|
| 714 |
-
)
|
| 715 |
-
if target is None:
|
| 716 |
-
raise ValueError(
|
| 717 |
-
f"Reference part '{part}' not found in path '{ref_str}'"
|
| 718 |
-
)
|
| 719 |
-
if isinstance(target, Reference_30):
|
| 720 |
-
return self._resolve_ref(target)
|
| 721 |
-
return target
|
| 722 |
-
except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
|
| 723 |
-
raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
|
| 724 |
-
return item
|
| 725 |
-
|
| 726 |
-
def _extract_schema_as_dict(
|
| 727 |
-
self, schema_obj: Schema_30 | Reference_30
|
| 728 |
-
) -> JsonSchema:
|
| 729 |
-
"""Resolves a schema/reference and returns it as a dictionary for OpenAPI 3.0."""
|
| 730 |
-
resolved_schema = self._resolve_ref(schema_obj)
|
| 731 |
-
if isinstance(resolved_schema, Schema_30):
|
| 732 |
-
# Using exclude_none=True might be better than exclude_unset sometimes
|
| 733 |
-
return resolved_schema.model_dump(
|
| 734 |
-
mode="json", by_alias=True, exclude_none=True
|
| 735 |
-
)
|
| 736 |
-
elif isinstance(resolved_schema, dict):
|
| 737 |
-
logger.warning(
|
| 738 |
-
"Resolved schema reference resulted in a dict, not a Schema model."
|
| 739 |
-
)
|
| 740 |
-
return resolved_schema
|
| 741 |
-
else:
|
| 742 |
-
ref_str = getattr(schema_obj, "ref", "unknown")
|
| 743 |
-
logger.warning(
|
| 744 |
-
f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
|
| 745 |
-
)
|
| 746 |
-
return {}
|
| 747 |
-
|
| 748 |
-
def _extract_parameters(
|
| 749 |
-
self,
|
| 750 |
-
operation_params: list[Parameter_30 | Reference_30] | None,
|
| 751 |
-
path_item_params: list[Parameter_30 | Reference_30] | None,
|
| 752 |
-
) -> list[ParameterInfo]:
|
| 753 |
-
"""Extracts and resolves parameters for OpenAPI 3.0."""
|
| 754 |
-
extracted_params: list[ParameterInfo] = []
|
| 755 |
-
seen_params: dict[
|
| 756 |
-
tuple[str, str], bool
|
| 757 |
-
] = {} # Use string keys to avoid type issues
|
| 758 |
-
all_params_refs = (operation_params or []) + (path_item_params or [])
|
| 759 |
-
|
| 760 |
-
for param_or_ref in all_params_refs:
|
| 761 |
-
try:
|
| 762 |
-
parameter = cast(Parameter_30, self._resolve_ref(param_or_ref))
|
| 763 |
-
if not isinstance(parameter, Parameter_30):
|
| 764 |
-
logger.warning(
|
| 765 |
-
f"Expected Parameter after resolving reference, got {type(parameter)}. Skipping."
|
| 766 |
-
)
|
| 767 |
-
continue
|
| 768 |
-
|
| 769 |
-
# OpenAPI 3.0 uses 'in' field for parameter location
|
| 770 |
-
param_in = parameter.param_in
|
| 771 |
-
param_location = self._convert_to_parameter_location(param_in)
|
| 772 |
-
param_schema_obj = parameter.param_schema
|
| 773 |
-
|
| 774 |
-
param_key = (parameter.name, param_in)
|
| 775 |
-
if param_key in seen_params:
|
| 776 |
-
continue
|
| 777 |
-
seen_params[param_key] = True
|
| 778 |
-
|
| 779 |
-
param_schema_dict = {}
|
| 780 |
-
if param_schema_obj: # Check if schema exists
|
| 781 |
-
# Resolve the schema if it's a reference
|
| 782 |
-
resolved_schema = self._resolve_ref(param_schema_obj)
|
| 783 |
-
param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
|
| 784 |
-
|
| 785 |
-
# Ensure default value is preserved from resolved schema
|
| 786 |
-
if (
|
| 787 |
-
not isinstance(resolved_schema, Reference_30)
|
| 788 |
-
and hasattr(resolved_schema, "default")
|
| 789 |
-
and resolved_schema.default is not None
|
| 790 |
-
):
|
| 791 |
-
param_schema_dict["default"] = resolved_schema.default
|
| 792 |
-
elif parameter.content:
|
| 793 |
-
# Handle complex parameters with 'content'
|
| 794 |
-
first_media_type = next(iter(parameter.content.values()), None)
|
| 795 |
-
if first_media_type and first_media_type.media_type_schema:
|
| 796 |
-
# Resolve the schema if it's a reference
|
| 797 |
-
media_schema = first_media_type.media_type_schema
|
| 798 |
-
resolved_media_schema = self._resolve_ref(media_schema)
|
| 799 |
-
param_schema_dict = self._extract_schema_as_dict(media_schema)
|
| 800 |
-
|
| 801 |
-
# Ensure default value is preserved from resolved schema
|
| 802 |
-
if (
|
| 803 |
-
not isinstance(resolved_media_schema, Reference_30)
|
| 804 |
-
and hasattr(resolved_media_schema, "default")
|
| 805 |
-
and resolved_media_schema.default is not None
|
| 806 |
-
):
|
| 807 |
-
param_schema_dict["default"] = resolved_media_schema.default
|
| 808 |
-
|
| 809 |
-
logger.debug(
|
| 810 |
-
f"Parameter '{parameter.name}' using schema from 'content' field."
|
| 811 |
-
)
|
| 812 |
-
|
| 813 |
-
# Manually create ParameterInfo instance using correct field names
|
| 814 |
-
param_info = ParameterInfo(
|
| 815 |
-
name=parameter.name,
|
| 816 |
-
location=param_location, # Use converted parameter location
|
| 817 |
-
required=parameter.required,
|
| 818 |
-
schema=param_schema_dict, # Populate 'schema' field in IR
|
| 819 |
-
description=parameter.description,
|
| 820 |
-
)
|
| 821 |
-
extracted_params.append(param_info)
|
| 822 |
-
|
| 823 |
-
except (
|
| 824 |
-
ValidationError,
|
| 825 |
-
ValueError,
|
| 826 |
-
AttributeError,
|
| 827 |
-
TypeError,
|
| 828 |
-
) as e: # Added TypeError
|
| 829 |
-
param_name = getattr(
|
| 830 |
-
param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
|
| 831 |
-
)
|
| 832 |
-
logger.error(
|
| 833 |
-
f"Failed to extract parameter '{param_name}': {e}", exc_info=False
|
| 834 |
-
)
|
| 835 |
-
|
| 836 |
-
return extracted_params
|
| 837 |
-
|
| 838 |
-
def _extract_request_body(
|
| 839 |
-
self, request_body_or_ref: RequestBody_30 | Reference_30 | None
|
| 840 |
-
) -> RequestBodyInfo | None:
|
| 841 |
-
"""Extracts request body information for OpenAPI 3.0 using correct attribute names."""
|
| 842 |
-
if request_body_or_ref is None:
|
| 843 |
-
return None
|
| 844 |
-
|
| 845 |
-
try:
|
| 846 |
-
request_body = cast(RequestBody_30, self._resolve_ref(request_body_or_ref))
|
| 847 |
-
|
| 848 |
-
if not isinstance(request_body, RequestBody_30):
|
| 849 |
-
logger.warning(
|
| 850 |
-
f"Expected RequestBody after resolving reference, got {type(request_body)}. Returning None."
|
| 851 |
-
)
|
| 852 |
-
return None
|
| 853 |
-
|
| 854 |
-
request_body_info = RequestBodyInfo(
|
| 855 |
-
required=request_body.required,
|
| 856 |
-
description=request_body.description,
|
| 857 |
-
)
|
| 858 |
-
|
| 859 |
-
# Process content field for request body schemas
|
| 860 |
-
if request_body.content:
|
| 861 |
-
for media_type_key, media_type_obj in request_body.content.items():
|
| 862 |
-
if (
|
| 863 |
-
media_type_obj and media_type_obj.media_type_schema
|
| 864 |
-
): # CORRECTED: Use 'media_type_schema'
|
| 865 |
-
schema_dict = self._extract_schema_as_dict(
|
| 866 |
-
media_type_obj.media_type_schema
|
| 867 |
-
)
|
| 868 |
-
request_body_info.content_schema[media_type_key] = schema_dict
|
| 869 |
-
|
| 870 |
-
return request_body_info
|
| 871 |
-
|
| 872 |
-
except (ValidationError, ValueError, AttributeError) as e:
|
| 873 |
-
ref_str = getattr(request_body_or_ref, "ref", "unknown")
|
| 874 |
-
logger.error(
|
| 875 |
-
f"Failed to extract request body info from reference '{ref_str}': {e}",
|
| 876 |
-
exc_info=False,
|
| 877 |
-
)
|
| 878 |
-
return None
|
| 879 |
-
|
| 880 |
-
def _extract_responses(
|
| 881 |
-
self,
|
| 882 |
-
operation_responses: dict[str, Response_30 | Reference_30] | None,
|
| 883 |
-
) -> dict[str, ResponseInfo]:
|
| 884 |
-
"""Extracts response information from an OpenAPI 3.0 operation's responses."""
|
| 885 |
-
extracted_responses: dict[str, ResponseInfo] = {}
|
| 886 |
-
if not operation_responses:
|
| 887 |
-
return extracted_responses
|
| 888 |
-
|
| 889 |
-
for status_code, response_or_ref in operation_responses.items():
|
| 890 |
-
try:
|
| 891 |
-
# Skip 'default' response for simplicity if needed
|
| 892 |
-
# if status_code == "default":
|
| 893 |
-
# continue
|
| 894 |
-
|
| 895 |
-
response = cast(Response_30, self._resolve_ref(response_or_ref))
|
| 896 |
-
|
| 897 |
-
if not isinstance(response, Response_30):
|
| 898 |
-
logger.warning(
|
| 899 |
-
f"Expected Response after resolving reference for status code {status_code}, "
|
| 900 |
-
f"got {type(response)}. Skipping."
|
| 901 |
-
)
|
| 902 |
-
continue
|
| 903 |
-
|
| 904 |
-
response_info = ResponseInfo(description=response.description)
|
| 905 |
-
|
| 906 |
-
# Extract content schemas if present
|
| 907 |
-
if response.content:
|
| 908 |
-
for media_type_key, media_type_obj in response.content.items():
|
| 909 |
-
if (
|
| 910 |
-
media_type_obj and media_type_obj.media_type_schema
|
| 911 |
-
): # CORRECTED: Use 'media_type_schema'
|
| 912 |
-
schema_dict = self._extract_schema_as_dict(
|
| 913 |
-
media_type_obj.media_type_schema
|
| 914 |
-
)
|
| 915 |
-
response_info.content_schema[media_type_key] = schema_dict
|
| 916 |
-
|
| 917 |
-
extracted_responses[status_code] = response_info
|
| 918 |
-
|
| 919 |
-
except (ValidationError, ValueError, AttributeError) as e:
|
| 920 |
-
ref_str = getattr(response_or_ref, "ref", "unknown")
|
| 921 |
-
logger.error(
|
| 922 |
-
f"Failed to extract response info for status code {status_code} "
|
| 923 |
-
f"from reference '{ref_str}': {e}",
|
| 924 |
-
exc_info=False,
|
| 925 |
-
)
|
| 926 |
-
|
| 927 |
-
return extracted_responses
|
| 928 |
-
|
| 929 |
|
| 930 |
def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
|
| 931 |
"""
|
|
@@ -956,6 +593,7 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
|
|
| 956 |
# "multipleOf", "minItems", "maxItems", "uniqueItems",
|
| 957 |
# "minProperties", "maxProperties"
|
| 958 |
]
|
|
|
|
| 959 |
for field in fields_to_remove:
|
| 960 |
if field in cleaned:
|
| 961 |
cleaned.pop(field)
|
|
@@ -985,11 +623,6 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
|
|
| 985 |
# Maybe keep 'true' or represent as 'Allows additional properties' text?
|
| 986 |
pass # Keep simple boolean for now
|
| 987 |
|
| 988 |
-
# Remove title if it just repeats the property name (heuristic)
|
| 989 |
-
# This requires knowing the property name, so better done when formatting properties dict
|
| 990 |
-
|
| 991 |
-
return cleaned
|
| 992 |
-
|
| 993 |
|
| 994 |
def generate_example_from_schema(schema: JsonSchema | None) -> Any:
|
| 995 |
"""
|
|
@@ -1088,8 +721,8 @@ def format_description_with_responses(
|
|
| 1088 |
responses: dict[
|
| 1089 |
str, Any
|
| 1090 |
], # Changed from specific ResponseInfo type to avoid circular imports
|
| 1091 |
-
parameters: list[
|
| 1092 |
-
request_body:
|
| 1093 |
) -> str:
|
| 1094 |
"""
|
| 1095 |
Formats the base description string with response, parameter, and request body information.
|
|
@@ -1097,10 +730,10 @@ def format_description_with_responses(
|
|
| 1097 |
Args:
|
| 1098 |
base_description (str): The initial description to be formatted.
|
| 1099 |
responses (dict[str, Any]): A dictionary of response information, keyed by status code.
|
| 1100 |
-
parameters (list[
|
| 1101 |
including path and query parameters. Each parameter includes details such as name,
|
| 1102 |
location, whether it is required, and a description.
|
| 1103 |
-
request_body (
|
| 1104 |
including its description, whether it is required, and its content schema.
|
| 1105 |
|
| 1106 |
Returns:
|
|
@@ -1239,7 +872,7 @@ def format_description_with_responses(
|
|
| 1239 |
return "\n".join(desc_parts)
|
| 1240 |
|
| 1241 |
|
| 1242 |
-
def _combine_schemas(route:
|
| 1243 |
"""
|
| 1244 |
Combines parameter and request body schemas into a single schema.
|
| 1245 |
|
|
@@ -1308,8 +941,6 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
|
|
| 1308 |
result["$defs"] = route.schema_definitions
|
| 1309 |
|
| 1310 |
# Use compress_schema to remove unused definitions
|
| 1311 |
-
from fastmcp.utilities.json_schema import compress_schema
|
| 1312 |
-
|
| 1313 |
result = compress_schema(result)
|
| 1314 |
|
| 1315 |
return result
|
|
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
+
from typing import Any, Generic, Literal, TypeVar
|
| 4 |
|
|
|
|
| 5 |
from openapi_pydantic import (
|
|
|
|
| 6 |
OpenAPI,
|
| 7 |
Operation,
|
| 8 |
Parameter,
|
|
|
|
| 24 |
from openapi_pydantic.v3.v3_0 import Schema as Schema_30
|
| 25 |
from pydantic import BaseModel, Field, ValidationError
|
| 26 |
|
| 27 |
+
from fastmcp.utilities.json_schema import compress_schema
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
| 30 |
|
|
|
|
| 47 |
schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
|
| 48 |
description: str | None = None
|
| 49 |
|
|
|
|
|
|
|
| 50 |
|
| 51 |
class RequestBodyInfo(BaseModel):
|
| 52 |
"""Represents the request body for an HTTP operation in our IR."""
|
|
|
|
| 97 |
"parse_openapi_to_http_routes",
|
| 98 |
]
|
| 99 |
|
| 100 |
+
# Type variables for generic parser
|
| 101 |
+
TOpenAPI = TypeVar("TOpenAPI", OpenAPI, OpenAPI_30)
|
| 102 |
+
TSchema = TypeVar("TSchema", Schema, Schema_30)
|
| 103 |
+
TReference = TypeVar("TReference", Reference, Reference_30)
|
| 104 |
+
TParameter = TypeVar("TParameter", Parameter, Parameter_30)
|
| 105 |
+
TRequestBody = TypeVar("TRequestBody", RequestBody, RequestBody_30)
|
| 106 |
+
TResponse = TypeVar("TResponse", Response, Response_30)
|
| 107 |
+
TOperation = TypeVar("TOperation", Operation, Operation_30)
|
| 108 |
+
TPathItem = TypeVar("TPathItem", PathItem, PathItem_30)
|
| 109 |
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
|
| 112 |
"""
|
| 113 |
Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
|
|
|
|
| 125 |
logger.info(
|
| 126 |
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
|
| 127 |
)
|
| 128 |
+
parser = OpenAPIParser(
|
| 129 |
+
openapi_30,
|
| 130 |
+
Reference_30,
|
| 131 |
+
Schema_30,
|
| 132 |
+
Parameter_30,
|
| 133 |
+
RequestBody_30,
|
| 134 |
+
Response_30,
|
| 135 |
+
Operation_30,
|
| 136 |
+
PathItem_30,
|
| 137 |
+
)
|
| 138 |
return parser.parse()
|
| 139 |
else:
|
| 140 |
# Default to OpenAPI 3.1 models
|
|
|
|
| 142 |
logger.info(
|
| 143 |
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
|
| 144 |
)
|
| 145 |
+
parser = OpenAPIParser(
|
| 146 |
+
openapi_31,
|
| 147 |
+
Reference,
|
| 148 |
+
Schema,
|
| 149 |
+
Parameter,
|
| 150 |
+
RequestBody,
|
| 151 |
+
Response,
|
| 152 |
+
Operation,
|
| 153 |
+
PathItem,
|
| 154 |
+
)
|
| 155 |
return parser.parse()
|
| 156 |
except ValidationError as e:
|
| 157 |
logger.error(f"OpenAPI schema validation failed: {e}")
|
|
|
|
| 160 |
raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
|
| 161 |
|
| 162 |
|
| 163 |
+
class OpenAPIParser(
|
| 164 |
+
Generic[
|
| 165 |
+
TOpenAPI,
|
| 166 |
+
TReference,
|
| 167 |
+
TSchema,
|
| 168 |
+
TParameter,
|
| 169 |
+
TRequestBody,
|
| 170 |
+
TResponse,
|
| 171 |
+
TOperation,
|
| 172 |
+
TPathItem,
|
| 173 |
+
]
|
| 174 |
+
):
|
| 175 |
+
"""Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
+
def __init__(
|
| 178 |
+
self,
|
| 179 |
+
openapi: TOpenAPI,
|
| 180 |
+
reference_cls: type[TReference],
|
| 181 |
+
schema_cls: type[TSchema],
|
| 182 |
+
parameter_cls: type[TParameter],
|
| 183 |
+
request_body_cls: type[TRequestBody],
|
| 184 |
+
response_cls: type[TResponse],
|
| 185 |
+
operation_cls: type[TOperation],
|
| 186 |
+
path_item_cls: type[TPathItem],
|
| 187 |
+
):
|
| 188 |
+
"""Initialize the parser with the OpenAPI schema and type classes."""
|
| 189 |
self.openapi = openapi
|
| 190 |
+
self.reference_cls = reference_cls
|
| 191 |
+
self.schema_cls = schema_cls
|
| 192 |
+
self.parameter_cls = parameter_cls
|
| 193 |
+
self.request_body_cls = request_body_cls
|
| 194 |
+
self.response_cls = response_cls
|
| 195 |
+
self.operation_cls = operation_cls
|
| 196 |
+
self.path_item_cls = path_item_cls
|
| 197 |
|
| 198 |
+
def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
|
| 199 |
+
"""Convert string parameter location to our ParameterLocation type."""
|
| 200 |
+
if param_in in ["path", "query", "header", "cookie"]:
|
| 201 |
+
return param_in # type: ignore[return-value] # Safe cast since we checked values
|
| 202 |
+
logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'")
|
| 203 |
+
return "query" # type: ignore[return-value] # Safe cast to default value
|
| 204 |
+
|
| 205 |
+
def _resolve_ref(self, item: Any) -> Any:
|
| 206 |
+
"""Resolves a reference to its target definition."""
|
| 207 |
+
if isinstance(item, self.reference_cls):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
ref_str = item.ref
|
| 209 |
try:
|
| 210 |
if not ref_str.startswith("#/"):
|
| 211 |
raise ValueError(
|
| 212 |
f"External or non-local reference not supported: {ref_str}"
|
| 213 |
)
|
| 214 |
+
|
| 215 |
parts = ref_str.strip("#/").split("/")
|
| 216 |
target = self.openapi
|
| 217 |
+
|
| 218 |
for part in parts:
|
| 219 |
if part.isdigit() and isinstance(target, list):
|
| 220 |
target = target[int(part)]
|
| 221 |
elif isinstance(target, BaseModel):
|
|
|
|
| 222 |
# Check class fields first, then model_extra
|
| 223 |
if part in target.__class__.model_fields:
|
| 224 |
target = getattr(target, part, None)
|
| 225 |
elif target.model_extra and part in target.model_extra:
|
| 226 |
target = target.model_extra[part]
|
| 227 |
else:
|
| 228 |
+
# Special handling for components
|
| 229 |
if part == "components" and hasattr(target, "components"):
|
| 230 |
target = getattr(target, "components")
|
| 231 |
elif hasattr(target, part): # Fallback check
|
|
|
|
| 236 |
target = target.get(part)
|
| 237 |
else:
|
| 238 |
raise ValueError(
|
| 239 |
+
f"Cannot traverse part '{part}' in reference '{ref_str}'"
|
| 240 |
)
|
| 241 |
+
|
| 242 |
if target is None:
|
| 243 |
raise ValueError(
|
| 244 |
f"Reference part '{part}' not found in path '{ref_str}'"
|
| 245 |
)
|
| 246 |
+
|
| 247 |
+
# Handle nested references
|
| 248 |
+
if isinstance(target, self.reference_cls):
|
| 249 |
return self._resolve_ref(target)
|
| 250 |
+
|
| 251 |
return target
|
| 252 |
except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
|
| 253 |
raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
|
| 254 |
+
|
| 255 |
return item
|
| 256 |
|
| 257 |
+
def _extract_schema_as_dict(self, schema_obj: Any) -> JsonSchema:
|
| 258 |
+
"""Resolves a schema and returns it as a dictionary."""
|
| 259 |
+
try:
|
| 260 |
+
resolved_schema = self._resolve_ref(schema_obj)
|
| 261 |
+
|
| 262 |
+
if isinstance(resolved_schema, (self.schema_cls)):
|
| 263 |
+
# Convert schema to dictionary
|
| 264 |
+
return resolved_schema.model_dump(
|
| 265 |
+
mode="json", by_alias=True, exclude_none=True
|
| 266 |
+
)
|
| 267 |
+
elif isinstance(resolved_schema, dict):
|
| 268 |
+
return resolved_schema
|
| 269 |
+
else:
|
| 270 |
+
logger.warning(
|
| 271 |
+
f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict."
|
| 272 |
+
)
|
| 273 |
+
return {}
|
| 274 |
+
except Exception as e:
|
| 275 |
+
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
|
| 276 |
return {}
|
| 277 |
|
| 278 |
def _extract_parameters(
|
| 279 |
self,
|
| 280 |
+
operation_params: list[Any] | None = None,
|
| 281 |
+
path_item_params: list[Any] | None = None,
|
| 282 |
) -> list[ParameterInfo]:
|
| 283 |
+
"""Extract and resolve parameters from operation and path item."""
|
| 284 |
extracted_params: list[ParameterInfo] = []
|
| 285 |
seen_params: dict[
|
| 286 |
tuple[str, str], bool
|
| 287 |
+
] = {} # Use tuple of (name, location) as key
|
| 288 |
+
all_params = (operation_params or []) + (path_item_params or [])
|
| 289 |
|
| 290 |
+
for param_or_ref in all_params:
|
| 291 |
try:
|
| 292 |
+
parameter = self._resolve_ref(param_or_ref)
|
| 293 |
+
|
| 294 |
+
if not isinstance(parameter, self.parameter_cls):
|
| 295 |
+
logger.warning(
|
| 296 |
+
f"Expected Parameter after resolving, got {type(parameter)}. Skipping."
|
| 297 |
+
)
|
| 298 |
continue
|
| 299 |
|
| 300 |
+
# Extract parameter info - handle both 3.0 and 3.1 parameter models
|
| 301 |
+
param_in = parameter.param_in # Both use param_in
|
| 302 |
param_location = self._convert_to_parameter_location(param_in)
|
| 303 |
+
param_schema_obj = parameter.param_schema # Both use param_schema
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
+
# Skip duplicate parameters (same name and location)
|
| 306 |
param_key = (parameter.name, param_in)
|
| 307 |
if param_key in seen_params:
|
| 308 |
continue
|
| 309 |
seen_params[param_key] = True
|
| 310 |
|
| 311 |
+
# Extract schema
|
| 312 |
param_schema_dict = {}
|
| 313 |
+
if param_schema_obj:
|
| 314 |
+
# Process schema object
|
|
|
|
| 315 |
param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
|
| 316 |
|
| 317 |
+
# Handle default value
|
| 318 |
+
resolved_schema = self._resolve_ref(param_schema_obj)
|
| 319 |
if (
|
| 320 |
+
not isinstance(resolved_schema, self.reference_cls)
|
| 321 |
and hasattr(resolved_schema, "default")
|
| 322 |
and resolved_schema.default is not None
|
| 323 |
):
|
| 324 |
param_schema_dict["default"] = resolved_schema.default
|
| 325 |
+
|
| 326 |
+
elif hasattr(parameter, "content") and parameter.content:
|
| 327 |
+
# Handle content-based parameters
|
| 328 |
first_media_type = next(iter(parameter.content.values()), None)
|
| 329 |
if (
|
| 330 |
+
first_media_type
|
| 331 |
+
and hasattr(first_media_type, "media_type_schema")
|
| 332 |
+
and first_media_type.media_type_schema
|
| 333 |
+
):
|
| 334 |
media_schema = first_media_type.media_type_schema
|
|
|
|
| 335 |
param_schema_dict = self._extract_schema_as_dict(media_schema)
|
| 336 |
|
| 337 |
+
# Handle default value in content schema
|
| 338 |
+
resolved_media_schema = self._resolve_ref(media_schema)
|
| 339 |
if (
|
| 340 |
+
not isinstance(resolved_media_schema, self.reference_cls)
|
| 341 |
and hasattr(resolved_media_schema, "default")
|
| 342 |
and resolved_media_schema.default is not None
|
| 343 |
):
|
| 344 |
param_schema_dict["default"] = resolved_media_schema.default
|
| 345 |
|
| 346 |
+
# Create parameter info object
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
param_info = ParameterInfo(
|
| 348 |
name=parameter.name,
|
| 349 |
+
location=param_location,
|
| 350 |
required=parameter.required,
|
| 351 |
+
schema=param_schema_dict,
|
| 352 |
description=parameter.description,
|
| 353 |
)
|
| 354 |
extracted_params.append(param_info)
|
| 355 |
+
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
param_name = getattr(
|
| 357 |
param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
|
| 358 |
)
|
|
|
|
| 362 |
|
| 363 |
return extracted_params
|
| 364 |
|
| 365 |
+
def _extract_request_body(self, request_body_or_ref: Any) -> RequestBodyInfo | None:
|
| 366 |
+
"""Extract and resolve request body information."""
|
|
|
|
|
|
|
| 367 |
if not request_body_or_ref:
|
| 368 |
return None
|
| 369 |
+
|
| 370 |
try:
|
| 371 |
+
request_body = self._resolve_ref(request_body_or_ref)
|
| 372 |
+
|
| 373 |
+
if not isinstance(request_body, self.request_body_cls):
|
| 374 |
+
logger.warning(
|
| 375 |
+
f"Expected RequestBody after resolving, got {type(request_body)}. Returning None."
|
| 376 |
+
)
|
| 377 |
return None
|
| 378 |
|
| 379 |
+
# Create request body info
|
| 380 |
+
request_body_info = RequestBodyInfo(
|
| 381 |
+
required=request_body.required,
|
| 382 |
+
description=request_body.description,
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# Extract content schemas
|
| 386 |
+
if hasattr(request_body, "content") and request_body.content:
|
| 387 |
for media_type_str, media_type_obj in request_body.content.items():
|
|
|
|
| 388 |
if (
|
| 389 |
+
media_type_obj
|
| 390 |
+
and hasattr(media_type_obj, "media_type_schema")
|
| 391 |
and media_type_obj.media_type_schema
|
| 392 |
+
):
|
|
|
|
| 393 |
try:
|
|
|
|
| 394 |
schema_dict = self._extract_schema_as_dict(
|
| 395 |
media_type_obj.media_type_schema
|
| 396 |
)
|
| 397 |
+
request_body_info.content_schema[media_type_str] = (
|
| 398 |
+
schema_dict
|
| 399 |
+
)
|
| 400 |
+
except Exception as e:
|
| 401 |
logger.error(
|
| 402 |
+
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
| 403 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
+
return request_body_info
|
| 406 |
+
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
| 408 |
logger.error(
|
| 409 |
f"Failed to extract request body '{ref_name}': {e}", exc_info=False
|
|
|
|
| 411 |
return None
|
| 412 |
|
| 413 |
def _extract_responses(
|
| 414 |
+
self, operation_responses: dict[str, Any] | None
|
|
|
|
| 415 |
) -> dict[str, ResponseInfo]:
|
| 416 |
+
"""Extract and resolve response information."""
|
| 417 |
extracted_responses: dict[str, ResponseInfo] = {}
|
| 418 |
+
|
| 419 |
if not operation_responses:
|
| 420 |
return extracted_responses
|
| 421 |
|
| 422 |
for status_code, resp_or_ref in operation_responses.items():
|
| 423 |
try:
|
| 424 |
+
response = self._resolve_ref(resp_or_ref)
|
| 425 |
+
|
| 426 |
+
if not isinstance(response, self.response_cls):
|
| 427 |
logger.warning(
|
| 428 |
+
f"Expected Response after resolving for status code {status_code}, "
|
| 429 |
+
f"got {type(response)}. Skipping."
|
| 430 |
)
|
| 431 |
continue
|
| 432 |
|
| 433 |
+
# Create response info
|
| 434 |
+
resp_info = ResponseInfo(description=response.description)
|
| 435 |
+
|
| 436 |
+
# Extract content schemas
|
| 437 |
+
if hasattr(response, "content") and response.content:
|
| 438 |
for media_type_str, media_type_obj in response.content.items():
|
| 439 |
if (
|
| 440 |
+
media_type_obj
|
| 441 |
+
and hasattr(media_type_obj, "media_type_schema")
|
| 442 |
and media_type_obj.media_type_schema
|
| 443 |
):
|
| 444 |
try:
|
| 445 |
schema_dict = self._extract_schema_as_dict(
|
| 446 |
media_type_obj.media_type_schema
|
| 447 |
)
|
| 448 |
+
resp_info.content_schema[media_type_str] = schema_dict
|
| 449 |
+
except Exception as e:
|
| 450 |
logger.error(
|
| 451 |
+
f"Failed to extract schema for media type '{media_type_str}' "
|
| 452 |
+
f"in response {status_code}: {e}"
|
| 453 |
)
|
| 454 |
|
|
|
|
|
|
|
|
|
|
| 455 |
extracted_responses[str(status_code)] = resp_info
|
| 456 |
+
except Exception as e:
|
|
|
|
| 457 |
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
| 458 |
logger.error(
|
| 459 |
f"Failed to extract response for status code {status_code} "
|
|
|
|
| 463 |
|
| 464 |
return extracted_responses
|
| 465 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
def parse(self) -> list[HTTPRoute]:
|
| 467 |
+
"""Parse the OpenAPI schema into HTTP routes."""
|
| 468 |
routes: list[HTTPRoute] = []
|
| 469 |
|
| 470 |
+
if not hasattr(self.openapi, "paths") or not self.openapi.paths:
|
| 471 |
logger.warning("OpenAPI schema has no paths defined.")
|
| 472 |
return []
|
| 473 |
|
| 474 |
+
# Extract component schemas
|
| 475 |
schema_definitions = {}
|
| 476 |
if hasattr(self.openapi, "components") and self.openapi.components:
|
| 477 |
components = self.openapi.components
|
| 478 |
if hasattr(components, "schemas") and components.schemas:
|
| 479 |
for name, schema in components.schemas.items():
|
| 480 |
try:
|
| 481 |
+
if isinstance(schema, self.reference_cls):
|
| 482 |
resolved_schema = self._resolve_ref(schema)
|
| 483 |
schema_definitions[name] = self._extract_schema_as_dict(
|
| 484 |
resolved_schema
|
|
|
|
| 492 |
f"Failed to extract schema definition '{name}': {e}"
|
| 493 |
)
|
| 494 |
|
| 495 |
+
# Process paths and operations
|
| 496 |
for path_str, path_item_obj in self.openapi.paths.items():
|
| 497 |
+
if not isinstance(path_item_obj, self.path_item_cls):
|
| 498 |
logger.warning(
|
| 499 |
+
f"Skipping invalid path item for path '{path_str}' (type: {type(path_item_obj)})"
|
| 500 |
)
|
| 501 |
continue
|
| 502 |
|
| 503 |
+
path_level_params = (
|
| 504 |
+
path_item_obj.parameters
|
| 505 |
+
if hasattr(path_item_obj, "parameters")
|
| 506 |
+
else None
|
| 507 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
|
| 509 |
+
# Get HTTP methods from the path item class fields
|
| 510 |
+
http_methods = [
|
| 511 |
+
"get",
|
| 512 |
+
"put",
|
| 513 |
+
"post",
|
| 514 |
+
"delete",
|
| 515 |
+
"options",
|
| 516 |
+
"head",
|
| 517 |
+
"patch",
|
| 518 |
+
"trace",
|
| 519 |
+
]
|
| 520 |
+
for method_lower in http_methods:
|
| 521 |
+
operation = getattr(path_item_obj, method_lower, None)
|
| 522 |
+
|
| 523 |
+
if operation and isinstance(operation, self.operation_cls):
|
| 524 |
+
# Cast method to HttpMethod - safe since we only use valid HTTP methods
|
| 525 |
+
method_upper = method_lower.upper()
|
| 526 |
|
|
|
|
|
|
|
|
|
|
| 527 |
try:
|
| 528 |
parameters = self._extract_parameters(
|
| 529 |
+
getattr(operation, "parameters", None), path_level_params
|
| 530 |
)
|
| 531 |
+
|
| 532 |
request_body_info = self._extract_request_body(
|
| 533 |
+
getattr(operation, "requestBody", None)
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
responses = self._extract_responses(
|
| 537 |
+
getattr(operation, "responses", None)
|
| 538 |
)
|
|
|
|
| 539 |
|
| 540 |
route = HTTPRoute(
|
| 541 |
path=path_str,
|
| 542 |
+
method=method_upper, # type: ignore[arg-type] # Known valid HTTP method
|
| 543 |
+
operation_id=getattr(operation, "operationId", None),
|
| 544 |
+
summary=getattr(operation, "summary", None),
|
| 545 |
+
description=getattr(operation, "description", None),
|
| 546 |
+
tags=getattr(operation, "tags", []) or [],
|
| 547 |
parameters=parameters,
|
| 548 |
request_body=request_body_info,
|
| 549 |
responses=responses,
|
|
|
|
| 554 |
f"Successfully extracted route: {method_upper} {path_str}"
|
| 555 |
)
|
| 556 |
except Exception as op_error:
|
| 557 |
+
op_id = getattr(operation, "operationId", "unknown")
|
| 558 |
logger.error(
|
| 559 |
f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
|
| 560 |
exc_info=True,
|
|
|
|
| 563 |
logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
|
| 564 |
return routes
|
| 565 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
|
| 567 |
def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
|
| 568 |
"""
|
|
|
|
| 593 |
# "multipleOf", "minItems", "maxItems", "uniqueItems",
|
| 594 |
# "minProperties", "maxProperties"
|
| 595 |
]
|
| 596 |
+
|
| 597 |
for field in fields_to_remove:
|
| 598 |
if field in cleaned:
|
| 599 |
cleaned.pop(field)
|
|
|
|
| 623 |
# Maybe keep 'true' or represent as 'Allows additional properties' text?
|
| 624 |
pass # Keep simple boolean for now
|
| 625 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
|
| 627 |
def generate_example_from_schema(schema: JsonSchema | None) -> Any:
|
| 628 |
"""
|
|
|
|
| 721 |
responses: dict[
|
| 722 |
str, Any
|
| 723 |
], # Changed from specific ResponseInfo type to avoid circular imports
|
| 724 |
+
parameters: list[ParameterInfo] | None = None, # Add parameters parameter
|
| 725 |
+
request_body: RequestBodyInfo | None = None, # Add request_body parameter
|
| 726 |
) -> str:
|
| 727 |
"""
|
| 728 |
Formats the base description string with response, parameter, and request body information.
|
|
|
|
| 730 |
Args:
|
| 731 |
base_description (str): The initial description to be formatted.
|
| 732 |
responses (dict[str, Any]): A dictionary of response information, keyed by status code.
|
| 733 |
+
parameters (list[ParameterInfo] | None, optional): A list of parameter information,
|
| 734 |
including path and query parameters. Each parameter includes details such as name,
|
| 735 |
location, whether it is required, and a description.
|
| 736 |
+
request_body (RequestBodyInfo | None, optional): Information about the request body,
|
| 737 |
including its description, whether it is required, and its content schema.
|
| 738 |
|
| 739 |
Returns:
|
|
|
|
| 872 |
return "\n".join(desc_parts)
|
| 873 |
|
| 874 |
|
| 875 |
+
def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
| 876 |
"""
|
| 877 |
Combines parameter and request body schemas into a single schema.
|
| 878 |
|
|
|
|
| 941 |
result["$defs"] = route.schema_definitions
|
| 942 |
|
| 943 |
# Use compress_schema to remove unused definitions
|
|
|
|
|
|
|
| 944 |
result = compress_schema(result)
|
| 945 |
|
| 946 |
return result
|
tests/server/test_openapi_path_parameters.py
CHANGED
|
@@ -188,3 +188,73 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client):
|
|
| 188 |
json=None,
|
| 189 |
timeout=None,
|
| 190 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
json=None,
|
| 189 |
timeout=None,
|
| 190 |
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@pytest.mark.asyncio
|
| 194 |
+
async def test_complex_nested_array_path_parameter(mock_client):
|
| 195 |
+
"""Test handling of complex nested array path parameters."""
|
| 196 |
+
# Create a route with a path parameter that contains nested objects in an array
|
| 197 |
+
route = HTTPRoute(
|
| 198 |
+
path="/report/{filters}",
|
| 199 |
+
method="GET",
|
| 200 |
+
operation_id="test-complex-filters",
|
| 201 |
+
parameters=[
|
| 202 |
+
ParameterInfo(
|
| 203 |
+
name="filters",
|
| 204 |
+
location="path",
|
| 205 |
+
required=True,
|
| 206 |
+
schema={
|
| 207 |
+
"type": "array",
|
| 208 |
+
"items": {
|
| 209 |
+
"type": "object",
|
| 210 |
+
"properties": {
|
| 211 |
+
"field": {"type": "string"},
|
| 212 |
+
"value": {"type": "string"},
|
| 213 |
+
},
|
| 214 |
+
},
|
| 215 |
+
},
|
| 216 |
+
)
|
| 217 |
+
],
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Create the tool
|
| 221 |
+
tool = OpenAPITool(
|
| 222 |
+
client=mock_client,
|
| 223 |
+
route=route,
|
| 224 |
+
name="test-complex-filters",
|
| 225 |
+
description="Test operation with complex filters",
|
| 226 |
+
parameters={},
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# Test with a more complex path parameter
|
| 230 |
+
# This would typically be serialized as JSON or a more complex format
|
| 231 |
+
# But for path parameters with style=simple, it should be comma-separated
|
| 232 |
+
complex_filters = [
|
| 233 |
+
{"field": "status", "value": "active"},
|
| 234 |
+
{"field": "type", "value": "user"},
|
| 235 |
+
]
|
| 236 |
+
|
| 237 |
+
# Execute the request with complex filters
|
| 238 |
+
await tool._execute_request(filters=complex_filters)
|
| 239 |
+
|
| 240 |
+
# The complex object should be properly serialized in the URL
|
| 241 |
+
# For path parameters, this would typically need a custom serialization strategy
|
| 242 |
+
# but our implementation should handle it safely
|
| 243 |
+
call_args = mock_client.request.call_args
|
| 244 |
+
|
| 245 |
+
# Verify the request was made
|
| 246 |
+
assert call_args is not None, "The request was not made"
|
| 247 |
+
|
| 248 |
+
# Get the called URL and verify it contains the serialized path parameter
|
| 249 |
+
called_url = call_args[1].get("url")
|
| 250 |
+
|
| 251 |
+
# Check that the path parameter is handled (we don't expect perfect serialization,
|
| 252 |
+
# but it should not cause errors and should maintain the array structure)
|
| 253 |
+
assert "/report/" in called_url, "The URL should contain the path prefix"
|
| 254 |
+
|
| 255 |
+
# Check that it didn't just convert the objects to string representations
|
| 256 |
+
# that include the Python object syntax
|
| 257 |
+
assert "status" in called_url, "The URL should contain filter field names"
|
| 258 |
+
assert "active" in called_url, "The URL should contain filter values"
|
| 259 |
+
assert "}" not in called_url, "The URL should not contain Python object syntax"
|
| 260 |
+
assert "{" not in called_url, "The URL should not contain Python object syntax"
|