from rest_framework import relations, renderers, serializers, status from rest_framework.settings import api_settings from rest_framework_json_api import encoders from rest_framework_json_api.utils import ( get_related_field, is_related_many, model_from_obj, model_to_resource_type ) from django.core import urlresolvers from django.core.exceptions import NON_FIELD_ERRORS from django.utils import encoding, six from django.utils.six.moves.urllib.parse import urlparse, urlunparse class WrapperNotApplicable(ValueError): def __init__(self, *args, **kwargs): self.data = kwargs.pop('data', None) self.renderer_context = kwargs.pop('renderer_context', None) return super(WrapperNotApplicable, self).__init__(*args, **kwargs) class JsonApiMixin(object): convert_by_name = { 'id': 'convert_to_text', api_settings.URL_FIELD_NAME: 'rename_to_href', } convert_by_type = { relations.PrimaryKeyRelatedField: 'handle_related_field', relations.HyperlinkedRelatedField: 'handle_url_field', serializers.ModelSerializer: 'handle_nested_serializer', } dict_class = dict encoder_class = encoders.JSONEncoder media_type = 'application/vnd.api+json' wrappers = [ 'wrap_empty_response', 'wrap_parser_error', 'wrap_field_error', 'wrap_generic_error', 'wrap_options', 'wrap_paginated', 'wrap_default' ] def render(self, data, accepted_media_type=None, renderer_context=None): """Convert native data to JSON API Tries each of the methods in `wrappers`, using the first successful one, or raises `WrapperNotApplicable`. """ wrapper = None success = False for wrapper_name in self.wrappers: wrapper_method = getattr(self, wrapper_name) try: wrapper = wrapper_method(data, renderer_context) except WrapperNotApplicable: pass else: success = True break if not success: raise WrapperNotApplicable( 'No acceptable wrappers found for response.', data=data, renderer_context=renderer_context) renderer_context["indent"] = 4 return super(JsonApiMixin, self).render( data=wrapper, accepted_media_type=accepted_media_type, renderer_context=renderer_context) def wrap_empty_response(self, data, renderer_context): """ Pass-through empty responses 204 No Content includes an empty response """ if data is not None: raise WrapperNotApplicable('Data must be empty.') return data def wrap_parser_error(self, data, renderer_context): """ Convert parser errors to the JSON API Error format Parser errors have a status code of 400, like field errors, but have the same native format as generic errors. Also, the detail message is often specific to the input, so the error is listed as a 'detail' rather than a 'title'. """ response = renderer_context.get("response", None) status_code = response and response.status_code if status_code != 400: raise WrapperNotApplicable('Status code must be 400.') if list(data.keys()) != ['detail']: raise WrapperNotApplicable('Data must only have "detail" key.') # Probably a parser error, unless `detail` is a valid field view = renderer_context.get("view", None) model = self.model_from_obj(view) if 'detail' in model._meta.get_all_field_names(): raise WrapperNotApplicable() return self.wrap_error( data, renderer_context, keys_are_fields=False, issue_is_title=False) def wrap_field_error(self, data, renderer_context): """ Convert field error native data to the JSON API Error format See the note about the JSON API Error format on `wrap_error`. The native format for field errors is a dictionary where the keys are field names (or 'non_field_errors' for additional errors) and the values are a list of error strings: { "min": [ "min must be greater than 0.", "min must be an even number." ], "max": ["max must be a positive number."], "non_field_errors": [ "Select either a range or an enumeration, not both."] } It is rendered into this JSON API error format: { "errors": [{ "status": "400", "path": "/min", "detail": "min must be greater than 0." },{ "status": "400", "path": "/min", "detail": "min must be an even number." },{ "status": "400", "path": "/max", "detail": "max must be a positive number." },{ "status": "400", "path": "/-", "detail": "Select either a range or an enumeration, not both." }] } """ response = renderer_context.get("response", None) status_code = response and response.status_code if status_code != 400: raise WrapperNotApplicable('Status code must be 400.') return self.wrap_error( data, renderer_context, keys_are_fields=True, issue_is_title=False) def wrap_generic_error(self, data, renderer_context): """ Convert generic error native data using the JSON API Error format See the note about the JSON API Error format on `wrap_error`. The native format for errors that are not bad requests, such as authentication issues or missing content, is a dictionary with a 'detail' key and a string value: { "detail": "Authentication credentials were not provided." } This is rendered into this JSON API error format: { "errors": [{ "status": "403", "title": "Authentication credentials were not provided" }] } """ response = renderer_context.get("response", None) status_code = response and response.status_code is_error = ( status.is_client_error(status_code) or status.is_server_error(status_code) ) if not is_error: raise WrapperNotApplicable("Status code must be 4xx or 5xx.") return self.wrap_error( data, renderer_context, keys_are_fields=False, issue_is_title=True) def wrap_error( self, data, renderer_context, keys_are_fields, issue_is_title): """Convert error native data to the JSON API Error format JSON API has a different format for errors, but Django REST Framework doesn't have a separate rendering path for errors. This results in some guesswork to determine if data is an error, what kind, and how to handle it. As of August 2014, there is not a consensus about the error format in JSON API. The format documentation defines an "errors" collection, and some possible fields for that collection, but without examples for common cases. If and when consensus is reached, this format will probably change. """ response = renderer_context.get("response", None) status_code = str(response and response.status_code) errors = [] for field, issues in data.items(): if isinstance(issues, six.string_types): issues = [issues] for issue in issues: error = self.dict_class() error["status"] = status_code if issue_is_title: error["title"] = issue else: error["detail"] = issue if keys_are_fields: if field in ('non_field_errors', NON_FIELD_ERRORS): error["path"] = '/-' else: error["path"] = '/' + field errors.append(error) wrapper = self.dict_class() wrapper["errors"] = errors return wrapper def wrap_options(self, data, renderer_context): '''Wrap OPTIONS data as JSON API meta value''' request = renderer_context.get("request", None) method = request and getattr(request, 'method') if method != 'OPTIONS': raise WrapperNotApplicable("Request method must be OPTIONS") wrapper = self.dict_class() wrapper["meta"] = data return wrapper def wrap_paginated(self, data, renderer_context): """Convert paginated data to JSON API with meta""" pagination_keys = ['count', 'next', 'previous', 'results'] for key in pagination_keys: if not (data and key in data): raise WrapperNotApplicable('Not paginated results') view = renderer_context.get("view", None) model = self.model_from_obj(view) resource_type = self.model_to_resource_type(model) try: from rest_framework.utils.serializer_helpers import ReturnList results = ReturnList( data["results"], serializer=data.serializer.fields["results"], ) except ImportError: results = data["results"] # Use default wrapper for results wrapper = self.wrap_default(results, renderer_context) # Add pagination metadata pagination = self.dict_class() pagination['previous'] = data['previous'] pagination['next'] = data['next'] pagination['count'] = data['count'] wrapper.setdefault('meta', self.dict_class()) wrapper['meta'].setdefault('pagination', self.dict_class()) wrapper['meta']['pagination'].setdefault( resource_type, self.dict_class()).update(pagination) return wrapper def wrap_default(self, data, renderer_context): """Convert native data to a JSON API resource collection This wrapper expects a standard DRF data object (a dict-like object with a `fields` dict-like attribute), or a list of such data objects. """ wrapper = self.dict_class() view = renderer_context.get("view", None) request = renderer_context.get("request", None) model = self.model_from_obj(view) resource_type = self.model_to_resource_type(model) if isinstance(data, list): many = True resources = data else: many = False resources = [data] items = [] links = self.dict_class() linked = self.dict_class() meta = self.dict_class() for resource in resources: converted = self.convert_resource(resource, data, request) item = converted.get('data', {}) linked_ids = converted.get('linked_ids', {}) if linked_ids: item["links"] = linked_ids items.append(item) links.update(converted.get('links', {})) linked = self.update_nested(linked, converted.get('linked', {})) meta.update(converted.get('meta', {})) if many: wrapper[resource_type] = items else: wrapper[resource_type] = items[0] if links: links = self.prepend_links_with_name(links, resource_type) wrapper["links"] = links if linked: wrapper["linked"] = linked if meta: wrapper["meta"] = meta return wrapper def convert_resource(self, resource, data, request): fields = self.fields_from_resource(resource, data) if not fields: raise WrapperNotApplicable('Items must have a fields attribute.') data = self.dict_class() linked_ids = self.dict_class() links = self.dict_class() linked = self.dict_class() meta = self.dict_class() for field_name, field in six.iteritems(fields): converted = None if field_name in self.convert_by_name: converter_name = self.convert_by_name[field_name] converter = getattr(self, converter_name) converted = converter(resource, field, field_name, request) else: related_field = get_related_field(field) for field_type, converter_name in \ six.iteritems(self.convert_by_type): if isinstance(related_field, field_type): converter = getattr(self, converter_name) converted = converter( resource, field, field_name, request) break if converted: data.update(converted.pop("data", {})) linked_ids.update(converted.pop("linked_ids", {})) links.update(converted.get("links", {})) linked = self.update_nested(linked, converted.get('linked', {})) meta.update(converted.get("meta", {})) else: data[field_name] = resource[field_name] return { 'data': data, 'linked_ids': linked_ids, 'links': links, 'linked': linked, 'meta': meta, } def convert_to_text(self, resource, field, field_name, request): data = self.dict_class() data[field_name] = encoding.force_text(resource[field_name]) return {"data": data} def rename_to_href(self, resource, field, field_name, request): data = self.dict_class() data['href'] = resource[field_name] return {"data": data} def prepend_links_with_name(self, links, name): changed_links = links.copy() for link_name, link_obj in six.iteritems(links): prepended_name = "%s.%s" % (name, link_name) link_template = "{%s}" % link_name prepended_template = "{%s}" % prepended_name updated_obj = changed_links[link_name] if "href" in link_obj: updated_obj["href"] = link_obj["href"].replace( link_template, prepended_template) changed_links[prepended_name] = changed_links[link_name] del changed_links[link_name] return changed_links def handle_nested_serializer(self, resource, field, field_name, request): serializer_field = get_related_field(field) if hasattr(serializer_field, "opts"): model = serializer_field.opts.model else: model = serializer_field.Meta.model resource_type = self.model_to_resource_type(model) linked_ids = self.dict_class() links = self.dict_class() linked = self.dict_class() linked[resource_type] = [] if is_related_many(field): items = resource[field_name] else: items = [resource[field_name]] obj_ids = [] resource.serializer = serializer_field for item in items: converted = self.convert_resource(item, resource, request) linked_obj = converted["data"] linked_ids = converted.pop("linked_ids", {}) if linked_ids: linked_obj["links"] = linked_ids obj_ids.append(converted["data"]["id"]) field_links = self.prepend_links_with_name( converted.get("links", {}), resource_type) field_links[field_name] = { "type": resource_type, } if "href" in converted["data"]: url_field_name = api_settings.URL_FIELD_NAME url_field = serializer_field.fields[url_field_name] field_links[field_name]["href"] = self.url_to_template( url_field.view_name, request, field_name, ) links.update(field_links) linked[resource_type].append(linked_obj) if is_related_many(field): linked_ids[field_name] = obj_ids else: linked_ids[field_name] = obj_ids[0] return {"linked_ids": linked_ids, "links": links, "linked": linked} def handle_related_field(self, resource, field, field_name, request): links = self.dict_class() linked_ids = self.dict_class() related_field = get_related_field(field) model = self.model_from_obj(related_field) resource_type = self.model_to_resource_type(model) if field_name in resource: links[field_name] = { "type": resource_type, } if is_related_many(field): link_data = [ encoding.force_text(pk) for pk in resource[field_name]] elif resource[field_name]: link_data = encoding.force_text(resource[field_name]) else: link_data = None linked_ids[field_name] = link_data return {"linked_ids": linked_ids, "links": links} def handle_url_field(self, resource, field, field_name, request): links = self.dict_class() linked_ids = self.dict_class() related_field = get_related_field(field) model = self.model_from_obj(related_field) resource_type = self.model_to_resource_type(model) links[field_name] = { "href": self.url_to_template(related_field.view_name, request, field_name), "type": resource_type, } if field_name in resource: linked_ids[field_name] = self.url_to_pk( resource[field_name], field) return {"linked_ids": linked_ids, "links": links} def url_to_pk(self, url_data, field): if is_related_many(field): try: obj_list = field.to_internal_value(url_data) except AttributeError: obj_list = [field.from_native(url) for url in url_data] return [encoding.force_text(obj.pk) for obj in obj_list] if url_data: try: obj = field.to_internal_value(url_data) except AttributeError: obj = field.from_native(url_data) return encoding.force_text(obj.pk) else: return None def url_to_template(self, view_name, request, template_name): resolver = urlresolvers.get_resolver(None) info = resolver.reverse_dict[view_name] path_template = info[0][0][0] # FIXME: what happens when URL has more than one dynamic values? # e.g. nested relations: manufacturer/%(id)s/cars/%(card_id)s path = path_template % {info[0][0][1][0]: '{%s}' % template_name} parsed_url = urlparse(request.build_absolute_uri()) return urlunparse( [parsed_url.scheme, parsed_url.netloc, path, '', '', ''] ) def fields_from_resource(self, resource, data): if hasattr(data, "serializer"): resource = data.serializer if hasattr(resource, "child"): resource = resource.child return getattr(resource, "fields", None) def model_to_resource_type(self, model): return model_to_resource_type(model) def model_from_obj(self, obj): return model_from_obj(obj) def update_nested(self, existing_linked, u): for k, new_values in u.items(): if k in existing_linked: # The dictionary already exists, so we need to check # that all the already existing links in the dictionary # aren't the same. If they aren't, add them. for item in new_values: mapped_ids = map(lambda x: x['id'], existing_linked[k]) if not item['id'] in mapped_ids: existing_linked[k].append(item) else: existing_linked[k] = new_values return existing_linked class JsonApiRenderer(JsonApiMixin, renderers.JSONRenderer): pass