mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
Optimize OpenAPI payload size by 46% (#1452)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c6768dad5f
commit
dd34846266
10 changed files with 326 additions and 109 deletions
|
|
@ -11,7 +11,7 @@ from jsonschema_path import SchemaPath
|
|||
from fastmcp.experimental.utilities.openapi import (
|
||||
HTTPRoute,
|
||||
extract_output_schema_from_responses,
|
||||
format_description_with_responses,
|
||||
format_simple_description,
|
||||
parse_openapi_to_http_routes,
|
||||
)
|
||||
from fastmcp.experimental.utilities.openapi.director import RequestDirector
|
||||
|
|
@ -267,7 +267,9 @@ class FastMCPOpenAPI(FastMCP):
|
|||
|
||||
# Extract output schema from OpenAPI responses
|
||||
output_schema = extract_output_schema_from_responses(
|
||||
route.responses, route.schema_definitions, route.openapi_version
|
||||
route.responses,
|
||||
route.response_schemas,
|
||||
route.openapi_version,
|
||||
)
|
||||
|
||||
# Get a unique tool name
|
||||
|
|
@ -279,10 +281,9 @@ class FastMCPOpenAPI(FastMCP):
|
|||
or f"Executes {route.method} {route.path}"
|
||||
)
|
||||
|
||||
# Format enhanced description with parameters and request body
|
||||
enhanced_description = format_description_with_responses(
|
||||
# Use simplified description formatter for tools
|
||||
enhanced_description = format_simple_description(
|
||||
base_description=base_description,
|
||||
responses=route.responses,
|
||||
parameters=route.parameters,
|
||||
request_body=route.request_body,
|
||||
)
|
||||
|
|
@ -331,10 +332,9 @@ class FastMCPOpenAPI(FastMCP):
|
|||
route.description or route.summary or f"Represents {route.path}"
|
||||
)
|
||||
|
||||
# Format enhanced description with parameters and request body
|
||||
enhanced_description = format_description_with_responses(
|
||||
# Use simplified description for resources
|
||||
enhanced_description = format_simple_description(
|
||||
base_description=base_description,
|
||||
responses=route.responses,
|
||||
parameters=route.parameters,
|
||||
request_body=route.request_body,
|
||||
)
|
||||
|
|
@ -388,10 +388,9 @@ class FastMCPOpenAPI(FastMCP):
|
|||
route.description or route.summary or f"Template for {route.path}"
|
||||
)
|
||||
|
||||
# Format enhanced description with parameters and request body
|
||||
enhanced_description = format_description_with_responses(
|
||||
# Use simplified description for resource templates
|
||||
enhanced_description = format_simple_description(
|
||||
base_description=base_description,
|
||||
responses=route.responses,
|
||||
parameters=route.parameters,
|
||||
request_body=route.request_body,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from .formatters import (
|
|||
format_deep_object_parameter,
|
||||
format_description_with_responses,
|
||||
format_json_for_description,
|
||||
format_simple_description,
|
||||
generate_example_from_schema,
|
||||
)
|
||||
|
||||
|
|
@ -54,6 +55,7 @@ __all__ = [
|
|||
"format_deep_object_parameter",
|
||||
"format_description_with_responses",
|
||||
"format_json_for_description",
|
||||
"format_simple_description",
|
||||
"generate_example_from_schema",
|
||||
# Schemas
|
||||
"_combine_schemas",
|
||||
|
|
|
|||
|
|
@ -189,6 +189,39 @@ def format_json_for_description(data: Any, indent: int = 2) -> str:
|
|||
return f"```\nCould not serialize to JSON: {data}\n```"
|
||||
|
||||
|
||||
def format_simple_description(
|
||||
base_description: str,
|
||||
parameters: list[ParameterInfo] | None = None,
|
||||
request_body: RequestBodyInfo | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Formats a simple description for MCP objects (tools, resources, prompts).
|
||||
Excludes response details, examples, and verbose status codes.
|
||||
|
||||
Args:
|
||||
base_description (str): The initial description to be formatted.
|
||||
parameters (list[ParameterInfo] | None, optional): A list of parameter information.
|
||||
request_body (RequestBodyInfo | None, optional): Information about the request body.
|
||||
|
||||
Returns:
|
||||
str: The formatted description string with minimal details.
|
||||
"""
|
||||
desc_parts = [base_description]
|
||||
|
||||
# Only add critical parameter information if they have descriptions
|
||||
if parameters:
|
||||
path_params = [p for p in parameters if p.location == "path" and p.description]
|
||||
if path_params:
|
||||
desc_parts.append("\n\n**Path Parameters:**")
|
||||
for param in path_params:
|
||||
desc_parts.append(f"\n- **{param.name}**: {param.description}")
|
||||
|
||||
# Skip query parameters, request body details, and all response information
|
||||
# These are already captured in the inputSchema
|
||||
|
||||
return "\n".join(desc_parts)
|
||||
|
||||
|
||||
def format_description_with_responses(
|
||||
base_description: str,
|
||||
responses: dict[
|
||||
|
|
@ -351,5 +384,6 @@ __all__ = [
|
|||
"format_deep_object_parameter",
|
||||
"format_description_with_responses",
|
||||
"format_json_for_description",
|
||||
"format_simple_description",
|
||||
"generate_example_from_schema",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -58,9 +58,12 @@ class HTTPRoute(FastMCPBaseModel):
|
|||
responses: dict[str, ResponseInfo] = Field(
|
||||
default_factory=dict
|
||||
) # Key: status code str
|
||||
schema_definitions: dict[str, JsonSchema] = Field(
|
||||
request_schemas: dict[str, JsonSchema] = Field(
|
||||
default_factory=dict
|
||||
) # Store component schemas
|
||||
) # Store schemas needed for input (parameters/request body)
|
||||
response_schemas: dict[str, JsonSchema] = Field(
|
||||
default_factory=dict
|
||||
) # Store schemas needed for output (responses)
|
||||
extensions: dict[str, Any] = Field(default_factory=dict)
|
||||
openapi_version: str | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -402,77 +402,116 @@ class OpenAPIParser(
|
|||
)
|
||||
return None
|
||||
|
||||
def _is_success_status_code(self, status_code: str) -> bool:
|
||||
"""Check if a status code represents a successful response (2xx)."""
|
||||
try:
|
||||
code_int = int(status_code)
|
||||
return 200 <= code_int < 300
|
||||
except (ValueError, TypeError):
|
||||
# Handle special cases like 'default' or other non-numeric codes
|
||||
return status_code.lower() in ["default", "2xx"]
|
||||
|
||||
def _get_primary_success_response(
|
||||
self, operation_responses: dict[str, Any]
|
||||
) -> tuple[str, Any] | None:
|
||||
"""Get the primary success response for an MCP tool. We only need one success response."""
|
||||
if not operation_responses:
|
||||
return None
|
||||
|
||||
# Priority order: 200, 201, 202, 204, 207, then any other 2xx
|
||||
priority_codes = ["200", "201", "202", "204", "207"]
|
||||
|
||||
# First check priority codes
|
||||
for code in priority_codes:
|
||||
if code in operation_responses:
|
||||
return (code, operation_responses[code])
|
||||
|
||||
# Then check any other 2xx codes
|
||||
for status_code, resp_or_ref in operation_responses.items():
|
||||
if self._is_success_status_code(status_code):
|
||||
return (status_code, resp_or_ref)
|
||||
|
||||
# If no success codes found, return None (tool will have no output schema)
|
||||
return None
|
||||
|
||||
def _extract_responses(
|
||||
self, operation_responses: dict[str, Any] | None
|
||||
) -> dict[str, ResponseInfo]:
|
||||
"""Extract and resolve response information."""
|
||||
"""Extract and resolve response information. Only includes the primary success response for MCP tools."""
|
||||
extracted_responses: dict[str, ResponseInfo] = {}
|
||||
|
||||
if not operation_responses:
|
||||
return extracted_responses
|
||||
|
||||
for status_code, resp_or_ref in operation_responses.items():
|
||||
try:
|
||||
response = self._resolve_ref(resp_or_ref)
|
||||
# For MCP tools, we only need the primary success response
|
||||
primary_response = self._get_primary_success_response(operation_responses)
|
||||
if not primary_response:
|
||||
logger.debug("No success responses found, tool will have no output schema")
|
||||
return extracted_responses
|
||||
|
||||
if not isinstance(response, self.response_cls):
|
||||
logger.warning(
|
||||
f"Expected Response after resolving for status code {status_code}, "
|
||||
f"got {type(response)}. Skipping."
|
||||
)
|
||||
continue
|
||||
status_code, resp_or_ref = primary_response
|
||||
logger.debug(f"Using primary success response: {status_code}")
|
||||
|
||||
# Create response info
|
||||
resp_info = ResponseInfo(description=response.description)
|
||||
try:
|
||||
response = self._resolve_ref(resp_or_ref)
|
||||
|
||||
# Extract content schemas
|
||||
if hasattr(response, "content") and response.content:
|
||||
for media_type_str, media_type_obj in response.content.items():
|
||||
if (
|
||||
media_type_obj
|
||||
and hasattr(media_type_obj, "media_type_schema")
|
||||
and media_type_obj.media_type_schema
|
||||
):
|
||||
try:
|
||||
schema_dict = self._extract_schema_as_dict(
|
||||
media_type_obj.media_type_schema
|
||||
)
|
||||
resp_info.content_schema[media_type_str] = schema_dict
|
||||
except ValueError as e:
|
||||
# Re-raise ValueError for external reference errors
|
||||
if (
|
||||
"External or non-local reference not supported"
|
||||
in str(e)
|
||||
):
|
||||
raise
|
||||
logger.error(
|
||||
f"Failed to extract schema for media type '{media_type_str}' "
|
||||
f"in response {status_code}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to extract schema for media type '{media_type_str}' "
|
||||
f"in response {status_code}: {e}"
|
||||
)
|
||||
|
||||
extracted_responses[str(status_code)] = resp_info
|
||||
except ValueError as e:
|
||||
# Re-raise ValueError for external reference errors
|
||||
if "External or non-local reference not supported" in str(e):
|
||||
raise
|
||||
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
||||
logger.error(
|
||||
f"Failed to extract response for status code {status_code} "
|
||||
f"from reference '{ref_name}': {e}",
|
||||
exc_info=False,
|
||||
)
|
||||
except Exception as e:
|
||||
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
||||
logger.error(
|
||||
f"Failed to extract response for status code {status_code} "
|
||||
f"from reference '{ref_name}': {e}",
|
||||
exc_info=False,
|
||||
if not isinstance(response, self.response_cls):
|
||||
logger.warning(
|
||||
f"Expected Response after resolving for status code {status_code}, "
|
||||
f"got {type(response)}. Returning empty responses."
|
||||
)
|
||||
return extracted_responses
|
||||
|
||||
# Create response info
|
||||
resp_info = ResponseInfo(description=response.description)
|
||||
|
||||
# Extract content schemas
|
||||
if hasattr(response, "content") and response.content:
|
||||
for media_type_str, media_type_obj in response.content.items():
|
||||
if (
|
||||
media_type_obj
|
||||
and hasattr(media_type_obj, "media_type_schema")
|
||||
and media_type_obj.media_type_schema
|
||||
):
|
||||
try:
|
||||
schema_dict = self._extract_schema_as_dict(
|
||||
media_type_obj.media_type_schema
|
||||
)
|
||||
resp_info.content_schema[media_type_str] = schema_dict
|
||||
except ValueError as e:
|
||||
# Re-raise ValueError for external reference errors
|
||||
if "External or non-local reference not supported" in str(
|
||||
e
|
||||
):
|
||||
raise
|
||||
logger.error(
|
||||
f"Failed to extract schema for media type '{media_type_str}' "
|
||||
f"in response {status_code}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to extract schema for media type '{media_type_str}' "
|
||||
f"in response {status_code}: {e}"
|
||||
)
|
||||
|
||||
extracted_responses[str(status_code)] = resp_info
|
||||
except ValueError as e:
|
||||
# Re-raise ValueError for external reference errors
|
||||
if "External or non-local reference not supported" in str(e):
|
||||
raise
|
||||
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
||||
logger.error(
|
||||
f"Failed to extract response for status code {status_code} "
|
||||
f"from reference '{ref_name}': {e}",
|
||||
exc_info=False,
|
||||
)
|
||||
except Exception as e:
|
||||
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
||||
logger.error(
|
||||
f"Failed to extract response for status code {status_code} "
|
||||
f"from reference '{ref_name}': {e}",
|
||||
exc_info=False,
|
||||
)
|
||||
|
||||
return extracted_responses
|
||||
|
||||
|
|
@ -525,24 +564,22 @@ class OpenAPIParser(
|
|||
find_refs(schema)
|
||||
return collected
|
||||
|
||||
def _extract_route_schema_dependencies(
|
||||
def _extract_input_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.
|
||||
Extract only the schema definitions needed for input (parameters and request body).
|
||||
|
||||
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
|
||||
Dictionary containing only the schemas needed for input
|
||||
"""
|
||||
needed_schemas = set()
|
||||
|
||||
|
|
@ -558,6 +595,28 @@ class OpenAPIParser(
|
|||
deps = self._extract_schema_dependencies(content_schema, all_schemas)
|
||||
needed_schemas.update(deps)
|
||||
|
||||
# Return only the needed input schemas
|
||||
return {
|
||||
name: all_schemas[name] for name in needed_schemas if name in all_schemas
|
||||
}
|
||||
|
||||
def _extract_output_schema_dependencies(
|
||||
self,
|
||||
responses: dict[str, ResponseInfo],
|
||||
all_schemas: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Extract only the schema definitions needed for outputs (responses).
|
||||
|
||||
Args:
|
||||
responses: Route responses
|
||||
all_schemas: All available schema definitions
|
||||
|
||||
Returns:
|
||||
Dictionary containing only the schemas needed for outputs
|
||||
"""
|
||||
needed_schemas = set()
|
||||
|
||||
# Check responses for schema references
|
||||
for response in responses.values():
|
||||
if response.content_schema:
|
||||
|
|
@ -567,7 +626,7 @@ class OpenAPIParser(
|
|||
)
|
||||
needed_schemas.update(deps)
|
||||
|
||||
# Return only the needed schemas
|
||||
# Return only the needed output schemas
|
||||
return {
|
||||
name: all_schemas[name] for name in needed_schemas if name in all_schemas
|
||||
}
|
||||
|
|
@ -661,10 +720,13 @@ class OpenAPIParser(
|
|||
if k.startswith("x-")
|
||||
}
|
||||
|
||||
# Extract only the schemas needed for this route
|
||||
route_schemas = self._extract_route_schema_dependencies(
|
||||
# Extract schemas separately for input and output
|
||||
input_schemas = self._extract_input_schema_dependencies(
|
||||
parameters,
|
||||
request_body_info,
|
||||
schema_definitions,
|
||||
)
|
||||
output_schemas = self._extract_output_schema_dependencies(
|
||||
responses,
|
||||
schema_definitions,
|
||||
)
|
||||
|
|
@ -680,7 +742,8 @@ class OpenAPIParser(
|
|||
parameters=parameters,
|
||||
request_body=request_body_info,
|
||||
responses=responses,
|
||||
schema_definitions=route_schemas, # Use pre-pruned schemas
|
||||
request_schemas=input_schemas,
|
||||
response_schemas=output_schemas,
|
||||
extensions=extensions,
|
||||
openapi_version=self.openapi_version,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -364,10 +364,11 @@ def _combine_schemas_and_map_params(
|
|||
"required": required,
|
||||
}
|
||||
# Add schema definitions if available
|
||||
if route.schema_definitions:
|
||||
schema_defs = route.request_schemas
|
||||
if schema_defs:
|
||||
if convert_refs:
|
||||
# Need to convert refs and prune
|
||||
all_defs = route.schema_definitions.copy()
|
||||
all_defs = schema_defs.copy()
|
||||
# Convert each schema definition recursively
|
||||
for name, schema in all_defs.items():
|
||||
if isinstance(schema, dict):
|
||||
|
|
@ -409,7 +410,7 @@ def _combine_schemas_and_map_params(
|
|||
}
|
||||
else:
|
||||
# From parser - already converted and pruned
|
||||
result["$defs"] = route.schema_definitions
|
||||
result["$defs"] = schema_defs
|
||||
|
||||
return result, parameter_map
|
||||
|
||||
|
|
|
|||
|
|
@ -116,8 +116,10 @@ class TestEndToEndCompatibility:
|
|||
assert legacy_tool.name == new_tool.name
|
||||
assert legacy_tool.name == "get_user"
|
||||
|
||||
# Descriptions should be identical
|
||||
assert legacy_tool.description == new_tool.description
|
||||
# Descriptions may differ (new server has simplified descriptions)
|
||||
# Just check that both have descriptions
|
||||
assert legacy_tool.description
|
||||
assert new_tool.description
|
||||
|
||||
# Input schemas should be identical
|
||||
legacy_schema = legacy_tool.inputSchema
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ class TestHTTPRoute:
|
|||
parameters=parameters,
|
||||
request_body=request_body,
|
||||
responses=responses,
|
||||
schema_definitions={"User": {"type": "object"}},
|
||||
request_schemas={"User": {"type": "object"}},
|
||||
extensions={"x-custom": "value"},
|
||||
)
|
||||
|
||||
|
|
@ -261,7 +261,7 @@ class TestHTTPRoute:
|
|||
assert len(route.parameters) == 1
|
||||
assert route.request_body is not None
|
||||
assert "200" in route.responses
|
||||
assert "User" in route.schema_definitions
|
||||
assert "User" in route.request_schemas
|
||||
assert route.extensions["x-custom"] == "value"
|
||||
|
||||
def test_route_pre_calculated_fields(self):
|
||||
|
|
@ -303,14 +303,15 @@ class TestHTTPRoute:
|
|||
tags=[],
|
||||
parameters=[],
|
||||
responses={},
|
||||
schema_definitions={},
|
||||
request_schemas={},
|
||||
extensions={},
|
||||
)
|
||||
|
||||
assert route.tags == []
|
||||
assert route.parameters == []
|
||||
assert route.responses == {}
|
||||
assert route.schema_definitions == {}
|
||||
assert route.request_schemas == {}
|
||||
assert route.response_schemas == {}
|
||||
assert route.extensions == {}
|
||||
|
||||
def test_route_defaults(self):
|
||||
|
|
@ -327,7 +328,8 @@ class TestHTTPRoute:
|
|||
assert route.parameters == []
|
||||
assert route.request_body is None
|
||||
assert route.responses == {}
|
||||
assert route.schema_definitions == {}
|
||||
assert route.request_schemas == {}
|
||||
assert route.response_schemas == {}
|
||||
assert route.extensions == {}
|
||||
assert route.flat_param_schema == {}
|
||||
assert route.parameter_map == {}
|
||||
|
|
|
|||
|
|
@ -253,12 +253,12 @@ class TestOpenAPIParser:
|
|||
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
|
||||
# SchemaA is expanded inline, so it's NOT in request_schemas
|
||||
assert "SchemaA" not in route.request_schemas
|
||||
|
||||
# But SchemaB and SchemaC MUST be there (transitive dependencies)
|
||||
assert "SchemaB" in route.schema_definitions
|
||||
assert "SchemaC" in route.schema_definitions
|
||||
assert "SchemaB" in route.request_schemas
|
||||
assert "SchemaC" in route.request_schemas
|
||||
|
||||
# Same in the flat parameter schema
|
||||
assert "SchemaB" in route.flat_param_schema["$defs"]
|
||||
|
|
@ -316,11 +316,11 @@ class TestOpenAPIParser:
|
|||
route = routes[0]
|
||||
|
||||
# Profile is expanded inline, NOT in schema_defs
|
||||
assert "Profile" not in route.schema_definitions
|
||||
assert "Profile" not in route.request_schemas
|
||||
|
||||
# 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
|
||||
assert "countryCode" in route.request_schemas # Was missing in #1372
|
||||
assert "AccountInfo" in route.request_schemas # Was missing in #1372
|
||||
|
||||
# Same in flat parameter schema
|
||||
assert "countryCode" in route.flat_param_schema["$defs"]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from fastmcp.experimental.utilities.openapi.models import (
|
|||
RequestBodyInfo,
|
||||
ResponseInfo,
|
||||
)
|
||||
from fastmcp.experimental.utilities.openapi.parser import parse_openapi_to_http_routes
|
||||
from fastmcp.experimental.utilities.openapi.schemas import (
|
||||
_combine_schemas_and_map_params,
|
||||
extract_output_schema_from_responses,
|
||||
|
|
@ -35,7 +36,7 @@ class TestTransitiveAndNestedReferences:
|
|||
}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"User": {
|
||||
"type": "object",
|
||||
"properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
|
||||
|
|
@ -183,7 +184,7 @@ class TestTransitiveAndNestedReferences:
|
|||
"application/json": {"$ref": "#/components/schemas/Profile"}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"Profile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -234,7 +235,7 @@ class TestTransitiveAndNestedReferences:
|
|||
"application/json": {"$ref": "#/components/schemas/User"}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -297,7 +298,7 @@ class TestTransitiveAndNestedReferences:
|
|||
}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"User": {
|
||||
"type": "object",
|
||||
"properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
|
||||
|
|
@ -352,7 +353,7 @@ class TestTransitiveAndNestedReferences:
|
|||
}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"TypeA": {
|
||||
"type": "object",
|
||||
"properties": {"nested": {"$ref": "#/components/schemas/Nested"}},
|
||||
|
|
@ -403,7 +404,7 @@ class TestTransitiveAndNestedReferences:
|
|||
"application/json": {"$ref": "#/components/schemas/Level1"}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"Level1": {
|
||||
"type": "object",
|
||||
"properties": {"level2": {"$ref": "#/components/schemas/Level2"}},
|
||||
|
|
@ -470,7 +471,7 @@ class TestTransitiveAndNestedReferences:
|
|||
"application/json": {"$ref": "#/components/schemas/Node"}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"Node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -511,7 +512,7 @@ class TestTransitiveAndNestedReferences:
|
|||
}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"Left": {
|
||||
"type": "object",
|
||||
"properties": {"shared": {"$ref": "#/components/schemas/Shared"}},
|
||||
|
|
@ -558,7 +559,7 @@ class TestTransitiveAndNestedReferences:
|
|||
"application/json": {"$ref": "#/components/schemas/Content"}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"Content": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -612,7 +613,7 @@ class TestTransitiveAndNestedReferences:
|
|||
}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"SimpleString": {"type": "string"},
|
||||
"EmptyObject": {"type": "object"},
|
||||
"UnreferencedSchema": {"type": "number"},
|
||||
|
|
@ -645,7 +646,7 @@ class TestTransitiveAndNestedReferences:
|
|||
"application/json": {"$ref": "#/components/schemas/DirectBody"}
|
||||
},
|
||||
),
|
||||
schema_definitions={
|
||||
request_schemas={
|
||||
"DirectBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -675,3 +676,113 @@ class TestTransitiveAndNestedReferences:
|
|||
combined_schema["$defs"]["DirectBody"]["properties"]["nested"]["$ref"]
|
||||
== "#/$defs/NestedBody"
|
||||
)
|
||||
|
||||
def test_separate_input_output_schemas(self):
|
||||
"""Test that input and output schemas contain different schema
|
||||
definitions and don't overlap in the ultimate schema definitions."""
|
||||
# OpenAPI spec with transitive dependencies to force schema inclusion
|
||||
openapi_spec = {
|
||||
"openapi": "3.0.1",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/test": {
|
||||
"post": {
|
||||
"summary": "Test endpoint",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InputContainer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OutputContainer"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"InputContainer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"$ref": "#/components/schemas/InputData"}
|
||||
},
|
||||
},
|
||||
"InputData": {
|
||||
"type": "object",
|
||||
"properties": {"input_field": {"type": "string"}},
|
||||
},
|
||||
"OutputContainer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": {"$ref": "#/components/schemas/OutputData"}
|
||||
},
|
||||
},
|
||||
"OutputData": {
|
||||
"type": "object",
|
||||
"properties": {"output_field": {"type": "string"}},
|
||||
},
|
||||
"UnusedSchema": {
|
||||
"type": "object",
|
||||
"properties": {"unused_field": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
routes = parse_openapi_to_http_routes(openapi_spec)
|
||||
assert len(routes) == 1
|
||||
|
||||
route = routes[0]
|
||||
|
||||
# Check that schemas are properly separated
|
||||
input_schema_names = set(route.request_schemas.keys())
|
||||
output_schema_names = set(route.response_schemas.keys())
|
||||
|
||||
# Input should contain transitive dependencies from InputContainer
|
||||
assert "InputData" in input_schema_names, (
|
||||
f"Expected InputData in request schemas: {input_schema_names}"
|
||||
)
|
||||
assert "OutputContainer" not in input_schema_names, (
|
||||
"OutputContainer should not be in request schemas"
|
||||
)
|
||||
assert "OutputData" not in input_schema_names, (
|
||||
"OutputData should not be in request schemas"
|
||||
)
|
||||
|
||||
# Output should contain transitive dependencies from OutputContainer
|
||||
assert "OutputData" in output_schema_names, (
|
||||
f"Expected OutputData in response schemas: {output_schema_names}"
|
||||
)
|
||||
assert "InputContainer" not in output_schema_names, (
|
||||
"InputContainer should not be in response schemas"
|
||||
)
|
||||
assert "InputData" not in output_schema_names, (
|
||||
"InputData should not be in response schemas"
|
||||
)
|
||||
|
||||
# Neither should contain unused schema
|
||||
assert "UnusedSchema" not in input_schema_names, (
|
||||
"UnusedSchema should not be in request schemas"
|
||||
)
|
||||
assert "UnusedSchema" not in output_schema_names, (
|
||||
"UnusedSchema should not be in response schemas"
|
||||
)
|
||||
|
||||
# Verify no overlap
|
||||
overlap = input_schema_names & output_schema_names
|
||||
assert len(overlap) == 0, (
|
||||
f"Found overlapping schemas between input and output: {overlap}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue