From bc836aac703bc10116c736b6bdde327646edc28c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 8 Jun 2016 14:24:02 +0100 Subject: [PATCH 01/31] 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 0000000000..9795bed956 --- /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 dd30636f45..aace54e6ea 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -156,6 +156,15 @@ def value_from_object(field, obj): 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 264f7ac3b6..1ec33deb78 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -22,7 +22,8 @@ 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 @@ def render(self, data, accepted_media_type=None, renderer_context=None): "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 027a78cc13..17ea5baccf 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -18,13 +18,16 @@ 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 @@ def get_urls(self): """ 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 @@ def get_urls(self): 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 @@ def get_api_root_view(self): 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(): From b64340ba636d012d092531d749bfad4d4c8696f3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 11:22:45 +0100 Subject: [PATCH 02/31] Add coreapi to optional requirements. --- requirements/requirements-optionals.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 241e1951de..87ae271997 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -2,3 +2,4 @@ markdown==2.6.4 django-guardian==1.4.3 django-filter==0.13.0 +coreapi==1.21.0 From c890ad4d67e925ce4794daccad730b0f9e869547 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 12:20:35 +0100 Subject: [PATCH 03/31] Clean up test failures --- rest_framework/renderers.py | 4 ++-- rest_framework/routers.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 1ec33deb78..511f5d4744 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -22,7 +22,7 @@ from rest_framework import VERSION, exceptions, serializers, status from rest_framework.compat import ( - coreapi, INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, + INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, coreapi, template_render ) from rest_framework.exceptions import ParseError @@ -795,4 +795,4 @@ class CoreJSONRenderer(BaseRenderer): 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) + return codec.dump(data, indent=indent) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 17ea5baccf..789f0217f6 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -236,7 +236,6 @@ def get_urls(self): """ Use the registered viewsets to generate a list of URL patterns. """ - self.get_links() ret = [] for prefix, viewset, basename in self.registry: @@ -264,7 +263,6 @@ def get_urls(self): return ret def get_links(self): - ret = [] content = {} for prefix, viewset, basename in self.registry: From 2d28390386b792cdf40bcd8bc178a4f69f6db13e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 12:28:58 +0100 Subject: [PATCH 04/31] Add missing newline --- rest_framework/routers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 789f0217f6..0dfb2bb480 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -30,6 +30,7 @@ from rest_framework.settings import api_settings from rest_framework.urlpatterns import format_suffix_patterns + Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) DynamicDetailRoute = namedtuple('DynamicDetailRoute', ['url', 'name', 'initkwargs']) DynamicListRoute = namedtuple('DynamicListRoute', ['url', 'name', 'initkwargs']) From 744dba44fada535b0ec8733607497a7e8fecae2b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 12:29:11 +0100 Subject: [PATCH 05/31] Minor docs update --- docs/tutorial/7-schemas-and-client-libraries.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md index 9795bed956..62191ba631 100644 --- a/docs/tutorial/7-schemas-and-client-libraries.md +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -26,7 +26,7 @@ Core API command line client. $ pip install coreapi-cli -The first +First we'll load the API schema using the command line client. $ coreapi get http://127.0.0.1:8000/ @@ -44,6 +44,7 @@ The first retrieve(id) } +At this point we're able to see all the available API endpoints. We can now interact with the API using the command line client: From 56ece731e25c3451e1606c03aaa877ac4fd0ce65 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 13:32:21 +0100 Subject: [PATCH 06/31] Version bump for coreapi in requirements --- requirements/requirements-optionals.txt | 2 +- rest_framework/routers.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 87ae271997..54c0804919 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -2,4 +2,4 @@ markdown==2.6.4 django-guardian==1.4.3 django-filter==0.13.0 -coreapi==1.21.0 +coreapi==1.21.1 diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 0dfb2bb480..789f0217f6 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -30,7 +30,6 @@ from rest_framework.settings import api_settings from rest_framework.urlpatterns import format_suffix_patterns - Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) DynamicDetailRoute = namedtuple('DynamicDetailRoute', ['url', 'name', 'initkwargs']) DynamicListRoute = namedtuple('DynamicListRoute', ['url', 'name', 'initkwargs']) From 99adbf19d37130372b5f67f06109cd32d97dbbf9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 13:53:55 +0100 Subject: [PATCH 07/31] Catch SyntaxError when importing coreapi with python 3.2 --- rest_framework/compat.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index aace54e6ea..9c69eaa032 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -160,7 +160,8 @@ def value_from_object(field, obj): try: import coreapi import uritemplate -except ImportError: +except (ImportError, SyntaxError): + # SyntaxError is possible under python 3.2 coreapi = None uritemplate = None From 80c595edbaf4812bead5d091f4f2412f87e54bd7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 14:01:55 +0100 Subject: [PATCH 08/31] Add --diff to isort --- runtests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtests.py b/runtests.py index 1627e33b2c..31593c3b44 100755 --- a/runtests.py +++ b/runtests.py @@ -14,7 +14,7 @@ FLAKE8_ARGS = ['rest_framework', 'tests', '--ignore=E501'] -ISORT_ARGS = ['--recursive', '--check-only', '-p', 'tests', 'rest_framework', 'tests'] +ISORT_ARGS = ['--recursive', '--diff', '-p', 'tests', 'rest_framework', 'tests'] sys.path.append(os.path.dirname(__file__)) From 47c776596c67bc4eb3afa14ec317f2262ac556fe Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 14:04:58 +0100 Subject: [PATCH 09/31] Import coreapi from compat --- rest_framework/routers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 789f0217f6..7ef047bed2 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -18,13 +18,13 @@ 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 renderers, views +from rest_framework.compat import coreapi from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.settings import api_settings @@ -335,6 +335,7 @@ def get_api_root_view(self): view_renderers = api_settings.DEFAULT_RENDERER_CLASSES if self.schema_title: + assert coreapi is not None, '`coreapi` must be installed for schema support.' content = self.get_links() schema = coreapi.Document(title=self.schema_title, content=content) view_renderers += [renderers.CoreJSONRenderer] From 29e228d2ddc3f42d63c3a97d1205271605c5ca7f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 14:07:17 +0100 Subject: [PATCH 10/31] Fail gracefully if attempting to use schemas without coreapi being installed. --- rest_framework/renderers.py | 3 +++ rest_framework/routers.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 511f5d4744..c4f4725523 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -792,6 +792,9 @@ class CoreJSONRenderer(BaseRenderer): 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() diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 7ef047bed2..f3e4bc4eaf 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -335,7 +335,7 @@ def get_api_root_view(self): view_renderers = api_settings.DEFAULT_RENDERER_CLASSES if self.schema_title: - assert coreapi is not None, '`coreapi` must be installed for schema support.' + assert coreapi, '`coreapi` must be installed for schema support.' content = self.get_links() schema = coreapi.Document(title=self.schema_title, content=content) view_renderers += [renderers.CoreJSONRenderer] From eeffca40eb9c40cc3b7ac61d7eaa7426d14b0bb3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 20:59:57 +0100 Subject: [PATCH 11/31] Tutorial updates --- docs/img/corejson-format.png | Bin 0 -> 20499 bytes docs/index.md | 1 + .../7-schemas-and-client-libraries.md | 88 +++++++++++++++--- mkdocs.yml | 1 + rest_framework/routers.py | 2 +- runtests.py | 2 +- 6 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 docs/img/corejson-format.png diff --git a/docs/img/corejson-format.png b/docs/img/corejson-format.png new file mode 100644 index 0000000000000000000000000000000000000000..36c197a0d02a78fa165f3cd123343ca87a27f102 GIT binary patch literal 20499 zcmZU(V{~P~vpyV7Y}>Xbwrx8nwv&l%+nHoyV`5KiYhs%x=9{^{`@i?D^_~xB?cH6~ zwX3>&Rn=1!siYu@0E-I?0s?{{EhVM`0s_kR^?U&R?dxAq3V8(t1e?rCR8&b?RFqiB z*}>e()(iwhD>U^7q?Y<*%g1?_qSNtZlEjI|iG7hUGUA5Qv4&U*GIB9S3NvzFQ4xx; zm^iu^3WeC9n2;LzCFR}g#??pur|%`-^n{;wqxmFnTVd2uzvm!d_)j;G|;DjrA{!mCf>T+r2uPDg|VYHs=Ba^1}Qb%Plf$7KsR$4EaUTeEw`47kI>xJ2k-2hL|Z>zR0qBF;H4eNy2xTr?;)$u7_ z-;QeE=v^UrC<%l{C_qp%u4U>VH9+knX7ATK8=V4HX)s&w$K`HfF?tz`oykw z3ZaSnRT7FUra`0E(pXLTgW3ZgE(~1CXn!`<(qv(_iRS0rrz!SL`kJ8k^VRcrt0hZFFmnC2qaMRgLs^@jf@iyZ-(YY+UC z_>(;;^~9EaK*{O95O+mHa56sEI7jyhaG(1Qep}Kb+-l+aG!!_4h!{!o>h&4|v?}Cm zpvpH+=-6iz(6+)62l;;aSbYZQa|UjPX%W7Ykrg;qPr!pXJrij=a)7vvAK$w_5oON6 zfoS!BI_^|>LvTCGhOJuG1wdQQCifwF27n-oFO$Qe_YwXCbqz#ehFaEUJj#_A|-;dT3`>NxRXhyTzxV(LMs_j5S%ufuGGhws2Vk-ZUs zLR0(+x+X)7U@eTzAd8E@5|KbnbR{REk+e=UUszU5fZ0d8g$wRyHhE%3we-O?vSq%^ zP?961L{p7vizpvn@8j!}GdX0o(Xyt;a;I|VTtc>qzaNq?R;_U5IBnf{^6~mn8DKcXQSJ2Kls}RBAaoN5hUE8s zk}IKl!Zbke2a%CPB*=|WsiFOa77g(T*+O?k7eVi!nV{jLHlc2yp{2>9Ayl4FdZU~} z6OX76vneFHL9S2Gl07HsCTCPqQMvr7Ro$RjOc*;fQj}StrM*sEmZ|{ z1$_ma8iCq$Ic8aTd1yID89{k}d1D!qvWIF<;fHj;6qz(YYEL*>OjSfxkxdL3niixH zp^^LmVn=T$aDnhw@^}6*^>N6_?-ME4NLLD1dDrRV?s>Mk@tN#-s5y%{&H1fg{?h># z$mTO<*G5vtWk$#58RlZxkl0N)8#qX88mte@Q_MRVVj1w6ZyBr^HO%~`SpajRG>c}l zICDm0d$T+vVXY8tcx^DP2JM>0m^%7K=SJ%WuZEKPr3S?M52HQPc_W~ytVJaN&s^91 z2(WDSXd&3Q*()#=xPyGOGK?~)JT}{Z6PX<8f>g?D!E@v`g{8-w&XrD~iLVK{46^KD zPiXIZiF`?VX@9B6N6&}L=f_v-`Pb9@2hNX0Pv0MkH_br5ZNTCDKHN6len&s&@bsWV zFHxU=+$YKB6#*%)tvmTu*%}L@5V{iPH7Xay3uqDrc=+b~c>9$ge^+F%;Ik3}O)BUz57(xqZE z2RjEkmpTVvg=dvu9c7(l1vcz9TsBZNC?1p@-0dgr;~sR6UZp&yFr^Zy8>um?b5+a~ z*Hx4iNoiE6=cuEqIji$j-^pc4uZnQ1wkbkZ78WNJ%@y9-wCrA9Z!CNGZ;;gw`zzk( zJ$j2jcwJVn^H#TurWScAxQWCnPO4v2{SD#i;gRmr^OX@5>KEPX9O9K{kr$lAon+FP z(=n~q)e-#dVMAr$&RP**=>&j_~;H#&`AP+jIHR`Oc;Qt5B{$Hbpu$JN1a@h!4#*b1HHs;@q|q z<<{gZc@b-;2~ewumqJSDqGw|v&sQt>E#rA_3P z?~||C{kMCtd+QDUZS2kODf`v%+7PJ@t_+SGqW#-qVu5ka?aq+(=oVsa7{;%QR$;asJ<^opw3Srygp?$97p2ebLD2!xk8nbAS*jySCcn5g&=;CDp)x1EEWB^1i?TQg+y@v?b+=k843X}VQUN-=?Yx0eUwN9h>IaHO!5u=22Y z(GbzjQ5sRcQQ@+`Wlj}baus;r_hN=K3shp~{<2;+#55cB z1eA}r!q!OWy*p0c{~dALJJaV(aah5;xU-tCaNm`(6j_t`%&oDkn5SnOBw z>GKkOoStpS(TzBSv+)v$5f zv87D+l=?@#e({iFuPOa3@a&9)oYe}b3L;E*egN6~1_@F|0AeHtBW=@7DdsI`K|#=6;Vpzs8WaaOublERZeCVWm$TBfP;!7nPH}-qP4WkH1Op9<{v%!JYC^#~=)oanEsZehIm$RvO6^RoURicdxMEYvQ*c>)qfDpdt-z^t zRUH_;g9h+T&NhEL)W!s+c~_%W>Dv1J9bJXuO5u&;Ldi)*@@(60Z%#7bz~M9Ho9ue) zLV87iF?g`KxkAE5@Ikv{@T5RjG>6}VJ&G}kBA2KU^NsMsoyXQi$HH;QBhI-76j%Sp$}%j6sP{8g)Nn*PHj{zHx-)sVBUVp=Xw&QZ35 z!B4djO)+{MdOv+u$M#n_my^_nhQ`bz((zncc3Ka$K6TY2UdEiR4A;?Le_tAmV}~#f zbTWDBE8mt^YE<v0At}gFsGPqnbLfZJ`Ea++Ss3e?U#Nsl^#58O>59C zFLS=v!i^JSn_|vKPWFfH>BBsrYlxA_kX86DT(W2W&UF>UDZ=s1pv~B-AFCT|@Nj8x zes*aAI{#u}X8cL#DXS$!uIsA3?tdf;gEmTO1(RvQjr&X<%1wcFu(_e)9raLC{-f;C z64sK!I{sAVr1&%*HwsT2Z#gR_TRDqPxAj7{v8u_-^nEeyuYGA(UT`H~;sd`_=I907 zLqANvRN8RE?+1ut4puQ|EK)90Fj2CgDu^ht9Wo0x;s?$xi)+j}o;yP)F)5?7e~Jvd z?48eq(fML)&eJvoP%M*tsHiv?5YCg#gg>PaDX^Pn?WzXfg-132cV5wAz>Ei9bJS$| zC6_;M-mj4FaYXu474gTnXIRL$(1AbCg|oY9Ne%O(7V9!i;}Fv((^GkPG*^|DHC2@r ze1<+d1B1WPcPoOKI+nEbl&di+0cE;vFAsnB&g_nT-7Yo!eBDJQ021>zhwE$N3>lkMkXUVNpTea?6mc6He&!SzD-cV+Qa7a!91wUZ<^|Aff zSl_BF?v{WRSX4u#27hDdI!*lU-|y!m@sitA3+6E$ip(u7x;uUEYA$`3Y`39)#s48% z7T|x@dk%e5cSB@beq^xQGy-jz=|1k{dgQ--{X17q)F3$IpY}n@#QWB)d!s$6XS4Ot z1LBI|E_9rDmhrAd+6*H(M2E~AA`pdN2vtm&3f}~RfW-h#fWVIj&h!gEnT9R1IK?f3 zU{~qjZg=e9a{qZ^xUaUU-;h|dPWiYA8uF;}x$~?}EN@@DobtpklVx?tZtoqu}}#FLbPMq&x7b%w z^ZG4ury?g$PZP&+sr*(CQ5~{4uqMp{rH;r*WG$`0SCtu5F8BV1scsQ0uHuW}w^taVqgR zS)nU=8jM$n*O*0{t!po6_dF+IiFK@W=6W7=D)8gTdb)Qb-uL`5<|TQCbLCC6O>#DP zLv%tBEPoRZ#XpUs{qB#;`Ml?$O>rcXDuM&-R-TtXu>bUEfx9vEXYt_HIo`+Ci{QFM zU%{%CJtxR_Sr9D(FycK%5K=nJJZXsDAY2d|3=qXr@Fz9;fT@#DUwgYXM)k3E@l-r|s&9WZK!VN`9wW}Iyvf3&@QybHg( zdw_d5bx?nBaHPMDw_UrLNP$dpOj|@bK?W8%5%CbMkHNH(**cEa1$j{1T2wzL+aMGt zlP4-H=MFfH8O$5udo>)8qz zXrzwEu36c6Iv4vDJW{aKpD0PLx<$yFY@aRd-6{Bb9{H49)aeU(C|*O6X%RMEk@l!5 zZctPkSN%iR!uR5Gvg=-6-rM1^ncLmx%HT5f5(_88+tq2rV#SgDviO~RSnk|*?Wn>5 z9!@Y_#IJ<@?9;DPriZeAs^N#f(DCv&LM2d%UdWI@cqPc8vd4EJ{1FHXA%bHtvTZU5 zVcW8+dlF4*%(37a5%3xMnD9^|{RJS6BZd!_U%dH)N(2XLbdI>oQ!V!Q7)(lJ)rgb6 z3BYsR;R^Y=`Z;=5G~OQK4z8n%Crc-9H~eQ(uk?x{m#C*aiPVm)Q|d03a(0Gz&QGI?E*41K09T3_&B{C+nH?xAm#>Lmu|bca@H&>EyR z+}X5ia1Ypsj0;;IdbWCxt;wEho_;sYH^n!+mkF3ZQJ?6!s9i-{rO36}l_r$e9ZSa; zonQ#1ztIG-Cm5s_r|c!AtIcS*s=;bbX_T+}`cG4-^Kgi}QN>zLl2(7e(AIb7cX@{o z1|{21ZxGX(XcNK|N0eaACUCslsYO;!SJOtuZ4bV3u1-9PS z-@?HbgDXU_MF1nU{cdCxzp#JVnB4D{prR(NAR5Ro@$z{M@Vb3y>G9+^-Eo;Mwc$6S zol!X)LZU}t_)*vJ5~x;qq+P5ZMRllSV6ifq8BX=67|`lNvv_uT7e2*~c|XS^byxYE>8R<;yk)lJw4t_^v|sqs z|Ade`UQLIv?!|8n#!f((3jTf#KOxGVz-0>ZLs)VK=m+Hm4lq_;fkq63+jd{ri;3Vt zjmVK8BtFCDjzK!4Kjcn>=|IX3_6-6Ia_`YP8D7&qslBNNA(Lb=3y}z+NV3SKP~?$* z6p||=DqR#*6k`?&NcJnbx)RKD9C93H8_m0 zE#)gTD$@GNURl`H;lpIWA!nxp=s8riW6SmS`ulPb))9*3__Vl!cqBbM<3K|F11rYkVI+SIwqBPQia=d*?zr3uBw#V@!(gwdzj4> zCo`wbsU5EH{J|PxHM(63Z%{`_ySLZ+Wz9Xt!}$7@@AmceyV28LDwVY2vczuwyC>n> z?uBgW&2X(7m37Is)i2Ifw%{x3t+Rc(md`zOH(Sp|3CJ*cu3Xn=21cQ-LS zhyYf=BHGzm&-n7PCf$tb9t8*isBX`NZMV%a2p4}(;LBh*(#I=j+9#MTOz$`|>R@8V=xOKpRZai_;q&DAdbBfhH7547v$c2O@#H7{FAAQo z=YPXYq{RP4;%dWBswJ;PEb8EFM$Ey;&d5wE0830v%;#)s&Z8nG@!#xUZ~UZ|uC9(e zOiUgg9*iDrj1JBgOf1~o+)T`@OsuR7Ula^3KkQwNJsIp>$o{j)|651Q%mv_V<>+eV zU{CySU1JjmH&=dA(tm>f=l7rQH1o9jUrF{Z|E<=Sf=vHrm{=H@nf_<(FIK*PqdZDh zo@TaMVpeu$_AXyC1lX84`TmRk|2Ol$68{gU_WyFSvU2}l&i^y>-<*6*{}lK?3jN1g z|26t$E&*6RrvEX$0PLJE2m%PmcQ9!&VKqUT}0DzqqjQAjrt#lY_<=Zbs!@i{mBC`WiXRXC-j zvJ#Floo;N!cXr*L6BZWs1;4PIoJsD1bPD5E?qO zSdw3w5fW6G7y?YhoCE~bL0AY`3JD@)V(wQSdHNR>G8iaHBQi*23o$Wt5!4saiLzpn z>=zYuU_gR4bijZvGBU&*_!rS#8P#a<7gdOm&!iNQR8qG;MB?EE5d^BG3^f0VcpP z(V1&q(eD<`cC`W0q3a%V)Bn>qyg0hmlZ@GN=4XJkBk<7Z1C-3Xe)#TWzTWjjMK1e0 zgEhLTmBhk~I8mb5!+x@&1e1^nB|)H(DTOJf@BR5oJ(;aYBa=d!z4=|MBmuBv>)n3A ziK37=VHDa2n7tL#?kQrfEQ;kjkpF&eF)Nvt5hV1D%<<`Zr%aa06qCahRr$-lITFNa zHvkg0QX8_+{r4q@cg(}d>~JU|ALfF#EOskY`=0lQ)}kMu?~j0i2o$G8g6J8~H10GX z7DkrWw_<0v~N3baBw$o!0El_HGAN}!=F&Nr$1QT0sP(%PwP3Y z#Px4qEKnU&K1P0TdqjIR9hZM<{oieT&H+R&q%aJ@W`8?8m?UCxLpM4+_HKrWJmlQD zg1CSa&L91PZ~Xz=3MrYUPpP#&ke_#opCQ3eNT*xlOg%B7a9D^^OnOiz5BZg&Cevi< zN8YVxL_Z*>zbfbcQQj9g^7l)mpqQNGj(j4Yh9s^f_yYHLIIyvg+0NKJwaF0wzf9naxt5X zS}a2!6!PCh@5hpsmX^H)9_xY6z@xLwyOu3Kyrb#-WmzeyAp!z|?&?@Z!|s@9ET&zo zo~O{MexmnTqMh^A#+5b~s#_q?X?GyfvJqq1vw@wg=K2N8elg=MJYuq`zFye_DDZxw zxVNZjxcKMehUgPTwM1?fA=3e1Z{{-2&~l7aSl*OK{PCRssofKs|Kkwn{reGWFgO&V z(Zl(w08akPLMgN92r@yw^T*}Y2jb_w;b-XQYtQF=)=DhrW<*{dVdvwf_lvOC%Sq`? zQ?0N3u>VddmTT7H;^NB=OYuIqo`FFP5#?dDbI;3SEw01cN&W{-8k1oS2lQd2j)jX0 zTld=mlT8r7Yr#B2(Y;)IiF3AFtaEiA{t{npd;7oz2n;xQ);M;Y77Q|)S5uwlL>sB}o8%)rTmxq>~ z*qgoKvun9@=e4!9CiB&$rLc~xZrwd#FF4XMv*F8J`W-IaFOozbEyOsYSR6KqQs0+5 ztLxB#^r0hxNAB2R1kQDk!+i374jm2Aw=x>EPY#=I#X)<230HL)h+jxjR-? zg3I7?BK3P7Hw?G1qg=nFxX5!_fRXi)A*+6Jfy994p!K*42l zY3E&@7NWOps4VnIESM>4sox9d%??{Nx#j!~Yf{zGI+sZlvcGv*%+hmL(BkdZnvFNw z-87VWs*TQ0Umz8BW92}|QQkZ$-UrYWjpj?_v)ET0k0vsI5zz}wu={K)=JJGARM5Ul zd@i+g7ZaIuk3JLM22zYBf=uV~mJ%J0jmf+RIEn1ke(7FsIZN94dfBlJJ~jzkF3zkE z{3&q1pz0M9Su`3RfrQtg@cH2Ixm3Hd;PNF97L~5qwz1n1xo+gS)5t#hDO&>|x?(^ea zXI`(~B$SZN#A_i&3-cw}Qp$5kNwy&^b1(7hS~M~u!Q3|HzgKlr(oAM?$l9cx|4o!b z$W+1v4%u@%nLd}1sLGQ4`1lgrJ;m9SfaL$w&%>XV`*+cRBW&b4W?0y-!}vowihTid zb#XtQ3D3kH9@zgqo$uP?C*lz}kLQ2Lpu`Y} z7)nU7TzNkL7pfk5(S{ifaJbtWj<=bNGA_TF%wQ3<5yqEm#!bRRXaW;3jpw_K@DnOq ztd}+AAcsDTEi#915&$COa-!l-9?$-2lJ`eVW|_dFB9y9`V3MA&jyVLhM-Nd-&ngiD zTWnWZ6DDHnFKKPY%-*V+ZvcPE;Qi~`ZI&v}VV)k=H;Gl#G`R0%kBZmO6knQrsGTJ;Xt_hQT`aJGBkX>x(C%$MQ zrfC1SDJM*K-WU?l86`TCJ@dy<2g|tl`q#d&DMq*^boU)gDbsUYl|5+Fvilrvmv1gd zQ;;3D8g{({JD%u7VhRd%-sAS!B;U}6?6ZBJs*`aRMx>;|Af&rB0JnB-jRspDTfXfb z(zlu1E|h*(kA8~dq=Y-TqWDYN>rQ(^C&!eAACxFnx9=#qi}_|$%1*X%8?s9%(z$Nt z9Os9PHRZzdb%f>W4RjMM(`w&+`a*yJoT?o$HrWH6SW*Xaa>i(CI=Xpsv5N?#moLX4 z&JgQhE}(V(lCZy$`xf2v1h=97{B;jKsuP`TCoCazMt8RbIH(bS0tEYiUam}7CR^GSbM$RJ#^YdMCV-7OnQ$bp;OcpKm1^T*k{$i1f z9~gYXkAAJ}d7+d4MveqnfjlAyry_pCaaWCm8irZrps-57UTxZcqHsbq<&u@^PnW6- z{Z(@Q<-e9T6y2vYzcp!B?hHVNLvXGBaZ+jqkNf3R4kbNRalj(qy*?ou>t_qMn;ep8c4K61nb*F@^>Frfw8#M0e$@{#WUobFYTtAi049h(l&_UU&a zz#=kb%N~JB+~fB7RmRl}Xab(XXJ69V%hkR(X{nE!EC>eUb+Hb5jD>EMe>F?_{k)u%1h zjSiNGV@O`9Q9ZYkxhifQF~F8GG-%HX6M2s!hiAH)NoC#fMx7m|@48gJ$jl!G@KyER ziw^@7SE-8nRY{guakv6n))zFX?dEVfGncQ=Mftw3W;?`^HK1uc zV7H#G!(mIne-ou6v8p_l#WyYWZ$?k(P#<)-sgPad^CK4Lt{h~9(8M1a-thr@f#s;g zb(IaV4tCKIdKivuYF3~odlC*-n=?p$WBLs{{`rv>x`h~b96BQRAe*4V7^1j&-&^j4 zB;{CC+C))yW@I&qRHhVlPKn@;Thj!kDNki^Q_WaeLDtBT9AU{KuGj!ea|JTouufBJ zb7ND*ppZZz=E>-d38N3=i0M<}S!?qz15{?mq8lKO;D5!>tfh>_6Hgmg|-N_XRh>mIFjm+35A(mxJ3hye)5r%f;CwSyQOXh?4 zx+sJXNzN(I*YgB^$W!tp&jhqspSNga1}-}Jyc^E<0tkZ1yLslWM@QT-!)^}iZwdLJ zPTu6W_DdsI%CYW)leBG+NP9OOv;V@jC8AZJENuIYq_$kP^K&_tpm!l&3qNYI=hx?= z#bY? zl}EY3#VPH8KLcZj>@-a{6b`61os*h)_*4m5cO@Hk+!qeBcP5LkS>@LUrdKn!g>%&90@BbTFYob*boAl zv>yitM;7|3*}+uXdTop@z=e+RY^niEzZGjQw6dMe1Dv06uW{35!F`+WvQ&xN>$D@%B&xIRmb}_&}hqy~t=J z$fe{E3x~U5TQREL<^1&CkyJ{UT6q(TT?rMX7X;}Jn9YiCg~;o7%y?#B*d%*%qZzQB z{uW&B_sNVjibsIZ(2Me_ZZM8h3YVy;JsmF(mM?nEd5a=BB+ z!qwfZY!+vc00=2T8@?e5X%)OZ(1E3X4Xz!b-95&3{9k*UPWf;9RQGb!dy$d&pmE}1 zfb)J;5pzE!I582wpl@!lF>AE&9L$f*PQ-R)b zEvtLX-K!CEwzyWNItR~df2masa7#KPn}1QT;jqcN%1e7Wi4xD)H|C7)$8QKRjvHyt zW234Q19n&6!Q0mQM}19DkXdEvHwkkl zI8Cv3K97bEHW!Gns7R$tdUM2r&r2hm@cD{*1G#DVwOWPzf?SAA`7RR|G?}H{bHv%; z!qT41@WBcqXjA-$K$C*_DW`w=~@;W-QnZ{Q@646ENCnVgqt# zJf|JF2aA}%8Z5v2o+&sJl7W#k!dQK+3ZCQak> z*nZKd1}0Y15Uee(I41u`3_T)Nz2(TiQQwPl)DRu-Bn^kJ3+Gx*Z~mr6xBA_{yD#_> zAgz0Q^s>M0;4?cSBb8#Nn^)lTPQhH53v4)Qp=rn2v?6OKNZ`j|8A1%)yrrI%*?6+$ zQ2X`sZ9Gxd9L4r-lQ0s>?{muim0rjAp8udtI&9OQXKrA zsKoj@$`tow^P@aMEbA~5_mZ$!U)*MsXDQRkZI@d_fy0haX_ulo!`7FbU{o0;%LZc8 z>&WuAqiiP^X~K-(W?OVROA)cF2X)-RB-iZ13=dOhzSBWewUA7Ik@+FN9nE*`&6QK; zjI(8LxA;O?0v%Gydt@o<%wmHZ3Q3&Efb@|vX>K$0)WBRvH>q4zQt`O{FQ>x|Ult)N z0Jbo~o@g~Wk?GVcEJLUI$x%Sj6Osh(|3&aDD!$GN5hxtBkVDdVMl6i`PA|GWT4%yl z8g7Bjtb+d!_+t78d{uUnxBLUXK)%imNN+^rKi~@s%!<3ADiMNl+N{=vj0c-NPtKo@Y?&BkwJx(ap&YnXZ93m zzOEuihZ_clQS&Opsk6xy#=(?-7fRjph{)&uiu}9tji>oRH%u*{IDRuMC=OBfNqNi; zY;2hN$EhrNO9$R0)TYRVjeUOST`P9DI&c_pi=BtOSvfWvx|H@y%YVCy>2)!`9S(y` zJy#FN{ex-2`*(|OH6(DL>GgKAxNh@Ru=ZCslDU81fIRXHbP;4GgAOw|M{)m)y7p*> zPf9I8G>+g4Q5Bz1ElOF22q~^*MM;e4VmBJK%EushmoMYRa!sN4*rd#m_&JB^_ZoEG zJT>xit_5qED2ehrp6l(y1R-u*}bvz;@ZIsa?(L#76g#-0hnVTZ;U zV&Ybhfv?|YbPZ?7#CgU@mp_C`-URKD+#~Yp;`qw_9%j|7gRiwr^36&Lp3?mZ)U1;c z()Y?+w{i!8f(Xm`rT*iQX;CU?=Dj#1<2YDNzbpBtDYl;8S)by-+{>Z!>9k)miHbnk zp5A!^A(HmlrKE86miT2Unq1*br(M;(nc>b zIXxF zHwHMmE&f~;Nv(>jtvSq6Y=!^u+|>Nr#BbcJ*mxr{DYC`nq*WuIh)Jfz2YVbTr}xEg zLtZg~^g(qtcuA9AOk#OxXqnmSPjJRBcD%Fx%g978e=L4KIEhNzrYlIBI zH7*tmVoREJFW^^zHqypU<5M!L{1bKD%)yurNPKfuAME<_JT8&J6XE%80n#%xXMBbu z!I&hLVC6EYkzbkmgPWQg8Sc~66Pc=U_jqN)_nh(s>8TPK@lkC^d!ab?zwrg*OEeXK zHrpdvze35cb`&NOir6cM@b`8;;9mOM1<*+-<^`;_6dJbt20ke-yppvB&JKzrLq`f( zCucs+qiEU(NH6~?opry*OxaKikm)?4IZwc=yP-p(b;haMx`XTw3s75jJG9;3;N-W= z1xHkVWs@If0+f)JH1H1f{R)N;^WFkoz~YbL76;Vr%{o6K}J+o#Z;`cR3{3^eTPv`M~z4B>_9$1XFyhWLXuzek~qptr==`jq$r($6M z#!daHg)?RsYU%$=H!+h)RCIh5$#0ss#6qvnOkm0QAa$S9E!6G2dEWlyBCJtu%dBjakgbpXakU)hu;xO z%5Tnc&J^jd`Xh#Ta#Jf`adGwP8)8Yd`ktVw{czBcLCZvqmx|u!Ih|H5W95}PTTx5= zpM708B|ckhT64LfA-eRs{R6d2lY4k-B+C6?30nGRi%f|@#!=@BvPIK(Wvg3gN?(Oy zlLBwTnu3qe0W_TX%^N|%QjUO`kP&uzt5+({B{VCOSR!<8j?KkqZH}K+>`ZY^2-w(6 zUBVu^?F*v&Y%&0?h8GNer7M@{+rnqPmQz$j)vRR(q48d*T3d1=Ephi#k_(48lga5Q zrN?@_>w$E8(eUX;5WH?zrm@=G*~XvuP&&%B7AAp^KntTAf_^2v0hT`_qx9=u(409; zNlq5-peGGyx7XS!!cqZW@Kf3EUBP1kWmIvj2XB0AFguH9iRO7m*v zQUSxg(a_4Ce!H>#tvscT(M%5HX2#lqgUN7`MV$jOwBD;7x?Z_UN7r`NLk5YmOQN0kxmJ;e&4XT60Ij?-+J0h=sOnJ2n?M@P+Rsfmb>Hn zOU^E8?Ib55MgLdBNjgBQ>DYl!=>GE@G3W?D@U@RygTZlc376&UGm*Bb(7?qT@* z#r>CRY`iv9gH8U%ua^KMBLyn~%F4?U3sbp4<+Dv(10QvUl_qzXMY&U}=+5Nun@LWI zlLNA$`)50*f^(;72%*&h(iHi-m$Leh~aAecP zUy?#}nNKwCH#PFn-enlzuv@Q5tY?kI)b`x1ckFhQiuuc7!e7!FjR#;b#U^|G_9@Y6 z#7p)XvU($gH`P6;47RugwM_m_`+-0Z0IJc9QvMuBGznbd^28BHHc$b6{FMH9qS<_( zSoru>KiA=R}HhV9P<1&zDFt=bW;kUAdca%y9qc51Ia|5Od` zO_7Ft)9G5-=T(V`X`^G$Ub4}W@iA7F)~VG!c!wpj4iP9ujBEs|Nt%?#{#DcyLUu$1 zC1E?NLzn(n?-Tn9;gX=c$^TU!r$~rR5AoATrN5&5q=>JK0kto^=&RV-%l$=^)2~AQ zuMUWc@)cXgs&zyeT)|u~x}yq99Ti1d>>$kge@mS*Q>jL7oyOd%kf!ZOm6o1PCy=&KGW1deomHi8Qa{bXolhY7cyM!J zO}$GtgSPpqh*Hzj4=^{WcRB&As+M zHinL_cxiJvas*Ctp4EJ8_rc_%HDIdNG(vzjx6GF+{%UQ-CjX90m#AGEENpUwVAa+) zGo~bnn)zIBj8Wbn2>G{yVfENwpddr_*91`Xt^=o+>ozgPDi*JW~u zTmo5&9qb%Zufu(k$*Dej^a01-V}4(soh?`lb5(;Bjdi>*O0mAM;X<3EQLB+EOS=lX zMA}ExXy`kF^a#B?Ei-td)nDRh3HeyN`3$KhR)&5tOkOsWy6?zoGYOfCQxi$D?Cx_Ng6E^Wg9)0hD=5#T8p)n$vbdD0-6}I9xQD!=e-CjRz%DTkjIGqz5BPjT-n&l zg66BJCFmN>*ES`p{R(Pa;t9N&)G|Vz@_8SHMdn6J#N_&!iw1}0S5GvEk{?fz#=O0~ z9dGwnTSev;o6#Pp_2UnCAB+_u>1wPZukXYHF6MtPYRM$`utOiH>Wbcwau@fe(~(5DT_ZjLbHN!w)1cend8Z;%;S z*&smu?Go)18FNxu8sV*w%Qa$Zn!im{W`$>E4W71ud9QVRGp)D#bYGCmjVj;V+I2(V z0w63dhF#DdKEEARfAyrX=&y80j~aAAt}Qb>gq8nEd%w_tan2S0hm9(mtwoQukfe*^Pb;FKS2i}1hs8z;^dHVIpB$cmHjPq^=~6WLLfby|kiIo^WJ8dDNq znb0i`1~s0YIUoOxO!SLXd0my>V0m~)wBL`@eAZA%W)i(n3Tib4#&kIzC2DR=u4vYXSq4Fy2?Rc-K!S0ZLiK1wd}0*b67UI&w8^o zKUXMAR#pSPyvFwhXV|p*N;@pf+LgFNGCNBL2T|Zhx+|kTBw?X*YRWH_HrtQREEqc* z$=Aeh^aickZ4(mWnUcC}17_#w^d+V=fzTrv+F*TCV@34fzu(7dhh%l|Z0eQXW6Je( z!mYP7!6>xu|46i2F!VLFHS!6=(kVY~lq#LaCX_;rcLFSiOHU#Cv-KREN`G;!jei*EfA5E`8l@~=}TMdAz3EUuJfrTTL4JEX+X=KcR^ z=n41*J)+PU_lM@sKYs0hFQXy`a~S{O8TyYHu>d^oTdww8JKu0kXx5;B_n&AaY*uLh z0tXc65eOj(HGnymh=Ta^`HNeHtH@WymB#sn#IeX2ZpmRIMzosnNcyyHYf*7hH1bie z^&w1N>p=ySFnb}2qbZhW=DcaGuCQW)s zl3;JXq~Xg4cPZgg&EsP_r&6_{6&`DoUf9u%>4Ihdm@BdATd$o9TBnVYiD$DIc{pAv zd-FE-tkv=v!vv@aGEo!!4mE%KcVb!-Q!ZU8m}bfJ*X~2OTpn8yDSpDKjM6qA3+B zEzHg^fddm0YR{ZQ%#r-r1?qy4X#7BHyLJQSezh9ge!vIm^7Evb>i`1_EcdjCGCEri z_G{k~yiXU}9Q$Qj*+sWs?$3CJySlozIW9Ig3GeRicKWKK)}_z^>umlJMvjl4z#*0wjibw15c z&*`T-sEBI9fyHDX=I7^EWwH>wBj?8|9+x}8sMi``-2YY5JDWBA|4KL$f2h9yk4Mx9 z*|$MRmh241mMzL|>}$e|vG02n$vO=tVhnw@(SQjrGc+>C(DdbbGF^|M&zlD^vl7+Qrh&uSOVo7t@ zJ}-41mOWZM;@p&C;pTApbi8e8#(x{Npg>aH`9&$bDV-4@72g|>GL<^%ad4X*!*tI7 z{HXY~+1`3qsMYK=TPAx{XK3c=MKz*SAT}?;d z4yYZ}^%AEh4_58BxI2e@4S3NC{_W!M&k&(EO3MfSEcu9rb-ERfMQtz7z+?UF+4!AUQ}6+H8aCv%xtaQ zL|~ZFWn=5drzpw%gYtOQTGMCWZ;Fl)8I1oM|K~wFRw#q!u;dpt#eBbAhM-6a+5gf< z4Y)G^HQn~;lJ9x#xvHMMoptpI+b8lX)2rGHMYzDFF5$z@2X-~1A4ZWoq(zpAwDcJ@ z=ecq8Z*8ZaoA~(^3+v#xh2U)dFb~j@^N8* z%v56>cesY)>*IH38~wIs|1KtRQ50;p1FYOqSp}h<-BphGyBf?(xpC*?Qp!7aFVy1@r^9{BtgbwpUl14T-0B;9Flu;qu?A3^UCH>eJ2I5io=IgUi)F*~fCE?QP5>gU00 z;m@5(3eQ)TP?~D_(e;%>)s^)u(mRed#=;S7^9!>d7pmVoR3JTg%%&aUPoi=Yqb*02 zo+aizFz1{0GPi8caY!U^M9x7MtwQHK4y1BuZw<#Xo~QA(M~Cao`L|IySTYOeeeM@y zLvo5o!m|D6Awx5!QAdN1GRlfDEKY4@eddT;ITg5^n=tze(WzW}q14DZr|ibhwg@D` zuXXPaxHQWKR>9eOSfu@mqkvd&!O0`XpJ&R3nk^Kfdx$#BHrd2tri(AK*C-~tYoxqO zz5@|@B4fxvrw7_>Kb;tO#~zSAaToN<6rYxM-)N0%eb-cBz6~fN#E0e6WiyG+#kuXS zf01oYaO3d^2za+564ZOTUC?KeeNZ+bJrjH`el{`Y$5D_C%o%@G&EJ+(Eiw0-W0g|2 zJb@Fu`fYn*=0)r~D`L##OS84U7kzj8hVf+bD+s6!Wn%*pT5Q+**2aM=s~cJWls%gB zEkBkATWbd`_*1#gjaIl7PS}FF{*)A`kiT6`O{+-vAMCuZgKykdrOTUBd4=wMd~^bk$BJ)VWZ$#} zsFCWzn@?otJVV~_rN3V&+Hi0}H8^^~GX%}m6+hrduKXQ*5$){Jt>CKBmsTI0kbKtb zTxX?~w8H1ZsV;XQxgKc;5Av`aS`>T{>)`?Y8t7DC%H*E;B6FWso!bWkRXso1=UYQX z1_uW#bjLRzvFX11w)Wxvff3};K#VWkK%b*Xj}zkG?vB!-nq}{^%sHx@ajxOS=Vzub zMYVcHHomNp|8^5$(Zh)4WS0}n*@P?~n6%XbJbipgJ?rUZP}H5E-eR@DC`WTyzVeWG z2Q7r#0lx&mBO7|D30o05rvVG0r)(;~Pd$VN%ZHI#3{{yNfE3)k3fNZM&m^J&ZfA1R z5uIm^_*5V!nY46~e>T8dSEo{h2cTJ5fKV5h2sDZ}(zNK#*T?dQLXpk6+C7psfZ|yb zUyb_3rNS(dz;qoLds*zAuu4Qkgis!ZLmWYoepYvVxb)a(Ih|E79pXF9x8lw4kl||B zG1cYG24lWz@I-N^9<-sWD^LuQREx+G6da~`cA=ki(11G?kU&V&ffq#sMyu0xc5&PP z#3}_$*FG@fWqs%}BzE_l^8`61P@Vq}9u8ca58uBx>^z!XqFt{ma$`i(=e{3w*seW) z0~*fQaXDugAwoXqe;3)VP=67iN-y;;8FAqmQ~K$}E=qs(_o;gXOofAE#a8ou0r(#QFWJUkg!6 z`&wj4&?(BWTJT2bs}J%eYR@n@o@|y*ep4;hRQZa84s`gdugzAyBNy>$f zL)I|G4vx)%Vnol+h}u&Bp!9JQ+bFnrWw6Omh_AE|2cN8zR2&pyH!#d@t&xmV?`x(M zlTK`So3ex7r;2OC_{E6&WCTWA5G5v;K$tK3?0Yt%_F><3Sf9Z#8}|Z?Gd46lZ@#qs z9xLo)9Kd2TK^Y?}|A;9$_`+Mqq4JoUpvpSd=LH(#?rsD2anmO#o#;^#w-B#x73ip#Kbt z41T&RloW!`@ci=gF&9~$XR}EC_R_uJgP`T%`&A^2OI4c;jCT&OpOFIN^~fOZw_d?!2ehNnNi^al^(>IP9)0QnW59 z(^Z0SjH!|dH`XV{RhUL#Y;ftnVFa?h=31fNWY`l$aF!|~lOlkq6n%P{96d+QO9kX| zj|gCa=@WsQik;_JmHF!_`Lrubu-<%Z=D?~b>M7Ugd?Syq7(~hyMJ8)8q{t(;&hrlI zxi=5{>aC@-aVywu&4&X$EoHo-dw}R2XL|^9MRdJ0ZR1cer~jOXK#F4m*zVVF_17)7 zV$I3J*Ad1!z@*oBfJ8vmd&MkfpaIDaP&S%rRb(2Lt7urZ2VAMNbp9nEonM3D)&v4$ z>}l!zOI_C#KnM}~V;7SeU8;SMFsHaVqP8`mUC_CXP!<#=yJ-CUb}w z7w<5vJuMF~N|R3lo*c9d$T9#@2g(}eGBla)3j|(gY0l&WX72$$kP7*qN$}7v1qcBC z@C(j8z$5B=vtvdCxE>L!^1MJ{eAAxV<>`wHGjH*t>^j|(AnyLs1g#;a!dThSzanKB W6-VWcKNo4ud8WozM*kXmru+}qf?_iO literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 9204a4f117..fe7abf0318 100644 --- a/docs/index.md +++ b/docs/index.md @@ -292,6 +292,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 diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md index 62191ba631..7900d03f2d 100644 --- a/docs/tutorial/7-schemas-and-client-libraries.md +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -1,9 +1,26 @@ -# Tutorial 7: Schemas & Client Libraries +# 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. +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 @@ -11,20 +28,57 @@ 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. +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 root of the API in a browser you should now see ... TODO +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", + ... ## 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. +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 available. 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. - $ pip install coreapi-cli + 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. @@ -50,6 +104,8 @@ We can now interact with the API using the command line client: $ coreapi action list_snippets +## Authenticating our client + TODO - authentication $ coreapi action snippets create --param code "print('hello, world')" @@ -58,8 +114,14 @@ TODO - authentication ## Using a client library -TODO +*TODO - document using python client library, rather than the command line tool.* + +## Using another schema format + +*TODO - document using OpenAPI instead.* ## Customizing schema generation -TODO +*TODO - document writing an explict schema view.* + +[coreapi]: http://www.coreapi.org diff --git a/mkdocs.yml b/mkdocs.yml index 19d1b35538..551b6bcd24 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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' diff --git a/rest_framework/routers.py b/rest_framework/routers.py index f3e4bc4eaf..81f8f8c838 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -332,7 +332,7 @@ def get_api_root_view(self): for prefix, viewset, basename in self.registry: api_root_dict[prefix] = list_name.format(basename=basename) - view_renderers = api_settings.DEFAULT_RENDERER_CLASSES + view_renderers = list(api_settings.DEFAULT_RENDERER_CLASSES) if self.schema_title: assert coreapi, '`coreapi` must be installed for schema support.' diff --git a/runtests.py b/runtests.py index 31593c3b44..e97ac03672 100755 --- a/runtests.py +++ b/runtests.py @@ -14,7 +14,7 @@ FLAKE8_ARGS = ['rest_framework', 'tests', '--ignore=E501'] -ISORT_ARGS = ['--recursive', '--diff', '-p', 'tests', 'rest_framework', 'tests'] +ISORT_ARGS = ['--recursive', '--check-only', '-o' 'uritemplate', '-p', 'tests', 'rest_framework', 'tests'] sys.path.append(os.path.dirname(__file__)) From 6c60f58a56de9f148eea5c5573eff433ac90488a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Jun 2016 21:51:22 +0100 Subject: [PATCH 12/31] Docs update --- docs/tutorial/7-schemas-and-client-libraries.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md index 7900d03f2d..7a2ad61c3b 100644 --- a/docs/tutorial/7-schemas-and-client-libraries.md +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -58,12 +58,17 @@ content type in the `Accept` header. "_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 available. Check that it +using `pip`, so the client tool should already be installed. Check that it is available on the command line... $ coreapi @@ -125,3 +130,5 @@ TODO - authentication *TODO - document writing an explict schema view.* [coreapi]: http://www.coreapi.org +[corejson]: http://www.coreapi.org/specification/encoding/#core-json-encoding +[openapi]: https://openapis.org/ From b7fcdd257e1fe240c90a62d4f2edb154dd4663b7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 Jun 2016 11:09:16 +0100 Subject: [PATCH 13/31] Initial schema generation & first tutorial 7 draft --- docs/tutorial/6-viewsets-and-routers.md | 26 +--- .../7-schemas-and-client-libraries.md | 118 +++++++++++++++--- rest_framework/routers.py | 28 +++-- 3 files changed, 124 insertions(+), 48 deletions(-) diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md index f1dbe94431..00152cc178 100644 --- a/docs/tutorial/6-viewsets-and-routers.md +++ b/docs/tutorial/6-viewsets-and-routers.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 diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md index 7a2ad61c3b..78a73e7173 100644 --- a/docs/tutorial/7-schemas-and-client-libraries.md +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -90,45 +90,127 @@ First we'll load the API schema using the command line client. $ coreapi get http://127.0.0.1:8000/ snippets: { - create(code, [title], [linenos], [language], [style]) - destroy(id) - highlight(id) + highlight(pk) list() - partial_update(id, [title], [code], [linenos], [language], [style]) - retrieve(id) - update(id, code, [title], [linenos], [language], [style]) + retrieve(pk) } users: { list() - retrieve(id) + retrieve(pk) } -At this point we're able to see all the available API endpoints. +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 hightlight HTML for a particular snippet we need to provide an id. -We can now interact with the API using the command line client: + $ coreapi action snippets highlight --param pk 1 + - $ coreapi action list_snippets + + + Example + ... ## Authenticating our client -TODO - authentication +If we want to be able to create and edit snippets, we'll need to authenticate +as a valid user. In this case we'll just use basic auth. - $ coreapi action snippets create --param code "print('hello, world')" +Make sure to replace the `` and `` below with your +actual username and password. $ coreapi credentials add 127.0.0.1 : --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. -## Using a client library +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. -*TODO - document using python client library, rather than the command line tool.* +You can review the final [tutorial code][repo] on GitHub, or try out a live example in [the sandbox][sandbox]. -## Using another schema format +## Onwards and upwards -*TODO - document using OpenAPI instead.* +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: -## Customizing schema generation +* 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. -*TODO - document writing an explict schema view.* +**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 diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 81f8f8c838..ae5f22606b 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -23,8 +23,9 @@ from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import NoReverseMatch -from rest_framework import renderers, views +from rest_framework import exceptions, renderers, views from rest_framework.compat import coreapi +from rest_framework.request import override_method from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.settings import api_settings @@ -262,7 +263,7 @@ def get_urls(self): return ret - def get_links(self): + def get_links(self, request=None): content = {} for prefix, viewset, basename in self.registry: @@ -284,13 +285,23 @@ def get_links(self): continue for method, action in mapping.items(): + link = self.get_link(viewset, url, method, request) + if link is None: + continue # User does not have permissions. 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): + def get_link(self, viewset, url, method, request=None): + view_instance = viewset() + if request is not None: + with override_method(view_instance, request, method.upper()) as request: + try: + view_instance.check_permissions(request) + except exceptions.APIException as exc: + return None + fields = [] for variable in uritemplate.variables(url): @@ -298,7 +309,7 @@ def get_link(self, viewset, url, method): fields.append(field) if method in ('put', 'patch', 'post'): - cls = viewset().get_serializer_class() + cls = view_instance.get_serializer_class() serializer = cls() for field in serializer.fields.values(): if field.read_only: @@ -336,9 +347,8 @@ def get_api_root_view(self): if self.schema_title: assert coreapi, '`coreapi` must be installed for schema support.' - content = self.get_links() - schema = coreapi.Document(title=self.schema_title, content=content) view_renderers += [renderers.CoreJSONRenderer] + router = self class APIRoot(views.APIView): _ignore_model_permissions = True @@ -346,6 +356,10 @@ class APIRoot(views.APIView): def get(self, request, *args, **kwargs): if request.accepted_renderer.format == 'corejson': + content = router.get_links(request) + if not content: + raise exceptions.PermissionDenied() + schema = coreapi.Document(title=router.schema_title, content=content) return Response(schema) ret = OrderedDict() From 2e60f41b462315bcb2a1f723f5c3f9646d2e11a7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 Jun 2016 11:10:29 +0100 Subject: [PATCH 14/31] Spelling --- docs/tutorial/7-schemas-and-client-libraries.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md index 78a73e7173..c89dcd3d70 100644 --- a/docs/tutorial/7-schemas-and-client-libraries.md +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -119,7 +119,7 @@ Let's try listing the existing snippets, using the command line client: ... Some of the API endpoints require named parameters. For example, to get back -the hightlight HTML for a particular snippet we need to provide an id. +the highlight HTML for a particular snippet we need to provide an id. $ coreapi action snippets highlight --param pk 1 From 2ffa145f5d5a4ed48c308b4c0735abe15edcf210 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 Jun 2016 14:23:24 +0100 Subject: [PATCH 15/31] Remove unused variable --- rest_framework/routers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index ae5f22606b..7e5bf4f842 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -299,7 +299,7 @@ def get_link(self, viewset, url, method, request=None): with override_method(view_instance, request, method.upper()) as request: try: view_instance.check_permissions(request) - except exceptions.APIException as exc: + except exceptions.APIException: return None fields = [] From b709dd484d2ca85a08544d400635ee6844b57013 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 Jun 2016 14:23:32 +0100 Subject: [PATCH 16/31] Docs tweak --- docs/tutorial/7-schemas-and-client-libraries.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md index c89dcd3d70..8d772a5bff 100644 --- a/docs/tutorial/7-schemas-and-client-libraries.md +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -131,8 +131,8 @@ the highlight HTML for a particular snippet we need to provide an id. ## Authenticating our client -If we want to be able to create and edit snippets, we'll need to authenticate -as a valid user. In this case we'll just use basic auth. +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 `` and `` below with your actual username and password. From 482289695d0f1c0960de5645a05f503cea1d8c5a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 15 Jun 2016 16:43:50 +0100 Subject: [PATCH 17/31] Added SchemaGenerator class --- rest_framework/routers.py | 79 ++--------------- rest_framework/schemas.py | 176 +++++++++++++++++++++++++++++++++++++ rest_framework/viewsets.py | 1 + tests/test_routers.py | 2 +- 4 files changed, 187 insertions(+), 71 deletions(-) create mode 100644 rest_framework/schemas.py diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 7e5bf4f842..3bd1fc850b 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -18,16 +18,15 @@ import itertools from collections import OrderedDict, namedtuple -import uritemplate from django.conf.urls import url from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import NoReverseMatch from rest_framework import exceptions, renderers, views from rest_framework.compat import coreapi -from rest_framework.request import override_method 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 @@ -263,63 +262,6 @@ def get_urls(self): return ret - def get_links(self, request=None): - 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(): - link = self.get_link(viewset, url, method, request) - if link is None: - continue # User does not have permissions. - if prefix not in content: - content[prefix] = {} - content[prefix][action] = link - return content - - def get_link(self, viewset, url, method, request=None): - view_instance = viewset() - if request is not None: - with override_method(view_instance, request, method.upper()) as request: - try: - view_instance.check_permissions(request) - except exceptions.APIException: - return None - - 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 = view_instance.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): """ @@ -334,7 +276,7 @@ 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. """ @@ -345,10 +287,10 @@ def get_api_root_view(self): view_renderers = list(api_settings.DEFAULT_RENDERER_CLASSES) - if self.schema_title: + if schema_urls and self.schema_title: assert coreapi, '`coreapi` must be installed for schema support.' view_renderers += [renderers.CoreJSONRenderer] - router = self + schema_generator = SchemaGenerator(patterns=schema_urls) class APIRoot(views.APIView): _ignore_model_permissions = True @@ -356,10 +298,9 @@ class APIRoot(views.APIView): def get(self, request, *args, **kwargs): if request.accepted_renderer.format == 'corejson': - content = router.get_links(request) - if not content: + schema = schema_generator.get_schema(request) + if schema is None: raise exceptions.PermissionDenied() - schema = coreapi.Document(title=router.schema_title, content=content) return Response(schema) ret = OrderedDict() @@ -388,15 +329,13 @@ def get_urls(self): 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) diff --git a/rest_framework/schemas.py b/rest_framework/schemas.py new file mode 100644 index 0000000000..c47ff3eb7e --- /dev/null +++ b/rest_framework/schemas.py @@ -0,0 +1,176 @@ +from importlib import import_module + +import coreapi +import uritemplate +from django.conf import settings +from django.contrib.admindocs.views import simplify_regex +from django.core.urlresolvers import RegexURLPattern, RegexURLResolver +from django.utils import six + +from rest_framework import exceptions +from rest_framework.request import clone_request +from rest_framework.views import APIView + + +class SchemaGenerator(object): + default_mapping = { + 'get': 'read', + 'post': 'create', + 'put': 'update', + 'patch': 'partial_update', + 'delete': 'destroy', + } + + def __init__(self, schema_title=None, patterns=None, urlconf=None): + if patterns is None and urlconf is not None: + if isinstance(urlconf, six.string_types): + urls = import_module(urlconf) + else: + urls = urlconf + patterns = urls.urlpatterns + elif patterns is None and urlconf is None: + urls = import_module(settings.ROOT_URLCONF) + patterns = urls.urlpatterns + + self.schema_title = schema_title + self.endpoints = self.get_api_endpoints(patterns) + + def get_schema(self, request=None): + if request is None: + endpoints = self.endpoints + else: + # Filter the list of endpoints to only include those that + # the user has permission on. + endpoints = [] + for key, link, callback in self.endpoints: + method = link.action.upper() + view = callback.cls() + view.request = clone_request(request, method) + try: + view.check_permissions(view.request) + except exceptions.APIException: + pass + else: + endpoints.append((key, link, callback)) + + if not endpoints: + return None + + # Generate the schema content structure, from the endpoints. + # ('users', 'list'), Link -> {'users': {'list': Link()}} + content = {} + for key, link, callback in endpoints: + insert_into = content + for item in key[:1]: + if item not in insert_into: + insert_into[item] = {} + insert_into = insert_into[item] + insert_into[key[-1]] = link + + # Return the schema document. + return coreapi.Document(title=self.schema_title, content=content) + + def get_api_endpoints(self, patterns, prefix=''): + """ + Return a list of all available API endpoints by inspecting the URL conf. + """ + api_endpoints = [] + + for pattern in patterns: + path_regex = prefix + pattern.regex.pattern + + if isinstance(pattern, RegexURLPattern): + path = self.get_path(path_regex) + callback = pattern.callback + if self.include_endpoint(path, callback): + for method in self.get_allowed_methods(callback): + key = self.get_key(path, method, callback) + link = self.get_link(path, method, callback) + endpoint = (key, link, callback) + api_endpoints.append(endpoint) + + elif isinstance(pattern, RegexURLResolver): + nested_endpoints = self.get_api_endpoints( + patterns=pattern.url_patterns, + prefix=path_regex + ) + api_endpoints.extend(nested_endpoints) + + return api_endpoints + + def get_path(self, path_regex): + """ + Given a URL conf regex, return a URI template string. + """ + path = simplify_regex(path_regex) + path = path.replace('<', '{').replace('>', '}') + return path + + def include_endpoint(self, path, callback): + """ + Return True if the given endpoint should be included. + """ + cls = getattr(callback, 'cls', None) + if (cls is None) or not issubclass(cls, APIView): + return False + + if path.endswith('.{format}') or path.endswith('.{format}/'): + return False + + if path == '/': + return False + + return True + + def get_allowed_methods(self, callback): + """ + Return a list of the valid HTTP methods for this endpoint. + """ + if hasattr(callback, 'actions'): + return [method.upper() for method in callback.actions.keys()] + + return [ + method for method in + callback.cls().allowed_methods if method != 'OPTIONS' + ] + + def get_key(self, path, method, callback): + """ + Return a tuple of strings, indicating the identity to use for a + given endpoint. eg. ('users', 'list'). + """ + category = None + for item in path.strip('/').split('/'): + if '{' in item: + break + category = item + + actions = getattr(callback, 'actions', self.default_mapping) + action = actions[method.lower()] + + if category: + return (category, action) + return (action,) + + def get_link(self, path, method, callback): + """ + Return a `coreapi.Link` instance for the given endpoint. + """ + view = callback.cls() + fields = [] + + for variable in uritemplate.variables(path): + field = coreapi.Field(name=variable, location='path', required=True) + fields.append(field) + + if method in ('PUT', 'PATCH', 'POST'): + serializer_class = view.get_serializer_class() + serializer = serializer_class() + 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=path, action=method.lower(), fields=fields) diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 05434b72ec..7687448c48 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -98,6 +98,7 @@ def view(request, *args, **kwargs): # 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): diff --git a/tests/test_routers.py b/tests/test_routers.py index acab660d8b..f45039f802 100644 --- a/tests/test_routers.py +++ b/tests/test_routers.py @@ -257,7 +257,7 @@ class NoteViewSet(viewsets.ModelViewSet): 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): From 1f76ccaeeeac6c74dabb683f3dc0ebcb458a0b1e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 15 Jun 2016 17:00:26 +0100 Subject: [PATCH 18/31] Fail gracefully if coreapi is not installed and SchemaGenerator is used --- rest_framework/routers.py | 2 -- rest_framework/schemas.py | 5 +++-- schema-support | 0 3 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 schema-support diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 3bd1fc850b..4a0ed6f505 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -23,7 +23,6 @@ from django.core.urlresolvers import NoReverseMatch from rest_framework import exceptions, renderers, views -from rest_framework.compat import coreapi from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.schemas import SchemaGenerator @@ -288,7 +287,6 @@ def get_api_root_view(self, schema_urls=None): view_renderers = list(api_settings.DEFAULT_RENDERER_CLASSES) if schema_urls and self.schema_title: - assert coreapi, '`coreapi` must be installed for schema support.' view_renderers += [renderers.CoreJSONRenderer] schema_generator = SchemaGenerator(patterns=schema_urls) diff --git a/rest_framework/schemas.py b/rest_framework/schemas.py index c47ff3eb7e..fc9c7baba7 100644 --- a/rest_framework/schemas.py +++ b/rest_framework/schemas.py @@ -1,13 +1,12 @@ from importlib import import_module -import coreapi -import uritemplate from django.conf import settings from django.contrib.admindocs.views import simplify_regex from django.core.urlresolvers import RegexURLPattern, RegexURLResolver from django.utils import six from rest_framework import exceptions +from rest_framework.compat import coreapi, uritemplate from rest_framework.request import clone_request from rest_framework.views import APIView @@ -22,6 +21,8 @@ class SchemaGenerator(object): } def __init__(self, schema_title=None, patterns=None, urlconf=None): + assert coreapi, '`coreapi` must be installed for schema support.' + if patterns is None and urlconf is not None: if isinstance(urlconf, six.string_types): urls = import_module(urlconf) diff --git a/schema-support b/schema-support new file mode 100644 index 0000000000..e69de29bb2 From cad24b1ecd78e181618aedb6d555df9090f4b3a1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 21 Jun 2016 16:59:53 +0100 Subject: [PATCH 19/31] Schema docs, pagination controls, filter controls --- docs/api-guide/schemas.md | 360 +++++++++++++++++++++++++++++++++++ docs/index.md | 2 + mkdocs.yml | 1 + rest_framework/filters.py | 20 ++ rest_framework/pagination.py | 15 ++ rest_framework/routers.py | 16 +- rest_framework/schemas.py | 126 +++++++++--- 7 files changed, 510 insertions(+), 30 deletions(-) create mode 100644 docs/api-guide/schemas.md diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md new file mode 100644 index 0000000000..0fbe6e64b8 --- /dev/null +++ b/docs/api-guide/schemas.md @@ -0,0 +1,360 @@ +source: schemas.py + +# Schemas + +> A machine-readable [schema] describes what resources are available via the API, what their URLs are, how they are represented and what operations they support. +> +> — Heroku, [JSON Schema for the Heroku Platform API][cite] + +API schemas are a useful tool that allow for a range of use cases, including +generating reference documentation, or driving dynamic client libraries that +can interact with your API. + +## Representing schemas internally + +REST framework uses [Core API][coreapi] in order to model schema information in +a format-independent representation. This information can then be rendered +into various different schema formats, or used to generate API documentation. + +When using Core API, a schema is represented as a `Document` which is the +top-level container object for information about the API. Available API +interactions are represented using `Link` objects. Each link includes a URL, +HTTP method, and may include a list of `Field` instances, which describe any +parameters that may be accepted by the API endpoint. The `Link` and `Field` +instances may also include descriptions, that allow an API schema to be +rendered into user documentation. + +Here's an example of an API description that includes a single `search` +endpoint: + + coreapi.Document( + title='Flight Search API', + url='https://api.example.org/', + content={ + 'search': coreapi.Link( + url='/search/', + action='get', + fields=[ + coreapi.Field( + name='from', + required=True, + location='query', + description='City name or airport code.' + ), + coreapi.Field( + name='to', + required=True, + location='query', + description='City name or airport code.' + ), + coreapi.Field( + name='date', + required=True, + location='query', + description='Flight date in "YYYY-MM-DD" format.' + ) + ], + description='Return flight availability and prices.' + ) + } + ) + +## Schema output formats + +In order to be presented in an HTTP response, the internal representation +has to be rendered into the actual bytes that are used in the response. + +[Core JSON][corejson] is designed as a canonical format for use with Core API. +REST framework includes a renderer class for handling this media type, which +is available as `renderers.CoreJSONRenderer`. + +Other schema formats such as [Open API][open-api] (Formerly "Swagger"), +[JSON HyperSchema][json-hyperschema], or [API Blueprint][api-blueprint] can +also be supported by implementing a custom renderer class. + +## Schemas vs Hypermedia + +It's worth pointing out here that Core API can also be used to model hypermedia +responses, which present an alternative interaction style to API schemas. + +With an API schema, the entire available interface is presented up-front +as a single endpoint. Responses to individual API endpoints are then typically +presented as plain data, without any further interactions contained in each +response. + +With Hypermedia, the client is instead presented with a document containing +both data and available interactions. Each interaction results in a new +document, detailing both the current state and the available interactions. + +Further information and support on building Hypermedia APIs with REST framework +is planned for a future version. + +--- + +# Adding a schema + +You'll need to install the `coreapi` package in order to add schema support +for REST framework. + + pip install coreapi + +REST framework includes functionality for auto-generating a schema, +or allows you to specify one explicitly. There are a few different ways to +add a schema to your API, depending on exactly what you need. + +## Using DefaultRouter + +If you're using `DefaultRouter` then you can include an auto-generated schema, +simply by adding a `schema_title` argument to the router. + + router = DefaultRouter(schema_title='Server Monitoring API') + +The schema will be included in by the root URL, `/`, and presented to clients +that include the Core JSON media type in their `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": "Server Monitoring API" + }, + "_type": "document", + ... + } + +This is a great zero-configuration option for when you want to get up and +running really quickly. If you want a little more flexibility over the +schema output then you'll need to consider using `SchemaGenerator` instead. + +## Using SchemaGenerator + +The most common way to add a schema to your API is to use the `SchemaGenerator` +class to auto-generate the `Document` instance, and to return that from a view. + +This option gives you the flexibility of setting up the schema endpoint +with whatever behaviour you want. For example, you can apply different +permission, throttling or authentication policies to the schema endpoint. + +Here's an example of using `SchemaGenerator` together with a view to +return the schema. + +**views.py:** + + from rest_framework.decorators import api_view, renderer_classes + from rest_framework import renderers, schemas + + generator = schemas.SchemaGenerator(title='Bookings API') + + @api_view() + @renderer_classes([renderers.CoreJSONRenderer]) + def schema_view(request): + return generator.get_schema() + +**urls.py:** + + urlpatterns = [ + url('/', schema_view), + ... + ] + +You can also serve different schemas to different users, depending on the +permissions they have available. This approach can be used to ensure that +unauthenticated requests are presented with a different schema to +authenticated requests, or to ensure that different parts of the API are +made visible to different users depending on their role. + +In order to present a schema with endpoints filtered by user permissions, +you need to pass the `request` argument to the `get_schema()` method, like so: + + @api_view() + @renderer_classes([renderers.CoreJSONRenderer]) + def schema_view(request): + return generator.get_schema(request=request) + +## Explicit schema definition + +An alternative to the auto-generated approach is to specify the API schema +explicitly, by declaring a `Document` object in your codebase. Doing so is a +little more work, but ensures that you have full control over the schema +representation. + + import coreapi + from rest_framework.decorators import api_view, renderer_classes + from rest_framework import renderers + + schema = coreapi.Document( + title='Bookings API', + content={ + ... + } + ) + + @api_view() + @renderer_classes([renderers.CoreJSONRenderer]) + def schema_view(request): + return schema + +## Static schema file + +A final option is to write your API schema as a static file, using one +of the available formats, such as Core JSON or Open API. + +You could then either: + +* Write a schema definition as a static file, and [serve the static file directly][static-files]. +* Write a schema definition that is loaded using `Core API`, and then + rendered to one of many available formats, depending on the client request. + +--- + +# API Reference + +## SchemaGenerator + +A class that deals with introspecting your API views, which can be used to +generate a schema. + +Typically you'll instantiate `SchemaGenerator` with a single argument, like so: + + generator = SchemaGenerator(title='Stock Prices API') + +Arguments: + +* `title` - The name of the API. **required** +* `patterns` - A list of URLs to inspect when generating the schema. Defaults to the project's URL conf. +* `urlconf` - A URL conf module name to use when generating the schema. Defaults to `settings.ROOT_URLCONF`. + +### get_schema() + +Returns a `coreapi.Document` instance that represents the API schema. + + @api_view + @renderer_classes([renderers.CoreJSONRenderer]) + def schema_view(request): + return generator.get_schema() + +Arguments: + +* `request` - The incoming request. Optionally used if you want to apply per-user permissions to the schema-generation. + +--- + +## Core API + +This documentation gives a brief overview of the components within the `coreapi` +package that are used to represent an API schema. + +Note that these classes are imported from the `coreapi` package, rather than +from the `rest_framework` package. + +### Document + +Represents a container for the API schema. + +#### `title` + +A name for the API. + +#### `url` + +A canonical URL for the API. + +#### `content` + +A dictionary, containing the `Link` objects that the schema contains. + +In order to provide more structure to the schema, the `content` dictionary +may be nested, typically to a second level. For example: + + content={ + "bookings": { + "list": Link(...), + "create": Link(...), + ... + }, + "venues": { + "list": Link(...), + ... + }, + ... + } + +### Link + +Represents an individual API endpoint. + +#### `url` + +The URL of the endpoint. May be a URI template, such as `/users/{username}/`. + +#### `action` + +The HTTP method associated with the endpoint. Note that URLs that support +more than one HTTP method, should correspond to a single `Link` for each. + +#### `fields` + +A list of `Field` instances, describing the available parameters on the input. + +#### `description` + +A short description of the meaning and intended usage of the endpoint. + +### Field + +Represents a single input parameter on a given API endpoint. + +#### `name` + +A descriptive name for the input. + +#### `required` + +A boolean, indicated if the client is required to included a value, or if +the parameter can be omitted. + +#### `location` + +Determines how the information is encoded into the request. Should be one of +the following strings: + +**"path"** + +Included in a templated URI. For example a `url` value of `/products/{product_code}/` could be used together with a `"path"` field, to handle API inputs in a URL path such as `/products/slim-fit-jeans/`. + +These fields will normally correspond with [named arguments in the project URL conf][named-arguments]. + +**"query"** + +Included as a URL query parameter. For example `?search=sale`. Typically for `GET` requests. + +These fields will normally correspond with pagination and filtering controls on a view. + +**"form"** + +Included in the request body, as a single item of a JSON object or HTML form. For example `{"colour": "blue", ...}`. Typically for `POST`, `PUT` and `PATCH` requests. Multiple `"form"` fields may be included on a single link. + +These fields will normally correspond with serializer fields on a view. + +**"body"** + +Included as the complete request body. Typically for `POST`, `PUT` and `PATCH` requests. No more than one `"body"` field may exist on a link. May not be used together with `"form"` fields. + +These fields will normally correspond with views that use `ListSerializer` to validate the request input, or with file upload views. + +#### `description` + +A short description of the meaning and intended usage of the input field. + + +[cite]: https://blog.heroku.com/archives/2014/1/8/json_schema_for_heroku_platform_api +[coreapi]: http://www.coreapi.org/ +[corejson]: http://www.coreapi.org/specification/encoding/#core-json-encoding +[open-api]: https://openapis.org/ +[json-hyperschema]: http://json-schema.org/latest/json-schema-hypermedia.html +[api-blueprint]: https://apiblueprint.org/ +[static-files]: https://docs.djangoproject.com/en/dev/howto/static-files/ +[named-arguments]: https://docs.djangoproject.com/en/dev/topics/http/urls/#named-groups diff --git a/docs/index.md b/docs/index.md index fe7abf0318..fbd1fe3e77 100644 --- a/docs/index.md +++ b/docs/index.md @@ -191,6 +191,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] @@ -314,6 +315,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 diff --git a/mkdocs.yml b/mkdocs.yml index 551b6bcd24..5fb64db5a3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,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' diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 3836e8170c..c3c9c5af30 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -71,6 +71,9 @@ def filter_queryset(self, request, queryset, view): """ raise NotImplementedError(".filter_queryset() must be overridden.") + def get_fields(self): + return [] + class DjangoFilterBackend(BaseFilterBackend): """ @@ -127,6 +130,17 @@ def to_html(self, request, queryset, view): template = loader.get_template(self.template) return template_render(template, context) + def get_fields(self): + 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. @@ -191,6 +205,9 @@ def to_html(self, request, queryset, view): template = loader.get_template(self.template) return template_render(template, context) + def get_fields(self): + return [self.search_param] + class OrderingFilter(BaseFilterBackend): # The URL query parameter used for the ordering. @@ -304,6 +321,9 @@ def to_html(self, request, queryset, view): context = self.get_template_context(request, queryset, view) return template_render(template, context) + def get_fields(self): + return [self.ordering_param] + class DjangoObjectPermissionsFilter(BaseFilterBackend): """ diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index fc20ea2669..370af5a740 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -157,6 +157,9 @@ def to_html(self): # pragma: no cover def get_results(self, data): return data['results'] + def get_fields(self): + return [] + class PageNumberPagination(BasePagination): """ @@ -280,6 +283,11 @@ def to_html(self): context = self.get_html_context() return template_render(template, context) + def get_fields(self): + 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,10 @@ def to_html(self): context = self.get_html_context() return template_render(template, context) + def get_fields(self): + return [self.limit_query_param, self.offset_query_param] + + class CursorPagination(BasePagination): """ @@ -706,3 +718,6 @@ def to_html(self): template = loader.get_template(self.template) context = self.get_html_context() return template_render(template, context) + + def get_fields(self): + return [self.cursor_query_param] diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 4a0ed6f505..f781102b73 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -270,6 +270,7 @@ 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) @@ -287,20 +288,29 @@ def get_api_root_view(self, schema_urls=None): view_renderers = list(api_settings.DEFAULT_RENDERER_CLASSES) if schema_urls and self.schema_title: - view_renderers += [renderers.CoreJSONRenderer] - schema_generator = SchemaGenerator(patterns=schema_urls) + 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.format == 'corejson': + 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(): diff --git a/rest_framework/schemas.py b/rest_framework/schemas.py index fc9c7baba7..e1a0212f1f 100644 --- a/rest_framework/schemas.py +++ b/rest_framework/schemas.py @@ -11,6 +11,32 @@ from rest_framework.views import APIView +def is_api_view(callback): + """ + Return `True` if the given view callback is a REST framework view/viewset. + """ + cls = getattr(callback, 'cls', None) + return (cls is not None) and issubclass(cls, APIView) + + +def insert_into(target, keys, item): + """ + Insert `item` into the nested dictionary `target`. + + For example: + + target = {} + insert_into(target, ('users', 'list'), Link(...)) + insert_into(target, ('users', 'detail'), Link(...)) + assert target == {'users': {'list': Link(...), 'detail': Link(...)}} + """ + for key in keys[:1]: + if key not in target: + target[key] = {} + target = target[key] + target[keys[-1]] = item + + class SchemaGenerator(object): default_mapping = { 'get': 'read', @@ -20,7 +46,7 @@ class SchemaGenerator(object): 'delete': 'destroy', } - def __init__(self, schema_title=None, patterns=None, urlconf=None): + def __init__(self, title=None, patterns=None, urlconf=None): assert coreapi, '`coreapi` must be installed for schema support.' if patterns is None and urlconf is not None: @@ -33,7 +59,7 @@ def __init__(self, schema_title=None, patterns=None, urlconf=None): urls = import_module(settings.ROOT_URLCONF) patterns = urls.urlpatterns - self.schema_title = schema_title + self.title = title self.endpoints = self.get_api_endpoints(patterns) def get_schema(self, request=None): @@ -61,15 +87,10 @@ def get_schema(self, request=None): # ('users', 'list'), Link -> {'users': {'list': Link()}} content = {} for key, link, callback in endpoints: - insert_into = content - for item in key[:1]: - if item not in insert_into: - insert_into[item] = {} - insert_into = insert_into[item] - insert_into[key[-1]] = link + insert_into(content, key, link) # Return the schema document. - return coreapi.Document(title=self.schema_title, content=content) + return coreapi.Document(title=self.title, content=content) def get_api_endpoints(self, patterns, prefix=''): """ @@ -83,7 +104,7 @@ def get_api_endpoints(self, patterns, prefix=''): if isinstance(pattern, RegexURLPattern): path = self.get_path(path_regex) callback = pattern.callback - if self.include_endpoint(path, callback): + if self.should_include_endpoint(path, callback): for method in self.get_allowed_methods(callback): key = self.get_key(path, method, callback) link = self.get_link(path, method, callback) @@ -107,19 +128,18 @@ def get_path(self, path_regex): path = path.replace('<', '{').replace('>', '}') return path - def include_endpoint(self, path, callback): + def should_include_endpoint(self, path, callback): """ - Return True if the given endpoint should be included. + Return `True` if the given endpoint should be included. """ - cls = getattr(callback, 'cls', None) - if (cls is None) or not issubclass(cls, APIView): - return False + if not is_api_view(callback): + return False # Ignore anything except REST framework views. if path.endswith('.{format}') or path.endswith('.{format}/'): - return False + return False # Ignore .json style URLs. if path == '/': - return False + return False # Ignore the root endpoint. return True @@ -153,25 +173,77 @@ def get_key(self, path, method, callback): return (category, action) return (action,) + # Methods for generating each individual `Link` instance... + def get_link(self, path, method, callback): """ Return a `coreapi.Link` instance for the given endpoint. """ view = callback.cls() + fields = self.get_path_fields(path, method, callback, view) + fields += self.get_serializer_fields(path, method, callback, view) + fields += self.get_pagination_fields(path, method, callback, view) + fields += self.get_filter_fields(path, method, callback, view) + return coreapi.Link(url=path, action=method.lower(), fields=fields) + + def get_path_fields(self, path, method, callback, view): + """ + Return a list of `coreapi.Field` instances corresponding to any + templated path variables. + """ fields = [] for variable in uritemplate.variables(path): field = coreapi.Field(name=variable, location='path', required=True) fields.append(field) - if method in ('PUT', 'PATCH', 'POST'): - serializer_class = view.get_serializer_class() - serializer = serializer_class() - 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 fields - return coreapi.Link(url=path, action=method.lower(), fields=fields) + def get_serializer_fields(self, path, method, callback, view): + """ + Return a list of `coreapi.Field` instances corresponding to any + request body input, as determined by the serializer class. + """ + if method not in ('PUT', 'PATCH', 'POST'): + return [] + + fields = [] + + serializer_class = view.get_serializer_class() + serializer = serializer_class() + 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 fields + + def get_pagination_fields(self, path, method, callback, view): + if method != 'GET': + return [] + + if hasattr(callback, 'actions') and ('list' not in callback.actions.values()): + return [] + + if not hasattr(view, 'pagination_class'): + return [] + + paginator = view.pagination_class() + return paginator.get_fields() + + def get_filter_fields(self, path, method, callback, view): + if method != 'GET': + return [] + + if hasattr(callback, 'actions') and ('list' not in callback.actions.values()): + return [] + + if not hasattr(view, 'filter_backends'): + return [] + + fields = [] + for filter_backend in view.filter_backends: + fields += filter_backend().get_fields() + return fields From 8fb260214b5efa933d7ebffac07840bce0a83eb0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 21 Jun 2016 21:07:25 +0100 Subject: [PATCH 20/31] Resolve NameError --- rest_framework/routers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index f781102b73..64f13a4468 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -286,6 +286,7 @@ def get_api_root_view(self, schema_urls=None): 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) From b438281f34fde5c1351d969d5034bb759c4bbad5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 21 Jun 2016 21:16:45 +0100 Subject: [PATCH 21/31] Add 'view' argument to 'get_fields()' --- rest_framework/filters.py | 8 ++++---- rest_framework/pagination.py | 8 ++++---- rest_framework/schemas.py | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/rest_framework/filters.py b/rest_framework/filters.py index c3c9c5af30..912b9083cb 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -71,7 +71,7 @@ def filter_queryset(self, request, queryset, view): """ raise NotImplementedError(".filter_queryset() must be overridden.") - def get_fields(self): + def get_fields(self, view): return [] @@ -130,7 +130,7 @@ def to_html(self, request, queryset, view): template = loader.get_template(self.template) return template_render(template, context) - def get_fields(self): + def get_fields(self, view): filter_class = getattr(view, 'filter_class', None) if filter_class: return list(filter_class().filters.keys()) @@ -205,7 +205,7 @@ def to_html(self, request, queryset, view): template = loader.get_template(self.template) return template_render(template, context) - def get_fields(self): + def get_fields(self, view): return [self.search_param] @@ -321,7 +321,7 @@ def to_html(self, request, queryset, view): context = self.get_template_context(request, queryset, view) return template_render(template, context) - def get_fields(self): + def get_fields(self, view): return [self.ordering_param] diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 370af5a740..d0fb483cee 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -157,7 +157,7 @@ def to_html(self): # pragma: no cover def get_results(self, data): return data['results'] - def get_fields(self): + def get_fields(self, view): return [] @@ -283,7 +283,7 @@ def to_html(self): context = self.get_html_context() return template_render(template, context) - def get_fields(self): + 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] @@ -412,7 +412,7 @@ def to_html(self): context = self.get_html_context() return template_render(template, context) - def get_fields(self): + def get_fields(self, view): return [self.limit_query_param, self.offset_query_param] @@ -719,5 +719,5 @@ def to_html(self): context = self.get_html_context() return template_render(template, context) - def get_fields(self): + def get_fields(self, view): return [self.cursor_query_param] diff --git a/rest_framework/schemas.py b/rest_framework/schemas.py index e1a0212f1f..d92fac5aa8 100644 --- a/rest_framework/schemas.py +++ b/rest_framework/schemas.py @@ -231,7 +231,7 @@ def get_pagination_fields(self, path, method, callback, view): return [] paginator = view.pagination_class() - return paginator.get_fields() + return paginator.get_fields(view) def get_filter_fields(self, path, method, callback, view): if method != 'GET': @@ -245,5 +245,5 @@ def get_filter_fields(self, path, method, callback, view): fields = [] for filter_backend in view.filter_backends: - fields += filter_backend().get_fields() + fields += filter_backend().get_fields(view) return fields From 8519b4e24c3c990e2b002172ab2c218bbd9f5e86 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 21 Jun 2016 21:17:44 +0100 Subject: [PATCH 22/31] Remove extranous blank line --- rest_framework/pagination.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index d0fb483cee..0047f6d41c 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -416,7 +416,6 @@ def get_fields(self, view): return [self.limit_query_param, self.offset_query_param] - class CursorPagination(BasePagination): """ The cursor pagination implementation is neccessarily complex. From 2f5c9748d38c95322dd363e50ef41124c994a712 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 22 Jun 2016 14:11:37 +0100 Subject: [PATCH 23/31] Add integration tests for schema generation --- docs/api-guide/schemas.md | 23 ++++++ rest_framework/schemas.py | 62 +++++++++++++- rest_framework/utils/encoders.py | 7 +- tests/test_schemas.py | 137 +++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 tests/test_schemas.py diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 0fbe6e64b8..69850c430c 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -345,6 +345,29 @@ Included as the complete request body. Typically for `POST`, `PUT` and `PATCH` r These fields will normally correspond with views that use `ListSerializer` to validate the request input, or with file upload views. +#### `encoding` + +**"application/json"** + +JSON encoded request content. Corresponds to views using `JSONParser`. +Valid only if either one or more `location="form"` fields, or a single +`location="body"` field is included on the `Link`. + +**"multipart/form-data"** + +Multipart encoded request content. Corresponds to views using `MultiPartParser`. +Valid only if one or more `location="form"` fields is included on the `Link`. + +**"application/x-www-form-urlencoded"** + +URL encoded request content. Corresponds to views using `FormParser`. Valid +only if one or more `location="form"` fields is included on the `Link`. + +**"application/octet-stream"** + +Binary upload request content. Corresponds to views using `FileUploadParser`. +Valid only if a `location="body"` field is included on the `Link`. + #### `description` A short description of the meaning and intended usage of the input field. diff --git a/rest_framework/schemas.py b/rest_framework/schemas.py index d92fac5aa8..c2d2762501 100644 --- a/rest_framework/schemas.py +++ b/rest_framework/schemas.py @@ -5,12 +5,23 @@ from django.core.urlresolvers import RegexURLPattern, RegexURLResolver from django.utils import six -from rest_framework import exceptions +from rest_framework import exceptions, serializers from rest_framework.compat import coreapi, uritemplate from rest_framework.request import clone_request from rest_framework.views import APIView +def as_query_fields(items): + """ + Take a list of Fields and plain strings. + Convert any pain strings into `location='query'` Field instances. + """ + return [ + item if isinstance(item, coreapi.Field) else coreapi.Field(name=item, required=False, location='query') + for item in items + ] + + def is_api_view(callback): """ Return `True` if the given view callback is a REST framework view/viewset. @@ -180,11 +191,47 @@ def get_link(self, path, method, callback): Return a `coreapi.Link` instance for the given endpoint. """ view = callback.cls() + fields = self.get_path_fields(path, method, callback, view) fields += self.get_serializer_fields(path, method, callback, view) fields += self.get_pagination_fields(path, method, callback, view) fields += self.get_filter_fields(path, method, callback, view) - return coreapi.Link(url=path, action=method.lower(), fields=fields) + + if fields: + encoding = self.get_encoding(path, method, callback, view) + else: + encoding = None + + return coreapi.Link( + url=path, + action=method.lower(), + encoding=encoding, + fields=fields + ) + + def get_encoding(self, path, method, callback, view): + """ + Return the 'encoding' parameter to use for a given endpoint. + """ + if method not in set(('POST', 'PUT', 'PATCH')): + return None + + # Core API supports the following request encodings over HTTP... + supported_media_types = set(( + 'application/json', + 'application/x-www-form-urlencoded', + 'multipart/form-data', + )) + parser_classes = getattr(view, 'parser_classes', []) + for parser_class in parser_classes: + media_type = getattr(parser_class, 'media_type', None) + if media_type in supported_media_types: + return media_type + # Raw binary uploads are supported with "application/octet-stream" + if media_type == '*/*': + return 'application/octet-stream' + + return None def get_path_fields(self, path, method, callback, view): """ @@ -211,6 +258,13 @@ def get_serializer_fields(self, path, method, callback, view): serializer_class = view.get_serializer_class() serializer = serializer_class() + + if isinstance(serializer, serializers.ListSerializer): + return coreapi.Field(name='data', location='body', required=True) + + if not isinstance(serializer, serializers.Serializer): + return [] + for field in serializer.fields.values(): if field.read_only: continue @@ -231,7 +285,7 @@ def get_pagination_fields(self, path, method, callback, view): return [] paginator = view.pagination_class() - return paginator.get_fields(view) + return as_query_fields(paginator.get_fields(view)) def get_filter_fields(self, path, method, callback, view): if method != 'GET': @@ -245,5 +299,5 @@ def get_filter_fields(self, path, method, callback, view): fields = [] for filter_backend in view.filter_backends: - fields += filter_backend().get_fields(view) + fields += as_query_fields(filter_backend().get_fields(view)) return fields diff --git a/rest_framework/utils/encoders.py b/rest_framework/utils/encoders.py index f883b49257..e5b52ea5f9 100644 --- a/rest_framework/utils/encoders.py +++ b/rest_framework/utils/encoders.py @@ -13,7 +13,7 @@ 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 @@ def default(self, obj): 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) diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000000..0341d0df52 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,137 @@ +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') +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) From e78753dd0c659bb31613b99d8f5107f659af6744 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 22 Jun 2016 14:20:05 +0100 Subject: [PATCH 24/31] Only set 'encoding' if a 'form' or 'body' field exists --- rest_framework/schemas.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rest_framework/schemas.py b/rest_framework/schemas.py index c2d2762501..cf84aca74c 100644 --- a/rest_framework/schemas.py +++ b/rest_framework/schemas.py @@ -197,7 +197,7 @@ def get_link(self, path, method, callback): fields += self.get_pagination_fields(path, method, callback, view) fields += self.get_filter_fields(path, method, callback, view) - if fields: + if fields and any([field.location in ('form', 'body') for field in fields]): encoding = self.get_encoding(path, method, callback, view) else: encoding = None @@ -213,9 +213,6 @@ def get_encoding(self, path, method, callback, view): """ Return the 'encoding' parameter to use for a given endpoint. """ - if method not in set(('POST', 'PUT', 'PATCH')): - return None - # Core API supports the following request encodings over HTTP... supported_media_types = set(( 'application/json', From 84bb5ea4fffe059677f32c09f21e05f907ead7df Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 24 Jun 2016 11:23:31 +0100 Subject: [PATCH 25/31] Do not use schmea in tests if coreapi is not installed --- tests/test_schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 0341d0df52..7d3308ed95 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -31,7 +31,7 @@ class ExampleViewSet(ModelViewSet): serializer_class = ExampleSerializer -router = DefaultRouter(schema_title='Example API') +router = DefaultRouter(schema_title='Example API' if coreapi else None) router.register('example', ExampleViewSet, base_name='example') urlpatterns = [ url(r'^', include(router.urls)) From 63e84676a42f62c30c6c1d257784d14db7028fa0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 29 Jun 2016 17:22:10 +0100 Subject: [PATCH 26/31] Inital pass at API client docs --- docs/index.md | 2 + docs/topics/api-clients.md | 164 +++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 167 insertions(+) create mode 100644 docs/topics/api-clients.md diff --git a/docs/index.md b/docs/index.md index fbd1fe3e77..79dca62896 100644 --- a/docs/index.md +++ b/docs/index.md @@ -204,6 +204,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] @@ -324,6 +325,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 diff --git a/docs/topics/api-clients.md b/docs/topics/api-clients.md new file mode 100644 index 0000000000..20557977fa --- /dev/null +++ b/docs/topics/api-clients.md @@ -0,0 +1,164 @@ +# API Clients + +An API client handles the underlying details of how network requests are made +and how responses are decoded. They present the developer with an application +interface to work against, rather than working directly with the network interface. + +The API clients documented here are not restricted to REST framework APIs, +and *can be used with any API that exposes a supported schema format*. + +## Client-side Core API + +Core API is a document specification that can be used to describe APIs. It can +be used either server-side, as is done with REST framework's Schema generation, +or used client-side, as described here + +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. + +Using a dynamically driven client has a number of advantages over interacting +with an API by building HTTP requests directly. + +#### More meaningful interaction + +API interactions are presented in a more meaningful way. You're working at +the application interface layer, rather than the network interface layer. + +#### Resilience & evolvability + +The client determines what endpoints are available, what parameters exist +against each particular endpoint, and how HTTP requests are formed. + +This also allows for a degree of API evolvability. URLs can be modified +without breaking existing clients, or more efficient encodings can be used +on-the-wire, with clients transparently upgrading. + +#### Self-descriptive APIs + +A dynamically driven client is able to present documentation on the API to the +end user. This documentation allows the user to discover the available endpoints +and parameters, and better understand the API they are working with. + +Because this documentation is driven by the API schema it will always be fully +up to date with the most recently deployed version of the service. + +--- + +# Command line client + +The command line client allows you to inspect and interact with any API that +exposes a supported schema format. + +## Getting started + +To install the Core API command line client, use pip. + + pip install coreapi + +**TODO** + + coreapi get http://api.example.org/ + +To interact with the API, use the `action` command. This command requires a list +of keys that are used to index into the link. + + coreapi action users list + +Some actions may include optional or required parameters. + + coreapi action users create --params username example + +To inspect the underlying HTTP request and response, use the `--debug` flag. + + coreapi action users create --params username example --debug + +To see some brief documentation on a particular link, use the `describe` command, +passing a list of keys that index into the link. + + coreapi describe users create + +**TODO**: + +* string params / data params +* file uploads +* file downloads + +## Authentication & headers + +The `credentials` command is used to manage the request `Authentication:` header. +Any credentials added are always linked to a particular domain, so as to ensure +that credentials are not leaked across differing APIs. + +The format for adding a new credential is: + + coreapi credentials add + +For instance: + + coreapi credentials add api.example.org "Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b" + +The optional `--auth` flag also allows you to add specific types of authentication, +handling the encoding for you. Currently only `"basic"` is supported as an option here. +For example: + + coreapi credentials add api.example.org tomchristie:foobar --auth basic + +You can also add specific request headers, using the `headers` command: + + coreapi headers add api.example.org x-api-version 2 + +For more information and a listing of the available subcommands use `coreapi +credentials --help` or `coreapi headers --help`. + +## Utilities + +The command line client includes functionality for bookmarking API URLs +under a memorable name. For example, you can add a bookmark for the +existing API, like so... + + coreapi bookmarks add accountmanagement + +There is also functionality for navigating forward or backward through the +history of which API URLs have been accessed. + +For more information and a listing of the available subcommands use +`coreapi bookmarks --help` or `coreapi history --help`. + +## Other commands + +**TODO** + +To display the current `Document`, use the `show` command. + + coreapi show + +To reload the current `Document` from the network, use `reload`. + + coreapi reload + + + load + +To remove the current document, history, credentials, headers and bookmarks, use `clear`: + + coreapi clear + +--- + +# Python client library + +The `coreapi` Python package allows you to programatically interact with any +API that exposes a supported schema format. + +## Getting started + + client = coreapi.Client() + schema = client.get('http://...') + + client.action(schema, ['users', 'list']) + client.action(schema, ['users', 'list'], params={"page": 2}) + +## Codecs + +## Transports diff --git a/mkdocs.yml b/mkdocs.yml index 5fb64db5a3..b10fbefb54 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,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' From bdbcb3341b02d055a86205c142e55cfadcd64911 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 29 Jun 2016 17:23:46 +0100 Subject: [PATCH 27/31] Inital pass at API client docs --- docs/topics/api-clients.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/topics/api-clients.md b/docs/topics/api-clients.md index 20557977fa..4c03c236e8 100644 --- a/docs/topics/api-clients.md +++ b/docs/topics/api-clients.md @@ -122,6 +122,9 @@ existing API, like so... There is also functionality for navigating forward or backward through the history of which API URLs have been accessed. + coreapi history show + coreapi history back + For more information and a listing of the available subcommands use `coreapi bookmarks --help` or `coreapi history --help`. @@ -137,6 +140,7 @@ To reload the current `Document` from the network, use `reload`. coreapi reload +To load a schema file from disk. load From 7236af32f8993abb642cf36f0e9b1dd4facbeb53 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 30 Jun 2016 11:46:40 +0100 Subject: [PATCH 28/31] More work towards client documentation --- docs/topics/api-clients.md | 87 +++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/docs/topics/api-clients.md b/docs/topics/api-clients.md index 4c03c236e8..2e354e2c45 100644 --- a/docs/topics/api-clients.md +++ b/docs/topics/api-clients.md @@ -56,10 +56,13 @@ To install the Core API command line client, use pip. pip install coreapi -**TODO** +To start inspecting and interacting with an API the schema must be loaded. coreapi get http://api.example.org/ +This will then load the schema, displaying the resulting `Document`. This +`Document` includes all the available interactions that may be made against the API. + To interact with the API, use the `action` command. This command requires a list of keys that are used to index into the link. @@ -130,21 +133,20 @@ For more information and a listing of the available subcommands use ## Other commands -**TODO** - -To display the current `Document`, use the `show` command. +To display the current `Document`: coreapi show -To reload the current `Document` from the network, use `reload`. +To reload the current `Document` from the network: coreapi reload -To load a schema file from disk. +To load a schema file from disk: - load + coreapi load my-api-schema.json --format corejson -To remove the current document, history, credentials, headers and bookmarks, use `clear`: +To remove the current document, along with all currently saved history, +credentials, headers and bookmarks: coreapi clear @@ -157,12 +159,75 @@ API that exposes a supported schema format. ## Getting started +You'll need to install the `coreapi` package using `pip` before you can get +started. Once you've done so, open up a python terminal. + +In order to start working with an API, we first need a `Client` instance. The +client holds any configuration around which codecs and transports are supported +when interacting with an API, which allows you to provide for more advanced +kinds of behaviour. + + import coreapi client = coreapi.Client() - schema = client.get('http://...') - client.action(schema, ['users', 'list']) - client.action(schema, ['users', 'list'], params={"page": 2}) +Once we have a `Client` instance, we can fetch an API schema from the network. + + schema = client.get('https://api.example.org/') + +The object returned from this call will be a `Document` instance, which is +the internal representation of the interface that we are interacting with. + +Now that we have our schema `Document`, we can now start to interact with the API: + + users = client.action(schema, ['users', 'list']) + +Some endpoints may include named parameters, which might be either optional or required: + + new_user = client.action(schema, ['users', 'create'], params={"username": "max"}) + +**TODO**: *file uploads*, *describe/help?* ## Codecs +Codecs are responsible for encoding or decoding Documents. + +The decoding process is used by a client to take a bytestring of an API schema +definition, and returning the Core API `Document` that represents that interface. + +A codec should be associated with a particular media type, such as **TODO**. + +This media type is used by the server in the response `Content-Type` header, +in order to indicate what kind of data is being returned in the response. + +#### Configuring codecs + +**TODO** + +#### Loading and saving schemas + +You can use a codec directly, in order to load an existing schema definition, +and return the resulting `Document`. + + schema_definition = open('my-api-schema.json', 'r').read() + codec = codecs.CoreJSONCodec() + schema = codec.load(schema_definition) + +You can also use a codec directly to generate a schema definition given a `Document` instance: + + schema_definition = codec.dump(schema) + output_file = open('my-api-schema.json', 'r') + output_file.write(schema_definition) + +#### Writing custom codecs + ## Transports + +**TODO** + +#### Configuring transports + +**TODO** + +#### Writing custom transports + +**TODO** From 89540ab92dcee0c9c098bdcc86bfdb18b827bacb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 4 Jul 2016 13:52:23 +0100 Subject: [PATCH 29/31] Add coreapi to optional packages list --- docs/index.md | 2 ++ docs/topics/api-clients.md | 70 ++++++++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/docs/index.md b/docs/index.md index 79dca62896..bd75029b7b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -68,6 +68,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. @@ -273,6 +274,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [mozilla]: http://www.mozilla.org/en-US/about/ [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 diff --git a/docs/topics/api-clients.md b/docs/topics/api-clients.md index 2e354e2c45..e91b0dddab 100644 --- a/docs/topics/api-clients.md +++ b/docs/topics/api-clients.md @@ -52,13 +52,29 @@ exposes a supported schema format. ## Getting started -To install the Core API command line client, use pip. - - pip install coreapi - -To start inspecting and interacting with an API the schema must be loaded. - - coreapi get http://api.example.org/ +To install the Core API command line client, use `pip`. + + $ pip install coreapi + +To start inspecting and interacting with an API the schema must first be loaded +from the network. + + + $ coreapi get http://api.example.org/ + + snippets: { + create(code, [title], [linenos], [language], [style]) + destroy(pk) + highlight(pk) + list([page]) + partial_update(pk, [title], [code], [linenos], [language], [style]) + retrieve(pk) + update(pk, code, [title], [linenos], [language], [style]) + } + users: { + list([page]) + retrieve(pk) + } This will then load the schema, displaying the resulting `Document`. This `Document` includes all the available interactions that may be made against the API. @@ -66,20 +82,46 @@ This will then load the schema, displaying the resulting `Document`. This To interact with the API, use the `action` command. This command requires a list of keys that are used to index into the link. - coreapi action users list + $ coreapi action users list + [ + { + "url": "http://127.0.0.1:8000/users/2/", + "id": 2, + "username": "aziz", + "snippets": [] + }, + ... + ] -Some actions may include optional or required parameters. +To inspect the underlying HTTP request and response, use the `--debug` flag. - coreapi action users create --params username example + $ coreapi action users list --debug + > GET /users/ HTTP/1.1 + > Accept: application/vnd.coreapi+json, */* + > Authorization: Basic bWF4Om1heA== + > Host: 127.0.0.1 + > User-Agent: coreapi + < 200 OK + < Allow: GET, HEAD, OPTIONS + < Content-Type: application/json + < Date: Thu, 30 Jun 2016 10:51:46 GMT + < Server: WSGIServer/0.1 Python/2.7.10 + < Vary: Accept, Cookie + < + < [{"url":"http://127.0.0.1/users/2/","id":2,"username":"aziz","snippets":[]},{"url":"http://127.0.0.1/users/3/","id":3,"username":"amy","snippets":["http://127.0.0.1/snippets/3/"]},{"url":"http://127.0.0.1/users/4/","id":4,"username":"max","snippets":["http://127.0.0.1/snippets/4/","http://127.0.0.1/snippets/5/","http://127.0.0.1/snippets/6/","http://127.0.0.1/snippets/7/"]},{"url":"http://127.0.0.1/users/5/","id":5,"username":"jose","snippets":[]},{"url":"http://127.0.0.1/users/6/","id":6,"username":"admin","snippets":["http://127.0.0.1/snippets/1/","http://127.0.0.1/snippets/2/"]}] + + [ + ... + ] -To inspect the underlying HTTP request and response, use the `--debug` flag. +Some actions may include optional or required parameters. - coreapi action users create --params username example --debug + $ coreapi action users create --params username example To see some brief documentation on a particular link, use the `describe` command, passing a list of keys that index into the link. - coreapi describe users create + $ coreapi describe users create **TODO**: @@ -185,8 +227,6 @@ Some endpoints may include named parameters, which might be either optional or r new_user = client.action(schema, ['users', 'create'], params={"username": "max"}) -**TODO**: *file uploads*, *describe/help?* - ## Codecs Codecs are responsible for encoding or decoding Documents. From e3ced75bb4affb6f174167e1925cc5e85289c335 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 4 Jul 2016 16:17:42 +0100 Subject: [PATCH 30/31] Clean up API clients docs --- docs/topics/api-clients.md | 67 +++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/docs/topics/api-clients.md b/docs/topics/api-clients.md index e91b0dddab..5f09c2a8f2 100644 --- a/docs/topics/api-clients.md +++ b/docs/topics/api-clients.md @@ -4,14 +4,18 @@ An API client handles the underlying details of how network requests are made and how responses are decoded. They present the developer with an application interface to work against, rather than working directly with the network interface. -The API clients documented here are not restricted to REST framework APIs, -and *can be used with any API that exposes a supported schema format*. +The API clients documented here are not restricted to APIs built with Django REST framework. + They can be used with any API that exposes a supported schema format. + +For example, [the Heroku platform API][heroku-api] exposes a schema in the JSON +Hyperschema format. As a result, the Core API command line client and Python +client library can be [used to interact with the Heroku API][heroku-example]. ## Client-side Core API -Core API is a document specification that can be used to describe APIs. It can -be used either server-side, as is done with REST framework's Schema generation, -or used client-side, as described here +[Core API][core-api] is a document specification that can be used to describe APIs. It can +be used either server-side, as is done with REST framework's [schema generation][schema-generation], +or used client-side, as described here. 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 @@ -118,17 +122,6 @@ Some actions may include optional or required parameters. $ coreapi action users create --params username example -To see some brief documentation on a particular link, use the `describe` command, -passing a list of keys that index into the link. - - $ coreapi describe users create - -**TODO**: - -* string params / data params -* file uploads -* file downloads - ## Authentication & headers The `credentials` command is used to manage the request `Authentication:` header. @@ -241,7 +234,18 @@ in order to indicate what kind of data is being returned in the response. #### Configuring codecs -**TODO** +The codecs that are available can be configured when instantiating a client. +The keyword argument used here is `decoders`, because in the context of a +client the codecs are only for *decoding* responses. + +In the following example we'll configure a client to only accept `Core JSON` +and `JSON` responses. This will allow us to receive and decode a Core JSON schema, +and subsequently to receive JSON responses made against the API. + + from coreapi import codecs, Client + + decoders = [codecs.CoreJSONCodec(), codecs.JSONCodec()] + client = Client(decoders=decoders) #### Loading and saving schemas @@ -258,16 +262,33 @@ You can also use a codec directly to generate a schema definition given a `Docum output_file = open('my-api-schema.json', 'r') output_file.write(schema_definition) -#### Writing custom codecs - ## Transports -**TODO** +Transports are responsible for making network requests. The set of transports +that a client has installed determines which network protocols it is able to +support. + +Currently the `coreapi` library only includes an HTTP/HTTPS transport, but +other protocols can also be supported. #### Configuring transports -**TODO** +The behaviour of the network layer can be customized by configuring the +transports that the client is instantiated with. + + import requests + from coreapi import transports, Client + + credentials = {'api.example.org': 'Token 3bd44a009d16ff'} + transports = transports.HTTPTransport(credentials=credentials) + client = Client(transports=transports) -#### Writing custom transports +More complex customizations can also be achieved, for example modifying the +underlying `requests.Session` instance to [attach transport adaptors][transport-adaptors] +that modify the outgoing requests. -**TODO** +[heroku-api]: https://devcenter.heroku.com/categories/platform-api +[heroku-example]: http://www.coreapi.org/tools-and-resources/example-services/#heroku-json-hyper-schema +[core-api]: http://www.coreapi.org/ +[schema-generation]: ../api-guide/schemas.md +[transport-adaptors]: http://docs.python-requests.org/en/master/user/advanced/#transport-adapters From 12be5b391f25838896aaf344a57b60d5ed323ccb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 4 Jul 2016 16:24:36 +0100 Subject: [PATCH 31/31] Resolve typo --- docs/api-guide/schemas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 69850c430c..9fa1ba2e3a 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -109,7 +109,7 @@ simply by adding a `schema_title` argument to the router. router = DefaultRouter(schema_title='Server Monitoring API') -The schema will be included in by the root URL, `/`, and presented to clients +The schema will be included at the root URL, `/`, and presented to clients that include the Core JSON media type in their `Accept` header. $ http http://127.0.0.1:8000/ Accept:application/vnd.coreapi+json