Browse Source

feat: Add PropertyNames constraint support for JSON Schema propertyNames

This PR implements comprehensive support for JSON Schema propertyNames constraints
in FastAPI, resolving issue #13880.

Features:
- PropertyNames constraint class with flexible validation options
- Pattern-based constraints: PropertyNames(pattern='^prefix_')
- Length constraints: PropertyNames(min_length=3, max_length=50)
- Combined constraints: PropertyNames(pattern='^[A-Z_]+$', min_length=3, max_length=50)
- Custom schema constraints: PropertyNames(schema={'type': 'string', 'enum': ['key1', 'key2']})
- Full integration with both Pydantic v1 and v2
- Comprehensive test coverage

Usage:
from typing import Dict, Annotated
from fastapi.schema import PropertyNames
from pydantic import BaseModel

class UserConfig(BaseModel):
    settings: Annotated[Dict[str, str], PropertyNames(pattern='^user_')]

The implementation correctly generates OpenAPI schemas with propertyNames
constraints, enabling proper validation of dynamic object keys in APIs.

Fixes #13880
pull/14142/head
I531058 10 months ago
parent
commit
a3d9c5decc
Failed to extract signature
  1. 70
      fastapi/_compat.py
  2. 112
      fastapi/schema.py
  3. 183
      tests/test_property_names.py

70
fastapi/_compat.py

@ -205,6 +205,17 @@ if PYDANTIC_V2:
json_schema["title"] = (
field.field_info.title or field.alias.title().replace("_", " ")
)
# Check for PropertyNames constraint in field metadata
try:
from fastapi.schema import get_property_names_constraint, apply_property_names_to_schema
property_names_constraint = get_property_names_constraint(field.field_info)
if property_names_constraint:
json_schema = apply_property_names_to_schema(json_schema, property_names_constraint)
except ImportError:
# Gracefully handle if schema module is not available
pass
return json_schema
def get_compat_model_name_map(fields: List[ModelField]) -> ModelNameMap:
@ -236,6 +247,10 @@ if PYDANTIC_V2:
if "description" in item_def:
item_description = cast(str, item_def["description"]).split("\f")[0]
item_def["description"] = item_description
# Apply PropertyNames constraints to model definitions
_apply_property_names_to_definitions(definitions, fields)
return field_mapping, definitions # type: ignore[return-value]
def is_scalar_field(field: ModelField) -> bool:
@ -469,9 +484,21 @@ else:
separate_input_output_schemas: bool = True,
) -> Dict[str, Any]:
# This expects that GenerateJsonSchema was already used to generate the definitions
return field_schema( # type: ignore[no-any-return]
json_schema = field_schema( # type: ignore[no-any-return]
field, model_name_map=model_name_map, ref_prefix=REF_PREFIX
)[0]
# Check for PropertyNames constraint in field metadata
try:
from fastapi.schema import get_property_names_constraint, apply_property_names_to_schema
property_names_constraint = get_property_names_constraint(field.field_info)
if property_names_constraint:
json_schema = apply_property_names_to_schema(json_schema, property_names_constraint)
except ImportError:
# Gracefully handle if schema module is not available
pass
return json_schema
def get_compat_model_name_map(fields: List[ModelField]) -> ModelNameMap:
models = get_flat_models_from_fields(fields, known_models=set())
@ -660,6 +687,47 @@ def is_uploadfile_sequence_annotation(annotation: Any) -> bool:
)
def _apply_property_names_to_definitions(
definitions: Dict[str, Dict[str, Any]],
fields: List[ModelField]
) -> None:
"""
Apply PropertyNames constraints to model definitions by checking each model's fields.
"""
try:
from fastapi.schema import get_property_names_constraint, apply_property_names_to_schema
# Group fields by their model type
model_fields_map: Dict[Type[BaseModel], List[ModelField]] = {}
for field in fields:
# Get the actual annotation (which might be the BaseModel class)
annotation = field.field_info.annotation
if lenient_issubclass(annotation, BaseModel):
if annotation not in model_fields_map:
model_fields_map[annotation] = []
# Get fields from the model itself, not the field that references it
model_fields_map[annotation].extend(get_model_fields(annotation))
# Apply PropertyNames constraints to definitions
for model_class, model_field_list in model_fields_map.items():
model_name = model_class.__name__
if model_name in definitions:
definition = definitions[model_name]
if "properties" in definition:
for model_field in model_field_list:
property_names_constraint = get_property_names_constraint(model_field.field_info)
if property_names_constraint:
field_name = model_field.alias
if field_name in definition["properties"]:
field_schema = definition["properties"][field_name]
field_schema = apply_property_names_to_schema(field_schema, property_names_constraint)
definition["properties"][field_name] = field_schema
except ImportError:
# Schema module not available
pass
@lru_cache
def get_cached_model_fields(model: Type[BaseModel]) -> List[ModelField]:
return get_model_fields(model)

