From 0398ee00921b53be84c7b5212ceeb76a99c6ded1 Mon Sep 17 00:00:00 2001 From: Alexander Anikeev Date: Wed, 27 May 2020 16:06:51 +0700 Subject: [PATCH] Add `trailing_slash` argument to the session that helps to avoid issues with Django Rest Framework redirects --- src/jsonapi_client/document.py | 14 +++--- src/jsonapi_client/objects.py | 4 +- src/jsonapi_client/resourceobject.py | 6 +-- src/jsonapi_client/session.py | 10 ++-- tests/test_client.py | 69 ++++++++++++++++++++++++++-- 5 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/jsonapi_client/document.py b/src/jsonapi_client/document.py index c4607cb..8b34208 100644 --- a/src/jsonapi_client/document.py +++ b/src/jsonapi_client/document.py @@ -1,5 +1,5 @@ """ -JSON API Python client +JSON API Python client https://github.com/qvantel/jsonapi-client (see JSON API specification in http://jsonapi.org/) @@ -62,6 +62,8 @@ def __init__(self, session: 'Session', no_cache: bool=False) -> None: self._no_cache = no_cache # if true, do not store resources to session cache self._url = url + if session.trailing_slash and not self._url.endswith('/'): + self._url = self._url + '/' super().__init__(session, json_data) @property @@ -113,11 +115,11 @@ def __str__(self): def _iterator_sync(self) -> 'Iterator[ResourceObject]': # if we currently have no items on the page, then there's no need to yield items # and check the next page - # we do this because there are APIs that always have a 'next' link, even when + # we do this because there are APIs that always have a 'next' link, even when # there are no items on the page if len(self.resources) == 0: return - + yield from self.resources if self.links.next: @@ -127,11 +129,11 @@ def _iterator_sync(self) -> 'Iterator[ResourceObject]': async def _iterator_async(self) -> 'AsyncIterator[ResourceObject]': # if we currently have no items on the page, then there's no need to yield items # and check the next page - # we do this because there are APIs that always have a 'next' link, even when + # we do this because there are APIs that always have a 'next' link, even when # there are no items on the page if len(self.resources) == 0: return - + for res in self.resources: yield res @@ -158,4 +160,4 @@ def mark_invalid(self): """ super().mark_invalid() for r in self.resources: - r.mark_invalid() \ No newline at end of file + r.mark_invalid() diff --git a/src/jsonapi_client/objects.py b/src/jsonapi_client/objects.py index 02dc6db..5cb4a43 100644 --- a/src/jsonapi_client/objects.py +++ b/src/jsonapi_client/objects.py @@ -78,6 +78,8 @@ def _handle_data(self, data): self.meta = Meta(self.session, data.get('meta', {})) else: self.href = '' + if self.session.trailing_slash and self.href and not self.href.endswith('/'): + self.href = self.href + '/' def __eq__(self, other): return self.href == other.href @@ -149,7 +151,7 @@ def _handle_data(self, data): @property def url(self): - return f'{self.session.url_prefix}/{self.type}/{self.id}' + return f'{self.session.url_prefix}/{self.type}/{self.id}{self.session.trailing_slash}' def __str__(self): return f'{self.type}: {self.id}' diff --git a/src/jsonapi_client/resourceobject.py b/src/jsonapi_client/resourceobject.py index 7898eb2..39a8a6f 100644 --- a/src/jsonapi_client/resourceobject.py +++ b/src/jsonapi_client/resourceobject.py @@ -1,5 +1,5 @@ """ -JSON API Python client +JSON API Python client https://github.com/qvantel/jsonapi-client (see JSON API specification in http://jsonapi.org/) @@ -277,7 +277,7 @@ def _determine_class(self, data: dict, relation_type: str=None): """ From data and/or provided relation_type, determine Relationship class to be used. - + :param data: Source data dictionary :param relation_type: either 'to-one' or 'to-many' """ @@ -474,7 +474,7 @@ def dirty_fields(self): @property def url(self) -> str: url = str(self.links.self) - return url or self.id and f'{self.session.url_prefix}/{self.type}/{self.id}' + return url or self.id and f'{self.session.url_prefix}/{self.type}/{self.id}/{self.session.trailing_slash}' @property def post_url(self) -> str: diff --git a/src/jsonapi_client/session.py b/src/jsonapi_client/session.py index 4ce00d2..69e529c 100644 --- a/src/jsonapi_client/session.py +++ b/src/jsonapi_client/session.py @@ -123,7 +123,8 @@ def __init__(self, server_url: str=None, schema: dict=None, request_kwargs: dict=None, loop: 'AbstractEventLoop'=None, - use_relationship_iterator: bool=False,) -> None: + use_relationship_iterator: bool=False, + trailing_slash=False,) -> None: self._server: ParseResult self.enable_async = enable_async @@ -143,6 +144,7 @@ def __init__(self, server_url: str=None, import aiohttp self._aiohttp_session = aiohttp.ClientSession(loop=loop) self.use_relationship_iterator = use_relationship_iterator + self.trailing_slash = '/' if trailing_slash else '' def add_resources(self, *resources: 'ResourceObject') -> None: """ @@ -151,6 +153,8 @@ def add_resources(self, *resources: 'ResourceObject') -> None: for res in resources: self.resources_by_resource_identifier[(res.type, res.id)] = res lnk = res.links.self.url if res.links.self else res.url + if self.trailing_slash and not lnk.endswith(self.trailing_slash): + lnk = lnk + '/' if lnk: self.resources_by_link[lnk] = res @@ -312,9 +316,9 @@ def url_prefix(self) -> str: def _url_for_resource(self, resource_type: str, resource_id: str=None, filter: 'Modifier'=None) -> str: - url = f'{self.url_prefix}/{resource_type}' + url = f'{self.url_prefix}/{resource_type}{self.trailing_slash}' if resource_id is not None: - url = f'{url}/{resource_id}' + url = f'{url}/{resource_id}{self.trailing_slash}' if filter: url = filter.url_with_modifiers(url) return url diff --git a/tests/test_client.py b/tests/test_client.py index 2e41e5f..7cfc248 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -198,7 +198,7 @@ async def __call__(self, *args): def mocked_fetch(mocker): def mock_fetch(url): parsed_url = urlparse(url) - file_path = parsed_url.path[1:] + file_path = parsed_url.path[1:].rstrip('/') query = parsed_url.query return load(f'{file_path}?{query}' if query else file_path) @@ -210,8 +210,8 @@ class MockedFetchAsync: async def __call__(self, url): return mock_fetch(url) - m1 = mocker.patch('jsonapi_client.session.Session._fetch_json', new_callable=MockedFetch) - m2 = mocker.patch('jsonapi_client.session.Session._fetch_json_async', new_callable=MockedFetchAsync) + mocker.patch('jsonapi_client.session.Session._fetch_json', new_callable=MockedFetch) + mocker.patch('jsonapi_client.session.Session._fetch_json_async', new_callable=MockedFetchAsync) return @@ -228,7 +228,7 @@ def session(): def test_initialization(mocked_fetch, article_schema): s = Session('http://localhost:8080', schema=article_schema) - article = s.get('articles') + s.get('articles') assert s.resources_by_link['http://example.com/articles/1'] is \ s.resources_by_resource_identifier[('articles', '1')] assert s.resources_by_link['http://example.com/comments/12'] is \ @@ -239,10 +239,23 @@ def test_initialization(mocked_fetch, article_schema): s.resources_by_resource_identifier[('people', '9')] +def test_initialization_w_trailing_slash(mocked_fetch, article_schema): + s = Session('http://localhost:8080', schema=article_schema, trailing_slash=True) + s.get('articles') + assert s.resources_by_link['http://example.com/articles/1/'] is \ + s.resources_by_resource_identifier[('articles', '1')] + assert s.resources_by_link['http://example.com/comments/12/'] is \ + s.resources_by_resource_identifier[('comments', '12')] + assert s.resources_by_link['http://example.com/comments/5/'] is \ + s.resources_by_resource_identifier[('comments', '5')] + assert s.resources_by_link['http://example.com/people/9/'] is \ + s.resources_by_resource_identifier[('people', '9')] + + @pytest.mark.asyncio async def test_initialization_async(mocked_fetch, article_schema): s = Session('http://localhost:8080', enable_async=True, schema=article_schema) - article = await s.get('articles') + await s.get('articles') assert s.resources_by_link['http://example.com/articles/1'] is \ s.resources_by_resource_identifier[('articles', '1')] assert s.resources_by_link['http://example.com/comments/12'] is \ @@ -271,6 +284,23 @@ def test_basic_attributes(mocked_fetch, article_schema): assert my_attrs == attr_set +def test_basic_attributes_w_trailing_slash(mocked_fetch, article_schema): + s = Session('http://localhost:8080', schema=article_schema, trailing_slash=True) + doc = s.get('articles') + assert len(doc.resources) == 3 + article = doc.resources[0] + assert article.id == "1" + assert article.type == "articles" + assert article.title.startswith('JSON API paints') + + assert doc.links.self.href == 'http://example.com/articles/' + attr_set = {'title', 'author', 'comments', 'nested1', 'comment_or_author', 'comments_or_authors'} + + my_attrs = {i for i in dir(article.fields) if not i.startswith('_')} + + assert my_attrs == attr_set + + def test_resourceobject_without_attributes(mocked_fetch): s = Session('http://localhost:8080', schema=invitation_schema) doc = s.get('invitations') @@ -286,6 +316,20 @@ def test_resourceobject_without_attributes(mocked_fetch): assert my_attrs == attr_set +def test_resourceobject_without_attributes_w_trailing_slash(mocked_fetch): + s = Session('http://localhost:8080', schema=invitation_schema, trailing_slash=True) + doc = s.get('invitations') + assert len(doc.resources) == 1 + invitation = doc.resources[0] + assert invitation.id == "1" + assert invitation.type == "invitations" + assert doc.links.self.href == 'http://example.com/invitations/' + attr_set = {'host', 'guest'} + + my_attrs = {i for i in dir(invitation.fields) if not i.startswith('_')} + + assert my_attrs == attr_set + @pytest.mark.asyncio async def test_basic_attributes_async(mocked_fetch, article_schema): @@ -339,6 +383,21 @@ def test_relationships_single(mocked_fetch, article_schema): assert article3.comment_or_author is None +def test_relationships_single_w_trailing_slash(mocked_fetch, article_schema): + s = Session('http://localhost:8080', schema=article_schema, trailing_slash=True) + article, article2, article3 = s.get('articles').resources + author = article.author + assert {i for i in dir(author.fields) if not i.startswith('_')} \ + == {'first_name', 'last_name', 'twitter'} + assert author.type == 'people' + assert author.id == '9' + + assert author.first_name == 'Dan' + assert author['first-name'] == 'Dan' + assert author.last_name == 'Gebhardt' + assert article.relationships.author.links.self.href == "http://example.com/articles/1/relationships/author/" + + @pytest.mark.asyncio async def test_relationships_iterator_async(mocked_fetch, article_schema): s = Session('http://localhost:8080', enable_async=True, schema=article_schema, use_relationship_iterator=True)