Skip to content

Commit

Permalink
Adds schema information at root endpoint.
Browse files Browse the repository at this point in the history
  • Loading branch information
jfinkels committed Sep 11, 2016
1 parent d1bb33a commit f628567
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Not yet released.
- :issue:`200`: be smarter about determining the ``collection_name`` for
polymorphic models defined with single-table inheritance.
- :issue:`253`: don't assign to callable attributes of models.
- :issue:`268`: adds top-level endpoint that exposes API schema.
- :issue:`481,488`: added negation (``not``) operator for search.
- :issue:`492`: support JSON API recommended "simple" filtering.
- :issue:`508`: flush the session before postprocessors, and commit after.
Expand Down
36 changes: 36 additions & 0 deletions docs/requestformat.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,42 @@ specification.
updating
updatingrelationships

Schema at root endpoint
-----------------------

A :http:method:`GET` request to the root endpoint responds with a valid JSON
API document whose `meta` element contains a `urls` object, which itself
contains one member for each resource object exposed by the API. For example, a
request like

.. sourcecode:: http

GET /api/person HTTP/1.1
Host: example.com
Accept: application/vnd.api+json

yields the response

.. sourcecode:: http

HTTP/1.1 200 OK
Content-Type: application/vnd.api+json

{
"data": null,
"jsonapi": {
"version": "1.0"
},
"included": [],
"links": {},
"meta": {
"urls": {
"article": "http://example.com/api/article",
"person": "http://example.com/api/person",
}
}
}

.. _idstring:

Resource ID must be a string
Expand Down
69 changes: 68 additions & 1 deletion flask_restless/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from .views import API
from .views import FunctionAPI
from .views import RelationshipAPI
from .views import SchemaView

#: The names of HTTP methods that allow fetching information.
READONLY_METHODS = frozenset(('GET', ))
Expand Down Expand Up @@ -181,6 +182,24 @@ def __init__(self, app=None, session=None, flask_sqlalchemy_db=None,
#: those models.
self.created_apis_for = {}

# TODO In Python 2.7, this can just be
#
# self.models = self.created_apis_for.viewkeys()
#
# and in Python 3+,
#
# self.models = self.created_apis_for.keys()
#
# Since we need to support Python 2.6, we need to manually keep
# `self.models` updated on each addition to
# `self.created_apis_for`.

#: The set of models for which an API has been created.
#:
#: This set matches the set of keys of :attr:`.created_apis_for`
#: exactly.
self.models = set()

#: List of blueprints created by :meth:`create_api` to be registered
#: to the app when calling :meth:`init_app`.
self.blueprints = []
Expand Down Expand Up @@ -216,6 +235,43 @@ def api_name(collection_name):
"""
return APIManager.APINAME_FORMAT.format(collection_name)

def _create_schema(self):
"""Create a blueprint with an endpoint that exposes the API schema.
This method returns an instance of :class:`flask.Blueprint` that
has a single route at the top-level that exposes the URLs for
each created API; for example, `GET /api` yields a response that
indicates
.. sourcecode:: json
{
"person": "http://example.com/api/person",
"article": "http://example.com/api/person"
}
The view provided by this blueprint depends on the value of
:attr:`.models`, so changes to :attr:`.models` will be reflected
in the response.
"""
# It is important that `self.models` is being passed as
# reference here, since it will be updated each time
# `create_api` is called (in the setting where APIManager is
# provided with a Flask object at instantiation time).
schema_view = SchemaView.as_view('schemaview', self.models)
url_prefix = self.url_prefix or DEFAULT_URL_PREFIX

# The name of this blueprint must be unique, so in order to
# accomodate the situation where we have multiple APIManager
# instances calling `init_app` on a single Flask instance, we
# append the ID of this APIManager object to the name of the
# blueprint.
name = 'manager{0}.schema'.format(id(self))
blueprint = Blueprint(name, __name__, url_prefix=url_prefix)
blueprint.add_url_rule('', view_func=schema_view)
return blueprint

def model_for(self, collection_name):
"""Returns the SQLAlchemy model class whose type is given by the
specified collection name.
Expand Down Expand Up @@ -323,7 +379,6 @@ def primary_key_for(self, model):
return self.created_apis_for[model].primary_key

def init_app(self, app):

"""Registers any created APIs on the given Flask application.
This function should only be called if no Flask application was
Expand Down Expand Up @@ -384,6 +439,9 @@ def init_app(self, app):
# Register any queued blueprints on the given application.
for blueprint in self.blueprints:
app.register_blueprint(blueprint)
# Create a view for the top-level endpoint that returns the schema.
blueprint = self._create_schema()
app.register_blueprint(blueprint)

def create_api_blueprint(self, name, model, methods=READONLY_METHODS,
url_prefix=None, collection_name=None,
Expand Down Expand Up @@ -776,6 +834,7 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS,
# the specified model.
self.created_apis_for[model] = APIInfo(collection_name, blueprint.name,
serializer, primary_key)
self.models.add(model)
return blueprint

def create_api(self, *args, **kw):
Expand Down Expand Up @@ -840,3 +899,11 @@ def create_api(self, *args, **kw):
# application.
if self.app is not None:
self.app.register_blueprint(blueprint)
# If this is the first blueprint, create a schema
# endpoint. It will be updated indirectly each time
# `create_api_blueprint` is called, so it is not necessary
# to make any further modifications to the registered
# blueprint.
if len(self.blueprints) == 1:
blueprint = self._create_schema()
self.app.register_blueprint(blueprint)
2 changes: 2 additions & 0 deletions flask_restless/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"""
from .base import CONTENT_TYPE
from .base import ProcessingException
from .base import SchemaView
from .resources import API
from .relationships import RelationshipAPI
from .function import FunctionAPI
Expand All @@ -29,4 +30,5 @@
'FunctionAPI',
'ProcessingException',
'RelationshipAPI',
'SchemaView',
]
38 changes: 38 additions & 0 deletions flask_restless/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,44 @@ def num_results(self):
return self._num_results


