Fixed issues with schema name collisions

This commit is contained in:
Marcin Lubimow 2017-10-07 19:14:10 +01:00
parent 22565d9a65
commit 7bc5f1e4a3
2 changed files with 174 additions and 49 deletions

View File

@ -64,6 +64,12 @@ to customise schema structure.
""" """
class LinkNode(OrderedDict):
def __init__(self):
self.links = []
super(LinkNode, self).__init__()
def insert_into(target, keys, value): def insert_into(target, keys, value):
""" """
Nested dictionary insertion. Nested dictionary insertion.
@ -71,14 +77,15 @@ def insert_into(target, keys, value):
>>> example = {} >>> example = {}
>>> insert_into(example, ['a', 'b', 'c'], 123) >>> insert_into(example, ['a', 'b', 'c'], 123)
>>> example >>> example
{'a': {'b': {'c': 123}}} LinkNode({'a': LinkNode({'b': LinkNode({'c': LinkNode(links=[123])}}})))
""" """
for key in keys[:-1]: for key in keys:
if key not in target: if key not in target:
target[key] = {} target[key] = LinkNode()
target = target[key] target = target[key]
try: try:
target[keys[-1]] = value target.links.append(value)
except TypeError: except TypeError:
msg = INSERT_INTO_COLLISION_FMT.format( msg = INSERT_INTO_COLLISION_FMT.format(
value_url=value.url, value_url=value.url,
@ -88,6 +95,27 @@ def insert_into(target, keys, value):
raise ValueError(msg) raise ValueError(msg)
def get_unique_key(obj, base_key):
i = 0
while True:
key = '{}_{}'.format(base_key, i)
if key not in obj:
return key
i += 1
def distribute_links(obj, parent=None, parent_key='root'):
if parent is None:
parent = obj
for link in obj.links:
key = get_unique_key(parent, parent_key)
parent[key] = link
for key, value in obj.items():
distribute_links(value, obj, key)
def is_custom_action(action): def is_custom_action(action):
return action not in set([ return action not in set([
'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy' 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy'
@ -255,6 +283,7 @@ class SchemaGenerator(object):
if not url and request is not None: if not url and request is not None:
url = request.build_absolute_uri() url = request.build_absolute_uri()
distribute_links(links)
return coreapi.Document( return coreapi.Document(
title=self.title, description=self.description, title=self.title, description=self.description,
url=url, content=links url=url, content=links
@ -265,7 +294,7 @@ class SchemaGenerator(object):
Return a dictionary containing all the links that should be Return a dictionary containing all the links that should be
included in the API schema. included in the API schema.
""" """
links = OrderedDict() links = LinkNode()
# Generate (path, method, view) given (path, method, callback). # Generate (path, method, view) given (path, method, callback).
paths = [] paths = []
@ -288,6 +317,7 @@ class SchemaGenerator(object):
subpath = path[len(prefix):] subpath = path[len(prefix):]
keys = self.get_keys(subpath, method, view) keys = self.get_keys(subpath, method, view)
insert_into(links, keys, link) insert_into(links, keys, link)
return links return links
# Methods used when we generate a view instance from the raw callback... # Methods used when we generate a view instance from the raw callback...

View File

@ -19,7 +19,6 @@ from rest_framework.schemas import (
AutoSchema, ManualSchema, SchemaGenerator, get_schema_view AutoSchema, ManualSchema, SchemaGenerator, get_schema_view
) )
from rest_framework.schemas.generators import EndpointEnumerator 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.test import APIClient, APIRequestFactory
from rest_framework.utils import formatting from rest_framework.utils import formatting
from rest_framework.views import APIView from rest_framework.views import APIView
@ -121,7 +120,8 @@ class TestRouterGeneratedSchema(TestCase):
title='Example API', title='Example API',
content={ content={
'example': { 'example': {
'list': coreapi.Link( 'list': {},
'list_0': coreapi.Link(
url='/example/', url='/example/',
action='get', action='get',
fields=[ fields=[
@ -130,17 +130,20 @@ class TestRouterGeneratedSchema(TestCase):
coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) 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( 'custom_list_action': {},
'custom_list_action_0': coreapi.Link(
url='/example/custom_list_action/', url='/example/custom_list_action/',
action='get' action='get'
), ),
'custom_list_action_multiple_methods': { 'custom_list_action_multiple_methods': {
'read': coreapi.Link( 'read': {},
'read_0': coreapi.Link(
url='/example/custom_list_action_multiple_methods/', url='/example/custom_list_action_multiple_methods/',
action='get' action='get'
) )
}, },
'read': coreapi.Link( 'read': {},
'read_0': coreapi.Link(
url='/example/{id}/', url='/example/{id}/',
action='get', action='get',
fields=[ fields=[
@ -151,6 +154,7 @@ class TestRouterGeneratedSchema(TestCase):
} }
} }
) )
assert response.data == expected assert response.data == expected
def test_authenticated_request(self): def test_authenticated_request(self):
@ -163,7 +167,8 @@ class TestRouterGeneratedSchema(TestCase):
title='Example API', title='Example API',
content={ content={
'example': { 'example': {
'list': coreapi.Link( 'list': {},
'list_0': coreapi.Link(
url='/example/', url='/example/',
action='get', action='get',
fields=[ fields=[
@ -172,7 +177,8 @@ class TestRouterGeneratedSchema(TestCase):
coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.'))
] ]
), ),
'create': coreapi.Link( 'create': {},
'create_0': coreapi.Link(
url='/example/', url='/example/',
action='post', action='post',
encoding='application/json', encoding='application/json',
@ -181,7 +187,8 @@ class TestRouterGeneratedSchema(TestCase):
coreapi.Field('b', required=False, location='form', schema=coreschema.String(title='B')) coreapi.Field('b', required=False, location='form', schema=coreschema.String(title='B'))
] ]
), ),
'read': coreapi.Link( 'read': {},
'read_0': coreapi.Link(
url='/example/{id}/', url='/example/{id}/',
action='get', action='get',
fields=[ fields=[
@ -189,7 +196,8 @@ class TestRouterGeneratedSchema(TestCase):
coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) 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( 'custom_action': {},
'custom_action_0': coreapi.Link(
url='/example/{id}/custom_action/', url='/example/{id}/custom_action/',
action='post', action='post',
encoding='application/json', encoding='application/json',
@ -200,7 +208,8 @@ class TestRouterGeneratedSchema(TestCase):
coreapi.Field('d', required=False, location='form', schema=coreschema.String(title='D')), coreapi.Field('d', required=False, location='form', schema=coreschema.String(title='D')),
] ]
), ),
'custom_action_with_list_fields': coreapi.Link( 'custom_action_with_list_fields': {},
'custom_action_with_list_fields_0': coreapi.Link(
url='/example/{id}/custom_action_with_list_fields/', url='/example/{id}/custom_action_with_list_fields/',
action='post', action='post',
encoding='application/json', encoding='application/json',
@ -211,21 +220,25 @@ class TestRouterGeneratedSchema(TestCase):
coreapi.Field('b', required=True, location='form', schema=coreschema.Array(title='B', items=coreschema.String())), coreapi.Field('b', required=True, location='form', schema=coreschema.Array(title='B', items=coreschema.String())),
] ]
), ),
'custom_list_action': coreapi.Link( 'custom_list_action': {},
'custom_list_action_0': coreapi.Link(
url='/example/custom_list_action/', url='/example/custom_list_action/',
action='get' action='get'
), ),
'custom_list_action_multiple_methods': { 'custom_list_action_multiple_methods': {
'read': coreapi.Link( 'read': {},
'read_0': coreapi.Link(
url='/example/custom_list_action_multiple_methods/', url='/example/custom_list_action_multiple_methods/',
action='get' action='get'
), ),
'create': coreapi.Link( 'create': {},
'create_0': coreapi.Link(
url='/example/custom_list_action_multiple_methods/', url='/example/custom_list_action_multiple_methods/',
action='post' action='post'
) )
}, },
'update': coreapi.Link( 'update': {},
'update_0': coreapi.Link(
url='/example/{id}/', url='/example/{id}/',
action='put', action='put',
encoding='application/json', encoding='application/json',
@ -236,7 +249,8 @@ class TestRouterGeneratedSchema(TestCase):
coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) 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( 'partial_update': {},
'partial_update_0': coreapi.Link(
url='/example/{id}/', url='/example/{id}/',
action='patch', action='patch',
encoding='application/json', encoding='application/json',
@ -247,7 +261,8 @@ class TestRouterGeneratedSchema(TestCase):
coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.'))
] ]
), ),
'delete': coreapi.Link( 'delete': {},
'delete_0': coreapi.Link(
url='/example/{id}/', url='/example/{id}/',
action='delete', action='delete',
fields=[ fields=[
@ -329,17 +344,20 @@ class TestSchemaGenerator(TestCase):
title='Example API', title='Example API',
content={ content={
'example': { 'example': {
'create': coreapi.Link( 'create': {},
'create_0': coreapi.Link(
url='/example/', url='/example/',
action='post', action='post',
fields=[] fields=[]
), ),
'list': coreapi.Link( 'list': {},
'list_0': coreapi.Link(
url='/example/', url='/example/',
action='get', action='get',
fields=[] fields=[]
), ),
'read': coreapi.Link( 'read': {},
'read_0': coreapi.Link(
url='/example/{id}/', url='/example/{id}/',
action='get', action='get',
fields=[ fields=[
@ -347,7 +365,8 @@ class TestSchemaGenerator(TestCase):
] ]
), ),
'sub': { 'sub': {
'list': coreapi.Link( 'list': {},
'list_0': coreapi.Link(
url='/example/{id}/sub/', url='/example/{id}/sub/',
action='get', action='get',
fields=[ fields=[
@ -382,17 +401,20 @@ class TestSchemaGeneratorNotAtRoot(TestCase):
title='Example API', title='Example API',
content={ content={
'example': { 'example': {
'create': coreapi.Link( 'create': {},
'create_0': coreapi.Link(
url='/api/v1/example/', url='/api/v1/example/',
action='post', action='post',
fields=[] fields=[]
), ),
'list': coreapi.Link( 'list': {},
'list_0': coreapi.Link(
url='/api/v1/example/', url='/api/v1/example/',
action='get', action='get',
fields=[] fields=[]
), ),
'read': coreapi.Link( 'read': {},
'read_0': coreapi.Link(
url='/api/v1/example/{id}/', url='/api/v1/example/{id}/',
action='get', action='get',
fields=[ fields=[
@ -400,7 +422,8 @@ class TestSchemaGeneratorNotAtRoot(TestCase):
] ]
), ),
'sub': { 'sub': {
'list': coreapi.Link( 'list': {},
'list_0': coreapi.Link(
url='/api/v1/example/{id}/sub/', url='/api/v1/example/{id}/sub/',
action='get', action='get',
fields=[ fields=[
@ -437,7 +460,8 @@ class TestSchemaGeneratorWithMethodLimitedViewSets(TestCase):
title='Example API', title='Example API',
content={ content={
'example1': { 'example1': {
'list': coreapi.Link( 'list': {},
'list_0': coreapi.Link(
url='/example1/', url='/example1/',
action='get', action='get',
fields=[ fields=[
@ -446,17 +470,20 @@ class TestSchemaGeneratorWithMethodLimitedViewSets(TestCase):
coreapi.Field('ordering', required=False, location='query', schema=coreschema.String(title='Ordering', description='Which field to use when ordering the results.')) 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( 'custom_list_action': {},
'custom_list_action_0': coreapi.Link(
url='/example1/custom_list_action/', url='/example1/custom_list_action/',
action='get' action='get'
), ),
'custom_list_action_multiple_methods': { 'custom_list_action_multiple_methods': {
'read': coreapi.Link( 'read': {},
'read_0': coreapi.Link(
url='/example1/custom_list_action_multiple_methods/', url='/example1/custom_list_action_multiple_methods/',
action='get' action='get'
) )
}, },
'read': coreapi.Link( 'read': {},
'read_0': coreapi.Link(
url='/example1/{id}/', url='/example1/{id}/',
action='get', action='get',
fields=[ fields=[
@ -494,7 +521,8 @@ class TestSchemaGeneratorWithRestrictedViewSets(TestCase):
title='Example API', title='Example API',
content={ content={
'example': { 'example': {
'list': coreapi.Link( 'list': {},
'list_0': coreapi.Link(
url='/example/', url='/example/',
action='get', action='get',
fields=[] fields=[]
@ -666,7 +694,8 @@ class SchemaGenerationExclusionTests(TestCase):
title='Exclusions', title='Exclusions',
content={ content={
'included-fbv': { 'included-fbv': {
'list': coreapi.Link(url='/included-fbv/', action='get') 'list': {},
'list_0': coreapi.Link(url='/included-fbv/', action='get')
} }
} }
) )
@ -749,6 +778,10 @@ class NamingCollisionView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = BasicModelSerializer serializer_class = BasicModelSerializer
class BasicNamingCollisionView(generics.RetrieveAPIView):
queryset = BasicModel.objects.all()
class NamingCollisionViewSet(GenericViewSet): class NamingCollisionViewSet(GenericViewSet):
""" """
Example via: https://stackoverflow.com/questions/43778668/django-rest-framwork-occured-typeerror-link-object-does-not-support-item-ass/ Example via: https://stackoverflow.com/questions/43778668/django-rest-framwork-occured-typeerror-link-object-does-not-support-item-ass/
@ -779,9 +812,31 @@ class TestURLNamingCollisions(TestCase):
] ]
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns) generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
schema = generator.get_schema()
with pytest.raises(ValueError): expected = coreapi.Document(
generator.get_schema() url='',
title='Naming Colisions',
content={
'test': {
'list': {
'list': {},
'list_0': 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, number=0):
if methods is None:
methods = ('read', 'update', 'partial_update', 'delete')
for method in methods:
key = '{}_{}'.format(method, number)
assert loc[key].url == url
def test_manually_routing_generic_view(self): def test_manually_routing_generic_view(self):
patterns = [ patterns = [
@ -797,8 +852,14 @@ class TestURLNamingCollisions(TestCase):
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns) generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
with pytest.raises(ValueError): schema = generator.get_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')
def test_from_router(self): def test_from_router(self):
patterns = [ patterns = [
@ -806,18 +867,52 @@ class TestURLNamingCollisions(TestCase):
] ]
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns) generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
schema = generator.get_schema()
desc = schema['detail_0'].description # not important here
with pytest.raises(ValueError): expected = coreapi.Document(
generator.get_schema() url='',
title='Naming Colisions',
content={
'detail': {
'detail_export': {},
'detail_export_0': 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_is_list_view_recognises_retrieve_view_subclasses(): def test_url_under_same_key_not_replaced(self):
class TestView(generics.RetrieveAPIView): patterns = [
pass url(r'example/(?P<pk>\d+)/$', BasicNamingCollisionView.as_view()),
url(r'example/(?P<slug>\w+)/$', BasicNamingCollisionView.as_view()),
]
path = '/looks/like/a/list/view/' generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
method = 'get' schema = generator.get_schema()
view = TestView()
is_list = is_list_view(path, method, view) assert schema['example']['read'] == {}
assert not is_list, "RetrieveAPIView subclasses should not be classified as list views." assert schema['example']['read_0'].url == '/example/{id}/'
assert schema['example']['read_1'].url == '/example/{slug}/'
def test_url_under_same_key_not_replaced_another(self):
patterns = [
url(r'^test/list/', simple_fbv),
url(r'^test/(?P<pk>\d+)/list/', simple_fbv),
]
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
schema = generator.get_schema()
assert schema['test']['list']['list_0'].url == '/test/list/'
assert schema['test']['list']['list_1'].url == '/test/{id}/list/'