11"""JSON API schemas builder class."""
22from dataclasses import dataclass
33from 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
1717import pydantic
18- from pydantic import BaseConfig , validator
18+ from pydantic import BaseConfig , root_validator , validator
1919from pydantic import BaseModel as PydanticBaseModel
20+ from pydantic .class_validators import ROOT_VALIDATOR_CONFIG_KEY , VALIDATOR_CONFIG_KEY
2021from pydantic .fields import FieldInfo , ModelField , Validator
2122
2223from fastapi_jsonapi .data_typing import TypeSchema
3536from fastapi_jsonapi .schema_base import BaseModel , Field , RelationshipInfo , registry
3637from fastapi_jsonapi .splitter import SPLIT_REL
3738
38- if TYPE_CHECKING :
39- pass
40-
4139JSON_API_RESPONSE_TYPE = Dict [Union [int , str ], Dict [str , Any ]]
4240
4341JSONAPIObjectSchemaType = 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 )
7573class 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