diff --git a/docs/index.md b/docs/index.md index c336979..3bb892c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ layout: default **jsonapi** is heavily inspired by [graphql](https://graphql.org), but aimed at a much simpler use case. The idea is to have a minimal framework for easily building JSON based APIs, that doesn't require any particular frontend technology. The design is inspired in **graphql**'s idea of a single fully customizable endpoint, but instead of defining a specific query language, **jsonapi** is entirely based on JSON both for the query and the response, requires much less boilerplate code, only works in Python, and of course, is much less battle-tested. If you find **graphql** amazing but would like to try a decaffeinated version that you can setup in 10 lines, then give **jsonapi** a shoot. -## Instalation +## Installation **jsonapi** is a single Python file with no dependencies that you can just clone and distribute with your project's source code: diff --git a/docs/operators.md b/docs/operators.md index 5edd7de..e5bf802 100644 --- a/docs/operators.md +++ b/docs/operators.md @@ -25,7 +25,7 @@ The next thing you'll probably want is to pass some arguments to your functions. ``` -You can also use the "lazy" way, which is simply to prepend `"$"` to all arguments: +You can also use the "lazy" way, which is simply to prepend `"$"` to all arguments: ```python >>> api({ "sum": { "$a": 27, "$b": 15 } }) @@ -56,4 +56,35 @@ If your function receives a complex argument (i.e., a JSON dict), you will autom ``` -You can read more about `JsonObj`'s magic tricks [here](/jsonobj.md). \ No newline at end of file +You can read more about `JsonObj`'s magic tricks [here](/jsonobj.md). + +## Collection operators + +For collections we have a couple interesting operators that return some aggregated information about the collection itself. These operators are used the same way as standard navigation, but their names start with `_`. In turn, these operators are not applied on the collection's content, but instead on the collection itself. + +The `_count` operator returns the number of items in the collection: + +```python +>>> class CountApi(JsonApi): +... def elements(self): +... return list(range(10)) + +>>> api = CountApi() +>>> api({ 'elements' }) +{'elements': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]} + +>>> api({ 'elements': { '_count' }}) +{'elements': {'_count': 10}} + +``` + +Now the problem is that you lost the items themselves. Well, this can fixed by calling another operator `_items` which returns back the items of the collection: + +```python +>>> from pprint import pprint + +>>> r = api({ 'elements': { '_count', '_items' }}) +>>> pprint(r) +{'elements': {'_count': 10, '_items': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}} + +``` \ No newline at end of file diff --git a/jsonapi.py b/jsonapi.py index b33b33d..76f7762 100644 --- a/jsonapi.py +++ b/jsonapi.py @@ -9,7 +9,11 @@ def __init__(self, **kwargs): setattr(self, str(k), self._parse(w)) def dict(self): - return {key: self._json(value) for key, value in self.__dict__.items() if not hasattr(value, '__call__')} + return { + key: self._json(value) for key, value + in self.__dict__.items() + if not hasattr(value, '__call__') + } def json(self, **kwargs): return json.dumps(self.dict(), **kwargs) @@ -53,48 +57,74 @@ def _query(self, obj, query): if isinstance(obj, (int, float, str, bool)): return obj - if isinstance(obj, (list, tuple)): - return [self._query(i, query) for i in obj] + if hasattr(obj, '__iter__'): + return self._query_iter(obj, query) if isinstance(obj, dict): return {str(k): self._query(v, query) for k, v in obj.items()} if isinstance(obj, JsonObj): - if query is None: - return {key: value for key, value in obj.__dict__.items() if not hasattr(value, '__call__')} + return self._query_obj(obj, query) - payload = {} + raise TypeError("type %s is not supported" % type(x)) - if isinstance(query, dict): - items = query.items() - else: - items = [(key, None) for key in query] + def _query_iter(self, obj, query): + meta = self._extract(query, '_') - for key, value in items: - attr = getattr(obj, key) - args = {} - navigation = value + if not meta: + return [self._query(i, query) for i in obj] - if isinstance(navigation, dict): - if "$" in navigation: - args = {str(k): self._parse(v) - for k, v in navigation.pop("$").items()} - else: - for a in list(navigation.keys()): - if a.startswith("$"): - v = navigation.pop(a) - args[a.strip("$")] = self._parse(v) + result = {} - if hasattr(attr, '__call__'): - result = attr(**args) - else: - result = attr + for m in meta: + result["_%s" % m] = getattr(self, '_meta_%s' % m)(obj, query) - payload[key] = self._query(result, navigation) + return result - return payload + def _query_obj(self, obj, query): + if query is None: + return {key: value for key, value in obj.__dict__.items() if not hasattr(value, '__call__')} - raise TypeError("type %s is not supported" % type(x)) + payload = {} + + if isinstance(query, dict): items = query.items() + else: items = [(key, None) for key in query] + + for key, value in items: + attr = getattr(obj, key) + query = value + args = self._extract(query, "$") + + if hasattr(attr, '__call__'): result = attr(**args) + else: result = attr + + payload[key] = self._query(result, query) + + return payload + + def _extract(self, query, label): + if query is None: + return {} + + if not isinstance(query, dict): + query = { k: None for k in query } + + if label in query: + return {str(k): self._parse(v) for k, v in query.pop(label).items()} + + args = {} + + for a in list(query): + if a.startswith(label): + args[a.strip(label)] = self._parse(query.pop(a)) + + return args + + def _meta_count(self, obj, query): + return len(obj) + + def _meta_items(self, obj, query): + return [self._query(i, query) for i in obj] def __call__(self, query, encode=False, **kw): if isinstance(query, str):