fastmcp/tests/utilities/openapi/test_nullable_fields.py
Jeremiah Lowin b501f05794
Switch to new OpenAPI parser as default (#2513)
* Switch to new OpenAPI parser as default

Remove the legacy OpenAPI parser and make the experimental parser the
default. The experimental parser (introduced in 2.11) offers better
performance, improved compatibility, and a more maintainable architecture.

- Delete legacy parser (server/openapi.py, utilities/openapi.py)
- Move experimental parser to main locations
- Remove enable_new_openapi_parser feature flag
- Update documentation to remove experimental references

* Add deprecation stubs for experimental OpenAPI imports

* Add deprecated enable_new_openapi_parser setting and deprecation tests

* SDK docs

* REview comments

* Fix docstrings

* Update docstring

* Review comments

* Fix broken links
2025-12-01 20:29:18 -05:00

375 lines
14 KiB
Python

"""Tests for nullable field handling in OpenAPI schemas."""
import pytest
from jsonschema import ValidationError, validate
from fastmcp.utilities.openapi.json_schema_converter import (
convert_openapi_schema_to_json_schema,
)
class TestHandleNullableFields:
"""Test conversion of OpenAPI nullable fields to JSON Schema format."""
def test_root_level_nullable_string(self):
"""Test nullable string at root level."""
input_schema = {"type": "string", "nullable": True}
expected = {"type": ["string", "null"]}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_root_level_nullable_integer(self):
"""Test nullable integer at root level."""
input_schema = {"type": "integer", "nullable": True}
expected = {"type": ["integer", "null"]}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_root_level_nullable_boolean(self):
"""Test nullable boolean at root level."""
input_schema = {"type": "boolean", "nullable": True}
expected = {"type": ["boolean", "null"]}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_property_level_nullable_fields(self):
"""Test nullable fields in properties."""
input_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"company": {"type": "string", "nullable": True},
"age": {"type": "integer", "nullable": True},
"active": {"type": "boolean", "nullable": True},
},
}
expected = {
"type": "object",
"properties": {
"name": {"type": "string"},
"company": {"type": ["string", "null"]},
"age": {"type": ["integer", "null"]},
"active": {"type": ["boolean", "null"]},
},
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_mixed_nullable_and_non_nullable(self):
"""Test mix of nullable and non-nullable fields."""
input_schema = {
"type": "object",
"properties": {
"required_field": {"type": "string"},
"optional_nullable": {"type": "string", "nullable": True},
"optional_non_nullable": {"type": "string"},
},
"required": ["required_field"],
}
expected = {
"type": "object",
"properties": {
"required_field": {"type": "string"},
"optional_nullable": {"type": ["string", "null"]},
"optional_non_nullable": {"type": "string"},
},
"required": ["required_field"],
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_nullable_false_ignored(self):
"""Test that nullable: false is ignored (removed but no type change)."""
input_schema = {"type": "string", "nullable": False}
expected = {"type": "string"}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_no_nullable_field_unchanged(self):
"""Test that schemas without nullable field are unchanged."""
input_schema = {
"type": "object",
"properties": {"name": {"type": "string"}},
}
expected = input_schema.copy()
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_nullable_without_type_removes_nullable(self):
"""Test that nullable field is removed even without type."""
input_schema = {"nullable": True, "description": "Some field"}
expected = {"description": "Some field"}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_preserves_other_fields(self):
"""Test that other fields are preserved during conversion."""
input_schema = {
"type": "string",
"nullable": True,
"description": "A nullable string",
"example": "test",
"format": "email",
}
expected = {
"type": ["string", "null"],
"description": "A nullable string",
"example": "test",
"format": "email",
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_non_dict_input_unchanged(self):
"""Test that non-dict inputs are returned unchanged."""
assert convert_openapi_schema_to_json_schema("string", "3.0.0") == "string" # type: ignore[arg-type]
assert convert_openapi_schema_to_json_schema(123, "3.0.0") == 123 # type: ignore[arg-type]
assert convert_openapi_schema_to_json_schema(None, "3.0.0") is None # type: ignore[arg-type]
assert convert_openapi_schema_to_json_schema([1, 2, 3], "3.0.0") == [1, 2, 3] # type: ignore[arg-type]
def test_performance_optimization_no_copy_when_unchanged(self):
"""Test that schemas without nullable fields return the same object (no copy)."""
input_schema = {
"type": "object",
"properties": {"name": {"type": "string"}},
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
# Should return the exact same object, not a copy
assert result is input_schema
def test_union_types_with_nullable(self):
"""Test nullable handling with existing union types (type as array)."""
input_schema = {"type": ["string", "integer"], "nullable": True}
expected = {"type": ["string", "integer", "null"]}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_already_nullable_union_unchanged(self):
"""Test that union types already containing null are not modified."""
input_schema = {"type": ["string", "null"], "nullable": True}
expected = {"type": ["string", "null"]}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_property_level_union_with_nullable(self):
"""Test nullable handling with union types in properties."""
input_schema = {
"type": "object",
"properties": {"value": {"type": ["string", "integer"], "nullable": True}},
}
expected = {
"type": "object",
"properties": {"value": {"type": ["string", "integer", "null"]}},
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_complex_union_nullable_scenarios(self):
"""Test various complex union type scenarios."""
# Already has null in different position
input1 = {"type": ["null", "string", "integer"], "nullable": True}
result1 = convert_openapi_schema_to_json_schema(input1, "3.0.0")
assert result1 == {"type": ["null", "string", "integer"]}
# Single item array
input2 = {"type": ["string"], "nullable": True}
result2 = convert_openapi_schema_to_json_schema(input2, "3.0.0")
assert result2 == {"type": ["string", "null"]}
def test_oneof_with_nullable(self):
"""Test nullable handling with oneOf constructs."""
input_schema = {
"oneOf": [{"type": "string"}, {"type": "integer"}],
"nullable": True,
}
expected = {
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_anyof_with_nullable(self):
"""Test nullable handling with anyOf constructs."""
input_schema = {
"anyOf": [{"type": "string"}, {"type": "integer"}],
"nullable": True,
}
expected = {
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_anyof_already_nullable(self):
"""Test anyOf that already contains null type."""
input_schema = {
"anyOf": [{"type": "string"}, {"type": "null"}],
"nullable": True,
}
expected = {"anyOf": [{"type": "string"}, {"type": "null"}]}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_allof_with_nullable(self):
"""Test nullable handling with allOf constructs."""
input_schema = {
"allOf": [{"type": "string"}, {"minLength": 1}],
"nullable": True,
}
expected = {
"anyOf": [
{"allOf": [{"type": "string"}, {"minLength": 1}]},
{"type": "null"},
]
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_property_level_oneof_with_nullable(self):
"""Test nullable handling with oneOf in properties."""
input_schema = {
"type": "object",
"properties": {
"value": {
"oneOf": [{"type": "string"}, {"type": "integer"}],
"nullable": True,
}
},
}
expected = {
"type": "object",
"properties": {
"value": {
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
}
},
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_nullable_enum_field(self):
"""Test nullable enum field - issue #2082."""
input_schema = {
"type": "string",
"nullable": True,
"enum": ["VALUE1", "VALUE2", "VALUE3"],
}
expected = {
"type": ["string", "null"],
"enum": ["VALUE1", "VALUE2", "VALUE3", None],
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_nullable_enum_already_contains_null(self):
"""Test nullable enum that already contains None."""
input_schema = {
"type": "string",
"nullable": True,
"enum": ["VALUE1", "VALUE2", None],
}
expected = {
"type": ["string", "null"],
"enum": ["VALUE1", "VALUE2", None],
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_nullable_enum_without_type(self):
"""Test nullable enum without explicit type field."""
input_schema = {
"nullable": True,
"enum": ["VALUE1", "VALUE2", "VALUE3"],
}
expected = {
"enum": ["VALUE1", "VALUE2", "VALUE3", None],
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_non_nullable_enum_unchanged(self):
"""Test that enum without nullable is unchanged."""
input_schema = {
"type": "string",
"enum": ["VALUE1", "VALUE2", "VALUE3"],
}
expected = {
"type": "string",
"enum": ["VALUE1", "VALUE2", "VALUE3"],
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_property_level_nullable_enum(self):
"""Test nullable enum in object properties."""
input_schema = {
"type": "object",
"properties": {
"status": {
"type": "string",
"nullable": True,
"enum": ["ACTIVE", "INACTIVE", "PENDING"],
},
"name": {"type": "string"},
},
}
expected = {
"type": "object",
"properties": {
"status": {
"type": ["string", "null"],
"enum": ["ACTIVE", "INACTIVE", "PENDING", None],
},
"name": {"type": "string"},
},
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_nullable_integer_enum(self):
"""Test nullable enum with integer values."""
input_schema = {
"type": "integer",
"nullable": True,
"enum": [1, 2, 3],
}
expected = {
"type": ["integer", "null"],
"enum": [1, 2, 3, None],
}
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
class TestNullableFieldValidation:
"""Test that converted schemas validate correctly with jsonschema."""
def test_nullable_string_validates(self):
"""Test that nullable string validates both null and string values."""
openapi_schema = {"type": "string", "nullable": True}
json_schema = convert_openapi_schema_to_json_schema(openapi_schema, "3.0.0")
# Both null and string should validate
validate(instance=None, schema=json_schema)
validate(instance="test", schema=json_schema)
# Other types should fail
with pytest.raises(ValidationError):
validate(instance=123, schema=json_schema)
def test_nullable_enum_validates(self):
"""Test that nullable enum validates null, enum values, and rejects invalid values."""
openapi_schema = {
"type": "string",
"nullable": True,
"enum": ["VALUE1", "VALUE2", "VALUE3"],
}
json_schema = convert_openapi_schema_to_json_schema(openapi_schema, "3.0.0")
# Null and enum values should validate
validate(instance=None, schema=json_schema)
validate(instance="VALUE1", schema=json_schema)
# Invalid values should fail
with pytest.raises(ValidationError):
validate(instance="INVALID", schema=json_schema)