Repository: falconry/falcon

Issue:
Add a response helper for setting link header
Implemented as Request.add_link

See also: https://tools.ietf.org/html/rfc5988



Discussion:

Resolving patch:
diff --git a/falcon/response.py b/falcon/response.py
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -161,6 +161,119 @@ def set_headers(self, headers):
         for name, value in headers:
             _headers[name.lower()] = value
 
+    def add_link(self, target, rel, title=None, title_star=None,
+                 anchor=None, hreflang=None, type_hint=None):
+        """
+        Add a link header to the response.
+
+        See also: https://tools.ietf.org/html/rfc5988
+
+        Note:
+            Calling this method repeatedly will cause each link to be
+            appended to the Link header value, separated by commas.
+
+        Note:
+            So-called "link-extension" elements, as defined by RFC 5988,
+            are not yet supported. See also Issue #288.
+
+        Args:
+            target (str): Target IRI for the resource identified by the
+                link. Will be converted to a URI, if necessary, per
+                RFC 3987, Section 3.1.
+            rel (str): Relation type of the link, such as "next" or
+                "bookmark". See also http://goo.gl/618GHr for a list
+                of registered link relation types.
+
+        Kwargs:
+            title (str): Human-readable label for the destination of
+                the link (default None). If the title includes non-ASCII
+                characters, you will need to use `title_star` instead, or
+                provide both a US-ASCII version using `title` and a
+                Unicode version using `title_star`.
+            title_star (tuple of str): Localized title describing the
+                destination of the link (default None). The value must be a
+                two-member tuple in the form of (*language-tag*, *text*),
+                where *language-tag* is a standard language identifier as
+                defined in RFC 5646, Section 2.1, and *text* is a Unicode
+                string.
+
+                Note:
+                    *language-tag* may be an empty string, in which case the
+                    client will assume the language from the general context
+                    of the current request.
+
+                Note:
+                    *text* will always be encoded as UTF-8. If the string
+                    contains non-ASCII characters, it should be passed as
+                    a "unicode" type string (requires the 'u' prefix in
+                    Python 2).
+
+            anchor (str): Override the context IRI with a different URI
+                (default None). By default, the context IRI for the link is
+                simply the IRI of the requested resource. The value
+                provided may be a relative URI.
+            hreflang (str or iterable): Either a single *language-tag*, or
+                a list or tuple of such tags to provide a hint to the client
+                as to the language of the result of following the link. A
+                list of tags may be given in order to indicate to the
+                client that the target resource is available in multiple
+                languages.
+            type_hint(str): Provides a hint as to the media type of the
+                result of dereferencing the link (default None). As noted
+                in RFC 5988, this is only a hint and does not override the
+                Content-Type header returned when the link is followed.
+
+        """
+
+        # PERF(kgriffs): Heuristic to detect possiblity of an extension
+        # relation type, in which case it will be a URL that may contain
+        # reserved characters. Otherwise, don't waste time running the
+        # string through uri.encode
+        #
+        # Example values for rel:
+        #
+        #     "next"
+        #     "http://example.com/ext-type"
+        #     "https://example.com/ext-type"
+        #     "alternate http://example.com/ext-type"
+        #     "http://example.com/ext-type alternate"
+        #
+        if '//' in rel:
+            if ' ' in rel:
+                rel = ('"' +
+                       ' '.join([uri.encode(r) for r in rel.split()]) +
+                       '"')
+            else:
+                rel = '"' + uri.encode(rel) + '"'
+
+        value = '<' + uri.encode(target) + '>; rel=' + rel
+
+        if title is not None:
+            value += '; title="' + title + '"'
+
+        if title_star is not None:
+            value += ("; title*=UTF-8'" + title_star[0] + "'" +
+                      uri.encode_value(title_star[1]))
+
+        if type_hint is not None:
+            value += '; type="' + type_hint + '"'
+
+        if hreflang is not None:
+            if isinstance(hreflang, six.string_types):
+                value += '; hreflang=' + hreflang
+            else:
+                value += '; '
+                value += '; '.join(['hreflang=' + lang for lang in hreflang])
+
+        if anchor is not None:
+            value += '; anchor="' + uri.encode(anchor) + '"'
+
+        _headers = self._headers
+        if 'link' in _headers:
+            _headers['link'] += ', ' + value
+        else:
+            _headers['link'] = value
+
     cache_control = header_property(
         'Cache-Control',
         """Sets the Cache-Control header.


Test patch:
diff --git a/tests/test_headers.py b/tests/test_headers.py
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -123,10 +123,25 @@ def __init__(self, vary):
         self.vary = vary
 
     def on_get(self, req, resp):
-        resp.body = "{}"
+        resp.body = '{}'
         resp.vary = self.vary
 
 
+class LinkHeaderResource:
+
+    def __init__(self):
+        self._links = []
+
+    def add_link(self, *args, **kwargs):
+        self._links.append((args, kwargs))
+
+    def on_get(self, req, resp):
+        resp.body = '{}'
+
+        for args, kwargs in self._links:
+            resp.add_link(*args, **kwargs)
+
+
 class TestHeaders(testing.TestBase):
 
     def before(self):
@@ -408,3 +423,124 @@ def test_custom_content_type(self):
 
         self.simulate_request(self.test_route)
         self.assertIn(('content-type', content_type), self.srmock.headers)
+
+    def test_add_link_single(self):
+        expected_value = '</things/2842>; rel=next'
+
+        self.resource = LinkHeaderResource()
+        self.resource.add_link('/things/2842', 'next')
+
+        self._check_link_header(expected_value)
+
+    def test_add_link_multiple(self):
+        expected_value = (
+            '</things/2842>; rel=next, ' +
+            '<http://%C3%A7runchy/bacon>; rel=contents, ' +
+            '<ab%C3%A7>; rel="http://example.com/ext-type", ' +
+            '<ab%C3%A7>; rel="http://example.com/%C3%A7runchy", ' +
+            '<ab%C3%A7>; rel="https://example.com/too-%C3%A7runchy", ' +
+            '</alt-thing>; rel="alternate http://example.com/%C3%A7runchy"')
+
+        uri = u'ab\u00e7' if six.PY3 else 'ab\xc3\xa7'
+
+        self.resource = LinkHeaderResource()
+        self.resource.add_link('/things/2842', 'next')
+        self.resource.add_link(u'http://\u00e7runchy/bacon', 'contents')
+        self.resource.add_link(uri, 'http://example.com/ext-type')
+        self.resource.add_link(uri, u'http://example.com/\u00e7runchy')
+        self.resource.add_link(uri, u'https://example.com/too-\u00e7runchy')
+        self.resource.add_link('/alt-thing',
+                               u'alternate http://example.com/\u00e7runchy')
+
+        self._check_link_header(expected_value)
+
+    def test_add_link_with_title(self):
+        expected_value = ('</related/thing>; rel=item; '
+                          'title="A related thing"')
+
+        self.resource = LinkHeaderResource()
+        self.resource.add_link('/related/thing', 'item',
+                               title='A related thing')
+
+        self._check_link_header(expected_value)
+
+    def test_add_link_with_title_star(self):
+        expected_value = ('</related/thing>; rel=item; '
+                          "title*=UTF-8''A%20related%20thing, "
+                          '</%C3%A7runchy/thing>; rel=item; '
+                          "title*=UTF-8'en'A%20%C3%A7runchy%20thing")
+
+        self.resource = LinkHeaderResource()
+        self.resource.add_link('/related/thing', 'item',
+                               title_star=('', 'A related thing'))
+
+        self.resource.add_link(u'/\u00e7runchy/thing', 'item',
+                               title_star=('en', u'A \u00e7runchy thing'))
+
+        self._check_link_header(expected_value)
+
+    def test_add_link_with_anchor(self):
+        expected_value = ('</related/thing>; rel=item; '
+                          'anchor="/some%20thing/or-other"')
+
+        self.resource = LinkHeaderResource()
+        self.resource.add_link('/related/thing', 'item',
+                               anchor='/some thing/or-other')
+
+        self._check_link_header(expected_value)
+
+    def test_add_link_with_hreflang(self):
+        expected_value = ('</related/thing>; rel=about; '
+                          'hreflang=en')
+
+        self.resource = LinkHeaderResource()
+        self.resource.add_link('/related/thing', 'about',
+                               hreflang='en')
+
+        self._check_link_header(expected_value)
+
+    def test_add_link_with_hreflang_multi(self):
+        expected_value = ('</related/thing>; rel=about; '
+                          'hreflang=en-GB; hreflang=de')
+
+        self.resource = LinkHeaderResource()
+        self.resource.add_link('/related/thing', 'about',
+                               hreflang=('en-GB', 'de'))
+
+        self._check_link_header(expected_value)
+
+    def test_add_link_with_type_hint(self):
+        expected_value = ('</related/thing>; rel=alternate; '
+                          'type="video/mp4; codecs=avc1.640028"')
+
+        self.resource = LinkHeaderResource()
+        self.resource.add_link('/related/thing', 'alternate',
+                               type_hint='video/mp4; codecs=avc1.640028')
+
+        self._check_link_header(expected_value)
+
+    def test_add_link_complex(self):
+        expected_value = ('</related/thing>; rel=alternate; '
+                          'title="A related thing"; '
+                          "title*=UTF-8'en'A%20%C3%A7runchy%20thing; "
+                          'type="application/json"; '
+                          'hreflang=en-GB; hreflang=de')
+
+        self.resource = LinkHeaderResource()
+        self.resource.add_link('/related/thing', 'alternate',
+                               title='A related thing',
+                               hreflang=('en-GB', 'de'),
+                               type_hint='application/json',
+                               title_star=('en', u'A \u00e7runchy thing'))
+
+        self._check_link_header(expected_value)
+
+    # ----------------------------------------------------------------------
+    # Helpers
+    # ----------------------------------------------------------------------
+
+    def _check_link_header(self, expected_value):
+        self.api.add_route(self.test_route, self.resource)
+
+        self.simulate_request(self.test_route)
+        self.assertEqual(expected_value, self.srmock.headers_dict['link'])