Merge "Add facility for plugins to register web routes"
diff --git a/tests/base.py b/tests/base.py
index a03abf3..f68f59a 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -1006,7 +1006,7 @@
 
         if use_zuulweb:
             req = urllib.request.Request(
-                'http://127.0.0.1:%s/driver/github/%s/payload'
+                'http://127.0.0.1:%s/connection/%s/payload'
                 % (self.zuul_web_port, self.connection_name),
                 data=payload, headers=headers)
             return urllib.request.urlopen(req)
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index 7aca428..cd36ba3 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -751,7 +751,7 @@
         self.web = zuul.web.ZuulWeb(
             listen_address='127.0.0.1', listen_port=0,
             gear_server='127.0.0.1', gear_port=self.gearman_server.port,
-            github_connections={'github': self.fake_github})
+            connections=[self.fake_github])
         loop = asyncio.new_event_loop()
         loop.set_debug(True)
         ws_thread = threading.Thread(target=self.web.run, args=(loop,))
diff --git a/zuul/cmd/web.py b/zuul/cmd/web.py
index bba062e..abdb1cb 100755
--- a/zuul/cmd/web.py
+++ b/zuul/cmd/web.py
@@ -22,8 +22,6 @@
 import zuul.cmd
 import zuul.web
 
-from zuul.driver.sql import sqlconnection
-from zuul.driver.github import githubconnection
 from zuul.lib.config import get_default
 
 
@@ -50,34 +48,15 @@
         params['ssl_cert'] = get_default(self.config, 'gearman', 'ssl_cert')
         params['ssl_ca'] = get_default(self.config, 'gearman', 'ssl_ca')
 
-        sql_conn_name = get_default(self.config, 'web',
-                                    'sql_connection_name')
-        sql_conn = None
-        if sql_conn_name:
-            # we want a specific sql connection
-            sql_conn = self.connections.connections.get(sql_conn_name)
-            if not sql_conn:
-                self.log.error("Couldn't find sql connection '%s'" %
-                               sql_conn_name)
-                sys.exit(1)
-        else:
-            # look for any sql connection
-            connections = [c for c in self.connections.connections.values()
-                           if isinstance(c, sqlconnection.SQLConnection)]
-            if len(connections) > 1:
-                self.log.error("Multiple sql connection found, "
-                               "set the sql_connection_name option "
-                               "in zuul.conf [web] section")
-                sys.exit(1)
-            if connections:
-                # use this sql connection by default
-                sql_conn = connections[0]
-        params['sql_connection'] = sql_conn
-
-        params['github_connections'] = {}
+        params['connections'] = []
+        # Validate config here before we spin up the ZuulWeb object
         for conn_name, connection in self.connections.connections.items():
-            if isinstance(connection, githubconnection.GithubConnection):
-                params['github_connections'][conn_name] = connection
+            try:
+                if connection.validateWebConfig(self.config, self.connections):
+                    params['connections'].append(connection)
+            except Exception:
+                self.log.exception("Error validating config")
+                sys.exit(1)
 
         try:
             self.web = zuul.web.ZuulWeb(**params)
