Commit 6ff9840b by Tom Christie Committed by GitHub

Schemas & client libraries. (#4179)

* Added schema generation support.
* New tutorial section.
* API guide on schema generation.
* Topic guide on API clients.
parent 1d2fba90
......@@ -91,6 +91,7 @@ REST framework requires the following:
The following packages are optional:
* [coreapi][coreapi] (1.21.0+) - Schema generation support.
* [Markdown][markdown] (2.1.0+) - Markdown support for the browsable API.
* [django-filter][django-filter] (0.9.2+) - Filtering support.
* [django-crispy-forms][django-crispy-forms] - Improved HTML display for filtering.
......@@ -214,6 +215,7 @@ The API guide is your complete reference manual to all the functionality provide
* [Versioning][versioning]
* [Content negotiation][contentnegotiation]
* [Metadata][metadata]
* [Schemas][schemas]
* [Format suffixes][formatsuffixes]
* [Returning URLs][reverse]
* [Exceptions][exceptions]
......@@ -226,6 +228,7 @@ The API guide is your complete reference manual to all the functionality provide
General guides to using REST framework.
* [Documenting your API][documenting-your-api]
* [API Clients][api-clients]
* [Internationalization][internationalization]
* [AJAX, CSRF & CORS][ajax-csrf-cors]
* [HTML & Forms][html-and-forms]
......@@ -296,6 +299,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
[redhat]: https://www.redhat.com/
[heroku]: https://www.heroku.com/
[eventbrite]: https://www.eventbrite.co.uk/about/
[coreapi]: http://pypi.python.org/pypi/coreapi/
[markdown]: http://pypi.python.org/pypi/Markdown/
[django-filter]: http://pypi.python.org/pypi/django-filter
[django-crispy-forms]: https://github.com/maraujop/django-crispy-forms
......@@ -318,6 +322,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
[tut-4]: tutorial/4-authentication-and-permissions.md
[tut-5]: tutorial/5-relationships-and-hyperlinked-apis.md
[tut-6]: tutorial/6-viewsets-and-routers.md
[tut-7]: tutorial/7-schemas-and-client-libraries.md
[request]: api-guide/requests.md
[response]: api-guide/responses.md
......@@ -339,6 +344,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
[versioning]: api-guide/versioning.md
[contentnegotiation]: api-guide/content-negotiation.md
[metadata]: api-guide/metadata.md
[schemas]: 'api-guide/schemas.md'
[formatsuffixes]: api-guide/format-suffixes.md
[reverse]: api-guide/reverse.md
[exceptions]: api-guide/exceptions.md
......@@ -347,6 +353,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
[settings]: api-guide/settings.md
[documenting-your-api]: topics/documenting-your-api.md
[api-clients]: topics/api-clients.md
[internationalization]: topics/internationalization.md
[ajax-csrf-cors]: topics/ajax-csrf-cors.md
[html-and-forms]: topics/html-and-forms.md
......
......@@ -130,27 +130,7 @@ Using viewsets can be a really useful abstraction. It helps ensure that URL con
That doesn't mean it's always the right approach to take. There's a similar set of trade-offs to consider as when using class-based views instead of function based views. Using viewsets is less explicit than building your views individually.
## Reviewing our work
In [part 7][tut-7] of the tutorial we'll look at how we can add an API schema,
and interact with our API using a client library or command line tool.
With an incredibly small amount of code, we've now got a complete pastebin Web API, which is fully web browsable, and comes complete with authentication, per-object permissions, and multiple renderer formats.
We've walked through each step of the design process, and seen how if we need to customize anything we can gradually work our way down to simply using regular Django views.
You can review the final [tutorial code][repo] on GitHub, or try out a live example in [the sandbox][sandbox].
## Onwards and upwards
We've reached the end of our tutorial. If you want to get more involved in the REST framework project, here are a few places you can start:
* Contribute on [GitHub][github] by reviewing and submitting issues, and making pull requests.
* Join the [REST framework discussion group][group], and help build the community.
* Follow [the author][twitter] on Twitter and say hi.
**Now go build awesome things.**
[repo]: https://github.com/tomchristie/rest-framework-tutorial
[sandbox]: http://restframework.herokuapp.com/
[github]: https://github.com/tomchristie/django-rest-framework
[group]: https://groups.google.com/forum/?fromgroups#!forum/django-rest-framework
[twitter]: https://twitter.com/_tomchristie
[tut-7]: 7-schemas-and-client-libraries.md
# Tutorial 7: Schemas & client libraries
A schema is a machine-readable document that describes the available API
endpoints, their URLS, and what operations they support.
Schemas can be a useful tool for auto-generated documentation, and can also
be used to drive dynamic client libraries that can interact with the API.
## Core API
In order to provide schema support REST framework uses [Core API][coreapi].
Core API is a document specification for describing APIs. It is used to provide
an internal representation format of the available endpoints and possible
interactions that an API exposes. It can either be used server-side, or
client-side.
When used server-side, Core API allows an API to support rendering to a wide
range of schema or hypermedia formats.
When used client-side, Core API allows for dynamically driven client libraries
that can interact with any API that exposes a supported schema or hypermedia
format.
## Adding a schema
REST framework supports either explicitly defined schema views, or
automatically generated schemas. Since we're using viewsets and routers,
we can simply use the automatic schema generation.
You'll need to install the `coreapi` python package in order to include an
API schema.
$ pip install coreapi
We can now include a schema for our API, by adding a `schema_title` argument to
the router instantiation.
router = DefaultRouter(schema_title='Pastebin API')
If you visit the API root endpoint in a browser you should now see `corejson`
representation become available as an option.
![Schema format](../img/corejson-format.png)
We can also request the schema from the command line, by specifying the desired
content type in the `Accept` header.
$ http http://127.0.0.1:8000/ Accept:application/vnd.coreapi+json
HTTP/1.0 200 OK
Allow: GET, HEAD, OPTIONS
Content-Type: application/vnd.coreapi+json
{
"_meta": {
"title": "Pastebin API"
},
"_type": "document",
...
The default output style is to use the [Core JSON][corejson] encoding.
Other schema formats, such as [Open API][openapi] (formerly Swagger) are
also supported.
## Using a command line client
Now that our API is exposing a schema endpoint, we can use a dynamic client
library to interact with the API. To demonstrate this, let's use the
Core API command line client. We've already installed the `coreapi` package
using `pip`, so the client tool should already be installed. Check that it
is available on the command line...
$ coreapi
Usage: coreapi [OPTIONS] COMMAND [ARGS]...
Command line client for interacting with CoreAPI services.
Visit http://www.coreapi.org for more information.
Options:
--version Display the package version number.
--help Show this message and exit.
Commands:
...
First we'll load the API schema using the command line client.
$ coreapi get http://127.0.0.1:8000/
<Pastebin API "http://127.0.0.1:8000/">
snippets: {
highlight(pk)
list()
retrieve(pk)
}
users: {
list()
retrieve(pk)
}
We haven't authenticated yet, so right now we're only able to see the read only
endpoints, in line with how we've set up the permissions on the API.
Let's try listing the existing snippets, using the command line client:
$ coreapi action snippets list
[
{
"url": "http://127.0.0.1:8000/snippets/1/",
"highlight": "http://127.0.0.1:8000/snippets/1/highlight/",
"owner": "lucy",
"title": "Example",
"code": "print('hello, world!')",
"linenos": true,
"language": "python",
"style": "friendly"
},
...
Some of the API endpoints require named parameters. For example, to get back
the highlight HTML for a particular snippet we need to provide an id.
$ coreapi action snippets highlight --param pk 1
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<title>Example</title>
...
## Authenticating our client
If we want to be able to create, edit and delete snippets, we'll need to
authenticate as a valid user. In this case we'll just use basic auth.
Make sure to replace the `<username>` and `<password>` below with your
actual username and password.
$ coreapi credentials add 127.0.0.1 <username>:<password> --auth basic
Added credentials
127.0.0.1 "Basic <...>"
Now if we fetch the schema again, we should be able to see the full
set of available interactions.
$ coreapi reload
Pastebin API "http://127.0.0.1:8000/">
snippets: {
create(code, [title], [linenos], [language], [style])
destroy(pk)
highlight(pk)
list()
partial_update(pk, [title], [code], [linenos], [language], [style])
retrieve(pk)
update(pk, code, [title], [linenos], [language], [style])
}
users: {
list()
retrieve(pk)
}
We're now able to interact with these endpoints. For example, to create a new
snippet:
$ coreapi action snippets create --param title "Example" --param code "print('hello, world')"
{
"url": "http://127.0.0.1:8000/snippets/7/",
"id": 7,
"highlight": "http://127.0.0.1:8000/snippets/7/highlight/",
"owner": "lucy",
"title": "Example",
"code": "print('hello, world')",
"linenos": false,
"language": "python",
"style": "friendly"
}
And to delete a snippet:
$ coreapi action snippets destroy --param pk 7
As well as the command line client, developers can also interact with your
API using client libraries. The Python client library is the first of these
to be available, and a Javascript client library is planned to be released
soon.
For more details on customizing schema generation and using Core API
client libraries you'll need to refer to the full documentation.
## Reviewing our work
With an incredibly small amount of code, we've now got a complete pastebin Web API, which is fully web browsable, includes a schema-driven client library, and comes complete with authentication, per-object permissions, and multiple renderer formats.
We've walked through each step of the design process, and seen how if we need to customize anything we can gradually work our way down to simply using regular Django views.
You can review the final [tutorial code][repo] on GitHub, or try out a live example in [the sandbox][sandbox].
## Onwards and upwards
We've reached the end of our tutorial. If you want to get more involved in the REST framework project, here are a few places you can start:
* Contribute on [GitHub][github] by reviewing and submitting issues, and making pull requests.
* Join the [REST framework discussion group][group], and help build the community.
* Follow [the author][twitter] on Twitter and say hi.
**Now go build awesome things.**
[coreapi]: http://www.coreapi.org
[corejson]: http://www.coreapi.org/specification/encoding/#core-json-encoding
[openapi]: https://openapis.org/
[repo]: https://github.com/tomchristie/rest-framework-tutorial
[sandbox]: http://restframework.herokuapp.com/
[github]: https://github.com/tomchristie/django-rest-framework
[group]: https://groups.google.com/forum/?fromgroups#!forum/django-rest-framework
[twitter]: https://twitter.com/_tomchristie
......@@ -20,6 +20,7 @@ pages:
- '4 - Authentication and permissions': 'tutorial/4-authentication-and-permissions.md'
- '5 - Relationships and hyperlinked APIs': 'tutorial/5-relationships-and-hyperlinked-apis.md'
- '6 - Viewsets and routers': 'tutorial/6-viewsets-and-routers.md'
- '7 - Schemas and client libraries': 'tutorial/7-schemas-and-client-libraries.md'
- API Guide:
- 'Requests': 'api-guide/requests.md'
- 'Responses': 'api-guide/responses.md'
......@@ -41,6 +42,7 @@ pages:
- 'Versioning': 'api-guide/versioning.md'
- 'Content negotiation': 'api-guide/content-negotiation.md'
- 'Metadata': 'api-guide/metadata.md'
- 'Schemas': 'api-guide/schemas.md'
- 'Format suffixes': 'api-guide/format-suffixes.md'
- 'Returning URLs': 'api-guide/reverse.md'
- 'Exceptions': 'api-guide/exceptions.md'
......@@ -49,6 +51,7 @@ pages:
- 'Settings': 'api-guide/settings.md'
- Topics:
- 'Documenting your API': 'topics/documenting-your-api.md'
- 'API Clients': 'topics/api-clients.md'
- 'Internationalization': 'topics/internationalization.md'
- 'AJAX, CSRF & CORS': 'topics/ajax-csrf-cors.md'
- 'HTML & Forms': 'topics/html-and-forms.md'
......
......@@ -2,3 +2,4 @@
markdown==2.6.4
django-guardian==1.4.3
django-filter==0.13.0
coreapi==1.21.1
......@@ -156,6 +156,16 @@ except ImportError:
crispy_forms = None
# coreapi is optional (Note that uritemplate is a dependancy of coreapi)
try:
import coreapi
import uritemplate
except (ImportError, SyntaxError):
# SyntaxError is possible under python 3.2
coreapi = None
uritemplate = None
# Django-guardian is optional. Import only if guardian is in INSTALLED_APPS
# Fixes (#1712). We keep the try/except for the test suite.
guardian = None
......
......@@ -72,6 +72,9 @@ class BaseFilterBackend(object):
"""
raise NotImplementedError(".filter_queryset() must be overridden.")
def get_fields(self, view):
return []
class DjangoFilterBackend(BaseFilterBackend):
"""
......@@ -128,6 +131,17 @@ class DjangoFilterBackend(BaseFilterBackend):
template = loader.get_template(self.template)
return template_render(template, context)
def get_fields(self, view):
filter_class = getattr(view, 'filter_class', None)
if filter_class:
return list(filter_class().filters.keys())
filter_fields = getattr(view, 'filter_fields', None)
if filter_fields:
return filter_fields
return []
class SearchFilter(BaseFilterBackend):
# The URL query parameter used for the search.
......@@ -217,6 +231,9 @@ class SearchFilter(BaseFilterBackend):
template = loader.get_template(self.template)
return template_render(template, context)
def get_fields(self, view):
return [self.search_param]
class OrderingFilter(BaseFilterBackend):
# The URL query parameter used for the ordering.
......@@ -330,6 +347,9 @@ class OrderingFilter(BaseFilterBackend):
context = self.get_template_context(request, queryset, view)
return template_render(template, context)
def get_fields(self, view):
return [self.ordering_param]
class DjangoObjectPermissionsFilter(BaseFilterBackend):
"""
......
......@@ -157,6 +157,9 @@ class BasePagination(object):
def get_results(self, data):
return data['results']
def get_fields(self, view):
return []
class PageNumberPagination(BasePagination):
"""
......@@ -280,6 +283,11 @@ class PageNumberPagination(BasePagination):
context = self.get_html_context()
return template_render(template, context)
def get_fields(self, view):
if self.page_size_query_param is None:
return [self.page_query_param]
return [self.page_query_param, self.page_size_query_param]
class LimitOffsetPagination(BasePagination):
"""
......@@ -404,6 +412,9 @@ class LimitOffsetPagination(BasePagination):
context = self.get_html_context()
return template_render(template, context)
def get_fields(self, view):
return [self.limit_query_param, self.offset_query_param]
class CursorPagination(BasePagination):
"""
......@@ -706,3 +717,6 @@ class CursorPagination(BasePagination):
template = loader.get_template(self.template)
context = self.get_html_context()
return template_render(template, context)
def get_fields(self, view):
return [self.cursor_query_param]
......@@ -22,7 +22,8 @@ from django.utils import six
from rest_framework import VERSION, exceptions, serializers, status
from rest_framework.compat import (
INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, template_render
INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, coreapi,
template_render
)
from rest_framework.exceptions import ParseError
from rest_framework.request import is_form_media_type, override_method
......@@ -790,3 +791,17 @@ class MultiPartRenderer(BaseRenderer):
"test case." % key
)
return encode_multipart(self.BOUNDARY, data)
class CoreJSONRenderer(BaseRenderer):
media_type = 'application/vnd.coreapi+json'
charset = None
format = 'corejson'
def __init__(self):
assert coreapi, 'Using CoreJSONRenderer, but `coreapi` is not installed.'
def render(self, data, media_type=None, renderer_context=None):
indent = bool(renderer_context.get('indent', 0))
codec = coreapi.codecs.CoreJSONCodec()
return codec.dump(data, indent=indent)
......@@ -22,9 +22,11 @@ from django.conf.urls import url
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import NoReverseMatch
from rest_framework import views
from rest_framework import exceptions, renderers, views
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.schemas import SchemaGenerator
from rest_framework.settings import api_settings
from rest_framework.urlpatterns import format_suffix_patterns
Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs'])
......@@ -255,6 +257,7 @@ class SimpleRouter(BaseRouter):
lookup=lookup,
trailing_slash=self.trailing_slash
)
view = viewset.as_view(mapping, **route.initkwargs)
name = route.name.format(basename=basename)
ret.append(url(regex, view, name=name))
......@@ -270,8 +273,13 @@ class DefaultRouter(SimpleRouter):
include_root_view = True
include_format_suffixes = True
root_view_name = 'api-root'
schema_renderers = [renderers.CoreJSONRenderer]
def __init__(self, *args, **kwargs):
self.schema_title = kwargs.pop('schema_title', None)
super(DefaultRouter, self).__init__(*args, **kwargs)
def get_api_root_view(self):
def get_api_root_view(self, schema_urls=None):
"""
Return a view to use as the API root.
"""
......@@ -280,10 +288,33 @@ class DefaultRouter(SimpleRouter):
for prefix, viewset, basename in self.registry:
api_root_dict[prefix] = list_name.format(basename=basename)
view_renderers = list(api_settings.DEFAULT_RENDERER_CLASSES)
schema_media_types = []
if schema_urls and self.schema_title:
view_renderers += list(self.schema_renderers)
schema_generator = SchemaGenerator(
title=self.schema_title,
patterns=schema_urls
)
schema_media_types = [
renderer.media_type
for renderer in self.schema_renderers
]
class APIRoot(views.APIView):
_ignore_model_permissions = True
renderer_classes = view_renderers
def get(self, request, *args, **kwargs):
if request.accepted_renderer.media_type in schema_media_types:
# Return a schema response.
schema = schema_generator.get_schema(request)
if schema is None:
raise exceptions.PermissionDenied()
return Response(schema)
# Return a plain {"name": "hyperlink"} response.
ret = OrderedDict()
namespace = request.resolver_match.namespace
for key, url_name in api_root_dict.items():
......@@ -310,15 +341,13 @@ class DefaultRouter(SimpleRouter):
Generate the list of URL patterns, including a default root view
for the API, and appending `.json` style format suffixes.
"""
urls = []
urls = super(DefaultRouter, self).get_urls()
if self.include_root_view:
root_url = url(r'^$', self.get_api_root_view(), name=self.root_view_name)
view = self.get_api_root_view(schema_urls=urls)
root_url = url(r'^$', view, name=self.root_view_name)
urls.append(root_url)
default_urls = super(DefaultRouter, self).get_urls()
urls.extend(default_urls)
if self.include_format_suffixes:
urls = format_suffix_patterns(urls)
......
......@@ -13,7 +13,7 @@ from django.utils import six, timezone
from django.utils.encoding import force_text
from django.utils.functional import Promise
from rest_framework.compat import total_seconds
from rest_framework.compat import coreapi, total_seconds
class JSONEncoder(json.JSONEncoder):
......@@ -64,4 +64,9 @@ class JSONEncoder(json.JSONEncoder):
pass
elif hasattr(obj, '__iter__'):
return tuple(item for item in obj)
elif (coreapi is not None) and isinstance(obj, (coreapi.Document, coreapi.Error)):
raise RuntimeError(
'Cannot return a coreapi object from a JSON view. '
'You should be using a schema renderer instead for this view.'
)
return super(JSONEncoder, self).default(obj)
......@@ -98,6 +98,7 @@ class ViewSetMixin(object):
# resolved URL.
view.cls = cls
view.suffix = initkwargs.get('suffix', None)
view.actions = actions
return csrf_exempt(view)
def initialize_request(self, request, *args, **kwargs):
......
......@@ -14,7 +14,7 @@ PYTEST_ARGS = {
FLAKE8_ARGS = ['rest_framework', 'tests', '--ignore=E501']
ISORT_ARGS = ['--recursive', '--check-only', '-p', 'tests', 'rest_framework', 'tests']
ISORT_ARGS = ['--recursive', '--check-only', '-o' 'uritemplate', '-p', 'tests', 'rest_framework', 'tests']
sys.path.append(os.path.dirname(__file__))
......
......@@ -257,7 +257,7 @@ class TestNameableRoot(TestCase):
def test_router_has_custom_name(self):
expected = 'nameable-root'
self.assertEqual(expected, self.urls[0].name)
self.assertEqual(expected, self.urls[-1].name)
class TestActionKeywordArgs(TestCase):
......
import unittest
from django.conf.urls import include, url
from django.test import TestCase, override_settings
from rest_framework import filters, pagination, permissions, serializers
from rest_framework.compat import coreapi
from rest_framework.routers import DefaultRouter
from rest_framework.test import APIClient
from rest_framework.viewsets import ModelViewSet
class MockUser(object):
def is_authenticated(self):
return True
class ExamplePagination(pagination.PageNumberPagination):
page_size = 100
class ExampleSerializer(serializers.Serializer):
a = serializers.CharField(required=True)
b = serializers.CharField(required=False)
class ExampleViewSet(ModelViewSet):
pagination_class = ExamplePagination
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filter_backends = [filters.OrderingFilter]
serializer_class = ExampleSerializer
router = DefaultRouter(schema_title='Example API' if coreapi else None)
router.register('example', ExampleViewSet, base_name='example')
urlpatterns = [
url(r'^', include(router.urls))
]
@unittest.skipUnless(coreapi, 'coreapi is not installed')
@override_settings(ROOT_URLCONF='tests.test_schemas')
class TestRouterGeneratedSchema(TestCase):
def test_anonymous_request(self):
client = APIClient()
response = client.get('/', HTTP_ACCEPT='application/vnd.coreapi+json')
self.assertEqual(response.status_code, 200)
expected = coreapi.Document(
url='',
title='Example API',
content={
'example': {
'list': coreapi.Link(
url='/example/',
action='get',
fields=[
coreapi.Field('page', required=False, location='query'),
coreapi.Field('ordering', required=False, location='query')
]
),
'retrieve': coreapi.Link(
url='/example/{pk}/',
action='get',
fields=[
coreapi.Field('pk', required=True, location='path')
]
)
}
}
)
self.assertEqual(response.data, expected)
def test_authenticated_request(self):
client = APIClient()
client.force_authenticate(MockUser())
response = client.get('/', HTTP_ACCEPT='application/vnd.coreapi+json')
self.assertEqual(response.status_code, 200)
expected = coreapi.Document(
url='',
title='Example API',
content={
'example': {
'list': coreapi.Link(
url='/example/',
action='get',
fields=[
coreapi.Field('page', required=False, location='query'),
coreapi.Field('ordering', required=False, location='query')
]
),
'create': coreapi.Link(
url='/example/',
action='post',
encoding='application/json',
fields=[
coreapi.Field('a', required=True, location='form'),
coreapi.Field('b', required=False, location='form')
]
),
'retrieve': coreapi.Link(
url='/example/{pk}/',
action='get',
fields=[
coreapi.Field('pk', required=True, location='path')
]
),
'update': coreapi.Link(
url='/example/{pk}/',
action='put',
encoding='application/json',
fields=[
coreapi.Field('pk', required=True, location='path'),
coreapi.Field('a', required=True, location='form'),
coreapi.Field('b', required=False, location='form')
]
),
'partial_update': coreapi.Link(
url='/example/{pk}/',
action='patch',
encoding='application/json',
fields=[
coreapi.Field('pk', required=True, location='path'),
coreapi.Field('a', required=False, location='form'),
coreapi.Field('b', required=False, location='form')
]
),
'destroy': coreapi.Link(
url='/example/{pk}/',
action='delete',
fields=[
coreapi.Field('pk', required=True, location='path')
]
)
}
}
)
self.assertEqual(response.data, expected)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment