diff --git a/AUTHORS.rst b/AUTHORS.rst index b32babb..4b184ca 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -33,3 +33,4 @@ Contributors (chronological) - Areeb Jamal `@iamareebjamal `_ - Suren Khorenyan `@mahenzon `_ - Karthikeyan Singaravelan `@tirkarthi `_ +- Max Buck `@buckmaxwell `_ diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 1ce272c..c6480ab 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -135,6 +135,64 @@ To serialize links, pass a URL format string and a dictionary of keyword argumen # } # } +It is possible to create a polymorphic relationship by having the serialized model define __jsonapi_type__. Polymorphic relationships are supported by json-api and by many front end frameworks that implement it like `ember `_. + +.. code-block:: python + + class PaymentMethod(Bunch): + __jsonapi_type__ = "payment-methods" + + + class PaymentMethodCreditCard(PaymentMethod, Bunch): + __jsonapi_type__ = "payment-methods-cc" + + + class PaymentMethodPaypal(PaymentMethod, Bunch): + __jsonapi_type__ = "payment-methods-paypal" + + + class User: + def __init__(self, id): + self.id = id + self.payment_methods = get_payment_methods(id) + +A polymorphic Schema can be created using `OneOfSchema `_. For example, a user may have multiple payment methods with slightly different attributes. Note that OneOfSchema must be separately installed and that there are other ways of creating a polymorphic Schema, this is merely an example. + + +.. code-block:: python + + class PaymentMethodCreditCardSchema(Schema): + id = fields.Str() + last_4 = fields.Str() + + class Meta: + type_ = "payment-methods-cc" + + + class PaymentMethodPaypalSchema(Schema): + id = fields.Str() + linked_email = fields.Str() + + class Meta: + type_ = "payment-methods-paypal" + + + class PaymentMethodSchema(Schema, OneOfSchema): + id = fields.Str() + type_schemas = { + "payment-methods-cc": PaymentMethodCreditCardSchema, + "payment-methods-paypal": PaymentMethodPaypalSchema, + } + + def get_obj_type(self, obj): + if isinstance(obj, PaymentMethod): + return obj.__jsonapi_type__ + else: + raise Exception("Unknown object type: {}".format(obj.__class__.__name__)) + + class Meta: + type_ = "payment-methods" + Resource linkages ----------------- diff --git a/marshmallow_jsonapi/fields.py b/marshmallow_jsonapi/fields.py index 0e16527..2f04400 100644 --- a/marshmallow_jsonapi/fields.py +++ b/marshmallow_jsonapi/fields.py @@ -61,6 +61,9 @@ class Relationship(BaseRelationship): This field is read-only by default. + The type_ keyword argument will be ignored in favor of a __jsonapi_type__ attribute on the serialized + resource. This allows support for polymorphic relationships. + :param str related_url: Format string for related resource links. :param dict related_url_kwargs: Replacement fields for `related_url`. String arguments enclosed in `< >` will be interpreted as attributes to pull from the target object. @@ -173,12 +176,12 @@ def get_self_url(self, obj): def get_resource_linkage(self, value): if self.many: resource_object = [ - {"type": self.type_, "id": _stringify(self._get_id(each))} + {"type": self._get_type(each), "id": _stringify(self._get_id(each))} for each in value ] else: resource_object = { - "type": self.type_, + "type": self._get_type(value), "id": _stringify(self._get_id(value)), } return resource_object @@ -289,6 +292,12 @@ def _get_id(self, value): else: return get_value(value, self.id_field, value) + def _get_type(self, obj): + try: + return obj.__jsonapi_type__ + except AttributeError: + return self.type_ + class DocumentMeta(Field): """Field which serializes to a "meta object" within a document’s “top level”. diff --git a/setup.py b/setup.py index 1a6ce11..13f6b0a 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,13 @@ INSTALL_REQUIRES = ("marshmallow>=2.15.2",) EXTRAS_REQUIRE = { - "tests": ["pytest", "mock", "faker==4.18.0", "Flask==1.1.2"], + "tests": [ + "pytest", + "mock", + "faker==4.18.0", + "Flask==1.1.2", + "marshmallow-oneofschema==2.1.0", + ], "lint": ["flake8==3.8.4", "flake8-bugbear==20.11.1", "pre-commit~=2.0"], } EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"] diff --git a/tests/base.py b/tests/base.py index 6958d67..59862f2 100644 --- a/tests/base.py +++ b/tests/base.py @@ -4,6 +4,7 @@ from marshmallow import validate from marshmallow_jsonapi import Schema, fields +from marshmallow_oneofschema import OneOfSchema fake = Factory.create() @@ -30,6 +31,76 @@ class Keyword(Bunch): pass +class User(Bunch): + pass + + +class PaymentMethod(Bunch): + """Base Class for Payment Methods""" + + __jsonapi_type__ = "payment-methods" + + +class PaymentMethodCreditCard(PaymentMethod, Bunch): + __jsonapi_type__ = "payment-methods-cc" + + +class PaymentMethodPaypal(PaymentMethod, Bunch): + __jsonapi_type__ = "payment-methods-paypal" + + +class PaymentMethodCreditCardSchema(Schema): + id = fields.Str() + last_4 = fields.Str() + + class Meta: + type_ = "payment-methods-cc" + + +class PaymentMethodPaypalSchema(Schema): + id = fields.Str() + linked_email = fields.Str() + + class Meta: + type_ = "payment-methods-paypal" + + +class PaymentMethodSchema(Schema, OneOfSchema): + """Using https://github.com/marshmallow-code/marshmallow-oneofschema is one way to have a polymorphic schema""" + + id = fields.Str() + + type_schemas = { + "payment-methods-cc": PaymentMethodCreditCardSchema, + "payment-methods-paypal": PaymentMethodPaypalSchema, + } + + def get_obj_type(self, obj): + if isinstance(obj, PaymentMethod): + return obj.__jsonapi_type__ + else: + raise Exception(f"Unknown object type: {obj.__class__.__name__}") + + class Meta: + type_ = "payment-methods" + + +class UserSchema(Schema): + id = fields.Str() + first_name = fields.Str(required=True) + last_name = fields.Str(required=True) + + payment_methods = fields.Relationship( + schema=PaymentMethodSchema, + include_resource_linkage=True, + many=True, + type_="payment-methods", + ) + + class Meta: + type_ = "users" + + class AuthorSchema(Schema): id = fields.Str() first_name = fields.Str(required=True) diff --git a/tests/conftest.py b/tests/conftest.py index be677f5..64e0a7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,15 @@ import pytest -from tests.base import Author, Post, Comment, Keyword, fake +from tests.base import ( + Author, + Post, + Comment, + Keyword, + User, + PaymentMethodCreditCard, + PaymentMethodPaypal, + fake, +) def make_author(): @@ -35,6 +44,32 @@ def make_keyword(): return Keyword(keyword=fake.domain_word()) +def make_payment_methods(): + return [ + PaymentMethodCreditCard(id=fake.random_int(), last_4="1335"), + PaymentMethodPaypal(id=fake.random_int(), linked_email="gal@example.com"), + ] + + +def make_user(): + return User( + id=fake.random_int(), + first_name=fake.first_name(), + last_name=fake.last_name(), + payment_methods=make_payment_methods(), + ) + + +@pytest.fixture() +def user(): + return make_user() + + +@pytest.fixture() +def payment_methods(): + return make_payment_methods() + + @pytest.fixture() def author(): return make_author() diff --git a/tests/test_schema.py b/tests/test_schema.py index 7373cbe..93d8863 100755 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -10,6 +10,7 @@ PostSchema, PolygonSchema, ArticleSchema, + UserSchema, ) @@ -71,6 +72,33 @@ def test_dump_many(self, authors): assert attribs["first_name"] == authors[0].first_name assert attribs["last_name"] == authors[0].last_name + def test_dump_single_with_polymorphic_relationship(self, user): + data = UserSchema(include_data=("payment_methods",)).dump(user) + assert "data" in data + assert type(data["data"]["relationships"]["payment_methods"]["data"]) is list + + first_payment_method = data["data"]["relationships"]["payment_methods"]["data"][ + 0 + ] + assert first_payment_method["id"] == str(user.payment_methods[0].id) + assert first_payment_method["type"] == user.payment_methods[0].__jsonapi_type__ + + second_payment_method = data["data"]["relationships"]["payment_methods"][ + "data" + ][1] + assert second_payment_method["id"] == str(user.payment_methods[1].id) + assert second_payment_method["type"] == user.payment_methods[1].__jsonapi_type__ + + included_resources = data["included"] + assert ( + included_resources[0]["attributes"]["last_4"] + == user.payment_methods[0].last_4 + ) + assert ( + included_resources[1]["attributes"]["linked_email"] + == user.payment_methods[1].linked_email + ) + def test_self_link_single(self, author): data = AuthorSchema().dump(author) assert "links" in data @@ -150,6 +178,15 @@ def test_include_data_with_all_relations(self, post): } assert included_comments_author_ids == expected_comments_author_ids + def test_include_data_with_polymorphic_relation(self, user): + data = UserSchema(include_data=("payment_methods",)).dump(user) + + assert "included" in data + assert len(data["included"]) == 2 + for included in data["included"]: + assert included["id"] + assert included["type"] in ("payment-methods-cc", "payment-methods-paypal") + def test_include_no_data(self, post): data = PostSchema(include_data=()).dump(post) assert "included" not in data