import unittest import pytest from django.conf.urls import include, url from django.core.exceptions import PermissionDenied from django.http import Http404 from django.test import TestCase, override_settings from rest_framework import ( filters, generics, pagination, permissions, serializers ) from rest_framework.compat import coreapi, coreschema, get_regex_pattern from rest_framework.decorators import ( api_view, detail_route, list_route, schema ) from rest_framework.request import Request from rest_framework.routers import DefaultRouter, SimpleRouter from rest_framework.schemas import ( AutoSchema, ManualSchema, SchemaGenerator, get_schema_view ) from rest_framework.schemas.generators import EndpointEnumerator from rest_framework.schemas.utils import is_list_view from rest_framework.test import APIClient, APIRequestFactory from rest_framework.utils import formatting from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet, ModelViewSet from .models import BasicModel factory = APIRequestFactory() class MockUser(object): def is_authenticated(self): return True class ExamplePagination(pagination.PageNumberPagination): page_size = 100 page_size_query_param = 'page_size' class EmptySerializer(serializers.Serializer): pass class ExampleSerializer(serializers.Serializer): a = serializers.CharField(required=True, help_text='A field description') b = serializers.CharField(required=False) read_only = serializers.CharField(read_only=True) hidden = serializers.HiddenField(default='hello') class AnotherSerializerWithListFields(serializers.Serializer): a = serializers.ListField(child=serializers.IntegerField()) b = serializers.ListSerializer(child=serializers.CharField()) class AnotherSerializer(serializers.Serializer): c = serializers.CharField(required=True) d = serializers.CharField(required=False) class ExampleViewSet(ModelViewSet): pagination_class = ExamplePagination permission_classes = [permissions.IsAuthenticatedOrReadOnly] filter_backends = [filters.OrderingFilter] serializer_class = ExampleSerializer @detail_route(methods=['post'], serializer_class=AnotherSerializer) def custom_action(self, request, pk): """ A description of custom action. """ return super(ExampleSerializer, self).retrieve(self, request) @detail_route(methods=['post'], serializer_class=AnotherSerializerWithListFields) def custom_action_with_list_fields(self, request, pk): """ A custom action using both list field and list serializer in the serializer. """ return super(ExampleSerializer, self).retrieve(self, request) @list_route() def custom_list_action(self, request): return super(ExampleViewSet, self).list(self, request) @list_route(methods=['post', 'get'], serializer_class=EmptySerializer) def custom_list_action_multiple_methods(self, request): return super(ExampleViewSet, self).list(self, request) def get_serializer(self, *args, **kwargs): assert self.request assert self.action return super(ExampleViewSet, self).get_serializer(*args, **kwargs) if coreapi: schema_view = get_schema_view(title='Example API') else: def schema_view(request): pass router = DefaultRouter() router.register('example', ExampleViewSet, base_name='example') urlpatterns = [ url(r'^$', schema_view), 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/coreapi+json') assert response.status_code == 200 expected = coreapi.Document( url='http://testserver/', title='Example API', content={ 'example': { 'list': coreapi.Link( url='/example/', action='get', fields=[ coreapi.Field('page', required=False, location='query', schema=coreschema.Integer(title='Page', description='A page number within the paginated result set.')), coreapi.Field('page_size', required=False, location='query', schema=coreschema.Integer(title='Page size', description='Number of results to return per page.')), coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) ] ), 'custom_list_action': coreapi.Link( url='/example/custom_list_action/', action='get' ), 'custom_list_action_multiple_methods': { 'read': coreapi.Link( url='/example/custom_list_action_multiple_methods/', action='get' ) }, 'read': coreapi.Link( url='/example/{id}/', action='get', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()), coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) ] ) } } ) assert response.data == expected def test_authenticated_request(self): client = APIClient() client.force_authenticate(MockUser()) response = client.get('/', HTTP_ACCEPT='application/coreapi+json') assert response.status_code == 200 expected = coreapi.Document( url='http://testserver/', title='Example API', content={ 'example': { 'list': coreapi.Link( url='/example/', action='get', fields=[ coreapi.Field('page', required=False, location='query', schema=coreschema.Integer(title='Page', description='A page number within the paginated result set.')), coreapi.Field('page_size', required=False, location='query', schema=coreschema.Integer(title='Page size', description='Number of results to return per page.')), coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) ] ), 'create': coreapi.Link( url='/example/', action='post', encoding='application/json', fields=[ coreapi.Field('a', required=True, location='form', schema=coreschema.String(title='A', description='A field description')), coreapi.Field('b', required=False, location='form', schema=coreschema.String(title='B')) ] ), 'read': coreapi.Link( url='/example/{id}/', action='get', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()), coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) ] ), 'custom_action': coreapi.Link( url='/example/{id}/custom_action/', action='post', encoding='application/json', description='A description of custom action.', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()), coreapi.Field('c', required=True, location='form', schema=coreschema.String(title='C')), coreapi.Field('d', required=False, location='form', schema=coreschema.String(title='D')), ] ), 'custom_action_with_list_fields': coreapi.Link( url='/example/{id}/custom_action_with_list_fields/', action='post', encoding='application/json', description='A custom action using both list field and list serializer in the serializer.', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()), coreapi.Field('a', required=True, location='form', schema=coreschema.Array(title='A', items=coreschema.Integer())), coreapi.Field('b', required=True, location='form', schema=coreschema.Array(title='B', items=coreschema.String())), ] ), 'custom_list_action': coreapi.Link( url='/example/custom_list_action/', action='get' ), 'custom_list_action_multiple_methods': { 'read': coreapi.Link( url='/example/custom_list_action_multiple_methods/', action='get' ), 'create': coreapi.Link( url='/example/custom_list_action_multiple_methods/', action='post' ) }, 'update': coreapi.Link( url='/example/{id}/', action='put', encoding='application/json', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()), coreapi.Field('a', required=True, location='form', schema=coreschema.String(title='A', description=('A field description'))), coreapi.Field('b', required=False, location='form', schema=coreschema.String(title='B')), coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) ] ), 'partial_update': coreapi.Link( url='/example/{id}/', action='patch', encoding='application/json', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()), coreapi.Field('a', required=False, location='form', schema=coreschema.String(title='A', description='A field description')), coreapi.Field('b', required=False, location='form', schema=coreschema.String(title='B')), coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) ] ), 'delete': coreapi.Link( url='/example/{id}/', action='delete', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()), coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) ] ) } } ) assert response.data == expected class DenyAllUsingHttp404(permissions.BasePermission): def has_permission(self, request, view): raise Http404() def has_object_permission(self, request, view, obj): raise Http404() class DenyAllUsingPermissionDenied(permissions.BasePermission): def has_permission(self, request, view): raise PermissionDenied() def has_object_permission(self, request, view, obj): raise PermissionDenied() class Http404ExampleViewSet(ExampleViewSet): permission_classes = [DenyAllUsingHttp404] class PermissionDeniedExampleViewSet(ExampleViewSet): permission_classes = [DenyAllUsingPermissionDenied] class MethodLimitedViewSet(ExampleViewSet): permission_classes = [] http_method_names = ['get', 'head', 'options'] class ExampleListView(APIView): permission_classes = [permissions.IsAuthenticatedOrReadOnly] def get(self, *args, **kwargs): pass def post(self, request, *args, **kwargs): pass class ExampleDetailView(APIView): permission_classes = [permissions.IsAuthenticatedOrReadOnly] def get(self, *args, **kwargs): pass @unittest.skipUnless(coreapi, 'coreapi is not installed') class TestSchemaGenerator(TestCase): def setUp(self): self.patterns = [ url(r'^example/?$', ExampleListView.as_view()), url(r'^example/(?P\d+)/?$', ExampleDetailView.as_view()), url(r'^example/(?P\d+)/sub/?$', ExampleDetailView.as_view()), ] def test_schema_for_regular_views(self): """ Ensure that schema generation works for APIView classes. """ generator = SchemaGenerator(title='Example API', patterns=self.patterns) schema = generator.get_schema() expected = coreapi.Document( url='', title='Example API', content={ 'example': { 'create': coreapi.Link( url='/example/', action='post', fields=[] ), 'list': coreapi.Link( url='/example/', action='get', fields=[] ), 'read': coreapi.Link( url='/example/{id}/', action='get', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()) ] ), 'sub': { 'list': coreapi.Link( url='/example/{id}/sub/', action='get', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()) ] ) } } } ) assert schema == expected @unittest.skipUnless(coreapi, 'coreapi is not installed') class TestSchemaGeneratorNotAtRoot(TestCase): def setUp(self): self.patterns = [ url(r'^api/v1/example/?$', ExampleListView.as_view()), url(r'^api/v1/example/(?P\d+)/?$', ExampleDetailView.as_view()), url(r'^api/v1/example/(?P\d+)/sub/?$', ExampleDetailView.as_view()), ] def test_schema_for_regular_views(self): """ Ensure that schema generation with an API that is not at the URL root continues to use correct structure for link keys. """ generator = SchemaGenerator(title='Example API', patterns=self.patterns) schema = generator.get_schema() expected = coreapi.Document( url='', title='Example API', content={ 'example': { 'create': coreapi.Link( url='/api/v1/example/', action='post', fields=[] ), 'list': coreapi.Link( url='/api/v1/example/', action='get', fields=[] ), 'read': coreapi.Link( url='/api/v1/example/{id}/', action='get', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()) ] ), 'sub': { 'list': coreapi.Link( url='/api/v1/example/{id}/sub/', action='get', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()) ] ) } } } ) assert schema == expected @unittest.skipUnless(coreapi, 'coreapi is not installed') class TestSchemaGeneratorWithMethodLimitedViewSets(TestCase): def setUp(self): router = DefaultRouter() router.register('example1', MethodLimitedViewSet, base_name='example1') self.patterns = [ url(r'^', include(router.urls)) ] def test_schema_for_regular_views(self): """ Ensure that schema generation works for ViewSet classes with method limitation by Django CBV's http_method_names attribute """ generator = SchemaGenerator(title='Example API', patterns=self.patterns) request = factory.get('/example1/') schema = generator.get_schema(Request(request)) expected = coreapi.Document( url='http://testserver/example1/', title='Example API', content={ 'example1': { 'list': coreapi.Link( url='/example1/', action='get', fields=[ coreapi.Field('page', required=False, location='query', schema=coreschema.Integer(title='Page', description='A page number within the paginated result set.')), coreapi.Field('page_size', required=False, location='query', schema=coreschema.Integer(title='Page size', description='Number of results to return per page.')), coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) ] ), 'custom_list_action': coreapi.Link( url='/example1/custom_list_action/', action='get' ), 'custom_list_action_multiple_methods': { 'read': coreapi.Link( url='/example1/custom_list_action_multiple_methods/', action='get' ) }, 'read': coreapi.Link( url='/example1/{id}/', action='get', fields=[ coreapi.Field('id', required=True, location='path', schema=coreschema.String()), coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) ] ) } } ) assert schema == expected @unittest.skipUnless(coreapi, 'coreapi is not installed') class TestSchemaGeneratorWithRestrictedViewSets(TestCase): def setUp(self): router = DefaultRouter() router.register('example1', Http404ExampleViewSet, base_name='example1') router.register('example2', PermissionDeniedExampleViewSet, base_name='example2') self.patterns = [ url('^example/?$', ExampleListView.as_view()), url(r'^', include(router.urls)) ] def test_schema_for_regular_views(self): """ Ensure that schema generation works for ViewSet classes with permission classes raising exceptions. """ generator = SchemaGenerator(title='Example API', patterns=self.patterns) request = factory.get('/') schema = generator.get_schema(Request(request)) expected = coreapi.Document( url='http://testserver/', title='Example API', content={ 'example': { 'list': coreapi.Link( url='/example/', action='get', fields=[] ), }, } ) assert schema == expected @unittest.skipUnless(coreapi, 'coreapi is not installed') class Test4605Regression(TestCase): def test_4605_regression(self): generator = SchemaGenerator() prefix = generator.determine_path_prefix([ '/api/v1/items/', '/auth/convert-token/' ]) 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 def test_docstring_is_not_stripped_by_get_description(): class ExampleDocstringAPIView(APIView): """ === title * item a * item a-a * item a-b * item b - item 1 - item 2 code block begin code code code code block end the end """ def get(self, *args, **kwargs): pass def post(self, request, *args, **kwargs): pass view = ExampleDocstringAPIView() schema = view.schema descr = schema.get_description('example', 'get') # the first and last character are '\n' correctly removed by get_description assert descr == formatting.dedent(ExampleDocstringAPIView.__doc__[1:][:-1]) # Views for SchemaGenerationExclusionTests class ExcludedAPIView(APIView): schema = None def get(self, request, *args, **kwargs): pass @api_view(['GET']) @schema(None) def excluded_fbv(request): pass @api_view(['GET']) def included_fbv(request): pass @unittest.skipUnless(coreapi, 'coreapi is not installed') class SchemaGenerationExclusionTests(TestCase): def setUp(self): self.patterns = [ url('^excluded-cbv/$', ExcludedAPIView.as_view()), url('^excluded-fbv/$', excluded_fbv), url('^included-fbv/$', included_fbv), ] def test_schema_generator_excludes_correctly(self): """Schema should not include excluded views""" generator = SchemaGenerator(title='Exclusions', patterns=self.patterns) schema = generator.get_schema() expected = coreapi.Document( url='', title='Exclusions', content={ 'included-fbv': { 'list': coreapi.Link(url='/included-fbv/', action='get') } } ) assert len(schema.data) == 1 assert 'included-fbv' in schema.data assert schema == expected def test_endpoint_enumerator_excludes_correctly(self): """It is responsibility of EndpointEnumerator to exclude views""" inspector = EndpointEnumerator(self.patterns) endpoints = inspector.get_api_endpoints() assert len(endpoints) == 1 path, method, callback = endpoints[0] assert path == '/included-fbv/' def test_should_include_endpoint_excludes_correctly(self): """This is the specific method that should handle the exclusion""" inspector = EndpointEnumerator(self.patterns) # Not pretty. Mimics internals of EndpointEnumerator to put should_include_endpoint under test pairs = [(inspector.get_path_from_regex(get_regex_pattern(pattern)), pattern.callback) for pattern in self.patterns] should_include = [ inspector.should_include_endpoint(*pair) for pair in pairs ] expected = [False, False, True] assert should_include == expected def test_deprecations(self): with pytest.warns(PendingDeprecationWarning) as record: @api_view(["GET"], exclude_from_schema=True) def view(request): pass assert len(record) == 1 assert str(record[0].message) == ( "The `exclude_from_schema` argument to `api_view` is pending " "deprecation. Use the `schema` decorator instead, passing `None`." ) class OldFashionedExcludedView(APIView): exclude_from_schema = True def get(self, request, *args, **kwargs): pass patterns = [ url('^excluded-old-fashioned/$', OldFashionedExcludedView.as_view()), ] inspector = EndpointEnumerator(patterns) with pytest.warns(PendingDeprecationWarning) as record: inspector.get_api_endpoints() assert len(record) == 1 assert str(record[0].message) == ( "The `OldFashionedExcludedView.exclude_from_schema` attribute is " "pending deprecation. Set `schema = None` instead." ) @api_view(["GET"]) def simple_fbv(request): pass class BasicModelSerializer(serializers.ModelSerializer): class Meta: model = BasicModel fields = "__all__" class NamingCollisionView(generics.RetrieveUpdateDestroyAPIView): queryset = BasicModel.objects.all() serializer_class = BasicModelSerializer class BasicNamingCollisionView(generics.RetrieveAPIView): queryset = BasicModel.objects.all() class NamingCollisionViewSet(GenericViewSet): """ Example via: https://stackoverflow.com/questions/43778668/django-rest-framwork-occured-typeerror-link-object-does-not-support-item-ass/ """ permision_class = () @list_route() def detail(self, request): return {} @list_route(url_path='detail/export') def detail_export(self, request): return {} naming_collisions_router = SimpleRouter() naming_collisions_router.register(r'collision', NamingCollisionViewSet, base_name="collision") class TestURLNamingCollisions(TestCase): """ Ref: https://github.com/encode/django-rest-framework/issues/4704 """ def test_manually_routing_nested_routes(self): patterns = [ url(r'^test', simple_fbv), url(r'^test/list/', simple_fbv), ] generator = SchemaGenerator(title='Naming Colisions', patterns=patterns) schema = generator.get_schema() expected = coreapi.Document( url='', title='Naming Colisions', content={ 'test': { 'list': { 'list': coreapi.Link(url='/test/list/', action='get') }, 'list_0': coreapi.Link(url='/test', action='get') } } ) assert expected == schema def _verify_cbv_links(self, loc, url, methods=None, suffixes=None): if methods is None: methods = ('read', 'update', 'partial_update', 'delete') if suffixes is None: suffixes = (None for m in methods) for method, suffix in zip(methods, suffixes): if suffix is not None: key = '{}_{}'.format(method, suffix) else: key = method assert loc[key].url == url def test_manually_routing_generic_view(self): patterns = [ url(r'^test', NamingCollisionView.as_view()), url(r'^test/retrieve/', NamingCollisionView.as_view()), url(r'^test/update/', NamingCollisionView.as_view()), # Fails with method names: url(r'^test/get/', NamingCollisionView.as_view()), url(r'^test/put/', NamingCollisionView.as_view()), url(r'^test/delete/', NamingCollisionView.as_view()), ] generator = SchemaGenerator(title='Naming Colisions', patterns=patterns) schema = generator.get_schema() self._verify_cbv_links(schema['test']['delete'], '/test/delete/') self._verify_cbv_links(schema['test']['put'], '/test/put/') self._verify_cbv_links(schema['test']['get'], '/test/get/') self._verify_cbv_links(schema['test']['update'], '/test/update/') self._verify_cbv_links(schema['test']['retrieve'], '/test/retrieve/') self._verify_cbv_links(schema['test'], '/test', suffixes=(None, '0', None, '0')) def test_from_router(self): patterns = [ url(r'from-router', include(naming_collisions_router.urls)), ] generator = SchemaGenerator(title='Naming Colisions', patterns=patterns) schema = generator.get_schema() desc = schema['detail_0'].description # not important here expected = coreapi.Document( url='', title='Naming Colisions', content={ 'detail': { 'detail_export': coreapi.Link( url='/from-routercollision/detail/export/', action='get', description=desc) }, 'detail_0': coreapi.Link( url='/from-routercollision/detail/', action='get', description=desc ) } ) assert schema == expected def test_url_under_same_key_not_replaced(self): patterns = [ url(r'example/(?P\d+)/$', BasicNamingCollisionView.as_view()), url(r'example/(?P\w+)/$', BasicNamingCollisionView.as_view()), ] generator = SchemaGenerator(title='Naming Colisions', patterns=patterns) schema = generator.get_schema() assert schema['example']['read'].url == '/example/{id}/' assert schema['example']['read_0'].url == '/example/{slug}/' def test_url_under_same_key_not_replaced_another(self): patterns = [ url(r'^test/list/', simple_fbv), url(r'^test/(?P\d+)/list/', simple_fbv), ] generator = SchemaGenerator(title='Naming Colisions', patterns=patterns) schema = generator.get_schema() assert schema['test']['list']['list'].url == '/test/list/' assert schema['test']['list']['list_0'].url == '/test/{id}/list/' def test_is_list_view_recognises_retrieve_view_subclasses(): class TestView(generics.RetrieveAPIView): pass path = '/looks/like/a/list/view/' method = 'get' view = TestView() is_list = is_list_view(path, method, view) assert not is_list, "RetrieveAPIView subclasses should not be classified as list views." def test_head_and_options_methods_are_excluded(): """ Regression test for #5528 https://github.com/encode/django-rest-framework/issues/5528 Viewset OPTIONS actions were not being correctly excluded Initial cases here shown to be working as expected. """ @api_view(['options', 'get']) def fbv(request): pass inspector = EndpointEnumerator() path = '/a/path/' callback = fbv assert inspector.should_include_endpoint(path, callback) assert inspector.get_allowed_methods(callback) == ["GET"] class AnAPIView(APIView): def get(self, request, *args, **kwargs): pass def options(self, request, *args, **kwargs): pass callback = AnAPIView.as_view() assert inspector.should_include_endpoint(path, callback) assert inspector.get_allowed_methods(callback) == ["GET"] class AViewSet(ModelViewSet): @detail_route(methods=['options', 'get']) def custom_action(self, request, pk): pass callback = AViewSet.as_view({ "options": "custom_action", "get": "custom_action" }) assert inspector.should_include_endpoint(path, callback) assert inspector.get_allowed_methods(callback) == ["GET"]