Skip to content

Commit 5d7b6e5

Browse files
mlubimowCarlton Gibson
authored andcommitted
Fixed issues with schema name collisions (#5486)
* Fixed issues with schema name collisions * Fixed mutating issues in python 3 * Optimized solution * Fixed isort * Removed not needed cast * Fix for key collision * Added preferred key to preserve if available * Add accidently removed test
1 parent c7fb60b commit 5d7b6e5

File tree

2 files changed

+122
-11
lines changed

2 files changed

+122
-11
lines changed

rest_framework/schemas/generators.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
See schemas.__init__.py for package overview.
55
"""
66
import warnings
7-
from collections import OrderedDict
7+
from collections import Counter, OrderedDict
88
from importlib import import_module
99

1010
from django.conf import settings
@@ -64,21 +64,41 @@ def is_api_view(callback):
6464
"""
6565

6666

67+
class LinkNode(OrderedDict):
68+
def __init__(self):
69+
self.links = []
70+
self.methods_counter = Counter()
71+
super(LinkNode, self).__init__()
72+
73+
def get_available_key(self, preferred_key):
74+
if preferred_key not in self:
75+
return preferred_key
76+
77+
while True:
78+
current_val = self.methods_counter[preferred_key]
79+
self.methods_counter[preferred_key] += 1
80+
81+
key = '{}_{}'.format(preferred_key, current_val)
82+
if key not in self:
83+
return key
84+
85+
6786
def insert_into(target, keys, value):
6887
"""
6988
Nested dictionary insertion.
7089
7190
>>> example = {}
7291
>>> insert_into(example, ['a', 'b', 'c'], 123)
7392
>>> example
74-
{'a': {'b': {'c': 123}}}
93+
LinkNode({'a': LinkNode({'b': LinkNode({'c': LinkNode(links=[123])}}})))
7594
"""
7695
for key in keys[:-1]:
7796
if key not in target:
78-
target[key] = {}
97+
target[key] = LinkNode()
7998
target = target[key]
99+
80100
try:
81-
target[keys[-1]] = value
101+
target.links.append((keys[-1], value))
82102
except TypeError:
83103
msg = INSERT_INTO_COLLISION_FMT.format(
84104
value_url=value.url,
@@ -88,6 +108,15 @@ def insert_into(target, keys, value):
88108
raise ValueError(msg)
89109

90110

111+
def distribute_links(obj):
112+
for key, value in obj.items():
113+
distribute_links(value)
114+
115+
for preferred_key, link in obj.links:
116+
key = obj.get_available_key(preferred_key)
117+
obj[key] = link
118+
119+
91120
def is_custom_action(action):
92121
return action not in set([
93122
'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy'
@@ -255,6 +284,7 @@ def get_schema(self, request=None, public=False):
255284
if not url and request is not None:
256285
url = request.build_absolute_uri()
257286

287+
distribute_links(links)
258288
return coreapi.Document(
259289
title=self.title, description=self.description,
260290
url=url, content=links
@@ -265,7 +295,7 @@ def get_links(self, request=None):
265295
Return a dictionary containing all the links that should be
266296
included in the API schema.
267297
"""
268-
links = OrderedDict()
298+
links = LinkNode()
269299

270300
# Generate (path, method, view) given (path, method, callback).
271301
paths = []
@@ -288,6 +318,7 @@ def get_links(self, request=None):
288318
subpath = path[len(prefix):]
289319
keys = self.get_keys(subpath, method, view)
290320
insert_into(links, keys, link)
321+
291322
return links
292323

293324
# Methods used when we generate a view instance from the raw callback...

tests/test_schemas.py

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,10 @@ class NamingCollisionView(generics.RetrieveUpdateDestroyAPIView):
749749
serializer_class = BasicModelSerializer
750750

751751

752+
class BasicNamingCollisionView(generics.RetrieveAPIView):
753+
queryset = BasicModel.objects.all()
754+
755+
752756
class NamingCollisionViewSet(GenericViewSet):
753757
"""
754758
Example via: https://stackoverflow.com/questions/43778668/django-rest-framwork-occured-typeerror-link-object-does-not-support-item-ass/
@@ -779,9 +783,35 @@ def test_manually_routing_nested_routes(self):
779783
]
780784

781785
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
786+
schema = generator.get_schema()
782787

783-
with pytest.raises(ValueError):
784-
generator.get_schema()
788+
expected = coreapi.Document(
789+
url='',
790+
title='Naming Colisions',
791+
content={
792+
'test': {
793+
'list': {
794+
'list': coreapi.Link(url='/test/list/', action='get')
795+
},
796+
'list_0': coreapi.Link(url='/test', action='get')
797+
}
798+
}
799+
)
800+
801+
assert expected == schema
802+
803+
def _verify_cbv_links(self, loc, url, methods=None, suffixes=None):
804+
if methods is None:
805+
methods = ('read', 'update', 'partial_update', 'delete')
806+
if suffixes is None:
807+
suffixes = (None for m in methods)
808+
809+
for method, suffix in zip(methods, suffixes):
810+
if suffix is not None:
811+
key = '{}_{}'.format(method, suffix)
812+
else:
813+
key = method
814+
assert loc[key].url == url
785815

786816
def test_manually_routing_generic_view(self):
787817
patterns = [
@@ -797,18 +827,68 @@ def test_manually_routing_generic_view(self):
797827

798828
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
799829

800-
with pytest.raises(ValueError):
801-
generator.get_schema()
830+
schema = generator.get_schema()
831+
832+
self._verify_cbv_links(schema['test']['delete'], '/test/delete/')
833+
self._verify_cbv_links(schema['test']['put'], '/test/put/')
834+
self._verify_cbv_links(schema['test']['get'], '/test/get/')
835+
self._verify_cbv_links(schema['test']['update'], '/test/update/')
836+
self._verify_cbv_links(schema['test']['retrieve'], '/test/retrieve/')
837+
self._verify_cbv_links(schema['test'], '/test', suffixes=(None, '0', None, '0'))
802838

803839
def test_from_router(self):
804840
patterns = [
805841
url(r'from-router', include(naming_collisions_router.urls)),
806842
]
807843

808844
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
845+
schema = generator.get_schema()
846+
desc = schema['detail_0'].description # not important here
847+
848+
expected = coreapi.Document(
849+
url='',
850+
title='Naming Colisions',
851+
content={
852+
'detail': {
853+
'detail_export': coreapi.Link(
854+
url='/from-routercollision/detail/export/',
855+
action='get',
856+
description=desc)
857+
},
858+
'detail_0': coreapi.Link(
859+
url='/from-routercollision/detail/',
860+
action='get',
861+
description=desc
862+
)
863+
}
864+
)
865+
866+
assert schema == expected
867+
868+
def test_url_under_same_key_not_replaced(self):
869+
patterns = [
870+
url(r'example/(?P<pk>\d+)/$', BasicNamingCollisionView.as_view()),
871+
url(r'example/(?P<slug>\w+)/$', BasicNamingCollisionView.as_view()),
872+
]
873+
874+
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
875+
schema = generator.get_schema()
876+
877+
assert schema['example']['read'].url == '/example/{id}/'
878+
assert schema['example']['read_0'].url == '/example/{slug}/'
879+
880+
def test_url_under_same_key_not_replaced_another(self):
881+
882+
patterns = [
883+
url(r'^test/list/', simple_fbv),
884+
url(r'^test/(?P<pk>\d+)/list/', simple_fbv),
885+
]
886+
887+
generator = SchemaGenerator(title='Naming Colisions', patterns=patterns)
888+
schema = generator.get_schema()
809889

810-
with pytest.raises(ValueError):
811-
generator.get_schema()
890+
assert schema['test']['list']['list'].url == '/test/list/'
891+
assert schema['test']['list']['list_0'].url == '/test/{id}/list/'
812892

813893

814894
def test_is_list_view_recognises_retrieve_view_subclasses():

0 commit comments

Comments
 (0)