Skip to content

Custom content negotiation #171

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
dist: trusty
language: python
python:
- '2.7'
- '3.4'
- '3.5'
- 'pypy'
- '3.6'
- 'pypy3'
install:
- pip install -r requirements.txt
- pip install coveralls coverage
Expand Down
22 changes: 18 additions & 4 deletions flask_rest_jsonapi/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@
import inspect
from functools import wraps

from flask import request, abort
from flask import abort
from flask import request

from flask_rest_jsonapi.resource import ResourceList, ResourceRelationship
from flask_rest_jsonapi.decorators import jsonapi_exception_formatter
from flask_rest_jsonapi.resource import ResourceList, ResourceRelationship


class Api(object):
"""The main class of the Api"""

def __init__(self, app=None, blueprint=None, decorators=None):
def __init__(self, app=None, blueprint=None, decorators=None, request_parsers=None, response_renderers=None):
"""Initialize an instance of the Api

:param app: the flask application
Expand All @@ -29,9 +30,14 @@ def __init__(self, app=None, blueprint=None, decorators=None):
self.resource_registry = []
self.decorators = decorators or tuple()

# Store any custom parsers and renderers, which will be passed to the resources
self.request_parsers = request_parsers or {}
self.response_renderers = response_renderers or {}

if app is not None:
self.init_app(app, blueprint)


def init_app(self, app=None, blueprint=None, additional_blueprints=None):
"""Update flask application with our api

Expand Down Expand Up @@ -69,7 +75,11 @@ def route(self, resource, view, *urls, **kwargs):
resource.view = view
url_rule_options = kwargs.get('url_rule_options') or dict()

view_func = resource.as_view(view)
view_func = resource.as_view(
view,
request_parsers=self.request_parsers,
response_renderers=self.response_renderers
)

if 'blueprint' in kwargs:
resource.view = '.'.join([kwargs['blueprint'].name, resource.view])
Expand All @@ -95,6 +105,7 @@ def oauth_manager(self, oauth_manager):

:param oauth_manager: the oauth manager
"""

@self.app.before_request
@jsonapi_exception_formatter
def before_request():
Expand Down Expand Up @@ -165,6 +176,7 @@ def permission_manager(self, permission_manager, with_decorators=True):

def has_permission(self, *args, **kwargs):
"""Decorator used to check permissions before to call resource manager method"""

def wrapper(view):
if getattr(view, '_has_permissions_decorator', False) is True:
return view
Expand All @@ -174,8 +186,10 @@ def wrapper(view):
def decorated(*view_args, **view_kwargs):
self.check_permissions(view, view_args, view_kwargs, *args, **kwargs)
return view(*view_args, **view_kwargs)

decorated._has_permissions_decorator = True
return decorated

return wrapper

@staticmethod
Expand Down
54 changes: 54 additions & 0 deletions flask_rest_jsonapi/content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import json

from flask import make_response
from flask.wrappers import Response as FlaskResponse
from werkzeug.wrappers import Response

from flask_rest_jsonapi.utils import JSONEncoder


def parse_json(request):
"""
Default content parser for JSON
"""
return request.json


def render_json(response):
"""
Default content renderer for JSON
"""
headers = {'Content-Type': 'application/vnd.api+json'}
if isinstance(response, Response):
response.headers.add('Content-Type', 'application/vnd.api+json')
return response

if not isinstance(response, tuple):
if isinstance(response, dict):
response.update({'jsonapi': {'version': '1.0'}})
return make_response(json.dumps(response, cls=JSONEncoder), 200, headers)

try:
data, status_code, headers = response
headers.update({'Content-Type': 'application/vnd.api+json'})
except ValueError:
pass

try:
data, status_code = response
except ValueError:
pass

if isinstance(data, dict):
data.update({'jsonapi': {'version': '1.0'}})

if isinstance(data, FlaskResponse):
data.headers.add('Content-Type', 'application/vnd.api+json')
data.status_code = status_code
return data
elif isinstance(data, str):
json_reponse = data
else:
json_reponse = json.dumps(data, cls=JSONEncoder)

return make_response(json_reponse, status_code, headers)
14 changes: 14 additions & 0 deletions flask_rest_jsonapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,17 @@ class AccessDenied(JsonApiException):

title = 'Access denied'
status = '403'


class InvalidContentType(JsonApiException):
"""When the request uses a content type the API doesn't understand"""

