Spaces:
Running
Running
Merge pull request #697 from phateffect/replace-w-defs
Browse filesreplace $ref pointing to `#/components/schemas/` with `#/$defs/`
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -872,6 +872,50 @@ def format_description_with_responses(
|
|
| 872 |
return "\n".join(desc_parts)
|
| 873 |
|
| 874 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
| 876 |
"""
|
| 877 |
Combines parameter and request body schemas into a single schema.
|
|
@@ -889,38 +933,18 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
| 889 |
for param in route.parameters:
|
| 890 |
if param.required:
|
| 891 |
required.append(param.name)
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
# Convert #/components/schemas references to #/$defs references
|
| 897 |
-
if isinstance(param_schema, dict) and "$ref" in param_schema:
|
| 898 |
-
ref_path = param_schema["$ref"]
|
| 899 |
-
if ref_path.startswith("#/components/schemas/"):
|
| 900 |
-
schema_name = ref_path.split("/")[-1]
|
| 901 |
-
param_schema["$ref"] = f"#/$defs/{schema_name}"
|
| 902 |
-
|
| 903 |
-
# Also handle anyOf, allOf, oneOf references
|
| 904 |
-
for section in ["anyOf", "allOf", "oneOf"]:
|
| 905 |
-
if section in param_schema and isinstance(param_schema[section], list):
|
| 906 |
-
for i, item in enumerate(param_schema[section]):
|
| 907 |
-
if isinstance(item, dict) and "$ref" in item:
|
| 908 |
-
ref_path = item["$ref"]
|
| 909 |
-
if ref_path.startswith("#/components/schemas/"):
|
| 910 |
-
schema_name = ref_path.split("/")[-1]
|
| 911 |
-
param_schema[section][i]["$ref"] = f"#/$defs/{schema_name}"
|
| 912 |
-
|
| 913 |
-
# Add parameter description to schema if available and not already present
|
| 914 |
-
if param.description and not param_schema.get("description"):
|
| 915 |
-
param_schema["description"] = param.description
|
| 916 |
-
|
| 917 |
-
properties[param.name] = param_schema
|
| 918 |
|
| 919 |
# Add request body if it exists
|
| 920 |
if route.request_body and route.request_body.content_schema:
|
| 921 |
# For now, just use the first content type's schema
|
| 922 |
content_type = next(iter(route.request_body.content_schema))
|
| 923 |
-
body_schema =
|
|
|
|
|
|
|
|
|
|
| 924 |
body_props = body_schema.get("properties", {})
|
| 925 |
|
| 926 |
# Add request body properties
|
|
@@ -935,7 +959,6 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
| 935 |
"properties": properties,
|
| 936 |
"required": required,
|
| 937 |
}
|
| 938 |
-
|
| 939 |
# Add schema definitions if available
|
| 940 |
if route.schema_definitions:
|
| 941 |
result["$defs"] = route.schema_definitions
|
|
|
|
| 872 |
return "\n".join(desc_parts)
|
| 873 |
|
| 874 |
|
| 875 |
+
def _replace_ref_with_defs(
|
| 876 |
+
info: dict[str, Any], description: str | None = None
|
| 877 |
+
) -> dict[str, Any]:
|
| 878 |
+
"""
|
| 879 |
+
Replace openapi $ref with jsonschema $defs
|
| 880 |
+
|
| 881 |
+
Examples:
|
| 882 |
+
- {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
|
| 883 |
+
- {"$ref": "#/components/schemas/..."}
|
| 884 |
+
- {"items": {"$ref": "#/components/schemas/..."}}
|
| 885 |
+
- {"anyOf": [{"$ref": "#/components/schemas/..."}]}
|
| 886 |
+
- {"allOf": [{"$ref": "#/components/schemas/..."}]}
|
| 887 |
+
- {"oneOf": [{"$ref": "#/components/schemas/..."}]}
|
| 888 |
+
|
| 889 |
+
Args:
|
| 890 |
+
info: dict[str, Any]
|
| 891 |
+
description: str | None
|
| 892 |
+
|
| 893 |
+
Returns:
|
| 894 |
+
dict[str, Any]
|
| 895 |
+
"""
|
| 896 |
+
schema = info.copy()
|
| 897 |
+
if ref_path := schema.get("$ref"):
|
| 898 |
+
if ref_path.startswith("#/components/schemas/"):
|
| 899 |
+
schema_name = ref_path.split("/")[-1]
|
| 900 |
+
schema["$ref"] = f"#/$defs/{schema_name}"
|
| 901 |
+
elif properties := schema.get("properties"):
|
| 902 |
+
if "$ref" in properties:
|
| 903 |
+
schema["properties"] = _replace_ref_with_defs(properties)
|
| 904 |
+
else:
|
| 905 |
+
schema["properties"] = {
|
| 906 |
+
prop_name: _replace_ref_with_defs(prop_schema)
|
| 907 |
+
for prop_name, prop_schema in properties.items()
|
| 908 |
+
}
|
| 909 |
+
elif item_schema := schema.get("items"):
|
| 910 |
+
schema["items"] = _replace_ref_with_defs(item_schema)
|
| 911 |
+
for section in ["anyOf", "allOf", "oneOf"]:
|
| 912 |
+
for i, item in enumerate(schema.get(section, [])):
|
| 913 |
+
schema[section][i] = _replace_ref_with_defs(item)
|
| 914 |
+
if info.get("description", description) and not schema.get("description"):
|
| 915 |
+
schema["description"] = description
|
| 916 |
+
return schema
|
| 917 |
+
|
| 918 |
+
|
| 919 |
def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
| 920 |
"""
|
| 921 |
Combines parameter and request body schemas into a single schema.
|
|
|
|
| 933 |
for param in route.parameters:
|
| 934 |
if param.required:
|
| 935 |
required.append(param.name)
|
| 936 |
+
properties[param.name] = _replace_ref_with_defs(
|
| 937 |
+
param.schema_.copy(), param.description
|
| 938 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
|
| 940 |
# Add request body if it exists
|
| 941 |
if route.request_body and route.request_body.content_schema:
|
| 942 |
# For now, just use the first content type's schema
|
| 943 |
content_type = next(iter(route.request_body.content_schema))
|
| 944 |
+
body_schema = _replace_ref_with_defs(
|
| 945 |
+
route.request_body.content_schema[content_type].copy(),
|
| 946 |
+
route.request_body.description,
|
| 947 |
+
)
|
| 948 |
body_props = body_schema.get("properties", {})
|
| 949 |
|
| 950 |
# Add request body properties
|
|
|
|
| 959 |
"properties": properties,
|
| 960 |
"required": required,
|
| 961 |
}
|
|
|
|
| 962 |
# Add schema definitions if available
|
| 963 |
if route.schema_definitions:
|
| 964 |
result["$defs"] = route.schema_definitions
|
tests/utilities/openapi/test_openapi.py
CHANGED
|
@@ -6,7 +6,11 @@ import pytest
|
|
| 6 |
from fastapi import Body, FastAPI, Path, Query
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
| 9 |
-
from fastmcp.utilities.openapi import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
# --- Test Data: Static OpenAPI Schema Dictionaries --- #
|
| 12 |
|
|
@@ -1023,6 +1027,9 @@ def test_openapi_30_reference_resolution(openapi_30_with_references):
|
|
| 1023 |
# or it still has a $ref field
|
| 1024 |
assert "properties" in category or "$ref" in category
|
| 1025 |
|
|
|
|
|
|
|
|
|
|
| 1026 |
|
| 1027 |
def test_openapi_31_reference_resolution(openapi_31_with_references):
|
| 1028 |
"""Test that references are correctly resolved in OpenAPI 3.1 schemas."""
|
|
@@ -1057,6 +1064,9 @@ def test_openapi_31_reference_resolution(openapi_31_with_references):
|
|
| 1057 |
# or it still has a $ref field
|
| 1058 |
assert "properties" in category or "$ref" in category
|
| 1059 |
|
|
|
|
|
|
|
|
|
|
| 1060 |
|
| 1061 |
def test_consistent_output_across_versions(
|
| 1062 |
openapi_30_with_references, openapi_31_with_references
|
|
@@ -1093,3 +1103,103 @@ def test_consistent_output_across_versions(
|
|
| 1093 |
"properties"
|
| 1094 |
]
|
| 1095 |
assert set(schema_30.keys()) == set(schema_31.keys())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from fastapi import Body, FastAPI, Path, Query
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
| 9 |
+
from fastmcp.utilities.openapi import (
|
| 10 |
+
_combine_schemas,
|
| 11 |
+
_replace_ref_with_defs,
|
| 12 |
+
parse_openapi_to_http_routes,
|
| 13 |
+
)
|
| 14 |
|
| 15 |
# --- Test Data: Static OpenAPI Schema Dictionaries --- #
|
| 16 |
|
|
|
|
| 1027 |
# or it still has a $ref field
|
| 1028 |
assert "properties" in category or "$ref" in category
|
| 1029 |
|
| 1030 |
+
combined_schema = _combine_schemas(route)
|
| 1031 |
+
assert "#/$defs/" in combined_schema["properties"]["category"]["$ref"]
|
| 1032 |
+
|
| 1033 |
|
| 1034 |
def test_openapi_31_reference_resolution(openapi_31_with_references):
|
| 1035 |
"""Test that references are correctly resolved in OpenAPI 3.1 schemas."""
|
|
|
|
| 1064 |
# or it still has a $ref field
|
| 1065 |
assert "properties" in category or "$ref" in category
|
| 1066 |
|
| 1067 |
+
combined_schema = _combine_schemas(route)
|
| 1068 |
+
assert "#/$defs/" in combined_schema["properties"]["category"]["$ref"]
|
| 1069 |
+
|
| 1070 |
|
| 1071 |
def test_consistent_output_across_versions(
|
| 1072 |
openapi_30_with_references, openapi_31_with_references
|
|
|
|
| 1103 |
"properties"
|
| 1104 |
]
|
| 1105 |
assert set(schema_30.keys()) == set(schema_31.keys())
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
class TestReplaceRefWithDefs:
|
| 1109 |
+
@pytest.fixture(scope="class")
|
| 1110 |
+
def schemas(self):
|
| 1111 |
+
"""Provide test schemas for _replace_ref_with_defs function."""
|
| 1112 |
+
return {
|
| 1113 |
+
"ref_type": {
|
| 1114 |
+
"$ref": "#/components/schemas/RefFoo",
|
| 1115 |
+
},
|
| 1116 |
+
"object_type": {
|
| 1117 |
+
"type": "object",
|
| 1118 |
+
"properties": {"$ref": "#/components/schemas/ObjectFoo"},
|
| 1119 |
+
},
|
| 1120 |
+
"array_type": {
|
| 1121 |
+
"type": "array",
|
| 1122 |
+
"items": {"$ref": "#/components/schemas/ArrayFoo"},
|
| 1123 |
+
},
|
| 1124 |
+
"any_of_type": {
|
| 1125 |
+
"anyOf": [
|
| 1126 |
+
{"$ref": "#/components/schemas/AnyOfFoo"},
|
| 1127 |
+
{"$ref": "#/components/schemas/AnyOfBar"},
|
| 1128 |
+
]
|
| 1129 |
+
},
|
| 1130 |
+
"all_of_type": {
|
| 1131 |
+
"allOf": [
|
| 1132 |
+
{"$ref": "#/components/schemas/AllOfFoo"},
|
| 1133 |
+
{"$ref": "#/components/schemas/AllOfBar"},
|
| 1134 |
+
]
|
| 1135 |
+
},
|
| 1136 |
+
"one_of_type": {
|
| 1137 |
+
"oneOf": [
|
| 1138 |
+
{"$ref": "#/components/schemas/OneOfFoo"},
|
| 1139 |
+
{"$ref": "#/components/schemas/OneOfBar"},
|
| 1140 |
+
]
|
| 1141 |
+
},
|
| 1142 |
+
"nested_type": {
|
| 1143 |
+
"type": "object",
|
| 1144 |
+
"properties": {
|
| 1145 |
+
"pets": {
|
| 1146 |
+
"oneOf": [
|
| 1147 |
+
{"$ref": "#/components/schemas/Cat"},
|
| 1148 |
+
{"$ref": "#/components/schemas/Dog"},
|
| 1149 |
+
]
|
| 1150 |
+
},
|
| 1151 |
+
},
|
| 1152 |
+
},
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
def test_replace_direct_ref(self, schemas):
|
| 1156 |
+
"""Test replacing direct $ref references."""
|
| 1157 |
+
result = _replace_ref_with_defs(schemas["ref_type"])
|
| 1158 |
+
assert result == {"$ref": "#/$defs/RefFoo"}
|
| 1159 |
+
|
| 1160 |
+
def test_replace_object_property_ref(self, schemas):
|
| 1161 |
+
"""Test replacing $ref in object properties."""
|
| 1162 |
+
result = _replace_ref_with_defs(schemas["object_type"])
|
| 1163 |
+
assert result == {
|
| 1164 |
+
"type": "object",
|
| 1165 |
+
"properties": {"$ref": "#/$defs/ObjectFoo"},
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
def test_replace_array_items_ref(self, schemas):
|
| 1169 |
+
"""Test replacing $ref in array items."""
|
| 1170 |
+
result = _replace_ref_with_defs(schemas["array_type"])
|
| 1171 |
+
assert result == {
|
| 1172 |
+
"type": "array",
|
| 1173 |
+
"items": {"$ref": "#/$defs/ArrayFoo"},
|
| 1174 |
+
}
|
| 1175 |
+
|
| 1176 |
+
def test_replace_any_of_refs(self, schemas):
|
| 1177 |
+
"""Test replacing $ref in anyOf schemas."""
|
| 1178 |
+
result = _replace_ref_with_defs(schemas["any_of_type"])
|
| 1179 |
+
assert result == {
|
| 1180 |
+
"anyOf": [{"$ref": "#/$defs/AnyOfFoo"}, {"$ref": "#/$defs/AnyOfBar"}]
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
def test_replace_all_of_refs(self, schemas):
|
| 1184 |
+
"""Test replacing $ref in allOf schemas."""
|
| 1185 |
+
result = _replace_ref_with_defs(schemas["all_of_type"])
|
| 1186 |
+
assert result == {
|
| 1187 |
+
"allOf": [{"$ref": "#/$defs/AllOfFoo"}, {"$ref": "#/$defs/AllOfBar"}]
|
| 1188 |
+
}
|
| 1189 |
+
|
| 1190 |
+
def test_replace_one_of_refs(self, schemas):
|
| 1191 |
+
"""Test replacing $ref in oneOf schemas."""
|
| 1192 |
+
result = _replace_ref_with_defs(schemas["one_of_type"])
|
| 1193 |
+
assert result == {
|
| 1194 |
+
"oneOf": [{"$ref": "#/$defs/OneOfFoo"}, {"$ref": "#/$defs/OneOfBar"}]
|
| 1195 |
+
}
|
| 1196 |
+
|
| 1197 |
+
def test_replace_nested_refs(self, schemas):
|
| 1198 |
+
"""Test replacing $ref in deeply nested schema structures."""
|
| 1199 |
+
result = _replace_ref_with_defs(schemas["nested_type"])
|
| 1200 |
+
assert result == {
|
| 1201 |
+
"type": "object",
|
| 1202 |
+
"properties": {
|
| 1203 |
+
"pets": {"oneOf": [{"$ref": "#/$defs/Cat"}, {"$ref": "#/$defs/Dog"}]}
|
| 1204 |
+
},
|
| 1205 |
+
}
|