112
fastapi/schema.py

@ -0,0 +1,112 @@
"""
FastAPI schema utilities including PropertyNames constraint support.
"""
from typing import Any, Dict, Optional, Union
from pydantic import BaseModel
from pydantic.fields import FieldInfo
class PropertyNames:
"""
A constraint class for specifying propertyNames validation in JSON Schema.
This allows developers to specify patterns or schemas that all property names
in a dictionary/object must match.
Examples:
# Pattern-based constraint
PropertyNames(pattern="^prefix_")
# Schema-based constraint
PropertyNames(min_length=3, max_length=50)
# Complex schema constraint
PropertyNames(schema={"type": "string", "enum": ["key1", "key2", "key3"]})
"""
def __init__(
self,
pattern: Optional[str] = None,
min_length: Optional[int] = None,
max_length: Optional[int] = None,
schema: Optional[Dict[str, Any]] = None,
**kwargs: Any
):
"""
Initialize PropertyNames constraint.
Args:
pattern: Regular expression pattern that property names must match
min_length: Minimum length for property names
max_length: Maximum length for property names
schema: Custom JSON schema for property names validation
**kwargs: Additional JSON schema properties
"""
self.constraint_schema: Dict[str, Any] = {}
if pattern is not None:
self.constraint_schema["pattern"] = pattern
if min_length is not None:
self.constraint_schema["minLength"] = min_length
if max_length is not None:
self.constraint_schema["maxLength"] = max_length
if schema is not None:
self.constraint_schema.update(schema)
# Add any additional properties
for key, value in kwargs.items():
if value is not None:
self.constraint_schema[key] = value
if not self.constraint_schema:
raise ValueError("PropertyNames constraint must specify at least one validation rule")
def get_constraint_schema(self) -> Dict[str, Any]:
"""Get the JSON schema representation of this constraint."""
return self.constraint_schema.copy()
def __repr__(self) -> str:
return f"PropertyNames({self.constraint_schema})"
def get_property_names_constraint(field_info: FieldInfo) -> Optional[Dict[str, Any]]:
"""
Extract PropertyNames constraint from field metadata.
Args:
field_info: Pydantic FieldInfo object
Returns:
PropertyNames constraint schema or None if not found
"""
if not hasattr(field_info, 'metadata') or not field_info.metadata:
return None
for metadata_item in field_info.metadata:
if isinstance(metadata_item, PropertyNames):
return metadata_item.get_constraint_schema()
return None
def apply_property_names_to_schema(
schema: Dict[str, Any],
property_names_constraint: Dict[str, Any]
) -> Dict[str, Any]:
"""
Apply PropertyNames constraint to a JSON schema.
Args:
schema: The JSON schema to modify
property_names_constraint: The propertyNames constraint to apply
Returns:
Modified schema with propertyNames constraint
"""
if schema.get("type") == "object":
schema["propertyNames"] = property_names_constraint
return schema

183
tests/test_property_names.py