title = 'Bad request'
status = '415'


class InvalidAcceptType(JsonApiException):
"""When the request expects a content type that the API doesn't support"""

title = 'Bad request'
status = '406'
119 changes: 61 additions & 58 deletions flask_rest_jsonapi/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,22 @@
"""This module contains the logic of resource management"""

import inspect
import json
from six import with_metaclass

from werkzeug.wrappers import Response
from flask import request, url_for, make_response
from flask.wrappers import Response as FlaskResponse
from flask import request, url_for
from flask.views import MethodView, MethodViewType
from marshmallow_jsonapi.exceptions import IncorrectTypeError
from marshmallow import ValidationError
from marshmallow_jsonapi.exceptions import IncorrectTypeError
from six import with_metaclass

from flask_rest_jsonapi.querystring import QueryStringManager as QSManager
from flask_rest_jsonapi.pagination import add_pagination_links
from flask_rest_jsonapi.exceptions import InvalidType, BadRequest, RelationNotFound
from flask_rest_jsonapi.data_layers.alchemy import SqlalchemyDataLayer
from flask_rest_jsonapi.data_layers.base import BaseDataLayer
from flask_rest_jsonapi.decorators import check_headers, check_method_requirements, jsonapi_exception_formatter
from flask_rest_jsonapi.exceptions import InvalidType, BadRequest, RelationNotFound, InvalidContentType, \
InvalidAcceptType
from flask_rest_jsonapi.pagination import add_pagination_links
from flask_rest_jsonapi.querystring import QueryStringManager as QSManager
from flask_rest_jsonapi.schema import compute_schema, get_relationships, get_model_field
from flask_rest_jsonapi.data_layers.base import BaseDataLayer
from flask_rest_jsonapi.data_layers.alchemy import SqlalchemyDataLayer
from flask_rest_jsonapi.utils import JSONEncoder
from flask_rest_jsonapi.content import render_json, parse_json


class ResourceMeta(MethodViewType):
Expand All @@ -33,7 +31,7 @@ def __new__(cls, name, bases, d):
if not isinstance(d['data_layer'], dict):
raise Exception("You must provide a data layer information as dict in {}".format(cls.__name__))

if d['data_layer'].get('class') is not None\
if d['data_layer'].get('class') is not None \
and BaseDataLayer not in inspect.getmro(d['data_layer']['class']):
raise Exception("You must provide a data layer class inherited from BaseDataLayer in {}"
.format(cls.__name__))
Expand All @@ -42,7 +40,7 @@ def __new__(cls, name, bases, d):
data_layer_kwargs = d['data_layer']
rv._data_layer = data_layer_cls(data_layer_kwargs)

rv.decorators = (check_headers,)
rv.decorators = ()
if 'decorators' in d:
rv.decorators += d['decorators']

Expand All @@ -52,13 +50,33 @@ def __new__(cls, name, bases, d):
class Resource(MethodView):
"""Base resource class"""

def __new__(cls):
def __new__(cls, request_parsers=None, response_renderers=None):
"""Constructor of a resource instance"""
if hasattr(cls, '_data_layer'):
cls._data_layer.resource = cls

return super(Resource, cls).__new__(cls)

def __init__(self, request_parsers=None, response_renderers=None):
# Start with default parsers, but accept user provided ones
self.request_parsers = {
'application/vnd.api+json': parse_json,
'application/json': parse_json
}
if request_parsers is not None:
self.request_parsers.update(request_parsers)

# Start with default renderers, but accept user provided ones
self.response_renderers = {
'application/vnd.api+json': render_json,
'application/json': render_json
}
if response_renderers is not None:
self.response_renderers.update(response_renderers)

def parse_request(self):
return self.request_parsers[request.content_type](request)

