Repository: encode/django-rest-framework

Issue:
get_schema_view() does not respect url= keyword argument
## Checklist

- [x] I have verified that that issue exists against the `master` branch of Django REST framework.
- [x] I have searched for similar issues in both open and closed tickets and cannot find a duplicate.
- [x] This is not a usage question. (Those should be directed to the [discussion group](https://groups.google.com/forum/#!forum/django-rest-framework) instead.)
- [x] This cannot be dealt with as a third party library. (We prefer new functionality to be [in the form of third party libraries](https://www.django-rest-framework.org/community/third-party-packages/#about-third-party-packages) where possible.)
- [x] I have reduced the issue to the simplest possible case.
- [ ] I have included a failing test as a pull request. (If you are unable to do so we can still accept the issue.)

## Steps to reproduce
1. create an app with the following content

test_app/urls.py
```python
from django.urls import path
from rest_framework.schemas import get_schema_view
from rest_framework import views, response


class ExampleModelView(views.APIView):
    def get(self, request, fmt=None):
        return response.Response(status=200, data={'test_endpoint': 'success'})


schema_view = get_schema_view(
    title='Example Schema',
    url='api/'  # note that url= is being specified here
)

urlpatterns = [
    path('api/test', ExampleModelView.as_view()),
    path('schema/', schema_view, name='schema-endpoint')
]
```

prefix_test/urls.py (root urls module)
```python
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('test_app.urls'))
]
```

2. run the django testserver and navigate to localhost:8000/schema

## Expected behavior
A yml schema spec with the content:

```yml
HTTP 200 OK
Allow: GET, HEAD, OPTIONS
Content-Type: application/vnd.oai.openapi
Vary: Accept

openapi: 3.0.2
info:
  title: Example Schema
  version: TODO
paths:
  /api/test:
    get:
      operationId: ListExampleModels
      parameters: []
      responses:
        '200':
          content:
            application/json:
              schema: {}
```

## Actual behavior

```yml
HTTP 200 OK
Allow: GET, HEAD, OPTIONS
Content-Type: application/vnd.oai.openapi
Vary: Accept

openapi: 3.0.2
info:
  title: Example Schema
  version: TODO
paths:
  /test:
    get:
      operationId: ListExampleModels
      parameters: []
      responses:
        '200':
          content:
            application/json:
              schema: {}
```

note the difference between the given paths. Issue #6675 appears similar but not identical. If one examines the openapi.py [`get_paths()`](https://github.com/encode/django-rest-framework/blob/master/rest_framework/schemas/openapi.py#L34) method, it's clear that a url prefix is being determined, removed from url endpoint paths and discarded. 



Discussion:

Resolving patch:
diff --git a/rest_framework/schemas/coreapi.py b/rest_framework/schemas/coreapi.py
--- a/rest_framework/schemas/coreapi.py
+++ b/rest_framework/schemas/coreapi.py
@@ -20,7 +20,18 @@
 header_regex = re.compile('^[a-zA-Z][0-9A-Za-z_]*:')
 
 # Generator #
-# TODO: Pull some of this into base.
+
+
+def common_path(paths):
+    split_paths = [path.strip('/').split('/') for path in paths]
+    s1 = min(split_paths)
+    s2 = max(split_paths)
+    common = s1
+    for i, c in enumerate(s1):
+        if c != s2[i]:
+            common = s1[:i]
+            break
+    return '/' + '/'.join(common)
 
 
 def is_custom_action(action):
@@ -209,6 +220,37 @@ def get_keys(self, subpath, method, view):
         # Default action, eg "/users/", "/users/{pk}/"
         return named_path_components + [action]
 
+    def determine_path_prefix(self, paths):
+        """
+        Given a list of all paths, return the common prefix which should be
+        discounted when generating a schema structure.
+
+        This will be the longest common string that does not include that last
+        component of the URL, or the last component before a path parameter.
+
+        For example:
+
+        /api/v1/users/
+        /api/v1/users/{pk}/
+
+        The path prefix is '/api/v1'
+        """
+        prefixes = []
+        for path in paths:
+            components = path.strip('/').split('/')
+            initial_components = []
+            for component in components:
+                if '{' in component:
+                    break
+                initial_components.append(component)
+            prefix = '/'.join(initial_components[:-1])
+            if not prefix:
+                # We can just break early in the case that there's at least
+                # one URL that doesn't have a path prefix.
+                return '/'
+            prefixes.append('/' + prefix + '/')
+        return common_path(prefixes)
+
 # View Inspectors #
 
 
diff --git a/rest_framework/schemas/generators.py b/rest_framework/schemas/generators.py
--- a/rest_framework/schemas/generators.py
+++ b/rest_framework/schemas/generators.py
@@ -18,18 +18,6 @@
 from rest_framework.utils.model_meta import _get_pk
 
 
-def common_path(paths):
-    split_paths = [path.strip('/').split('/') for path in paths]
-    s1 = min(split_paths)
-    s2 = max(split_paths)
-    common = s1
-    for i, c in enumerate(s1):
-        if c != s2[i]:
-            common = s1[:i]
-            break
-    return '/' + '/'.join(common)
-
-
 def get_pk_name(model):
     meta = model._meta.concrete_model._meta
     return _get_pk(meta).name
@@ -236,37 +224,6 @@ def coerce_path(self, path, method, view):
     def get_schema(self, request=None, public=False):
         raise NotImplementedError(".get_schema() must be implemented in subclasses.")
 
-    def determine_path_prefix(self, paths):
-        """
-        Given a list of all paths, return the common prefix which should be
-        discounted when generating a schema structure.
-
-        This will be the longest common string that does not include that last
-        component of the URL, or the last component before a path parameter.
-
-        For example:
-
-        /api/v1/users/
-        /api/v1/users/{pk}/
-
-        The path prefix is '/api/v1'
-        """
-        prefixes = []
-        for path in paths:
-            components = path.strip('/').split('/')
-            initial_components = []
-            for component in components:
-                if '{' in component:
-                    break
-                initial_components.append(component)
-            prefix = '/'.join(initial_components[:-1])
-            if not prefix:
-                # We can just break early in the case that there's at least
-                # one URL that doesn't have a path prefix.
-                return '/'
-            prefixes.append('/' + prefix + '/')
-        return common_path(prefixes)
-
     def has_view_permissions(self, path, method, view):
         """
         Return `True` if the incoming request has the correct view permissions.
diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py
--- a/rest_framework/schemas/openapi.py
+++ b/rest_framework/schemas/openapi.py
@@ -1,4 +1,5 @@
 import warnings
+from urllib.parse import urljoin
 
 from django.core.validators import (
     DecimalValidator, EmailValidator, MaxLengthValidator, MaxValueValidator,
@@ -39,17 +40,18 @@ def get_paths(self, request=None):
         # Only generate the path prefix for paths that will be included
         if not paths:
             return None
-        prefix = self.determine_path_prefix(paths)
-        if prefix == '/':  # no prefix
-            prefix = ''
 
         for path, method, view in view_endpoints:
             if not self.has_view_permissions(path, method, view):
                 continue
             operation = view.schema.get_operation(path, method)
-            subpath = path[len(prefix):]
-            result.setdefault(subpath, {})
-            result[subpath][method.lower()] = operation
+            # Normalise path for any provided mount url.
+            if path.startswith('/'):
+                path = path[1:]
+            path = urljoin(self.url or '/', path)
+
+            result.setdefault(path, {})
+            result[path][method.lower()] = operation
 
         return result
 


Test patch:
diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py
--- a/tests/schemas/test_openapi.py
+++ b/tests/schemas/test_openapi.py
@@ -190,85 +190,36 @@ def test_repeat_operation_ids(self):
         assert schema_str.count("newExample") == 1
         assert schema_str.count("oldExample") == 1
 
-
-@pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.')
-@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'})
-class TestGenerator(TestCase):
-
-    def test_override_settings(self):
-        assert isinstance(views.ExampleListView.schema, AutoSchema)
-
-    def test_paths_construction(self):
-        """Construction of the `paths` key."""
-        patterns = [
-            url(r'^example/?$', views.ExampleListView.as_view()),
-        ]
-        generator = SchemaGenerator(patterns=patterns)
-        generator._initialise_endpoints()
-
-        paths = generator.get_paths()
-
-        assert '/example/' in paths
-        example_operations = paths['/example/']
-        assert len(example_operations) == 2
-        assert 'get' in example_operations
-        assert 'post' in example_operations
-
-    def test_prefixed_paths_construction(self):
-        """Construction of the `paths` key with a common prefix."""
-        patterns = [
-            url(r'^api/v1/example/?$', views.ExampleListView.as_view()),
-            url(r'^api/v1/example/{pk}/?$', views.ExampleDetailView.as_view()),
-        ]
-        generator = SchemaGenerator(patterns=patterns)
-        generator._initialise_endpoints()
-
-        paths = generator.get_paths()
-
-        assert '/example/' in paths
-        assert '/example/{id}/' in paths
-
-    def test_schema_construction(self):
-        """Construction of the top level dictionary."""
-        patterns = [
-            url(r'^example/?$', views.ExampleListView.as_view()),
-        ]
-        generator = SchemaGenerator(patterns=patterns)
-
-        request = create_request('/')
-        schema = generator.get_schema(request=request)
-
-        assert 'openapi' in schema
-        assert 'paths' in schema
-
     def test_serializer_datefield(self):
-        patterns = [
-            url(r'^example/?$', views.ExampleGenericViewSet.as_view({"get": "get"})),
-        ]
-        generator = SchemaGenerator(patterns=patterns)
-
-        request = create_request('/')
-        schema = generator.get_schema(request=request)
-
-        response = schema['paths']['/example/']['get']['responses']
-        response_schema = response['200']['content']['application/json']['schema']['properties']
+        path = '/'
+        method = 'GET'
+        view = create_view(
+            views.ExampleGenericAPIView,
+            method,
+            create_request(path),
+        )
+        inspector = AutoSchema()
+        inspector.view = view
 
+        responses = inspector._get_responses(path, method)
+        response_schema = responses['200']['content']['application/json']['schema']['properties']
         assert response_schema['date']['type'] == response_schema['datetime']['type'] == 'string'
-
         assert response_schema['date']['format'] == 'date'
         assert response_schema['datetime']['format'] == 'date-time'
 
     def test_serializer_validators(self):
-        patterns = [
-            url(r'^example/?$', views.ExampleValdidatedAPIView.as_view()),
-        ]
-        generator = SchemaGenerator(patterns=patterns)
-
-        request = create_request('/')
-        schema = generator.get_schema(request=request)
+        path = '/'
+        method = 'GET'
+        view = create_view(
+            views.ExampleValidatedAPIView,
+            method,
+            create_request(path),
+        )
+        inspector = AutoSchema()
+        inspector.view = view
 
-        response = schema['paths']['/example/']['get']['responses']
-        response_schema = response['200']['content']['application/json']['schema']['properties']
+        responses = inspector._get_responses(path, method)
+        response_schema = responses['200']['content']['application/json']['schema']['properties']
 
         assert response_schema['integer']['type'] == 'integer'
         assert response_schema['integer']['maximum'] == 99
@@ -307,3 +258,67 @@ def test_serializer_validators(self):
 
         assert response_schema['ip']['type'] == 'string'
         assert 'format' not in response_schema['ip']
+
+
+@pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.')
+@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'})
+class TestGenerator(TestCase):
+
+    def test_override_settings(self):
+        assert isinstance(views.ExampleListView.schema, AutoSchema)
+
+    def test_paths_construction(self):
+        """Construction of the `paths` key."""
+        patterns = [
+            url(r'^example/?$', views.ExampleListView.as_view()),
+        ]
+        generator = SchemaGenerator(patterns=patterns)
+        generator._initialise_endpoints()
+
+        paths = generator.get_paths()
+
+        assert '/example/' in paths
+        example_operations = paths['/example/']
+        assert len(example_operations) == 2
+        assert 'get' in example_operations
+        assert 'post' in example_operations
+
+    def test_prefixed_paths_construction(self):
+        """Construction of the `paths` key maintains a common prefix."""
+        patterns = [
+            url(r'^v1/example/?$', views.ExampleListView.as_view()),
+            url(r'^v1/example/{pk}/?$', views.ExampleDetailView.as_view()),
+        ]
+        generator = SchemaGenerator(patterns=patterns)
+        generator._initialise_endpoints()
+
+        paths = generator.get_paths()
+
+        assert '/v1/example/' in paths
+        assert '/v1/example/{id}/' in paths
+
+    def test_mount_url_prefixed_to_paths(self):
+        patterns = [
+            url(r'^example/?$', views.ExampleListView.as_view()),
+            url(r'^example/{pk}/?$', views.ExampleDetailView.as_view()),
+        ]
+        generator = SchemaGenerator(patterns=patterns, url='/api')
+        generator._initialise_endpoints()
+
+        paths = generator.get_paths()
+
+        assert '/api/example/' in paths
+        assert '/api/example/{id}/' in paths
+
+    def test_schema_construction(self):
+        """Construction of the top level dictionary."""
+        patterns = [
+            url(r'^example/?$', views.ExampleListView.as_view()),
+        ]
+        generator = SchemaGenerator(patterns=patterns)
+
+        request = create_request('/')
+        schema = generator.get_schema(request=request)
+
+        assert 'openapi' in schema
+        assert 'paths' in schema
diff --git a/tests/schemas/views.py b/tests/schemas/views.py
--- a/tests/schemas/views.py
+++ b/tests/schemas/views.py
@@ -96,7 +96,7 @@ class ExampleValidatedSerializer(serializers.Serializer):
     ip = serializers.IPAddressField()
 
 
-class ExampleValdidatedAPIView(generics.GenericAPIView):
+class ExampleValidatedAPIView(generics.GenericAPIView):
     serializer_class = ExampleValidatedSerializer
 
     def get(self, *args, **kwargs):