Jeremiah Lowin commited on
Commit
9d663e6
·
unverified ·
2 Parent(s): 9c9e8809522dfa

Merge pull request #697 from phateffect/replace-w-defs

Browse files

replace $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
- # Copy the schema and add description if available
894
- param_schema = param.schema_.copy() if isinstance(param.schema_, dict) else {}
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 = route.request_body.content_schema[content_type]
 
 
 
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 parse_openapi_to_http_routes
 
 
 
 
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
+ }