Commit d54df8c4 by Carlton Gibson Committed by Tom Christie

Refactor schema generation to allow per-view customisation (#5354)

* Initial Refactor Step

* Add descriptor class
* call from generator
* proxy back to generator for implementation.

* Move `get_link` to descriptor

* Move `get_description` to descriptor

* Remove need for generator in get_description

* Move get_path_fields to descriptor

* Move `get_serializer_fields` to descriptor

* Move `get_pagination_fields` to descriptor

* Move `get_filter_fields` to descriptor

* Move `get_encoding` to descriptor.

* Pass just `url` from SchemaGenerator to descriptor

* Make `view` a property

Encapsulates check for a view instance.

* Adjust API Reference docs

* Add `ManualSchema` class

* Refactor to `ViewInspector` plus `AutoSchema`

The interface then is **just** `get_link()`

* Add `manual_fields` kwarg to AutoSchema

* Add schema decorator for FBVs

* Adjust comments

* Docs: Provide full params in example

Ref feedback https://github.com/encode/django-rest-framework/pull/5354/files/b52e372f8f936204753b17fe7c9bfb517b93a045#r137254795

* Add docstring for ViewInstpector.__get__ descriptor method.

Ref https://github.com/encode/django-rest-framework/pull/5354#discussion_r137265022

* Make `schemas` a package.

* Split generators, inspectors, views.

* Adjust imports

* Rename to EndpointEnumerator

* Adjust ManualSchema to take `fields`

… and `description`.

Allows `url` and `action` to remain dynamic

* Add package/module docstrings
parent 5ea810d5
...@@ -184,6 +184,28 @@ The available decorators are: ...@@ -184,6 +184,28 @@ The available decorators are:
Each of these decorators takes a single argument which must be a list or tuple of classes. Each of these decorators takes a single argument which must be a list or tuple of classes.
## View schema decorator
To override the default schema generation for function based views you may use
the `@schema` decorator. This must come *after* (below) the `@api_view`
decorator. For example:
from rest_framework.decorators import api_view, schema
from rest_framework.schemas import AutoSchema
class CustomAutoSchema(AutoSchema):
def get_link(self, path, method, base_url):
# override view introspection here...
@api_view(['GET'])
@schema(CustomAutoSchema())
def view(request):
return Response({"message": "Hello for today! See you tomorrow!"})
This decorator takes a single `AutoSchema` instance, an `AutoSchema` subclass
instance or `ManualSchema` instance as described in the [Schemas documentation][schemas],
[cite]: http://reinout.vanrees.org/weblog/2011/08/24/class-based-views-usage.html [cite]: http://reinout.vanrees.org/weblog/2011/08/24/class-based-views-usage.html
[cite2]: http://www.boredomandlaziness.org/2012/05/djangos-cbvs-are-not-mistake-but.html [cite2]: http://www.boredomandlaziness.org/2012/05/djangos-cbvs-are-not-mistake-but.html
[settings]: settings.md [settings]: settings.md
......
...@@ -72,6 +72,9 @@ def api_view(http_method_names=None, exclude_from_schema=False): ...@@ -72,6 +72,9 @@ def api_view(http_method_names=None, exclude_from_schema=False):
WrappedAPIView.permission_classes = getattr(func, 'permission_classes', WrappedAPIView.permission_classes = getattr(func, 'permission_classes',
APIView.permission_classes) APIView.permission_classes)
WrappedAPIView.schema = getattr(func, 'schema',
APIView.schema)
WrappedAPIView.exclude_from_schema = exclude_from_schema WrappedAPIView.exclude_from_schema = exclude_from_schema
return WrappedAPIView.as_view() return WrappedAPIView.as_view()
return decorator return decorator
...@@ -112,6 +115,13 @@ def permission_classes(permission_classes): ...@@ -112,6 +115,13 @@ def permission_classes(permission_classes):
return decorator return decorator
def schema(view_inspector):
def decorator(func):
func.schema = view_inspector
return func
return decorator
def detail_route(methods=None, **kwargs): def detail_route(methods=None, **kwargs):
""" """
Used to mark a method on a ViewSet that should be routed for detail requests. Used to mark a method on a ViewSet that should be routed for detail requests.
......
...@@ -26,7 +26,8 @@ from rest_framework import views ...@@ -26,7 +26,8 @@ from rest_framework import views
from rest_framework.compat import NoReverseMatch from rest_framework.compat import NoReverseMatch
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.schemas import SchemaGenerator, SchemaView from rest_framework.schemas import SchemaGenerator
from rest_framework.schemas.views import SchemaView
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
from rest_framework.urlpatterns import format_suffix_patterns from rest_framework.urlpatterns import format_suffix_patterns
......
"""
rest_framework.schemas
schemas:
__init__.py
generators.py # Top-down schema generation
inspectors.py # Per-endpoint view introspection
utils.py # Shared helper functions
views.py # Houses `SchemaView`, `APIView` subclass.
We expose a minimal "public" API directly from `schemas`. This covers the
basic use-cases:
from rest_framework.schemas import (
AutoSchema,
ManualSchema,
get_schema_view,
SchemaGenerator,
)
Other access should target the submodules directly
"""
from .generators import SchemaGenerator
from .inspectors import AutoSchema, ManualSchema # noqa
def get_schema_view(
title=None, url=None, description=None, urlconf=None, renderer_classes=None,
public=False, patterns=None, generator_class=SchemaGenerator):
"""
Return a schema view.
"""
# Avoid import cycle on APIView
from .views import SchemaView
generator = generator_class(
title=title, url=url, description=description,
urlconf=urlconf, patterns=patterns,
)
return SchemaView.as_view(
renderer_classes=renderer_classes,
schema_generator=generator,
public=public,
)
"""
utils.py # Shared helper functions
See schemas.__init__.py for package overview.
"""
def is_list_view(path, method, view):
"""
Return True if the given path/method appears to represent a list view.
"""
if hasattr(view, 'action'):
# Viewsets have an explicitly defined action, which we can inspect.
return view.action == 'list'
if method.lower() != 'get':
return False
path_components = path.strip('/').split('/')
if path_components and '{' in path_components[-1]:
return False
return True
"""
views.py # Houses `SchemaView`, `APIView` subclass.
See schemas.__init__.py for package overview.
"""
from rest_framework import exceptions, renderers
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
class SchemaView(APIView):
_ignore_model_permissions = True
exclude_from_schema = True
renderer_classes = None
schema_generator = None
public = False
def __init__(self, *args, **kwargs):
super(SchemaView, self).__init__(*args, **kwargs)
if self.renderer_classes is None:
if renderers.BrowsableAPIRenderer in api_settings.DEFAULT_RENDERER_CLASSES:
self.renderer_classes = [
renderers.CoreJSONRenderer,
renderers.BrowsableAPIRenderer,
]
else:
self.renderer_classes = [renderers.CoreJSONRenderer]
def get(self, request, *args, **kwargs):
schema = self.schema_generator.get_schema(request, self.public)
if schema is None:
raise exceptions.PermissionDenied()
return Response(schema)
...@@ -19,6 +19,7 @@ from rest_framework import exceptions, status ...@@ -19,6 +19,7 @@ from rest_framework import exceptions, status
from rest_framework.compat import set_rollback from rest_framework.compat import set_rollback
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.schemas import AutoSchema
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
from rest_framework.utils import formatting from rest_framework.utils import formatting
...@@ -113,6 +114,7 @@ class APIView(View): ...@@ -113,6 +114,7 @@ class APIView(View):
# Mark the view as being included or excluded from schema generation. # Mark the view as being included or excluded from schema generation.
exclude_from_schema = False exclude_from_schema = False
schema = AutoSchema()
@classmethod @classmethod
def as_view(cls, **initkwargs): def as_view(cls, **initkwargs):
......
...@@ -6,12 +6,13 @@ from rest_framework import status ...@@ -6,12 +6,13 @@ from rest_framework import status
from rest_framework.authentication import BasicAuthentication from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import ( from rest_framework.decorators import (
api_view, authentication_classes, parser_classes, permission_classes, api_view, authentication_classes, parser_classes, permission_classes,
renderer_classes, throttle_classes renderer_classes, schema, throttle_classes
) )
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.schemas import AutoSchema
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from rest_framework.throttling import UserRateThrottle from rest_framework.throttling import UserRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
...@@ -151,3 +152,17 @@ class DecoratorTestCase(TestCase): ...@@ -151,3 +152,17 @@ class DecoratorTestCase(TestCase):
response = view(request) response = view(request)
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
def test_schema(self):
"""
Checks CustomSchema class is set on view
"""
class CustomSchema(AutoSchema):
pass
@api_view(['GET'])
@schema(CustomSchema())
def view(request):
return Response({})
assert isinstance(view.cls.schema, CustomSchema)
import unittest import unittest
import pytest
from django.conf.urls import include, url from django.conf.urls import include, url
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404 from django.http import Http404
...@@ -10,7 +11,9 @@ from rest_framework.compat import coreapi, coreschema ...@@ -10,7 +11,9 @@ from rest_framework.compat import coreapi, coreschema
from rest_framework.decorators import detail_route, list_route from rest_framework.decorators import detail_route, list_route
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from rest_framework.schemas import SchemaGenerator, get_schema_view from rest_framework.schemas import (
AutoSchema, ManualSchema, SchemaGenerator, get_schema_view
)
from rest_framework.test import APIClient, APIRequestFactory from rest_framework.test import APIClient, APIRequestFactory
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
...@@ -496,3 +499,81 @@ class Test4605Regression(TestCase): ...@@ -496,3 +499,81 @@ class Test4605Regression(TestCase):
'/auth/convert-token/' '/auth/convert-token/'
]) ])
assert prefix == '/' assert prefix == '/'
class TestDescriptor(TestCase):
def test_apiview_schema_descriptor(self):
view = APIView()
assert hasattr(view, 'schema')
assert isinstance(view.schema, AutoSchema)
def test_get_link_requires_instance(self):
descriptor = APIView.schema # Accessed from class
with pytest.raises(AssertionError):
descriptor.get_link(None, None, None) # ???: Do the dummy arguments require a tighter assert?
def test_manual_fields(self):
class CustomView(APIView):
schema = AutoSchema(manual_fields=[
coreapi.Field(
"my_extra_field",
required=True,
location="path",
schema=coreschema.String()
),
])
view = CustomView()
link = view.schema.get_link('/a/url/{id}/', 'GET', '')
fields = link.fields
assert len(fields) == 2
assert "my_extra_field" in [f.name for f in fields]
def test_view_with_manual_schema(self):
path = '/example'
method = 'get'
base_url = None
fields = [
coreapi.Field(
"first_field",
required=True,
location="path",
schema=coreschema.String()
),
coreapi.Field(
"second_field",
required=True,
location="path",
schema=coreschema.String()
),
coreapi.Field(
"third_field",
required=True,
location="path",
schema=coreschema.String()
),
]
description = "A test endpoint"
class CustomView(APIView):
"""
ManualSchema takes list of fields for endpoint.
- Provides url and action, which are always dynamic
"""
schema = ManualSchema(fields, description)
expected = coreapi.Link(
url=path,
action=method,
fields=fields,
description=description
)
view = CustomView()
link = view.schema.get_link(path, method, base_url)
assert link == expected
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment