Repository: falconry/falcon

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

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1574?src=pr&el=h1) Report
> Merging [#1574](https://codecov.io/gh/falconry/falcon/pull/1574?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/05ccbb1092e1e32df4edbc85a492b4bbc78adf27?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1574/graphs/tree.svg?width=650&token=zOtkfNQ3h6&height=150&src=pr)](https://codecov.io/gh/falconry/falcon/pull/1574?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1574   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          38      38           
  Lines        2567    2570    +3     
  Branches      392     394    +2     
======================================
+ Hits         2567    2570    +3
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1574?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/response.py](https://codecov.io/gh/falconry/falcon/pull/1574/diff?src=pr&el=tree#diff-ZmFsY29uL3Jlc3BvbnNlLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1574?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1574?src=pr&el=footer). Last update [05ccbb1...3628e2c](https://codecov.io/gh/falconry/falcon/pull/1574?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-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()