Repository: fair-workflows/nanopub

Issue:
Agree a fix for the (wrong) URI returned when publishing to test server
I pulled out a snippet from what @miguel-ruano said in an issue on Nanotate:

> i made the update of nanopub in nanotate and make changes according of the new version and works perfect!!!,
> thanks for the update,
> 
> testing in test mode, i see something when publishing the nanopublication the return url is like http://purl.org/np/RAdG9VXVOBq-7Ju5H085CgynfrVlwpajLb02B-W0ss8Zc,
> when i go to url this returns 404(not found), because the nanopub not is publish in http://server.nanopubs.lod.labs.vu.nl/ if not in http://test-server.nanopubs.lod.labs.vu.nl/
> 
> seeing the nanopub code exactly in the NanopubClient publish method
> 
> the process is the following
> first sign the rdf and then publish it
> 
> in the process of sign the rdf, the code seems not know about the test enviroment and always url base is like http://purl.org/np/RAdG9VXVOBq-7Ju5H085CgynfrVlwpajLb02B-W0ss8Zc
> 
> ```
> def sign(self, unsigned_file: Union[str, Path]) -> str:
> 	unsigned_file = str(unsigned_file)
>         self._run_command(f'{NANOPUB_SCRIPT} sign ' + unsigned_file)
>         return self._get_signed_file(unsigned_file)
> ```
> 
> so when extract the nanopub uri, after publishing in test server
> 
> ```
>  @staticmethod
>  def extract_nanopub_url(signed: Union[str, Path]):
>       # Extract nanopub URL
>       # (this is pretty horrible, switch to python version as soon as it is ready)
>       extracturl = rdflib.Graph()
>       extracturl.parse(str(signed), format="trig")
>       return dict(extracturl.namespaces())['this'].__str__()
> ```
> 
> always return the same url for test or production mode, i guess that bug is in the sign method.
> 
> I hope this helps you to solve it in the future.

Can we make a quick fix for this by just modifying the wrapper methods he mentions above? Or is there something else we need to be careful about?

Comments:

Patch:
diff --git a/nanopub/client.py b/nanopub/client.py
index 88fadf8..5e6ba62 100644
--- a/nanopub/client.py
+++ b/nanopub/client.py
@@ -19,7 +19,7 @@ NANOPUB_GRLC_URLS = ["http://grlc.nanopubs.lod.labs.vu.nl/api/local/local/",
                      "http://grlc.np.scify.org/api/local/local/",
                      "http://grlc.np.dumontierlab.com/api/local/local/"]
 NANOPUB_TEST_GRLC_URL = 'http://test-grlc.nanopubs.lod.labs.vu.nl/api/local/local/'
-
+NANOPUB_TEST_URL = 'http://test-server.nanopubs.lod.labs.vu.nl/'
 
 @unique
 class Formats(Enum):
@@ -158,8 +158,7 @@ class NanopubClient:
 
         return nanopubs
 
-    @staticmethod
-    def fetch(uri, format: str = 'trig'):
+    def fetch(self, uri, format: str = 'trig'):
         """
         Download the nanopublication at the specified URI (in specified format).
 
@@ -174,12 +173,16 @@ class NanopubClient:
                              f'{[format.value for format in Formats]})')
 
         r = requests.get(uri + extension)
+        if not r.ok and self.use_test_server:
+            # Let's try the test server
+            nanopub_id = uri.rsplit('/', 1)[-1]
+            uri = NANOPUB_TEST_URL + nanopub_id
+            r = requests.get(uri + extension)
         r.raise_for_status()
 
-        if r.ok:
-            nanopub_rdf = rdflib.ConjunctiveGraph()
-            nanopub_rdf.parse(data=r.text, format=format)
-            return Publication(rdf=nanopub_rdf, source_uri=uri)
+        nanopub_rdf = rdflib.ConjunctiveGraph()
+        nanopub_rdf.parse(data=r.text, format=format)
+        return Publication(rdf=nanopub_rdf, source_uri=uri)
 
     def publish(self, nanopub: Publication):
         """
@@ -230,10 +233,61 @@ class NanopubClient:
 
         publication = Publication.from_assertion(assertion_rdf=assertion_rdf,
                                                  attribute_to_profile=True)
-        
+
         # TODO: This is a hacky solution, should be changed once we can add provenance triples to
         #  from_assertion method.
         publication.provenance.add((rdflib.URIRef(profile.get_orcid_id()),
                                     namespaces.HYCL.claims,
                                     rdflib.URIRef(DEFAULT_NANOPUB_URI + '#mystatement')))
         self.publish(publication)
