diff --git a/fastapi/_compat.py b/fastapi/_compat.py index 26b6638c8..7b9d4aed1 100644 --- a/fastapi/_compat.py +++ b/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) diff --git a/fastapi/schema.py b/fastapi/schema.py new file mode 100644 index 000000000..3c0530d2a --- /dev/null +++ b/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 diff --git a/tests/test_property_names.py b/tests/test_property_names.py new file mode 100644 index 000000000..27cca9f1e --- /dev/null +++ b/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