Skip to content

Commit

Permalink
Makes API compliant with JSON API specification.
Browse files Browse the repository at this point in the history
Previously, the behavior of Flask-Restless was a bit arbitrary. Now we
force it to comply with a concrete (though still changing)
specification, which can be found at http://jsonapi.org/.

This is a (severely) backwards-incompatible change, as it changes which
API endpoints are exposed and the format of requests and responses.

This change also moves JSON API compliance tests to a convenient
distinct test module, `tests.test_jsonapi.py`, so that compliance with
the specification can be easily verified. These tests correspond to
version 1.0rc2 of the JSON API specification, which can be found in
commit json-api/json-api@af5dfcc.

This change fixes (or at least makes it much easier to fix or much
easier to mark as "won't fix") quite a few issues, including but not
limited to

  - #87
  - #153
  - #168
  - #193
  - #208
  - #211
  - #213
  - #243
  - #252
  - #253
  - #258
  - #261
  - #262
  - #303
  - #394
  • Loading branch information
jfinkels committed Mar 7, 2015
1 parent 97a6827 commit 362f6cf
Show file tree
Hide file tree
Showing 18 changed files with 7,563 additions and 1,981 deletions.
4 changes: 4 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ in Flask-Restless.

.. automethod:: create_api_blueprint

.. autofunction:: collection_name(model, _apimanager=None)

.. autofunction:: model_for(collection_name, _apimanager=None)

.. autofunction:: url_for(model, instid=None, relationname=None, relationinstid=None, _apimanager=None, **kw)

.. autoclass:: ProcessingException
15 changes: 15 additions & 0 deletions docs/customizing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ method::

Then the API will be exposed at ``/api/people`` instead of ``/api/person``.

.. note::

According to the `JSON API standard`_,

.. blockquote::

Note: This spec is agnostic about inflection rules, so the value of type
can be either plural or singular. However, the same value should be used
consistently throughout an implementation.

It's up to you to make sure your collection names are either all plural or
all singular!

.. _JSON API standard: http://jsonapi.org/format/#document-structure-resource-types

Specifying one of many primary keys
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
3 changes: 3 additions & 0 deletions flask_restless/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
__version__ = '0.17.1-dev'

# make the following names available as part of the public API
from .helpers import collection_name
from .helpers import model_for
from .helpers import url_for
from .manager import APIManager
from .manager import IllegalArgumentError
from .views import CONTENT_TYPE
from .views import ProcessingException
607 changes: 451 additions & 156 deletions flask_restless/helpers.py

Large diffs are not rendered by default.

194 changes: 148 additions & 46 deletions flask_restless/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,19 @@
"""
from collections import defaultdict
from collections import namedtuple
from urllib.parse import urljoin

import flask
from flask import request
from flask import Blueprint

from .helpers import primary_key_name
from .helpers import collection_name
from .helpers import model_for
from .helpers import url_for
from .views import API
from .views import FunctionAPI
from .views import RelationshipAPI

#: The set of methods which are allowed by default when creating an API
READONLY_METHODS = frozenset(('GET', ))
Expand All @@ -37,9 +42,6 @@
'universal_preprocessors',
'universal_postprocessors'])

#: A global list of created :class:`APIManager` objects.
created_managers = []

#: A tuple that stores information about a created API.
#:
#: The first element, `collection_name`, is the name by which a collection of
Expand Down Expand Up @@ -131,9 +133,14 @@ def __init__(self, app=None, **kw):
#: the corresponding collection names for those models.
self.created_apis_for = {}

# Stash this instance so that it can be examined later by other
# functions in this module.
url_for.created_managers.append(self)
# Stash this instance so that it can be examined later by the global
# `url_for`, `model_for`, and `collection_name` functions.
#
# TODO This is a bit of poor code style because it requires the
# APIManager to know about these global functions that use it.
url_for.register(self)
model_for.register(self)
collection_name.register(self)

self.flask_sqlalchemy_db = kw.pop('flask_sqlalchemy_db', None)
self.session = kw.pop('session', None)
Expand Down Expand Up @@ -182,45 +189,104 @@ def api_name(collection_name):
"""
return APIManager.APINAME_FORMAT.format(collection_name)

