From 558c7d2a6606e8ea8154b7b82919442a758fe2b3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 9 Aug 2025 20:53:51 -0400 Subject: [PATCH] Fix OpenAPI transitive references and performance (#1372) (#1426) --- pyproject.toml | 1 + .../experimental/server/openapi/routing.py | 2 +- .../experimental/server/openapi/server.py | 12 - .../utilities/openapi/__init__.py | 2 - .../experimental/utilities/openapi/parser.py | 137 +++- .../experimental/utilities/openapi/schemas.py | 223 +++--- src/fastmcp/utilities/openapi.py | 8 +- tests/experimental/server/conftest.py | 11 + .../{ => openapi}/test_openapi_performance.py | 4 +- .../openapi/test_performance_comparison.py | 4 +- .../utilities/openapi/conftest.py | 8 + .../utilities/openapi/test_parser.py | 129 ++++ .../utilities/openapi/test_schemas.py | 14 +- .../openapi/test_transitive_references.py | 677 ++++++++++++++++++ uv.lock | 38 + 15 files changed, 1126 insertions(+), 144 deletions(-) create mode 100644 tests/experimental/server/conftest.py rename tests/experimental/server/{ => openapi}/test_openapi_performance.py (98%) create mode 100644 tests/experimental/utilities/openapi/test_transitive_references.py diff --git a/pyproject.toml b/pyproject.toml index 308cabc0c..f8e734dda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyperclip>=1.9.0", "openapi-core>=0.19.5", + "msgspec>=0.19.0", ] requires-python = ">=3.10" readme = "README.md" diff --git a/src/fastmcp/experimental/server/openapi/routing.py b/src/fastmcp/experimental/server/openapi/routing.py index f89ed361a..ca7ebdb94 100644 --- a/src/fastmcp/experimental/server/openapi/routing.py +++ b/src/fastmcp/experimental/server/openapi/routing.py @@ -113,7 +113,7 @@ def _determine_route_type( # We know mcp_type is not None here due to post_init validation assert route_map.mcp_type is not None logger.debug( - f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}" + f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}" ) return route_map diff --git a/src/fastmcp/experimental/server/openapi/server.py b/src/fastmcp/experimental/server/openapi/server.py index fd188ee3a..e5cae8fcc 100644 --- a/src/fastmcp/experimental/server/openapi/server.py +++ b/src/fastmcp/experimental/server/openapi/server.py @@ -151,9 +151,6 @@ class FastMCPOpenAPI(FastMCP): try: self._spec = SchemaPath.from_dict(openapi_spec) # type: ignore[arg-type] self._director = RequestDirector(self._spec) - logger.debug( - "Initialized OpenAPI RequestDirector for stateless request building" - ) except Exception as e: logger.error(f"Failed to initialize RequestDirector: {e}") raise ValueError(f"Invalid OpenAPI specification: {e}") from e @@ -318,9 +315,6 @@ class FastMCPOpenAPI(FastMCP): # Register the tool by directly assigning to the tools dictionary self._tool_manager._tools[final_tool_name] = tool - logger.debug( - f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}" - ) def _create_openapi_resource( self, @@ -372,9 +366,6 @@ class FastMCPOpenAPI(FastMCP): # Register the resource by directly assigning to the resources dictionary self._resource_manager._resources[final_resource_uri] = resource - logger.debug( - f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}" - ) def _create_openapi_template( self, @@ -455,9 +446,6 @@ class FastMCPOpenAPI(FastMCP): # Register the template by directly assigning to the templates dictionary self._resource_manager._templates[final_template_uri] = template - logger.debug( - f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}" - ) # Export public symbols diff --git a/src/fastmcp/experimental/utilities/openapi/__init__.py b/src/fastmcp/experimental/utilities/openapi/__init__.py index 588f422f5..4231539fc 100644 --- a/src/fastmcp/experimental/utilities/openapi/__init__.py +++ b/src/fastmcp/experimental/utilities/openapi/__init__.py @@ -28,7 +28,6 @@ from .schemas import ( _combine_schemas, extract_output_schema_from_responses, clean_schema_for_display, - _replace_ref_with_defs, _make_optional_parameter_nullable, ) @@ -60,7 +59,6 @@ __all__ = [ "_combine_schemas", "extract_output_schema_from_responses", "clean_schema_for_display", - "_replace_ref_with_defs", "_make_optional_parameter_nullable", # JSON Schema Converter "convert_openapi_schema_to_json_schema", diff --git a/src/fastmcp/experimental/utilities/openapi/parser.py b/src/fastmcp/experimental/utilities/openapi/parser.py index ac6dc1020..300a46b06 100644 --- a/src/fastmcp/experimental/utilities/openapi/parser.py +++ b/src/fastmcp/experimental/utilities/openapi/parser.py @@ -34,7 +34,10 @@ from .models import ( RequestBodyInfo, ResponseInfo, ) -from .schemas import _combine_schemas_and_map_params, _replace_ref_with_defs +from .schemas import ( + _combine_schemas_and_map_params, + _replace_ref_with_defs_recursive, +) logger = get_logger(__name__) @@ -63,7 +66,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute if openapi_version.startswith("3.0"): # Use OpenAPI 3.0 models openapi_30 = OpenAPI_30.model_validate(openapi_dict) - logger.info( + logger.debug( f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}" ) parser = OpenAPIParser( @@ -81,7 +84,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute else: # Default to OpenAPI 3.1 models openapi_31 = OpenAPI.model_validate(openapi_dict) - logger.info( + logger.debug( f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}" ) parser = OpenAPIParser( @@ -207,7 +210,7 @@ class OpenAPIParser( try: resolved_schema = self._resolve_ref(schema_obj) - if isinstance(resolved_schema, (self.schema_cls)): + if isinstance(resolved_schema, self.schema_cls): # Convert schema to dictionary result = resolved_schema.model_dump( mode="json", by_alias=True, exclude_none=True @@ -220,7 +223,10 @@ class OpenAPIParser( ) result = {} - return _replace_ref_with_defs(result) + # Convert refs from OpenAPI format to JSON Schema format using recursive approach + + result = _replace_ref_with_defs_recursive(result) + return result except ValueError as e: # Re-raise ValueError for external reference errors and other validation issues if "External or non-local reference not supported" in str(e): @@ -470,6 +476,102 @@ class OpenAPIParser( return extracted_responses + def _extract_schema_dependencies( + self, + schema: dict, + all_schemas: dict[str, Any], + collected: set[str] | None = None, + ) -> set[str]: + """ + Extract all schema names referenced by a schema (including transitive dependencies). + + Args: + schema: The schema to analyze + all_schemas: All available schema definitions + collected: Set of already collected schema names (for recursion) + + Returns: + Set of schema names that are referenced + """ + if collected is None: + collected = set() + + def find_refs(obj): + """Recursively find all $ref references.""" + if isinstance(obj, dict): + if "$ref" in obj and isinstance(obj["$ref"], str): + ref = obj["$ref"] + # Handle both converted and unconverted refs + if ref.startswith("#/$defs/"): + schema_name = ref.split("/")[-1] + elif ref.startswith("#/components/schemas/"): + schema_name = ref.split("/")[-1] + else: + return + + # Add this schema and recursively find its dependencies + if schema_name not in collected and schema_name in all_schemas: + collected.add(schema_name) + # Recursively find dependencies of this schema + find_refs(all_schemas[schema_name]) + + # Continue searching in all values + for value in obj.values(): + find_refs(value) + elif isinstance(obj, list): + for item in obj: + find_refs(item) + + find_refs(schema) + return collected + + def _extract_route_schema_dependencies( + self, + parameters: list[ParameterInfo], + request_body: RequestBodyInfo | None, + responses: dict[str, ResponseInfo], + all_schemas: dict[str, Any], + ) -> dict[str, Any]: + """ + Extract only the schema definitions needed for a specific route. + + Args: + parameters: Route parameters + request_body: Route request body + responses: Route responses + all_schemas: All available schema definitions + + Returns: + Dictionary containing only the schemas needed for this route + """ + needed_schemas = set() + + # Check parameters for schema references + for param in parameters: + if param.schema_: + deps = self._extract_schema_dependencies(param.schema_, all_schemas) + needed_schemas.update(deps) + + # Check request body for schema references + if request_body and request_body.content_schema: + for content_schema in request_body.content_schema.values(): + deps = self._extract_schema_dependencies(content_schema, all_schemas) + needed_schemas.update(deps) + + # Check responses for schema references + for response in responses.values(): + if response.content_schema: + for content_schema in response.content_schema.values(): + deps = self._extract_schema_dependencies( + content_schema, all_schemas + ) + needed_schemas.update(deps) + + # Return only the needed schemas + return { + name: all_schemas[name] for name in needed_schemas if name in all_schemas + } + def parse(self) -> list[HTTPRoute]: """Parse the OpenAPI schema into HTTP routes.""" routes: list[HTTPRoute] = [] @@ -499,6 +601,13 @@ class OpenAPIParser( f"Failed to extract schema definition '{name}': {e}" ) + # Convert schema definitions refs from OpenAPI to JSON Schema format (once) + if schema_definitions: + # Convert each schema definition recursively + for name, schema in schema_definitions.items(): + if isinstance(schema, dict): + schema_definitions[name] = _replace_ref_with_defs_recursive(schema) + # Process paths and operations for path_str, path_item_obj in self.openapi.paths.items(): if not isinstance(path_item_obj, self.path_item_cls): @@ -552,6 +661,14 @@ class OpenAPIParser( if k.startswith("x-") } + # Extract only the schemas needed for this route + route_schemas = self._extract_route_schema_dependencies( + parameters, + request_body_info, + responses, + schema_definitions, + ) + # Create initial route without pre-calculated fields route = HTTPRoute( path=path_str, @@ -563,7 +680,7 @@ class OpenAPIParser( parameters=parameters, request_body=request_body_info, responses=responses, - schema_definitions=schema_definitions, + schema_definitions=route_schemas, # Use pre-pruned schemas extensions=extensions, openapi_version=self.openapi_version, ) @@ -571,7 +688,8 @@ class OpenAPIParser( # Pre-calculate schema and parameter mapping for performance try: flat_schema, param_map = _combine_schemas_and_map_params( - route + route, + convert_refs=False, # Parser already converted refs ) route.flat_param_schema = flat_schema route.parameter_map = param_map @@ -586,9 +704,6 @@ class OpenAPIParser( } route.parameter_map = {} routes.append(route) - logger.info( - f"Successfully extracted route: {method_upper} {path_str}" - ) except ValueError as op_error: # Re-raise ValueError for external reference errors if "External or non-local reference not supported" in str( @@ -607,7 +722,7 @@ class OpenAPIParser( exc_info=True, ) - logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.") + logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.") return routes diff --git a/src/fastmcp/experimental/utilities/openapi/schemas.py b/src/fastmcp/experimental/utilities/openapi/schemas.py index f82865af4..279046d32 100644 --- a/src/fastmcp/experimental/utilities/openapi/schemas.py +++ b/src/fastmcp/experimental/utilities/openapi/schemas.py @@ -71,11 +71,11 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None: return cleaned -def _replace_ref_with_defs( +def _replace_ref_with_defs_recursive( info: dict[str, Any], description: str | None = None ) -> dict[str, Any]: """ - Replace openapi $ref with jsonschema $defs + Replace openapi $ref with jsonschema $defs recursively. Examples: - {"type": "object", "properties": {"$ref": "#/components/schemas/..."}} @@ -106,22 +106,35 @@ def _replace_ref_with_defs( ) elif properties := schema.get("properties"): if "$ref" in properties: - schema["properties"] = _replace_ref_with_defs(properties) + schema["properties"] = _replace_ref_with_defs_recursive(properties) else: schema["properties"] = { - prop_name: _replace_ref_with_defs(prop_schema) + prop_name: _replace_ref_with_defs_recursive(prop_schema) for prop_name, prop_schema in properties.items() } elif item_schema := schema.get("items"): - schema["items"] = _replace_ref_with_defs(item_schema) + schema["items"] = _replace_ref_with_defs_recursive(item_schema) for section in ["anyOf", "allOf", "oneOf"]: for i, item in enumerate(schema.get(section, [])): - schema[section][i] = _replace_ref_with_defs(item) + schema[section][i] = _replace_ref_with_defs_recursive(item) if info.get("description", description) and not schema.get("description"): schema["description"] = description return schema +def _ensure_refs_converted(schema: dict[str, Any]) -> dict[str, Any]: + """ + Ensure all OpenAPI refs are converted to JSON Schema format using recursive approach. + + Args: + schema: Schema that may contain OpenAPI refs + + Returns: + Schema with all refs converted to JSON Schema format + """ + return _replace_ref_with_defs_recursive(schema) + + def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]: """ 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]: def _combine_schemas_and_map_params( route: HTTPRoute, + convert_refs: bool = True, ) -> tuple[dict[str, Any], dict[str, dict[str, str]]]: """ Combines parameter and request body schemas into a single schema. @@ -233,10 +247,17 @@ def _combine_schemas_and_map_params( if route.request_body and route.request_body.content_schema: content_type = next(iter(route.request_body.content_schema)) - body_schema = _replace_ref_with_defs( - route.request_body.content_schema[content_type].copy(), - route.request_body.description, - ) + + # Convert refs if needed + if convert_refs: + body_schema = _ensure_refs_converted( + route.request_body.content_schema[content_type] + ) + else: + body_schema = route.request_body.content_schema[content_type] + + if route.request_body.description and not body_schema.get("description"): + body_schema["description"] = route.request_body.description # Handle allOf at the top level by merging all schemas if "allOf" in body_schema and isinstance(body_schema["allOf"], list): @@ -287,10 +308,11 @@ def _combine_schemas_and_map_params( "openapi_name": param.name, } - # Add location info to description - param_schema = _replace_ref_with_defs( - param.schema_.copy(), param.description - ) + # Convert refs if needed + if convert_refs: + param_schema = _ensure_refs_converted(param.schema_) + else: + param_schema = param.schema_ original_desc = param_schema.get("description", "") location_desc = f"({param.location.capitalize()} parameter)" if original_desc: @@ -313,9 +335,11 @@ def _combine_schemas_and_map_params( "openapi_name": param.name, } - param_schema = _replace_ref_with_defs( - param.schema_.copy(), param.description - ) + # Convert refs if needed + if convert_refs: + param_schema = _ensure_refs_converted(param.schema_) + else: + param_schema = param.schema_ # Don't make optional parameters nullable - they can simply be omitted # The OpenAPI specification doesn't require optional parameters to accept null values @@ -324,14 +348,28 @@ def _combine_schemas_and_map_params( # Add request body properties (no suffixes for body parameters) if route.request_body and route.request_body.content_schema: - for prop_name, prop_schema in body_props.items(): - properties[prop_name] = prop_schema + # If body is just a $ref, we need to handle it differently + if "$ref" in body_schema and not body_props: + # The entire body is a reference to a schema + # We need to expand this inline or keep the ref + # For simplicity, we'll keep it as a single property + properties["body"] = body_schema + if route.request_body.required: + required.append("body") + parameter_map["body"] = {"location": "body", "openapi_name": "body"} + else: + # Normal case: body has properties + for prop_name, prop_schema in body_props.items(): + properties[prop_name] = prop_schema - # Track parameter mapping for body properties - parameter_map[prop_name] = {"location": "body", "openapi_name": prop_name} + # Track parameter mapping for body properties + parameter_map[prop_name] = { + "location": "body", + "openapi_name": prop_name, + } - if route.request_body.required: - required.extend(body_schema.get("required", [])) + if route.request_body.required: + required.extend(body_schema.get("required", [])) result = { "type": "object", @@ -340,42 +378,51 @@ def _combine_schemas_and_map_params( } # Add schema definitions if available if route.schema_definitions: - result["$defs"] = route.schema_definitions.copy() + if convert_refs: + # Need to convert refs and prune + all_defs = route.schema_definitions.copy() + # Convert each schema definition recursively + for name, schema in all_defs.items(): + if isinstance(schema, dict): + all_defs[name] = _replace_ref_with_defs_recursive(schema) - # Use lightweight compression - prune additionalProperties and unused definitions - if result.get("additionalProperties") is False: - result.pop("additionalProperties") + # Prune to only needed schemas + used_refs = set() - # Remove unused definitions (lightweight approach - just check direct $ref usage) - if "$defs" in result: - used_refs = set() + def find_refs_in_value(value): + """Recursively find all $ref references.""" + if isinstance(value, dict): + if "$ref" in value and isinstance(value["$ref"], str): + ref = value["$ref"] + if ref.startswith("#/$defs/"): + used_refs.add(ref.split("/")[-1]) + for v in value.values(): + find_refs_in_value(v) + elif isinstance(value, list): + for item in value: + find_refs_in_value(item) - def find_refs_in_value(value): - if isinstance(value, dict): - if "$ref" in value and isinstance(value["$ref"], str): - ref = value["$ref"] - if ref.startswith("#/$defs/"): - used_refs.add(ref.split("/")[-1]) - for v in value.values(): - find_refs_in_value(v) - elif isinstance(value, list): - for item in value: - find_refs_in_value(item) + # Find refs in properties + find_refs_in_value(properties) - # Find refs in the main schema (excluding $defs section) - for key, value in result.items(): - if key != "$defs": - find_refs_in_value(value) + # Collect transitive dependencies + if used_refs: + collected_all = False + while not collected_all: + initial_count = len(used_refs) + for name in list(used_refs): + if name in all_defs: + find_refs_in_value(all_defs[name]) + collected_all = len(used_refs) == initial_count - # Remove unused definitions - if used_refs: - result["$defs"] = { - name: def_schema - for name, def_schema in result["$defs"].items() - if name in used_refs - } + result["$defs"] = { + name: def_schema + for name, def_schema in all_defs.items() + if name in used_refs + } else: - result.pop("$defs") + # From parser - already converted and pruned + result["$defs"] = route.schema_definitions return result, parameter_map @@ -466,17 +513,17 @@ def extract_output_schema_from_responses( if not schema or not isinstance(schema, dict): return None - # Clean and copy the schema - output_schema = schema.copy() + # Convert refs if needed + output_schema = _ensure_refs_converted(schema) # If schema has a $ref, resolve it first before processing nullable fields if "$ref" in output_schema and schema_definitions: ref_path = output_schema["$ref"] - if ref_path.startswith("#/components/schemas/"): + if ref_path.startswith("#/$defs/"): schema_name = ref_path.split("/")[-1] if schema_name in schema_definitions: # Replace $ref with the actual schema definition - output_schema = schema_definitions[schema_name].copy() + output_schema = _ensure_refs_converted(schema_definitions[schema_name]) # Convert OpenAPI schema to JSON Schema format # Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types @@ -499,57 +546,26 @@ def extract_output_schema_from_responses( } output_schema = wrapped_schema - # Add schema definitions if available and handle nullable fields in them - # Only add $defs if we didn't resolve the $ref inline above - if schema_definitions and "$ref" not in schema.copy(): - processed_defs = {} - for def_name, def_schema in schema_definitions.items(): - # Convert OpenAPI schema definitions to JSON Schema format - if openapi_version and openapi_version.startswith("3.0"): - from .json_schema_converter import convert_openapi_schema_to_json_schema + # Add schema definitions if available + if schema_definitions: + # Convert refs if needed + processed_defs = schema_definitions.copy() + # Convert each schema definition recursively + for name, schema in processed_defs.items(): + if isinstance(schema, dict): + processed_defs[name] = _replace_ref_with_defs_recursive(schema) + # Convert OpenAPI schema definitions to JSON Schema format if needed + if openapi_version and openapi_version.startswith("3.0"): + from .json_schema_converter import convert_openapi_schema_to_json_schema + + for def_name in list(processed_defs.keys()): processed_defs[def_name] = convert_openapi_schema_to_json_schema( - def_schema, openapi_version + processed_defs[def_name], openapi_version ) - else: - processed_defs[def_name] = def_schema + output_schema["$defs"] = processed_defs - # Use lightweight compression - prune additionalProperties and unused definitions - if output_schema.get("additionalProperties") is False: - output_schema.pop("additionalProperties") - - # Remove unused definitions (lightweight approach - just check direct $ref usage) - if "$defs" in output_schema: - used_refs = set() - - def find_refs_in_value(value): - if isinstance(value, dict): - if "$ref" in value and isinstance(value["$ref"], str): - ref = value["$ref"] - if ref.startswith("#/$defs/"): - used_refs.add(ref.split("/")[-1]) - for v in value.values(): - find_refs_in_value(v) - elif isinstance(value, list): - for item in value: - find_refs_in_value(item) - - # Find refs in the main schema (excluding $defs section) - for key, value in output_schema.items(): - if key != "$defs": - find_refs_in_value(value) - - # Remove unused definitions - if used_refs: - output_schema["$defs"] = { - name: def_schema - for name, def_schema in output_schema["$defs"].items() - if name in used_refs - } - else: - output_schema.pop("$defs") - return output_schema @@ -559,6 +575,5 @@ __all__ = [ "_combine_schemas", "_combine_schemas_and_map_params", "extract_output_schema_from_responses", - "_replace_ref_with_defs", "_make_optional_parameter_nullable", ] diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py index 886456ca7..d0e8ae90b 100644 --- a/src/fastmcp/utilities/openapi.py +++ b/src/fastmcp/utilities/openapi.py @@ -212,7 +212,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute if openapi_version.startswith("3.0"): # Use OpenAPI 3.0 models openapi_30 = OpenAPI_30.model_validate(openapi_dict) - logger.info( + logger.debug( f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}" ) parser = OpenAPIParser( @@ -230,7 +230,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute else: # Default to OpenAPI 3.1 models openapi_31 = OpenAPI.model_validate(openapi_dict) - logger.info( + logger.debug( f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}" ) parser = OpenAPIParser( @@ -713,7 +713,7 @@ class OpenAPIParser( openapi_version=self.openapi_version, ) routes.append(route) - logger.info( + logger.debug( f"Successfully extracted route: {method_upper} {path_str}" ) except ValueError as op_error: @@ -734,7 +734,7 @@ class OpenAPIParser( exc_info=True, ) - logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.") + logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.") return routes diff --git a/tests/experimental/server/conftest.py b/tests/experimental/server/conftest.py new file mode 100644 index 000000000..3a6b24101 --- /dev/null +++ b/tests/experimental/server/conftest.py @@ -0,0 +1,11 @@ +"""Shared fixtures for openapi_new utilities tests.""" + +import pytest + +from fastmcp.utilities.tests import temporary_settings + + +@pytest.fixture(autouse=True) +def use_new_openapi_parser(): + with temporary_settings(experimental__enable_new_openapi_parser=True): + yield diff --git a/tests/experimental/server/test_openapi_performance.py b/tests/experimental/server/openapi/test_openapi_performance.py similarity index 98% rename from tests/experimental/server/test_openapi_performance.py rename to tests/experimental/server/openapi/test_openapi_performance.py index 0102ee5db..e962522e3 100644 --- a/tests/experimental/server/test_openapi_performance.py +++ b/tests/experimental/server/openapi/test_openapi_performance.py @@ -22,8 +22,8 @@ def use_new_openapi_parser(): class TestOpenAPIPerformance: """Performance tests for OpenAPI parsing with real-world large schemas.""" - # 20 second maximum timeout for this test no matter what - @pytest.mark.timeout(20) + # 10 second maximum timeout for this test no matter what + @pytest.mark.timeout(10) async def test_github_api_schema_performance(self): """ Test that GitHub's full API schema parses quickly. diff --git a/tests/experimental/server/openapi/test_performance_comparison.py b/tests/experimental/server/openapi/test_performance_comparison.py index cf326180c..99ee4f886 100644 --- a/tests/experimental/server/openapi/test_performance_comparison.py +++ b/tests/experimental/server/openapi/test_performance_comparison.py @@ -215,7 +215,7 @@ class TestPerformanceComparison: # Performance should be comparable (within reasonable margin) performance_ratio = max(new_avg, legacy_avg) / min(new_avg, legacy_avg) - assert performance_ratio < 2.0, ( + assert performance_ratio < 3.0, ( f"Performance should be comparable, ratio: {performance_ratio:.2f}x" ) @@ -286,6 +286,6 @@ class TestPerformanceComparison: current_refs = len(gc.get_objects()) # Allow reasonable memory growth but not exponential growth_ratio = current_refs / max(baseline_refs, 1) - assert growth_ratio < 5, ( + assert growth_ratio < 3.0, ( f"Memory usage grew by {growth_ratio}x, which seems excessive" ) diff --git a/tests/experimental/utilities/openapi/conftest.py b/tests/experimental/utilities/openapi/conftest.py index b7158dd1c..c5f497824 100644 --- a/tests/experimental/utilities/openapi/conftest.py +++ b/tests/experimental/utilities/openapi/conftest.py @@ -2,6 +2,14 @@ import pytest +from fastmcp.utilities.tests import temporary_settings + + +@pytest.fixture(autouse=True) +def use_new_openapi_parser(): + with temporary_settings(experimental__enable_new_openapi_parser=True): + yield + @pytest.fixture def basic_openapi_30_spec(): diff --git a/tests/experimental/utilities/openapi/test_parser.py b/tests/experimental/utilities/openapi/test_parser.py index 6c666b250..afd31edda 100644 --- a/tests/experimental/utilities/openapi/test_parser.py +++ b/tests/experimental/utilities/openapi/test_parser.py @@ -203,6 +203,135 @@ class TestOpenAPIParser: assert param.location == "path" assert param.required is True + def test_parse_simple_transitive_refs(self): + """Test that A->B->C transitive references are preserved. + + When a request body references schema A, which references B, which references C: + - A is expanded inline (expected optimization) + - B and C MUST be included in $defs (the bug fix for #1372) + """ + spec = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "components": { + "schemas": { + "SchemaA": { + "type": "object", + "properties": { + "refToB": {"$ref": "#/components/schemas/SchemaB"} + }, + }, + "SchemaB": { + "type": "object", + "properties": { + "refToC": {"$ref": "#/components/schemas/SchemaC"} + }, + }, + "SchemaC": { + "type": "string", + "enum": ["value1", "value2"], + }, + } + }, + "paths": { + "/test": { + "post": { + "operationId": "test_op", + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/SchemaA"} + } + } + }, + "responses": {"200": {"description": "OK"}}, + } + } + }, + } + + routes = parse_openapi_to_http_routes(spec) + route = routes[0] + + # SchemaA is expanded inline, so it's NOT in schema_definitions + assert "SchemaA" not in route.schema_definitions + + # But SchemaB and SchemaC MUST be there (transitive dependencies) + assert "SchemaB" in route.schema_definitions + assert "SchemaC" in route.schema_definitions + + # Same in the flat parameter schema + assert "SchemaB" in route.flat_param_schema["$defs"] + assert "SchemaC" in route.flat_param_schema["$defs"] + + def test_parse_tspicer_issue_1372(self): + """Reproduce the exact bug from issue #1372 (tspicer's report). + + Issue: Profile -> {countryCode, AccountInfo} transitive refs were missing from $defs. + """ + spec = { + "openapi": "3.0.1", + "info": {"title": "Test", "version": "1.0.0"}, + "components": { + "schemas": { + "Profile": { + "type": "object", + "properties": { + "profileId": {"type": "integer"}, + "countryCode": {"$ref": "#/components/schemas/countryCode"}, + "accountInfo": {"$ref": "#/components/schemas/AccountInfo"}, + }, + }, + "countryCode": { + "type": "string", + "enum": ["US", "UK", "CA", "AU"], + }, + "AccountInfo": { + "type": "object", + "properties": { + "accountId": {"type": "string"}, + "accountType": {"type": "string"}, + }, + }, + } + }, + "paths": { + "/profile": { + "post": { + "operationId": "create_profile", + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Profile"} + } + }, + }, + "responses": {"200": {"description": "OK"}}, + } + } + }, + } + + routes = parse_openapi_to_http_routes(spec) + route = routes[0] + + # Profile is expanded inline, NOT in schema_defs + assert "Profile" not in route.schema_definitions + + # Bug fix: countryCode and AccountInfo MUST be in schema_defs + assert "countryCode" in route.schema_definitions # Was missing in #1372 + assert "AccountInfo" in route.schema_definitions # Was missing in #1372 + + # Same in flat parameter schema + assert "countryCode" in route.flat_param_schema["$defs"] + assert "AccountInfo" in route.flat_param_schema["$defs"] + + # Verify Profile's properties were inlined correctly + props = route.flat_param_schema["properties"] + assert "profileId" in props + assert props["countryCode"]["$ref"] == "#/$defs/countryCode" + assert props["accountInfo"]["$ref"] == "#/$defs/AccountInfo" + def test_parameter_schema_extraction(self, complex_spec): """Test that parameter schemas are properly extracted.""" routes = parse_openapi_to_http_routes(complex_spec) diff --git a/tests/experimental/utilities/openapi/test_schemas.py b/tests/experimental/utilities/openapi/test_schemas.py index 6ee625d9c..2c210e752 100644 --- a/tests/experimental/utilities/openapi/test_schemas.py +++ b/tests/experimental/utilities/openapi/test_schemas.py @@ -10,8 +10,9 @@ from fastmcp.experimental.utilities.openapi.models import ( from fastmcp.experimental.utilities.openapi.schemas import ( _combine_schemas, _combine_schemas_and_map_params, - _replace_ref_with_defs, + _replace_ref_with_defs_recursive, ) +from fastmcp.utilities.json_schema import compress_schema class TestSchemaProcessing: @@ -230,6 +231,7 @@ class TestSchemaProcessing: def test_replace_ref_with_defs(self): """Test replacing $ref with $defs for JSON Schema compatibility.""" + schema_with_ref = { "type": "object", "properties": { @@ -241,13 +243,15 @@ class TestSchemaProcessing: }, } - result = _replace_ref_with_defs(schema_with_ref) + # Use our recursive replacement approach + result = _replace_ref_with_defs_recursive(schema_with_ref) assert result["properties"]["user"]["$ref"] == "#/$defs/User" assert result["properties"]["items"]["items"]["$ref"] == "#/$defs/Item" def test_replace_ref_with_defs_nested(self): """Test replacing $ref in deeply nested structures.""" + nested_schema = { "type": "object", "properties": { @@ -269,7 +273,8 @@ class TestSchemaProcessing: }, } - result = _replace_ref_with_defs(nested_schema) + # Use our recursive replacement approach + result = _replace_ref_with_defs_recursive(nested_schema) # Check nested object property nested_prop = result["properties"]["data"]["properties"]["nested"] @@ -476,7 +481,6 @@ class TestEdgeCases: def test_oneof_reference_preserved(self): """Test that schemas referenced in oneOf are preserved.""" - from fastmcp.utilities.json_schema import compress_schema schema = { "type": "object", @@ -497,7 +501,6 @@ class TestEdgeCases: def test_anyof_reference_preserved(self): """Test that schemas referenced in anyOf are preserved.""" - from fastmcp.utilities.json_schema import compress_schema schema = { "type": "object", @@ -515,7 +518,6 @@ class TestEdgeCases: def test_allof_reference_preserved(self): """Test that schemas referenced in allOf are preserved.""" - from fastmcp.utilities.json_schema import compress_schema schema = { "type": "object", diff --git a/tests/experimental/utilities/openapi/test_transitive_references.py b/tests/experimental/utilities/openapi/test_transitive_references.py new file mode 100644 index 000000000..2eab6e2be --- /dev/null +++ b/tests/experimental/utilities/openapi/test_transitive_references.py @@ -0,0 +1,677 @@ +"""Comprehensive tests for transitive and nested reference handling (Issue #1372).""" + +from fastmcp.experimental.utilities.openapi.models import ( + HTTPRoute, + ParameterInfo, + RequestBodyInfo, + ResponseInfo, +) +from fastmcp.experimental.utilities.openapi.schemas import ( + _combine_schemas_and_map_params, + extract_output_schema_from_responses, +) + + +class TestTransitiveAndNestedReferences: + """Comprehensive tests for transitive and nested reference handling (Issue #1372).""" + + def test_nested_refs_in_schema_definitions_converted(self): + """$refs inside schema definitions must be converted from OpenAPI to JSON Schema format.""" + route = HTTPRoute( + path="/users/{id}", + method="POST", + operation_id="create_user", + parameters=[ + ParameterInfo( + name="id", location="path", required=True, schema={"type": "string"} + ) + ], + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "object", + "properties": {"user": {"$ref": "#/components/schemas/User"}}, + } + }, + ), + schema_definitions={ + "User": { + "type": "object", + "properties": {"profile": {"$ref": "#/components/schemas/Profile"}}, + }, + "Profile": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # Root level refs should be converted + assert combined_schema["properties"]["user"]["$ref"] == "#/$defs/User" + + # Refs inside schema definitions should also be converted + user_def = combined_schema["$defs"]["User"] + assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile" + + def test_transitive_dependencies_in_response_schemas(self): + """Transitive dependencies (A→B→C) must all be preserved in response schemas.""" + # This mimics the exact structure reported in issue #1372 + responses = { + "201": ResponseInfo( + description="User created", + content_schema={ + "application/json": {"$ref": "#/components/schemas/User"} + }, + ) + } + + schema_definitions = { + "User": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "profile": {"$ref": "#/components/schemas/Profile"}, + }, + "required": ["id", "profile"], + }, + "Profile": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/components/schemas/Address"}, + }, + "required": ["name", "address"], + }, + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"}, + "zipcode": {"type": "string"}, + }, + "required": ["street", "city", "zipcode"], + }, + } + + result = extract_output_schema_from_responses( + responses, schema_definitions=schema_definitions, openapi_version="3.0.3" + ) + + # All transitive dependencies must be preserved + assert result is not None + assert "$defs" in result + assert "User" in result["$defs"], "User should be preserved" + assert "Profile" in result["$defs"], "Profile should be preserved" + assert "Address" in result["$defs"], "Address must be preserved (main bug)" + + # All refs should be converted to #/$defs format + user_def = result["$defs"]["User"] + assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile" + + profile_def = result["$defs"]["Profile"] + assert profile_def["properties"]["address"]["$ref"] == "#/$defs/Address" + + def test_elongl_reported_case_xref_with_nullable_function(self): + """Test the specific case reported by elongl with nullable function reference.""" + responses = { + "200": ResponseInfo( + description="Success", + content_schema={ + "application/json": { + "type": "array", + "items": {"$ref": "#/components/schemas/Xref"}, + } + }, + ) + } + + schema_definitions = { + "Xref": { + "type": "object", + "properties": { + "address": {"type": "string", "title": "Address"}, + "type": {"type": "string", "title": "Type"}, + "function": { + "anyOf": [ + {"$ref": "#/components/schemas/Function"}, + {"type": "null"}, + ] + }, + }, + "required": ["address", "type", "function"], + "title": "Xref", + }, + "Function": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"type": "string"}, + }, + "title": "Function", + }, + } + + result = extract_output_schema_from_responses( + responses, schema_definitions=schema_definitions + ) + + # Function must be included in $defs + assert result is not None + assert "$defs" in result + assert "Xref" in result["$defs"], "Xref should be preserved" + assert "Function" in result["$defs"], ( + "Function must be preserved (reported bug)" + ) + + # Refs in anyOf should be converted + xref_def = result["$defs"]["Xref"] + function_prop = xref_def["properties"]["function"] + assert function_prop["anyOf"][0]["$ref"] == "#/$defs/Function" + + def test_tspicer_reported_case_profile_with_nested_refs(self): + """Test the specific case reported by tspicer with Profile->countryCode->AccountInfo.""" + route = HTTPRoute( + path="/profile", + method="POST", + operation_id="create_profile", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": {"$ref": "#/components/schemas/Profile"} + }, + ), + schema_definitions={ + "Profile": { + "type": "object", + "properties": { + "profileId": {"type": "integer"}, + "countryCode": {"$ref": "#/components/schemas/countryCode"}, + "accountInfo": {"$ref": "#/components/schemas/AccountInfo"}, + }, + }, + "countryCode": { + "type": "string", + "enum": ["US", "UK", "CA", "AU"], + }, + "AccountInfo": { + "type": "object", + "properties": { + "accountId": {"type": "string"}, + "accountType": {"type": "string"}, + }, + }, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # All referenced schemas must be included in $defs + assert "Profile" in combined_schema["$defs"], "Profile should be preserved" + assert "countryCode" in combined_schema["$defs"], ( + "countryCode must be preserved" + ) + assert "AccountInfo" in combined_schema["$defs"], ( + "AccountInfo must be preserved" + ) + + # All refs should be converted + profile_def = combined_schema["$defs"]["Profile"] + assert profile_def["properties"]["countryCode"]["$ref"] == "#/$defs/countryCode" + assert profile_def["properties"]["accountInfo"]["$ref"] == "#/$defs/AccountInfo" + + def test_transitive_refs_in_request_body_schemas(self): + """Transitive $refs in request body schemas must be preserved and converted.""" + route = HTTPRoute( + path="/users", + method="POST", + operation_id="create_user", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": {"$ref": "#/components/schemas/User"} + }, + ), + schema_definitions={ + "User": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "profile": {"$ref": "#/components/schemas/Profile"}, + }, + "required": ["id", "profile"], + }, + "Profile": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/components/schemas/Address"}, + }, + "required": ["name", "address"], + }, + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"}, + "zipcode": {"type": "string"}, + }, + "required": ["street", "city", "zipcode"], + }, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # All transitive dependencies should be preserved + assert "User" in combined_schema["$defs"] + assert "Profile" in combined_schema["$defs"] + assert "Address" in combined_schema["$defs"] + + # All internal refs should be converted to #/$defs format + user_def = combined_schema["$defs"]["User"] + assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile" + + profile_def = combined_schema["$defs"]["Profile"] + assert profile_def["properties"]["address"]["$ref"] == "#/$defs/Address" + + def test_refs_in_array_items_converted(self): + """$refs inside array items must be converted from OpenAPI to JSON Schema format.""" + route = HTTPRoute( + path="/users", + method="POST", + operation_id="create_users", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": {"$ref": "#/components/schemas/User"}, + } + }, + } + }, + ), + schema_definitions={ + "User": { + "type": "object", + "properties": {"profile": {"$ref": "#/components/schemas/Profile"}}, + }, + "Profile": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # Array item refs should be converted + assert combined_schema["properties"]["users"]["items"]["$ref"] == "#/$defs/User" + + # Nested refs should be converted + user_def = combined_schema["$defs"]["User"] + assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile" + + def test_refs_in_composition_keywords_converted(self): + """$refs inside oneOf/anyOf/allOf must be converted from OpenAPI to JSON Schema format.""" + route = HTTPRoute( + path="/data", + method="POST", + operation_id="create_data", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "object", + "properties": { + "data": { + "oneOf": [ + {"$ref": "#/components/schemas/TypeA"}, + {"$ref": "#/components/schemas/TypeB"}, + ] + }, + "alternate": { + "anyOf": [ + {"$ref": "#/components/schemas/TypeC"}, + {"$ref": "#/components/schemas/TypeD"}, + ] + }, + "combined": { + "allOf": [ + {"$ref": "#/components/schemas/BaseType"}, + {"properties": {"extra": {"type": "string"}}}, + ] + }, + }, + } + }, + ), + schema_definitions={ + "TypeA": { + "type": "object", + "properties": {"nested": {"$ref": "#/components/schemas/Nested"}}, + }, + "TypeB": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + "TypeC": {"type": "string"}, + "TypeD": {"type": "number"}, + "BaseType": { + "type": "object", + "properties": {"base": {"type": "string"}}, + }, + "Nested": {"type": "string"}, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # oneOf refs should be converted + oneof_refs = combined_schema["properties"]["data"]["oneOf"] + assert oneof_refs[0]["$ref"] == "#/$defs/TypeA" + assert oneof_refs[1]["$ref"] == "#/$defs/TypeB" + + # anyOf refs should be converted + anyof_refs = combined_schema["properties"]["alternate"]["anyOf"] + assert anyof_refs[0]["$ref"] == "#/$defs/TypeC" + assert anyof_refs[1]["$ref"] == "#/$defs/TypeD" + + # allOf refs should be converted + allof_refs = combined_schema["properties"]["combined"]["allOf"] + assert allof_refs[0]["$ref"] == "#/$defs/BaseType" + + # Transitive refs should be converted + type_a_def = combined_schema["$defs"]["TypeA"] + assert type_a_def["properties"]["nested"]["$ref"] == "#/$defs/Nested" + + def test_deeply_nested_transitive_refs_preserved(self): + """Deeply nested transitive refs (A→B→C→D→E) must all be preserved.""" + route = HTTPRoute( + path="/deep", + method="POST", + operation_id="create_deep", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": {"$ref": "#/components/schemas/Level1"} + }, + ), + schema_definitions={ + "Level1": { + "type": "object", + "properties": {"level2": {"$ref": "#/components/schemas/Level2"}}, + }, + "Level2": { + "type": "object", + "properties": {"level3": {"$ref": "#/components/schemas/Level3"}}, + }, + "Level3": { + "type": "object", + "properties": {"level4": {"$ref": "#/components/schemas/Level4"}}, + }, + "Level4": { + "type": "object", + "properties": {"level5": {"$ref": "#/components/schemas/Level5"}}, + }, + "Level5": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + "UnusedSchema": {"type": "number"}, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # All levels should be preserved + assert "Level1" in combined_schema["$defs"] + assert "Level2" in combined_schema["$defs"] + assert "Level3" in combined_schema["$defs"] + assert "Level4" in combined_schema["$defs"] + assert "Level5" in combined_schema["$defs"] + + # Unused should be removed (pruning is allowed for unused schemas) + assert "UnusedSchema" not in combined_schema["$defs"] + + # All refs should be converted + assert ( + combined_schema["$defs"]["Level1"]["properties"]["level2"]["$ref"] + == "#/$defs/Level2" + ) + assert ( + combined_schema["$defs"]["Level2"]["properties"]["level3"]["$ref"] + == "#/$defs/Level3" + ) + assert ( + combined_schema["$defs"]["Level3"]["properties"]["level4"]["$ref"] + == "#/$defs/Level4" + ) + assert ( + combined_schema["$defs"]["Level4"]["properties"]["level5"]["$ref"] + == "#/$defs/Level5" + ) + + def test_circular_references_handled(self): + """Circular references (A→B→A) must be handled without infinite loops.""" + route = HTTPRoute( + path="/circular", + method="POST", + operation_id="circular_test", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": {"$ref": "#/components/schemas/Node"} + }, + ), + schema_definitions={ + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/components/schemas/Node"}, + }, + }, + }, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # Node should be preserved + assert "Node" in combined_schema["$defs"] + + # Self-reference should be converted + node_def = combined_schema["$defs"]["Node"] + assert node_def["properties"]["children"]["items"]["$ref"] == "#/$defs/Node" + + def test_multiple_reference_paths_to_same_schema(self): + """Multiple paths to the same schema (diamond pattern) must preserve the schema.""" + route = HTTPRoute( + path="/diamond", + method="POST", + operation_id="diamond_test", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "object", + "properties": { + "left": {"$ref": "#/components/schemas/Left"}, + "right": {"$ref": "#/components/schemas/Right"}, + }, + } + }, + ), + schema_definitions={ + "Left": { + "type": "object", + "properties": {"shared": {"$ref": "#/components/schemas/Shared"}}, + }, + "Right": { + "type": "object", + "properties": {"shared": {"$ref": "#/components/schemas/Shared"}}, + }, + "Shared": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # All schemas should be preserved + assert "Left" in combined_schema["$defs"] + assert "Right" in combined_schema["$defs"] + assert "Shared" in combined_schema["$defs"] + + # All refs should be converted + assert combined_schema["properties"]["left"]["$ref"] == "#/$defs/Left" + assert combined_schema["properties"]["right"]["$ref"] == "#/$defs/Right" + assert ( + combined_schema["$defs"]["Left"]["properties"]["shared"]["$ref"] + == "#/$defs/Shared" + ) + assert ( + combined_schema["$defs"]["Right"]["properties"]["shared"]["$ref"] + == "#/$defs/Shared" + ) + + def test_refs_in_nested_content_schemas(self): + """$refs in nested content schemas (the original bug location) must be converted.""" + route = HTTPRoute( + path="/content", + method="POST", + operation_id="content_test", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": {"$ref": "#/components/schemas/Content"} + }, + ), + schema_definitions={ + "Content": { + "type": "object", + "properties": { + "media": { + "type": "object", + "properties": { + "application/json": { + "$ref": "#/components/schemas/JsonContent" + } + }, + } + }, + }, + "JsonContent": { + "type": "object", + "properties": {"data": {"type": "string"}}, + }, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # Both schemas should be preserved + assert "Content" in combined_schema["$defs"] + assert "JsonContent" in combined_schema["$defs"] + + # Nested ref should be converted + content_def = combined_schema["$defs"]["Content"] + nested_ref = content_def["properties"]["media"]["properties"][ + "application/json" + ] + assert nested_ref["$ref"] == "#/$defs/JsonContent" + + def test_unnecessary_defs_preserved_when_referenced(self): + """Even seemingly unnecessary $defs must be preserved if they're referenced.""" + route = HTTPRoute( + path="/test", + method="POST", + operation_id="test_unnecessary", + request_body=RequestBodyInfo( + required=True, + content_schema={ + "application/json": { + "type": "object", + "properties": { + # Reference to a simple type schema + "simple": {"$ref": "#/components/schemas/SimpleString"}, + # Reference to an empty object schema + "empty": {"$ref": "#/components/schemas/EmptyObject"}, + }, + } + }, + ), + schema_definitions={ + "SimpleString": {"type": "string"}, + "EmptyObject": {"type": "object"}, + "UnreferencedSchema": {"type": "number"}, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # Referenced schemas should be preserved even if simple + assert "SimpleString" in combined_schema["$defs"] + assert "EmptyObject" in combined_schema["$defs"] + + # Unreferenced should be removed + assert "UnreferencedSchema" not in combined_schema["$defs"] + + # Refs should be converted + assert combined_schema["properties"]["simple"]["$ref"] == "#/$defs/SimpleString" + assert combined_schema["properties"]["empty"]["$ref"] == "#/$defs/EmptyObject" + + def test_ref_only_request_body_handled(self): + """Request bodies that are just a $ref (not an object with properties) must work.""" + route = HTTPRoute( + path="/direct-ref", + method="POST", + operation_id="direct_ref_test", + request_body=RequestBodyInfo( + required=True, + content_schema={ + # Direct $ref, not wrapped in an object + "application/json": {"$ref": "#/components/schemas/DirectBody"} + }, + ), + schema_definitions={ + "DirectBody": { + "type": "object", + "properties": { + "field1": {"type": "string"}, + "nested": {"$ref": "#/components/schemas/NestedBody"}, + }, + }, + "NestedBody": { + "type": "object", + "properties": {"field2": {"type": "number"}}, + }, + }, + ) + + combined_schema, _ = _combine_schemas_and_map_params(route) + + # Should handle the direct ref properly + assert "body" in combined_schema["properties"] + assert combined_schema["properties"]["body"]["$ref"] == "#/$defs/DirectBody" + + # Both schemas should be preserved + assert "DirectBody" in combined_schema["$defs"] + assert "NestedBody" in combined_schema["$defs"] + + # Nested ref should be converted + assert ( + combined_schema["$defs"]["DirectBody"]["properties"]["nested"]["$ref"] + == "#/$defs/NestedBody" + ) diff --git a/uv.lock b/uv.lock index 3e43002ba..df705fd41 100644 --- a/uv.lock +++ b/uv.lock @@ -530,6 +530,7 @@ dependencies = [ { name = "exceptiongroup" }, { name = "httpx" }, { name = "mcp" }, + { name = "msgspec" }, { name = "openapi-core" }, { name = "openapi-pydantic" }, { name = "pydantic", extra = ["email"] }, @@ -574,6 +575,7 @@ requires-dist = [ { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.10.0" }, + { name = "msgspec", specifier = ">=0.19.0" }, { name = "openapi-core", specifier = ">=0.19.5" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, @@ -979,6 +981,42 @@ wheels = [ { 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" }, ] +[[package]] +name = "msgspec" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, +] + [[package]] name = "nodeenv" version = "1.9.1"