+
+        nanopub = Publication.from_assertion(assertion_rdf=assertion_rdf)
+        self.publish(nanopub)
+
+    def _check_public_keys_match(self, uri):
+        """ Check for matching public keys of a nanopublication with the profile.
+
+        Raises:
+            AssertionError: When the nanopublication is signed with a public key that does not
+                match the public key in the profile
+        """
+        publication = self.fetch(uri)
+        their_public_keys = list(publication.pubinfo.objects(rdflib.URIRef(uri + '#sig'),
+                                                             namespaces.NPX.hasPublicKey))
+        if len(their_public_keys) > 0:
+            their_public_key = str(their_public_keys[0])
+            if len(their_public_keys) > 1:
+                warnings.warn(f'Nanopublication is signed with multiple public keys, we will use '
+                              f'this one: {their_public_key}')
+            if their_public_key != profile.get_public_key():
+                raise AssertionError('The public key in your profile does not match the public key'
+                                     'that the publication that you want to retract is signed '
+                                     'with. Use force=True to force retraction anyway.')
+
+    def retract(self, uri: str, force=False):
+        """ Retract a nanopublication.
+
+        Publish a retraction nanpublication that declares retraction of the nanopublication that
+        corresponds to the 'uri' argument.
+
+        Args:
+            uri: The uri pointing to the to-be-retracted nanopublication
+            force: Toggle using force to retract, this will even retract the nanopublication if
+                it is signed with a different public key than the one in the user profile.
+
+        Returns:
+            publication info dictionary with keys 'concept_uri' and 'nanopub_uri' of the
+                retraction nanopublication
+        """
+        if not force:
+            self._check_public_keys_match(uri)
+        assertion_rdf = rdflib.Graph()
+        orcid_id = profile.get_orcid_id()
+        if orcid_id is None:
+            raise RuntimeError('You need to setup your profile with ORCID iD in order to retract a '
+                               'nanopublication, see the instructions in the README')
+        assertion_rdf.add((rdflib.URIRef(orcid_id), namespaces.NPX.retracts,
+                           rdflib.URIRef(uri)))
+        publication = Publication.from_assertion(assertion_rdf=assertion_rdf,
+                                                 attribute_to_profile=True)
+        return self.publish(publication)
diff --git a/nanopub/profile.py b/nanopub/profile.py
index 3c16277..8a55a23 100644
--- a/nanopub/profile.py
+++ b/nanopub/profile.py
@@ -8,8 +8,8 @@ from nanopub.definitions import PROFILE_PATH
 
 ORCID_ID = 'orcid_id'
 NAME = 'name'
-PUBLIC_KEY = 'public_key'
-PRIVATE_KEY = 'private_key'
+PUBLIC_KEY_FILEPATH = 'public_key'
+PRIVATE_KEY_FILEPATH = 'private_key'
 PROFILE_NANOPUB = 'profile_nanopub'
 
 
@@ -17,6 +17,12 @@ def get_orcid_id():
     return get_profile()[ORCID_ID]
 
 
+def get_public_key():
+    filepath = get_profile()[PUBLIC_KEY_FILEPATH]
+    with open(filepath, 'r') as f:
+        return f.read()
+
+
 @lru_cache()
 def get_profile() -> Dict[str, any]:
     """
@@ -32,8 +38,8 @@ def get_profile() -> Dict[str, any]:
 
 def store_profile(name: str, orcid_id: str, public_key: Path, private_key: Path,
                   profile_nanopub_uri: str = None):
-    profile = {NAME: name, ORCID_ID: orcid_id, PUBLIC_KEY: str(public_key),
-               PRIVATE_KEY: str(private_key)}
+    profile = {NAME: name, ORCID_ID: orcid_id, PUBLIC_KEY_FILEPATH: str(public_key),
+               PRIVATE_KEY_FILEPATH: str(private_key)}
 
     if profile_nanopub_uri:
         profile[PROFILE_NANOPUB] = profile_nanopub_uri


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index b1d9861..754e0ab 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -88,15 +88,14 @@ class TestNanopubClient:
         Check that Nanopub fetch is returning results for a few known nanopub URIs.
         """
         known_nps = [
-            'http://purl.org/np/RAFNR1VMQC0AUhjcX2yf94aXmG1uIhteGXpq12Of88l78',
-            'http://purl.org/np/RAePO1Fi2Wp1ARk2XfOnTTwtTkAX1FBU3XuCwq7ng0jIo',
-            'http://purl.org/np/RA48Iprh_kQvb602TR0ammkR6LQsYHZ8pyZqZTPQIl17s'
+            'http://purl.org/np/RANGY8fx_EYVeZzJOinH9FoY-WrQBerKKUy2J9RCDWH6U',
+            'http://purl.org/np/RAABh3eQwmkdflVp50zYavHUK0NgZE2g2ewS2j4Ur6FHI',
+            'http://purl.org/np/RA8to60YFWSVCh2n_iyHZ2yiYEt-hX_DdqbWa5yI9r-gI'
         ]
 
         for np_uri in known_nps:
             np = client.fetch(np_uri, format='trig')
             assert isinstance(np, Publication)
-            assert np.source_uri == np_uri
             assert len(np.rdf) > 0
             assert np.assertion is not None
             assert np.pubinfo is not None
@@ -143,3 +142,33 @@ class TestNanopubClient:
         pubinfo = client.publish(nanopub)
         assert pubinfo['nanopub_uri'] == test_published_uri
         assert pubinfo['concept_uri'] == expected_concept_uri
+
+    def test_retract_with_force(self):
+        client = NanopubClient()
+        client.java_wrapper.publish = mock.MagicMock()
+        client.retract('http://www.example.com/my-nanopub', force=True)
+
+    @mock.patch('nanopub.client.profile.get_public_key')
+    def test_retract_without_force(self, mock_get_public_key):
+        test_uri = 'http://www.example.com/my-nanopub'
+        test_public_key = 'test key'
+        client = NanopubClient()
+        client.java_wrapper.publish = mock.MagicMock()
+
+        # Return a mocked to-be-retracted publication object that is signed with public key
+        mock_publication = mock.MagicMock()
+        mock_publication.pubinfo = rdflib.Graph()
+        mock_publication.pubinfo.add((rdflib.URIRef(test_uri + '#sig'),
+                                      namespaces.NPX.hasPublicKey,
+                                      rdflib.Literal(test_public_key)))
+        client.fetch = mock.MagicMock(return_value=mock_publication)
+
+        # Retract should be successful when public keys match
+        mock_get_public_key.return_value = test_public_key
+        client.retract(test_uri)
+
+        # And fail if they don't match
+        mock_get_public_key.return_value = 'Different public key'
+        with pytest.raises(AssertionError):
+            client.retract(test_uri)
+