patch.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. from __future__ import unicode_literals
  2. from rest_framework import serializers
  3. from rest_framework.response import Response
  4. from rest_framework.settings import api_settings
  5. from django.core.exceptions import PermissionDenied, ValidationError
  6. from django.db import transaction
  7. from django.http import Http404
  8. from django.utils import six
  9. from django.utils.translation import gettext as _
  10. ALLOWED_OPS = ('add', 'remove', 'replace')
  11. class InvalidAction(Exception):
  12. pass
  13. HANDLED_EXCEPTIONS = (
  14. serializers.ValidationError,
  15. ValidationError,
  16. InvalidAction,
  17. PermissionDenied,
  18. Http404,
  19. )
  20. class ApiPatch(object):
  21. def __init__(self):
  22. self._actions = []
  23. def add(self, path, handler):
  24. self._actions.append({
  25. 'op': 'add',
  26. 'path': path,
  27. 'handler': handler,
  28. })
  29. def remove(self, path, handler):
  30. self._actions.append({
  31. 'op': 'remove',
  32. 'path': path,
  33. 'handler': handler,
  34. })
  35. def replace(self, path, handler):
  36. self._actions.append({
  37. 'op': 'replace',
  38. 'path': path,
  39. 'handler': handler,
  40. })
  41. def dispatch(self, request, target):
  42. response = {'id': target.pk}
  43. try:
  44. self.validate_actions(request.data)
  45. except HANDLED_EXCEPTIONS as exception:
  46. return self.handle_exception(exception)
  47. for action in request.data:
  48. try:
  49. self.dispatch_action(response, request, target, action)
  50. except HANDLED_EXCEPTIONS as exception:
  51. return self.handle_exception(exception, path=action['path'])
  52. return Response(response)
  53. def dispatch_bulk(self, request, targets):
  54. try:
  55. self.validate_actions(request.data['ops'])
  56. except HANDLED_EXCEPTIONS as exception:
  57. return self.handle_exception(exception)
  58. result = []
  59. for target in targets:
  60. data = {'id': target.pk, 'status': 200, 'patch': {}}
  61. for action in request.data['ops']:
  62. try:
  63. self.dispatch_action(data['patch'], request, target, action)
  64. except HANDLED_EXCEPTIONS as exception:
  65. errors, status = self.get_error_data_status(exception, path=action['path'])
  66. data = {'id': target.pk, 'status': status, }
  67. if status == 400:
  68. data['invalid'] = errors
  69. else:
  70. data.update(errors)
  71. break
  72. result.append(data)
  73. # sort result items by id then cast id and status to string
  74. # so we are compliant with our bulk actions spec
  75. result.sort(key=lambda item: item['id'])
  76. for data in result:
  77. data['id'] = str(data['id'])
  78. data['status'] = str(data['status'])
  79. # always returning 200 on op error saves us logic duplication
  80. # in the frontend, were we need to do success handling in both
  81. # success and error handles
  82. return Response(result)
  83. def validate_actions(self, actions):
  84. if not isinstance(actions, list):
  85. raise InvalidAction(_("PATCH request should be a list of operations."))
  86. reduced_actions = []
  87. for action in actions:
  88. self.validate_action(action)
  89. reduced_action = self.reduce_action(action)
  90. if reduced_action in reduced_actions:
  91. raise InvalidAction(
  92. _('"%(op)s" op for "%(path)s" path is repeated.') % reduced_action)
  93. reduced_actions.append(reduced_action)
  94. def validate_action(self, action):
  95. if not action.get('op'):
  96. raise InvalidAction(_('"op" parameter must be defined.'))
  97. if action.get('op') not in ALLOWED_OPS:
  98. raise InvalidAction(_('"%s" op is unsupported.') % action.get('op'))
  99. if not action.get('path'):
  100. raise InvalidAction(_('"%s" op has to specify path.') % action.get('op'))
  101. if 'value' not in action:
  102. raise InvalidAction(_('"%s" op has to specify value.') % action.get('op'))
  103. def reduce_action(self, action):
  104. return {'op': action['op'], 'path': action['path']}
  105. def dispatch_action(self, data, request, target, action):
  106. for handler in self._actions:
  107. if action['op'] == handler['op'] and action['path'] == handler['path']:
  108. with transaction.atomic():
  109. data.update(handler['handler'](request, target, action['value']))
  110. def handle_exception(self, exception, path=None):
  111. data, status = self.get_error_data_status(exception, path)
  112. return Response(data, status=status)
  113. def get_error_data_status(self, exception, path=None):
  114. if path:
  115. # if path is set, we swap comma for underscore
  116. # this makes it easier to process errors in frontend
  117. path = path.replace('-', '_')
  118. if isinstance(exception, InvalidAction):
  119. return {api_settings.NON_FIELD_ERRORS_KEY: [six.text_type(exception)]}, 400
  120. if isinstance(exception, serializers.ValidationError):
  121. return {path: exception.detail}, 400
  122. if isinstance(exception, ValidationError):
  123. return {path: exception.messages}, 400
  124. if isinstance(exception, PermissionDenied):
  125. return {'detail': six.text_type(exception)}, 403
  126. if isinstance(exception, Http404):
  127. return {'detail': 'NOT FOUND'}, 404