Skip to content

Commit a6ed570

Browse files
authored
Merge pull request #39 from mts-ai/feature/pydantic-validators
added origin validators to attributes schema
2 parents c08d0ad + cb5ef3f commit a6ed570

File tree

3 files changed

+503
-13
lines changed

3 files changed

+503
-13
lines changed

fastapi_jsonapi/schema_builder.py

Lines changed: 120 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,18 @@
77
Iterable,
88
List,
99
Optional,
10+
Set,
1011
Tuple,
1112
Type,
1213
TypeVar,
1314
Union,
1415
)
1516

1617
import pydantic
17-
from pydantic import BaseConfig
18+
from pydantic import BaseConfig, root_validator, validator
1819
from pydantic import BaseModel as PydanticBaseModel
19-
from pydantic.fields import FieldInfo, ModelField
20+
from pydantic.class_validators import ROOT_VALIDATOR_CONFIG_KEY, VALIDATOR_CONFIG_KEY
21+
from pydantic.fields import FieldInfo, ModelField, Validator
2022

2123
from fastapi_jsonapi.data_typing import TypeSchema
2224
from fastapi_jsonapi.schema import (
@@ -64,13 +66,13 @@ class BuiltSchemasDTO:
6466
list_response_schema: Type[JSONAPIResultListSchema]
6567

6668

67-
FieldValidator = Optional[Any]
69+
FieldValidators = Dict[str, Callable]
6870

6971

7072
@dataclass(frozen=True)
7173
class SchemasInfoDTO:
7274
# id field
73-
resource_id_field: Tuple[Type, FieldInfo, Callable]
75+
resource_id_field: Tuple[Type, FieldInfo, Callable, FieldValidators]
7476
# pre-built attributes
7577
attributes_schema: Type[BaseModel]
7678
# relationships
@@ -236,7 +238,7 @@ def _get_info_from_schema_for_building(
236238
relationships_schema_fields = {}
237239
included_schemas: List[Tuple[str, BaseModel, str]] = []
238240
has_required_relationship = False
239-
resource_id_field = (str, Field(None), None)
241+
resource_id_field = (str, Field(None), None, {})
240242

241243
for name, field in (schema.__fields__ or {}).items():
242244
if isinstance(field.field_info.extra.get("relationship"), RelationshipInfo):
@@ -263,12 +265,15 @@ def _get_info_from_schema_for_building(
263265
# works both for to-one and to-many
264266
included_schemas.append((name, field.type_, relationship.resource_type))
265267
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+
266271
if not field.field_info.extra.get("client_can_set_id"):
267272
continue
268273

269274
# todo: support for union types?
270275
# support custom cast func
271-
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)
272277
else:
273278
attributes_schema_fields[name] = (field.outer_type_, field.field_info)
274279

@@ -279,6 +284,7 @@ class ConfigOrmMode(BaseConfig):
279284
f"{base_name}AttributesJSONAPI",
280285
**attributes_schema_fields,
281286
__config__=ConfigOrmMode,
287+
__validators__=self._extract_validators(schema, exclude_for_field_names={"id"}),
282288
)
283289

284290
relationships_schema = pydantic.create_model(
@@ -346,14 +352,119 @@ def create_relationship_data_schema(
346352
self.relationship_schema_cache[cache_key] = relationship_data_schema
347353
return relationship_data_schema
348354

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]:
391+
validators = {}
392+
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)
400+
401+
return validators
402+
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)
444+
445+
return validators
446+
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+
349460
def _build_jsonapi_object(
350461
self,
351462
base_name: str,
352463
resource_type: str,
353464
attributes_schema: Type[TypeSchema],
354465
relationships_schema: Type[TypeSchema],
355466
includes,
356-
resource_id_field: Tuple[Type, FieldInfo, Callable],
467+
resource_id_field: Tuple[Type, FieldInfo, Callable, FieldValidators],
357468
model_base: Type[JSONAPIObjectSchemaType] = JSONAPIObjectSchema,
358469
use_schema_cache: bool = True,
359470
relationships_required: bool = False,
@@ -362,7 +473,7 @@ def _build_jsonapi_object(
362473
if use_schema_cache and base_name in self.base_jsonapi_object_schemas_cache:
363474
return self.base_jsonapi_object_schemas_cache[base_name]
364475

365-
field_type, field_info, id_cast_func = resource_id_field
476+
field_type, field_info, id_cast_func, id_validators = resource_id_field
366477

367478
id_field_kw = {
368479
**field_info.extra,
@@ -383,6 +494,7 @@ def _build_jsonapi_object(
383494
f"{base_name}ObjectJSONAPI",
384495
**object_jsonapi_schema_fields,
385496
type=(str, Field(default=resource_type or self._resource_type, description="Resource type")),
497+
__validators__=id_validators,
386498
__base__=model_base,
387499
)
388500

0 commit comments

Comments
 (0)