diff --git a/zuul/connection/__init__.py b/zuul/connection/__init__.py
index 5115154..86f14d6 100644
--- a/zuul/connection/__init__.py
+++ b/zuul/connection/__init__.py
@@ -74,3 +74,30 @@
         This lets the user supply a list of change objects that are
         still in use.  Anything in our cache that isn't in the supplied
         list should be safe to remove from the cache."""
+
+    def getWebHandlers(self, zuul_web):
+        """Return a list of web handlers to register with zuul-web.
+
+        :param zuul.web.ZuulWeb zuul_web:
+            Zuul Web instance.
+        :returns: List of `zuul.web.handler.BaseWebHandler` instances.
+        """
+        return []
+
+    def validateWebConfig(self, config, connections):
+        """Validate config and determine whether to register web handlers.
+
+        By default this method returns False, which means this connection
+        has no web handlers to register.
+
+        If the method returns True, then its `getWebHandlers` method
+        should be called during route registration.
+
+        If there is a fatal error, the method should raise an exception.
+
+        :param config:
+           The parsed config object.
+        :param zuul.lib.connections.ConnectionRegistry connections:
+           Registry of all configured connections.
+        """
+        return False
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 0ec3588..6072f4c 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -24,6 +24,7 @@
 import json
 import traceback
 
+from aiohttp import web
 import cachecontrol
 from cachecontrol.cache import DictCache
 from cachecontrol.heuristics import BaseHeuristic
@@ -37,6 +38,7 @@
 import gear
 
 from zuul.connection import BaseConnection
+from zuul.web.handler import BaseDriverWebHandler
 from zuul.lib.config import get_default
 from zuul.model import Ref, Branch, Tag, Project
 from zuul.exceptions import MergeFailure
@@ -1136,6 +1138,69 @@
 
         return statuses
 
+    def getWebHandlers(self, zuul_web):
+        return [GithubWebhookHandler(self, zuul_web, 'POST', 'payload')]
+
+    def validateWebConfig(self, config, connections):
+        if 'webhook_token' not in self.connection_config:
+            raise Exception(
+                "webhook_token not found in config for connection %s" %
+                self.connection_name)
+        return True
+
+
+class GithubWebhookHandler(BaseDriverWebHandler):
+
+    log = logging.getLogger("zuul.GithubWebhookHandler")
+
+    def __init__(self, connection, zuul_web, method, path):
+        super(GithubWebhookHandler, self).__init__(
+            connection=connection, zuul_web=zuul_web, method=method, path=path)
+        self.token = self.connection.connection_config.get('webhook_token')
+
+    def _validate_signature(self, body, headers):
+        try:
+            request_signature = headers['x-hub-signature']
+        except KeyError:
+            raise web.HTTPUnauthorized(
+                reason='X-Hub-Signature header missing.')
+
+        payload_signature = _sign_request(body, self.token)
+
+        self.log.debug("Payload Signature: {0}".format(str(payload_signature)))
+        self.log.debug("Request Signature: {0}".format(str(request_signature)))
+        if not hmac.compare_digest(
+            str(payload_signature), str(request_signature)):
+            raise web.HTTPUnauthorized(
+                reason=('Request signature does not match calculated payload '
+                        'signature. Check that secret is correct.'))
+
+        return True
+
+    async def handleRequest(self, request):
+        # Note(tobiash): We need to normalize the headers. Otherwise we will
+        # have trouble to get them from the dict afterwards.
+        # e.g.
+        # GitHub: sent: X-GitHub-Event received: X-GitHub-Event
+        # urllib: sent: X-GitHub-Event received: X-Github-Event
+        #
+        # We cannot easily solve this mismatch as every http processing lib
+        # modifies the header casing in its own way and by specification http
+        # headers are case insensitive so just lowercase all so we don't have
+        # to take care later.
+        headers = dict()
+        for key, value in request.headers.items():
+            headers[key.lower()] = value
+        body = await request.read()
+        self._validate_signature(body, headers)
+        # We cannot send the raw body through gearman, so it's easy to just
+        # encode it as json, after decoding it as utf-8
+        json_body = json.loads(body.decode('utf-8'))
+        job = self.zuul_web.rpc.submitJob(
+            'github:%s:payload' % self.connection.connection_name,
+            {'headers': headers, 'body': json_body})
+        return web.json_response(json.loads(job.data[0]))
+
 
 def _status_as_tuple(status):
     """Translate a status into a tuple of user, context, state"""
diff --git a/zuul/driver/sql/sqlconnection.py b/zuul/driver/sql/sqlconnection.py
index 715d72b..501a2c5 100644
--- a/zuul/driver/sql/sqlconnection.py
+++ b/zuul/driver/sql/sqlconnection.py
@@ -14,14 +14,19 @@
 
 import logging
 
+from aiohttp import web
 import alembic
 import alembic.command
 import alembic.config
 import sqlalchemy as sa
 import sqlalchemy.pool
-import voluptuous as v
+from sqlalchemy.sql import select
+import urllib.parse
+import voluptuous
 
 from zuul.connection import BaseConnection
+from zuul.lib.config import get_default
+from zuul.web.handler import BaseWebHandler, StaticHandler
 
 BUILDSET_TABLE = 'zuul_buildset'
 BUILD_TABLE = 'zuul_build'
@@ -120,7 +125,122 @@
 
         return zuul_buildset_table, zuul_build_table
 
+    def getWebHandlers(self, zuul_web):
+        return [
+            SqlWebHandler(self, zuul_web, 'GET', '/{tenant}/builds.json'),
+            StaticHandler(zuul_web, '/{tenant}/builds.html'),
+        ]
+
+    def validateWebConfig(self, config, connections):
+        sql_conn_name = get_default(config, 'web', 'sql_connection_name')
+        if sql_conn_name:
+            # The config wants a specific sql connection. Check the whole
+            # list of connections to make sure it can be satisfied.
+            sql_conn = connections.connections.get(sql_conn_name)
+            if not sql_conn:
+                raise Exception(
+                    "Couldn't find sql connection '%s'" % sql_conn_name)
+            if self.connection_name == sql_conn.connection_name:
+                return True
+        else:
+            # Check to see if there is more than one connection
+            conn_objects = [c for c in connections.connections.values()
+                            if isinstance(c, SQLConnection)]
+            if len(conn_objects) > 1:
+                raise Exception("Multiple sql connection found, "
+                                "set the sql_connection_name option "
+                                "in zuul.conf [web] section")
+            return True
+
+
+class SqlWebHandler(BaseWebHandler):
+    log = logging.getLogger("zuul.web.SqlHandler")
+    filters = ("project", "pipeline", "change", "patchset", "ref",
+               "result", "uuid", "job_name", "voting", "node_name", "newrev")
+
+    def __init__(self, connection, zuul_web, method, path):
+        super(SqlWebHandler, self).__init__(
+            connection=connection, zuul_web=zuul_web, method=method, path=path)
+
+    def query(self, args):
+        build = self.connection.zuul_build_table
+        buildset = self.connection.zuul_buildset_table
+        query = select([
+            buildset.c.project,
+            buildset.c.pipeline,
+            buildset.c.change,
+            buildset.c.patchset,
+            buildset.c.ref,
+            buildset.c.newrev,
+            buildset.c.ref_url,
+            build.c.result,
+            build.c.uuid,
+            build.c.job_name,
+            build.c.voting,
+            build.c.node_name,
+            build.c.start_time,
+            build.c.end_time,
+            build.c.log_url]).select_from(build.join(buildset))
+        for table in ('build', 'buildset'):
+            for key, val in args['%s_filters' % table].items():
+                if table == 'build':
+                    column = build.c
+                else:
+                    column = buildset.c
+                query = query.where(getattr(column, key).in_(val))
+        return query.limit(args['limit']).offset(args['skip']).order_by(
+            build.c.id.desc())
+
+    async def get_builds(self, args):
+        """Return a list of build"""
+        builds = []
+        with self.connection.engine.begin() as conn:
+            query = self.query(args)
+            for row in conn.execute(query):
+                build = dict(row)
+                # Convert date to iso format
+                if row.start_time:
+                    build['start_time'] = row.start_time.strftime(
+                        '%Y-%m-%dT%H:%M:%S')
+                if row.end_time:
+                    build['end_time'] = row.end_time.strftime(
+                        '%Y-%m-%dT%H:%M:%S')
+                # Compute run duration
+                if row.start_time and row.end_time:
+                    build['duration'] = (row.end_time -
+                                         row.start_time).total_seconds()
+                builds.append(build)
+        return builds
+
+    async def handleRequest(self, request):
+        try:
+            args = {
+                'buildset_filters': {},
+                'build_filters': {},
+                'limit': 50,
+                'skip': 0,
+            }
+            for k, v in urllib.parse.parse_qsl(request.rel_url.query_string):
+                if k in ("tenant", "project", "pipeline", "change",
+                         "patchset", "ref", "newrev"):
+                    args['buildset_filters'].setdefault(k, []).append(v)
+                elif k in ("uuid", "job_name", "voting", "node_name",
+                           "result"):
+                    args['build_filters'].setdefault(k, []).append(v)
+                elif k in ("limit", "skip"):
+                    args[k] = int(v)
+                else:
+                    raise ValueError("Unknown parameter %s" % k)
+            data = await self.get_builds(args)
+            resp = web.json_response(data)
+            resp.headers['Access-Control-Allow-Origin'] = '*'
+        except Exception as e:
+            self.log.exception("Jobs exception:")
+            resp = web.json_response({'error_description': 'Internal error'},
+                                     status=500)
+        return resp
+
 
 def getSchema():
-    sql_connection = v.Any(str, v.Schema(dict))
+    sql_connection = voluptuous.Any(str, voluptuous.Schema(dict))
     return sql_connection
diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py
index 201785d..adbafb5 100755
--- a/zuul/web/__init__.py
+++ b/zuul/web/__init__.py
@@ -16,31 +16,21 @@
 
 
 import asyncio
-import hashlib
-import hmac
 import json
 import logging
 import os
 import time
-import urllib.parse
 import uvloop
 
 import aiohttp
 from aiohttp import web
 
-from sqlalchemy.sql import select
-
 import zuul.rpcclient
+from zuul.web.handler import StaticHandler
 
 STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
 
 
-def _sign_request(body, secret):
-    signature = 'sha1=' + hmac.new(
-        secret.encode('utf-8'), body, hashlib.sha1).hexdigest()
-    return signature
-
-
 class LogStreamingHandler(object):
     log = logging.getLogger("zuul.web.LogStreamingHandler")
 
@@ -158,9 +148,8 @@
     # Tenant status cache expiry
     cache_expiry = 1
 
-    def __init__(self, rpc, github_connections):
+    def __init__(self, rpc):
         self.rpc = rpc
-        self.github_connections = github_connections
         self.cache = {}
         self.cache_time = {}
         self.controllers = {
@@ -168,7 +157,6 @@
             'status_get': self.status_get,
             'job_list': self.job_list,
             'key_get': self.key_get,
-            'payload_post': self.payload_post,
         }
 
     async def tenant_list(self, request):
@@ -203,65 +191,6 @@
                                                   'project': project})
         return web.Response(body=job.data[0])
 
-    def _validate_signature(self, body, headers, secret):
-        try:
-            request_signature = headers['x-hub-signature']
-        except KeyError:
-            raise web.HTTPUnauthorized(
-                reason='X-Hub-Signature header missing.')
-
-        payload_signature = _sign_request(body, secret)
-
-        self.log.debug("Payload Signature: {0}".format(str(payload_signature)))
-        self.log.debug("Request Signature: {0}".format(str(request_signature)))
-        if not hmac.compare_digest(
-            str(payload_signature), str(request_signature)):
-            raise web.HTTPUnauthorized(
-                reason=('Request signature does not match calculated payload '
-                        'signature. Check that secret is correct.'))
-
-        return True
-
-    async def github_payload(self, post):
-        connection = post.match_info["connection"]
-        github_connection = self.github_connections.get(connection)
-        token = github_connection.connection_config.get('webhook_token')
-
-        # Note(tobiash): We need to normalize the headers. Otherwise we will
-        # have trouble to get them from the dict afterwards.
-        # e.g.
-        # GitHub: sent: X-GitHub-Event received: X-GitHub-Event
-        # urllib: sent: X-GitHub-Event received: X-Github-Event
-        #
-        # We cannot easily solve this mismatch as every http processing lib
-        # modifies the header casing in its own way and by specification http
-        # headers are case insensitive so just lowercase all so we don't have
-        # to take care later.
-        headers = dict()
-        for key, value in post.headers.items():
-            headers[key.lower()] = value
-        body = await post.read()
-        self._validate_signature(body, headers, token)
-        # We cannot send the raw body through gearman, so it's easy to just
-        # encode it as json, after decoding it as utf-8
-        json_body = json.loads(body.decode('utf-8'))
-        job = self.rpc.submitJob('github:%s:payload' % connection,
-                                 {'headers': headers, 'body': json_body})
-        jobdata = json.loads(job.data[0])
-        return web.json_response(jobdata, status=jobdata['return_code'])
-
-    async def payload_post(self, post):
-        # Allow for other drivers to also accept a payload in the future,
-        # instead of hardcoding this to GitHub
-        driver = post.match_info["driver"]
-        try:
-            method = getattr(self, driver + '_payload')
-        except AttributeError as e:
-            self.log.exception("Unknown driver error:")
-            raise web.HTTPNotFound
-
-        return await method(post)
-
     async def processRequest(self, request, action):
         try:
             resp = await self.controllers[action](request)
@@ -274,93 +203,6 @@
         return resp
 
 
-class SqlHandler(object):
-    log = logging.getLogger("zuul.web.SqlHandler")
-    filters = ("project", "pipeline", "change", "patchset", "ref",
-               "result", "uuid", "job_name", "voting", "node_name", "newrev")
-
-    def __init__(self, connection):
-        self.connection = connection
-
-    def query(self, args):
-        build = self.connection.zuul_build_table
-        buildset = self.connection.zuul_buildset_table
-        query = select([
-            buildset.c.project,
-            buildset.c.pipeline,
-            buildset.c.change,
-            buildset.c.patchset,
-            buildset.c.ref,
-            buildset.c.newrev,
-            buildset.c.ref_url,
-            build.c.result,
-            build.c.uuid,
-            build.c.job_name,
-            build.c.voting,
-            build.c.node_name,
-            build.c.start_time,
-            build.c.end_time,
-            build.c.log_url]).select_from(build.join(buildset))
-        for table in ('build', 'buildset'):
-            for k, v in args['%s_filters' % table].items():
-                if table == 'build':
-                    column = build.c
-                else:
-                    column = buildset.c
-                query = query.where(getattr(column, k).in_(v))
-        return query.limit(args['limit']).offset(args['skip']).order_by(
-            build.c.id.desc())
-
-    def get_builds(self, args):
-        """Return a list of build"""
-        builds = []
-        with self.connection.engine.begin() as conn:
-            query = self.query(args)
-            for row in conn.execute(query):
-                build = dict(row)
-                # Convert date to iso format
-                if row.start_time:
-                    build['start_time'] = row.start_time.strftime(
-                        '%Y-%m-%dT%H:%M:%S')
-                if row.end_time:
-                    build['end_time'] = row.end_time.strftime(
-                        '%Y-%m-%dT%H:%M:%S')
-                # Compute run duration
-                if row.start_time and row.end_time:
-                    build['duration'] = (row.end_time -
-                                         row.start_time).total_seconds()
-                builds.append(build)
-        return builds
-
-    async def processRequest(self, request):
-        try:
-            args = {
-                'buildset_filters': {},
-                'build_filters': {},
-                'limit': 50,
-                'skip': 0,
-            }
-            for k, v in urllib.parse.parse_qsl(request.rel_url.query_string):
-                if k in ("tenant", "project", "pipeline", "change",
-                         "patchset", "ref", "newrev"):
-                    args['buildset_filters'].setdefault(k, []).append(v)
-                elif k in ("uuid", "job_name", "voting", "node_name",
-                           "result"):
-                    args['build_filters'].setdefault(k, []).append(v)
-                elif k in ("limit", "skip"):
-                    args[k] = int(v)
-                else:
-                    raise ValueError("Unknown parameter %s" % k)
-            data = self.get_builds(args)
-            resp = web.json_response(data)
-            resp.headers['Access-Control-Allow-Origin'] = '*'
-        except Exception as e:
-            self.log.exception("Jobs exception:")
-            resp = web.json_response({'error_description': 'Internal error'},
-                                     status=500)
-        return resp
-
-
 class ZuulWeb(object):
 
     log = logging.getLogger("zuul.web.ZuulWeb")
@@ -369,8 +211,7 @@
                  gear_server, gear_port,
                  ssl_key=None, ssl_cert=None, ssl_ca=None,
                  static_cache_expiry=3600,
-                 sql_connection=None,
-                 github_connections={}):
+                 connections=None):
         self.listen_address = listen_address
         self.listen_port = listen_port
         self.event_loop = None
@@ -381,11 +222,11 @@
         self.rpc = zuul.rpcclient.RPCClient(gear_server, gear_port,
                                             ssl_key, ssl_cert, ssl_ca)
         self.log_streaming_handler = LogStreamingHandler(self.rpc)
-        self.gearman_handler = GearmanHandler(self.rpc, github_connections)
-        if sql_connection:
-            self.sql_handler = SqlHandler(sql_connection)
-        else:
-            self.sql_handler = None
+        self.gearman_handler = GearmanHandler(self.rpc)
+        self._plugin_routes = []  # type: List[zuul.web.handler.BaseWebHandler]
+        connections = connections or []
+        for connection in connections:
+            self._plugin_routes.extend(connection.getWebHandlers(self))
 
     async def _handleWebsocket(self, request):
         return await self.log_streaming_handler.processRequest(
@@ -401,34 +242,9 @@
     async def _handleJobsRequest(self, request):
         return await self.gearman_handler.processRequest(request, 'job_list')
 
-    async def _handleSqlRequest(self, request):
-        return await self.sql_handler.processRequest(request)
-
     async def _handleKeyRequest(self, request):
         return await self.gearman_handler.processRequest(request, 'key_get')
 
-    async def _handlePayloadPost(self, post):
-        return await self.gearman_handler.processRequest(post,
-                                                         'payload_post')
-
-    async def _handleStaticRequest(self, request):
-        fp = None
-        if request.path.endswith("tenants.html") or request.path.endswith("/"):
-            fp = os.path.join(STATIC_DIR, "index.html")
-        elif request.path.endswith("status.html"):
-            fp = os.path.join(STATIC_DIR, "status.html")
-        elif request.path.endswith("jobs.html"):
-            fp = os.path.join(STATIC_DIR, "jobs.html")
-        elif request.path.endswith("builds.html"):
-            fp = os.path.join(STATIC_DIR, "builds.html")
-        elif request.path.endswith("stream.html"):
-            fp = os.path.join(STATIC_DIR, "stream.html")
-        headers = {}
-        if self.static_cache_expiry:
-            headers['Cache-Control'] = "public, max-age=%d" % \
-                self.static_cache_expiry
-        return web.FileResponse(fp, headers=headers)
-
     def run(self, loop=None):
         """
         Run the websocket daemon.
