diff --git a/api/__init__.py b/api/__init__.py index 42971ab..4de9342 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,5 +1,7 @@ # -*- encoding: utf-8 -*- +from distutils.util import strtobool import os # TODO: create config module and put this constant there. BASE_URI = os.environ.get("BOTZ_BASE_URI", "http://localhost") +DEBUG = strtobool(os.environ.get("BOTZ_DEBUG", "False")) diff --git a/api/app.py b/api/app.py index 1912600..51203a4 100644 --- a/api/app.py +++ b/api/app.py @@ -8,6 +8,7 @@ from aiohttp import web import base64 from cryptography import fernet import logging +import logging.handlers import os import typing as T @@ -17,15 +18,19 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage from bot_z.async_operator import AsyncOperator from api.rest import routes +from api.conf import read_conf -def setup_log() -> logging.Logger: +def setup_log(level: str, syslog: bool) -> logging.Logger: alog = logging.getLogger("api") - alog.setLevel(os.environ.get("BOTZ_LOGLEVEL", logging.INFO)) + alog.setLevel(os.environ.get("BOTZ_LOGLEVEL", level)) h = logging.StreamHandler() f = logging.Formatter("%(levelname)s [%(name)s] -> %(message)s") h.setFormatter(f) alog.addHandler(h) + if syslog: + sh = logging.handlers.SysLogHandler() + alog.addHandler(sh) return alog @@ -34,28 +39,63 @@ def init_secret() -> bytes: return base64.urlsafe_b64decode(fernet_key) -def setup_session(app: web.Application): +def setup_session(app: web.Application, secure: bool): secret = init_secret() - setup(app, EncryptedCookieStorage(secret)) + setup( + app, + EncryptedCookieStorage( + secret_key=secret, cookie_name="BOTZ_SESSION", httponly=False, secure=secure + ), + ) -def run(address: T.Text, port: int) -> None: +def run( + address: T.Optional[T.Text], port: T.Optional[int], conf_path: T.Optional[T.Text] +) -> None: """Application entrypoint.""" - alog = setup_log() + conf = read_conf(conf_path) + + # This closure is needed to intercept the to-be-prepared response + # and add the right CORS headers + async def on_prepare_cors(request, response): + response.headers["Access-Control-Allow-Origin"] = conf["http"].get("cors_allow") + response.headers["Access-Control-Allow-Credentials"] = "true" + + alog = setup_log(conf["log"]["level"], conf["log"]["syslog"]) + alog.debug("conf %s", conf) app = web.Application(logger=alog) - setup_session(app) + app["base_uri"] = conf["base_uri"] + app["debug"] = conf["debug"] + app["headless"] = conf["headless"] + if conf["http"].get("cors_allow"): + app.on_response_prepare.append(on_prepare_cors) + setup_session(app, conf["http"]["cookie_secure"]) app.add_routes(routes) - web.run_app(app, host=address, port=port) + addr = [] + if address is not None: + addr.append(address) + if conf["http"].get("bind_addr"): + addr.extend(conf["http"]["bind_addr"]) + if port is None: + port = conf["http"]["port"] + alog.debug("Starting app with: addr -> %s, port -> %d", addr, port) + web.run_app(app, host=addr, port=port) @click.command() @click.option( - "-a", - "--address", - type=click.STRING, - help="Address to bind the server to.", - default="127.0.0.1", + "-a", "--address", type=click.STRING, help="Address to bind the server to." ) -@click.option("-p", "--port", type=click.INT, help="Port to bind to", default=3003) -def cli(address: T.Text, port: int) -> None: - run(address, port) +@click.option("-p", "--port", type=click.INT, help="Port to bind to.") +@click.option( + "-c", + "--conf", + type=click.Path(exists=False), + help="A path to a configuration file.", +) +def cli( + address: T.Optional[T.Text], + port: T.Optional[int] = None, + conf: T.Optional[T.Text] = None, +) -> None: + run(address, port, conf) diff --git a/api/conf.py b/api/conf.py new file mode 100644 index 0000000..497771c --- /dev/null +++ b/api/conf.py @@ -0,0 +1,93 @@ +# -*- encoding: utf-8 -*- +import os +from pprint import pprint +import typing as T + +import yaml + +from api import DEBUG, BASE_URI + + +def read_conf(path: T.Optional[T.Text]) -> T.Dict: + """ + Read the configuration from the provided path. + Such configuration may provide the following information: + --- + base_uri: + debug: + headless: + log: + level: + syslog: + http: + bind_addr: + port: + cookie_name: + cookie_secure: + cors_allow: + """ + if path is None: + path = seek_path() + if path is not None: + with open(path) as f: + conf = yaml.safe_load(f) + else: + conf = {} + if "base_uri" not in conf: + if BASE_URI is None: + raise RuntimeError("Missing base_uri") + conf["base_uri"] = BASE_URI + if "debug" not in conf: + conf["debug"] = DEBUG + if "headless" not in conf: + conf["headless"] = True + conf = validate_log_conf(conf) + conf = validate_http_log(conf) + pprint(conf) + return conf + + +def seek_path() -> T.Optional[T.Text]: + """ + Seeks the path to a config file, in the following order: + - $BOTZ_CONFIG + - $PWD/.botz.yaml + - ~/.botz.yaml + - /etc/botz/conf.yaml + """ + paths = [ + os.path.join(os.path.curdir, ".botz.yaml"), + os.path.expanduser("~/.botz.yaml"), + "/etc/botz/conf.yaml", + ] + env = os.environ.get("BOTZ_CONFIG") + if env is not None: + paths.insert(0, env) + for path in paths: + if os.path.exists(path): + return path + return None + + +def validate_log_conf(conf: T.Dict[T.Text, T.Any]) -> T.Dict[T.Text, T.Any]: + if "log" not in conf: + conf["log"] = {} + if conf["log"].get("level") is None: + conf["log"]["level"] = "INFO" + if conf["log"].get("syslog") is None: + conf["log"]["syslog"] = False + return conf + + +def validate_http_log(conf: T.Dict[T.Text, T.Any]) -> T.Dict[T.Text, T.Any]: + if "http" not in conf: + conf["http"] = {} + if conf["http"].get("bind_addr") is None: + conf["http"]["bind_addr"] = ["127.0.0.1"] + if conf["http"].get("port") is None: + conf["http"]["port"] = 3003 + if conf["http"].get("cookie_name") is None: + conf["http"]["cookie_name"] = "BOTZ_SESSION" + if conf["http"].get("cookie_secure") is None: + conf["http"]["cookie_secure"] = True + return conf diff --git a/setup.py b/setup.py index 53e26ef..f75fa90 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ requirements = [ "cchardet", "aiohttp-session==2.7.0", "cryptography==2.7", + "PyYAML==5.1.2", ] setup_requirements = [] # type: T.List[str]