From bc836aac703bc10116c736b6bdde327646edc28c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 8 Jun 2016 14:24:02 +0100 Subject: [PATCH] Initial pass at schema support --- .../7-schemas-and-client-libraries.md | 64 +++++++++++++++++ rest_framework/compat.py | 9 +++ rest_framework/renderers.py | 14 +++- rest_framework/routers.py | 70 ++++++++++++++++++- 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 docs/tutorial/7-schemas-and-client-libraries.md diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md new file mode 100644 index 000000000..9795bed95 --- /dev/null +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -0,0 +1,64 @@ +# Tutorial 7: Schemas & Client Libraries + +An API schema is a document that describes the available endpoints that +a service provides. Schemas are a useful tool for documentation, and can also +be used to provide information to client libraries, allowing for simpler and +more robust interaction with an API. + +## 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. + +To include a schema for our API, we add a `schema_title` argument to the +router instantiation. + + router = DefaultRouter(schema_title='Pastebin API') + +If you visit the root of the API in a browser you should now see ... TODO + +## 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 install the +Core API command line client. + + $ pip install coreapi-cli + +The first + + $ coreapi get http://127.0.0.1:8000/ + + snippets: { + create(code, [title], [linenos], [language], [style]) + destroy(id) + highlight(id) + list() + partial_update(id, [title], [code], [linenos], [language], [style]) + retrieve(id) + update(id, code, [title], [linenos], [language], [style]) + } + users: { + list() + retrieve(id) + } + + +We can now interact with the API using the command line client: + + $ coreapi action list_snippets + +TODO - authentication + + $ coreapi action snippets create --param code "print('hello, world')" + + $ coreapi credentials add 127.0.0.1 : --auth basic + +## Using a client library + +TODO + +## Customizing schema generation + +TODO diff --git a/rest_framework/compat.py b/rest_framework/compat.py index dd30636f4..aace54e6e 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -156,6 +156,15 @@ except ImportError: crispy_forms = None +# coreapi is optional (Note that uritemplate is a dependancy of coreapi) +try: + import coreapi + import uritemplate +except ImportError: + 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 diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 264f7ac3b..1ec33deb7 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -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 + coreapi, INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, + template_render ) from rest_framework.exceptions import ParseError from rest_framework.request import is_form_media_type, override_method @@ -784,3 +785,14 @@ 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 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=True) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 027a78cc1..17ea5bacc 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -18,13 +18,16 @@ from __future__ import unicode_literals import itertools from collections import OrderedDict, namedtuple +import coreapi +import uritemplate 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 renderers, views from rest_framework.response import Response from rest_framework.reverse import reverse +from rest_framework.settings import api_settings from rest_framework.urlpatterns import format_suffix_patterns Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) @@ -233,6 +236,7 @@ class SimpleRouter(BaseRouter): """ Use the registered viewsets to generate a list of URL patterns. """ + self.get_links() ret = [] for prefix, viewset, basename in self.registry: @@ -252,12 +256,61 @@ 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)) return ret + def get_links(self): + ret = [] + content = {} + + for prefix, viewset, basename in self.registry: + lookup_field = getattr(viewset, 'lookup_field', 'pk') + lookup_url_kwarg = getattr(viewset, 'lookup_url_kwarg', None) or lookup_field + lookup_placeholder = '{' + lookup_url_kwarg + '}' + + routes = self.get_routes(viewset) + + for route in routes: + url = '/' + route.url.format( + prefix=prefix, + lookup=lookup_placeholder, + trailing_slash=self.trailing_slash + ).lstrip('^').rstrip('$') + + mapping = self.get_method_map(viewset, route.mapping) + if not mapping: + continue + + for method, action in mapping.items(): + if prefix not in content: + content[prefix] = {} + link = self.get_link(viewset, url, method) + content[prefix][action] = link + return content + + def get_link(self, viewset, url, method): + fields = [] + + for variable in uritemplate.variables(url): + field = coreapi.Field(name=variable, location='path', required=True) + fields.append(field) + + if method in ('put', 'patch', 'post'): + cls = viewset().get_serializer_class() + serializer = cls() + for field in serializer.fields.values(): + if field.read_only: + continue + required = field.required and method != 'patch' + field = coreapi.Field(name=field.source, location='form', required=required) + fields.append(field) + + return coreapi.Link(url=url, action=method, fields=fields) + class DefaultRouter(SimpleRouter): """ @@ -268,6 +321,10 @@ class DefaultRouter(SimpleRouter): include_format_suffixes = True root_view_name = 'api-root' + 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): """ Return a view to use as the API root. @@ -277,10 +334,21 @@ class DefaultRouter(SimpleRouter): for prefix, viewset, basename in self.registry: api_root_dict[prefix] = list_name.format(basename=basename) + view_renderers = api_settings.DEFAULT_RENDERER_CLASSES + + if self.schema_title: + content = self.get_links() + schema = coreapi.Document(title=self.schema_title, content=content) + view_renderers += [renderers.CoreJSONRenderer] + class APIRoot(views.APIView): _ignore_model_permissions = True + renderer_classes = view_renderers def get(self, request, *args, **kwargs): + if request.accepted_renderer.format == 'corejson': + return Response(schema) + ret = OrderedDict() namespace = request.resolver_match.namespace for key, url_name in api_root_dict.items():