60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
843 lines
32 KiB
Python
843 lines
32 KiB
Python
"""Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
|
|
|
|
from fastmcp.utilities.openapi.models import (
|
|
HTTPRoute,
|
|
ParameterInfo,
|
|
RequestBodyInfo,
|
|
ResponseInfo,
|
|
)
|
|
from fastmcp.utilities.openapi.parser import parse_openapi_to_http_routes
|
|
from fastmcp.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"}},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"}
|
|
},
|
|
),
|
|
request_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"},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
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"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"},
|
|
}
|
|
},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"}}},
|
|
]
|
|
},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"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"
|
|
)
|
|
|
|
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}"
|
|
)
|
|
|
|
def test_issue_2087_top_level_response_ref_includes_all_nested_schemas(self):
|
|
"""Issue #2087: Top-level response $ref must include itself in response_schemas."""
|
|
spec = {
|
|
"openapi": "3.0.1",
|
|
"info": {"title": "Test", "version": "1.0"},
|
|
"paths": {
|
|
"/persons": {
|
|
"get": {
|
|
"responses": {
|
|
"200": {
|
|
"description": "OK",
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {
|
|
"$ref": "#/components/schemas/PersonList"
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"components": {
|
|
"schemas": {
|
|
"PersonList": {
|
|
"properties": {
|
|
"Items": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/components/schemas/Person"},
|
|
}
|
|
}
|
|
},
|
|
"Person": {
|
|
"properties": {
|
|
"Name": {"$ref": "#/components/schemas/Name"},
|
|
"Job": {"$ref": "#/components/schemas/Job"},
|
|
}
|
|
},
|
|
"Name": {"properties": {"First": {"type": "string"}}},
|
|
"Job": {"properties": {"Company": {"type": "string"}}},
|
|
}
|
|
},
|
|
}
|
|
|
|
routes = parse_openapi_to_http_routes(spec)
|
|
route = routes[0]
|
|
|
|
# Bug was: PersonList missing from response_schemas despite being top-level ref
|
|
assert "PersonList" in route.response_schemas
|
|
assert "Person" in route.response_schemas
|
|
assert "Name" in route.response_schemas
|
|
assert "Job" in route.response_schemas
|