Repository: twisted/twisted

Issue:
Add a --add-header flag to twist web
|[<img alt="alex's avatar" src="https://avatars.githubusercontent.com/u/772?s=50" width="50" height="50">](https://github.com/alex)| @alex reported|
|-|-|
|Trac ID|trac#9241|
|Type|enhancement|
|Created|2017-07-28 12:17:46Z|

This would make it easy to add headers to all responses. This is useful for things like "adding an HSTS header to all responses in a static web app, without writing code".

<details><summary>Searchable metadata</summary>

```
trac-id__9241 9241
type__enhancement enhancement
reporter__alex alex
priority__normal normal
milestone__None None
branch__ 
branch_author__ 
status__closed closed
resolution__fixed fixed
component__core core
keywords__None None
time__1501244266950695 1501244266950695
changetime__1501595055861628 1501595055861628
version__None None
owner__Alex Alex

```
</details>



Discussion:

Resolving patch:
diff --git a/src/twisted/web/tap.py b/src/twisted/web/tap.py
--- a/src/twisted/web/tap.py
+++ b/src/twisted/web/tap.py
@@ -15,7 +15,7 @@
 from twisted.python import usage, reflect, threadpool
 from twisted.spread import pb
 from twisted.web import distrib
-from twisted.web import server, static, script, demo, wsgi
+from twisted.web import resource, server, static, script, demo, wsgi
 from twisted.web import twcgi
 
 
@@ -65,6 +65,7 @@ def __init__(self):
         usage.Options.__init__(self)
         self['indexes'] = []
         self['root'] = None
+        self['extraHeaders'] = []
 
 
     def opt_index(self, indexName):
@@ -174,6 +175,15 @@ def opt_ignore_ext(self, ext):
         self['root'].ignoreExt(ext)
 
 
+    def opt_add_header(self, header):
+        """
+        Specify an additional header to be included in all responses. Specified
+        as "HeaderName: HeaderValue".
+        """
+        name, value = header.split(':', 1)
+        self['extraHeaders'].append((name.strip(), value.strip()))
+
+
     def postOptions(self):
         """
         Set up conditional defaults and check for dependencies.
@@ -211,6 +221,19 @@ def makePersonalServerFactory(site):
 
 
 
+class _AddHeadersResource(resource.Resource):
+    def __init__(self, originalResource, headers):
+        self._originalResource = originalResource
+        self._headers = headers
+
+
+    def getChildWithDefault(self, name, request):
+        for k, v in self._headers:
+            request.responseHeaders.addRawHeader(k, v)
+        return self._originalResource.getChildWithDefault(name, request)
+
+
+
 def makeService(config):
     s = service.MultiService()
     if config['root']:
@@ -224,6 +247,9 @@ def makeService(config):
     if isinstance(root, static.File):
         root.registry.setComponent(interfaces.IServiceCollection, s)
 
+    if config['extraHeaders']:
+        root = _AddHeadersResource(root, config['extraHeaders'])
+
     if config['logfile']:
         site = server.Site(root, logPath=config['logfile'])
     else:


Test patch:
diff --git a/src/twisted/web/test/test_tap.py b/src/twisted/web/test/test_tap.py
--- a/src/twisted/web/test/test_tap.py
+++ b/src/twisted/web/test/test_tap.py
@@ -18,12 +18,14 @@
 from twisted.python.usage import UsageError
 from twisted.spread.pb import PBServerFactory
 from twisted.trial.unittest import TestCase
+from twisted.web import demo
 from twisted.web.distrib import ResourcePublisher, UserDirectory
 from twisted.web.script import PythonScript
 from twisted.web.server import Site
 from twisted.web.static import Data, File
 from twisted.web.tap import Options, makeService
-from twisted.web.tap import makePersonalServerFactory
+from twisted.web.tap import makePersonalServerFactory, _AddHeadersResource
+from twisted.web.test.requesthelper import DummyRequest
 from twisted.web.twcgi import CGIScript
 from twisted.web.wsgi import WSGIResource
 
@@ -250,3 +252,46 @@ def test_HTTPSAcceptedOnAvailableSSL(self):
 
     if requireModule('OpenSSL.SSL') is None:
         test_HTTPSAcceptedOnAvailableSSL.skip = 'SSL module is not available.'
+
+
+    def test_add_header_parsing(self):
+        """
+        When --add-header is specific, the value is parsed.
+        """
+        options = Options()
+        options.parseOptions(
+            ['--add-header', 'K1: V1', '--add-header', 'K2: V2']
+        )
+        self.assertEqual(options['extraHeaders'], [('K1', 'V1'), ('K2', 'V2')])
+
+
+    def test_add_header_resource(self):
+        """
+        When --add-header is specified, the resource is a composition that adds
+        headers.
+        """
+        options = Options()
+        options.parseOptions(
+            ['--add-header', 'K1: V1', '--add-header', 'K2: V2']
+        )
+        service = makeService(options)
+        resource = service.services[0].factory.resource
+        self.assertIsInstance(resource, _AddHeadersResource)
+        self.assertEqual(resource._headers, [('K1', 'V1'), ('K2', 'V2')])
+        self.assertIsInstance(resource._originalResource, demo.Test)
+
+
+
+class AddHeadersResourceTests(TestCase):
+    def test_getChildWithDefault(self):
+        """
+        When getChildWithDefault is invoked, it adds the headers to the
+        response.
+        """
+        resource = _AddHeadersResource(
+            demo.Test(), [("K1", "V1"), ("K2", "V2"), ("K1", "V3")])
+        request = DummyRequest([])
+        resource.getChildWithDefault("", request)
+        self.assertEqual(
+            request.responseHeaders.getRawHeaders("K1"), ["V1", "V3"])
+        self.assertEqual(request.responseHeaders.getRawHeaders("K2"), ["V2"])