Browse Source
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 #13880pull/14142/head
3 changed files with 364 additions and 1 deletions
@ -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 |
|||
@ -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…
Reference in new issue