marshmallow icon indicating copy to clipboard operation
marshmallow copied to clipboard

Pass schema's context to validation functions

Open evgeny-sureev opened this issue 8 years ago • 16 comments

I am thinking about a way to pass context to validation function.

Currently validation function expect exactly one argument, value of a field. Maybe Field._validate method can check with inspect.getargspec and if function expect two arguments, send schema's context as second argument?

I know that context is available in validation methods declared with @validates decorator, but I want to reuse validation function in different schemas with different fields.

evgeny-sureev avatar Oct 02 '15 11:10 evgeny-sureev

An interesting idea. I don't think using inspect is the right approach; we used to do this with Function and Method fields, but it turned out to incur significant performance overhead. I think it would be more consistent with the current API to use a decorator, e.g. @pass_schema_context.

sloria avatar Oct 02 '15 12:10 sloria

Something like this?

@pass_schema_context
def validate_quantity(n, context):
    pass

class ItemSchema(Schema):
    quantity = fields.Integer(validate=validate_quantity)

evgeny-sureev avatar Oct 02 '15 16:10 evgeny-sureev

Yes, that was what I was thinking. Although it might be better to pass the field and field name rather than just the context. That would allow you to access the context with field.context.

sloria avatar Oct 03 '15 13:10 sloria

@evgeny-sureev What is your intended use for the schema context?

Symantically, validation involving a schema instance would be considered schema level validation, which uses the @validates_schema decorator. The documentation shows an example of accessing a schema instance in a validator.

https://marshmallow.readthedocs.io/en/latest/extending.html#schema-level-validation

deckar01 avatar Jun 24 '16 17:06 deckar01

@deckar01 I have a use case where it would be helpful if context could be passed to validators.

For example:

class IPSchema(Schema):
    cidr = fields.String()

   @validates('cidr')
   def valid_network(self, data):
        if data not in self.context.get('whitelisted_networks', []):
            raise ValidationError('Network {0} is not allowed'.format(data))

This way we could selectively pass in what is allowed, perhaps for a given user:

 whitelist = ['192.168.1.1/32']
 admin_whitelist = ['0.0.0.0/0']

 schema = IPSchema()

 if user.is_admin:
     schema.context['network_whitelist'] = admin_whitelist
 else:
     schema.context['network_whitelist'] = whitelist

 schema.load({'ip': '172.168.1.1/32'})

If there is concern about mixing serialization/de-serialization endpoint perhaps we define a validation_context instead?

kevgliss avatar Jul 13 '16 17:07 kevgliss

@kevgliss You can use the existing @validate_schema decorator to validate against the schema context. The field_names parameter of the ValidationError exception allows you to specify which field(s) the error belongs to.

class IPSchema(Schema):
    cidr = fields.String()

   @validate_schema
   def valid_network(self, data):
        cidr = data['cidr']
        if cidr not in self.context.get('whitelisted_networks', []):
            raise ValidationError('Network {0} is not allowed'.format(cidr), ['cidr'])

deckar01 avatar Jul 14 '16 01:07 deckar01

@kevgliss I agree with @deckar01 that you can access context via self if you make validation a schema's member function. Although IMO this kind of validations should not be done during deserialization. Instead you check them afterwards on deserialized data. E.g. you do not check if passed "name" value is unique inside your database in data deserialization code.

maximkulkin avatar Jul 14 '16 05:07 maximkulkin

Thanks @deckar01, I didn't see that fields could be specified on schema level validation.

@maximkulkin I would generally agree, although I am experimenting with abusing marshmallow by using it for validation outside of the normal serialization process.

https://github.com/kevgliss/aws-flare

kevgliss avatar Jul 14 '16 16:07 kevgliss

@kevgliss Yes, this kind of checks is easier to implement outside of deserialization logic.

maximkulkin avatar Jul 14 '16 18:07 maximkulkin

I want to reuse validation function in different schemas with different fields.

I didn't fully digest this requirement the first time around.

Currently you would have to:

def validate_foo(foo, field_name, context):
    if foo not in context['bar']:
        raise ValidationError('Foo must be in bar', [field_name])

class ItemSchema(Schema):
    quantity = fields.Integer()

    @validates_schema
    def validate_quantity(self, data):
        validate_foo(data['quantity'], 'quantity', self.context)

class AnotherSchema(Schema):
    quantity = fields.Integer()

    @validates_schema
    def validate_quantity(self, data):
        validate_foo(data['quantity'], 'quantity', self.context)

but it would be a lot more convenient if you had access to the context in a field validator:

@pass_schema_context
def validate_foo(foo, context):
    if foo not in context['bar']:
        raise ValidationError('Foo must be in bar')

class ItemSchema(Schema):
    quantity = fields.Integer(validate=validate_foo)

class AnotherSchema(Schema):
    quantity = fields.Integer(validate=validate_foo)

So Field._validate() would need to be able to efficiently identify when a validator has been wrapped in @pass_schema_context decorator and provide the schema context as an additional argument.

I'm not sure it makes sense to use a decorator outside of the schema body in order to identify that a field-level validator expects schema-level data. All the other decorators are used to register a processor with a specific schema class.

deckar01 avatar Jul 14 '16 23:07 deckar01

👍 for passing context to validators

s0undt3ch avatar Jan 20 '17 17:01 s0undt3ch

+1 for passing context to validators. My use case is the following:

class ItemSchema(Schema):
    item_id = fields.Integer(validate=lambda val, ctx: val in ctx.get('admissible_ids', ()))

schema = ItemSchema(context={'admissible_ids': WHITELISTED_IDS})
schema.load(input_dict)

egnartsms avatar Jan 31 '17 17:01 egnartsms

There is a proposal in #334 to allow error messages to be defined as callables that receive the input value and field object. It would also make sense to also pass the field object to validators if we move forward with the OP.

sloria avatar Mar 20 '17 00:03 sloria

Proposed fix in #709

s0undt3ch avatar Dec 28 '17 10:12 s0undt3ch

DRF added this functionality--we may decide to do something similar? https://www.django-rest-framework.org/community/3.11-announcement/#validator-default-context

sloria avatar Dec 12 '19 23:12 sloria

For anyone looking for a way to do this in 2021 and don't mind some extra overhead, here is a generic hack.

from inspect import signature
from marshmallow import Schema, fields

class ExtendedSchema(Schema):
    def __init__(self, *args, validator_context=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.validator_context = validator_context

    def on_bind_field(self, field_name, field_obj):
        field_obj.validators = [lambda val: self._validate_with_context(method, val) for method in field_obj.validators]

    def _validate_with_context(self, method, value):
        args = signature(method).parameters
        if len(args) > 1 and 'context' in args.keys():
            return method(value, context=self.validator_context)
        else:
            return method(value)

class MySchema(ExtendedSchema):
    name = fields.String(validate=custom_validator)

def custom_validator(value, context):
    # Use context here
    pass

You can also set self.validator_context via @pre_load if you want to base it on the input data.

lham avatar Mar 29 '21 21:03 lham