Repository: Pylons/pyramid

Issue:
Allow multiple values for `header` predicate
## Feature Request

**Is your feature request related to an issue? Please describe.**
We can use predicates like `header="X-GitHub-Whatever"` or `header="X-Facebook-Thing:cool-value"` within a route or view config.  However, it is not possible to specify more than one header, so one would need to write a custom predicate here, or perform checks inside the view.

**Describe the solution you'd like**
`header` should accept a string or tuple of strings, like `request_param` does.

**Describe alternatives you've considered**
New parameter `headers` always taking a sequence—would not fit with existing behaviour for `request_param` and others.

**Additional context**
Implementation should be easy in `pyramid.predicates.HeaderPredicate`.
Tuples were not accepted before, so no compatibility issue.



Discussion:
In general the official predicates are always an "and" operation, but if that's what you're proposing then I see no issue with accepting a PR that added that to the predicate.
Yes, the request was to have all headers matching, like other built-in predicates.
PR incoming

Resolving patch:
diff --git a/src/pyramid/config/routes.py b/src/pyramid/config/routes.py
--- a/src/pyramid/config/routes.py
+++ b/src/pyramid/config/routes.py
@@ -211,31 +211,32 @@ def add_route(
           dictionary (an HTTP ``GET`` or ``POST`` variable) that has a
           name which matches the supplied value.  If the value
           supplied as the argument has a ``=`` sign in it,
-          e.g. ``request_param="foo=123"``, then the key
-          (``foo``) must both exist in the ``request.params`` dictionary, and
+          e.g. ``request_param="foo=123"``, then both the key
+          (``foo``) must exist in the ``request.params`` dictionary, and
           the value must match the right hand side of the expression (``123``)
           for the route to "match" the current request.  If this predicate
           returns ``False``, route matching continues.
 
         header
 
-          This argument represents an HTTP header name or a header
-          name/value pair.  If the argument contains a ``:`` (colon),
-          it will be considered a name/value pair
-          (e.g. ``User-Agent:Mozilla/.*`` or ``Host:localhost``).  If
-          the value contains a colon, the value portion should be a
-          regular expression.  If the value does not contain a colon,
-          the entire value will be considered to be the header name
-          (e.g. ``If-Modified-Since``).  If the value evaluates to a
-          header name only without a value, the header specified by
-          the name must be present in the request for this predicate
-          to be true.  If the value evaluates to a header name/value
-          pair, the header specified by the name must be present in
-          the request *and* the regular expression specified as the
-          value must match the header value.  Whether or not the value
-          represents a header name or a header name/value pair, the
-          case of the header name is not significant.  If this
-          predicate returns ``False``, route matching continues.
+          This argument can be a string or an iterable of strings for HTTP
+          headers.  The matching is determined as follow:
+
+          - If a string does not contain a ``:`` (colon), it will be
+            considered to be the header name (example ``If-Modified-Since``).
+            In this case, the header specified by the name must be present
+            in the request for this string to match.  Case is not significant.
+
+          - If a string contains a colon, it will be considered a
+            name/value pair (for example ``User-Agent:Mozilla/.*`` or
+            ``Host:localhost``), where the value part is a regular
+            expression.  The header specified by the name must be present
+            in the request *and* the regular expression specified as the
+            value part must match the value of the request header.  Case is
+            not significant for the header name, but it is for the value.
+
+          All strings must be matched for this predicate to return ``True``.
+          If this predicate returns ``False``, route matching continues.
 
         accept
 
diff --git a/src/pyramid/config/views.py b/src/pyramid/config/views.py
--- a/src/pyramid/config/views.py
+++ b/src/pyramid/config/views.py
@@ -670,22 +670,24 @@ def wrapper(context, request):
 
         header
 
-          This value represents an HTTP header name or a header
-          name/value pair.  If the value contains a ``:`` (colon), it
-          will be considered a name/value pair
-          (e.g. ``User-Agent:Mozilla/.*`` or ``Host:localhost``).  The
-          value portion should be a regular expression.  If the value
-          does not contain a colon, the entire value will be
-          considered to be the header name
-          (e.g. ``If-Modified-Since``).  If the value evaluates to a
-          header name only without a value, the header specified by
-          the name must be present in the request for this predicate
-          to be true.  If the value evaluates to a header name/value
-          pair, the header specified by the name must be present in
-          the request *and* the regular expression specified as the
-          value must match the header value.  Whether or not the value
-          represents a header name or a header name/value pair, the
-          case of the header name is not significant.
+          This argument can be a string or an iterable of strings for HTTP
+          headers.  The matching is determined as follow:
+
+          - If a string does not contain a ``:`` (colon), it will be
+            considered to be a header name (example ``If-Modified-Since``).
+            In this case, the header specified by the name must be present
+            in the request for this string to match.  Case is not significant.
+
+          - If a string contains a colon, it will be considered a
+            name/value pair (for example ``User-Agent:Mozilla/.*`` or
+            ``Host:localhost``), where the value part is a regular
+            expression.  The header specified by the name must be present
+            in the request *and* the regular expression specified as the
+            value part must match the value of the request header.  Case is
+            not significant for the header name, but it is for the value.
+
+          All strings must be matched for this predicate to return ``True``.
+          If this predicate returns ``False``, view matching continues.
 
         path_info
 
diff --git a/src/pyramid/predicates.py b/src/pyramid/predicates.py
--- a/src/pyramid/predicates.py
+++ b/src/pyramid/predicates.py
@@ -98,33 +98,43 @@ def __call__(self, context, request):
 
 class HeaderPredicate:
     def __init__(self, val, config):
-        name = val
-        v = None
-        if ':' in name:
-            name, val_str = name.split(':', 1)
-            try:
-                v = re.compile(val_str)
-            except re.error as why:
-                raise ConfigurationError(why.args[0])
-        if v is None:
-            self._text = 'header %s' % (name,)
-        else:
-            self._text = 'header %s=%s' % (name, val_str)
-        self.name = name
-        self.val = v
+        values = []
+
+        val = as_sorted_tuple(val)
+        for name in val:
+            v, val_str = None, None
+            if ':' in name:
+                name, val_str = name.split(':', 1)
+                try:
+                    v = re.compile(val_str)
+                except re.error as why:
+                    raise ConfigurationError(why.args[0])
+
+            values.append((name, v, val_str))
+
+        self.val = values
 
     def text(self):
-        return self._text
+        return 'header %s' % ', '.join(
+            '%s=%s' % (name, val_str) if val_str else name
+            for name, _, val_str in self.val
+        )
 
     phash = text
 
     def __call__(self, context, request):
-        if self.val is None:
-            return self.name in request.headers
-        val = request.headers.get(self.name)
-        if val is None:
-            return False
-        return self.val.match(val) is not None
+        for name, val, _ in self.val:
+            if val is None:
+                if name not in request.headers:
+                    return False
+            else:
+                value = request.headers.get(name)
+                if value is None:
+                    return False
+                if val.match(value) is None:
+                    return False
+
+        return True
 
 
 class AcceptPredicate:


Test patch:
diff --git a/tests/test_config/test_predicates.py b/tests/test_config/test_predicates.py
--- a/tests/test_config/test_predicates.py
+++ b/tests/test_config/test_predicates.py
@@ -312,6 +312,20 @@ def test_predicate_text_is_correct(self):
         self.assertEqual(predicates[10].text(), 'classmethod predicate')
         self.assertTrue(predicates[11].text().startswith('custom predicate'))
 
+    def test_predicate_text_is_correct_when_multiple(self):
+        _, predicates, _ = self._callFUT(
+            request_method=('one', 'two'),
+            request_param=('par2=on', 'par1'),
+            header=('header2', 'header1:val.*'),
+            accept=('accept1', 'accept2'),
+            match_param=('foo=bar', 'baz=bim'),
+        )
+        self.assertEqual(predicates[0].text(), "request_method = one,two")
+        self.assertEqual(predicates[1].text(), 'request_param par1,par2=on')
+        self.assertEqual(predicates[2].text(), 'header header1=val.*, header2')
+        self.assertEqual(predicates[3].text(), 'accept = accept1, accept2')
+        self.assertEqual(predicates[4].text(), "match_param baz=bim,foo=bar")
+
     def test_match_param_from_string(self):
         _, predicates, _ = self._callFUT(match_param='foo=bar')
         request = DummyRequest()
@@ -354,6 +368,72 @@ def test_request_method_ordering_hashes_same(self):
         hash2, _, __ = self._callFUT(request_method='GET')
         self.assertEqual(hash1, hash2)
 
+    def test_header_simple(self):
+        _, predicates, _ = self._callFUT(header='foo')
+        request = DummyRequest()
+        request.headers = {'foo': 'bars', 'baz': 'foo'}
+        self.assertTrue(predicates[0](Dummy(), request))
+
+    def test_header_simple_fails(self):
+        _, predicates, _ = self._callFUT(header='content-length')
+        request = DummyRequest()
+        request.headers = {'foo': 'bars', 'baz': 'foo'}
+        self.assertFalse(predicates[0](Dummy(), request))
+
+    def test_header_with_value(self):
+        _, predicates, _ = self._callFUT(header='foo:bar')
+        request = DummyRequest()
+        request.headers = {'foo': 'bars', 'baz': 'foo'}
+        self.assertTrue(predicates[0](Dummy(), request))
+
+    def test_header_with_value_fails(self):
+        _, predicates, _ = self._callFUT(header='foo:bar')
+        request = DummyRequest()
+        request.headers = {'foo': 'nobar', 'baz': 'foo'}
+        self.assertFalse(predicates[0](Dummy(), request))
+
+    def test_header_with_value_fails_case(self):
+        _, predicates, _ = self._callFUT(header='foo:bar')
+        request = DummyRequest()
+        request.headers = {'foo': 'BAR'}
+        self.assertFalse(predicates[0](Dummy(), request))
+
+    def test_header_multiple(self):
+        _, predicates, _ = self._callFUT(header=('foo', 'content-length'))
+        request = DummyRequest()
+        request.headers = {'foo': 'bars', 'content-length': '42'}
+        self.assertTrue(predicates[0](Dummy(), request))
+
+    def test_header_multiple_fails(self):
+        _, predicates, _ = self._callFUT(header=('foo', 'content-encoding'))
+        request = DummyRequest()
+        request.headers = {'foo': 'bars', 'content-length': '42'}
+        self.assertFalse(predicates[0](Dummy(), request))
+
+    def test_header_multiple_with_values(self):
+        _, predicates, _ = self._callFUT(header=('foo:bar', 'spam:egg'))
+        request = DummyRequest()
+        request.headers = {'foo': 'bars', 'spam': 'eggs'}
+        self.assertTrue(predicates[0](Dummy(), request))
+
+    def test_header_multiple_with_values_fails(self):
+        _, predicates, _ = self._callFUT(header=('foo:bar', 'spam:egg$'))
+        request = DummyRequest()
+        request.headers = {'foo': 'bars', 'spam': 'eggs'}
+        self.assertFalse(predicates[0](Dummy(), request))
+
+    def test_header_multiple_mixed(self):
+        _, predicates, _ = self._callFUT(header=('foo:bar', 'spam'))
+        request = DummyRequest()
+        request.headers = {'foo': 'bars', 'spam': 'ham'}
+        self.assertTrue(predicates[0](Dummy(), request))
+
+    def test_header_multiple_mixed_fails(self):
+        _, predicates, _ = self._callFUT(header=('foo:bar', 'spam'))
+        request = DummyRequest()
+        request.headers = {'foo': 'nobar', 'spamme': 'ham'}
+        self.assertFalse(predicates[0](Dummy(), request))
+
     def test_unknown_predicate(self):
         from pyramid.exceptions import ConfigurationError
 
@@ -472,6 +552,7 @@ def __init__(self, environ=None):
             environ = {}
         self.environ = environ
         self.params = {}
+        self.headers = {}
         self.cookies = {}