fix(json-schema): keep string constraints when a format is set

_create_string_type returned early whenever a format was present, so
minLength/maxLength/pattern were silently dropped for any string schema that
also had a format — including a custom/unknown format (which falls back to str)
and uri-reference. Apply the string constraints when the format resolves to a
str-based type; non-str formats (date-time, json) are left unchanged.
This commit is contained in:
ikatyal21 2026-06-27 11:41:46 -05:00
commit 59cb9f50f2
2 changed files with 50 additions and 7 deletions

View file

@ -269,12 +269,14 @@ def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]
if "const" in schema:
return Literal[schema["const"]] # type: ignore
base: Any = str
if fmt := schema.get("format"):
if fmt == "uri":
return AnyUrl
elif fmt == "uri-reference":
return str
return FORMAT_TYPES.get(fmt, str)
base = str
else:
base = FORMAT_TYPES.get(fmt, str)
constraints = {
k: v
@ -286,10 +288,15 @@ def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]
if v is not None
}
if not constraints:
return str
# StringConstraints (min/max length, pattern) only apply to str-based types.
# Non-str formats (e.g. date-time -> datetime, json -> Json) carry no such
# constraints. Previously *any* format made this function return early,
# silently dropping minLength/maxLength/pattern even for plain-string
# formats (e.g. a custom format that falls back to str).
if not constraints or not (isinstance(base, type) and issubclass(base, str)):
return base
annotated: Any = Annotated[str, StringConstraints(**constraints)]
annotated: Any = Annotated[base, StringConstraints(**constraints)]
if "pattern" in constraints:
try:
@ -307,10 +314,10 @@ def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]
pattern_field = Field(json_schema_extra={"x-unsupported-pattern": pattern})
if constraints:
annotated = Annotated[
str, StringConstraints(**constraints), pattern_field
base, StringConstraints(**constraints), pattern_field
] # type: ignore[valid-type]
else:
annotated = Annotated[str, pattern_field] # type: ignore[valid-type]
annotated = Annotated[base, pattern_field] # type: ignore[valid-type]
return annotated

View file

@ -130,3 +130,39 @@ class TestNumberConstraints:
validator = TypeAdapter(exclusive_max_number)
with pytest.raises(ValidationError):
validator.validate_python(100)
class TestStringFormatConstraints:
"""A ``format`` must not silently drop string length/pattern constraints."""
def test_custom_format_keeps_max_length(self):
# An unrecognized format falls back to ``str``; maxLength must still apply.
t = json_schema_to_type({"type": "string", "format": "phone", "maxLength": 3})
validator = TypeAdapter(t)
assert validator.validate_python("abc") == "abc"
with pytest.raises(ValidationError):
validator.validate_python("abcd")
def test_custom_format_keeps_min_length(self):
t = json_schema_to_type({"type": "string", "format": "phone", "minLength": 3})
validator = TypeAdapter(t)
assert validator.validate_python("abc") == "abc"
with pytest.raises(ValidationError):
validator.validate_python("ab")
def test_uri_reference_format_keeps_max_length(self):
t = json_schema_to_type(
{"type": "string", "format": "uri-reference", "maxLength": 5}
)
validator = TypeAdapter(t)
assert validator.validate_python("a/b") == "a/b"
with pytest.raises(ValidationError):
validator.validate_python("too/long/path")
def test_non_string_format_ignores_length(self):
# date-time resolves to ``datetime`` (not str), so length constraints do
# not apply and the type must stay datetime (regression guard).
import datetime
t = json_schema_to_type({"type": "string", "format": "date-time"})
assert t is datetime.datetime