Browse Source

Merge pull request #119 from whikloj/issue-97

Add Preference-Applied header to GET
Stefano Cossu 4 years ago
parent
commit
17bbae0815
2 changed files with 307 additions and 19 deletions
  1. 84 19
      lakesuperior/endpoints/ldp.py
  2. 223 0
      tests/3_endpoints/test_3_0_ldp.py

+ 84 - 19
lakesuperior/endpoints/ldp.py

@@ -73,6 +73,13 @@ std_headers = {
 vw_blacklist = {
 }
 
+"""Prefer representations currently supported"""
+option_to_uri = {
+    'embed_children': Ldpr.EMBED_CHILD_RES_URI,
+    'incl_children': Ldpr.RETURN_CHILD_RES_URI,
+    'incl_inbound': Ldpr.RETURN_INBOUND_REF_URI,
+    'incl_srv_mgd': Ldpr.RETURN_SRV_MGD_RES_URI
+}
 
 ldp = Blueprint(
         'ldp', __name__, template_folder='templates',
@@ -162,7 +169,7 @@ def get_resource(uid, out_fmt=None):
         prefer = toolbox.parse_rfc7240(request.headers['prefer'])
         logger.debug('Parsed Prefer header: {}'.format(pformat(prefer)))
         if 'return' in prefer:
-            repr_options = parse_repr_options(prefer['return'])
+            repr_options = parse_repr_options(prefer['return'], out_headers)
 
     rsrc = rsrc_api.get(uid, repr_options)
 
@@ -643,7 +650,7 @@ def _set_post_put_params():
     return handling, disposition
 
 
-def parse_repr_options(retr_opts):
+def parse_repr_options(retr_opts, out_headers):
     """
     Set options to retrieve IMR.
 
@@ -651,9 +658,9 @@ def parse_repr_options(retr_opts):
     are set once in the `imr()` property.
 
     :param dict retr_opts:: Options parsed from `Prefer` header.
+    :param dict out_headers:: Response headers.
     """
     logger.debug('Parsing retrieval options: {}'.format(retr_opts))
-    imr_options = {}
 
     if retr_opts.get('value') == 'minimal':
         imr_options = {
@@ -662,6 +669,7 @@ def parse_repr_options(retr_opts):
             'incl_inbound' : False,
             'incl_srv_mgd' : False,
         }
+        out_headers['Preference-Applied'] = 'return="minimal"'
     else:
         # Default.
         imr_options = {
@@ -673,28 +681,85 @@ def parse_repr_options(retr_opts):
 
         # Override defaults.
         if 'parameters' in retr_opts:
-            include = retr_opts['parameters']['include'].split(' ') \
-                    if 'include' in retr_opts['parameters'] else []
-            omit = retr_opts['parameters']['omit'].split(' ') \
-                    if 'omit' in retr_opts['parameters'] else []
-
-            logger.debug('Include: {}'.format(include))
-            logger.debug('Omit: {}'.format(omit))
-
-            if str(Ldpr.EMBED_CHILD_RES_URI) in include:
-                    imr_options['embed_children'] = True
-            if str(Ldpr.RETURN_CHILD_RES_URI) in omit:
-                    imr_options['incl_children'] = False
-            if str(Ldpr.RETURN_INBOUND_REF_URI) in include:
-                    imr_options['incl_inbound'] = True
-            if str(Ldpr.RETURN_SRV_MGD_RES_URI) in omit:
-                    imr_options['incl_srv_mgd'] = False
+            try:
+                pref_imr_options = _valid_preferences(retr_opts)
+                include = list()
+                omit = list()
+                for k, v in pref_imr_options.items():
+                    # pref_imr_options only contains requested preferences,
+                    # override the defaults for those.
+                    imr_options[k] = v
+                    # This creates Preference-Applied headers.
+                    if v:
+                        list_holder = include
+                    else:
+                        list_holder = omit
+                    list_holder.append(str(option_to_uri[k]))
+                header_output = ''
+                if len(include) > 0:
+                    header_output += ' include="' + ' '.join(include) + '";'
+                if len(omit) > 0:
+                    header_output += ' omit="' + ' '.join(omit) + '";'
+                if len(header_output) > 0:
+                    out_headers['Preference-Applied'] = 'return=representation;'\
+                                                        + header_output
+            except KeyError:
+                # Invalid Prefer header so we disregard the entire thing.
+                pass
 
     logger.debug('Retrieval options: {}'.format(pformat(imr_options)))
 
     return imr_options
 
 
+def _preference_decision(include, omit, header):
+    """
+    Determine whether a header is in include or omit but not both.
+
+    :param include:: list of include preference uris
+    :param omit:: list of omit preference uris
+    :param header:: the uri to look for
+    :return: True if in include only or false if in omit only.
+    """
+    if str(header) in include or str(header) in omit:
+        if str(header) in include and str(header) in omit:
+            # You can't include and omit, so ignore it.
+            raise KeyError('Can\'t include and omit same preference')
+        else:
+            return str(header) in include
+    return None
+
+
+def _valid_preferences(retr_opts):
+    """
+    Parse the Preference header to determine which we are applying.
+
+    Re-used for response Preference-Applied header.
+
+    :param retr_opts: The incoming Preference header.
+    :return: list of options being applied.
+    """
+    imr_options = dict()
+    include = retr_opts['parameters']['include'].split(' ') \
+        if 'include' in retr_opts['parameters'] else []
+    omit = retr_opts['parameters']['omit'].split(' ') \
+        if 'omit' in retr_opts['parameters'] else []
+
+    logger.debug('Include: {}'.format(include))
+    logger.debug('Omit: {}'.format(omit))
+
+    distinct_representations = include.copy()
+    distinct_representations.extend(omit)
+    distinct_representations = set(distinct_representations)
+    uri_to_option = {str(v): k for k, v in option_to_uri.items()}
+    for uri in distinct_representations:
+        # Throws KeyError if we don't support the header
+        option = uri_to_option[uri]
+        imr_options[option] = _preference_decision(include, omit, uri)
+
+    return imr_options
+
+
 def _headers_from_metadata(rsrc, out_fmt='text/turtle'):
     """
     Create a dict of headers from a metadata graph.

+ 223 - 0
tests/3_endpoints/test_3_0_ldp.py

@@ -21,6 +21,7 @@ from lakesuperior import env
 from lakesuperior.dictionaries.namespaces import ns_collection as nsc
 from lakesuperior.model.ldp.ldpr import Ldpr
 
+from lakesuperior.util import toolbox
 
 digest_algo = env.app_globals.config['application']['uuid']['algo']
 
@@ -1715,11 +1716,17 @@ class TestPrefHeader:
             'Prefer' : 'return=representation; include={}'\
                     .format(Ldpr.EMBED_CHILD_RES_URI),
         })
+
         omit_embed_children_resp = self.client.get(parent_path, headers={
             'Prefer' : 'return=representation; omit={}'\
                     .format(Ldpr.EMBED_CHILD_RES_URI),
         })
 
+        self._assert_pref_applied(incl_embed_children_resp,
+                                  include=[Ldpr.EMBED_CHILD_RES_URI])
+        self._assert_pref_applied(omit_embed_children_resp,
+                                  omit=[Ldpr.EMBED_CHILD_RES_URI])
+
         default_gr = Graph().parse(data=cont_resp.data, format='turtle')
         incl_gr = Graph().parse(
                 data=incl_embed_children_resp.data, format='turtle')
@@ -1755,6 +1762,11 @@ class TestPrefHeader:
                     .format(Ldpr.RETURN_CHILD_RES_URI),
         })
 
+        self._assert_pref_applied(incl_children_resp,
+                                  include=[Ldpr.RETURN_CHILD_RES_URI])
+        self._assert_pref_applied(omit_children_resp,
+                                  omit=[Ldpr.RETURN_CHILD_RES_URI])
+
         default_gr = Graph().parse(data=cont_resp.data, format='turtle')
         incl_gr = Graph().parse(data=incl_children_resp.data, format='turtle')
         omit_gr = Graph().parse(data=omit_children_resp.data, format='turtle')
@@ -1786,6 +1798,11 @@ class TestPrefHeader:
                     .format(Ldpr.RETURN_INBOUND_REF_URI),
         })
 
+        self._assert_pref_applied(incl_inbound_resp,
+                                  include=[Ldpr.RETURN_INBOUND_REF_URI])
+        self._assert_pref_applied(omit_inbound_resp,
+                                  omit=[Ldpr.RETURN_INBOUND_REF_URI])
+
         default_gr = Graph().parse(data=cont_resp.data, format='turtle')
         incl_gr = Graph().parse(data=incl_inbound_resp.data, format='turtle')
         omit_gr = Graph().parse(data=omit_inbound_resp.data, format='turtle')
@@ -1817,6 +1834,11 @@ class TestPrefHeader:
                     .format(Ldpr.RETURN_SRV_MGD_RES_URI),
         })
 
+        self._assert_pref_applied(incl_srv_mgd_resp,
+                                  include=[Ldpr.RETURN_SRV_MGD_RES_URI])
+        self._assert_pref_applied(omit_srv_mgd_resp,
+                                  omit=[Ldpr.RETURN_SRV_MGD_RES_URI])
+
         default_gr = Graph().parse(data=cont_resp.data, format='turtle')
         incl_gr = Graph().parse(data=incl_srv_mgd_resp.data, format='turtle')
         omit_gr = Graph().parse(data=omit_srv_mgd_resp.data, format='turtle')
@@ -1859,6 +1881,207 @@ class TestPrefHeader:
         child_resp = self.client.get('/ldp/test_delete_no_tstone01/a')
         assert child_resp.status_code == 404
 
+    def test_contradicting_prefs(self):
+        """
+        Test include and omit the same preference. Does not apply a preference
+        or return a Preference-Applied header.
+        """
+        self.client.put('/ldp/test_contradicting_prefs01',
+                        content_type='text/turtle')
+
+        resp1 = self.client.get('/ldp/test_contradicting_prefs01', headers={
+            'prefer': (
+                'return=representation; include={0}; omit={0}'.format(
+                    Ldpr.RETURN_CHILD_RES_URI
+                )
+            )
+        })
+        assert resp1.status_code == 200
+        self._assert_pref_applied(resp1)
+
+        resp2 = self.client.get('/ldp/test_contradicting_prefs01', headers={
+            'prefer': (
+                'return=representation; include="{0} {1}"; omit={0}'.format(
+                    Ldpr.RETURN_CHILD_RES_URI,
+                    Ldpr.RETURN_SRV_MGD_RES_URI
+                )
+            )
+        })
+        assert resp2.status_code == 200
+        self._assert_pref_applied(resp2)
+
+        resp3 = self.client.get('/ldp/test_contradicting_prefs01', headers={
+            'prefer': (
+                'return=representation; include="{0} {1}"; omit="{0} {2}"'.format(
+                    Ldpr.EMBED_CHILD_RES_URI,
+                    Ldpr.RETURN_SRV_MGD_RES_URI,
+                    Ldpr.RETURN_INBOUND_REF_URI
+                )
+            )
+        })
+        assert resp3.status_code == 200
+        self._assert_pref_applied(resp3)
+
+    def test_multiple_preferences(self):
+        """
+        Test multiple include and/or omit preferences.
+        """
+        self.client.put('/ldp/test_multiple_preferences01',
+                        content_type='text/turtle')
+
+        resp1 = self.client.get('/ldp/test_multiple_preferences01', headers={
+            'prefer': (
+                'return=representation; include="{0} {1}"; omit="{2}"'.format(
+                    Ldpr.RETURN_CHILD_RES_URI,
+                    Ldpr.EMBED_CHILD_RES_URI,
+                    Ldpr.RETURN_SRV_MGD_RES_URI
+                )
+            )
+        })
+        assert resp1.status_code == 200
+        self._assert_pref_applied(
+            resp1,
+            include=[
+                Ldpr.RETURN_CHILD_RES_URI,
+                Ldpr.EMBED_CHILD_RES_URI
+            ],
+            omit=[
+                Ldpr.RETURN_SRV_MGD_RES_URI
+            ]
+        )
+
+    def test_invalid_preference(self):
+        """
+        Test to ensure Prefer headers with unknown include/omit URIs are
+        completely disregarded.
+        """
+        fake_preference = 'http://doesntexist.org/fake#preference'
+
+        self.client.put('/ldp/test_invalid_preference01',
+                        content_type='text/turtle')
+
+        resp1 = self.client.get('/ldp/test_invalid_preference01', headers={
+            'prefer': (
+                'return=representation; include="{0}"'.format(fake_preference)
+            )
+        })
+        assert resp1.status_code == 200
+        self._assert_pref_applied(resp1)
+
+        resp2 = self.client.get('/ldp/test_invalid_preference01', headers={
+            'prefer': 'return=representation; omit="{0}"'.format(fake_preference)
+        })
+        assert resp2.status_code == 200
+        self._assert_pref_applied(resp2)
+
+        resp3 = self.client.get('/ldp/test_invalid_preference01', headers={
+            'prefer': (
+                'return=representation; include="{0} {1}"'.format(
+                    fake_preference,
+                    Ldpr.EMBED_CHILD_RES_URI
+                )
+            )
+        })
+        assert resp3.status_code == 200
+        self._assert_pref_applied(resp3)
+
+        resp4 = self.client.get('/ldp/test_invalid_preference01', headers={
+            'prefer': (
+                'return=representation; omit="{0} {1}"'.format(
+                    fake_preference,
+                    Ldpr.EMBED_CHILD_RES_URI
+                )
+            )
+        })
+        assert resp4.status_code == 200
+        self._assert_pref_applied(resp4)
+
+        resp4 = self.client.get('/ldp/test_invalid_preference01', headers={
+            'prefer': (
+                'return=representation; include="{0}" omit="{1} {2}"'.format(
+                    fake_preference,
+                    Ldpr.EMBED_CHILD_RES_URI,
+                    Ldpr.RETURN_SRV_MGD_RES_URI
+                )
+            )
+        })
+        assert resp4.status_code == 200
+        self._assert_pref_applied(resp4)
+
+        resp5 = self.client.get('/ldp/test_invalid_preference01', headers={
+            'prefer': (
+                'return=representation; include="{0} {1}" omit="{2}"'.format(
+                    fake_preference,
+                    Ldpr.EMBED_CHILD_RES_URI,
+                    Ldpr.RETURN_SRV_MGD_RES_URI
+                )
+            )
+        })
+        assert resp5.status_code == 200
+        self._assert_pref_applied(resp5)
+
+    def test_return_minimal(self):
+        """
+        Test for Prefer: return=minimal
+        """
+        self.client.put('/ldp/test_return_minimal01', content_type='text/turtle')
+
+        resp1 = self.client.get('/ldp/test_return_minimal01', headers={
+            'prefer': 'return=minimal'
+        })
+        assert resp1.status_code == 200
+        self._assert_pref_applied(resp1, 'minimal')
+
+        resp2 = self.client.get('/ldp/test_return_minimal01', headers={
+            'prefer': 'return="minimal"'
+        })
+        assert resp2.status_code == 200
+        self._assert_pref_applied(resp2, 'minimal')
+
+    def _assert_pref_applied(self, response, value='representation', include=None, omit=None):
+        """
+        Utility to test a response for a Preference-Applied header with the
+        include or omit lists.
+
+        If both include and omit are empty and value is representation, it is
+        expected that there is NO Preference-Applied header.
+
+        :param response:: The client response.
+        :param string value:: The return=<value>.
+        :param list include:: Expected include URIs.
+        :param list omit:: Expected omit URIs.
+        """
+        if include is None and omit is None and value == 'representation':
+            assert 'Preference-Applied' not in response.headers
+        else:
+            if include is None:
+                include = []
+            if omit is None:
+                omit = []
+            assert 'Preference-Applied' in response.headers
+            headers = toolbox.parse_rfc7240(response.headers['Preference-Applied'])
+            assert headers['return']['value'] == value
+            if value == 'representation':
+                self._assert_pref_header_exists(include, headers['return'], 'include')
+                self._assert_pref_header_exists(omit, headers['return'], 'omit')
+
+    def _assert_pref_header_exists(self, expected, returned, type='include'):
+        """
+        Utility function to compare a list of expected preferences to an include
+        or omit string.
+
+        :param list expected:: List of expected preference URIs.
+        :param string returned:: Returned Prefer parameters
+        """
+        if len(expected) > 0:
+            expected = [str(k) for k in expected]
+            assert type in returned['parameters']
+            assert len(returned['parameters'][type]) > 0
+            received_prefs = returned['parameters'][type].split(' ')
+            for pref in received_prefs:
+                assert pref in expected
+        else:
+            assert type not in returned['parameters']
 
 
 #@pytest.mark.usefixtures('client_class')