Repository: falconry/falcon

Issue:
Update Response:add_link() to support setting the crossorigin attribute

Comments:

Patch:
diff --git a/docs/_newsfragments/1574-add-link-crossorigin.feature.rst b/docs/_newsfragments/1574-add-link-crossorigin.feature.rst
new file mode 100644
index 0000000..f5ea187
--- /dev/null
+++ b/docs/_newsfragments/1574-add-link-crossorigin.feature.rst
@@ -0,0 +1,2 @@
+The :meth:`~.Response.add_link` method now supports setting the `crossorigin`
+link CORS settings attribute.
diff --git a/falcon/response.py b/falcon/response.py
index 3042a5d..4d7e0ae 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -40,6 +40,8 @@ _STREAM_LEN_REMOVED_MSG = (
 )
 
 
+_RESERVED_CROSSORIGIN_VALUES = frozenset({'anonymous', 'use-credentials'})
+
 _RESERVED_SAMESITE_VALUES = frozenset({'lax', 'strict', 'none'})
 
 
@@ -668,7 +670,7 @@ class Response:
             _headers[name] = value
 
     def add_link(self, target, rel, title=None, title_star=None,
-                 anchor=None, hreflang=None, type_hint=None):
+                 anchor=None, hreflang=None, type_hint=None, crossorigin=None):
         """Add a link header to the response.
 
         (See also: RFC 5988, Section 1)
@@ -725,6 +727,9 @@ class Response:
                 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.
+            crossorigin(str):  Determines how cross origin requests are handled.
+                Can take values 'anonymous' or 'use-credentials' or None.
+                (See: https://www.w3.org/TR/html50/infrastructure.html#cors-settings-attribute)
 
         """
 
@@ -771,6 +776,19 @@ class Response:
         if anchor is not None:
             value += '; anchor="' + uri_encode(anchor) + '"'
 
+        if crossorigin is not None:
+            crossorigin = crossorigin.lower()
+            if crossorigin not in _RESERVED_CROSSORIGIN_VALUES:
+                raise ValueError(
+                    'crossorigin must be set to either '
+                    "'anonymous' or 'use-credentials'")
+            if crossorigin == 'anonymous':
+                value += '; crossorigin'
+            else:  # crossorigin == 'use-credentials'
+                # PERF(vytas): the only remaining value is inlined.
+                # Un-inline in case more values are supported in the future.
+                value += '; crossorigin="use-credentials"'
+
         _headers = self._headers
         if 'link' in _headers:
             _headers['link'] += ', ' + value


Test patch:
diff --git a/tests/test_headers.py b/tests/test_headers.py
index 1c29514..3d85eef 100644
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -701,6 +701,41 @@ class TestHeaders:
 
         self._check_link_header(client, resource, expected_value)
 
+    @pytest.mark.parametrize('crossorigin,expected_value', [
+        (None, '</related/thing>; rel=alternate'),
+        ('anonymous', '</related/thing>; rel=alternate; crossorigin'),
+        ('Anonymous', '</related/thing>; rel=alternate; crossorigin'),
+        ('AnOnYmOUs', '</related/thing>; rel=alternate; crossorigin'),
+        (
+            'Use-Credentials',
+            '</related/thing>; rel=alternate; crossorigin="use-credentials"',
+        ),
+        (
+            'use-credentials',
+            '</related/thing>; rel=alternate; crossorigin="use-credentials"',
+        ),
+    ])
+    def test_add_link_crossorigin(self, client, crossorigin, expected_value):
+        resource = LinkHeaderResource()
+        resource.add_link('/related/thing', 'alternate',
+                          crossorigin=crossorigin)
+
+        self._check_link_header(client, resource, expected_value)
+
+    @pytest.mark.parametrize('crossorigin', [
+        '*',
+        'Allow-all',
+        'Lax',
+        'MUST-REVALIDATE',
+        'Strict',
+        'deny',
+    ])
+    def test_add_link_invalid_crossorigin_value(self, crossorigin):
+        resp = falcon.Response()
+
+        with pytest.raises(ValueError):
+            resp.add_link('/related/resource', 'next', crossorigin=crossorigin)
+
     def test_content_length_options(self, client):
         result = client.simulate_options()