Repository: jaywink/federation

Issue:
Add support for Diaspora contact payload
This replaces the old `request` payload. See diaspora/diaspora_federation#32

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



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

> [Line 122:1](https://github.com/jaywink/federation/blob/7c5d86a3475b96eabaf6f7af37005ed5ebb82024/federation/entities/diaspora/entities.py#L122): [W293](https://duckduckgo.com/?q=pep8%20W293) blank line contains whitespace













Patch:
diff --git a/federation/entities/base.py b/federation/entities/base.py
index 1185248..97a055a 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"
+    "Post", "Image", "Comment", "Reaction", "Relationship", "Profile", "Retraction", "Follow",
 )
 
 
@@ -266,6 +266,21 @@ class Relationship(CreatedAtMixin, HandleMixin):
             ))
 
 
+class Follow(CreatedAtMixin, HandleMixin):
+    """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")
+
+
 class Profile(CreatedAtMixin, HandleMixin, OptionalRawContentMixin, PublicMixin, GUIDMixin):
     """Represents a profile for a user."""
     name = ""
diff --git a/federation/entities/diaspora/entities.py b/federation/entities/diaspora/entities.py
index e81bfa6..39d00ca 100644
--- a/federation/entities/diaspora/entities.py
+++ b/federation/entities/diaspora/entities.py
@@ -1,6 +1,6 @@
 from lxml import etree
 
-from federation.entities.base import Comment, Post, Reaction, Relationship, Profile, Retraction, BaseEntity
+from federation.entities.base import Comment, Post, Reaction, Relationship, Profile, Retraction, BaseEntity, Follow
 from federation.entities.diaspora.utils import format_dt, struct_to_xml, get_base_attributes
 from federation.exceptions import SignatureVerificationError
 from federation.protocols.diaspora.signatures import verify_relayable_signature, create_relayable_signature
@@ -104,7 +104,7 @@ class DiasporaLike(DiasporaRelayableMixin, Reaction):
 
 
 class DiasporaRequest(DiasporaEntityMixin, Relationship):
-    """Diaspora relationship request."""
+    """Diaspora legacy request."""
     relationship = "sharing"
 
     def to_xml(self):
@@ -117,6 +117,24 @@ class DiasporaRequest(DiasporaEntityMixin, Relationship):
         return element
 
 
+class DiasporaContact(DiasporaEntityMixin, Follow):
+    """Diaspora contact.
+
+    Note we don't implement 'sharing' at the moment so just send it as the same as 'following'.
+    """
+
+    def to_xml(self):
+        """Convert to XML message."""
+        element = etree.Element("contact")
+        struct_to_xml(element, [
+            {"author": self.handle},
+            {"recipient": self.target_handle},
+            {"following": "true" if self.following else "false"},
+            {"sharing": "true" if self.following else "false"},
+        ])
+        return element
+
+
 class DiasporaProfile(DiasporaEntityMixin, Profile):
     """Diaspora profile."""
 
diff --git a/federation/entities/diaspora/mappers.py b/federation/entities/diaspora/mappers.py
index 57828b3..22a7cd3 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
+from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile, Retraction, Follow
 from federation.entities.diaspora.entities import (
     DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction,
-    DiasporaRelayableMixin)
+    DiasporaRelayableMixin, DiasporaContact)
 from federation.protocols.diaspora.signatures import get_element_child_info
 from federation.utils.diaspora import retrieve_and_parse_profile
 
@@ -20,16 +20,19 @@ MAPPINGS = {
     "request": DiasporaRequest,
     "profile": DiasporaProfile,
     "retraction": DiasporaRetraction,
+    "contact": DiasporaContact,
 }
 
 TAGS = [
     # Order is important. Any top level tags should be before possibly child tags
-    "status_message", "comment", "like", "request", "profile", "retraction", "photo",
+    "status_message", "comment", "like", "request", "profile", "retraction", "photo", "contact",
 ]
 
 BOOLEAN_KEYS = (
     "public",
     "nsfw",
+    "following",
+    "sharing",
 )
 
 DATETIME_KEYS = (
@@ -205,7 +208,8 @@ def get_outbound_entity(entity, private_key):
     """
     outbound = None
     cls = entity.__class__
-    if cls in [DiasporaPost, DiasporaRequest, DiasporaComment, DiasporaLike, DiasporaProfile, DiasporaRetraction]:
+    if cls in [DiasporaPost, DiasporaRequest, DiasporaComment, DiasporaLike, DiasporaProfile, DiasporaRetraction,
+               DiasporaContact]:
         # Already fine
         outbound = entity
     elif cls == Post:
@@ -219,6 +223,8 @@ def get_outbound_entity(entity, private_key):
         if entity.relationship in ["sharing", "following"]:
             # Unfortunately we must send out in both cases since in Diaspora they are the same thing
             outbound = DiasporaRequest.from_base(entity)
+    elif cls == Follow:
+        outbound = DiasporaContact.from_base(entity)
     elif cls == Profile:
         outbound = DiasporaProfile.from_base(entity)
     elif cls == Retraction:


