Repository: jaywink/federation

Issue:
Add Diaspora Reshare entity support
https://diaspora.github.io/diaspora_federation/entities/reshare.html

Discussion:
pep8speaks: Hello @jaywink! Thanks for submitting the PR.







 - In the file [`federation/tests/entities/diaspora/test_entities.py`](https://github.com/jaywink/federation/blob/296c903b949d95cfdaf3d5c49e79391163621575/federation/tests/entities/diaspora/test_entities.py), following are the PEP8 issues :

> [Line 106:13](https://github.com/jaywink/federation/blob/296c903b949d95cfdaf3d5c49e79391163621575/federation/tests/entities/diaspora/test_entities.py#L106): [E122](https://duckduckgo.com/?q=pep8%20E122) continuation line missing indentation or outdented
> [Line 107:13](https://github.com/jaywink/federation/blob/296c903b949d95cfdaf3d5c49e79391163621575/federation/tests/entities/diaspora/test_entities.py#L107): [E122](https://duckduckgo.com/?q=pep8%20E122) continuation line missing indentation or outdented
> [Line 108:9](https://github.com/jaywink/federation/blob/296c903b949d95cfdaf3d5c49e79391163621575/federation/tests/entities/diaspora/test_entities.py#L108): [E122](https://duckduckgo.com/?q=pep8%20E122) continuation line missing indentation or outdented

 - In the file [`federation/tests/entities/diaspora/test_mappers.py`](https://github.com/jaywink/federation/blob/296c903b949d95cfdaf3d5c49e79391163621575/federation/tests/entities/diaspora/test_mappers.py), following are the PEP8 issues :

> [Line 203:30](https://github.com/jaywink/federation/blob/296c903b949d95cfdaf3d5c49e79391163621575/federation/tests/entities/diaspora/test_mappers.py#L203): [E712](https://duckduckgo.com/?q=pep8%20E712) comparison to True should be 'if cond is True:' or 'if cond:'








codecov[bot]: # [Codecov](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=h1) Report
> Merging [#100](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=desc) into [master](https://codecov.io/gh/jaywink/federation/commit/ebacea83b0d70f8e63ffacdf113f05600acc6451?src=pr&el=desc) will **increase** coverage by `0.15%`.
> The diff coverage is `97.95%`.

[![Impacted file tree graph](https://codecov.io/gh/jaywink/federation/pull/100/graphs/tree.svg?height=150&width=650&token=wkvcOv0pZ6&src=pr)](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #100      +/-   ##
==========================================
+ Coverage   91.67%   91.83%   +0.15%     
==========================================
  Files          17       17              
  Lines        1093     1102       +9     
==========================================
+ Hits         1002     1012      +10     
+ Misses         91       90       -1
```


| [Impacted Files](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [federation/entities/diaspora/mappers.py](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=tree#diff-ZmVkZXJhdGlvbi9lbnRpdGllcy9kaWFzcG9yYS9tYXBwZXJzLnB5) | `99.33% <100%> (ø)` | :arrow_up: |
| [federation/entities/diaspora/entities.py](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=tree#diff-ZmVkZXJhdGlvbi9lbnRpdGllcy9kaWFzcG9yYS9lbnRpdGllcy5weQ==) | `97.89% <100%> (+0.11%)` | :arrow_up: |
| [federation/entities/base.py](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=tree#diff-ZmVkZXJhdGlvbi9lbnRpdGllcy9iYXNlLnB5) | `96.44% <97.36%> (+0.59%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=footer). Last update [ebacea8...296c903](https://codecov.io/gh/jaywink/federation/pull/100?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07c5f19..19755b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
 
 ## [unreleased]
 
+### Added
+* Added base entity `Share` which maps to a `DiasporaReshare` for the Diaspora protocol. ([related issue](https://github.com/jaywink/federation/issues/94))
+
+  The `Share` entity supports all the properties that a Diaspora reshare does. Additionally two other properties are supported: `raw_content` and `entity_type`. The former can be used for a "quoted share" case where the sharer adds their own note to the share. The latter can be used to reference the type of object that was shared, to help the receiver, if it is not sharing a `Post` entity. The value must be a base entity class name.
+
 ### Fixed
 * Converting base entity `Profile` to `DiasporaProfile` for outbound sending missed two attributes, `image_urls` and `tag_list`. Those are now included so that the values transfer into the built payload.
 
diff --git a/docs/protocols.rst b/docs/protocols.rst
index 443cff1..a12f202 100644
--- a/docs/protocols.rst
+++ b/docs/protocols.rst
@@ -24,6 +24,7 @@ The feature set supported by this release is approximately the following:
    * Retraction
    * StatusMessage
    * Contact
+   * Reshare
 
 Implementation unfortunately currently requires knowledge of how Diaspora discovery works as the implementer has to implement all the necessary views correctly (even though this library provides document generators). However, the magic envelope, signature and entity building is all abstracted inside the library.
 
diff --git a/docs/usage.rst b/docs/usage.rst
index e69a918..5a84ebd 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -17,6 +17,7 @@ Entity types are as follows below.
 .. autoclass:: federation.entities.base.Reaction
 .. autoclass:: federation.entities.base.Relationship
 .. autoclass:: federation.entities.base.Retraction
+.. autoclass:: federation.entities.base.Share
 
 Protocol entities
 .................
diff --git a/federation/entities/base.py b/federation/entities/base.py
index 989516a..b8f8ba5 100644
--- a/federation/entities/base.py
+++ b/federation/entities/base.py
@@ -5,7 +5,7 @@ from dirty_validators.basic import Email
 
 
 __all__ = (
-    "Post", "Image", "Comment", "Reaction", "Relationship", "Profile", "Retraction", "Follow",
+    "Post", "Image", "Comment", "Reaction", "Relationship", "Profile", "Retraction", "Follow", "Share,"
 )
 
 
@@ -125,6 +125,24 @@ class TargetGUIDMixin(BaseEntity):
             raise ValueError("Target GUID must be at least 16 characters")
 
 
+class ParticipationMixin(TargetGUIDMixin):
+    """Reflects a participation to something."""
+    participation = ""
+
+    _participation_valid_values = ["reaction", "subscription", "comment"]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._required += ["participation"]
+
+    def validate_participation(self):
+        """Ensure participation is of a certain type."""
+        if self.participation not in self._participation_valid_values:
+            raise ValueError("participation should be one of: {valid}".format(
+                valid=", ".join(self._participation_valid_values)
+            ))
+
+
 class HandleMixin(BaseEntity):
     handle = ""
 
@@ -178,7 +196,43 @@ class OptionalRawContentMixin(RawContentMixin):
         self._required.remove("raw_content")
 
 
-class Image(GUIDMixin, HandleMixin, PublicMixin, OptionalRawContentMixin, CreatedAtMixin, BaseEntity):
+class EntityTypeMixin(BaseEntity):
+    """Provides a field for entity type.
+
+    Validates it is one of our entities.
+    """
+    entity_type = ""
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._required += ["entity_type"]
+
+    def validate_entity_type(self):
+        """Ensure type is some entity we know of."""
+        if self.entity_type not in __all__:
+            raise ValueError("Entity type %s not recognized." % self.entity_type)
+
+
+class ProviderDisplayNameMixin(BaseEntity):
+    """Provides a field for provider display name."""
+    provider_display_name = ""
+
+
+class TargetHandleMixin(BaseEntity):
+    """Provides a target handle field."""
+    target_handle = ""
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._required += ["target_handle"]
+
+    def validate_target_handle(self):
+        validator = Email()
+        if not validator.is_valid(self.target_handle):
+            raise ValueError("Target handle is not valid")
+
+
+class Image(GUIDMixin, HandleMixin, PublicMixin, OptionalRawContentMixin, CreatedAtMixin):
     """Reflects a single image, possibly linked to another object."""
     remote_path = ""
     remote_name = ""
@@ -192,32 +246,13 @@ class Image(GUIDMixin, HandleMixin, PublicMixin, OptionalRawContentMixin, Create
         self._required += ["remote_path", "remote_name"]
 
 
-class Post(RawContentMixin, GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, BaseEntity):
+class Post(RawContentMixin, GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, ProviderDisplayNameMixin):
     """Reflects a post, status message, etc, which will be composed from the message or to the message."""
-    provider_display_name = ""
     location = ""
 
     _allowed_children = (Image,)
 
 
-class ParticipationMixin(TargetGUIDMixin):
-    """Reflects a participation to something."""
-    participation = ""
-
-    _participation_valid_values = ["reaction", "subscription", "comment"]
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self._required += ["participation"]
-
-    def validate_participation(self):
-        """Ensure participation is of a certain type."""
-        if self.participation not in self._participation_valid_values:
-            raise ValueError("participation should be one of: {valid}".format(
-                valid=", ".join(self._participation_valid_values)
-            ))
-
-
 class Comment(RawContentMixin, GUIDMixin, ParticipationMixin, CreatedAtMixin, HandleMixin):
     """Represents a comment, linked to another object."""
     participation = "comment"
@@ -247,21 +282,15 @@ class Reaction(GUIDMixin, ParticipationMixin, CreatedAtMixin, HandleMixin):
             ))
 
 
-class Relationship(CreatedAtMixin, HandleMixin):
+class Relationship(CreatedAtMixin, HandleMixin, TargetHandleMixin):
     """Represents a relationship between two handles."""
-    target_handle = ""
     relationship = ""
 
     _relationship_valid_values = ["sharing", "following", "ignoring", "blocking"]
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self._required += ["relationship", "target_handle"]
-
-    def validate_target_handle(self):
-        validator = Email()
-        if not validator.is_valid(self.target_handle):
-            raise ValueError("Target handle is not valid")
+        self._required += ["relationship"]
 
     def validate_relationship(self):
         """Ensure relationship is of a certain type."""
@@ -271,19 +300,13 @@ class Relationship(CreatedAtMixin, HandleMixin):
             ))
 
 
-class Follow(CreatedAtMixin, HandleMixin):
+class Follow(CreatedAtMixin, HandleMixin, TargetHandleMixin):
     """Represents a handle following or unfollowing another handle."""
-    target_handle = ""
     following = True
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self._required += ["target_handle", "following"]
-
-    def validate_target_handle(self):
-        validator = Email()
-        if not validator.is_valid(self.target_handle):
-            raise ValueError("Target handle is not valid")
+        self._required += ["following"]
 
 
 class Profile(CreatedAtMixin, HandleMixin, OptionalRawContentMixin, PublicMixin, GUIDMixin):
@@ -313,15 +336,19 @@ class Profile(CreatedAtMixin, HandleMixin, OptionalRawContentMixin, PublicMixin,
                 raise ValueError("Email is not valid")
 
 
-class Retraction(CreatedAtMixin, HandleMixin, TargetGUIDMixin):
+class Retraction(CreatedAtMixin, HandleMixin, TargetGUIDMixin, EntityTypeMixin):
     """Represents a retraction of content by author."""
-    entity_type = ""
+    pass
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self._required += ["entity_type"]
 
-    def validate_entity_type(self):
-        """Ensure type is some entity we know of."""
-        if self.entity_type not in __all__:
-            raise ValueError("Entity type %s not recognized." % self.entity_type)
+class Share(CreatedAtMixin, HandleMixin, TargetGUIDMixin, GUIDMixin, EntityTypeMixin, OptionalRawContentMixin,
+            PublicMixin, ProviderDisplayNameMixin, TargetHandleMixin):
+    """Represents a share of another entity.
+
+    ``entity_type`` defaults to "Post" but can be any base entity class name. It should be the class name of the
+    entity that was shared.
+
+    The optional ``raw_content`` can be used for a "quoted share" case where the sharer adds their own note to the
+    share.
+    """
+    entity_type = "Post"
diff --git a/federation/entities/diaspora/entities.py b/federation/entities/diaspora/entities.py
index 5e23523..d7392aa 100644
--- a/federation/entities/diaspora/entities.py
+++ b/federation/entities/diaspora/entities.py
@@ -1,6 +1,7 @@
 from lxml import etree
 
-from federation.entities.base import Comment, Post, Reaction, Relationship, Profile, Retraction, BaseEntity, Follow
+from federation.entities.base import (
+    Comment, Post, Reaction, Relationship, Profile, Retraction, BaseEntity, Follow, Share)
 from federation.entities.diaspora.utils import format_dt, struct_to_xml, get_base_attributes, add_element_to_doc
 from federation.exceptions import SignatureVerificationError
 from federation.protocols.diaspora.signatures import verify_relayable_signature, create_relayable_signature
@@ -211,3 +212,22 @@ class DiasporaRetraction(DiasporaEntityMixin, Retraction):
             index = values.index(value)
             return list(DiasporaRetraction.mapped.keys())[index]
         return value
+
+
+class DiasporaReshare(DiasporaEntityMixin, Share):
+    """Diaspora Reshare."""
+    def to_xml(self):
+        element = etree.Element("reshare")
+        struct_to_xml(element, [
+            {"author": self.handle},
+            {"guid": self.guid},
+            {"created_at": format_dt(self.created_at)},
+            {"root_author": self.target_handle},
+            {"root_guid": self.target_guid},
+            {"provider_display_name": self.provider_display_name},
+            {"public": "true" if self.public else "false"},
+            # Some of our own not in Diaspora protocol
+            {"raw_content": self.raw_content},
+            {"entity_type": self.entity_type},
+        ])
+        return element
diff --git a/federation/entities/diaspora/mappers.py b/federation/entities/diaspora/mappers.py
index bb8a174..5acd205 100644
--- a/federation/entities/diaspora/mappers.py
+++ b/federation/entities/diaspora/mappers.py
@@ -3,10 +3,10 @@ from datetime import datetime
 
 from lxml import etree
 
-from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile, Retraction, Follow
+from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile, Retraction, Follow, Share
 from federation.entities.diaspora.entities import (
     DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction,
-    DiasporaRelayableMixin, DiasporaContact)
+    DiasporaRelayableMixin, DiasporaContact, DiasporaReshare)
 from federation.protocols.diaspora.signatures import get_element_child_info
 from federation.utils.diaspora import retrieve_and_parse_profile
 
@@ -21,11 +21,12 @@ MAPPINGS = {
     "profile": DiasporaProfile,
     "retraction": DiasporaRetraction,
     "contact": DiasporaContact,
+    "reshare": DiasporaReshare,
 }
 
 TAGS = [
     # Order is important. Any top level tags should be before possibly child tags
-    "status_message", "comment", "like", "request", "profile", "retraction", "photo", "contact",
+    "reshare", "status_message", "comment", "like", "request", "profile", "retraction", "photo", "contact",
 ]
 
 BOOLEAN_KEYS = (
@@ -44,6 +45,7 @@ INTEGER_KEYS = (
     "width",
 )
 
+
 def xml_children_as_dict(node):
     """Turn the children of node <xml> into a dict, keyed by tag name.
 
@@ -167,9 +169,9 @@ def transform_attributes(attrs, cls):
             transformed["raw_content"] = value
         elif key in ["diaspora_handle", "sender_handle", "author"]:
             transformed["handle"] = value
-        elif key in ["recipient_handle", "recipient"]:
+        elif key in ["recipient_handle", "recipient", "root_author", "root_diaspora_id"]:
             transformed["target_handle"] = value
-        elif key == "parent_guid":
+        elif key in ["parent_guid", "post_guid", "root_guid"]:
             transformed["target_guid"] = value
         elif key == "first_name":
             transformed["name"] = value
@@ -203,8 +205,6 @@ def transform_attributes(attrs, cls):
             transformed["linked_type"] = "Post"
         elif key == "author_signature":
             transformed["signature"] = value
-        elif key == "post_guid":
-            transformed["target_guid"] = value
         elif key in BOOLEAN_KEYS:
             transformed[key] = True if value == "true" else False
         elif key in DATETIME_KEYS:
@@ -239,7 +239,7 @@ def get_outbound_entity(entity, private_key):
     outbound = None
     cls = entity.__class__
     if cls in [DiasporaPost, DiasporaRequest, DiasporaComment, DiasporaLike, DiasporaProfile, DiasporaRetraction,
-               DiasporaContact]:
+               DiasporaContact, DiasporaReshare]:
         # Already fine
         outbound = entity
     elif cls == Post:
@@ -259,6 +259,8 @@ def get_outbound_entity(entity, private_key):
         outbound = DiasporaProfile.from_base(entity)
     elif cls == Retraction:
         outbound = DiasporaRetraction.from_base(entity)
+    elif cls == Share:
+        outbound = DiasporaReshare.from_base(entity)
     if not outbound:
         raise ValueError("Don't know how to convert this base entity to Diaspora protocol entities.")
     if isinstance(outbound, DiasporaRelayableMixin) and not outbound.signature:


Test patch:
diff --git a/federation/tests/entities/diaspora/test_entities.py b/federation/tests/entities/diaspora/test_entities.py
index 2f8671d..dea34c1 100644
--- a/federation/tests/entities/diaspora/test_entities.py
+++ b/federation/tests/entities/diaspora/test_entities.py
@@ -6,9 +6,10 @@ from lxml import etree
 from federation.entities.base import Profile
 from federation.entities.diaspora.entities import (
     DiasporaComment, DiasporaPost, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction,
-    DiasporaContact)
+    DiasporaContact, DiasporaReshare)
 from federation.entities.diaspora.mappers import message_to_objects
 from federation.exceptions import SignatureVerificationError
+from federation.tests.factories.entities import ShareFactory
 from federation.tests.fixtures.keys import get_dummy_private_key
 from federation.tests.fixtures.payloads import DIASPORA_POST_COMMENT
 
@@ -93,6 +94,21 @@ class TestEntitiesConvertToXML:
                     b"<following>true</following><sharing>true</sharing></contact>"
         assert etree.tostring(result) == converted
 
+    def test_reshare_to_xml(self):
+        base_entity = ShareFactory()
+        entity = DiasporaReshare.from_base(base_entity)
+        result = entity.to_xml()
+        assert result.tag == "reshare"
+        result.find("created_at").text = ""  # timestamp makes testing painful
+        converted = "<reshare><author>%s</author><guid>%s</guid><created_at></created_at><root_author>%s" \
+                    "</root_author><root_guid>%s</root_guid><provider_display_name>%s</provider_display_name>" \
+                    "<public>%s</public><raw_content>%s</raw_content><entity_type>%s</entity_type></reshare>" % (
+                        entity.handle, entity.guid, entity.target_handle, entity.target_guid,
+                        entity.provider_display_name, "true" if entity.public else "false", entity.raw_content,
+                        entity.entity_type,
+                    )
+        assert etree.tostring(result).decode("utf-8") == converted
+
 
 class TestDiasporaProfileFillExtraAttributes:
     def test_raises_if_no_handle(self):
diff --git a/federation/tests/entities/diaspora/test_mappers.py b/federation/tests/entities/diaspora/test_mappers.py
index 9313596..1adeac0 100644
--- a/federation/tests/entities/diaspora/test_mappers.py
+++ b/federation/tests/entities/diaspora/test_mappers.py
@@ -6,17 +6,18 @@ import pytest
 
 from federation.entities.base import (
     Comment, Post, Reaction, Relationship, Profile, Retraction, Image,
-    Follow)
+    Follow, Share)
 from federation.entities.diaspora.entities import (
     DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest,
-    DiasporaProfile, DiasporaRetraction, DiasporaContact)
+    DiasporaProfile, DiasporaRetraction, DiasporaContact, DiasporaReshare)
 from federation.entities.diaspora.mappers import (
     message_to_objects, get_outbound_entity, check_sender_and_entity_handle_match)
 from federation.tests.fixtures.payloads import (
     DIASPORA_POST_SIMPLE, DIASPORA_POST_COMMENT, DIASPORA_POST_LIKE,
     DIASPORA_REQUEST, DIASPORA_PROFILE, DIASPORA_POST_INVALID, DIASPORA_RETRACTION,
     DIASPORA_POST_WITH_PHOTOS, DIASPORA_POST_LEGACY_TIMESTAMP, DIASPORA_POST_LEGACY, DIASPORA_CONTACT,
-    DIASPORA_LEGACY_REQUEST_RETRACTION, DIASPORA_POST_WITH_PHOTOS_2, DIASPORA_PROFILE_EMPTY_TAGS)
+    DIASPORA_LEGACY_REQUEST_RETRACTION, DIASPORA_POST_WITH_PHOTOS_2, DIASPORA_PROFILE_EMPTY_TAGS, DIASPORA_RESHARE,
+    DIASPORA_RESHARE_WITH_EXTRA_PROPERTIES, DIASPORA_RESHARE_LEGACY)
 
 
 def mock_fill(attributes):
@@ -189,6 +190,40 @@ class TestDiasporaEntityMappersReceive:
         assert entity.target_handle == "bob@example.org"
         assert entity.following is True
 
+    def test_message_to_objects_reshare(self):
+        entities = message_to_objects(DIASPORA_RESHARE, "alice@example.org")
+        assert len(entities) == 1
+        entity = entities[0]
+        assert isinstance(entity, DiasporaReshare)
+        assert entity.handle == "alice@example.org"
+        assert entity.guid == "a0b53e5029f6013487753131731751e9"
+        assert entity.provider_display_name == ""
+        assert entity.target_handle == "bob@example.com"
+        assert entity.target_guid == "a0b53bc029f6013487753131731751e9"
+        assert entity.public is True
+        assert entity.entity_type == "Post"
+
+    def test_message_to_objects_reshare_legacy(self):
+        entities = message_to_objects(DIASPORA_RESHARE_LEGACY, "alice@example.org")
+        assert len(entities) == 1
+        entity = entities[0]
+        assert isinstance(entity, DiasporaReshare)
+        assert entity.handle == "alice@example.org"
+        assert entity.guid == "a0b53e5029f6013487753131731751e9"
+        assert entity.provider_display_name == ""
+        assert entity.target_handle == "bob@example.com"
+        assert entity.target_guid == "a0b53bc029f6013487753131731751e9"
+        assert entity.public is True
+        assert entity.entity_type == "Post"
+
+    def test_message_to_objects_reshare_extra_properties(self):
+        entities = message_to_objects(DIASPORA_RESHARE_WITH_EXTRA_PROPERTIES, "alice@example.org")
+        assert len(entities) == 1
+        entity = entities[0]
+        assert isinstance(entity, DiasporaReshare)
+        assert entity.raw_content == "Important note here"
+        assert entity.entity_type == "Comment"
+
     @patch("federation.entities.diaspora.mappers.logger.error")
     def test_invalid_entity_logs_an_error(self, mock_logger):
         entities = message_to_objects(DIASPORA_POST_INVALID, "alice@alice.diaspora.example.org")
@@ -242,6 +277,8 @@ class TestGetOutboundEntity:
         assert get_outbound_entity(entity, private_key) == entity
         entity = DiasporaContact()
         assert get_outbound_entity(entity, private_key) == entity
+        entity = DiasporaReshare()
+        assert get_outbound_entity(entity, private_key) == entity
 
     def test_post_is_converted_to_diasporapost(self, private_key):
         entity = Post()
@@ -283,6 +320,10 @@ class TestGetOutboundEntity:
         entity = Follow()
         assert isinstance(get_outbound_entity(entity, private_key), DiasporaContact)
 
+    def test_share_is_converted_to_diasporareshare(self, private_key):
+        entity = Share()
+        assert isinstance(get_outbound_entity(entity, private_key), DiasporaReshare)
+
     def test_signs_relayable_if_no_signature(self, private_key):
         entity = DiasporaComment()
         outbound = get_outbound_entity(entity, private_key)
diff --git a/federation/tests/entities/test_base.py b/federation/tests/entities/test_base.py
index 5eaa10a..89985e3 100644
--- a/federation/tests/entities/test_base.py
+++ b/federation/tests/entities/test_base.py
@@ -4,11 +4,11 @@ import pytest
 
 from federation.entities.base import (
     BaseEntity, Relationship, Profile, RawContentMixin, GUIDMixin, HandleMixin, PublicMixin, Image, Retraction,
-    Follow)
-from federation.tests.factories.entities import TaggedPostFactory, PostFactory
+    Follow, TargetHandleMixin)
+from federation.tests.factories.entities import TaggedPostFactory, PostFactory, ShareFactory
 
 
-class TestPostEntityTags():
+class TestPostEntityTags:
     def test_post_entity_returns_list_of_tags(self):
         post = TaggedPostFactory()
         assert post.tags == {"tagone", "tagtwo", "tagthree", "upper", "snakecase"}
@@ -18,7 +18,7 @@ class TestPostEntityTags():
         assert post.tags == set()
 
 
-class TestBaseEntityCallsValidateMethods():
+class TestBaseEntityCallsValidateMethods:
     def test_entity_calls_attribute_validate_method(self):
         post = PostFactory()
         post.validate_location = Mock()
@@ -48,28 +48,41 @@ class TestBaseEntityCallsValidateMethods():
             post._validate_children()
 
 
-class TestGUIDMixinValidate():
+class TestGUIDMixinValidate:
     def test_validate_guid_raises_on_low_length(self):
         guid = GUIDMixin(guid="x"*15)
         with pytest.raises(ValueError):
             guid.validate()
+        guid = GUIDMixin(guid="x" * 16)
+        guid.validate()
 
 
-class TestHandleMixinValidate():
+class TestHandleMixinValidate:
     def test_validate_handle_raises_on_invalid_format(self):
         handle = HandleMixin(handle="foobar")
         with pytest.raises(ValueError):
             handle.validate()
+        handle = HandleMixin(handle="foobar@example.com")
+        handle.validate()
 
 
-class TestPublicMixinValidate():
+class TestTargetHandleMixinValidate:
+    def test_validate_target_handle_raises_on_invalid_format(self):
+        handle = TargetHandleMixin(target_handle="foobar")
+        with pytest.raises(ValueError):
+            handle.validate()
+        handle = TargetHandleMixin(target_handle="foobar@example.com")
+        handle.validate()
+
+
+class TestPublicMixinValidate:
     def test_validate_public_raises_on_low_length(self):
         public = PublicMixin(public="foobar")
         with pytest.raises(ValueError):
             public.validate()
 
 
-class TestEntityRequiredAttributes():
+class TestEntityRequiredAttributes:
     def test_entity_checks_for_required_attributes(self):
         entity = BaseEntity()
         entity._required = ["foobar"]
@@ -85,7 +98,7 @@ class TestEntityRequiredAttributes():
             entity.validate()
 
 
-class TestRelationshipEntity():
+class TestRelationshipEntity:
     def test_instance_creation(self):
         entity = Relationship(handle="bob@example.com", target_handle="alice@example.com", relationship="following")
         assert entity
@@ -95,13 +108,8 @@ class TestRelationshipEntity():
             entity = Relationship(handle="bob@example.com", target_handle="alice@example.com", relationship="hating")
             entity.validate()
 
-    def test_instance_creation_validates_target_handle_value(self):
-        with pytest.raises(ValueError):
-            entity = Relationship(handle="bob@example.com", target_handle="fefle.com", relationship="following")
-            entity.validate()
 
-
-class TestProfileEntity():
+class TestProfileEntity:
     def test_instance_creation(self):
         entity = Profile(handle="bob@example.com", raw_content="foobar")
         assert entity
@@ -117,7 +125,7 @@ class TestProfileEntity():
             entity.validate()
 
 
-class TestImageEntity():
+class TestImageEntity:
     def test_instance_creation(self):
         entity = Image(
             guid="x"*16, handle="foo@example.com", public=False, remote_path="foobar", remote_name="barfoo"
@@ -137,7 +145,7 @@ class TestImageEntity():
             entity.validate()
 
 
-class TestRetractionEntity():
+class TestRetractionEntity:
     def test_instance_creation(self):
         entity = Retraction(
             handle="foo@example.com", target_guid="x"*16, entity_type="Post"
@@ -162,16 +170,15 @@ class TestRetractionEntity():
             entity.validate()
 
 
-class TestFollowEntity():
+class TestFollowEntity:
     def test_instance_creation(self):
         entity = Follow(
             handle="foo@example.com", target_handle="bar@example.org", following=True
         )
         entity.validate()
 
-    def test_required_validates(self):
-        entity = Follow(
-            handle="foo@example.com", following=True
-        )
-        with pytest.raises(ValueError):
-            entity.validate()
+
+class TestShareEntity:
+    def test_instance_creation(self):
+        entity = ShareFactory()
+        entity.validate()
diff --git a/federation/tests/factories/entities.py b/federation/tests/factories/entities.py
index 7e9fdf0..3f4ab26 100644
--- a/federation/tests/factories/entities.py
+++ b/federation/tests/factories/entities.py
@@ -1,9 +1,8 @@
-# -*- coding: utf-8 -*-
 from random import shuffle
 import factory
 from factory import fuzzy
 
-from federation.entities.base import Post, Profile
+from federation.entities.base import Post, Profile, Share
 from federation.entities.diaspora.entities import DiasporaPost
 
 
@@ -47,3 +46,15 @@ class ProfileFactory(GUIDMixinFactory, HandleMixinFactory, RawContentMixinFactor
 
     name = fuzzy.FuzzyText(length=30)
     public_key = fuzzy.FuzzyText(length=300)
+
+
+class ShareFactory(GUIDMixinFactory, HandleMixinFactory):
+    class Meta:
+        model = Share
+
+    target_guid = factory.Faker("uuid4")
+    entity_type = "Post"
+    raw_content = ""
+    public = factory.Faker("pybool")
+    provider_display_name = ""
+    target_handle = factory.Faker("safe_email")
diff --git a/federation/tests/fixtures/payloads.py b/federation/tests/fixtures/payloads.py
index 973247b..5b5ae85 100644
--- a/federation/tests/fixtures/payloads.py
+++ b/federation/tests/fixtures/payloads.py
@@ -231,3 +231,41 @@ DIASPORA_CONTACT = """
         <sharing>true</sharing>
     </contact>
 """
+
+DIASPORA_RESHARE = """
+    <reshare>
+        <author>alice@example.org</author>
+        <guid>a0b53e5029f6013487753131731751e9</guid>
+        <created_at>2016-07-12T00:36:42Z</created_at>
+        <provider_display_name/>
+        <root_author>bob@example.com</root_author>
+        <root_guid>a0b53bc029f6013487753131731751e9</root_guid>
+        <public>true</public>
+    </reshare>
+"""
+
+DIASPORA_RESHARE_LEGACY = """
+    <reshare>
+        <diaspora_handle>alice@example.org</diaspora_handle>
+        <guid>a0b53e5029f6013487753131731751e9</guid>
+        <created_at>2016-07-12T00:36:42Z</created_at>
+        <provider_display_name/>
+        <root_diaspora_id>bob@example.com</root_diaspora_id>
+        <root_guid>a0b53bc029f6013487753131731751e9</root_guid>
+        <public>true</public>
+    </reshare>
+"""
+
+DIASPORA_RESHARE_WITH_EXTRA_PROPERTIES = """
+    <reshare>
+        <author>alice@example.org</author>
+        <guid>a0b53e5029f6013487753131731751e9</guid>
+        <created_at>2016-07-12T00:36:42Z</created_at>
+        <provider_display_name/>
+        <root_author>bob@example.com</root_author>
+        <root_guid>a0b53bc029f6013487753131731751e9</root_guid>
+        <public>true</public>
+        <raw_content>Important note here</raw_content>
+        <entity_type>Comment</entity_type>
+    </reshare>
+"""