@ -0,0 +1,183 @@
"""
Tests for PropertyNames constraint support in FastAPI.
"""
from typing import Dict, Annotated
from fastapi import FastAPI
from fastapi.schema import PropertyNames
from pydantic import BaseModel
import json
def test_property_names_creation():
"""Test PropertyNames object creation and validation."""
# Test pattern constraint
pn1 = PropertyNames(pattern="^test_")
assert pn1.get_constraint_schema() == {"pattern": "^test_"}
# Test multiple constraints
pn2 = PropertyNames(min_length=5, max_length=20, pattern="^[a-z]+$")
expected = {"minLength": 5, "maxLength": 20, "pattern": "^[a-z]+$"}
assert pn2.get_constraint_schema() == expected
# Test schema constraint
pn3 = PropertyNames(schema={"type": "string", "format": "email"})
expected = {"type": "string", "format": "email"}
assert pn3.get_constraint_schema() == expected
# Test empty constraint should raise error
try:
PropertyNames()
assert False, "Should have raised ValueError"
except ValueError as e:
assert "PropertyNames constraint must specify at least one validation rule" in str(e)
def test_property_names_pattern_constraint():
"""Test PropertyNames with pattern constraint in FastAPI schema generation."""
class ModelWithPropertyNames(BaseModel):
properties: Annotated[
Dict[str, str],
PropertyNames(pattern="^prefix_")
]
app = FastAPI()
@app.post("/test")
def test_endpoint(data: ModelWithPropertyNames):
return data
# Get the OpenAPI schema
openapi_schema = app.openapi()
# Find the schema for our model
model_schema = None
for schema_name, schema_def in openapi_schema["components"]["schemas"].items():
if schema_name == "ModelWithPropertyNames":
properties_field = schema_def["properties"]["properties"]
if "propertyNames" in properties_field:
model_schema = properties_field
break
assert model_schema is not None, "Could not find model schema with propertyNames"
# Verify the propertyNames constraint is present
property_names_constraint = model_schema["propertyNames"]
assert property_names_constraint["pattern"] == "^prefix_"
# Verify the full schema structure
expected_keys = {"type", "additionalProperties", "propertyNames", "title"}
assert set(model_schema.keys()) >= expected_keys
def test_property_names_complex_constraints():
"""Test PropertyNames with multiple constraints."""
class ComplexPropertyNames(BaseModel):
config_vars: Annotated[
Dict[str, str],
PropertyNames(min_length=3, max_length=50, pattern="^[A-Z_]+$")
]
app = FastAPI()
@app.post("/test")
def test_endpoint(data: ComplexPropertyNames):
return data
# Get the OpenAPI schema
openapi_schema = app.openapi()
# Find the schema for our model
model_schema = None
for schema_name, schema_def in openapi_schema["components"]["schemas"].items():
if schema_name == "ComplexPropertyNames":
config_vars_field = schema_def["properties"]["config_vars"]
if "propertyNames" in config_vars_field:
model_schema = config_vars_field
break
assert model_schema is not None, "Could not find model schema with propertyNames"
# Verify the propertyNames constraint has all expected properties
property_names_constraint = model_schema["propertyNames"]
assert property_names_constraint["pattern"] == "^[A-Z_]+$"
assert property_names_constraint["minLength"] == 3
assert property_names_constraint["maxLength"] == 50
def test_property_names_schema_constraint():
"""Test PropertyNames with custom schema constraint."""
class SchemaPropertyNames(BaseModel):
allowed_keys: Annotated[
Dict[str, int],
PropertyNames(schema={"type": "string", "enum": ["key1", "key2", "key3"]})
]
app = FastAPI()
@app.post("/test")
def test_endpoint(data: SchemaPropertyNames):
return data
# Get the OpenAPI schema
openapi_schema = app.openapi()
# Find the schema for our model
model_schema = None
for schema_name, schema_def in openapi_schema["components"]["schemas"].items():
if schema_name == "SchemaPropertyNames":
allowed_keys_field = schema_def["properties"]["allowed_keys"]
if "propertyNames" in allowed_keys_field:
model_schema = allowed_keys_field
break
assert model_schema is not None, "Could not find model schema with propertyNames"
# Verify the propertyNames constraint with custom schema
property_names_constraint = model_schema["propertyNames"]
assert property_names_constraint["type"] == "string"
assert property_names_constraint["enum"] == ["key1", "key2", "key3"]
def test_property_names_no_constraint():
"""Test that models without PropertyNames constraints work normally."""
class NormalModel(BaseModel):
properties: Dict[str, str]
app = FastAPI()
@app.post("/test")
def test_endpoint(data: NormalModel):
return data
# Get the OpenAPI schema
openapi_schema = app.openapi()
# Find the schema for our model
for schema_name, schema_def in openapi_schema["components"]["schemas"].items():
if schema_name == "NormalModel":
properties_field = schema_def["properties"]["properties"]
# Should not have propertyNames constraint
assert "propertyNames" not in properties_field
break
else:
assert False, "Could not find NormalModel schema"
def test_property_names_kwargs():
"""Test PropertyNames with additional kwargs."""
pn = PropertyNames(format="email", description="Email addresses")
constraint_schema = pn.get_constraint_schema()
assert constraint_schema["format"] == "email"
assert constraint_schema["description"] == "Email addresses"
def test_property_names_repr():
"""Test PropertyNames string representation."""
pn = PropertyNames(pattern="^test_")
repr_str = repr(pn)
assert "PropertyNames" in repr_str
assert "pattern" in repr_str
assert "^test_" in repr_str
Loading…
Cancel
Save