@@ -446,20 +262,18 @@
             ('GET', '/{tenant}/jobs.json', self._handleJobsRequest),
             ('GET', '/{tenant}/console-stream', self._handleWebsocket),
             ('GET', '/{tenant}/{project:.*}.pub', self._handleKeyRequest),
-            ('GET', '/{tenant}/status.html', self._handleStaticRequest),
-            ('GET', '/{tenant}/jobs.html', self._handleStaticRequest),
-            ('GET', '/{tenant}/stream.html', self._handleStaticRequest),
-            ('GET', '/tenants.html', self._handleStaticRequest),
-            ('POST', '/driver/{driver}/{connection}/payload',
-             self._handlePayloadPost),
-            ('GET', '/', self._handleStaticRequest),
         ]
 
-        if self.sql_handler:
-            routes.append(('GET', '/{tenant}/builds.json',
-                           self._handleSqlRequest))
-            routes.append(('GET', '/{tenant}/builds.html',
-                           self._handleStaticRequest))
+        static_routes = [
+            StaticHandler(self, '/{tenant}/status.html'),
+            StaticHandler(self, '/{tenant}/jobs.html'),
+            StaticHandler(self, '/{tenant}/stream.html'),
+            StaticHandler(self, '/tenants.html', 'index.html'),
+            StaticHandler(self, '/', 'index.html'),
+        ]
+
+        for route in static_routes + self._plugin_routes:
+            routes.append((route.method, route.path, route.handleRequest))
 
         self.log.debug("ZuulWeb starting")
         asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
