diff --git a/halfapi/cli/__init__.py b/halfapi/cli/__init__.py index 3663dc7..66b74a8 100644 --- a/halfapi/cli/__init__.py +++ b/halfapi/cli/__init__.py @@ -1,17 +1 @@ -#!/usr/bin/env python3 -# builtins -import click - -@click.group(invoke_without_command=True, context_settings=CONTEXT_SETTINGS) -@click.option('--version', is_flag=True) -@click.pass_context -def cli(ctx, version): - if version: - import halfapi - return click.echo(halfapi.version) - - if ctx.invoked_subcommand is None: - return run() - -if __name__ == '__main__': - cli() +#!/usr/bin/env diff --git a/halfapi/cli/cli.py b/halfapi/cli/cli.py new file mode 100644 index 0000000..819c91d --- /dev/null +++ b/halfapi/cli/cli.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# builtins +import click +from halfapi.conf import IS_PROJECT + +@click.group(invoke_without_command=True) +@click.option('--version', is_flag=True) +@click.pass_context +def cli(ctx, version): + if version: + import halfapi + return click.echo(halfapi.version) + + #if not IS_PROJECT: + # return init() + #if ctx.invoked_subcommand is None: + # return run() + +if IS_PROJECT: + import halfapi.cli.domain + import halfapi.cli.run +else: + import halfapi.cli.init diff --git a/halfapi/cli/domain.py b/halfapi/cli/domain.py new file mode 100644 index 0000000..c832af1 --- /dev/null +++ b/halfapi/cli/domain.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# builtins +import sys +import logging +import click +import importlib +from .cli import cli +from halfapi.conf import DOMAINS, BASE_DIR +from halfapi.db import ( + Domain, + APIRouter, + APIRoute, + AclFunction, + Acl) + +logger = logging.getLogger('halfapi') + + +def delete_domain(domain): + d = Domain(name=domain) + if len(d) != 1: + return False + + d.delete(delete_all=True) + return True + + +@click.option('--domain', '-d', default=None, multiple=True) +@click.option('--update', default=False, is_flag=True) +@cli.command() +def routes(domain, update): + """ + Lists routes for the specified domains, or update them in the database + + Parameters: + domain (List[str]|None): The list of the domains to list/update + + The parameter has a misleading name as it is a multiple option + but this would be strange to use it several times named as "domains" + + update (boolean): If set, update the database for the selected domains + """ + + if not domain: + domain = DOMAINS + else: + for domain_name in domain: + if domain_name in DOMAINS: + continue + click.echo( + f'Domain {domain}s is not activated in the configuration') + + if update: + update_db(domain) + else: + list_routes(domain) + + +def list_routes(domain): + click.echo(f'\nDomain {domain}') + routes = Acl(domain=domain) + for route in routes.select(): + click.echo('-', route) + + +def update_db(domains): + + def add_domain(domain): + """ + Inserts Domain into database + + Parameters: + - domain (str): The domain's name + """ + new_domain = Domain(name=domain) + if len(new_domain) == 0: + click.echo(f'New domain {domain}') + new_domain.insert() + + + def add_router(name, domain): + """ + Inserts Router into database + + Parameters: + - name (str): The Router's name + """ + router = APIRouter() + router.name = name + router.domain = domain + + if len(router) == 0: + router.insert() + + + def add_acl_fct(fct, domain): + """ + Inserts ACL function into database + + Parameters: + - fct (Callable): The ACL function reference + - domain (str): The Domain's name + """ + acl = AclFunction() + acl.domain = domain + acl.name = fct.__name__ + if len(acl) == 0: + acl.insert() + + + def add_acls(acls, **route): + """ + Inserts ACL into database + + Parameters: + - acls (List[Callable]): List of the Route's ACL's + - route (dict): The Route + """ + route.pop('fct_name') + acl = Acl(**route) + + for fct in acls: + acl.acl_fct_name = fct.__name__ + + if len(acl) == 0: + if fct is not None: + add_acl_fct(fct, route['domain']) + + acl.insert() + + elif fct is None: + acl.delete() + + + def get_fct_name(http_verb, path): + """ + Returns the predictable name of the function for a route + + Parameters: + - http_verb (str): The Route's HTTP method (GET, POST, ...) + - path (str): A path beginning by '/' for the route + + Returns: + str: The *unique* function name for a route and it's verb + + + Examples: + + >>> get_fct_name('foo', 'bar') + Traceback (most recent call last): + ... + Exception: Malformed path + + >>> get_fct_name('get', '/') + 'get_' + + >>> get_fct_name('GET', '/') + 'get_' + + >>> get_fct_name('POST', '/foo') + 'post_foo' + + >>> get_fct_name('POST', '/foo/bar') + 'post_foo_bar' + + >>> get_fct_name('DEL', '/foo/{boo}/{far}/bar') + 'del_foo_BOO_FAR_bar' + + >>> get_fct_name('DEL', '/foo/{boo:zoo}') + 'del_foo_BOO' + """ + + if path[0] != '/': + raise Exception('Malformed path') + + elts = path[1:].split('/') + + fct_name = [http_verb.lower()] + for elt in elts: + if elt and elt[0] == '{': + fct_name.append(elt[1:-1].split(':')[0].upper()) + else: + fct_name.append(elt) + + return '_'.join(fct_name) + + + def add_route(http_verb, path, router, domain, acls): + """ + Inserts Route into database + + Parameters: + - http_verb (str): The Route's HTTP method (GET, POST, ...) + - path (str): A path beginning by '/' for the route + - router (str): The Route's Router name + - domain (str): The Domain's name + - acls (List[Callable]): The list of ACL functions for this Route + """ + + click.echo(f'Adding route /{domain}/{router}{path}') + + route = APIRoute() + # Route definition + route.http_verb = http_verb + route.path = path + route.fct_name = get_fct_name(http_verb, path) + route.router = router + route.domain = domain + + if len(route) == 0: + route.insert() + + add_acls(acls, **route.to_dict()) + + + sys.path.insert(0, BASE_DIR) + + for domain in domains: + # Reset Domain relations + delete_domain(domain) + + acl_set = set() + + try: + # Module retrieval + dom_mod = importlib.import_module(domain) + except ImportError: + # Domain is not available in current PYTHONPATH + click.echo(f"Can't import *{domain}*", err=True) + continue + + try: + add_domain(domain) + except Exception as e: + # Could not insert Domain + # @TODO : Insertion exception handling + click.echo(e) + click.echo(f"Could not insert *{domain}*", err=True) + continue + + # add sub routers + try: + ROUTERS = dom_mod.ROUTERS + except AttributeError: + # No ROUTERS variable in current domain, check domain/__init__.py + click.echo(f'The domain {domain} has no *ROUTERS* variable', err=True) + + for router_name in dom_mod.ROUTERS: + try: + router_mod = getattr(dom_mod.routers, router_name) + except AttributError: + # Missing router, continue + click.echo(f'The domain {domain} has no *{router_name}* router', err=True) + continue + + try: + add_router(router_name, domain) + except Exception as e: + # Could not insert Router + # @TODO : Insertion exception handling + print(e) + continue + + + for route_path, route_params in router_mod.ROUTES.items(): + for http_verb, acls in route_params.items(): + try: + # Insert a route and it's ACLS + add_route(http_verb, route_path, router_name, domain, acls) + except Exception as e: + # Could not insert route + # @TODO : Insertion exception handling + print(e) + continue diff --git a/halfapi/cli/init.py b/halfapi/cli/init.py new file mode 100644 index 0000000..46e07b9 --- /dev/null +++ b/halfapi/cli/init.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# builtins +import os +import sys +import re +import click +import logging + +from halfapi import __version__ +from halfapi.cli.lib.db import ProjectDB +from .cli import cli + +logger = logging.getLogger('halfapi') + +TMPL_HALFAPI_ETC = """Insert this into the HALFAPI_CONF_DIR/{project} file + +[project] +host = 127.0.0.1 +port = 8000 +secret = /path/to/secret_file +production = False +base_dir = {base_dir} +""" + +TMPL_HALFAPI_CONFIG = """[project] +name = {name} +halfapi_version = {halfapi_version} +""" + +@click.argument('project') +@cli.command() +def init(project): + if not re.match('^[a-z0-9_]+$', project, re.I): + click.echo('Project name must match "^[a-z0-9_]+$", retry.', err=True) + sys.exit(1) + + if os.path.exists(project): + click.echo(f'A file named {project} already exists, abort.', err=True) + sys.exit(1) + + + click.echo(f'create directory {project}') + os.mkdir(project) + + try: + pdb = ProjectDB(project) + pdb.init() + except Exception as e: + logger.warning(e) + logger.debug(os.environ.get('HALFORM_CONF_DIR')) + raise e + + os.mkdir(os.path.join(project, '.halfapi')) + open(os.path.join(project, '.halfapi', 'domains'), 'w').write('[domains]\n') + config_file = os.path.join(project, '.halfapi', 'config') + with open(config_file, 'w') as f: + f.write(TMPL_HALFAPI_CONFIG.format( + name=project, + halfapi_version=__version__ + )) + + print(TMPL_HALFAPI_ETC.format( + project=project, + base_dir=os.path.abspath(project) + )) + diff --git a/halfapi/cli/run.py b/halfapi/cli/run.py new file mode 100644 index 0000000..d9f3069 --- /dev/null +++ b/halfapi/cli/run.py @@ -0,0 +1,36 @@ +import sys +import click +import uvicorn + +from .cli import cli +from halfapi.cli.domain import list_routes +from halfapi.conf import (HOST, PORT, + PRODUCTION, BASE_DIR, DOMAINS) + +@click.option('--host', default=None) +@click.option('--port', default=None) +@cli.command() +def run(host, port): + + if not host: + host = HOST + + if not port: + port = PORT + + port = int(port) + + debug = reload = not PRODUCTION + log_level = 'info' if PRODUCTION else 'debug' + + click.echo('Launching application') + + sys.path.insert(0, BASE_DIR) + + [ list_routes(domain) for domain in DOMAINS ] + + uvicorn.run('halfapi.app:application', + host=host, + port=int(port), + log_level=log_level, + reload=reload) diff --git a/halfapi/conf.py b/halfapi/conf.py index 9171cf2..7dd590e 100644 --- a/halfapi/conf.py +++ b/halfapi/conf.py @@ -4,45 +4,48 @@ from os import environ import sys from configparser import ConfigParser -default_config = { - 'project': { - 'host': '127.0.0.1', - 'port': '8000', - 'secret': '', - 'base_dir': '', - 'production': 'no' +IS_PROJECT = os.path.isfile('.halfapi/config') + +if IS_PROJECT: + default_config = { + 'project': { + 'host': '127.0.0.1', + 'port': '8000', + 'secret': '', + 'base_dir': '', + 'production': 'no' + } } -} -config = ConfigParser(allow_no_value=True) -config.read_dict(default_config) -config.read(filenames=['.halfapi/config']) + config = ConfigParser(allow_no_value=True) + config.read_dict(default_config) + config.read(filenames=['.halfapi/config']) -PROJECT_NAME = config.get('project', 'name') + PROJECT_NAME = config.get('project', 'name') -if len(PROJECT_NAME) == 0: - raise Exception('Need a project name as argument') + if len(PROJECT_NAME) == 0: + raise Exception('Need a project name as argument') -config = ConfigParser(allow_no_value=True) -config.read_dict(default_config) -config.read(filenames=['.halfapi/domains']) + config = ConfigParser(allow_no_value=True) + config.read_dict(default_config) + config.read(filenames=['.halfapi/domains']) -DOMAINS = [domain for domain, _ in config.items('domains')] + DOMAINS = [domain for domain, _ in config.items('domains')] -CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api') + CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api') -config.read(filenames=[os.path.join( - CONF_DIR, - PROJECT_NAME -)]) + config.read(filenames=[os.path.join( + CONF_DIR, + PROJECT_NAME + )]) -HOST = config.get('project', 'host') -PORT = config.getint('project', 'port') -DB_NAME = f'halfapi_{PROJECT_NAME}' + HOST = config.get('project', 'host') + PORT = config.getint('project', 'port') + DB_NAME = f'halfapi_{PROJECT_NAME}' -with open(config.get('project', 'secret')) as secret_file: - SECRET = secret_file.read() + with open(config.get('project', 'secret')) as secret_file: + SECRET = secret_file.read() -PRODUCTION = config.getboolean('project', 'production') -BASE_DIR = config.get('project', 'base_dir') + PRODUCTION = config.getboolean('project', 'production') + BASE_DIR = config.get('project', 'base_dir') diff --git a/setup.py b/setup.py index 0dbb3d0..97bbb83 100755 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ setup( }, entry_points={ "console_scripts":[ - "halfapi=halfapi.cli:cli" + "halfapi=halfapi.cli.cli:cli" ] } ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 454d9e6..cc5a7c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -53,14 +53,6 @@ class TestCli(): r = runner.invoke(cli, ['init', '--help']) assert r.exit_code == 0 - with runner.isolated_filesystem(): - r = runner.invoke(cli, ['run', '--help']) - assert r.exit_code == 0 - - with runner.isolated_filesystem(): - r = runner.invoke(cli, ['domain', '--help']) - assert r.exit_code == 0 - def test_init_project_fail(self, runner, dropdb): # Missing argument (project) @@ -117,25 +109,46 @@ class TestCli(): assert res.exit_code == 0 assert res.exception is None + def test_run_commands(self, runner, dropdb): + with runner.isolated_filesystem(): + res = runner.invoke(cli, ['init', PROJNAME]) + res = runner.invoke(cli, ['run', '--help']) + assert res.exit_code == 0 + + with runner.isolated_filesystem(): + res = runner.invoke(cli, ['init', PROJNAME]) + res = runner.invoke(cli, ['run', 'foobar']) + assert res.exit_code == 2 + def test_domain_commands(self, runner, dropdb): with runner.isolated_filesystem(): + res = runner.invoke(cli, ['init', PROJNAME]) res = runner.invoke(cli, ['domain', 'foobar']) assert res.exit_code == 2 with runner.isolated_filesystem(): + res = runner.invoke(cli, ['init', PROJNAME]) + res = runner.invoke(cli, ['domain', '--help']) + assert r.exit_code == 0 + + with runner.isolated_filesystem(): + res = runner.invoke(cli, ['init', PROJNAME]) res = runner.invoke(cli, ['domain', 'create', '--help']) assert r.exit_code == 0 with runner.isolated_filesystem(): + res = runner.invoke(cli, ['init', PROJNAME]) res = runner.invoke(cli, ['domain', 'read', '--help']) assert r.exit_code == 0 with runner.isolated_filesystem(): + res = runner.invoke(cli, ['init', PROJNAME]) res = runner.invoke(cli, ['domain', 'update', '--help']) assert r.exit_code == 0 with runner.isolated_filesystem(): + res = runner.invoke(cli, ['init', PROJNAME]) res = runner.invoke(cli, ['domain', 'delete', '--help']) assert r.exit_code == 0