Skip to content

Commit 79d0f14

Browse files
committed
Initial Resource classes
0 parents  commit 79d0f14

9 files changed

+309
-0
lines changed

.gitignore

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# poetry builds
2+
dist
3+
4+
# python cache
5+
__pycache__
6+
7+
# pycharm
8+
.idea

README.rst

Whitespace-only changes.

jsonapy/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__version__ = '0.1.0'
2+
3+
from .base_resource import BaseResource

jsonapy/base_resource.py

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import json
2+
from collections import Iterable
3+
from typing import Callable
4+
from typing import Dict
5+
from typing import Literal
6+
from typing import Set
7+
from typing import TYPE_CHECKING
8+
from typing import Union
9+
10+
from jsonapy import utils
11+
12+
13+
class BaseResourceMeta(type):
14+
def __new__(mcs, name, bases, namespace): # noqa C901
15+
cls = super().__new__(mcs, name, bases, namespace)
16+
17+
annotations_items = cls.__annotations__.items()
18+
cls.__fields_types__ = cls.__annotations__
19+
cls.__atomic_fields_set__ = {
20+
name
21+
for name, type_ in annotations_items
22+
if not issubclass(type_, BaseResource)
23+
}
24+
cls.__relationships_fields_set__ = {
25+
name
26+
for name, type_ in annotations_items
27+
if issubclass(type_, BaseResource)
28+
}
29+
30+
return cls
31+
32+
33+
class BaseResource(metaclass=BaseResourceMeta):
34+
if TYPE_CHECKING:
35+
# for IDE
36+
__fields_types__: Dict[str, type]
37+
__atomic_fields_set__: Set[str]
38+
__relationships_fields_set__: Set[str]
39+
40+
@property
41+
def id(self):
42+
raise NotImplementedError
43+
44+
class Meta:
45+
resource_name: str
46+
47+
def jsonapi_dict(self, required_attributes, links, relationships):
48+
"""Dump the object as JSON in compliance with JSON:API specification.
49+
50+
Parameters
51+
52+
- `links`: a dictionary containing the links to include in data. For example::
53+
{
54+
"self": request.url_for(...),
55+
"related": request.url_for(...),
56+
}
57+
if using FastAPI / Starlette.
58+
- `required_attributes`: a iterable containing the fields names to include in dumped data. If all fields are
59+
required, provide the `"__all__"` literal instead of an iterable.
60+
- `relationships`: a dictionary specifying the relationships to include and their fields and links.
61+
The keys must be a attribute of the resource referencing another resource. The value is another dict containing
62+
two keys:
63+
+ `fields`: a list containing the fields to dump (see required_attributes above)
64+
+ `links`: a dict containing the links to dump (see links parameter above)
65+
For example, let's say an article is related to an author. The relationships dict could be::
66+
{
67+
"author": {
68+
"field": ["id", "name"]
69+
"links": {"self": request.url_for(...), ...}
70+
}
71+
}
72+
"""
73+
data = {
74+
"type": self.Meta.resource_name,
75+
"id": self.id,
76+
"attributes": self.filtered_attributes(required_attributes),
77+
}
78+
if links:
79+
data["links"] = links
80+
if relationships:
81+
self.validate_relationships(relationships)
82+
data["relationships"] = self.formatted_relationships(relationships)
83+
return data
84+
85+
def filtered_attributes(self, required_attributes: Union[Iterable, Literal["__all__"]]):
86+
if required_attributes == "__all__":
87+
required_attributes = self.__atomic_fields_set__
88+
return {
89+
utils.snake_to_camel_case(k): v
90+
for (k, v) in self.__dict__.items()
91+
if k in required_attributes
92+
}
93+
94+
def validate_relationships(self, relationships):
95+
errors = []
96+
for name, rel_dict in relationships.items():
97+
if name not in self.__relationships_fields_set__:
98+
errors.append(f"'{name}' is not a valid relationship.")
99+
if (rel_dict.get("links") is None and rel_dict.get("fields")) is None:
100+
errors.append(f"You must provide at least links or fields for the '{name}' relationship.")
101+
if errors:
102+
raise ValueError("\n".join(errors))
103+
104+
def formatted_relationships(self, relationships):
105+
relationships_dict = {}
106+
for name, rel_payload in relationships.items():
107+
related_object: BaseResource = self.__dict__[name]
108+
if related_object is None:
109+
relationships_dict[utils.snake_to_camel_case(name)] = None
110+
continue
111+
rel_data = {}
112+
if rel_payload.get("fields") is not None:
113+
rel_data["data"] = related_object.filtered_attributes(rel_payload["fields"])
114+
if rel_payload.get("links") is not None:
115+
rel_data["links"] = rel_payload["links"]
116+
relationships_dict[utils.snake_to_camel_case(name)] = rel_data
117+
return relationships_dict
118+
119+
def dump(self, required_attributes, links, relationships, dump_function: Callable[[Dict], str] = json.dumps) -> str:
120+
return dump_function(self.jsonapi_dict(required_attributes, links, relationships))

jsonapy/utils.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def snake_to_camel_case(text: str) -> str:
2+
"""Convert a snake_case string into camelCase format.
3+
This function doesnt check that passed text is in snake case.
4+
"""
5+
first, *others = text.split("_")
6+
return first + "".join(map(str.capitalize, others))

poetry.lock

+152
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[tool.poetry]
2+
name = "jsonapy"
3+
version = "0.1.0"
4+
description = "Library for dumping models into JSON:API"
5+
authors = ["Guillaume Fayard <[email protected]>"]
6+
7+
[tool.poetry.dependencies]
8+
python = "^3.8"
9+
10+
[tool.poetry.dev-dependencies]
11+
pytest = "^5.2"
12+
13+
[build-system]
14+
requires = ["poetry-core>=1.0.0"]
15+
build-backend = "poetry.core.masonry.api"

tests/__init__.py

Whitespace-only changes.

tests/test_jsonapy.py

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from jsonapy import __version__
2+
3+
4+
def test_version():
5+
assert __version__ == '0.1.0'

0 commit comments

Comments
 (0)