def collection_name(self, model):
"""Returns the name by which the user told us to call collections of
instances of this model.
def model_for(self, collection_name):
"""Returns the SQLAlchemy model class whose type is given by the
specified collection name.
`model` is a SQLAlchemy model class. This must be a model on which
:meth:`create_api_blueprint` has been invoked previously.
`collection_name` is a string containing the collection name as
provided to the ``collection_name`` keyword argument to
:meth:`create_api_blueprint`.
"""
return self.created_apis_for[model].collection_name
The collection name should correspond to a model on which
:meth:`create_api_blueprint` has been invoked previously. If it doesn't
this method raises :exc:`ValueError`.
def blueprint_name(self, model):
"""Returns the name of the blueprint in which an API was created for
the specified model.
This method is the inverse of :meth:`collection_name`::
`model` is a SQLAlchemy model class. This must be a model on which
:meth:`create_api_blueprint` has been invoked previously.
>>> from mymodels import Person
>>> manager.create_api(Person, collection_name='people')
>>> manager.collection_name(manager.model_for('people'))
'people'
>>> manager.model_for(manager.collection_name(Person))
<class 'mymodels.Person'>
"""
return self.created_apis_for[model].blueprint_name

def url_for(self, model, **kw):
# Reverse the dictionary.
models = {info.collection_name: model
for model, info in self.created_apis_for.items()}
try:
return models[collection_name]
except KeyError:
raise ValueError('Collection name {0} unknown. Be sure to set the'
' `collection_name` keyword argument when calling'
' `create_api()`.'.format(collection_name))

def url_for(self, model, _absolute_url=True, **kw):
"""Returns the URL for the specified model, similar to
:func:`flask.url_for`.
`model` is a SQLAlchemy model class. This must be a model on which
:meth:`create_api_blueprint` has been invoked previously.
`model` is a SQLAlchemy model class. This should be a model on which
:meth:`create_api_blueprint` has been invoked previously. If not, this
method raises a :exc:`ValueError`.
If `_absolute_url` is ``False``, this function will return just the URL
path without the ``scheme`` and ``netloc`` part of the URL. If it is
``True``, this function joins the relative URL to the url root for the
current request. This means `_absolute_url` can only be set to ``True``
if this function is called from within a `Flask request context`_. For
example::
>>> from mymodels import Person
>>> manager.create_api(Person)
>>> manager.url_for(Person, instid=3)
'http://example.com/api/people/3'
>>> manager.url_for(Person, instid=3, _absolute_url=False)
'/api/people/3'
This method only returns URLs for endpoints created by this
:class:`APIManager`.
The remaining keyword arguments are passed directly on to
:func:`flask.url_for`.
.. _Flask request context: http://flask.pocoo.org/docs/0.10/reqcontext/
"""
collection_name = self.collection_name(model)
try:
collection_name = self.created_apis_for[model].collection_name
blueprint_name = self.created_apis_for[model].blueprint_name
except KeyError:
raise ValueError('Model {0} unknown. Maybe you need to call'
' `create_api()`?'.format(model))
api_name = APIManager.api_name(collection_name)
blueprint_name = self.blueprint_name(model)
joined = '.'.join([blueprint_name, api_name])
return flask.url_for(joined, **kw)
parts = [blueprint_name, api_name]
# If we are looking for a relationship URL, the view name ends with
# '.links'.
if 'relationship' in kw and kw.pop('relationship'):
parts.append('links')
url = flask.url_for('.'.join(parts), **kw)
if _absolute_url:
url = urljoin(request.url_root, url)
return url

def collection_name(self, model):
"""Returns the collection name for the specified model, as specified by
the ``collection_name`` keyword argument to
:meth:`create_api_blueprint`.
`model` is a SQLAlchemy model class. This should be a model on which
:meth:`create_api_blueprint` has been invoked previously. If not, this
method raises a :exc:`ValueError`.
This method only returns URLs for endpoints created by this
:class:`APIManager`.
"""
try:
return self.created_apis_for[model].collection_name
except KeyError:
raise ValueError('Model {0} unknown. Maybe you need to call'
' `create_api()`?'.format(model))

def init_app(self, app, session=None, flask_sqlalchemy_db=None,
preprocessors=None, postprocessors=None):
Expand Down Expand Up @@ -325,11 +391,14 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS,
allow_patch_many=False, allow_delete_many=False,
allow_functions=False, exclude_columns=None,
include_columns=None, include_methods=None,
validation_exceptions=None, results_per_page=10,
max_results_per_page=100,
validation_exceptions=None, page_size=10,
max_page_size=100,
post_form_preprocessor=None, preprocessors=None,
postprocessors=None, primary_key=None,
serializer=None, deserializer=None):
serializer=None, deserializer=None,
includes=None, allow_to_many_replacement=False,
allow_delete_from_to_many_relationships=False,
allow_client_generated_ids=False):
"""Creates and returns a ReSTful API interface as a blueprint, but does
not register it on any :class:`flask.Flask` application.
Expand Down Expand Up @@ -556,10 +625,10 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS,
instance_methods = \
methods & frozenset(('GET', 'PATCH', 'DELETE', 'PUT'))
possibly_empty_instance_methods = methods & frozenset(('GET', ))
if allow_patch_many and ('PATCH' in methods or 'PUT' in methods):
possibly_empty_instance_methods |= frozenset(('PATCH', 'PUT'))
if allow_delete_many and 'DELETE' in methods:
possibly_empty_instance_methods |= frozenset(('DELETE', ))
# if allow_patch_many and ('PATCH' in methods or 'PUT' in methods):
# possibly_empty_instance_methods |= frozenset(('PATCH', 'PUT'))
# if allow_delete_many and 'DELETE' in methods:
# possibly_empty_instance_methods |= frozenset(('DELETE', ))