Test patch:
diff --git a/federation/tests/entities/diaspora/test_entities.py b/federation/tests/entities/diaspora/test_entities.py
index 32f8b2c..23d0ac0 100644
--- a/federation/tests/entities/diaspora/test_entities.py
+++ b/federation/tests/entities/diaspora/test_entities.py
@@ -6,7 +6,7 @@ from lxml import etree
 from federation.entities.base import Profile
 from federation.entities.diaspora.entities import (
     DiasporaComment, DiasporaPost, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction,
-)
+    DiasporaContact)
 from federation.exceptions import SignatureVerificationError
 from federation.tests.fixtures.keys import get_dummy_private_key
 
@@ -81,6 +81,14 @@ class TestEntitiesConvertToXML():
                     b"<target_guid>xxxxxxxxxxxxxxxx</target_guid><target_type>Post</target_type></retraction>"
         assert etree.tostring(result) == converted
 
+    def test_contact_to_xml(self):
+        entity = DiasporaContact(handle="alice@example.com", target_handle="bob@example.org", following=True)
+        result = entity.to_xml()
+        assert result.tag == "contact"
+        converted = b"<contact><author>alice@example.com</author><recipient>bob@example.org</recipient>" \
+                    b"<following>true</following><sharing>true</sharing></contact>"
+        assert etree.tostring(result) == 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 1516ae4..bd2b96b 100644
--- a/federation/tests/entities/diaspora/test_mappers.py
+++ b/federation/tests/entities/diaspora/test_mappers.py
@@ -4,18 +4,17 @@ from unittest.mock import patch, Mock
 import pytest
 
 from federation.entities.base import (
-    Comment, Post, Reaction, Relationship, Profile, Retraction, Image
-)
+    Comment, Post, Reaction, Relationship, Profile, Retraction, Image,
+    Follow)
 from federation.entities.diaspora.entities import (
     DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest,
-    DiasporaProfile, DiasporaRetraction
-)
+    DiasporaProfile, DiasporaRetraction, DiasporaContact)
 from federation.entities.diaspora.mappers import message_to_objects, get_outbound_entity
 from federation.tests.fixtures.keys import get_dummy_private_key
 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_POST_WITH_PHOTOS, DIASPORA_POST_LEGACY_TIMESTAMP, DIASPORA_POST_LEGACY, DIASPORA_CONTACT)
 
 
 def mock_fill(attributes):
@@ -155,6 +154,15 @@ class TestDiasporaEntityMappersReceive():
         assert entity.target_guid == "x" * 16
         assert entity.entity_type == "Post"
 
+    def test_message_to_objects_contact(self):
+        entities = message_to_objects(DIASPORA_CONTACT)
+        assert len(entities) == 1
+        entity = entities[0]
+        assert isinstance(entity, DiasporaContact)
+        assert entity.handle == "alice@example.com"
+        assert entity.target_handle == "bob@example.org"
+        assert entity.following is True
+
     @patch("federation.entities.diaspora.mappers.logger.error")
     def test_invalid_entity_logs_an_error(self, mock_logger):
         entities = message_to_objects(DIASPORA_POST_INVALID)
@@ -191,6 +199,8 @@ class TestGetOutboundEntity():
         assert get_outbound_entity(entity, dummy_key) == entity
         entity = DiasporaProfile()
         assert get_outbound_entity(entity, dummy_key) == entity
+        entity = DiasporaContact()
+        assert get_outbound_entity(entity, dummy_key) == entity
 
     def test_post_is_converted_to_diasporapost(self):
         entity = Post()
@@ -229,6 +239,10 @@ class TestGetOutboundEntity():
         entity = Retraction()
         assert isinstance(get_outbound_entity(entity, get_dummy_private_key()), DiasporaRetraction)
 
+    def test_follow_is_converted_to_diasporacontact(self):
+        entity = Follow()
+        assert isinstance(get_outbound_entity(entity, get_dummy_private_key()), DiasporaContact)
+
     def test_signs_relayable_if_no_signature(self):
         entity = DiasporaComment()
         dummy_key = get_dummy_private_key()
diff --git a/federation/tests/entities/test_base.py b/federation/tests/entities/test_base.py
index 6035868..5eaa10a 100644
--- a/federation/tests/entities/test_base.py
+++ b/federation/tests/entities/test_base.py
@@ -4,7 +4,7 @@ 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
 
 
@@ -160,3 +160,18 @@ class TestRetractionEntity():
         )
         with pytest.raises(ValueError):
             entity.validate()
+
+
+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()
diff --git a/federation/tests/fixtures/payloads.py b/federation/tests/fixtures/payloads.py
index b58c95e..ead44d5 100644
--- a/federation/tests/fixtures/payloads.py
+++ b/federation/tests/fixtures/payloads.py
@@ -173,3 +173,12 @@ DIASPORA_RETRACTION = """
         <target_type>Post</target_type>
     </retraction>
 """
+
+DIASPORA_CONTACT = """
+    <contact>
+        <author>alice@example.com</author>
+        <recipient>bob@example.org</recipient>
+        <following>true</following>
+        <sharing>true</sharing>
+    </contact>
+"""