Dec 20, 2013

Write Only Fields in Django REST Framework

For some odd reason, Django REST framework doesn't include any support for write-only fields. There's support for read-only fields, but not write-only. An example use case is for an API call that changes the user's password. You want to verify that their current password is correct as well. Searching online gives a lot of half-baked solutions implementing some hacky "delete it here, add it there" kind of patching.

Unfortunately, the side effects include these fields no longer appearing on the HTML REST interface, among other rather silly issues. Here's my solution:

class ProfileSerializer(serializers.Serializer):
    class Meta:
        write_only_fields = ('current_password','password')

    email = serializers.CharField(required=False)
    password = serializers.CharField(required=False)
    current_password = serializers.CharField()

    def to_native(self, obj):
        ret = self._dict_class()
        ret.fields = self._dict_class()
        for field_name, field in self.fields.items():
            if field.read_only and obj is None:
                continue
            elif field_name in getattr(self.opts, 'write_only_fields', ()):
                key = self.get_field_key(field_name)
                value = self.init_data.get(key, None) if self.init_data else None
                if value:
                    ret[key] = value
                ret.fields[key] = self.augment_field(field, field_name, key, value)
            else:
                field.initialize(parent=self, field_name=field_name)
                key = self.get_field_key(field_name)
                value = field.field_to_native(obj, field_name)
                method = getattr(self, 'transform_%s' % field_name, None)
                if callable(method):
                    value = method(obj, value)
                ret[key] = value
                ret.fields[key] = self.augment_field(field, field_name, key, value)
        return ret

    def restore_object(self, attrs, instance=None):
        return super(ReceptiveSerializer, self).restore_object(
            dict((k,v) for (k,v) in filter(
                lambda x:x[0] not in getattr(self.opts, 'write_only_fields', ()), attrs.items())
            ), instance)

    def validate_current_password(self, attrs, source):
        if self.object is None:
            return attrs
        u = authenticate(username=self.object.email, password=attrs[source])
        if u is not None:
            return attrs
        else:
            raise serializers.ValidationError('OBJECTION!')

The addition of the above to_native and restore_object methods (which you can copy/paste) will permit you to add a write_only_fields property to the Meta class, defining the fields you wish to have short circuit. If you wish to use this in multiple classes, you can extend this to a general serializer as follows:

class ReceptiveSerializerOptions(serializers.SerializerOptions):
    def __init__(self, meta):
        super(ReceptiveSerializerOptions, self).__init__(meta)
        self.write_only_fields = getattr(meta, 'write_only_fields', ())

class ReceptiveSerializer(serializers.Serializer):
    _options_class = ReceptiveSerializerOptions

    def to_native(self, obj):
        ret = self._dict_class()
        ret.fields = self._dict_class()
        for field_name, field in self.fields.items():
            if field.read_only and obj is None:
                continue
            elif field_name in getattr(self.opts, 'write_only_fields', ()):
                key = self.get_field_key(field_name)
                value = self.init_data.get(key, None) if self.init_data else None
                if value:
                    ret[key] = value
                ret.fields[key] = self.augment_field(field, field_name, key, value)
            else:
                field.initialize(parent=self, field_name=field_name)
                key = self.get_field_key(field_name)
                value = field.field_to_native(obj, field_name)
                method = getattr(self, 'transform_%s' % field_name, None)
                if callable(method):
                    value = method(obj, value)
                ret[key] = value
                ret.fields[key] = self.augment_field(field, field_name, key, value)
        return ret

    def restore_object(self, attrs, instance=None):
        return super(ReceptiveSerializer, self).restore_object(
            dict((k,v) for (k,v) in filter(
                lambda x:x[0] not in getattr(self.opts, 'write_only_fields', ()), attrs.items())
            ), instance)

Have it extend serializers.Serializer or serializers.ModelSerializer, whichever floats your boat. I've made a pull request for this update too. :D