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)