Spaces:
Running
Running
Jeremiah Lowin commited on
Fix OpenAPI transitive references and performance (#1372) (#1426)
Browse files- pyproject.toml +1 -0
- src/fastmcp/experimental/server/openapi/routing.py +1 -1
- src/fastmcp/experimental/server/openapi/server.py +0 -12
- src/fastmcp/experimental/utilities/openapi/__init__.py +0 -2
- src/fastmcp/experimental/utilities/openapi/parser.py +126 -11
- src/fastmcp/experimental/utilities/openapi/schemas.py +125 -110
- src/fastmcp/utilities/openapi.py +4 -4
- tests/experimental/server/conftest.py +11 -0
- tests/experimental/server/{test_openapi_performance.py → openapi/test_openapi_performance.py} +2 -2
- tests/experimental/server/openapi/test_performance_comparison.py +2 -2
- tests/experimental/utilities/openapi/conftest.py +8 -0
- tests/experimental/utilities/openapi/test_parser.py +129 -0
- tests/experimental/utilities/openapi/test_schemas.py +8 -6
- tests/experimental/utilities/openapi/test_transitive_references.py +677 -0
- uv.lock +38 -0
pyproject.toml
CHANGED
|
@@ -15,6 +15,7 @@ dependencies = [
|
|
| 15 |
"pydantic[email]>=2.11.7",
|
| 16 |
"pyperclip>=1.9.0",
|
| 17 |
"openapi-core>=0.19.5",
|
|
|
|
| 18 |
]
|
| 19 |
requires-python = ">=3.10"
|
| 20 |
readme = "README.md"
|
|
|
|
| 15 |
"pydantic[email]>=2.11.7",
|
| 16 |
"pyperclip>=1.9.0",
|
| 17 |
"openapi-core>=0.19.5",
|
| 18 |
+
"msgspec>=0.19.0",
|
| 19 |
]
|
| 20 |
requires-python = ">=3.10"
|
| 21 |
readme = "README.md"
|
src/fastmcp/experimental/server/openapi/routing.py
CHANGED
|
@@ -113,7 +113,7 @@ def _determine_route_type(
|
|
| 113 |
# We know mcp_type is not None here due to post_init validation
|
| 114 |
assert route_map.mcp_type is not None
|
| 115 |
logger.debug(
|
| 116 |
-
f"Route {route.method} {route.path}
|
| 117 |
)
|
| 118 |
return route_map
|
| 119 |
|
|
|
|
| 113 |
# We know mcp_type is not None here due to post_init validation
|
| 114 |
assert route_map.mcp_type is not None
|
| 115 |
logger.debug(
|
| 116 |
+
f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}"
|
| 117 |
)
|
| 118 |
return route_map
|
| 119 |
|
src/fastmcp/experimental/server/openapi/server.py
CHANGED
|
@@ -151,9 +151,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 151 |
try:
|
| 152 |
self._spec = SchemaPath.from_dict(openapi_spec) # type: ignore[arg-type]
|
| 153 |
self._director = RequestDirector(self._spec)
|
| 154 |
-
logger.debug(
|
| 155 |
-
"Initialized OpenAPI RequestDirector for stateless request building"
|
| 156 |
-
)
|
| 157 |
except Exception as e:
|
| 158 |
logger.error(f"Failed to initialize RequestDirector: {e}")
|
| 159 |
raise ValueError(f"Invalid OpenAPI specification: {e}") from e
|
|
@@ -318,9 +315,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 318 |
|
| 319 |
# Register the tool by directly assigning to the tools dictionary
|
| 320 |
self._tool_manager._tools[final_tool_name] = tool
|
| 321 |
-
logger.debug(
|
| 322 |
-
f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}"
|
| 323 |
-
)
|
| 324 |
|
| 325 |
def _create_openapi_resource(
|
| 326 |
self,
|
|
@@ -372,9 +366,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 372 |
|
| 373 |
# Register the resource by directly assigning to the resources dictionary
|
| 374 |
self._resource_manager._resources[final_resource_uri] = resource
|
| 375 |
-
logger.debug(
|
| 376 |
-
f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
| 377 |
-
)
|
| 378 |
|
| 379 |
def _create_openapi_template(
|
| 380 |
self,
|
|
@@ -455,9 +446,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 455 |
|
| 456 |
# Register the template by directly assigning to the templates dictionary
|
| 457 |
self._resource_manager._templates[final_template_uri] = template
|
| 458 |
-
logger.debug(
|
| 459 |
-
f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
| 460 |
-
)
|
| 461 |
|
| 462 |
|
| 463 |
# Export public symbols
|
|
|
|
| 151 |
try:
|
| 152 |
self._spec = SchemaPath.from_dict(openapi_spec) # type: ignore[arg-type]
|
| 153 |
self._director = RequestDirector(self._spec)
|
|
|
|
|
|
|
|
|
|
| 154 |
except Exception as e:
|
| 155 |
logger.error(f"Failed to initialize RequestDirector: {e}")
|
| 156 |
raise ValueError(f"Invalid OpenAPI specification: {e}") from e
|
|
|
|
| 315 |
|
| 316 |
# Register the tool by directly assigning to the tools dictionary
|
| 317 |
self._tool_manager._tools[final_tool_name] = tool
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
def _create_openapi_resource(
|
| 320 |
self,
|
|
|
|
| 366 |
|
| 367 |
# Register the resource by directly assigning to the resources dictionary
|
| 368 |
self._resource_manager._resources[final_resource_uri] = resource
|
|
|
|
|
|
|
|
|
|
| 369 |
|
| 370 |
def _create_openapi_template(
|
| 371 |
self,
|
|
|
|
| 446 |
|
| 447 |
# Register the template by directly assigning to the templates dictionary
|
| 448 |
self._resource_manager._templates[final_template_uri] = template
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
|
| 451 |
# Export public symbols
|
src/fastmcp/experimental/utilities/openapi/__init__.py
CHANGED
|
@@ -28,7 +28,6 @@ from .schemas import (
|
|
| 28 |
_combine_schemas,
|
| 29 |
extract_output_schema_from_responses,
|
| 30 |
clean_schema_for_display,
|
| 31 |
-
_replace_ref_with_defs,
|
| 32 |
_make_optional_parameter_nullable,
|
| 33 |
)
|
| 34 |
|
|
@@ -60,7 +59,6 @@ __all__ = [
|
|
| 60 |
"_combine_schemas",
|
| 61 |
"extract_output_schema_from_responses",
|
| 62 |
"clean_schema_for_display",
|
| 63 |
-
"_replace_ref_with_defs",
|
| 64 |
"_make_optional_parameter_nullable",
|
| 65 |
# JSON Schema Converter
|
| 66 |
"convert_openapi_schema_to_json_schema",
|
|
|
|
| 28 |
_combine_schemas,
|
| 29 |
extract_output_schema_from_responses,
|
| 30 |
clean_schema_for_display,
|
|
|
|
| 31 |
_make_optional_parameter_nullable,
|
| 32 |
)
|
| 33 |
|
|
|
|
| 59 |
"_combine_schemas",
|
| 60 |
"extract_output_schema_from_responses",
|
| 61 |
"clean_schema_for_display",
|
|
|
|
| 62 |
"_make_optional_parameter_nullable",
|
| 63 |
# JSON Schema Converter
|
| 64 |
"convert_openapi_schema_to_json_schema",
|
src/fastmcp/experimental/utilities/openapi/parser.py
CHANGED
|
@@ -34,7 +34,10 @@ from .models import (
|
|
| 34 |
RequestBodyInfo,
|
| 35 |
ResponseInfo,
|
| 36 |
)
|
| 37 |
-
from .schemas import
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
logger = get_logger(__name__)
|
| 40 |
|
|
@@ -63,7 +66,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 63 |
if openapi_version.startswith("3.0"):
|
| 64 |
# Use OpenAPI 3.0 models
|
| 65 |
openapi_30 = OpenAPI_30.model_validate(openapi_dict)
|
| 66 |
-
logger.
|
| 67 |
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
|
| 68 |
)
|
| 69 |
parser = OpenAPIParser(
|
|
@@ -81,7 +84,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 81 |
else:
|
| 82 |
# Default to OpenAPI 3.1 models
|
| 83 |
openapi_31 = OpenAPI.model_validate(openapi_dict)
|
| 84 |
-
logger.
|
| 85 |
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
|
| 86 |
)
|
| 87 |
parser = OpenAPIParser(
|
|
@@ -207,7 +210,7 @@ class OpenAPIParser(
|
|
| 207 |
try:
|
| 208 |
resolved_schema = self._resolve_ref(schema_obj)
|
| 209 |
|
| 210 |
-
if isinstance(resolved_schema,
|
| 211 |
# Convert schema to dictionary
|
| 212 |
result = resolved_schema.model_dump(
|
| 213 |
mode="json", by_alias=True, exclude_none=True
|
|
@@ -220,7 +223,10 @@ class OpenAPIParser(
|
|
| 220 |
)
|
| 221 |
result = {}
|
| 222 |
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
| 224 |
except ValueError as e:
|
| 225 |
# Re-raise ValueError for external reference errors and other validation issues
|
| 226 |
if "External or non-local reference not supported" in str(e):
|
|
@@ -470,6 +476,102 @@ class OpenAPIParser(
|
|
| 470 |
|
| 471 |
return extracted_responses
|
| 472 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
def parse(self) -> list[HTTPRoute]:
|
| 474 |
"""Parse the OpenAPI schema into HTTP routes."""
|
| 475 |
routes: list[HTTPRoute] = []
|
|
@@ -499,6 +601,13 @@ class OpenAPIParser(
|
|
| 499 |
f"Failed to extract schema definition '{name}': {e}"
|
| 500 |
)
|
| 501 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
# Process paths and operations
|
| 503 |
for path_str, path_item_obj in self.openapi.paths.items():
|
| 504 |
if not isinstance(path_item_obj, self.path_item_cls):
|
|
@@ -552,6 +661,14 @@ class OpenAPIParser(
|
|
| 552 |
if k.startswith("x-")
|
| 553 |
}
|
| 554 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
# Create initial route without pre-calculated fields
|
| 556 |
route = HTTPRoute(
|
| 557 |
path=path_str,
|
|
@@ -563,7 +680,7 @@ class OpenAPIParser(
|
|
| 563 |
parameters=parameters,
|
| 564 |
request_body=request_body_info,
|
| 565 |
responses=responses,
|
| 566 |
-
schema_definitions=
|
| 567 |
extensions=extensions,
|
| 568 |
openapi_version=self.openapi_version,
|
| 569 |
)
|
|
@@ -571,7 +688,8 @@ class OpenAPIParser(
|
|
| 571 |
# Pre-calculate schema and parameter mapping for performance
|
| 572 |
try:
|
| 573 |
flat_schema, param_map = _combine_schemas_and_map_params(
|
| 574 |
-
route
|
|
|
|
| 575 |
)
|
| 576 |
route.flat_param_schema = flat_schema
|
| 577 |
route.parameter_map = param_map
|
|
@@ -586,9 +704,6 @@ class OpenAPIParser(
|
|
| 586 |
}
|
| 587 |
route.parameter_map = {}
|
| 588 |
routes.append(route)
|
| 589 |
-
logger.info(
|
| 590 |
-
f"Successfully extracted route: {method_upper} {path_str}"
|
| 591 |
-
)
|
| 592 |
except ValueError as op_error:
|
| 593 |
# Re-raise ValueError for external reference errors
|
| 594 |
if "External or non-local reference not supported" in str(
|
|
@@ -607,7 +722,7 @@ class OpenAPIParser(
|
|
| 607 |
exc_info=True,
|
| 608 |
)
|
| 609 |
|
| 610 |
-
logger.
|
| 611 |
return routes
|
| 612 |
|
| 613 |
|
|
|
|
| 34 |
RequestBodyInfo,
|
| 35 |
ResponseInfo,
|
| 36 |
)
|
| 37 |
+
from .schemas import (
|
| 38 |
+
_combine_schemas_and_map_params,
|
| 39 |
+
_replace_ref_with_defs_recursive,
|
| 40 |
+
)
|
| 41 |
|
| 42 |
logger = get_logger(__name__)
|
| 43 |
|
|
|
|
| 66 |
if openapi_version.startswith("3.0"):
|
| 67 |
# Use OpenAPI 3.0 models
|
| 68 |
openapi_30 = OpenAPI_30.model_validate(openapi_dict)
|
| 69 |
+
logger.debug(
|
| 70 |
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
|
| 71 |
)
|
| 72 |
parser = OpenAPIParser(
|
|
|
|
| 84 |
else:
|
| 85 |
# Default to OpenAPI 3.1 models
|
| 86 |
openapi_31 = OpenAPI.model_validate(openapi_dict)
|
| 87 |
+
logger.debug(
|
| 88 |
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
|
| 89 |
)
|
| 90 |
parser = OpenAPIParser(
|
|
|
|
| 210 |
try:
|
| 211 |
resolved_schema = self._resolve_ref(schema_obj)
|
| 212 |
|
| 213 |
+
if isinstance(resolved_schema, self.schema_cls):
|
| 214 |
# Convert schema to dictionary
|
| 215 |
result = resolved_schema.model_dump(
|
| 216 |
mode="json", by_alias=True, exclude_none=True
|
|
|
|
| 223 |
)
|
| 224 |
result = {}
|
| 225 |
|
| 226 |
+
# Convert refs from OpenAPI format to JSON Schema format using recursive approach
|
| 227 |
+
|
| 228 |
+
result = _replace_ref_with_defs_recursive(result)
|
| 229 |
+
return result
|
| 230 |
except ValueError as e:
|
| 231 |
# Re-raise ValueError for external reference errors and other validation issues
|
| 232 |
if "External or non-local reference not supported" in str(e):
|
|
|
|
| 476 |
|
| 477 |
return extracted_responses
|
| 478 |
|
| 479 |
+
def _extract_schema_dependencies(
|
| 480 |
+
self,
|
| 481 |
+
schema: dict,
|
| 482 |
+
all_schemas: dict[str, Any],
|
| 483 |
+
collected: set[str] | None = None,
|
| 484 |
+
) -> set[str]:
|
| 485 |
+
"""
|
| 486 |
+
Extract all schema names referenced by a schema (including transitive dependencies).
|
| 487 |
+
|
| 488 |
+
Args:
|
| 489 |
+
schema: The schema to analyze
|
| 490 |
+
all_schemas: All available schema definitions
|
| 491 |
+
collected: Set of already collected schema names (for recursion)
|
| 492 |
+
|
| 493 |
+
Returns:
|
| 494 |
+
Set of schema names that are referenced
|
| 495 |
+
"""
|
| 496 |
+
if collected is None:
|
| 497 |
+
collected = set()
|
| 498 |
+
|
| 499 |
+
def find_refs(obj):
|
| 500 |
+
"""Recursively find all $ref references."""
|
| 501 |
+
if isinstance(obj, dict):
|
| 502 |
+
if "$ref" in obj and isinstance(obj["$ref"], str):
|
| 503 |
+
ref = obj["$ref"]
|
| 504 |
+
# Handle both converted and unconverted refs
|
| 505 |
+
if ref.startswith("#/$defs/"):
|
| 506 |
+
schema_name = ref.split("/")[-1]
|
| 507 |
+
elif ref.startswith("#/components/schemas/"):
|
| 508 |
+
schema_name = ref.split("/")[-1]
|
| 509 |
+
else:
|
| 510 |
+
return
|
| 511 |
+
|
| 512 |
+
# Add this schema and recursively find its dependencies
|
| 513 |
+
if schema_name not in collected and schema_name in all_schemas:
|
| 514 |
+
collected.add(schema_name)
|
| 515 |
+
# Recursively find dependencies of this schema
|
| 516 |
+
find_refs(all_schemas[schema_name])
|
| 517 |
+
|
| 518 |
+
# Continue searching in all values
|
| 519 |
+
for value in obj.values():
|
| 520 |
+
find_refs(value)
|
| 521 |
+
elif isinstance(obj, list):
|
| 522 |
+
for item in obj:
|
| 523 |
+
find_refs(item)
|
| 524 |
+
|
| 525 |
+
find_refs(schema)
|
| 526 |
+
return collected
|
| 527 |
+
|
| 528 |
+
def _extract_route_schema_dependencies(
|
| 529 |
+
self,
|
| 530 |
+
parameters: list[ParameterInfo],
|
| 531 |
+
request_body: RequestBodyInfo | None,
|
| 532 |
+
responses: dict[str, ResponseInfo],
|
| 533 |
+
all_schemas: dict[str, Any],
|
| 534 |
+
) -> dict[str, Any]:
|
| 535 |
+
"""
|
| 536 |
+
Extract only the schema definitions needed for a specific route.
|
| 537 |
+
|
| 538 |
+
Args:
|
| 539 |
+
parameters: Route parameters
|
| 540 |
+
request_body: Route request body
|
| 541 |
+
responses: Route responses
|
| 542 |
+
all_schemas: All available schema definitions
|
| 543 |
+
|
| 544 |
+
Returns:
|
| 545 |
+
Dictionary containing only the schemas needed for this route
|
| 546 |
+
"""
|
| 547 |
+
needed_schemas = set()
|
| 548 |
+
|
| 549 |
+
# Check parameters for schema references
|
| 550 |
+
for param in parameters:
|
| 551 |
+
if param.schema_:
|
| 552 |
+
deps = self._extract_schema_dependencies(param.schema_, all_schemas)
|
| 553 |
+
needed_schemas.update(deps)
|
| 554 |
+
|
| 555 |
+
# Check request body for schema references
|
| 556 |
+
if request_body and request_body.content_schema:
|
| 557 |
+
for content_schema in request_body.content_schema.values():
|
| 558 |
+
deps = self._extract_schema_dependencies(content_schema, all_schemas)
|
| 559 |
+
needed_schemas.update(deps)
|
| 560 |
+
|
| 561 |
+
# Check responses for schema references
|
| 562 |
+
for response in responses.values():
|
| 563 |
+
if response.content_schema:
|
| 564 |
+
for content_schema in response.content_schema.values():
|
| 565 |
+
deps = self._extract_schema_dependencies(
|
| 566 |
+
content_schema, all_schemas
|
| 567 |
+
)
|
| 568 |
+
needed_schemas.update(deps)
|
| 569 |
+
|
| 570 |
+
# Return only the needed schemas
|
| 571 |
+
return {
|
| 572 |
+
name: all_schemas[name] for name in needed_schemas if name in all_schemas
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
def parse(self) -> list[HTTPRoute]:
|
| 576 |
"""Parse the OpenAPI schema into HTTP routes."""
|
| 577 |
routes: list[HTTPRoute] = []
|
|
|
|
| 601 |
f"Failed to extract schema definition '{name}': {e}"
|
| 602 |
)
|
| 603 |
|
| 604 |
+
# Convert schema definitions refs from OpenAPI to JSON Schema format (once)
|
| 605 |
+
if schema_definitions:
|
| 606 |
+
# Convert each schema definition recursively
|
| 607 |
+
for name, schema in schema_definitions.items():
|
| 608 |
+
if isinstance(schema, dict):
|
| 609 |
+
schema_definitions[name] = _replace_ref_with_defs_recursive(schema)
|
| 610 |
+
|
| 611 |
# Process paths and operations
|
| 612 |
for path_str, path_item_obj in self.openapi.paths.items():
|
| 613 |
if not isinstance(path_item_obj, self.path_item_cls):
|
|
|
|
| 661 |
if k.startswith("x-")
|
| 662 |
}
|
| 663 |
|
| 664 |
+
# Extract only the schemas needed for this route
|
| 665 |
+
route_schemas = self._extract_route_schema_dependencies(
|
| 666 |
+
parameters,
|
| 667 |
+
request_body_info,
|
| 668 |
+
responses,
|
| 669 |
+
schema_definitions,
|
| 670 |
+
)
|
| 671 |
+
|
| 672 |
# Create initial route without pre-calculated fields
|
| 673 |
route = HTTPRoute(
|
| 674 |
path=path_str,
|
|
|
|
| 680 |
parameters=parameters,
|
| 681 |
request_body=request_body_info,
|
| 682 |
responses=responses,
|
| 683 |
+
schema_definitions=route_schemas, # Use pre-pruned schemas
|
| 684 |
extensions=extensions,
|
| 685 |
openapi_version=self.openapi_version,
|
| 686 |
)
|
|
|
|
| 688 |
# Pre-calculate schema and parameter mapping for performance
|
| 689 |
try:
|
| 690 |
flat_schema, param_map = _combine_schemas_and_map_params(
|
| 691 |
+
route,
|
| 692 |
+
convert_refs=False, # Parser already converted refs
|
| 693 |
)
|
| 694 |
route.flat_param_schema = flat_schema
|
| 695 |
route.parameter_map = param_map
|
|
|
|
| 704 |
}
|
| 705 |
route.parameter_map = {}
|
| 706 |
routes.append(route)
|
|
|
|
|
|
|
|
|
|
| 707 |
except ValueError as op_error:
|
| 708 |
# Re-raise ValueError for external reference errors
|
| 709 |
if "External or non-local reference not supported" in str(
|
|
|
|
| 722 |
exc_info=True,
|
| 723 |
)
|
| 724 |
|
| 725 |
+
logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
|
| 726 |
return routes
|
| 727 |
|
| 728 |
|
src/fastmcp/experimental/utilities/openapi/schemas.py
CHANGED
|
@@ -71,11 +71,11 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
|
|
| 71 |
return cleaned
|
| 72 |
|
| 73 |
|
| 74 |
-
def
|
| 75 |
info: dict[str, Any], description: str | None = None
|
| 76 |
) -> dict[str, Any]:
|
| 77 |
"""
|
| 78 |
-
Replace openapi $ref with jsonschema $defs
|
| 79 |
|
| 80 |
Examples:
|
| 81 |
- {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
|
|
@@ -106,22 +106,35 @@ def _replace_ref_with_defs(
|
|
| 106 |
)
|
| 107 |
elif properties := schema.get("properties"):
|
| 108 |
if "$ref" in properties:
|
| 109 |
-
schema["properties"] =
|
| 110 |
else:
|
| 111 |
schema["properties"] = {
|
| 112 |
-
prop_name:
|
| 113 |
for prop_name, prop_schema in properties.items()
|
| 114 |
}
|
| 115 |
elif item_schema := schema.get("items"):
|
| 116 |
-
schema["items"] =
|
| 117 |
for section in ["anyOf", "allOf", "oneOf"]:
|
| 118 |
for i, item in enumerate(schema.get(section, [])):
|
| 119 |
-
schema[section][i] =
|
| 120 |
if info.get("description", description) and not schema.get("description"):
|
| 121 |
schema["description"] = description
|
| 122 |
return schema
|
| 123 |
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
| 126 |
"""
|
| 127 |
Make an optional parameter schema nullable to allow None values.
|
|
@@ -202,6 +215,7 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
| 202 |
|
| 203 |
def _combine_schemas_and_map_params(
|
| 204 |
route: HTTPRoute,
|
|
|
|
| 205 |
) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
|
| 206 |
"""
|
| 207 |
Combines parameter and request body schemas into a single schema.
|
|
@@ -233,10 +247,17 @@ def _combine_schemas_and_map_params(
|
|
| 233 |
|
| 234 |
if route.request_body and route.request_body.content_schema:
|
| 235 |
content_type = next(iter(route.request_body.content_schema))
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
# Handle allOf at the top level by merging all schemas
|
| 242 |
if "allOf" in body_schema and isinstance(body_schema["allOf"], list):
|
|
@@ -287,10 +308,11 @@ def _combine_schemas_and_map_params(
|
|
| 287 |
"openapi_name": param.name,
|
| 288 |
}
|
| 289 |
|
| 290 |
-
#
|
| 291 |
-
|
| 292 |
-
param.schema_
|
| 293 |
-
|
|
|
|
| 294 |
original_desc = param_schema.get("description", "")
|
| 295 |
location_desc = f"({param.location.capitalize()} parameter)"
|
| 296 |
if original_desc:
|
|
@@ -313,9 +335,11 @@ def _combine_schemas_and_map_params(
|
|
| 313 |
"openapi_name": param.name,
|
| 314 |
}
|
| 315 |
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
| 319 |
|
| 320 |
# Don't make optional parameters nullable - they can simply be omitted
|
| 321 |
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
@@ -324,14 +348,28 @@ def _combine_schemas_and_map_params(
|
|
| 324 |
|
| 325 |
# Add request body properties (no suffixes for body parameters)
|
| 326 |
if route.request_body and route.request_body.content_schema:
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
|
| 330 |
-
|
| 331 |
-
|
|
|
|
|
|
|
|
|
|
| 332 |
|
| 333 |
-
|
| 334 |
-
|
| 335 |
|
| 336 |
result = {
|
| 337 |
"type": "object",
|
|
@@ -340,42 +378,51 @@ def _combine_schemas_and_map_params(
|
|
| 340 |
}
|
| 341 |
# Add schema definitions if available
|
| 342 |
if route.schema_definitions:
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
if ref
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
else:
|
| 378 |
-
|
|
|
|
| 379 |
|
| 380 |
return result, parameter_map
|
| 381 |
|
|
@@ -466,17 +513,17 @@ def extract_output_schema_from_responses(
|
|
| 466 |
if not schema or not isinstance(schema, dict):
|
| 467 |
return None
|
| 468 |
|
| 469 |
-
#
|
| 470 |
-
output_schema =
|
| 471 |
|
| 472 |
# If schema has a $ref, resolve it first before processing nullable fields
|
| 473 |
if "$ref" in output_schema and schema_definitions:
|
| 474 |
ref_path = output_schema["$ref"]
|
| 475 |
-
if ref_path.startswith("#/
|
| 476 |
schema_name = ref_path.split("/")[-1]
|
| 477 |
if schema_name in schema_definitions:
|
| 478 |
# Replace $ref with the actual schema definition
|
| 479 |
-
output_schema = schema_definitions[schema_name]
|
| 480 |
|
| 481 |
# Convert OpenAPI schema to JSON Schema format
|
| 482 |
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
@@ -499,56 +546,25 @@ def extract_output_schema_from_responses(
|
|
| 499 |
}
|
| 500 |
output_schema = wrapped_schema
|
| 501 |
|
| 502 |
-
# Add schema definitions if available
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
processed_defs =
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
if
|
| 509 |
-
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
processed_defs[def_name] = convert_openapi_schema_to_json_schema(
|
| 512 |
-
|
| 513 |
)
|
| 514 |
-
else:
|
| 515 |
-
processed_defs[def_name] = def_schema
|
| 516 |
-
output_schema["$defs"] = processed_defs
|
| 517 |
|
| 518 |
-
|
| 519 |
-
if output_schema.get("additionalProperties") is False:
|
| 520 |
-
output_schema.pop("additionalProperties")
|
| 521 |
-
|
| 522 |
-
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
| 523 |
-
if "$defs" in output_schema:
|
| 524 |
-
used_refs = set()
|
| 525 |
-
|
| 526 |
-
def find_refs_in_value(value):
|
| 527 |
-
if isinstance(value, dict):
|
| 528 |
-
if "$ref" in value and isinstance(value["$ref"], str):
|
| 529 |
-
ref = value["$ref"]
|
| 530 |
-
if ref.startswith("#/$defs/"):
|
| 531 |
-
used_refs.add(ref.split("/")[-1])
|
| 532 |
-
for v in value.values():
|
| 533 |
-
find_refs_in_value(v)
|
| 534 |
-
elif isinstance(value, list):
|
| 535 |
-
for item in value:
|
| 536 |
-
find_refs_in_value(item)
|
| 537 |
-
|
| 538 |
-
# Find refs in the main schema (excluding $defs section)
|
| 539 |
-
for key, value in output_schema.items():
|
| 540 |
-
if key != "$defs":
|
| 541 |
-
find_refs_in_value(value)
|
| 542 |
-
|
| 543 |
-
# Remove unused definitions
|
| 544 |
-
if used_refs:
|
| 545 |
-
output_schema["$defs"] = {
|
| 546 |
-
name: def_schema
|
| 547 |
-
for name, def_schema in output_schema["$defs"].items()
|
| 548 |
-
if name in used_refs
|
| 549 |
-
}
|
| 550 |
-
else:
|
| 551 |
-
output_schema.pop("$defs")
|
| 552 |
|
| 553 |
return output_schema
|
| 554 |
|
|
@@ -559,6 +575,5 @@ __all__ = [
|
|
| 559 |
"_combine_schemas",
|
| 560 |
"_combine_schemas_and_map_params",
|
| 561 |
"extract_output_schema_from_responses",
|
| 562 |
-
"_replace_ref_with_defs",
|
| 563 |
"_make_optional_parameter_nullable",
|
| 564 |
]
|
|
|
|
| 71 |
return cleaned
|
| 72 |
|
| 73 |
|
| 74 |
+
def _replace_ref_with_defs_recursive(
|
| 75 |
info: dict[str, Any], description: str | None = None
|
| 76 |
) -> dict[str, Any]:
|
| 77 |
"""
|
| 78 |
+
Replace openapi $ref with jsonschema $defs recursively.
|
| 79 |
|
| 80 |
Examples:
|
| 81 |
- {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
|
|
|
|
| 106 |
)
|
| 107 |
elif properties := schema.get("properties"):
|
| 108 |
if "$ref" in properties:
|
| 109 |
+
schema["properties"] = _replace_ref_with_defs_recursive(properties)
|
| 110 |
else:
|
| 111 |
schema["properties"] = {
|
| 112 |
+
prop_name: _replace_ref_with_defs_recursive(prop_schema)
|
| 113 |
for prop_name, prop_schema in properties.items()
|
| 114 |
}
|
| 115 |
elif item_schema := schema.get("items"):
|
| 116 |
+
schema["items"] = _replace_ref_with_defs_recursive(item_schema)
|
| 117 |
for section in ["anyOf", "allOf", "oneOf"]:
|
| 118 |
for i, item in enumerate(schema.get(section, [])):
|
| 119 |
+
schema[section][i] = _replace_ref_with_defs_recursive(item)
|
| 120 |
if info.get("description", description) and not schema.get("description"):
|
| 121 |
schema["description"] = description
|
| 122 |
return schema
|
| 123 |
|
| 124 |
|
| 125 |
+
def _ensure_refs_converted(schema: dict[str, Any]) -> dict[str, Any]:
|
| 126 |
+
"""
|
| 127 |
+
Ensure all OpenAPI refs are converted to JSON Schema format using recursive approach.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
schema: Schema that may contain OpenAPI refs
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
Schema with all refs converted to JSON Schema format
|
| 134 |
+
"""
|
| 135 |
+
return _replace_ref_with_defs_recursive(schema)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
| 139 |
"""
|
| 140 |
Make an optional parameter schema nullable to allow None values.
|
|
|
|
| 215 |
|
| 216 |
def _combine_schemas_and_map_params(
|
| 217 |
route: HTTPRoute,
|
| 218 |
+
convert_refs: bool = True,
|
| 219 |
) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
|
| 220 |
"""
|
| 221 |
Combines parameter and request body schemas into a single schema.
|
|
|
|
| 247 |
|
| 248 |
if route.request_body and route.request_body.content_schema:
|
| 249 |
content_type = next(iter(route.request_body.content_schema))
|
| 250 |
+
|
| 251 |
+
# Convert refs if needed
|
| 252 |
+
if convert_refs:
|
| 253 |
+
body_schema = _ensure_refs_converted(
|
| 254 |
+
route.request_body.content_schema[content_type]
|
| 255 |
+
)
|
| 256 |
+
else:
|
| 257 |
+
body_schema = route.request_body.content_schema[content_type]
|
| 258 |
+
|
| 259 |
+
if route.request_body.description and not body_schema.get("description"):
|
| 260 |
+
body_schema["description"] = route.request_body.description
|
| 261 |
|
| 262 |
# Handle allOf at the top level by merging all schemas
|
| 263 |
if "allOf" in body_schema and isinstance(body_schema["allOf"], list):
|
|
|
|
| 308 |
"openapi_name": param.name,
|
| 309 |
}
|
| 310 |
|
| 311 |
+
# Convert refs if needed
|
| 312 |
+
if convert_refs:
|
| 313 |
+
param_schema = _ensure_refs_converted(param.schema_)
|
| 314 |
+
else:
|
| 315 |
+
param_schema = param.schema_
|
| 316 |
original_desc = param_schema.get("description", "")
|
| 317 |
location_desc = f"({param.location.capitalize()} parameter)"
|
| 318 |
if original_desc:
|
|
|
|
| 335 |
"openapi_name": param.name,
|
| 336 |
}
|
| 337 |
|
| 338 |
+
# Convert refs if needed
|
| 339 |
+
if convert_refs:
|
| 340 |
+
param_schema = _ensure_refs_converted(param.schema_)
|
| 341 |
+
else:
|
| 342 |
+
param_schema = param.schema_
|
| 343 |
|
| 344 |
# Don't make optional parameters nullable - they can simply be omitted
|
| 345 |
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
|
|
| 348 |
|
| 349 |
# Add request body properties (no suffixes for body parameters)
|
| 350 |
if route.request_body and route.request_body.content_schema:
|
| 351 |
+
# If body is just a $ref, we need to handle it differently
|
| 352 |
+
if "$ref" in body_schema and not body_props:
|
| 353 |
+
# The entire body is a reference to a schema
|
| 354 |
+
# We need to expand this inline or keep the ref
|
| 355 |
+
# For simplicity, we'll keep it as a single property
|
| 356 |
+
properties["body"] = body_schema
|
| 357 |
+
if route.request_body.required:
|
| 358 |
+
required.append("body")
|
| 359 |
+
parameter_map["body"] = {"location": "body", "openapi_name": "body"}
|
| 360 |
+
else:
|
| 361 |
+
# Normal case: body has properties
|
| 362 |
+
for prop_name, prop_schema in body_props.items():
|
| 363 |
+
properties[prop_name] = prop_schema
|
| 364 |
|
| 365 |
+
# Track parameter mapping for body properties
|
| 366 |
+
parameter_map[prop_name] = {
|
| 367 |
+
"location": "body",
|
| 368 |
+
"openapi_name": prop_name,
|
| 369 |
+
}
|
| 370 |
|
| 371 |
+
if route.request_body.required:
|
| 372 |
+
required.extend(body_schema.get("required", []))
|
| 373 |
|
| 374 |
result = {
|
| 375 |
"type": "object",
|
|
|
|
| 378 |
}
|
| 379 |
# Add schema definitions if available
|
| 380 |
if route.schema_definitions:
|
| 381 |
+
if convert_refs:
|
| 382 |
+
# Need to convert refs and prune
|
| 383 |
+
all_defs = route.schema_definitions.copy()
|
| 384 |
+
# Convert each schema definition recursively
|
| 385 |
+
for name, schema in all_defs.items():
|
| 386 |
+
if isinstance(schema, dict):
|
| 387 |
+
all_defs[name] = _replace_ref_with_defs_recursive(schema)
|
| 388 |
+
|
| 389 |
+
# Prune to only needed schemas
|
| 390 |
+
used_refs = set()
|
| 391 |
+
|
| 392 |
+
def find_refs_in_value(value):
|
| 393 |
+
"""Recursively find all $ref references."""
|
| 394 |
+
if isinstance(value, dict):
|
| 395 |
+
if "$ref" in value and isinstance(value["$ref"], str):
|
| 396 |
+
ref = value["$ref"]
|
| 397 |
+
if ref.startswith("#/$defs/"):
|
| 398 |
+
used_refs.add(ref.split("/")[-1])
|
| 399 |
+
for v in value.values():
|
| 400 |
+
find_refs_in_value(v)
|
| 401 |
+
elif isinstance(value, list):
|
| 402 |
+
for item in value:
|
| 403 |
+
find_refs_in_value(item)
|
| 404 |
+
|
| 405 |
+
# Find refs in properties
|
| 406 |
+
find_refs_in_value(properties)
|
| 407 |
+
|
| 408 |
+
# Collect transitive dependencies
|
| 409 |
+
if used_refs:
|
| 410 |
+
collected_all = False
|
| 411 |
+
while not collected_all:
|
| 412 |
+
initial_count = len(used_refs)
|
| 413 |
+
for name in list(used_refs):
|
| 414 |
+
if name in all_defs:
|
| 415 |
+
find_refs_in_value(all_defs[name])
|
| 416 |
+
collected_all = len(used_refs) == initial_count
|
| 417 |
+
|
| 418 |
+
result["$defs"] = {
|
| 419 |
+
name: def_schema
|
| 420 |
+
for name, def_schema in all_defs.items()
|
| 421 |
+
if name in used_refs
|
| 422 |
+
}
|
| 423 |
else:
|
| 424 |
+
# From parser - already converted and pruned
|
| 425 |
+
result["$defs"] = route.schema_definitions
|
| 426 |
|
| 427 |
return result, parameter_map
|
| 428 |
|
|
|
|
| 513 |
if not schema or not isinstance(schema, dict):
|
| 514 |
return None
|
| 515 |
|
| 516 |
+
# Convert refs if needed
|
| 517 |
+
output_schema = _ensure_refs_converted(schema)
|
| 518 |
|
| 519 |
# If schema has a $ref, resolve it first before processing nullable fields
|
| 520 |
if "$ref" in output_schema and schema_definitions:
|
| 521 |
ref_path = output_schema["$ref"]
|
| 522 |
+
if ref_path.startswith("#/$defs/"):
|
| 523 |
schema_name = ref_path.split("/")[-1]
|
| 524 |
if schema_name in schema_definitions:
|
| 525 |
# Replace $ref with the actual schema definition
|
| 526 |
+
output_schema = _ensure_refs_converted(schema_definitions[schema_name])
|
| 527 |
|
| 528 |
# Convert OpenAPI schema to JSON Schema format
|
| 529 |
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
|
|
| 546 |
}
|
| 547 |
output_schema = wrapped_schema
|
| 548 |
|
| 549 |
+
# Add schema definitions if available
|
| 550 |
+
if schema_definitions:
|
| 551 |
+
# Convert refs if needed
|
| 552 |
+
processed_defs = schema_definitions.copy()
|
| 553 |
+
# Convert each schema definition recursively
|
| 554 |
+
for name, schema in processed_defs.items():
|
| 555 |
+
if isinstance(schema, dict):
|
| 556 |
+
processed_defs[name] = _replace_ref_with_defs_recursive(schema)
|
| 557 |
+
|
| 558 |
+
# Convert OpenAPI schema definitions to JSON Schema format if needed
|
| 559 |
+
if openapi_version and openapi_version.startswith("3.0"):
|
| 560 |
+
from .json_schema_converter import convert_openapi_schema_to_json_schema
|
| 561 |
+
|
| 562 |
+
for def_name in list(processed_defs.keys()):
|
| 563 |
processed_defs[def_name] = convert_openapi_schema_to_json_schema(
|
| 564 |
+
processed_defs[def_name], openapi_version
|
| 565 |
)
|
|
|
|
|
|
|
|
|
|
| 566 |
|
| 567 |
+
output_schema["$defs"] = processed_defs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
return output_schema
|
| 570 |
|
|
|
|
| 575 |
"_combine_schemas",
|
| 576 |
"_combine_schemas_and_map_params",
|
| 577 |
"extract_output_schema_from_responses",
|
|
|
|
| 578 |
"_make_optional_parameter_nullable",
|
| 579 |
]
|
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -212,7 +212,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 212 |
if openapi_version.startswith("3.0"):
|
| 213 |
# Use OpenAPI 3.0 models
|
| 214 |
openapi_30 = OpenAPI_30.model_validate(openapi_dict)
|
| 215 |
-
logger.
|
| 216 |
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
|
| 217 |
)
|
| 218 |
parser = OpenAPIParser(
|
|
@@ -230,7 +230,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 230 |
else:
|
| 231 |
# Default to OpenAPI 3.1 models
|
| 232 |
openapi_31 = OpenAPI.model_validate(openapi_dict)
|
| 233 |
-
logger.
|
| 234 |
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
|
| 235 |
)
|
| 236 |
parser = OpenAPIParser(
|
|
@@ -713,7 +713,7 @@ class OpenAPIParser(
|
|
| 713 |
openapi_version=self.openapi_version,
|
| 714 |
)
|
| 715 |
routes.append(route)
|
| 716 |
-
logger.
|
| 717 |
f"Successfully extracted route: {method_upper} {path_str}"
|
| 718 |
)
|
| 719 |
except ValueError as op_error:
|
|
@@ -734,7 +734,7 @@ class OpenAPIParser(
|
|
| 734 |
exc_info=True,
|
| 735 |
)
|
| 736 |
|
| 737 |
-
logger.
|
| 738 |
return routes
|
| 739 |
|
| 740 |
|
|
|
|
| 212 |
if openapi_version.startswith("3.0"):
|
| 213 |
# Use OpenAPI 3.0 models
|
| 214 |
openapi_30 = OpenAPI_30.model_validate(openapi_dict)
|
| 215 |
+
logger.debug(
|
| 216 |
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
|
| 217 |
)
|
| 218 |
parser = OpenAPIParser(
|
|
|
|
| 230 |
else:
|
| 231 |
# Default to OpenAPI 3.1 models
|
| 232 |
openapi_31 = OpenAPI.model_validate(openapi_dict)
|
| 233 |
+
logger.debug(
|
| 234 |
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
|
| 235 |
)
|
| 236 |
parser = OpenAPIParser(
|
|
|
|
| 713 |
openapi_version=self.openapi_version,
|
| 714 |
)
|
| 715 |
routes.append(route)
|
| 716 |
+
logger.debug(
|
| 717 |
f"Successfully extracted route: {method_upper} {path_str}"
|
| 718 |
)
|
| 719 |
except ValueError as op_error:
|
|
|
|
| 734 |
exc_info=True,
|
| 735 |
)
|
| 736 |
|
| 737 |
+
logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
|
| 738 |
return routes
|
| 739 |
|
| 740 |
|
tests/experimental/server/conftest.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures for openapi_new utilities tests."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from fastmcp.utilities.tests import temporary_settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest.fixture(autouse=True)
|
| 9 |
+
def use_new_openapi_parser():
|
| 10 |
+
with temporary_settings(experimental__enable_new_openapi_parser=True):
|
| 11 |
+
yield
|
tests/experimental/server/{test_openapi_performance.py → openapi/test_openapi_performance.py}
RENAMED
|
@@ -22,8 +22,8 @@ def use_new_openapi_parser():
|
|
| 22 |
class TestOpenAPIPerformance:
|
| 23 |
"""Performance tests for OpenAPI parsing with real-world large schemas."""
|
| 24 |
|
| 25 |
-
#
|
| 26 |
-
@pytest.mark.timeout(
|
| 27 |
async def test_github_api_schema_performance(self):
|
| 28 |
"""
|
| 29 |
Test that GitHub's full API schema parses quickly.
|
|
|
|
| 22 |
class TestOpenAPIPerformance:
|
| 23 |
"""Performance tests for OpenAPI parsing with real-world large schemas."""
|
| 24 |
|
| 25 |
+
# 10 second maximum timeout for this test no matter what
|
| 26 |
+
@pytest.mark.timeout(10)
|
| 27 |
async def test_github_api_schema_performance(self):
|
| 28 |
"""
|
| 29 |
Test that GitHub's full API schema parses quickly.
|
tests/experimental/server/openapi/test_performance_comparison.py
CHANGED
|
@@ -215,7 +215,7 @@ class TestPerformanceComparison:
|
|
| 215 |
|
| 216 |
# Performance should be comparable (within reasonable margin)
|
| 217 |
performance_ratio = max(new_avg, legacy_avg) / min(new_avg, legacy_avg)
|
| 218 |
-
assert performance_ratio <
|
| 219 |
f"Performance should be comparable, ratio: {performance_ratio:.2f}x"
|
| 220 |
)
|
| 221 |
|
|
@@ -286,6 +286,6 @@ class TestPerformanceComparison:
|
|
| 286 |
current_refs = len(gc.get_objects())
|
| 287 |
# Allow reasonable memory growth but not exponential
|
| 288 |
growth_ratio = current_refs / max(baseline_refs, 1)
|
| 289 |
-
assert growth_ratio <
|
| 290 |
f"Memory usage grew by {growth_ratio}x, which seems excessive"
|
| 291 |
)
|
|
|
|
| 215 |
|
| 216 |
# Performance should be comparable (within reasonable margin)
|
| 217 |
performance_ratio = max(new_avg, legacy_avg) / min(new_avg, legacy_avg)
|
| 218 |
+
assert performance_ratio < 3.0, (
|
| 219 |
f"Performance should be comparable, ratio: {performance_ratio:.2f}x"
|
| 220 |
)
|
| 221 |
|
|
|
|
| 286 |
current_refs = len(gc.get_objects())
|
| 287 |
# Allow reasonable memory growth but not exponential
|
| 288 |
growth_ratio = current_refs / max(baseline_refs, 1)
|
| 289 |
+
assert growth_ratio < 3.0, (
|
| 290 |
f"Memory usage grew by {growth_ratio}x, which seems excessive"
|
| 291 |
)
|
tests/experimental/utilities/openapi/conftest.py
CHANGED
|
@@ -2,6 +2,14 @@
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
@pytest.fixture
|
| 7 |
def basic_openapi_30_spec():
|
|
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
+
from fastmcp.utilities.tests import temporary_settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest.fixture(autouse=True)
|
| 9 |
+
def use_new_openapi_parser():
|
| 10 |
+
with temporary_settings(experimental__enable_new_openapi_parser=True):
|
| 11 |
+
yield
|
| 12 |
+
|
| 13 |
|
| 14 |
@pytest.fixture
|
| 15 |
def basic_openapi_30_spec():
|
tests/experimental/utilities/openapi/test_parser.py
CHANGED
|
@@ -203,6 +203,135 @@ class TestOpenAPIParser:
|
|
| 203 |
assert param.location == "path"
|
| 204 |
assert param.required is True
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def test_parameter_schema_extraction(self, complex_spec):
|
| 207 |
"""Test that parameter schemas are properly extracted."""
|
| 208 |
routes = parse_openapi_to_http_routes(complex_spec)
|
|
|
|
| 203 |
assert param.location == "path"
|
| 204 |
assert param.required is True
|
| 205 |
|
| 206 |
+
def test_parse_simple_transitive_refs(self):
|
| 207 |
+
"""Test that A->B->C transitive references are preserved.
|
| 208 |
+
|
| 209 |
+
When a request body references schema A, which references B, which references C:
|
| 210 |
+
- A is expanded inline (expected optimization)
|
| 211 |
+
- B and C MUST be included in $defs (the bug fix for #1372)
|
| 212 |
+
"""
|
| 213 |
+
spec = {
|
| 214 |
+
"openapi": "3.0.0",
|
| 215 |
+
"info": {"title": "Test", "version": "1.0.0"},
|
| 216 |
+
"components": {
|
| 217 |
+
"schemas": {
|
| 218 |
+
"SchemaA": {
|
| 219 |
+
"type": "object",
|
| 220 |
+
"properties": {
|
| 221 |
+
"refToB": {"$ref": "#/components/schemas/SchemaB"}
|
| 222 |
+
},
|
| 223 |
+
},
|
| 224 |
+
"SchemaB": {
|
| 225 |
+
"type": "object",
|
| 226 |
+
"properties": {
|
| 227 |
+
"refToC": {"$ref": "#/components/schemas/SchemaC"}
|
| 228 |
+
},
|
| 229 |
+
},
|
| 230 |
+
"SchemaC": {
|
| 231 |
+
"type": "string",
|
| 232 |
+
"enum": ["value1", "value2"],
|
| 233 |
+
},
|
| 234 |
+
}
|
| 235 |
+
},
|
| 236 |
+
"paths": {
|
| 237 |
+
"/test": {
|
| 238 |
+
"post": {
|
| 239 |
+
"operationId": "test_op",
|
| 240 |
+
"requestBody": {
|
| 241 |
+
"content": {
|
| 242 |
+
"application/json": {
|
| 243 |
+
"schema": {"$ref": "#/components/schemas/SchemaA"}
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
},
|
| 247 |
+
"responses": {"200": {"description": "OK"}},
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
},
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
routes = parse_openapi_to_http_routes(spec)
|
| 254 |
+
route = routes[0]
|
| 255 |
+
|
| 256 |
+
# SchemaA is expanded inline, so it's NOT in schema_definitions
|
| 257 |
+
assert "SchemaA" not in route.schema_definitions
|
| 258 |
+
|
| 259 |
+
# But SchemaB and SchemaC MUST be there (transitive dependencies)
|
| 260 |
+
assert "SchemaB" in route.schema_definitions
|
| 261 |
+
assert "SchemaC" in route.schema_definitions
|
| 262 |
+
|
| 263 |
+
# Same in the flat parameter schema
|
| 264 |
+
assert "SchemaB" in route.flat_param_schema["$defs"]
|
| 265 |
+
assert "SchemaC" in route.flat_param_schema["$defs"]
|
| 266 |
+
|
| 267 |
+
def test_parse_tspicer_issue_1372(self):
|
| 268 |
+
"""Reproduce the exact bug from issue #1372 (tspicer's report).
|
| 269 |
+
|
| 270 |
+
Issue: Profile -> {countryCode, AccountInfo} transitive refs were missing from $defs.
|
| 271 |
+
"""
|
| 272 |
+
spec = {
|
| 273 |
+
"openapi": "3.0.1",
|
| 274 |
+
"info": {"title": "Test", "version": "1.0.0"},
|
| 275 |
+
"components": {
|
| 276 |
+
"schemas": {
|
| 277 |
+
"Profile": {
|
| 278 |
+
"type": "object",
|
| 279 |
+
"properties": {
|
| 280 |
+
"profileId": {"type": "integer"},
|
| 281 |
+
"countryCode": {"$ref": "#/components/schemas/countryCode"},
|
| 282 |
+
"accountInfo": {"$ref": "#/components/schemas/AccountInfo"},
|
| 283 |
+
},
|
| 284 |
+
},
|
| 285 |
+
"countryCode": {
|
| 286 |
+
"type": "string",
|
| 287 |
+
"enum": ["US", "UK", "CA", "AU"],
|
| 288 |
+
},
|
| 289 |
+
"AccountInfo": {
|
| 290 |
+
"type": "object",
|
| 291 |
+
"properties": {
|
| 292 |
+
"accountId": {"type": "string"},
|
| 293 |
+
"accountType": {"type": "string"},
|
| 294 |
+
},
|
| 295 |
+
},
|
| 296 |
+
}
|
| 297 |
+
},
|
| 298 |
+
"paths": {
|
| 299 |
+
"/profile": {
|
| 300 |
+
"post": {
|
| 301 |
+
"operationId": "create_profile",
|
| 302 |
+
"requestBody": {
|
| 303 |
+
"content": {
|
| 304 |
+
"application/json": {
|
| 305 |
+
"schema": {"$ref": "#/components/schemas/Profile"}
|
| 306 |
+
}
|
| 307 |
+
},
|
| 308 |
+
},
|
| 309 |
+
"responses": {"200": {"description": "OK"}},
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
},
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
routes = parse_openapi_to_http_routes(spec)
|
| 316 |
+
route = routes[0]
|
| 317 |
+
|
| 318 |
+
# Profile is expanded inline, NOT in schema_defs
|
| 319 |
+
assert "Profile" not in route.schema_definitions
|
| 320 |
+
|
| 321 |
+
# Bug fix: countryCode and AccountInfo MUST be in schema_defs
|
| 322 |
+
assert "countryCode" in route.schema_definitions # Was missing in #1372
|
| 323 |
+
assert "AccountInfo" in route.schema_definitions # Was missing in #1372
|
| 324 |
+
|
| 325 |
+
# Same in flat parameter schema
|
| 326 |
+
assert "countryCode" in route.flat_param_schema["$defs"]
|
| 327 |
+
assert "AccountInfo" in route.flat_param_schema["$defs"]
|
| 328 |
+
|
| 329 |
+
# Verify Profile's properties were inlined correctly
|
| 330 |
+
props = route.flat_param_schema["properties"]
|
| 331 |
+
assert "profileId" in props
|
| 332 |
+
assert props["countryCode"]["$ref"] == "#/$defs/countryCode"
|
| 333 |
+
assert props["accountInfo"]["$ref"] == "#/$defs/AccountInfo"
|
| 334 |
+
|
| 335 |
def test_parameter_schema_extraction(self, complex_spec):
|
| 336 |
"""Test that parameter schemas are properly extracted."""
|
| 337 |
routes = parse_openapi_to_http_routes(complex_spec)
|
tests/experimental/utilities/openapi/test_schemas.py
CHANGED
|
@@ -10,8 +10,9 @@ from fastmcp.experimental.utilities.openapi.models import (
|
|
| 10 |
from fastmcp.experimental.utilities.openapi.schemas import (
|
| 11 |
_combine_schemas,
|
| 12 |
_combine_schemas_and_map_params,
|
| 13 |
-
|
| 14 |
)
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class TestSchemaProcessing:
|
|
@@ -230,6 +231,7 @@ class TestSchemaProcessing:
|
|
| 230 |
|
| 231 |
def test_replace_ref_with_defs(self):
|
| 232 |
"""Test replacing $ref with $defs for JSON Schema compatibility."""
|
|
|
|
| 233 |
schema_with_ref = {
|
| 234 |
"type": "object",
|
| 235 |
"properties": {
|
|
@@ -241,13 +243,15 @@ class TestSchemaProcessing:
|
|
| 241 |
},
|
| 242 |
}
|
| 243 |
|
| 244 |
-
|
|
|
|
| 245 |
|
| 246 |
assert result["properties"]["user"]["$ref"] == "#/$defs/User"
|
| 247 |
assert result["properties"]["items"]["items"]["$ref"] == "#/$defs/Item"
|
| 248 |
|
| 249 |
def test_replace_ref_with_defs_nested(self):
|
| 250 |
"""Test replacing $ref in deeply nested structures."""
|
|
|
|
| 251 |
nested_schema = {
|
| 252 |
"type": "object",
|
| 253 |
"properties": {
|
|
@@ -269,7 +273,8 @@ class TestSchemaProcessing:
|
|
| 269 |
},
|
| 270 |
}
|
| 271 |
|
| 272 |
-
|
|
|
|
| 273 |
|
| 274 |
# Check nested object property
|
| 275 |
nested_prop = result["properties"]["data"]["properties"]["nested"]
|
|
@@ -476,7 +481,6 @@ class TestEdgeCases:
|
|
| 476 |
|
| 477 |
def test_oneof_reference_preserved(self):
|
| 478 |
"""Test that schemas referenced in oneOf are preserved."""
|
| 479 |
-
from fastmcp.utilities.json_schema import compress_schema
|
| 480 |
|
| 481 |
schema = {
|
| 482 |
"type": "object",
|
|
@@ -497,7 +501,6 @@ class TestEdgeCases:
|
|
| 497 |
|
| 498 |
def test_anyof_reference_preserved(self):
|
| 499 |
"""Test that schemas referenced in anyOf are preserved."""
|
| 500 |
-
from fastmcp.utilities.json_schema import compress_schema
|
| 501 |
|
| 502 |
schema = {
|
| 503 |
"type": "object",
|
|
@@ -515,7 +518,6 @@ class TestEdgeCases:
|
|
| 515 |
|
| 516 |
def test_allof_reference_preserved(self):
|
| 517 |
"""Test that schemas referenced in allOf are preserved."""
|
| 518 |
-
from fastmcp.utilities.json_schema import compress_schema
|
| 519 |
|
| 520 |
schema = {
|
| 521 |
"type": "object",
|
|
|
|
| 10 |
from fastmcp.experimental.utilities.openapi.schemas import (
|
| 11 |
_combine_schemas,
|
| 12 |
_combine_schemas_and_map_params,
|
| 13 |
+
_replace_ref_with_defs_recursive,
|
| 14 |
)
|
| 15 |
+
from fastmcp.utilities.json_schema import compress_schema
|
| 16 |
|
| 17 |
|
| 18 |
class TestSchemaProcessing:
|
|
|
|
| 231 |
|
| 232 |
def test_replace_ref_with_defs(self):
|
| 233 |
"""Test replacing $ref with $defs for JSON Schema compatibility."""
|
| 234 |
+
|
| 235 |
schema_with_ref = {
|
| 236 |
"type": "object",
|
| 237 |
"properties": {
|
|
|
|
| 243 |
},
|
| 244 |
}
|
| 245 |
|
| 246 |
+
# Use our recursive replacement approach
|
| 247 |
+
result = _replace_ref_with_defs_recursive(schema_with_ref)
|
| 248 |
|
| 249 |
assert result["properties"]["user"]["$ref"] == "#/$defs/User"
|
| 250 |
assert result["properties"]["items"]["items"]["$ref"] == "#/$defs/Item"
|
| 251 |
|
| 252 |
def test_replace_ref_with_defs_nested(self):
|
| 253 |
"""Test replacing $ref in deeply nested structures."""
|
| 254 |
+
|
| 255 |
nested_schema = {
|
| 256 |
"type": "object",
|
| 257 |
"properties": {
|
|
|
|
| 273 |
},
|
| 274 |
}
|
| 275 |
|
| 276 |
+
# Use our recursive replacement approach
|
| 277 |
+
result = _replace_ref_with_defs_recursive(nested_schema)
|
| 278 |
|
| 279 |
# Check nested object property
|
| 280 |
nested_prop = result["properties"]["data"]["properties"]["nested"]
|
|
|
|
| 481 |
|
| 482 |
def test_oneof_reference_preserved(self):
|
| 483 |
"""Test that schemas referenced in oneOf are preserved."""
|
|
|
|
| 484 |
|
| 485 |
schema = {
|
| 486 |
"type": "object",
|
|
|
|
| 501 |
|
| 502 |
def test_anyof_reference_preserved(self):
|
| 503 |
"""Test that schemas referenced in anyOf are preserved."""
|
|
|
|
| 504 |
|
| 505 |
schema = {
|
| 506 |
"type": "object",
|
|
|
|
| 518 |
|
| 519 |
def test_allof_reference_preserved(self):
|
| 520 |
"""Test that schemas referenced in allOf are preserved."""
|
|
|
|
| 521 |
|
| 522 |
schema = {
|
| 523 |
"type": "object",
|
tests/experimental/utilities/openapi/test_transitive_references.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
|
| 2 |
+
|
| 3 |
+
from fastmcp.experimental.utilities.openapi.models import (
|
| 4 |
+
HTTPRoute,
|
| 5 |
+
ParameterInfo,
|
| 6 |
+
RequestBodyInfo,
|
| 7 |
+
ResponseInfo,
|
| 8 |
+
)
|
| 9 |
+
from fastmcp.experimental.utilities.openapi.schemas import (
|
| 10 |
+
_combine_schemas_and_map_params,
|
| 11 |
+
extract_output_schema_from_responses,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TestTransitiveAndNestedReferences:
|
| 16 |
+
"""Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
|
| 17 |
+
|
| 18 |
+
def test_nested_refs_in_schema_definitions_converted(self):
|
| 19 |
+
"""$refs inside schema definitions must be converted from OpenAPI to JSON Schema format."""
|
| 20 |
+
route = HTTPRoute(
|
| 21 |
+
path="/users/{id}",
|
| 22 |
+
method="POST",
|
| 23 |
+
operation_id="create_user",
|
| 24 |
+
parameters=[
|
| 25 |
+
ParameterInfo(
|
| 26 |
+
name="id", location="path", required=True, schema={"type": "string"}
|
| 27 |
+
)
|
| 28 |
+
],
|
| 29 |
+
request_body=RequestBodyInfo(
|
| 30 |
+
required=True,
|
| 31 |
+
content_schema={
|
| 32 |
+
"application/json": {
|
| 33 |
+
"type": "object",
|
| 34 |
+
"properties": {"user": {"$ref": "#/components/schemas/User"}},
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
+
),
|
| 38 |
+
schema_definitions={
|
| 39 |
+
"User": {
|
| 40 |
+
"type": "object",
|
| 41 |
+
"properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
|
| 42 |
+
},
|
| 43 |
+
"Profile": {
|
| 44 |
+
"type": "object",
|
| 45 |
+
"properties": {"name": {"type": "string"}},
|
| 46 |
+
},
|
| 47 |
+
},
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 51 |
+
|
| 52 |
+
# Root level refs should be converted
|
| 53 |
+
assert combined_schema["properties"]["user"]["$ref"] == "#/$defs/User"
|
| 54 |
+
|
| 55 |
+
# Refs inside schema definitions should also be converted
|
| 56 |
+
user_def = combined_schema["$defs"]["User"]
|
| 57 |
+
assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
|
| 58 |
+
|
| 59 |
+
def test_transitive_dependencies_in_response_schemas(self):
|
| 60 |
+
"""Transitive dependencies (A→B→C) must all be preserved in response schemas."""
|
| 61 |
+
# This mimics the exact structure reported in issue #1372
|
| 62 |
+
responses = {
|
| 63 |
+
"201": ResponseInfo(
|
| 64 |
+
description="User created",
|
| 65 |
+
content_schema={
|
| 66 |
+
"application/json": {"$ref": "#/components/schemas/User"}
|
| 67 |
+
},
|
| 68 |
+
)
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
schema_definitions = {
|
| 72 |
+
"User": {
|
| 73 |
+
"type": "object",
|
| 74 |
+
"properties": {
|
| 75 |
+
"id": {"type": "string"},
|
| 76 |
+
"profile": {"$ref": "#/components/schemas/Profile"},
|
| 77 |
+
},
|
| 78 |
+
"required": ["id", "profile"],
|
| 79 |
+
},
|
| 80 |
+
"Profile": {
|
| 81 |
+
"type": "object",
|
| 82 |
+
"properties": {
|
| 83 |
+
"name": {"type": "string"},
|
| 84 |
+
"address": {"$ref": "#/components/schemas/Address"},
|
| 85 |
+
},
|
| 86 |
+
"required": ["name", "address"],
|
| 87 |
+
},
|
| 88 |
+
"Address": {
|
| 89 |
+
"type": "object",
|
| 90 |
+
"properties": {
|
| 91 |
+
"street": {"type": "string"},
|
| 92 |
+
"city": {"type": "string"},
|
| 93 |
+
"zipcode": {"type": "string"},
|
| 94 |
+
},
|
| 95 |
+
"required": ["street", "city", "zipcode"],
|
| 96 |
+
},
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
result = extract_output_schema_from_responses(
|
| 100 |
+
responses, schema_definitions=schema_definitions, openapi_version="3.0.3"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# All transitive dependencies must be preserved
|
| 104 |
+
assert result is not None
|
| 105 |
+
assert "$defs" in result
|
| 106 |
+
assert "User" in result["$defs"], "User should be preserved"
|
| 107 |
+
assert "Profile" in result["$defs"], "Profile should be preserved"
|
| 108 |
+
assert "Address" in result["$defs"], "Address must be preserved (main bug)"
|
| 109 |
+
|
| 110 |
+
# All refs should be converted to #/$defs format
|
| 111 |
+
user_def = result["$defs"]["User"]
|
| 112 |
+
assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
|
| 113 |
+
|
| 114 |
+
profile_def = result["$defs"]["Profile"]
|
| 115 |
+
assert profile_def["properties"]["address"]["$ref"] == "#/$defs/Address"
|
| 116 |
+
|
| 117 |
+
def test_elongl_reported_case_xref_with_nullable_function(self):
|
| 118 |
+
"""Test the specific case reported by elongl with nullable function reference."""
|
| 119 |
+
responses = {
|
| 120 |
+
"200": ResponseInfo(
|
| 121 |
+
description="Success",
|
| 122 |
+
content_schema={
|
| 123 |
+
"application/json": {
|
| 124 |
+
"type": "array",
|
| 125 |
+
"items": {"$ref": "#/components/schemas/Xref"},
|
| 126 |
+
}
|
| 127 |
+
},
|
| 128 |
+
)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
schema_definitions = {
|
| 132 |
+
"Xref": {
|
| 133 |
+
"type": "object",
|
| 134 |
+
"properties": {
|
| 135 |
+
"address": {"type": "string", "title": "Address"},
|
| 136 |
+
"type": {"type": "string", "title": "Type"},
|
| 137 |
+
"function": {
|
| 138 |
+
"anyOf": [
|
| 139 |
+
{"$ref": "#/components/schemas/Function"},
|
| 140 |
+
{"type": "null"},
|
| 141 |
+
]
|
| 142 |
+
},
|
| 143 |
+
},
|
| 144 |
+
"required": ["address", "type", "function"],
|
| 145 |
+
"title": "Xref",
|
| 146 |
+
},
|
| 147 |
+
"Function": {
|
| 148 |
+
"type": "object",
|
| 149 |
+
"properties": {
|
| 150 |
+
"name": {"type": "string"},
|
| 151 |
+
"address": {"type": "string"},
|
| 152 |
+
},
|
| 153 |
+
"title": "Function",
|
| 154 |
+
},
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
result = extract_output_schema_from_responses(
|
| 158 |
+
responses, schema_definitions=schema_definitions
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Function must be included in $defs
|
| 162 |
+
assert result is not None
|
| 163 |
+
assert "$defs" in result
|
| 164 |
+
assert "Xref" in result["$defs"], "Xref should be preserved"
|
| 165 |
+
assert "Function" in result["$defs"], (
|
| 166 |
+
"Function must be preserved (reported bug)"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
# Refs in anyOf should be converted
|
| 170 |
+
xref_def = result["$defs"]["Xref"]
|
| 171 |
+
function_prop = xref_def["properties"]["function"]
|
| 172 |
+
assert function_prop["anyOf"][0]["$ref"] == "#/$defs/Function"
|
| 173 |
+
|
| 174 |
+
def test_tspicer_reported_case_profile_with_nested_refs(self):
|
| 175 |
+
"""Test the specific case reported by tspicer with Profile->countryCode->AccountInfo."""
|
| 176 |
+
route = HTTPRoute(
|
| 177 |
+
path="/profile",
|
| 178 |
+
method="POST",
|
| 179 |
+
operation_id="create_profile",
|
| 180 |
+
request_body=RequestBodyInfo(
|
| 181 |
+
required=True,
|
| 182 |
+
content_schema={
|
| 183 |
+
"application/json": {"$ref": "#/components/schemas/Profile"}
|
| 184 |
+
},
|
| 185 |
+
),
|
| 186 |
+
schema_definitions={
|
| 187 |
+
"Profile": {
|
| 188 |
+
"type": "object",
|
| 189 |
+
"properties": {
|
| 190 |
+
"profileId": {"type": "integer"},
|
| 191 |
+
"countryCode": {"$ref": "#/components/schemas/countryCode"},
|
| 192 |
+
"accountInfo": {"$ref": "#/components/schemas/AccountInfo"},
|
| 193 |
+
},
|
| 194 |
+
},
|
| 195 |
+
"countryCode": {
|
| 196 |
+
"type": "string",
|
| 197 |
+
"enum": ["US", "UK", "CA", "AU"],
|
| 198 |
+
},
|
| 199 |
+
"AccountInfo": {
|
| 200 |
+
"type": "object",
|
| 201 |
+
"properties": {
|
| 202 |
+
"accountId": {"type": "string"},
|
| 203 |
+
"accountType": {"type": "string"},
|
| 204 |
+
},
|
| 205 |
+
},
|
| 206 |
+
},
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 210 |
+
|
| 211 |
+
# All referenced schemas must be included in $defs
|
| 212 |
+
assert "Profile" in combined_schema["$defs"], "Profile should be preserved"
|
| 213 |
+
assert "countryCode" in combined_schema["$defs"], (
|
| 214 |
+
"countryCode must be preserved"
|
| 215 |
+
)
|
| 216 |
+
assert "AccountInfo" in combined_schema["$defs"], (
|
| 217 |
+
"AccountInfo must be preserved"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# All refs should be converted
|
| 221 |
+
profile_def = combined_schema["$defs"]["Profile"]
|
| 222 |
+
assert profile_def["properties"]["countryCode"]["$ref"] == "#/$defs/countryCode"
|
| 223 |
+
assert profile_def["properties"]["accountInfo"]["$ref"] == "#/$defs/AccountInfo"
|
| 224 |
+
|
| 225 |
+
def test_transitive_refs_in_request_body_schemas(self):
|
| 226 |
+
"""Transitive $refs in request body schemas must be preserved and converted."""
|
| 227 |
+
route = HTTPRoute(
|
| 228 |
+
path="/users",
|
| 229 |
+
method="POST",
|
| 230 |
+
operation_id="create_user",
|
| 231 |
+
request_body=RequestBodyInfo(
|
| 232 |
+
required=True,
|
| 233 |
+
content_schema={
|
| 234 |
+
"application/json": {"$ref": "#/components/schemas/User"}
|
| 235 |
+
},
|
| 236 |
+
),
|
| 237 |
+
schema_definitions={
|
| 238 |
+
"User": {
|
| 239 |
+
"type": "object",
|
| 240 |
+
"properties": {
|
| 241 |
+
"id": {"type": "string"},
|
| 242 |
+
"profile": {"$ref": "#/components/schemas/Profile"},
|
| 243 |
+
},
|
| 244 |
+
"required": ["id", "profile"],
|
| 245 |
+
},
|
| 246 |
+
"Profile": {
|
| 247 |
+
"type": "object",
|
| 248 |
+
"properties": {
|
| 249 |
+
"name": {"type": "string"},
|
| 250 |
+
"address": {"$ref": "#/components/schemas/Address"},
|
| 251 |
+
},
|
| 252 |
+
"required": ["name", "address"],
|
| 253 |
+
},
|
| 254 |
+
"Address": {
|
| 255 |
+
"type": "object",
|
| 256 |
+
"properties": {
|
| 257 |
+
"street": {"type": "string"},
|
| 258 |
+
"city": {"type": "string"},
|
| 259 |
+
"zipcode": {"type": "string"},
|
| 260 |
+
},
|
| 261 |
+
"required": ["street", "city", "zipcode"],
|
| 262 |
+
},
|
| 263 |
+
},
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 267 |
+
|
| 268 |
+
# All transitive dependencies should be preserved
|
| 269 |
+
assert "User" in combined_schema["$defs"]
|
| 270 |
+
assert "Profile" in combined_schema["$defs"]
|
| 271 |
+
assert "Address" in combined_schema["$defs"]
|
| 272 |
+
|
| 273 |
+
# All internal refs should be converted to #/$defs format
|
| 274 |
+
user_def = combined_schema["$defs"]["User"]
|
| 275 |
+
assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
|
| 276 |
+
|
| 277 |
+
profile_def = combined_schema["$defs"]["Profile"]
|
| 278 |
+
assert profile_def["properties"]["address"]["$ref"] == "#/$defs/Address"
|
| 279 |
+
|
| 280 |
+
def test_refs_in_array_items_converted(self):
|
| 281 |
+
"""$refs inside array items must be converted from OpenAPI to JSON Schema format."""
|
| 282 |
+
route = HTTPRoute(
|
| 283 |
+
path="/users",
|
| 284 |
+
method="POST",
|
| 285 |
+
operation_id="create_users",
|
| 286 |
+
request_body=RequestBodyInfo(
|
| 287 |
+
required=True,
|
| 288 |
+
content_schema={
|
| 289 |
+
"application/json": {
|
| 290 |
+
"type": "object",
|
| 291 |
+
"properties": {
|
| 292 |
+
"users": {
|
| 293 |
+
"type": "array",
|
| 294 |
+
"items": {"$ref": "#/components/schemas/User"},
|
| 295 |
+
}
|
| 296 |
+
},
|
| 297 |
+
}
|
| 298 |
+
},
|
| 299 |
+
),
|
| 300 |
+
schema_definitions={
|
| 301 |
+
"User": {
|
| 302 |
+
"type": "object",
|
| 303 |
+
"properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
|
| 304 |
+
},
|
| 305 |
+
"Profile": {
|
| 306 |
+
"type": "object",
|
| 307 |
+
"properties": {"name": {"type": "string"}},
|
| 308 |
+
},
|
| 309 |
+
},
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 313 |
+
|
| 314 |
+
# Array item refs should be converted
|
| 315 |
+
assert combined_schema["properties"]["users"]["items"]["$ref"] == "#/$defs/User"
|
| 316 |
+
|
| 317 |
+
# Nested refs should be converted
|
| 318 |
+
user_def = combined_schema["$defs"]["User"]
|
| 319 |
+
assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
|
| 320 |
+
|
| 321 |
+
def test_refs_in_composition_keywords_converted(self):
|
| 322 |
+
"""$refs inside oneOf/anyOf/allOf must be converted from OpenAPI to JSON Schema format."""
|
| 323 |
+
route = HTTPRoute(
|
| 324 |
+
path="/data",
|
| 325 |
+
method="POST",
|
| 326 |
+
operation_id="create_data",
|
| 327 |
+
request_body=RequestBodyInfo(
|
| 328 |
+
required=True,
|
| 329 |
+
content_schema={
|
| 330 |
+
"application/json": {
|
| 331 |
+
"type": "object",
|
| 332 |
+
"properties": {
|
| 333 |
+
"data": {
|
| 334 |
+
"oneOf": [
|
| 335 |
+
{"$ref": "#/components/schemas/TypeA"},
|
| 336 |
+
{"$ref": "#/components/schemas/TypeB"},
|
| 337 |
+
]
|
| 338 |
+
},
|
| 339 |
+
"alternate": {
|
| 340 |
+
"anyOf": [
|
| 341 |
+
{"$ref": "#/components/schemas/TypeC"},
|
| 342 |
+
{"$ref": "#/components/schemas/TypeD"},
|
| 343 |
+
]
|
| 344 |
+
},
|
| 345 |
+
"combined": {
|
| 346 |
+
"allOf": [
|
| 347 |
+
{"$ref": "#/components/schemas/BaseType"},
|
| 348 |
+
{"properties": {"extra": {"type": "string"}}},
|
| 349 |
+
]
|
| 350 |
+
},
|
| 351 |
+
},
|
| 352 |
+
}
|
| 353 |
+
},
|
| 354 |
+
),
|
| 355 |
+
schema_definitions={
|
| 356 |
+
"TypeA": {
|
| 357 |
+
"type": "object",
|
| 358 |
+
"properties": {"nested": {"$ref": "#/components/schemas/Nested"}},
|
| 359 |
+
},
|
| 360 |
+
"TypeB": {
|
| 361 |
+
"type": "object",
|
| 362 |
+
"properties": {"value": {"type": "string"}},
|
| 363 |
+
},
|
| 364 |
+
"TypeC": {"type": "string"},
|
| 365 |
+
"TypeD": {"type": "number"},
|
| 366 |
+
"BaseType": {
|
| 367 |
+
"type": "object",
|
| 368 |
+
"properties": {"base": {"type": "string"}},
|
| 369 |
+
},
|
| 370 |
+
"Nested": {"type": "string"},
|
| 371 |
+
},
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 375 |
+
|
| 376 |
+
# oneOf refs should be converted
|
| 377 |
+
oneof_refs = combined_schema["properties"]["data"]["oneOf"]
|
| 378 |
+
assert oneof_refs[0]["$ref"] == "#/$defs/TypeA"
|
| 379 |
+
assert oneof_refs[1]["$ref"] == "#/$defs/TypeB"
|
| 380 |
+
|
| 381 |
+
# anyOf refs should be converted
|
| 382 |
+
anyof_refs = combined_schema["properties"]["alternate"]["anyOf"]
|
| 383 |
+
assert anyof_refs[0]["$ref"] == "#/$defs/TypeC"
|
| 384 |
+
assert anyof_refs[1]["$ref"] == "#/$defs/TypeD"
|
| 385 |
+
|
| 386 |
+
# allOf refs should be converted
|
| 387 |
+
allof_refs = combined_schema["properties"]["combined"]["allOf"]
|
| 388 |
+
assert allof_refs[0]["$ref"] == "#/$defs/BaseType"
|
| 389 |
+
|
| 390 |
+
# Transitive refs should be converted
|
| 391 |
+
type_a_def = combined_schema["$defs"]["TypeA"]
|
| 392 |
+
assert type_a_def["properties"]["nested"]["$ref"] == "#/$defs/Nested"
|
| 393 |
+
|
| 394 |
+
def test_deeply_nested_transitive_refs_preserved(self):
|
| 395 |
+
"""Deeply nested transitive refs (A→B→C→D→E) must all be preserved."""
|
| 396 |
+
route = HTTPRoute(
|
| 397 |
+
path="/deep",
|
| 398 |
+
method="POST",
|
| 399 |
+
operation_id="create_deep",
|
| 400 |
+
request_body=RequestBodyInfo(
|
| 401 |
+
required=True,
|
| 402 |
+
content_schema={
|
| 403 |
+
"application/json": {"$ref": "#/components/schemas/Level1"}
|
| 404 |
+
},
|
| 405 |
+
),
|
| 406 |
+
schema_definitions={
|
| 407 |
+
"Level1": {
|
| 408 |
+
"type": "object",
|
| 409 |
+
"properties": {"level2": {"$ref": "#/components/schemas/Level2"}},
|
| 410 |
+
},
|
| 411 |
+
"Level2": {
|
| 412 |
+
"type": "object",
|
| 413 |
+
"properties": {"level3": {"$ref": "#/components/schemas/Level3"}},
|
| 414 |
+
},
|
| 415 |
+
"Level3": {
|
| 416 |
+
"type": "object",
|
| 417 |
+
"properties": {"level4": {"$ref": "#/components/schemas/Level4"}},
|
| 418 |
+
},
|
| 419 |
+
"Level4": {
|
| 420 |
+
"type": "object",
|
| 421 |
+
"properties": {"level5": {"$ref": "#/components/schemas/Level5"}},
|
| 422 |
+
},
|
| 423 |
+
"Level5": {
|
| 424 |
+
"type": "object",
|
| 425 |
+
"properties": {"value": {"type": "string"}},
|
| 426 |
+
},
|
| 427 |
+
"UnusedSchema": {"type": "number"},
|
| 428 |
+
},
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 432 |
+
|
| 433 |
+
# All levels should be preserved
|
| 434 |
+
assert "Level1" in combined_schema["$defs"]
|
| 435 |
+
assert "Level2" in combined_schema["$defs"]
|
| 436 |
+
assert "Level3" in combined_schema["$defs"]
|
| 437 |
+
assert "Level4" in combined_schema["$defs"]
|
| 438 |
+
assert "Level5" in combined_schema["$defs"]
|
| 439 |
+
|
| 440 |
+
# Unused should be removed (pruning is allowed for unused schemas)
|
| 441 |
+
assert "UnusedSchema" not in combined_schema["$defs"]
|
| 442 |
+
|
| 443 |
+
# All refs should be converted
|
| 444 |
+
assert (
|
| 445 |
+
combined_schema["$defs"]["Level1"]["properties"]["level2"]["$ref"]
|
| 446 |
+
== "#/$defs/Level2"
|
| 447 |
+
)
|
| 448 |
+
assert (
|
| 449 |
+
combined_schema["$defs"]["Level2"]["properties"]["level3"]["$ref"]
|
| 450 |
+
== "#/$defs/Level3"
|
| 451 |
+
)
|
| 452 |
+
assert (
|
| 453 |
+
combined_schema["$defs"]["Level3"]["properties"]["level4"]["$ref"]
|
| 454 |
+
== "#/$defs/Level4"
|
| 455 |
+
)
|
| 456 |
+
assert (
|
| 457 |
+
combined_schema["$defs"]["Level4"]["properties"]["level5"]["$ref"]
|
| 458 |
+
== "#/$defs/Level5"
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
def test_circular_references_handled(self):
|
| 462 |
+
"""Circular references (A→B→A) must be handled without infinite loops."""
|
| 463 |
+
route = HTTPRoute(
|
| 464 |
+
path="/circular",
|
| 465 |
+
method="POST",
|
| 466 |
+
operation_id="circular_test",
|
| 467 |
+
request_body=RequestBodyInfo(
|
| 468 |
+
required=True,
|
| 469 |
+
content_schema={
|
| 470 |
+
"application/json": {"$ref": "#/components/schemas/Node"}
|
| 471 |
+
},
|
| 472 |
+
),
|
| 473 |
+
schema_definitions={
|
| 474 |
+
"Node": {
|
| 475 |
+
"type": "object",
|
| 476 |
+
"properties": {
|
| 477 |
+
"value": {"type": "string"},
|
| 478 |
+
"children": {
|
| 479 |
+
"type": "array",
|
| 480 |
+
"items": {"$ref": "#/components/schemas/Node"},
|
| 481 |
+
},
|
| 482 |
+
},
|
| 483 |
+
},
|
| 484 |
+
},
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 488 |
+
|
| 489 |
+
# Node should be preserved
|
| 490 |
+
assert "Node" in combined_schema["$defs"]
|
| 491 |
+
|
| 492 |
+
# Self-reference should be converted
|
| 493 |
+
node_def = combined_schema["$defs"]["Node"]
|
| 494 |
+
assert node_def["properties"]["children"]["items"]["$ref"] == "#/$defs/Node"
|
| 495 |
+
|
| 496 |
+
def test_multiple_reference_paths_to_same_schema(self):
|
| 497 |
+
"""Multiple paths to the same schema (diamond pattern) must preserve the schema."""
|
| 498 |
+
route = HTTPRoute(
|
| 499 |
+
path="/diamond",
|
| 500 |
+
method="POST",
|
| 501 |
+
operation_id="diamond_test",
|
| 502 |
+
request_body=RequestBodyInfo(
|
| 503 |
+
required=True,
|
| 504 |
+
content_schema={
|
| 505 |
+
"application/json": {
|
| 506 |
+
"type": "object",
|
| 507 |
+
"properties": {
|
| 508 |
+
"left": {"$ref": "#/components/schemas/Left"},
|
| 509 |
+
"right": {"$ref": "#/components/schemas/Right"},
|
| 510 |
+
},
|
| 511 |
+
}
|
| 512 |
+
},
|
| 513 |
+
),
|
| 514 |
+
schema_definitions={
|
| 515 |
+
"Left": {
|
| 516 |
+
"type": "object",
|
| 517 |
+
"properties": {"shared": {"$ref": "#/components/schemas/Shared"}},
|
| 518 |
+
},
|
| 519 |
+
"Right": {
|
| 520 |
+
"type": "object",
|
| 521 |
+
"properties": {"shared": {"$ref": "#/components/schemas/Shared"}},
|
| 522 |
+
},
|
| 523 |
+
"Shared": {
|
| 524 |
+
"type": "object",
|
| 525 |
+
"properties": {"value": {"type": "string"}},
|
| 526 |
+
},
|
| 527 |
+
},
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 531 |
+
|
| 532 |
+
# All schemas should be preserved
|
| 533 |
+
assert "Left" in combined_schema["$defs"]
|
| 534 |
+
assert "Right" in combined_schema["$defs"]
|
| 535 |
+
assert "Shared" in combined_schema["$defs"]
|
| 536 |
+
|
| 537 |
+
# All refs should be converted
|
| 538 |
+
assert combined_schema["properties"]["left"]["$ref"] == "#/$defs/Left"
|
| 539 |
+
assert combined_schema["properties"]["right"]["$ref"] == "#/$defs/Right"
|
| 540 |
+
assert (
|
| 541 |
+
combined_schema["$defs"]["Left"]["properties"]["shared"]["$ref"]
|
| 542 |
+
== "#/$defs/Shared"
|
| 543 |
+
)
|
| 544 |
+
assert (
|
| 545 |
+
combined_schema["$defs"]["Right"]["properties"]["shared"]["$ref"]
|
| 546 |
+
== "#/$defs/Shared"
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
def test_refs_in_nested_content_schemas(self):
|
| 550 |
+
"""$refs in nested content schemas (the original bug location) must be converted."""
|
| 551 |
+
route = HTTPRoute(
|
| 552 |
+
path="/content",
|
| 553 |
+
method="POST",
|
| 554 |
+
operation_id="content_test",
|
| 555 |
+
request_body=RequestBodyInfo(
|
| 556 |
+
required=True,
|
| 557 |
+
content_schema={
|
| 558 |
+
"application/json": {"$ref": "#/components/schemas/Content"}
|
| 559 |
+
},
|
| 560 |
+
),
|
| 561 |
+
schema_definitions={
|
| 562 |
+
"Content": {
|
| 563 |
+
"type": "object",
|
| 564 |
+
"properties": {
|
| 565 |
+
"media": {
|
| 566 |
+
"type": "object",
|
| 567 |
+
"properties": {
|
| 568 |
+
"application/json": {
|
| 569 |
+
"$ref": "#/components/schemas/JsonContent"
|
| 570 |
+
}
|
| 571 |
+
},
|
| 572 |
+
}
|
| 573 |
+
},
|
| 574 |
+
},
|
| 575 |
+
"JsonContent": {
|
| 576 |
+
"type": "object",
|
| 577 |
+
"properties": {"data": {"type": "string"}},
|
| 578 |
+
},
|
| 579 |
+
},
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 583 |
+
|
| 584 |
+
# Both schemas should be preserved
|
| 585 |
+
assert "Content" in combined_schema["$defs"]
|
| 586 |
+
assert "JsonContent" in combined_schema["$defs"]
|
| 587 |
+
|
| 588 |
+
# Nested ref should be converted
|
| 589 |
+
content_def = combined_schema["$defs"]["Content"]
|
| 590 |
+
nested_ref = content_def["properties"]["media"]["properties"][
|
| 591 |
+
"application/json"
|
| 592 |
+
]
|
| 593 |
+
assert nested_ref["$ref"] == "#/$defs/JsonContent"
|
| 594 |
+
|
| 595 |
+
def test_unnecessary_defs_preserved_when_referenced(self):
|
| 596 |
+
"""Even seemingly unnecessary $defs must be preserved if they're referenced."""
|
| 597 |
+
route = HTTPRoute(
|
| 598 |
+
path="/test",
|
| 599 |
+
method="POST",
|
| 600 |
+
operation_id="test_unnecessary",
|
| 601 |
+
request_body=RequestBodyInfo(
|
| 602 |
+
required=True,
|
| 603 |
+
content_schema={
|
| 604 |
+
"application/json": {
|
| 605 |
+
"type": "object",
|
| 606 |
+
"properties": {
|
| 607 |
+
# Reference to a simple type schema
|
| 608 |
+
"simple": {"$ref": "#/components/schemas/SimpleString"},
|
| 609 |
+
# Reference to an empty object schema
|
| 610 |
+
"empty": {"$ref": "#/components/schemas/EmptyObject"},
|
| 611 |
+
},
|
| 612 |
+
}
|
| 613 |
+
},
|
| 614 |
+
),
|
| 615 |
+
schema_definitions={
|
| 616 |
+
"SimpleString": {"type": "string"},
|
| 617 |
+
"EmptyObject": {"type": "object"},
|
| 618 |
+
"UnreferencedSchema": {"type": "number"},
|
| 619 |
+
},
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 623 |
+
|
| 624 |
+
# Referenced schemas should be preserved even if simple
|
| 625 |
+
assert "SimpleString" in combined_schema["$defs"]
|
| 626 |
+
assert "EmptyObject" in combined_schema["$defs"]
|
| 627 |
+
|
| 628 |
+
# Unreferenced should be removed
|
| 629 |
+
assert "UnreferencedSchema" not in combined_schema["$defs"]
|
| 630 |
+
|
| 631 |
+
# Refs should be converted
|
| 632 |
+
assert combined_schema["properties"]["simple"]["$ref"] == "#/$defs/SimpleString"
|
| 633 |
+
assert combined_schema["properties"]["empty"]["$ref"] == "#/$defs/EmptyObject"
|
| 634 |
+
|
| 635 |
+
def test_ref_only_request_body_handled(self):
|
| 636 |
+
"""Request bodies that are just a $ref (not an object with properties) must work."""
|
| 637 |
+
route = HTTPRoute(
|
| 638 |
+
path="/direct-ref",
|
| 639 |
+
method="POST",
|
| 640 |
+
operation_id="direct_ref_test",
|
| 641 |
+
request_body=RequestBodyInfo(
|
| 642 |
+
required=True,
|
| 643 |
+
content_schema={
|
| 644 |
+
# Direct $ref, not wrapped in an object
|
| 645 |
+
"application/json": {"$ref": "#/components/schemas/DirectBody"}
|
| 646 |
+
},
|
| 647 |
+
),
|
| 648 |
+
schema_definitions={
|
| 649 |
+
"DirectBody": {
|
| 650 |
+
"type": "object",
|
| 651 |
+
"properties": {
|
| 652 |
+
"field1": {"type": "string"},
|
| 653 |
+
"nested": {"$ref": "#/components/schemas/NestedBody"},
|
| 654 |
+
},
|
| 655 |
+
},
|
| 656 |
+
"NestedBody": {
|
| 657 |
+
"type": "object",
|
| 658 |
+
"properties": {"field2": {"type": "number"}},
|
| 659 |
+
},
|
| 660 |
+
},
|
| 661 |
+
)
|
| 662 |
+
|
| 663 |
+
combined_schema, _ = _combine_schemas_and_map_params(route)
|
| 664 |
+
|
| 665 |
+
# Should handle the direct ref properly
|
| 666 |
+
assert "body" in combined_schema["properties"]
|
| 667 |
+
assert combined_schema["properties"]["body"]["$ref"] == "#/$defs/DirectBody"
|
| 668 |
+
|
| 669 |
+
# Both schemas should be preserved
|
| 670 |
+
assert "DirectBody" in combined_schema["$defs"]
|
| 671 |
+
assert "NestedBody" in combined_schema["$defs"]
|
| 672 |
+
|
| 673 |
+
# Nested ref should be converted
|
| 674 |
+
assert (
|
| 675 |
+
combined_schema["$defs"]["DirectBody"]["properties"]["nested"]["$ref"]
|
| 676 |
+
== "#/$defs/NestedBody"
|
| 677 |
+
)
|
uv.lock
CHANGED
|
@@ -530,6 +530,7 @@ dependencies = [
|
|
| 530 |
{ name = "exceptiongroup" },
|
| 531 |
{ name = "httpx" },
|
| 532 |
{ name = "mcp" },
|
|
|
|
| 533 |
{ name = "openapi-core" },
|
| 534 |
{ name = "openapi-pydantic" },
|
| 535 |
{ name = "pydantic", extra = ["email"] },
|
|
@@ -574,6 +575,7 @@ requires-dist = [
|
|
| 574 |
{ name = "exceptiongroup", specifier = ">=1.2.2" },
|
| 575 |
{ name = "httpx", specifier = ">=0.28.1" },
|
| 576 |
{ name = "mcp", specifier = ">=1.10.0" },
|
|
|
|
| 577 |
{ name = "openapi-core", specifier = ">=0.19.5" },
|
| 578 |
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
|
| 579 |
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
|
@@ -979,6 +981,42 @@ wheels = [
|
|
| 979 |
{ url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" },
|
| 980 |
]
|
| 981 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 982 |
[[package]]
|
| 983 |
name = "nodeenv"
|
| 984 |
version = "1.9.1"
|
|
|
|
| 530 |
{ name = "exceptiongroup" },
|
| 531 |
{ name = "httpx" },
|
| 532 |
{ name = "mcp" },
|
| 533 |
+
{ name = "msgspec" },
|
| 534 |
{ name = "openapi-core" },
|
| 535 |
{ name = "openapi-pydantic" },
|
| 536 |
{ name = "pydantic", extra = ["email"] },
|
|
|
|
| 575 |
{ name = "exceptiongroup", specifier = ">=1.2.2" },
|
| 576 |
{ name = "httpx", specifier = ">=0.28.1" },
|
| 577 |
{ name = "mcp", specifier = ">=1.10.0" },
|
| 578 |
+
{ name = "msgspec", specifier = ">=0.19.0" },
|
| 579 |
{ name = "openapi-core", specifier = ">=0.19.5" },
|
| 580 |
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
|
| 581 |
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
|
|
|
| 981 |
{ url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" },
|
| 982 |
]
|
| 983 |
|
| 984 |
+
[[package]]
|
| 985 |
+
name = "msgspec"
|
| 986 |
+
version = "0.19.0"
|
| 987 |
+
source = { registry = "https://pypi.org/simple" }
|
| 988 |
+
sdist = { url = "https://files.pythonhosted.org/packages/cf/9b/95d8ce458462b8b71b8a70fa94563b2498b89933689f3a7b8911edfae3d7/msgspec-0.19.0.tar.gz", hash = "sha256:604037e7cd475345848116e89c553aa9a233259733ab51986ac924ab1b976f8e", size = 216934, upload-time = "2024-12-27T17:40:28.597Z" }
|
| 989 |
+
wheels = [
|
| 990 |
+
{ url = "https://files.pythonhosted.org/packages/13/40/817282b42f58399762267b30deb8ac011d8db373f8da0c212c85fbe62b8f/msgspec-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d8dd848ee7ca7c8153462557655570156c2be94e79acec3561cf379581343259", size = 190019, upload-time = "2024-12-27T17:39:13.803Z" },
|
| 991 |
+
{ url = "https://files.pythonhosted.org/packages/92/99/bd7ed738c00f223a8119928661167a89124140792af18af513e6519b0d54/msgspec-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0553bbc77662e5708fe66aa75e7bd3e4b0f209709c48b299afd791d711a93c36", size = 183680, upload-time = "2024-12-27T17:39:17.847Z" },
|
| 992 |
+
{ url = "https://files.pythonhosted.org/packages/e5/27/322badde18eb234e36d4a14122b89edd4e2973cdbc3da61ca7edf40a1ccd/msgspec-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe2c4bf29bf4e89790b3117470dea2c20b59932772483082c468b990d45fb947", size = 209334, upload-time = "2024-12-27T17:39:19.065Z" },
|
| 993 |
+
{ url = "https://files.pythonhosted.org/packages/c6/65/080509c5774a1592b2779d902a70b5fe008532759927e011f068145a16cb/msgspec-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e87ecfa9795ee5214861eab8326b0e75475c2e68a384002aa135ea2a27d909", size = 211551, upload-time = "2024-12-27T17:39:21.767Z" },
|
| 994 |
+
{ url = "https://files.pythonhosted.org/packages/6f/2e/1c23c6b4ca6f4285c30a39def1054e2bee281389e4b681b5e3711bd5a8c9/msgspec-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3c4ec642689da44618f68c90855a10edbc6ac3ff7c1d94395446c65a776e712a", size = 215099, upload-time = "2024-12-27T17:39:24.71Z" },
|
| 995 |
+
{ url = "https://files.pythonhosted.org/packages/83/fe/95f9654518879f3359d1e76bc41189113aa9102452170ab7c9a9a4ee52f6/msgspec-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2719647625320b60e2d8af06b35f5b12d4f4d281db30a15a1df22adb2295f633", size = 218211, upload-time = "2024-12-27T17:39:27.396Z" },
|
| 996 |
+
{ url = "https://files.pythonhosted.org/packages/79/f6/71ca7e87a1fb34dfe5efea8156c9ef59dd55613aeda2ca562f122cd22012/msgspec-0.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:695b832d0091edd86eeb535cd39e45f3919f48d997685f7ac31acb15e0a2ed90", size = 186174, upload-time = "2024-12-27T17:39:29.647Z" },
|
| 997 |
+
{ url = "https://files.pythonhosted.org/packages/24/d4/2ec2567ac30dab072cce3e91fb17803c52f0a37aab6b0c24375d2b20a581/msgspec-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa77046904db764b0462036bc63ef71f02b75b8f72e9c9dd4c447d6da1ed8f8e", size = 187939, upload-time = "2024-12-27T17:39:32.347Z" },
|
| 998 |
+
{ url = "https://files.pythonhosted.org/packages/2b/c0/18226e4328897f4f19875cb62bb9259fe47e901eade9d9376ab5f251a929/msgspec-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:047cfa8675eb3bad68722cfe95c60e7afabf84d1bd8938979dd2b92e9e4a9551", size = 182202, upload-time = "2024-12-27T17:39:33.633Z" },
|
| 999 |
+
{ url = "https://files.pythonhosted.org/packages/81/25/3a4b24d468203d8af90d1d351b77ea3cffb96b29492855cf83078f16bfe4/msgspec-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e78f46ff39a427e10b4a61614a2777ad69559cc8d603a7c05681f5a595ea98f7", size = 209029, upload-time = "2024-12-27T17:39:35.023Z" },
|
| 1000 |
+
{ url = "https://files.pythonhosted.org/packages/85/2e/db7e189b57901955239f7689b5dcd6ae9458637a9c66747326726c650523/msgspec-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c7adf191e4bd3be0e9231c3b6dc20cf1199ada2af523885efc2ed218eafd011", size = 210682, upload-time = "2024-12-27T17:39:36.384Z" },
|
| 1001 |
+
{ url = "https://files.pythonhosted.org/packages/03/97/7c8895c9074a97052d7e4a1cc1230b7b6e2ca2486714eb12c3f08bb9d284/msgspec-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f04cad4385e20be7c7176bb8ae3dca54a08e9756cfc97bcdb4f18560c3042063", size = 214003, upload-time = "2024-12-27T17:39:39.097Z" },
|
| 1002 |
+
{ url = "https://files.pythonhosted.org/packages/61/61/e892997bcaa289559b4d5869f066a8021b79f4bf8e955f831b095f47a4cd/msgspec-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45c8fb410670b3b7eb884d44a75589377c341ec1392b778311acdbfa55187716", size = 216833, upload-time = "2024-12-27T17:39:41.203Z" },
|
| 1003 |
+
{ url = "https://files.pythonhosted.org/packages/ce/3d/71b2dffd3a1c743ffe13296ff701ee503feaebc3f04d0e75613b6563c374/msgspec-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:70eaef4934b87193a27d802534dc466778ad8d536e296ae2f9334e182ac27b6c", size = 186184, upload-time = "2024-12-27T17:39:43.702Z" },
|
| 1004 |
+
{ url = "https://files.pythonhosted.org/packages/b2/5f/a70c24f075e3e7af2fae5414c7048b0e11389685b7f717bb55ba282a34a7/msgspec-0.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f98bd8962ad549c27d63845b50af3f53ec468b6318400c9f1adfe8b092d7b62f", size = 190485, upload-time = "2024-12-27T17:39:44.974Z" },
|
| 1005 |
+
{ url = "https://files.pythonhosted.org/packages/89/b0/1b9763938cfae12acf14b682fcf05c92855974d921a5a985ecc197d1c672/msgspec-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43bbb237feab761b815ed9df43b266114203f53596f9b6e6f00ebd79d178cdf2", size = 183910, upload-time = "2024-12-27T17:39:46.401Z" },
|
| 1006 |
+
{ url = "https://files.pythonhosted.org/packages/87/81/0c8c93f0b92c97e326b279795f9c5b956c5a97af28ca0fbb9fd86c83737a/msgspec-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cfc033c02c3e0aec52b71710d7f84cb3ca5eb407ab2ad23d75631153fdb1f12", size = 210633, upload-time = "2024-12-27T17:39:49.099Z" },
|
| 1007 |
+
{ url = "https://files.pythonhosted.org/packages/d0/ef/c5422ce8af73928d194a6606f8ae36e93a52fd5e8df5abd366903a5ca8da/msgspec-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d911c442571605e17658ca2b416fd8579c5050ac9adc5e00c2cb3126c97f73bc", size = 213594, upload-time = "2024-12-27T17:39:51.204Z" },
|
| 1008 |
+
{ url = "https://files.pythonhosted.org/packages/19/2b/4137bc2ed45660444842d042be2cf5b18aa06efd2cda107cff18253b9653/msgspec-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:757b501fa57e24896cf40a831442b19a864f56d253679f34f260dcb002524a6c", size = 214053, upload-time = "2024-12-27T17:39:52.866Z" },
|
| 1009 |
+
{ url = "https://files.pythonhosted.org/packages/9d/e6/8ad51bdc806aac1dc501e8fe43f759f9ed7284043d722b53323ea421c360/msgspec-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5f0f65f29b45e2816d8bded36e6b837a4bf5fb60ec4bc3c625fa2c6da4124537", size = 219081, upload-time = "2024-12-27T17:39:55.142Z" },
|
| 1010 |
+
{ url = "https://files.pythonhosted.org/packages/b1/ef/27dd35a7049c9a4f4211c6cd6a8c9db0a50647546f003a5867827ec45391/msgspec-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:067f0de1c33cfa0b6a8206562efdf6be5985b988b53dd244a8e06f993f27c8c0", size = 187467, upload-time = "2024-12-27T17:39:56.531Z" },
|
| 1011 |
+
{ url = "https://files.pythonhosted.org/packages/3c/cb/2842c312bbe618d8fefc8b9cedce37f773cdc8fa453306546dba2c21fd98/msgspec-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f12d30dd6266557aaaf0aa0f9580a9a8fbeadfa83699c487713e355ec5f0bd86", size = 190498, upload-time = "2024-12-27T17:40:00.427Z" },
|
| 1012 |
+
{ url = "https://files.pythonhosted.org/packages/58/95/c40b01b93465e1a5f3b6c7d91b10fb574818163740cc3acbe722d1e0e7e4/msgspec-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82b2c42c1b9ebc89e822e7e13bbe9d17ede0c23c187469fdd9505afd5a481314", size = 183950, upload-time = "2024-12-27T17:40:04.219Z" },
|
| 1013 |
+
{ url = "https://files.pythonhosted.org/packages/e8/f0/5b764e066ce9aba4b70d1db8b087ea66098c7c27d59b9dd8a3532774d48f/msgspec-0.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19746b50be214a54239aab822964f2ac81e38b0055cca94808359d779338c10e", size = 210647, upload-time = "2024-12-27T17:40:05.606Z" },
|
| 1014 |
+
{ url = "https://files.pythonhosted.org/packages/9d/87/bc14f49bc95c4cb0dd0a8c56028a67c014ee7e6818ccdce74a4862af259b/msgspec-0.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ef4bdb0ec8e4ad62e5a1f95230c08efb1f64f32e6e8dd2ced685bcc73858b5", size = 213563, upload-time = "2024-12-27T17:40:10.516Z" },
|
| 1015 |
+
{ url = "https://files.pythonhosted.org/packages/53/2f/2b1c2b056894fbaa975f68f81e3014bb447516a8b010f1bed3fb0e016ed7/msgspec-0.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac7f7c377c122b649f7545810c6cd1b47586e3aa3059126ce3516ac7ccc6a6a9", size = 213996, upload-time = "2024-12-27T17:40:12.244Z" },
|
| 1016 |
+
{ url = "https://files.pythonhosted.org/packages/aa/5a/4cd408d90d1417e8d2ce6a22b98a6853c1b4d7cb7669153e4424d60087f6/msgspec-0.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5bc1472223a643f5ffb5bf46ccdede7f9795078194f14edd69e3aab7020d327", size = 219087, upload-time = "2024-12-27T17:40:14.881Z" },
|
| 1017 |
+
{ url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432, upload-time = "2024-12-27T17:40:16.256Z" },
|
| 1018 |
+
]
|
| 1019 |
+
|
| 1020 |
[[package]]
|
| 1021 |
name = "nodeenv"
|
| 1022 |
version = "1.9.1"
|