@jsonapi_exception_formatter
def dispatch_request(self, *args, **kwargs):
"""Logic of how to handle a request"""
Expand All @@ -67,43 +85,31 @@ def dispatch_request(self, *args, **kwargs):
method = getattr(self, 'get', None)
assert method is not None, 'Unimplemented method {}'.format(request.method)

headers = {'Content-Type': 'application/vnd.api+json'}

response = method(*args, **kwargs)

if isinstance(response, Response):
response.headers.add('Content-Type', 'application/vnd.api+json')
return response

if not isinstance(response, tuple):
if isinstance(response, dict):
response.update({'jsonapi': {'version': '1.0'}})
return make_response(json.dumps(response, cls=JSONEncoder), 200, headers)

try:
data, status_code, headers = response
headers.update({'Content-Type': 'application/vnd.api+json'})
except ValueError:
pass

try:
data, status_code = response
except ValueError:
pass

if isinstance(data, dict):
data.update({'jsonapi': {'version': '1.0'}})

if isinstance(data, FlaskResponse):
data.headers.add('Content-Type', 'application/vnd.api+json')
data.status_code = status_code
return data
elif isinstance(data, str):
json_reponse = data
# Before we defer to the method function, parse the incoming request
if request.content_type not in self.request_parsers:
raise InvalidContentType(
'This endpoint only supports the following request content types: {}'.format(', '.join(
self.request_parsers.keys())
)
)

# Choose a renderer based on the Accept header
if len(request.accept_mimetypes) < 1:
# If the request doesn't specify a mimetype, assume JSON API
accept_type = 'application/vnd.api+json'
elif request.accept_mimetypes.best not in self.response_renderers:
# Check if we support the response type
raise InvalidAcceptType(
'This endpoint only provides the following content types: {}'.format(', '.join(
self.response_renderers.keys())
)
)
else:
json_reponse = json.dumps(data, cls=JSONEncoder)
accept_type = request.accept_mimetypes.best
renderer = self.response_renderers[accept_type]

return make_response(json_reponse, status_code, headers)
response = method(*args, **kwargs)
return renderer(response)


class ResourceList(with_metaclass(ResourceMeta, Resource)):
Expand Down Expand Up @@ -145,9 +151,8 @@ def get(self, *args, **kwargs):
@check_method_requirements
def post(self, *args, **kwargs):
"""Create an object"""
json_data = request.get_json() or {}

qs = QSManager(request.args, self.schema)
json_data = self.parse_request()

schema = compute_schema(self.schema,
getattr(self, 'post_schema_kwargs', dict()),
Expand Down Expand Up @@ -244,9 +249,8 @@ def get(self, *args, **kwargs):
@check_method_requirements
def patch(self, *args, **kwargs):
"""Update an object"""
json_data = request.get_json() or {}

qs = QSManager(request.args, self.schema)
json_data = self.parse_request()
schema_kwargs = getattr(self, 'patch_schema_kwargs', dict())
schema_kwargs.update({'partial': True})

Expand Down Expand Up @@ -382,7 +386,7 @@ def get(self, *args, **kwargs):
@check_method_requirements
def post(self, *args, **kwargs):
"""Add / create relationship(s)"""
json_data = request.get_json() or {}
json_data = self.parse_request()

relationship_field, model_relationship_field, related_type_, related_id_field = self._get_relationship_data()

Expand Down Expand Up @@ -426,7 +430,7 @@ def post(self, *args, **kwargs):
@check_method_requirements
def patch(self, *args, **kwargs):
"""Update a relationship"""
json_data = request.get_json() or {}
json_data = self.parse_request()

relationship_field, model_relationship_field, related_type_, related_id_field = self._get_relationship_data()

Expand Down Expand Up @@ -470,8 +474,7 @@ def patch(self, *args, **kwargs):
@check_method_requirements
def delete(self, *args, **kwargs):
"""Delete relationship(s)"""
json_data = request.get_json() or {}

json_data = self.parse_request()
relationship_field, model_relationship_field, related_type_, related_id_field = self._get_relationship_data()

if 'data' not in json_data:
Expand Down
Loading