class SchemaView(MethodView):
"""A view of the entire schema of an API.
This class provides a :meth:`.SchemaView.get` method that returns a
JSON API document containing a mapping from resource collection name
to the URL for that resource collection.
`models` is the set of models for which an API has been defined via
the :meth:`.APIManager.create_api` method. The
:meth:`.SchemaView.get` method will call :func:`.collection_name`
and :func:`.url_for` on each model, so if any is unknown, this view
will raise a server-side exception.
"""

#: List of decorators applied to every method of this class.
#:
#: If a subclass must add more decorators, prepend them to this list::
#:
#: class MyView(SchemaView):
#: decorators = [my_decorator] + SchemaView.decorators
#:
#: This way, the :data:`mimerender` function appears last. It must appear
#: last so that it can render the returned dictionary.
decorators = [requires_json_api_accept, requires_json_api_mimetype,
mimerender]

def __init__(self, models):
self.models = models

def get(self):
result = JsonApiDocument()
# TODO In Python 2.7+, this should be a dict comprehension.
result['meta']['urls'] = dict((collection_name(model), url_for(model))
for model in self.models)
return result


class ModelView(MethodView):
"""Base class for :class:`flask.MethodView` classes which represent a view
of a SQLAlchemy model.
Expand Down
6 changes: 5 additions & 1 deletion tests/test_creating.py
Original file line number Diff line number Diff line change
Expand Up @@ -998,9 +998,13 @@ class Tag(self.Base):
id = Column(Integer, primary_key=True)
name = Column(Unicode)

# HACK It seems that if we don't persist the Article class then
# the test sometimes gets confused about which Article class is
# being referenced in requests made in the test methods below.
self.Article = Article
self.Tag = Tag
self.Base.metadata.create_all()
self.manager.create_api(Article, methods=['POST'])
self.manager.create_api(self.Article, methods=['POST'])
# HACK Need to create APIs for these other models because otherwise
# we're not able to create the link URLs to them.
#
Expand Down
25 changes: 25 additions & 0 deletions tests/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,31 @@ def test_override_url_prefix(self):
# response = self.app.get('/bar/person')
# assert response.status_code == 404

def test_schema_app_in_constructor(self):
manager = APIManager(self.flaskapp, session=self.session)
manager.create_api(self.Article)
manager.create_api(self.Person)
response = self.app.get('/api')
self.assertEqual(response.status_code, 200)
document = loads(response.data)
urls = document['meta']['urls']
self.assertEqual(sorted(urls), ['article', 'person'])
self.assertTrue(urls['article'].endswith('/api/article'))
self.assertTrue(urls['person'].endswith('/api/person'))

def test_schema_init_app(self):
manager = APIManager(session=self.session)
manager.create_api(self.Article)
manager.create_api(self.Person)
manager.init_app(self.flaskapp)
response = self.app.get('/api')
self.assertEqual(response.status_code, 200)
document = loads(response.data)
urls = document['meta']['urls']
self.assertEqual(sorted(urls), ['article', 'person'])
self.assertTrue(urls['article'].endswith('/api/article'))
self.assertTrue(urls['person'].endswith('/api/person'))


class TestAPIManager(ManagerTestBase):
"""Unit tests for the :class:`flask_restless.manager.APIManager` class."""
Expand Down

0 comments on commit f628567

Please sign in to comment.