Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 35 additions & 20 deletions src/utils/openapi_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,35 +35,35 @@ def resolve_refs(spec: dict[str, Any]) -> dict[str, Any]:
# Get the components/schemas section for reference resolution
schemas = spec.get("components", {}).get("schemas", {})

def resolve_schema(obj: Any, visited: set[str] | None = None) -> Any:
def resolve_schema(obj: Any, visited: dict[str, int] | None = None) -> Any:
"""Recursively resolve $ref in an object."""
if visited is None:
visited = set()
visited = {}

if isinstance(obj, dict):
# Check if this is a $ref
if "$ref" in obj and len(obj) == 1:
ref_path = obj["$ref"]

# Prevent infinite recursion
if ref_path in visited:
# Return the ref as-is to avoid infinite loop
schema_name = ref_path.removeprefix("#/components/schemas/")
if schema_name == ref_path or schema_name not in schemas:
return obj

visited.add(ref_path)

# Extract schema name from reference
if ref_path.startswith("#/components/schemas/"):
schema_name = ref_path.split("/")[-1]
if schema_name in schemas:
# Recursively resolve the referenced schema
resolved = resolve_schema(schemas[schema_name].copy(), visited)
visited.remove(ref_path)
return resolved

# If we can't resolve, return as-is
visited.remove(ref_path)
return obj
# A $ref left in place would dangle once FastMCP moves the schema into a
# tool's $defs, so a cycle is cut rather than preserved. springdoc emits
# every polymorphic type as a cycle: the parent lists its subtypes in
# oneOf, and each subtype extends the parent through allOf. Re-expanding
# the parent once without its subtypes keeps the fields they inherit.
depth = visited.get(ref_path, 0)
if depth >= 2:
return _cycle_placeholder(schemas[schema_name])
target = schemas[schema_name]
if depth == 1:
target = _without_subtypes(target)

visited[ref_path] = depth + 1
resolved = resolve_schema(target, visited)
visited[ref_path] = depth
return resolved
else:
# Recursively process all values in the dict
result = {}
Expand All @@ -85,6 +85,21 @@ def resolve_schema(obj: Any, visited: set[str] | None = None) -> Any:
return spec


def _without_subtypes(schema: dict[str, Any]) -> dict[str, Any]:
return {
key: value
for key, value in schema.items()
if key not in ("oneOf", "anyOf", "discriminator")
}


def _cycle_placeholder(schema: dict[str, Any]) -> dict[str, Any]:
placeholder: dict[str, Any] = {"type": schema.get("type", "object")}
if "description" in schema:
placeholder["description"] = schema["description"]
return placeholder


# Use if context becomes too large for inline definitions
def resolve_refs_with_defs(spec: dict[str, Any]) -> dict[str, Any]:
"""
Expand Down
67 changes: 64 additions & 3 deletions tests/test_openapi_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,73 @@ def test_resolve_circular_refs(self):
# Should not raise an exception and should handle circular refs
resolved = resolve_refs(spec)

# Should have resolved the top-level ref but left circular ref intact
schema = resolved["paths"]["/api/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
assert schema["type"] == "object"
assert "value" in schema["properties"]
# Circular ref should be preserved to prevent infinite recursion
assert schema["properties"]["parent"]["$ref"] == "#/components/schemas/Node"
# The cycle is expanded once more, then cut with a placeholder instead of a $ref,
# which FastMCP would turn into a dangling #/$defs pointer
parent = schema["properties"]["parent"]
assert "value" in parent["properties"]
assert parent["properties"]["parent"] == {"type": "object"}
assert "$ref" not in json.dumps(schema)

def test_resolve_polymorphic_refs(self):
"""Test the parent/subtype cycle springdoc emits for polymorphic types."""
spec = {
"paths": {
"/api/test": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Action"}
}
}
}
}
}
},
"components": {
"schemas": {
"Action": {
"required": ["type"],
"type": "object",
"properties": {"type": {"type": "string"}},
"discriminator": {"propertyName": "type"},
"oneOf": [
{"$ref": "#/components/schemas/Action.JQ"},
{"$ref": "#/components/schemas/Action.HTTP"},
],
},
"Action.JQ": {
"type": "object",
"allOf": [
{"$ref": "#/components/schemas/Action"},
{"type": "object", "properties": {"expression": {"type": "string"}}},
],
},
"Action.HTTP": {
"type": "object",
"allOf": [
{"$ref": "#/components/schemas/Action"},
{"type": "object", "properties": {"url": {"type": "string"}}},
],
},
}
},
}

resolved = resolve_refs(spec)

schema = resolved["paths"]["/api/test"]["post"]["requestBody"]["content"]["application/json"]["schema"]
assert "$ref" not in json.dumps(schema)
jq_parent, jq_own = schema["oneOf"][0]["allOf"]
# The subtype keeps the fields it inherits, but not the parent's list of subtypes
assert jq_parent["properties"]["type"] == {"type": "string"}
assert jq_parent["required"] == ["type"]
assert "oneOf" not in jq_parent
assert "discriminator" not in jq_parent
assert jq_own["properties"]["expression"] == {"type": "string"}

def test_resolve_refs_in_arrays(self):
"""Test resolving $refs inside arrays."""
Expand Down
Loading