# Check that primary_key is included for no_instance_methods
if no_instance_methods:
Expand All @@ -585,12 +654,23 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS,
postprocessors_[key] = value + postprocessors_[key]
# the view function for the API for this model
api_view = API.as_view(apiname, restlessinfo.session, model,
exclude_columns, include_columns,
include_methods, validation_exceptions,
results_per_page, max_results_per_page,
post_form_preprocessor, preprocessors_,
postprocessors_, primary_key, serializer,
deserializer)
# Keyword arguments for APIBase.__init__()
preprocessors=preprocessors_,
postprocessors=postprocessors_,
primary_key=primary_key,
validation_exceptions=validation_exceptions,
allow_to_many_replacement=allow_to_many_replacement,
# Keyword arguments for API.__init__()
exclude_columns=exclude_columns,
include_columns=include_columns,
include_methods=include_methods,
page_size=page_size,
max_page_size=max_page_size,
serializer=serializer,
deserializer=deserializer,
includes=includes,
allow_client_generated_ids=allow_client_generated_ids,
allow_delete_many=allow_delete_many)
# suffix an integer to apiname according to already existing blueprints
blueprintname = APIManager._next_blueprint_name(app.blueprints,
apiname)
Expand All @@ -602,13 +682,13 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS,
# TODO should the url_prefix be specified here or in register_blueprint
blueprint = Blueprint(blueprintname, __name__, url_prefix=url_prefix)
# For example, /api/person.
blueprint.add_url_rule(collection_endpoint,
methods=no_instance_methods, view_func=api_view)
# blueprint.add_url_rule(collection_endpoint,
# methods=['GET', 'POST'], view_func=api_view)
# For example, /api/person/1.
blueprint.add_url_rule(collection_endpoint,
defaults={'instid': None, 'relationname': None,
'relationinstid': None},
methods=possibly_empty_instance_methods,
methods=frozenset(['GET', 'POST', 'DELETE']) & methods,
view_func=api_view)
# the per-instance endpoints will allow both integer and string primary
# key accesses
Expand All @@ -618,19 +698,41 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS,
defaults={'relationname': None,
'relationinstid': None},
view_func=api_view)
# add endpoints which expose related models
relation_endpoint = '{0}/<relationname>'.format(instance_endpoint)
# Create related resource URLs.
relation_endpoint = \
'{0}/<relationname>'.format(instance_endpoint)
relation_instance_endpoint = \
'{0}/<relationinstid>'.format(relation_endpoint)
# For example, /api/person/1/computers.
blueprint.add_url_rule(relation_endpoint,
methods=possibly_empty_instance_methods,
methods=frozenset(['GET', 'PUT', 'POST',
'DELETE']) & methods,
defaults={'relationinstid': None},
view_func=api_view)
# For example, /api/person/1/computers/2.
blueprint.add_url_rule(relation_instance_endpoint,
methods=instance_methods,
view_func=api_view)

# Create relationship URL endpoints.
RAPI = RelationshipAPI
relationship_api_name = '{0}.links'.format(apiname)
relationship_api_view = \
RAPI.as_view(relationship_api_name,
restlessinfo.session, model,
# Keyword arguments for APIBase.__init__()
preprocessors=preprocessors_,
postprocessors=postprocessors_,
primary_key=primary_key,
validation_exceptions=validation_exceptions,
allow_to_many_replacement=allow_to_many_replacement,
# Keyword arguments RelationshipAPI.__init__()
allow_delete_from_to_many_relationships=allow_delete_from_to_many_relationships)
relationship_endpoint = '{0}/links/<relationname>'.format(instance_endpoint)
blueprint.add_url_rule(relationship_endpoint,
methods=frozenset(['PUT', 'POST', 'DELETE']) & methods,
view_func=relationship_api_view)

# if function evaluation is allowed, add an endpoint at /api/eval/...
# which responds only to GET requests and responds with the result of
# evaluating functions on all instances of the specified model
Expand Down
Loading

0 comments on commit 362f6cf

Please sign in to comment.