From f8813b89f4ea06586e6c94963871a476135e0143 Mon Sep 17 00:00:00 2001 From: Yoann Date: Sat, 4 Dec 2021 15:46:52 +0100 Subject: [PATCH] Handle json_patch (rfc 6902) --- rest_framework/parsers.py | 28 ++++++++++++++++ rest_framework/serializers.py | 8 +++++ rest_framework/settings.py | 1 + rest_framework/utils/json_patch.py | 53 ++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 rest_framework/utils/json_patch.py diff --git a/rest_framework/parsers.py b/rest_framework/parsers.py index fc4eb1428..c61dd93d3 100644 --- a/rest_framework/parsers.py +++ b/rest_framework/parsers.py @@ -7,6 +7,7 @@ on the request, such as form content or json encoded data. import codecs from urllib import parse +import jsonpatch from django.conf import settings from django.core.files.uploadhandler import StopFutureHandlers from django.http import QueryDict @@ -67,6 +68,33 @@ class JSONParser(BaseParser): raise ParseError('JSON parse error - %s' % str(exc)) +class JSONPatchParser(BaseParser): + """ + Parses PATCH RFC 6902 JSON-serialized data. + """ + + media_type = 'application/json-patch+json' + renderer_class = renderers.JSONRenderer + strict = api_settings.STRICT_JSON + + def parse(self, stream, media_type=None, parser_context=None): + """ + Parses the incoming bytestream as JSON and returns the resulting data as json patch. + """ + parser_context = parser_context or {} + encoding = parser_context.get('encoding', settings.DEFAULT_CHARSET) + + try: + decoded_stream = codecs.getreader(encoding)(stream) + parse_constant = json.strict_constant if self.strict else None + data = json.load(decoded_stream, parse_constant=parse_constant) + return jsonpatch.JsonPatch(data) + except ValueError as exc: + raise ParseError('JSON parse error - %s' % str(exc)) + except jsonpatch.InvalidJsonPatch as exc: + raise ParseError('JSON Patch (rfc 6902) invalid - %s' % str(exc)) + + class FormParser(BaseParser): """ Parser for form data. diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 389680517..cea8b83e9 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -23,6 +23,7 @@ from django.db.models.fields import Field as DjangoModelField from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from jsonpatch import JsonPatch from rest_framework.compat import postgres_fields from rest_framework.exceptions import ErrorDetail, ValidationError @@ -33,6 +34,7 @@ from rest_framework.utils.field_mapping import ( ClassLookupDict, get_field_kwargs, get_nested_relation_kwargs, get_relation_kwargs, get_url_kwargs ) +from rest_framework.utils.json_patch import apply_json_patch from rest_framework.utils.serializer_helpers import ( BindingDict, BoundField, JSONBoundField, NestedBoundField, ReturnDict, ReturnList @@ -108,6 +110,12 @@ class BaseSerializer(Field): def __init__(self, instance=None, data=empty, **kwargs): self.instance = instance + + if isinstance(data, JsonPatch): + # serialise current instance to get a dict + instance_serialized = self.__class__(instance).data + data = apply_json_patch(patch=data, current_state=instance_serialized) + if data is not empty: self.initial_data = data self.partial = kwargs.pop('partial', False) diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 9eb4c5653..b17619f79 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -32,6 +32,7 @@ DEFAULTS = { ], 'DEFAULT_PARSER_CLASSES': [ 'rest_framework.parsers.JSONParser', + 'rest_framework.parsers.JSONPatchParser', 'rest_framework.parsers.FormParser', 'rest_framework.parsers.MultiPartParser' ], diff --git a/rest_framework/utils/json_patch.py b/rest_framework/utils/json_patch.py new file mode 100644 index 000000000..ce5e10978 --- /dev/null +++ b/rest_framework/utils/json_patch.py @@ -0,0 +1,53 @@ +from jsonpatch import ( + JsonPatch, + InvalidJsonPatch, + JsonPatchConflict, + JsonPatchTestFailed, + JsonPointerException, +) + +from rest_framework.exceptions import ValidationError, ParseError + + +def filter_state(state, paths_parts): + filtered_state = {} + for parts in paths_parts: + if len(parts) > 1: + parts = iter(parts) + next_part = next(parts) + parts = [list(parts)] + filtered_state[next_part] = filter_state(state[next_part], parts) + elif len(parts) == 1: + filtered_state[parts[0]] = state[parts[0]] + else: + # empty parts will raise JsonPointerException during apply() + # this type of error should be checked by json_patch at the + # initilization not during the application. + continue + + return filtered_state + + +def apply_json_patch(patch: JsonPatch, current_state: dict): + field = None + try: + # empty parts will raise JsonPointerException during apply() + # this type of error should be checked by json_patch at the + # initilization not during the application. + paths_parts = [[part for op in patch._ops for part in op.pointer.parts if part]] + filtered_state = filter_state(current_state, paths_parts) + return patch.apply(filtered_state) + except KeyError: + raise ValidationError( + {'details': f'JSON Patch (rfc 6902) path does not exist - {field}'} + ) + except JsonPatchConflict as exc: + raise ValidationError({'details': f'JSON Patch (rfc 6902) conflict - {exc}'}) + except JsonPatchTestFailed as exc: + raise ValidationError({'details': f'JSON Patch (rfc 6902) test failed - {exc}'}) + except JsonPointerException as exc: + raise ValidationError( + {'details': f"JSON Patch (rfc 6902) path's part invalid - {exc}"} + ) + except InvalidJsonPatch as exc: + raise ParseError(f'JSON Patch (rfc 6902) invalid - {exc}')