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