From 18aebbbe01a86d7d6d7638364a428c67f8374fd8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 10 Oct 2016 11:48:24 +0100 Subject: [PATCH] Added SCHEMA_COERCE_PATH_PK and SCHEMA_COERCE_METHOD_NAMES --- docs/api-guide/settings.md | 22 ++++++++++++++++ docs/tutorial/1-serialization.md | 14 +++++----- docs/tutorial/2-requests-and-responses.md | 8 +++--- docs/tutorial/3-class-based-views.md | 18 ++++++------- .../4-authentication-and-permissions.md | 4 +-- .../5-relationships-and-hyperlinked-apis.md | 14 +++++----- docs/tutorial/6-viewsets-and-routers.md | 6 ++--- .../7-schemas-and-client-libraries.md | 26 +++++++++---------- rest_framework/schemas.py | 24 ++++++++++++++--- rest_framework/settings.py | 7 +++++ tests/test_schemas.py | 14 +++++----- 11 files changed, 102 insertions(+), 55 deletions(-) diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md index 67d317839..58ceeeeb4 100644 --- a/docs/api-guide/settings.md +++ b/docs/api-guide/settings.md @@ -234,6 +234,28 @@ Default: --- +## Schema generation controls + +#### SCHEMA_COERCE_PATH_PK + +If set, this maps the `'pk'` identifier in the URL conf onto the actual field +name when generating a schema path parameter. Typically this will be `'id'`. +This gives a more suitable representation as "primary key" is an implementation +detail, wheras "identifier" is a more general concept. + +Default: `True` + +#### SCHEMA_COERCE_METHOD_NAMES + +If set, this is used to map internal viewset method names onto external action +names used in the schema generation. This allows us to generate names that +are more suitable for an external representation than those that are used +internally in the codebase. + +Default: `{'retrieve': 'read', 'destroy': 'delete'}` + +--- + ## Content type controls #### URL_FORMAT_OVERRIDE diff --git a/docs/tutorial/1-serialization.md b/docs/tutorial/1-serialization.md index 87856e037..434072e11 100644 --- a/docs/tutorial/1-serialization.md +++ b/docs/tutorial/1-serialization.md @@ -88,7 +88,7 @@ The first thing we need to get started on our Web API is to provide a way of ser class SnippetSerializer(serializers.Serializer): - pk = serializers.IntegerField(read_only=True) + id = serializers.IntegerField(read_only=True) title = serializers.CharField(required=False, allow_blank=True, max_length=100) code = serializers.CharField(style={'base_template': 'textarea.html'}) linenos = serializers.BooleanField(required=False) @@ -144,13 +144,13 @@ We've now got a few snippet instances to play with. Let's take a look at serial serializer = SnippetSerializer(snippet) serializer.data - # {'pk': 2, 'title': u'', 'code': u'print "hello, world"\n', 'linenos': False, 'language': u'python', 'style': u'friendly'} + # {'id': 2, 'title': u'', 'code': u'print "hello, world"\n', 'linenos': False, 'language': u'python', 'style': u'friendly'} At this point we've translated the model instance into Python native datatypes. To finalize the serialization process we render the data into `json`. content = JSONRenderer().render(serializer.data) content - # '{"pk": 2, "title": "", "code": "print \\"hello, world\\"\\n", "linenos": false, "language": "python", "style": "friendly"}' + # '{"id": 2, "title": "", "code": "print \\"hello, world\\"\\n", "linenos": false, "language": "python", "style": "friendly"}' Deserialization is similar. First we parse a stream into Python native datatypes... @@ -175,7 +175,7 @@ We can also serialize querysets instead of model instances. To do so we simply serializer = SnippetSerializer(Snippet.objects.all(), many=True) serializer.data - # [OrderedDict([('pk', 1), ('title', u''), ('code', u'foo = "bar"\n'), ('linenos', False), ('language', 'python'), ('style', 'friendly')]), OrderedDict([('pk', 2), ('title', u''), ('code', u'print "hello, world"\n'), ('linenos', False), ('language', 'python'), ('style', 'friendly')]), OrderedDict([('pk', 3), ('title', u''), ('code', u'print "hello, world"'), ('linenos', False), ('language', 'python'), ('style', 'friendly')])] + # [OrderedDict([('id', 1), ('title', u''), ('code', u'foo = "bar"\n'), ('linenos', False), ('language', 'python'), ('style', 'friendly')]), OrderedDict([('id', 2), ('title', u''), ('code', u'print "hello, world"\n'), ('linenos', False), ('language', 'python'), ('style', 'friendly')]), OrderedDict([('id', 3), ('title', u''), ('code', u'print "hello, world"'), ('linenos', False), ('language', 'python'), ('style', 'friendly')])] ## Using ModelSerializers @@ -259,12 +259,12 @@ Note that because we want to be able to POST to this view from clients that won' We'll also need a view which corresponds to an individual snippet, and can be used to retrieve, update or delete the snippet. @csrf_exempt - def snippet_detail(request, pk): + def snippet_detail(request, id): """ Retrieve, update or delete a code snippet. """ try: - snippet = Snippet.objects.get(pk=pk) + snippet = Snippet.objects.get(id=id) except Snippet.DoesNotExist: return HttpResponse(status=404) @@ -291,7 +291,7 @@ Finally we need to wire these views up. Create the `snippets/urls.py` file: urlpatterns = [ url(r'^snippets/$', views.snippet_list), - url(r'^snippets/(?P[0-9]+)/$', views.snippet_detail), + url(r'^snippets/(?P[0-9]+)/$', views.snippet_detail), ] We also need to wire up the root urlconf, in the `tutorial/urls.py` file, to include our snippet app's URLs. diff --git a/docs/tutorial/2-requests-and-responses.md b/docs/tutorial/2-requests-and-responses.md index 5c020a1f7..4aa0062a3 100644 --- a/docs/tutorial/2-requests-and-responses.md +++ b/docs/tutorial/2-requests-and-responses.md @@ -66,12 +66,12 @@ Our instance view is an improvement over the previous example. It's a little mo Here is the view for an individual snippet, in the `views.py` module. @api_view(['GET', 'PUT', 'DELETE']) - def snippet_detail(request, pk): + def snippet_detail(request, id): """ Retrieve, update or delete a snippet instance. """ try: - snippet = Snippet.objects.get(pk=pk) + snippet = Snippet.objects.get(id=id) except Snippet.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) @@ -104,7 +104,7 @@ Start by adding a `format` keyword argument to both of the views, like so. and - def snippet_detail(request, pk, format=None): + def snippet_detail(request, id, format=None): Now update the `urls.py` file slightly, to append a set of `format_suffix_patterns` in addition to the existing URLs. @@ -114,7 +114,7 @@ Now update the `urls.py` file slightly, to append a set of `format_suffix_patter urlpatterns = [ url(r'^snippets/$', views.snippet_list), - url(r'^snippets/(?P[0-9]+)$', views.snippet_detail), + url(r'^snippets/(?P[0-9]+)$', views.snippet_detail), ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/docs/tutorial/3-class-based-views.md b/docs/tutorial/3-class-based-views.md index f018666f5..6303994cd 100644 --- a/docs/tutorial/3-class-based-views.md +++ b/docs/tutorial/3-class-based-views.md @@ -36,27 +36,27 @@ So far, so good. It looks pretty similar to the previous case, but we've got be """ Retrieve, update or delete a snippet instance. """ - def get_object(self, pk): + def get_object(self, id): try: - return Snippet.objects.get(pk=pk) + return Snippet.objects.get(id=id) except Snippet.DoesNotExist: raise Http404 - def get(self, request, pk, format=None): - snippet = self.get_object(pk) + def get(self, request, id, format=None): + snippet = self.get_object(id) serializer = SnippetSerializer(snippet) return Response(serializer.data) - def put(self, request, pk, format=None): - snippet = self.get_object(pk) + def put(self, request, id, format=None): + snippet = self.get_object(id) serializer = SnippetSerializer(snippet, data=request.data) if serializer.is_valid(): serializer.save() return Response(serializer.data) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, pk, format=None): - snippet = self.get_object(pk) + def delete(self, request, id, format=None): + snippet = self.get_object(id) snippet.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -70,7 +70,7 @@ We'll also need to refactor our `urls.py` slightly now we're using class-based v urlpatterns = [ url(r'^snippets/$', views.SnippetList.as_view()), - url(r'^snippets/(?P[0-9]+)/$', views.SnippetDetail.as_view()), + url(r'^snippets/(?P[0-9]+)/$', views.SnippetDetail.as_view()), ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/docs/tutorial/4-authentication-and-permissions.md b/docs/tutorial/4-authentication-and-permissions.md index d69c38552..958f9d3f0 100644 --- a/docs/tutorial/4-authentication-and-permissions.md +++ b/docs/tutorial/4-authentication-and-permissions.md @@ -88,7 +88,7 @@ Make sure to also import the `UserSerializer` class Finally we need to add those views into the API, by referencing them from the URL conf. Add the following to the patterns in `urls.py`. url(r'^users/$', views.UserList.as_view()), - url(r'^users/(?P[0-9]+)/$', views.UserDetail.as_view()), + url(r'^users/(?P[0-9]+)/$', views.UserDetail.as_view()), ## Associating Snippets with Users @@ -150,7 +150,7 @@ The `r'^api-auth/'` part of pattern can actually be whatever URL you want to use Now if you open up the browser again and refresh the page you'll see a 'Login' link in the top right of the page. If you log in as one of the users you created earlier, you'll be able to create code snippets again. -Once you've created a few code snippets, navigate to the '/users/' endpoint, and notice that the representation includes a list of the snippet pks that are associated with each user, in each user's 'snippets' field. +Once you've created a few code snippets, navigate to the '/users/' endpoint, and notice that the representation includes a list of the snippet ids that are associated with each user, in each user's 'snippets' field. ## Object level permissions diff --git a/docs/tutorial/5-relationships-and-hyperlinked-apis.md b/docs/tutorial/5-relationships-and-hyperlinked-apis.md index 8cda09c62..9fb6c53e0 100644 --- a/docs/tutorial/5-relationships-and-hyperlinked-apis.md +++ b/docs/tutorial/5-relationships-and-hyperlinked-apis.md @@ -48,7 +48,7 @@ We'll add a url pattern for our new API root in `snippets/urls.py`: And then add a url pattern for the snippet highlights: - url(r'^snippets/(?P[0-9]+)/highlight/$', views.SnippetHighlight.as_view()), + url(r'^snippets/(?P[0-9]+)/highlight/$', views.SnippetHighlight.as_view()), ## Hyperlinking our API @@ -67,7 +67,7 @@ In this case we'd like to use a hyperlinked style between entities. In order to The `HyperlinkedModelSerializer` has the following differences from `ModelSerializer`: -* It does not include the `pk` field by default. +* It does not include the `id` field by default. * It includes a `url` field, using `HyperlinkedIdentityField`. * Relationships use `HyperlinkedRelatedField`, instead of `PrimaryKeyRelatedField`. @@ -80,7 +80,7 @@ We can easily re-write our existing serializers to use hyperlinking. In your `sn class Meta: model = Snippet - fields = ('url', 'pk', 'highlight', 'owner', + fields = ('url', 'id', 'highlight', 'owner', 'title', 'code', 'linenos', 'language', 'style') @@ -89,7 +89,7 @@ We can easily re-write our existing serializers to use hyperlinking. In your `sn class Meta: model = User - fields = ('url', 'pk', 'username', 'snippets') + fields = ('url', 'id', 'username', 'snippets') Notice that we've also added a new `'highlight'` field. This field is of the same type as the `url` field, except that it points to the `'snippet-highlight'` url pattern, instead of the `'snippet-detail'` url pattern. @@ -116,16 +116,16 @@ After adding all those names into our URLconf, our final `snippets/urls.py` file url(r'^snippets/$', views.SnippetList.as_view(), name='snippet-list'), - url(r'^snippets/(?P[0-9]+)/$', + url(r'^snippets/(?P[0-9]+)/$', views.SnippetDetail.as_view(), name='snippet-detail'), - url(r'^snippets/(?P[0-9]+)/highlight/$', + url(r'^snippets/(?P[0-9]+)/highlight/$', views.SnippetHighlight.as_view(), name='snippet-highlight'), url(r'^users/$', views.UserList.as_view(), name='user-list'), - url(r'^users/(?P[0-9]+)/$', + url(r'^users/(?P[0-9]+)/$', views.UserDetail.as_view(), name='user-detail') ]) diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md index 00152cc17..6e1321093 100644 --- a/docs/tutorial/6-viewsets-and-routers.md +++ b/docs/tutorial/6-viewsets-and-routers.md @@ -92,10 +92,10 @@ Now that we've bound our resources into concrete views, we can register the view urlpatterns = format_suffix_patterns([ url(r'^$', api_root), url(r'^snippets/$', snippet_list, name='snippet-list'), - url(r'^snippets/(?P[0-9]+)/$', snippet_detail, name='snippet-detail'), - url(r'^snippets/(?P[0-9]+)/highlight/$', snippet_highlight, name='snippet-highlight'), + url(r'^snippets/(?P[0-9]+)/$', snippet_detail, name='snippet-detail'), + url(r'^snippets/(?P[0-9]+)/highlight/$', snippet_highlight, name='snippet-highlight'), url(r'^users/$', user_list, name='user-list'), - url(r'^users/(?P[0-9]+)/$', user_detail, name='user-detail') + url(r'^users/(?P[0-9]+)/$', user_detail, name='user-detail') ]) ## Using Routers diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md index 3d4d2c941..705b79da6 100644 --- a/docs/tutorial/7-schemas-and-client-libraries.md +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -101,13 +101,13 @@ First we'll load the API schema using the command line client. $ coreapi get http://127.0.0.1:8000/schema/ snippets: { - highlight(pk) + highlight(id) list() - retrieve(pk) + read(id) } users: { list() - retrieve(pk) + read(id) } We haven't authenticated yet, so right now we're only able to see the read only @@ -119,7 +119,7 @@ Let's try listing the existing snippets, using the command line client: [ { "url": "http://127.0.0.1:8000/snippets/1/", - "pk": 1, + "id": 1, "highlight": "http://127.0.0.1:8000/snippets/1/highlight/", "owner": "lucy", "title": "Example", @@ -133,7 +133,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 highlight HTML for a particular snippet we need to provide an id. - $ coreapi action snippets highlight --param pk=1 + $ coreapi action snippets highlight --param id=1 @@ -160,16 +160,16 @@ set of available interactions. Pastebin API "http://127.0.0.1:8000/schema/"> snippets: { create(code, [title], [linenos], [language], [style]) - destroy(pk) - highlight(pk) + delete(id) + highlight(id) list() - partial_update(pk, [title], [code], [linenos], [language], [style]) - retrieve(pk) - update(pk, code, [title], [linenos], [language], [style]) + partial_update(id, [title], [code], [linenos], [language], [style]) + read(id) + update(id, code, [title], [linenos], [language], [style]) } users: { list() - retrieve(pk) + read(id) } We're now able to interact with these endpoints. For example, to create a new @@ -178,7 +178,7 @@ snippet: $ coreapi action snippets create --param title="Example" --param code="print('hello, world')" { "url": "http://127.0.0.1:8000/snippets/7/", - "pk": 7, + "id": 7, "highlight": "http://127.0.0.1:8000/snippets/7/highlight/", "owner": "lucy", "title": "Example", @@ -190,7 +190,7 @@ snippet: And to delete a snippet: - $ coreapi action snippets destroy --param pk=7 + $ coreapi action snippets delete --param id=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 diff --git a/rest_framework/schemas.py b/rest_framework/schemas.py index ac81782a9..12119adfc 100644 --- a/rest_framework/schemas.py +++ b/rest_framework/schemas.py @@ -194,7 +194,7 @@ class EndpointInspector(object): class SchemaGenerator(object): - # Map methods onto 'actions' that are the names used in the link layout. + # Map HTTP methods onto actions. default_mapping = { 'get': 'retrieve', 'post': 'create', @@ -203,9 +203,16 @@ class SchemaGenerator(object): 'delete': 'destroy', } endpoint_inspector_cls = EndpointInspector + + # Map the method names we use for viewset actions onto external schema names. + # These give us names that are more suitable for the external representation. + # Set by 'SCHEMA_COERCE_METHOD_NAMES'. + coerce_method_names = None + # 'pk' isn't great as an externally exposed name for an identifier, # so by default we prefer to use the actual model field name for schemas. - coerce_pk = True + # Set by 'SCHEMA_COERCE_PATH_PK'. + coerce_path_pk = None def __init__(self, title=None, url=None, patterns=None, urlconf=None): assert coreapi, '`coreapi` must be installed for schema support.' @@ -213,6 +220,9 @@ class SchemaGenerator(object): if url and not url.endswith('/'): url += '/' + self.coerce_method_names = api_settings.SCHEMA_COERCE_METHOD_NAMES + self.coerce_path_pk = api_settings.SCHEMA_COERCE_PATH_PK + self.patterns = patterns self.urlconf = urlconf self.title = title @@ -339,7 +349,7 @@ class SchemaGenerator(object): where possible. This is cleaner for an external representation. (Ie. "this is an identifier", not "this is a database primary key") """ - if not self.coerce_pk or '{pk}' not in path: + if not self.coerce_path_pk or '{pk}' not in path: return path model = getattr(getattr(view, 'queryset', None), 'model', None) if model: @@ -405,6 +415,9 @@ class SchemaGenerator(object): header = getattr(view, 'action', method.lower()) if header in sections: return sections[header].strip() + if header in self.coerce_method_names: + if self.coerce_method_names[header] in sections: + return sections[self.coerce_method_names[header]].strip() return sections[''].strip() def get_encoding(self, path, method, view): @@ -541,10 +554,15 @@ class SchemaGenerator(object): # Custom action, eg "/users/{pk}/activate/", "/users/active/" if len(view.action_map) > 1: action = self.default_mapping[method.lower()] + if action in self.coerce_method_names: + action = self.coerce_method_names[action] return named_path_components + [action] else: return named_path_components[:-1] + [action] + if action in self.coerce_method_names: + action = self.coerce_method_names[action] + # Default action, eg "/users/", "/users/{pk}/" return named_path_components + [action] diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 89e27e743..6d9ed2355 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -115,6 +115,13 @@ DEFAULTS = { # Browseable API 'HTML_SELECT_CUTOFF': 1000, 'HTML_SELECT_CUTOFF_TEXT': "More than {count} items...", + + # Schemas + 'SCHEMA_COERCE_PATH_PK': True, + 'SCHEMA_COERCE_METHOD_NAMES': { + 'retrieve': 'read', + 'destroy': 'delete' + }, } diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 0a422f078..c43fc1eff 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -104,12 +104,12 @@ class TestRouterGeneratedSchema(TestCase): action='get' ), 'custom_list_action_multiple_methods': { - 'retrieve': coreapi.Link( + 'read': coreapi.Link( url='/example/custom_list_action_multiple_methods/', action='get' ) }, - 'retrieve': coreapi.Link( + 'read': coreapi.Link( url='/example/{id}/', action='get', fields=[ @@ -148,7 +148,7 @@ class TestRouterGeneratedSchema(TestCase): coreapi.Field('b', required=False, location='form', type='string') ] ), - 'retrieve': coreapi.Link( + 'read': coreapi.Link( url='/example/{id}/', action='get', fields=[ @@ -171,7 +171,7 @@ class TestRouterGeneratedSchema(TestCase): action='get' ), 'custom_list_action_multiple_methods': { - 'retrieve': coreapi.Link( + 'read': coreapi.Link( url='/example/custom_list_action_multiple_methods/', action='get' ), @@ -200,7 +200,7 @@ class TestRouterGeneratedSchema(TestCase): coreapi.Field('b', required=False, location='form', type='string') ] ), - 'destroy': coreapi.Link( + 'delete': coreapi.Link( url='/example/{id}/', action='delete', fields=[ @@ -260,7 +260,7 @@ class TestSchemaGenerator(TestCase): action='get', fields=[] ), - 'retrieve': coreapi.Link( + 'read': coreapi.Link( url='/example/{id}/', action='get', fields=[ @@ -313,7 +313,7 @@ class TestSchemaGeneratorNotAtRoot(TestCase): action='get', fields=[] ), - 'retrieve': coreapi.Link( + 'read': coreapi.Link( url='/api/v1/example/{id}/', action='get', fields=[