diff --git a/README.rst b/README.rst index ba51344..f50cbaa 100644 --- a/README.rst +++ b/README.rst @@ -82,8 +82,8 @@ Client session # AsyncIO the same but remember to await: documents = await s.get('resource_type') -Filtering ---------- +Filtering and including +----------------------- .. code-block:: python @@ -92,14 +92,23 @@ Filtering filter = Filter(attribute='something', attribute2='something_else') # - filtering some-dict.some-attr == 'something' filter = Filter(some_dict__some_attr='something')) - # - filtering manually with your server syntax. - filter = Filter('filter[post]=1&filter[author]=2') - # If you have different URL schema for filtering, you can implement your own Filter - # class (derive it from Filter and reimplement format_filter_query). + # Same thing goes for including. + # - including two fields + include = Inclusion('related_field', 'other_related_field') - # Then fetch your filtered document - filtered = s.get('resource_type', filter) # AsyncIO with await + # Custom syntax for request parameters. + # If you have different URL schema for filtering or other GET parameters, + # you can implement your own Modifier class (derive it from Modifier and + # reimplement appended_query). + modifier = Modifier('filter[post]=1&filter[author]=2') + + # All above classes subclass Modifier and can be added to concatenate + # parameters + modifier_sum = filter + include + modifier + + # Now fetch your document + filtered = s.get('resource_type', modifier_sum) # AsyncIO with await # To access resources included in document: r1 = document.resources[0] # first ResourceObject of document. diff --git a/src/jsonapi_client/__init__.py b/src/jsonapi_client/__init__.py index 3c3f460..ce3b6c7 100644 --- a/src/jsonapi_client/__init__.py +++ b/src/jsonapi_client/__init__.py @@ -32,7 +32,7 @@ import pkg_resources from .session import Session -from .filter import Filter +from .filter import Filter, Inclusion, Modifier from .common import ResourceTuple __version__ = pkg_resources.get_distribution("jsonapi-client").version diff --git a/src/jsonapi_client/filter.py b/src/jsonapi_client/filter.py index bc850f5..f834eed 100644 --- a/src/jsonapi_client/filter.py +++ b/src/jsonapi_client/filter.py @@ -34,9 +34,51 @@ if TYPE_CHECKING: FilterKeywords = Dict[str, Union[str, Sequence[Union[str, int, float]]]] + IncludeKeywords = Sequence[str] -class Filter: +class Modifier: + """ + Base class for query modifiers. + You can derive your own class and use it if you have custom syntax. + """ + def __init__(self, query_str: str='') -> None: + self._query_str = query_str + + def url_with_modifiers(self, base_url: str) -> str: + """ + Returns url with modifiers appended. + + Example: + Modifier('filter[attr1]=1,2&filter[attr2]=2').filtered_url('doc') + -> 'GET doc?filter[attr1]=1,2&filter[attr2]=2' + """ + filter_query = self.appended_query() + fetch_url = f'{base_url}?{filter_query}' + return fetch_url + + def appended_query(self) -> str: + return self._query_str + + def __add__(self, other: 'Modifier') -> 'Modifier': + mods = [] + for m in [self, other]: + if isinstance(m, ModifierSum): + mods += m.modifiers + else: + mods.append(m) + return ModifierSum(mods) + + +class ModifierSum(Modifier): + def __init__(self, modifiers): + self.modifiers = modifiers + + def appended_query(self) -> str: + return '&'.join(m.appended_query() for m in self.modifiers) + + +class Filter(Modifier): """ Implements query filtering for Session.get etc. You can derive your own filter class and use it if you have a @@ -48,21 +90,18 @@ def __init__(self, query_str: str='', **filter_kwargs: 'FilterKeywords') -> None :param filter_kwargs: Specify required conditions on result. Example: Filter(attribute='1', relation__attribute='2') """ - self._query_str = query_str + super().__init__(query_str) self._filter_kwargs = filter_kwargs - def filtered_url(self, base_url: str) -> str: - """ - Returns url with filter parameters appended. + # This and next method prevent any existing subclasses from breaking + def url_with_modifiers(self, base_url: str) -> str: + return self.filtered_url(base_url) - Example: - Filter(attr1__in=[1, 2] attr2='2').filtered_url('doc') - -> 'GET doc?filter[attr1]=1,2&filter[attr2]=2' - """ + def filtered_url(self, base_url: str) -> str: + return super().url_with_modifiers(base_url) - filter_query = self._query_str or self.format_filter_query(**self._filter_kwargs) - fetch_url = f'{base_url}?{filter_query}' - return fetch_url + def appended_query(self) -> str: + return super().appended_query() or self.format_filter_query(**self._filter_kwargs) def format_filter_query(self, **kwargs: 'FilterKeywords') -> str: """ @@ -73,3 +112,16 @@ def jsonify_key(key): return key.replace('__', '.').replace('_', '-') return '&'.join(f'filter[{jsonify_key(key)}]={value}' for key, value in kwargs.items()) + + +class Inclusion(Modifier): + """ + Implements query inclusion for Session.get etc. + """ + def __init__(self, *include_args: 'IncludeKeywords') -> None: + super().__init__() + self._include_args = include_args + + def appended_query(self) -> str: + includes = ','.join(self._include_args) + return f'include={includes}' diff --git a/src/jsonapi_client/relationships.py b/src/jsonapi_client/relationships.py index ad439d7..b80c7f2 100644 --- a/src/jsonapi_client/relationships.py +++ b/src/jsonapi_client/relationships.py @@ -43,7 +43,7 @@ R_IDENT_TYPES = Union[str, ResourceObject, ResourceIdentifier, ResourceTuple] if TYPE_CHECKING: - from .filter import Filter + from .filter import Modifier from .document import Document from .session import Session @@ -79,24 +79,24 @@ def __init__(self, def is_single(self) -> bool: raise NotImplementedError - def _filter_sync(self, filter: 'Filter') -> 'Document': - url = filter.filtered_url(self.url) + def _modify_sync(self, modifier: 'Modifier') -> 'Document': + url = modifier.url_with_modifiers(self.url) return self.session.fetch_document_by_url(url) - async def _filter_async(self, filter_obj: 'Filter'): - url = filter_obj.filtered_url(self.url) + async def _modify_async(self, modifier: 'Modifier'): + url = modifier.url_with_modifiers(self.url) return self.session.fetch_document_by_url_async(url) - def filter(self, filter: 'Filter') -> 'Union[Awaitable[Document], Document]': + def filter(self, filter: 'Modifier') -> 'Union[Awaitable[Document], Document]': """ - Receive filtered list of resources. Use Filter instance. + Receive filtered list of resources. Use Modifier instance. If in async mode, this needs to be awaited. """ if self.session.enable_async: - return self._filter_async(filter) + return self._modify_async(filter) else: - return self._filter_sync(filter) + return self._modify_sync(filter) @property def is_dirty(self) -> bool: diff --git a/src/jsonapi_client/session.py b/src/jsonapi_client/session.py index 5031cf9..63e7ab3 100644 --- a/src/jsonapi_client/session.py +++ b/src/jsonapi_client/session.py @@ -50,7 +50,7 @@ from .document import Document from .resourceobject import ResourceObject from .relationships import ResourceTuple - from .filter import Filter + from .filter import Modifier logger = logging.getLogger(__name__) NOT_FOUND = object() @@ -311,20 +311,20 @@ def url_prefix(self) -> str: def _url_for_resource(self, resource_type: str, resource_id: str=None, - filter: 'Filter'=None) -> str: + filter: 'Modifier'=None) -> str: url = f'{self.url_prefix}/{resource_type}' if resource_id is not None: url = f'{url}/{resource_id}' if filter: - url = filter.filtered_url(url) + url = filter.url_with_modifiers(url) return url @staticmethod def _resource_type_and_filter( - resource_id_or_filter: 'Union[Filter, str]'=None)\ - -> 'Tuple[Optional[str], Optional[Filter]]': - from .filter import Filter - if isinstance(resource_id_or_filter, Filter): + resource_id_or_filter: 'Union[Modifier, str]'=None)\ + -> 'Tuple[Optional[str], Optional[Modifier]]': + from .filter import Modifier + if isinstance(resource_id_or_filter, Modifier): resource_id = None filter = resource_id_or_filter else: @@ -333,26 +333,26 @@ def _resource_type_and_filter( return resource_id, filter def _get_sync(self, resource_type: str, - resource_id_or_filter: 'Union[Filter, str]'=None) -> 'Document': + resource_id_or_filter: 'Union[Modifier, str]'=None) -> 'Document': resource_id, filter_ = self._resource_type_and_filter( resource_id_or_filter) url = self._url_for_resource(resource_type, resource_id, filter_) return self.fetch_document_by_url(url) async def _get_async(self, resource_type: str, - resource_id_or_filter: 'Union[Filter, str]'=None) -> 'Document': + resource_id_or_filter: 'Union[Modifier, str]'=None) -> 'Document': resource_id, filter_ = self._resource_type_and_filter( resource_id_or_filter) url = self._url_for_resource(resource_type, resource_id, filter_) return await self.fetch_document_by_url_async(url) def get(self, resource_type: str, - resource_id_or_filter: 'Union[Filter, str]'=None) \ + resource_id_or_filter: 'Union[Modifier, str]'=None) \ -> 'Union[Awaitable[Document], Document]': """ Request (GET) Document from server. - :param resource_id_or_filter: Resource id or Filter instance to filter + :param resource_id_or_filter: Resource id or Modifier instance to filter resulting resources. If session is used with enable_async=True, this needs @@ -363,18 +363,18 @@ def get(self, resource_type: str, else: return self._get_sync(resource_type, resource_id_or_filter) - def _iterate_sync(self, resource_type: str, filter: 'Filter'=None) \ + def _iterate_sync(self, resource_type: str, filter: 'Modifier'=None) \ -> 'Iterator[ResourceObject]': doc = self.get(resource_type, filter) yield from doc._iterator_sync() - async def _iterate_async(self, resource_type: str, filter: 'Filter'=None) \ + async def _iterate_async(self, resource_type: str, filter: 'Modifier'=None) \ -> 'AsyncIterator[ResourceObject]': doc = await self._get_async(resource_type, filter) async for res in doc._iterator_async(): yield res - def iterate(self, resource_type: str, filter: 'Filter'=None) \ + def iterate(self, resource_type: str, filter: 'Modifier'=None) \ -> 'Union[AsyncIterator[ResourceObject], Iterator[ResourceObject]]': """ Request (GET) Document from server and iterate through resources. @@ -384,7 +384,7 @@ def iterate(self, resource_type: str, filter: 'Filter'=None) \ If session is used with enable_async=True, this needs to iterated with async for. - :param filter: Filter instance to filter resulting resources. + :param filter: Modifier instance to filter resulting resources. """ if self.enable_async: return self._iterate_async(resource_type, filter) diff --git a/tests/test_modifiers.py b/tests/test_modifiers.py new file mode 100644 index 0000000..b1ecb6b --- /dev/null +++ b/tests/test_modifiers.py @@ -0,0 +1,28 @@ +from jsonapi_client.filter import Inclusion, Modifier + + +def test_modifier(): + url = 'http://localhost:8080' + query = 'example_attr=1' + m = Modifier(query) + assert m.url_with_modifiers(url) == f'{url}?{query}' + + +def test_inclusion(): + url = 'http://localhost:8080' + f = Inclusion('something', 'something_else') + assert f.url_with_modifiers(url) == f'{url}?include=something,something_else' + + +def test_modifier_sum(): + url = 'http://localhost:8080' + q1 = 'item1=1' + q2 = 'item2=2' + q3 = 'item3=3' + m1 = Modifier(q1) + m2 = Modifier(q2) + m3 = Modifier(q3) + + assert ((m1 + m2) + m3).url_with_modifiers(url) == f'{url}?{q1}&{q2}&{q3}' + assert (m1 + (m2 + m3)).url_with_modifiers(url) == f'{url}?{q1}&{q2}&{q3}' + assert (m1 + m2 + m3).url_with_modifiers(url) == f'{url}?{q1}&{q2}&{q3}'