From 1644c51deaf470962ce20fa8b0d0408e25b22116 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Thu, 3 Oct 2019 18:35:17 +1000 Subject: [PATCH 01/10] Added Fields, mixins, and instantiated field variables --- marshmallow_jsonapi/query_fields.py | 118 ++++++++++++++++++++++++++++ setup.py | 2 +- 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 marshmallow_jsonapi/query_fields.py diff --git a/marshmallow_jsonapi/query_fields.py b/marshmallow_jsonapi/query_fields.py new file mode 100644 index 0000000..05a0a76 --- /dev/null +++ b/marshmallow_jsonapi/query_fields.py @@ -0,0 +1,118 @@ +""" +Includes fields designed solely for parsing query/URL parameters from JSON API requests +""" +import querystring_parser.parser as qsp +from marshmallow import Schema +from webargs import core, fields +from webargs.fields import DelimitedList, String, Dict +from enum import Enum +import typing + + +class NestedQueryParserMixin: + """ + Mixin for creating a JSON API-compatible parser from a regular Webargs parser + + Examples: :: + from marshmallow_jsonapi.query_fields import NestedQueryParserMixin, JsonApiRequestSchema + from webargs.flaskparser import FlaskParser + + class FlaskJsonApiParser(FlaskParser, NestedQueryParserMixin): + pass + + parser = FlaskJsonApiParser() + + @parser.use_args(JsonApiRequestSchema()) + def greet(args): + return 'You requested to include these relationships: ' + ', '.join(args['include']) + """ + + def parse_querystring(self, req, name, field): + return core.get_value(qsp.parse(req.query_string), name, field) + + +class SortDirection(Enum): + """ + The direction to sort a field by + """ + ASCENDING = 1 + DESCENDING = 2 + + +class SortItem(typing.NamedTuple): + """ + Represents a single entry in the list of fields to sort by + """ + field: str + direction: SortDirection + + +class SortField(fields.Field): + """ + Marshmallow field that parses and dumps a JSON API sort parameter + """ + + def _serialize(self, value, attr, obj, **kwargs): + if value.direction == SortDirection.DESCENDING: + return '-' + value.field + else: + return value.field + + def _deserialize(self, value, attr, data, **kwargs): + if value.startswith('-'): + return SortItem(value[1:], SortDirection.DESCENDING) + else: + return SortItem(value[1:], SortDirection.ASCENDING) + + +class PagePaginationSchema(Schema): + number = fields.Integer() + size = fields.Integer() + + +class OffsetPaginationSchema(Schema): + offset = fields.Integer() + limit = fields.Integer() + + +class CursorPaginationSchema(Schema): + cursor = fields.Raw() + + +include_param = fields.DelimitedList(String(), data_key='include') +""" +The value of the include parameter MUST be a comma-separated (U+002C COMMA, “,”) list of relationship paths. +A relationship path is a dot-separated (U+002E FULL-STOP, “.”) list of relationship names. + +.. seealso:: + `JSON API Specification, Inclusion of Related Resources `_ + JSON API specification for the include request parameter +""" + +fields_param = Dict(keys=String(), values=DelimitedList(String()), data_key='fields') +""" +The value of the fields parameter MUST be a comma-separated (U+002C COMMA, “,”) list that refers to the name(s) of +the fields to be returned. + +.. seealso:: + `JSON API Specification, Sparse Fieldsets `_ + JSON API specification for the fields request parameter +""" + +sort_param = DelimitedList(SortField(), data_key='sort') +""" +An endpoint MAY support requests to sort the primary data with a sort query parameter. +The value for sort MUST represent sort fields. +An endpoint MAY support multiple sort fields by allowing comma-separated (U+002C COMMA, “,”) sort fields. +Sort fields SHOULD be applied in the order specified. + +.. seealso:: + `JSON API Specification, Sorting `_ + JSON API specification for the sort request parameter +""" + +filter_param = Dict(keys=String(), values=DelimitedList(String()), data_key='filter') + +page_pagination_param = fields.Nested(PagePaginationSchema(), data_key='page') +offset_pagination_param = fields.Nested(OffsetPaginationSchema(), data_key='page') +cursor_pagination_param = fields.Nested(CursorPaginationSchema(), data_key='page') diff --git a/setup.py b/setup.py index 7f5f760..e942db4 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import re from setuptools import setup, find_packages -INSTALL_REQUIRES = ("marshmallow>=2.8.0",) +INSTALL_REQUIRES = ("marshmallow>=2.8.0", 'webargs>=5.5.1', 'querystring-parser>=1.2.4') EXTRAS_REQUIRE = { "tests": ["pytest", "mock", "faker==2.0.1", "Flask==1.1.1"], "lint": [ From f124acdbbc3a10ac3fd681dd9ca0cf9921e2c678 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 4 Oct 2019 17:41:56 +1000 Subject: [PATCH 02/10] Reworked to use Field classes everywhere; added tests for all fields --- marshmallow_jsonapi/query_fields.py | 104 +++++++++++++++++----------- tests/tests_query.py | 78 +++++++++++++++++++++ 2 files changed, 143 insertions(+), 39 deletions(-) create mode 100644 tests/tests_query.py diff --git a/marshmallow_jsonapi/query_fields.py b/marshmallow_jsonapi/query_fields.py index 05a0a76..008dd3b 100644 --- a/marshmallow_jsonapi/query_fields.py +++ b/marshmallow_jsonapi/query_fields.py @@ -1,12 +1,13 @@ """ Includes fields designed solely for parsing query/URL parameters from JSON API requests """ +import typing +from enum import Enum + +import marshmallow as ma import querystring_parser.parser as qsp -from marshmallow import Schema from webargs import core, fields from webargs.fields import DelimitedList, String, Dict -from enum import Enum -import typing class NestedQueryParserMixin: @@ -62,57 +63,82 @@ def _deserialize(self, value, attr, data, **kwargs): if value.startswith('-'): return SortItem(value[1:], SortDirection.DESCENDING) else: - return SortItem(value[1:], SortDirection.ASCENDING) + return SortItem(value, SortDirection.ASCENDING) -class PagePaginationSchema(Schema): +class PagePaginationSchema(ma.Schema): number = fields.Integer() size = fields.Integer() -class OffsetPaginationSchema(Schema): +class OffsetPaginationSchema(ma.Schema): offset = fields.Integer() limit = fields.Integer() -class CursorPaginationSchema(Schema): - cursor = fields.Raw() +class Include(DelimitedList): + """ + The value of the include parameter MUST be a comma-separated (U+002C COMMA, “,”) list of relationship paths. + A relationship path is a dot-separated (U+002E FULL-STOP, “.”) list of relationship names. + .. seealso:: + `JSON API Specification, Inclusion of Related Resources `_ + JSON API specification for the include request parameter + """ -include_param = fields.DelimitedList(String(), data_key='include') -""" -The value of the include parameter MUST be a comma-separated (U+002C COMMA, “,”) list of relationship paths. -A relationship path is a dot-separated (U+002E FULL-STOP, “.”) list of relationship names. + def __init__(self): + super().__init__(String(), data_key='include', delimiter=',', as_string=True) -.. seealso:: - `JSON API Specification, Inclusion of Related Resources `_ - JSON API specification for the include request parameter -""" -fields_param = Dict(keys=String(), values=DelimitedList(String()), data_key='fields') -""" -The value of the fields parameter MUST be a comma-separated (U+002C COMMA, “,”) list that refers to the name(s) of -the fields to be returned. +class Fields(Dict): + """ + The value of the fields parameter MUST be a comma-separated (U+002C COMMA, “,”) list that refers to the name(s) of + the fields to be returned. -.. seealso:: - `JSON API Specification, Sparse Fieldsets `_ - JSON API specification for the fields request parameter -""" + .. seealso:: + `JSON API Specification, Sparse Fieldsets `_ + JSON API specification for the fields request parameter + """ -sort_param = DelimitedList(SortField(), data_key='sort') -""" -An endpoint MAY support requests to sort the primary data with a sort query parameter. -The value for sort MUST represent sort fields. -An endpoint MAY support multiple sort fields by allowing comma-separated (U+002C COMMA, “,”) sort fields. -Sort fields SHOULD be applied in the order specified. - -.. seealso:: - `JSON API Specification, Sorting `_ - JSON API specification for the sort request parameter -""" + def __init__(self): + super().__init__(keys=String(), values=DelimitedList(String(), delimiter=',', as_string=True), + data_key='fields') + + +class Sort(DelimitedList): + """ + An endpoint MAY support requests to sort the primary data with a sort query parameter. + The value for sort MUST represent sort fields. + An endpoint MAY support multiple sort fields by allowing comma-separated (U+002C COMMA, “,”) sort fields. + Sort fields SHOULD be applied in the order specified. + + .. seealso:: + `JSON API Specification, Sorting `_ + JSON API specification for the sort request parameter + """ + + def __init__(self): + super().__init__(SortField(), data_key='sort', delimiter=',', as_string=True) + + +class Filter(Dict): + def __init__(self): + super().__init__(keys=String(), values=DelimitedList(String(), delimiter=',', as_string=True), + data_key='filter') + + +class PagePagination(fields.Nested): + def __init__(self): + super().__init__(PagePaginationSchema(), data_key='page') + + +class OffsetPagination(fields.Nested): + def __init__(self): + super().__init__(OffsetPaginationSchema(), data_key='page') -filter_param = Dict(keys=String(), values=DelimitedList(String()), data_key='filter') -page_pagination_param = fields.Nested(PagePaginationSchema(), data_key='page') -offset_pagination_param = fields.Nested(OffsetPaginationSchema(), data_key='page') -cursor_pagination_param = fields.Nested(CursorPaginationSchema(), data_key='page') +class CursorPagination(fields.Nested): + def __init__(self, cursor_field): + super().__init__(ma.Schema.from_dict({ + 'cursor': cursor_field + }), data_key='page') diff --git a/tests/tests_query.py b/tests/tests_query.py new file mode 100644 index 0000000..f0ae313 --- /dev/null +++ b/tests/tests_query.py @@ -0,0 +1,78 @@ +import pytest + +from marshmallow_jsonapi import query_fields as qf +from marshmallow import fields + + +class MockRequest: + """ + A fake request object that has only a query string + """ + + def __init__(self, query): + self.query_string = query + + +class TestQueryParser: + def test_nested_field(self): + """ + Check that the query string parser can do what JSON API demands of it: parsing `param[key]` into a dictionary + """ + parser = qf.NestedQueryParserMixin() + request = MockRequest('include=author&fields[articles]=title,body,author&fields[people]=name') + + assert parser.parse_querystring(request, 'include', None) == 'author' + assert parser.parse_querystring(request, 'fields', None) == { + 'articles': 'title,body,author', + 'people': 'name' + } + + +@pytest.mark.parametrize( + ('field', 'serialized', 'deserialized'), + ( + (qf.SortField(), 'title', qf.SortItem(field='title', direction=qf.SortDirection.ASCENDING)), + (qf.SortField(), '-title', qf.SortItem(field='title', direction=qf.SortDirection.DESCENDING)), + (qf.Include(), 'author,comments.author', ['author', 'comments.author']), + (qf.Fields(), {'articles': 'title,body', 'people': 'name'}, + {'articles': ['title', 'body'], 'people': ['name']} + ), + (qf.Sort(), '-created,title', [ + qf.SortItem(field='created', direction=qf.SortDirection.DESCENDING), + qf.SortItem(field='title', direction=qf.SortDirection.ASCENDING) + ]), + (qf.PagePagination(), {'number': 3, 'size': 1}, {'number': 3, 'size': 1}), + (qf.OffsetPagination(), {'offset': 3, 'limit': 1}, {'offset': 3, 'limit': 1}), + (qf.CursorPagination(fields.Integer()), {'cursor': -1}, {'cursor': -1}), # A Twitter-api style cursor + ) +) +def test_serialize_deserialize_field(field, serialized, deserialized): + """ + Tests all new fields, ensuring they serialize and deserialize as expected + :param field: + :param serialized: + :param deserialized: + :return: + """ + assert field.serialize('some_field', dict(some_field=deserialized)) == serialized + assert field.deserialize(serialized) == deserialized + + +class TestPagePaginationSchema: + def test_validate(self): + schema = qf.PagePaginationSchema() + assert schema.validate({ + 'number': 3, + 'size': 1 + }) == {} + + +class TestOffsetPagePaginationSchema: + def test_validate(self): + schema = qf.OffsetPaginationSchema() + assert schema.validate({ + 'offset': 3, + 'limit': 1 + }) == {} + +# TODO: Add tests that use an entire schema, not just single fields From 5f8920dae19b8d0c4fb7bec308f2293db211aad6 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 4 Oct 2019 17:49:14 +1000 Subject: [PATCH 03/10] Add complete schema test --- tests/tests_query.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/tests_query.py b/tests/tests_query.py index f0ae313..d19709b 100644 --- a/tests/tests_query.py +++ b/tests/tests_query.py @@ -1,7 +1,15 @@ import pytest +from marshmallow import fields, Schema from marshmallow_jsonapi import query_fields as qf -from marshmallow import fields + + +class CompleteSchema(Schema): + sort = qf.Sort() + include = qf.Include() + fields = qf.Fields() + page = qf.PagePagination() + filter = qf.Filter() class MockRequest: @@ -44,6 +52,7 @@ def test_nested_field(self): (qf.PagePagination(), {'number': 3, 'size': 1}, {'number': 3, 'size': 1}), (qf.OffsetPagination(), {'offset': 3, 'limit': 1}, {'offset': 3, 'limit': 1}), (qf.CursorPagination(fields.Integer()), {'cursor': -1}, {'cursor': -1}), # A Twitter-api style cursor + (qf.Filter(), {'post': '1,2', 'author': 12}, {'post': [1, 2], 'author': [12]}), ) ) def test_serialize_deserialize_field(field, serialized, deserialized): @@ -75,4 +84,17 @@ def test_validate(self): 'limit': 1 }) == {} -# TODO: Add tests that use an entire schema, not just single fields + +class TestCompleteSchema: + def test_validate(self): + schema = CompleteSchema() + + assert schema.validate({ + 'sort': '-created,title', + 'include': 'author,comments.author', + 'fields': {'articles': 'title,body', 'people': 'name'}, + 'page': {'number': 3, 'size': 1}, + 'filter': {'post': '1,2', 'author': '12'} + }) == {} + +# TODO: Add end-to-end tests that go from querystring to parsed dictionary From 858bf22122ed78a5d2fe7f9355361cc8a73397c0 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Sat, 5 Oct 2019 19:32:53 +1000 Subject: [PATCH 04/10] Use NamedTuple to make linter happy --- tests/tests_query.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tests_query.py b/tests/tests_query.py index d19709b..577ca0f 100644 --- a/tests/tests_query.py +++ b/tests/tests_query.py @@ -1,5 +1,6 @@ import pytest from marshmallow import fields, Schema +from typing import NamedTuple from marshmallow_jsonapi import query_fields as qf @@ -12,13 +13,12 @@ class CompleteSchema(Schema): filter = qf.Filter() -class MockRequest: +class MockRequest(NamedTuple): """ A fake request object that has only a query string """ - def __init__(self, query): - self.query_string = query + query_string: str class TestQueryParser: From cbf743e66f2ae1e045cad9e98c999df37d55a38b Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Sat, 5 Oct 2019 19:50:53 +1000 Subject: [PATCH 05/10] Add black changes --- marshmallow_jsonapi/query_fields.py | 32 ++++++---- setup.py | 6 +- tests/tests_query.py | 96 +++++++++++++++++------------ 3 files changed, 82 insertions(+), 52 deletions(-) diff --git a/marshmallow_jsonapi/query_fields.py b/marshmallow_jsonapi/query_fields.py index 008dd3b..989a579 100644 --- a/marshmallow_jsonapi/query_fields.py +++ b/marshmallow_jsonapi/query_fields.py @@ -36,6 +36,7 @@ class SortDirection(Enum): """ The direction to sort a field by """ + ASCENDING = 1 DESCENDING = 2 @@ -44,6 +45,7 @@ class SortItem(typing.NamedTuple): """ Represents a single entry in the list of fields to sort by """ + field: str direction: SortDirection @@ -55,12 +57,12 @@ class SortField(fields.Field): def _serialize(self, value, attr, obj, **kwargs): if value.direction == SortDirection.DESCENDING: - return '-' + value.field + return "-" + value.field else: return value.field def _deserialize(self, value, attr, data, **kwargs): - if value.startswith('-'): + if value.startswith("-"): return SortItem(value[1:], SortDirection.DESCENDING) else: return SortItem(value, SortDirection.ASCENDING) @@ -87,7 +89,7 @@ class Include(DelimitedList): """ def __init__(self): - super().__init__(String(), data_key='include', delimiter=',', as_string=True) + super().__init__(String(), data_key="include", delimiter=",", as_string=True) class Fields(Dict): @@ -101,8 +103,11 @@ class Fields(Dict): """ def __init__(self): - super().__init__(keys=String(), values=DelimitedList(String(), delimiter=',', as_string=True), - data_key='fields') + super().__init__( + keys=String(), + values=DelimitedList(String(), delimiter=",", as_string=True), + data_key="fields", + ) class Sort(DelimitedList): @@ -118,27 +123,28 @@ class Sort(DelimitedList): """ def __init__(self): - super().__init__(SortField(), data_key='sort', delimiter=',', as_string=True) + super().__init__(SortField(), data_key="sort", delimiter=",", as_string=True) class Filter(Dict): def __init__(self): - super().__init__(keys=String(), values=DelimitedList(String(), delimiter=',', as_string=True), - data_key='filter') + super().__init__( + keys=String(), + values=DelimitedList(String(), delimiter=",", as_string=True), + data_key="filter", + ) class PagePagination(fields.Nested): def __init__(self): - super().__init__(PagePaginationSchema(), data_key='page') + super().__init__(PagePaginationSchema(), data_key="page") class OffsetPagination(fields.Nested): def __init__(self): - super().__init__(OffsetPaginationSchema(), data_key='page') + super().__init__(OffsetPaginationSchema(), data_key="page") class CursorPagination(fields.Nested): def __init__(self, cursor_field): - super().__init__(ma.Schema.from_dict({ - 'cursor': cursor_field - }), data_key='page') + super().__init__(ma.Schema.from_dict({"cursor": cursor_field}), data_key="page") diff --git a/setup.py b/setup.py index a690e9f..c67c229 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,11 @@ import re from setuptools import setup, find_packages -INSTALL_REQUIRES = ("marshmallow>=2.15.2", 'webargs>=5.5.1', 'querystring-parser>=1.2.4') +INSTALL_REQUIRES = ( + "marshmallow>=2.15.2", + "webargs>=5.5.1", + "querystring-parser>=1.2.4", +) EXTRAS_REQUIRE = { "tests": ["pytest", "mock", "faker==2.0.2", "Flask==1.1.1"], "lint": ["flake8==3.7.8", "flake8-bugbear==19.8.0", "pre-commit~=1.18"], diff --git a/tests/tests_query.py b/tests/tests_query.py index 577ca0f..e49d6f5 100644 --- a/tests/tests_query.py +++ b/tests/tests_query.py @@ -27,33 +27,53 @@ def test_nested_field(self): Check that the query string parser can do what JSON API demands of it: parsing `param[key]` into a dictionary """ parser = qf.NestedQueryParserMixin() - request = MockRequest('include=author&fields[articles]=title,body,author&fields[people]=name') - - assert parser.parse_querystring(request, 'include', None) == 'author' - assert parser.parse_querystring(request, 'fields', None) == { - 'articles': 'title,body,author', - 'people': 'name' + request = MockRequest( + "include=author&fields[articles]=title,body,author&fields[people]=name" + ) + + assert parser.parse_querystring(request, "include", None) == "author" + assert parser.parse_querystring(request, "fields", None) == { + "articles": "title,body,author", + "people": "name", } @pytest.mark.parametrize( - ('field', 'serialized', 'deserialized'), + ("field", "serialized", "deserialized"), ( - (qf.SortField(), 'title', qf.SortItem(field='title', direction=qf.SortDirection.ASCENDING)), - (qf.SortField(), '-title', qf.SortItem(field='title', direction=qf.SortDirection.DESCENDING)), - (qf.Include(), 'author,comments.author', ['author', 'comments.author']), - (qf.Fields(), {'articles': 'title,body', 'people': 'name'}, - {'articles': ['title', 'body'], 'people': ['name']} - ), - (qf.Sort(), '-created,title', [ - qf.SortItem(field='created', direction=qf.SortDirection.DESCENDING), - qf.SortItem(field='title', direction=qf.SortDirection.ASCENDING) - ]), - (qf.PagePagination(), {'number': 3, 'size': 1}, {'number': 3, 'size': 1}), - (qf.OffsetPagination(), {'offset': 3, 'limit': 1}, {'offset': 3, 'limit': 1}), - (qf.CursorPagination(fields.Integer()), {'cursor': -1}, {'cursor': -1}), # A Twitter-api style cursor - (qf.Filter(), {'post': '1,2', 'author': 12}, {'post': [1, 2], 'author': [12]}), - ) + ( + qf.SortField(), + "title", + qf.SortItem(field="title", direction=qf.SortDirection.ASCENDING), + ), + ( + qf.SortField(), + "-title", + qf.SortItem(field="title", direction=qf.SortDirection.DESCENDING), + ), + (qf.Include(), "author,comments.author", ["author", "comments.author"]), + ( + qf.Fields(), + {"articles": "title,body", "people": "name"}, + {"articles": ["title", "body"], "people": ["name"]}, + ), + ( + qf.Sort(), + "-created,title", + [ + qf.SortItem(field="created", direction=qf.SortDirection.DESCENDING), + qf.SortItem(field="title", direction=qf.SortDirection.ASCENDING), + ], + ), + (qf.PagePagination(), {"number": 3, "size": 1}, {"number": 3, "size": 1}), + (qf.OffsetPagination(), {"offset": 3, "limit": 1}, {"offset": 3, "limit": 1}), + ( + qf.CursorPagination(fields.Integer()), + {"cursor": -1}, + {"cursor": -1}, + ), # A Twitter-api style cursor + (qf.Filter(), {"post": "1,2", "author": 12}, {"post": [1, 2], "author": [12]}), + ), ) def test_serialize_deserialize_field(field, serialized, deserialized): """ @@ -63,38 +83,38 @@ def test_serialize_deserialize_field(field, serialized, deserialized): :param deserialized: :return: """ - assert field.serialize('some_field', dict(some_field=deserialized)) == serialized + assert field.serialize("some_field", dict(some_field=deserialized)) == serialized assert field.deserialize(serialized) == deserialized class TestPagePaginationSchema: def test_validate(self): schema = qf.PagePaginationSchema() - assert schema.validate({ - 'number': 3, - 'size': 1 - }) == {} + assert schema.validate({"number": 3, "size": 1}) == {} class TestOffsetPagePaginationSchema: def test_validate(self): schema = qf.OffsetPaginationSchema() - assert schema.validate({ - 'offset': 3, - 'limit': 1 - }) == {} + assert schema.validate({"offset": 3, "limit": 1}) == {} class TestCompleteSchema: def test_validate(self): schema = CompleteSchema() - assert schema.validate({ - 'sort': '-created,title', - 'include': 'author,comments.author', - 'fields': {'articles': 'title,body', 'people': 'name'}, - 'page': {'number': 3, 'size': 1}, - 'filter': {'post': '1,2', 'author': '12'} - }) == {} + assert ( + schema.validate( + { + "sort": "-created,title", + "include": "author,comments.author", + "fields": {"articles": "title,body", "people": "name"}, + "page": {"number": 3, "size": 1}, + "filter": {"post": "1,2", "author": "12"}, + } + ) + == {} + ) + # TODO: Add end-to-end tests that go from querystring to parsed dictionary From 9311d1d94557ea364aa8f9d63854f02d9328280a Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Sat, 5 Oct 2019 23:13:29 +1000 Subject: [PATCH 06/10] Add example query string tests --- tests/{tests_query.py => test_query.py} | 65 ++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) rename tests/{tests_query.py => test_query.py} (64%) diff --git a/tests/tests_query.py b/tests/test_query.py similarity index 64% rename from tests/tests_query.py rename to tests/test_query.py index e49d6f5..f88af35 100644 --- a/tests/tests_query.py +++ b/tests/test_query.py @@ -1,6 +1,8 @@ +from typing import NamedTuple + import pytest from marshmallow import fields, Schema -from typing import NamedTuple +from webargs.core import Parser from marshmallow_jsonapi import query_fields as qf @@ -117,4 +119,63 @@ def test_validate(self): ) -# TODO: Add end-to-end tests that go from querystring to parsed dictionary +@pytest.mark.parametrize( + ("query", "expected"), + ( + ("include=author", {"include": ["author"]}), + ( + "include=author&fields[articles]=title,body,author&fields[people]=name", + { + "fields": {"articles": ["title", "body", "author"], "people": ["name"]}, + "include": ["author"], + }, + ), + ( + "include=author&fields[articles]=title,body&fields[people]=name", + { + "fields": {"articles": ["title", "body"], "people": ["name"]}, + "include": ["author"], + }, + ), + ("page[number]=3&page[size]=1", {"page": {"size": 1, "number": 3}}), + ("include=comments.author", {"include": ["comments.author"]}), + ( + "sort=age", + {"sort": [qf.SortItem(field="age", direction=qf.SortDirection.ASCENDING)]}, + ), + ( + "sort=age,name", + { + "sort": [ + qf.SortItem(field="age", direction=qf.SortDirection.ASCENDING), + qf.SortItem(field="name", direction=qf.SortDirection.ASCENDING), + ] + }, + ), + ( + "sort=-created,title", + { + "sort": [ + qf.SortItem(field="created", direction=qf.SortDirection.DESCENDING), + qf.SortItem(field="title", direction=qf.SortDirection.ASCENDING), + ] + }, + ), + ), +) +def test_jsonapi_examples(query, expected): + """ + Tests example query strings from the JSON API specification + """ + request = MockRequest(query) + + class TestParser(qf.NestedQueryParserMixin, Parser): + pass + + parser = TestParser() + + @parser.use_args(CompleteSchema(), locations=("query",), req=request) + def handle(args): + return args + + assert handle() == expected From 97c51955fa7e338ba0eb1ba1dbce044a0f9264b2 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Sat, 5 Oct 2019 23:22:36 +1000 Subject: [PATCH 07/10] Fix a test, ensure Mashmallow 2 compatibility --- marshmallow_jsonapi/query_fields.py | 5 ++++- tests/test_query.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/marshmallow_jsonapi/query_fields.py b/marshmallow_jsonapi/query_fields.py index 989a579..56ca651 100644 --- a/marshmallow_jsonapi/query_fields.py +++ b/marshmallow_jsonapi/query_fields.py @@ -147,4 +147,7 @@ def __init__(self): class CursorPagination(fields.Nested): def __init__(self, cursor_field): - super().__init__(ma.Schema.from_dict({"cursor": cursor_field}), data_key="page") + class CursorPaginationSchema(ma.Schema): + cursor = cursor_field + + super().__init__(CursorPaginationSchema, data_key="page") diff --git a/tests/test_query.py b/tests/test_query.py index f88af35..46c4305 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -74,7 +74,11 @@ def test_nested_field(self): {"cursor": -1}, {"cursor": -1}, ), # A Twitter-api style cursor - (qf.Filter(), {"post": "1,2", "author": 12}, {"post": [1, 2], "author": [12]}), + ( + qf.Filter(), + {"post": "1,2", "author": "12"}, + {"post": ["1", "2"], "author": ["12"]}, + ), ), ) def test_serialize_deserialize_field(field, serialized, deserialized): From cac05251e8f9e7e6fe17e131cae68f34e6c66d15 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Mon, 7 Oct 2019 11:29:49 +1100 Subject: [PATCH 08/10] Skip most tests under Marshmallow 2 --- tests/test_query.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_query.py b/tests/test_query.py index 46c4305..b5b599f 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -2,7 +2,7 @@ import pytest from marshmallow import fields, Schema -from webargs.core import Parser +from webargs.core import Parser, MARSHMALLOW_VERSION_INFO from marshmallow_jsonapi import query_fields as qf @@ -89,6 +89,9 @@ def test_serialize_deserialize_field(field, serialized, deserialized): :param deserialized: :return: """ + if isinstance(field, fields.Dict) and MARSHMALLOW_VERSION_INFO[0] < 3: + pytest.skip("Marshmallow<3 doesn't support dictionary deserialization") + assert field.serialize("some_field", dict(some_field=deserialized)) == serialized assert field.deserialize(serialized) == deserialized @@ -123,6 +126,7 @@ def test_validate(self): ) +@pytest.mark.skipif(MARSHMALLOW_VERSION_INFO[0] < 3, reason="Marshmallow<3 doesn't support dictionary deserialization") @pytest.mark.parametrize( ("query", "expected"), ( From d4454d1029fde39f6633e8abd6f6db0303fe5f54 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Mon, 7 Oct 2019 11:33:44 +1100 Subject: [PATCH 09/10] Use dict2schema --- marshmallow_jsonapi/query_fields.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/marshmallow_jsonapi/query_fields.py b/marshmallow_jsonapi/query_fields.py index 56ca651..596632b 100644 --- a/marshmallow_jsonapi/query_fields.py +++ b/marshmallow_jsonapi/query_fields.py @@ -147,7 +147,6 @@ def __init__(self): class CursorPagination(fields.Nested): def __init__(self, cursor_field): - class CursorPaginationSchema(ma.Schema): - cursor = cursor_field - - super().__init__(CursorPaginationSchema, data_key="page") + super().__init__(core.dict2schema({ + 'cursor': cursor_field + }), data_key="page") From b967b28fea94d1b967fcaa01c0258c7d1712dd8f Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Mon, 7 Oct 2019 11:35:54 +1100 Subject: [PATCH 10/10] Add black changes --- marshmallow_jsonapi/query_fields.py | 4 +--- tests/test_query.py | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/marshmallow_jsonapi/query_fields.py b/marshmallow_jsonapi/query_fields.py index 596632b..63ff1d0 100644 --- a/marshmallow_jsonapi/query_fields.py +++ b/marshmallow_jsonapi/query_fields.py @@ -147,6 +147,4 @@ def __init__(self): class CursorPagination(fields.Nested): def __init__(self, cursor_field): - super().__init__(core.dict2schema({ - 'cursor': cursor_field - }), data_key="page") + super().__init__(core.dict2schema({"cursor": cursor_field}), data_key="page") diff --git a/tests/test_query.py b/tests/test_query.py index b5b599f..4184654 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -126,7 +126,10 @@ def test_validate(self): ) -@pytest.mark.skipif(MARSHMALLOW_VERSION_INFO[0] < 3, reason="Marshmallow<3 doesn't support dictionary deserialization") +@pytest.mark.skipif( + MARSHMALLOW_VERSION_INFO[0] < 3, + reason="Marshmallow<3 doesn't support dictionary deserialization", +) @pytest.mark.parametrize( ("query", "expected"), (