Skip to content

Commit 2d345ac

Browse files
committed
added root validators, updated logic of field validators
1 parent 098218e commit 2d345ac

File tree

2 files changed

+368
-51
lines changed

2 files changed

+368
-51
lines changed

fastapi_jsonapi/schema_builder.py

Lines changed: 112 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
"""JSON API schemas builder class."""
22
from dataclasses import dataclass
33
from typing import (
4-
TYPE_CHECKING,
54
Any,
65
Callable,
76
Dict,
87
Iterable,
98
List,
109
Optional,
10+
Set,
1111
Tuple,
1212
Type,
1313
TypeVar,
1414
Union,
1515
)
1616

1717
import pydantic
18-
from pydantic import BaseConfig, validator
18+
from pydantic import BaseConfig, root_validator, validator
1919
from pydantic import BaseModel as PydanticBaseModel
20+
from pydantic.class_validators import ROOT_VALIDATOR_CONFIG_KEY, VALIDATOR_CONFIG_KEY
2021
from pydantic.fields import FieldInfo, ModelField, Validator
2122

2223
from fastapi_jsonapi.data_typing import TypeSchema
@@ -35,9 +36,6 @@
3536
from fastapi_jsonapi.schema_base import BaseModel, Field, RelationshipInfo, registry
3637
from fastapi_jsonapi.splitter import SPLIT_REL
3738

38-
if TYPE_CHECKING:
39-
pass
40-
4139
JSON_API_RESPONSE_TYPE = Dict[Union[int, str], Dict[str, Any]]
4240

4341
JSONAPIObjectSchemaType = TypeVar("JSONAPIObjectSchemaType", bound=PydanticBaseModel)
@@ -68,13 +66,13 @@ class BuiltSchemasDTO:
6866
list_response_schema: Type[JSONAPIResultListSchema]
6967

7068

71-
FieldValidator = Optional[Any]
69+
FieldValidators = Dict[str, Callable]
7270

7371

7472
@dataclass(frozen=True)
7573
class SchemasInfoDTO:
7674
# id field
77-
resource_id_field: Tuple[Type, FieldInfo, Callable]
75+
resource_id_field: Tuple[Type, FieldInfo, Callable, FieldValidators]
7876
# pre-built attributes
7977
attributes_schema: Type[BaseModel]
8078
# relationships
@@ -240,7 +238,7 @@ def _get_info_from_schema_for_building(
240238
relationships_schema_fields = {}
241239
included_schemas: List[Tuple[str, BaseModel, str]] = []
242240
has_required_relationship = False
243-
resource_id_field = (str, Field(None), None)
241+
resource_id_field = (str, Field(None), None, {})
244242

245243
for name, field in (schema.__fields__ or {}).items():
246244
if isinstance(field.field_info.extra.get("relationship"), RelationshipInfo):
@@ -267,12 +265,15 @@ def _get_info_from_schema_for_building(
267265
# works both for to-one and to-many
268266
included_schemas.append((name, field.type_, relationship.resource_type))
269267
elif name == "id":
268+
id_validators = self._extract_field_validators(schema, target_field_name="id")
269+
resource_id_field = (*(resource_id_field[:-1]), id_validators)
270+
270271
if not field.field_info.extra.get("client_can_set_id"):
271272
continue
272273

273274
# todo: support for union types?
274275
# support custom cast func
275-
resource_id_field = (str, Field(**field.field_info.extra), field.outer_type_)
276+
resource_id_field = (str, Field(**field.field_info.extra), field.outer_type_, id_validators)
276277
else:
277278
attributes_schema_fields[name] = (field.outer_type_, field.field_info)
278279

@@ -283,7 +284,7 @@ class ConfigOrmMode(BaseConfig):
283284
f"{base_name}AttributesJSONAPI",
284285
**attributes_schema_fields,
285286
__config__=ConfigOrmMode,
286-
__validators__=self._extract_validators(schema),
287+
__validators__=self._extract_validators(schema, exclude_for_field_names={"id"}),
287288
)
288289

289290
relationships_schema = pydantic.create_model(
@@ -351,37 +352,119 @@ def create_relationship_data_schema(
351352
self.relationship_schema_cache[cache_key] = relationship_data_schema
352353
return relationship_data_schema
353354

354-
def _extract_validators(self, model: Type[BaseModel]) -> Dict[str, Callable]:
355+
def _is_target_validator(self, attr_name: str, value: Any, validator_config_key: str) -> bool:
356+
"""
357+
True if passed object is validator of type identified by "validator_config_key" arg
358+
359+
:param attr_name:
360+
:param value:
361+
:param validator_config_key: Choice field, available options are pydantic consts
362+
VALIDATOR_CONFIG_KEY, ROOT_VALIDATOR_CONFIG_KEY
363+
"""
364+
return (
365+
# also with private items
366+
not attr_name.startswith("__")
367+
and getattr(value, validator_config_key, None)
368+
)
369+
370+
def _unpack_validators(self, model: Type[BaseModel], validator_config_key: str) -> Dict[str, Validator]:
371+
"""
372+
Selects all validators from model attrs and unpack them from class methods
373+
374+
:param model: Type[BaseModel]
375+
:param validator_config_key: Choice field, available options are pydantic consts
376+
VALIDATOR_CONFIG_KEY, ROOT_VALIDATOR_CONFIG_KEY
377+
"""
378+
root_validator_class_methods = {
379+
# validators only
380+
attr_name: value
381+
for attr_name, value in model.__dict__.items()
382+
if self._is_target_validator(attr_name, value, validator_config_key)
383+
}
384+
385+
return {
386+
validator_name: getattr(validator_method, validator_config_key)
387+
for validator_name, validator_method in root_validator_class_methods.items()
388+
}
389+
390+
def _extract_root_validators(self, model: Type[BaseModel]) -> Dict[str, Callable]:
355391
validators = {}
356-
validator_origin_param_keys = ["pre", "each_item", "always", "check_fields"]
357392

358-
for field_name, model_field in model.__fields__.items():
359-
model_field: ModelField
393+
unpacked_validators = self._unpack_validators(model, ROOT_VALIDATOR_CONFIG_KEY)
394+
for validator_name, validator_instance in unpacked_validators.items():
395+
validators[validator_name] = root_validator(
396+
pre=validator_instance.pre,
397+
skip_on_failure=validator_instance.skip_on_failure,
398+
allow_reuse=True,
399+
)(validator_instance.func)
360400

361-
for validator_name, field_validator in model_field.class_validators.items():
362-
validator_name: str
363-
field_validator: Validator
401+
return validators
364402

365-
validators[validator_name] = validator(
366-
field_name,
367-
allow_reuse=True,
368-
**{
369-
# copy origin params
370-
param_name: getattr(field_validator, param_name)
371-
for param_name in validator_origin_param_keys
372-
},
373-
)(field_validator.func)
403+
def _extract_field_validators(
404+
self,
405+
model: Type[BaseModel],
406+
target_field_name: str = None,
407+
exclude_for_field_names: Set[str] = None,
408+
) -> Dict[str, Callable]:
409+
"""
410+
:param model: Type[BaseModel]
411+
:param target_field_name: Name of field for which validators will be returned.
412+
If not set the function will return validators for all fields.
413+
"""
414+
validators = {}
415+
validator_origin_param_keys = ("pre", "each_item", "always", "check_fields")
416+
417+
unpacked_validators = self._unpack_validators(model, VALIDATOR_CONFIG_KEY)
418+
for validator_name, (field_names, validator_instance) in unpacked_validators.items():
419+
if target_field_name and target_field_name not in field_names:
420+
continue
421+
elif target_field_name:
422+
field_names = [target_field_name] # noqa: PLW2901
423+
424+
if exclude_for_field_names:
425+
field_names = [ # noqa: PLW2901
426+
# filter names
427+
field_name
428+
for field_name in field_names
429+
if field_name not in exclude_for_field_names
430+
]
431+
432+
if not field_names:
433+
continue
434+
435+
validators[validator_name] = validator(
436+
*field_names,
437+
allow_reuse=True,
438+
**{
439+
# copy origin params
440+
param_name: getattr(validator_instance, param_name)
441+
for param_name in validator_origin_param_keys
442+
},
443+
)(validator_instance.func)
374444

375445
return validators
376446

447+
def _extract_validators(
448+
self,
449+
model: Type[BaseModel],
450+
exclude_for_field_names: Set[str] = None,
451+
) -> Dict[str, Callable]:
452+
return {
453+
**self._extract_field_validators(
454+
model,
455+
exclude_for_field_names=exclude_for_field_names,
456+
),
457+
**self._extract_root_validators(model),
458+
}
459+
377460
def _build_jsonapi_object(
378461
self,
379462
base_name: str,
380463
resource_type: str,
381464
attributes_schema: Type[TypeSchema],
382465
relationships_schema: Type[TypeSchema],
383466
includes,
384-
resource_id_field: Tuple[Type, FieldInfo, Callable],
467+
resource_id_field: Tuple[Type, FieldInfo, Callable, FieldValidators],
385468
model_base: Type[JSONAPIObjectSchemaType] = JSONAPIObjectSchema,
386469
use_schema_cache: bool = True,
387470
relationships_required: bool = False,
@@ -390,7 +473,7 @@ def _build_jsonapi_object(
390473
if use_schema_cache and base_name in self.base_jsonapi_object_schemas_cache:
391474
return self.base_jsonapi_object_schemas_cache[base_name]
392475

393-
field_type, field_info, id_cast_func = resource_id_field
476+
field_type, field_info, id_cast_func, id_validators = resource_id_field
394477

395478
id_field_kw = {
396479
**field_info.extra,
@@ -411,6 +494,7 @@ def _build_jsonapi_object(
411494
f"{base_name}ObjectJSONAPI",
412495
**object_jsonapi_schema_fields,
413496
type=(str, Field(default=resource_type or self._resource_type, description="Resource type")),
497+
__validators__=id_validators,
414498
__base__=model_base,
415499
)
416500

0 commit comments

Comments
 (0)