diff --git a/zuul/web/handler.py b/zuul/web/handler.py
new file mode 100644
index 0000000..43a4695
--- /dev/null
+++ b/zuul/web/handler.py
@@ -0,0 +1,61 @@
+# Copyright 2018 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import abc
+import os
+
+from aiohttp import web
+
+STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
+
+
+class BaseWebHandler(object, metaclass=abc.ABCMeta):
+
+    def __init__(self, connection, zuul_web, method, path):
+        self.connection = connection
+        self.zuul_web = zuul_web
+        self.method = method
+        self.path = path
+
+    @abc.abstractmethod
+    async def handleRequest(self, request):
+        """Process a web request."""
+
+
+class BaseDriverWebHandler(BaseWebHandler):
+
+    def __init__(self, connection, zuul_web, method, path):
+        super(BaseDriverWebHandler, self).__init__(
+            connection=connection, zuul_web=zuul_web, method=method, path=path)
+        if path.startswith('/'):
+            path = path[1:]
+        self.path = '/connection/{connection}/{path}'.format(
+            connection=self.connection.connection_name,
+            path=path)
+
+
+class StaticHandler(BaseWebHandler):
+
+    def __init__(self, zuul_web, path, file_path=None):
+        super(StaticHandler, self).__init__(None, zuul_web, 'GET', path)
+        self.file_path = file_path or path.split('/')[-1]
+
+    async def handleRequest(self, request):
+        """Process a web request."""
+        headers = {}
+        fp = os.path.join(STATIC_DIR, self.file_path)
+        if self.zuul_web.static_cache_expiry:
+            headers['Cache-Control'] = "public, max-age=%d" % \
+                self.zuul_web.static_cache_expiry
+        return web.FileResponse(fp, headers=headers)