[conf] use of toml for halfapi configs. re-enable possibility of multiple domains

This commit is contained in:
Maxime Alves LIRMM 2021-12-03 17:25:57 +01:00
parent d06857bf49
commit dbca2f28fb
15 changed files with 210 additions and 102 deletions

View File

@ -21,6 +21,8 @@ pyjwt = ">=2.3.0,<2.4.0"
pyyaml = ">=5.3.1,<6" pyyaml = ">=5.3.1,<6"
timing-asgi = ">=0.2.1,<1" timing-asgi = ">=0.2.1,<1"
schema = ">=0.7.4,<1" schema = ">=0.7.4,<1"
toml = "*"
pip = "*"
[scripts] [scripts]
halfapi = "python -m halfapi" halfapi = "python -m halfapi"

View File

@ -13,7 +13,7 @@ import orjson
from .cli import cli from .cli import cli
from ..conf import config, write_config, DOMAINSDICT from ..conf import write_config
from ..lib.domain import domain_schema from ..lib.domain import domain_schema
from ..lib.schemas import schema_dict_dom from ..lib.schemas import schema_dict_dom
@ -61,13 +61,7 @@ def create_domain(domain_name: str, module_path: str):
os.mkdir(router_path) os.mkdir(router_path)
create_init(router_path) create_init(router_path)
# TODO: Generate config file
if not config.has_section('domain'):
config.add_section('domain')
config.set('domain', 'name', domain_name)
config.set('domain', 'router', module_path)
write_config()
domain_tree_create() domain_tree_create()
""" """
@ -113,11 +107,13 @@ def list_routes(domain, m_dom):
def list_api_routes(): def list_api_routes():
""" """
Echoes the list of all active domains. Echoes the list of all active domains.
TODO: Rewrite function
""" """
click.echo('# API Routes') click.echo('# API Routes')
for domain, m_dom in DOMAINSDICT().items(): # for domain, m_dom in DOMAINSDICT().items():
list_routes(domain, m_dom) # list_routes(domain, m_dom)
@click.option('--read',default=True, is_flag=True) @click.option('--read',default=True, is_flag=True)

View File

@ -10,9 +10,10 @@ import uvicorn
from .cli import cli from .cli import cli
from .domain import list_api_routes from .domain import list_api_routes
from ..conf import (PROJECT_NAME, HOST, PORT, SCHEMA, from ..conf import (PROJECT_NAME, HOST, PORT, SCHEMA,
PRODUCTION, LOGLEVEL, DOMAINSDICT, CONFIG, DOMAIN, ROUTER) PRODUCTION, LOGLEVEL, CONFIG)
from ..logging import logger from ..logging import logger
from ..lib.schemas import schema_csv_dict from ..lib.schemas import schema_csv_dict
from ..half_domain import HalfDomain
@click.option('--host', default=HOST) @click.option('--host', default=HOST)
@click.option('--port', default=PORT) @click.option('--port', default=PORT)
@ -26,7 +27,8 @@ from ..lib.schemas import schema_csv_dict
@click.argument('schema', type=click.File('r'), required=False) @click.argument('schema', type=click.File('r'), required=False)
@click.argument('domain', required=False) @click.argument('domain', required=False)
@cli.command() @cli.command()
def run(host, port, reload, secret, production, loglevel, prefix, check, dryrun, schema, domain): def run(host, port, reload, secret, production, loglevel, prefix, check, dryrun,
schema, domain):
""" """
The "halfapi run" command The "halfapi run" command
""" """
@ -58,6 +60,19 @@ def run(host, port, reload, secret, production, loglevel, prefix, check, dryrun,
for key, val in schema_csv_dict(schema, prefix).items(): for key, val in schema_csv_dict(schema, prefix).items():
SCHEMA[key] = val SCHEMA[key] = val
if domain:
# If we specify a domain to run as argument
for key in CONFIG['domain']:
# Disable all domains
CONFIG['domain'].pop(key)
# And activate the desired one, mounted without prefix
CONFIG['domain'][domain] = {
'name': domain,
'prefix': False
}
# list_api_routes() # list_api_routes()
click.echo(f'uvicorn.run("halfapi.app:application"\n' \ click.echo(f'uvicorn.run("halfapi.app:application"\n' \

View File

@ -10,7 +10,6 @@ It uses the following environment variables :
It defines the following globals : It defines the following globals :
- PROJECT_NAME (str) - HALFAPI_PROJECT_NAME - PROJECT_NAME (str) - HALFAPI_PROJECT_NAME
- DOMAINSDICT ({domain_name: domain_module}) - HALFAPI_DOMAIN_NAME / HALFAPI_DOMAIN_MODULE
- PRODUCTION (bool) - HALFAPI_PRODUCTION - PRODUCTION (bool) - HALFAPI_PRODUCTION
- LOGLEVEL (string) - HALFAPI_LOGLEVEL - LOGLEVEL (string) - HALFAPI_LOGLEVEL
- BASE_DIR (str) - HALFAPI_BASE_DIR - BASE_DIR (str) - HALFAPI_BASE_DIR
@ -18,7 +17,6 @@ It defines the following globals :
- PORT (int) - HALFAPI_PORT - PORT (int) - HALFAPI_PORT
- CONF_DIR (str) - HALFAPI_CONF_DIR - CONF_DIR (str) - HALFAPI_CONF_DIR
- DRYRUN (bool) - HALFAPI_DRYRUN - DRYRUN (bool) - HALFAPI_DRYRUN
- config (ConfigParser)
It reads the following ressource : It reads the following ressource :
@ -30,43 +28,41 @@ It follows the following format :
name = PROJECT_NAME name = PROJECT_NAME
halfapi_version = HALFAPI_VERSION halfapi_version = HALFAPI_VERSION
[domains] [domain.domain_name]
domain_name = requirements-like-url name = domain_name
routers = routers
[domain.domain_name.config]
option = Argh
""" """
import logging import logging
import os import os
from os import environ from os import environ
import sys import sys
from configparser import ConfigParser
import importlib import importlib
import tempfile
import uuid
import toml
from .lib.domain import d_domains from .lib.domain import d_domains
from .logging import logger from .logging import logger
CONFIG = {}
PROJECT_NAME = environ.get('HALFAPI_PROJECT_NAME') or os.path.basename(os.getcwd())
DOMAINSDICT = lambda: {}
DOMAINS = {}
PRODUCTION = True PRODUCTION = True
LOGLEVEL = 'info' LOGLEVEL = 'info'
HOST = '127.0.0.1'
PORT = '3000'
SECRET = ''
CONF_FILE = os.environ.get('HALFAPI_CONF_FILE', '.halfapi/config') CONF_FILE = os.environ.get('HALFAPI_CONF_FILE', '.halfapi/config')
DRYRUN = bool(os.environ.get('HALFAPI_DRYRUN', False)) DRYRUN = bool(os.environ.get('HALFAPI_DRYRUN', False))
DOMAIN = None
ROUTER = None
SCHEMA = {} SCHEMA = {}
config = ConfigParser(allow_no_value=True)
CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api') CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api')
HALFAPI_ETC_FILE=os.path.join( HALFAPI_ETC_FILE=os.path.join(
CONF_DIR, 'default.ini' CONF_DIR, 'config'
) )
HALFAPI_DOT_FILE=os.path.join( HALFAPI_DOT_FILE=os.path.join(
os.getcwd(), '.halfapi', 'config') os.getcwd(), '.halfapi', 'config')
@ -85,59 +81,65 @@ def write_config():
""" """
Writes the current config to the highest priority config file Writes the current config to the highest priority config file
""" """
with open(conf_files()[-1], 'w') as halfapi_config: # with open(conf_files()[-1], 'w') as halfapi_config:
config.write(halfapi_config) # config.write(halfapi_config)
pass
def config_dict():
"""
The config object as a dict
"""
return {
section: dict(config.items(section))
for section in config.sections()
}
def read_config(): def read_config():
""" """
The highest index in "filenames" are the highest priorty The highest index in "filenames" are the highest priorty
""" """
config.read(HALFAPI_CONFIG_FILES) return toml.load(HALFAPI_CONFIG_FILES)
CONFIG = {} CONFIG = {}
read_config()
PROJECT_NAME = config.get('project', 'name', fallback=PROJECT_NAME) PROJECT_NAME = CONFIG.get('project', {}).get(
'name',
environ.get('HALFAPI_PROJECT_NAME', os.path.basename(os.getcwd())))
if len(PROJECT_NAME) == 0: if len(CONFIG.get('domain', {}).keys()) == 0:
raise Exception('Need a project name as argument') logger.info('No domains')
# logger.info('Running without domains: %s', d_domains(config) or 'empty domain dictionary')
DOMAINSDICT = lambda: d_domains(config)
DOMAINS = DOMAINSDICT()
if len(DOMAINS) == 0:
logger.info('Running without domains: %s', d_domains(config) or 'empty domain dictionary')
HOST = config.get('project', 'host', fallback=environ.get('HALFAPI_HOST', '127.0.0.1')) # Bind
PORT = config.getint('project', 'port', fallback=environ.get('HALFAPI_PORT', '3000')) HOST = CONFIG.get('project', {}).get(
'host',
environ.get('HALFAPI_HOST', '127.0.0.1'))
PORT = int(CONFIG.get('project', {}).get(
'port',
environ.get('HALFAPI_PORT', '3000')))
# Secret
SECRET = CONFIG.get('project', {}).get(
'secret',
environ.get('HALFAPI_SECRET'))
if not SECRET:
# TODO: Create a temporary secret
_, SECRET = tempfile.mkstemp()
with open('SECRET', 'w') as secret_file:
secret_file.write(str(uuid.uuid4()))
secret_path = config.get('project', 'secret', fallback=environ.get('HALFAPI_SECRET', ''))
try: try:
with open(secret_path, 'r') as secret_file: with open(SECRET, 'r') as secret_file:
SECRET = secret_file.read().strip()
CONFIG['secret'] = SECRET.strip() CONFIG['secret'] = SECRET.strip()
except FileNotFoundError as exc: except FileNotFoundError as exc:
logger.info('Running without secret file: %s', secret_path or 'no file specified') logger.info('Running without secret file: %s', SECRET or 'no file specified')
PRODUCTION = config.getboolean('project', 'production', PRODUCTION = bool(CONFIG.get('project', {}).get(
fallback=environ.get('HALFAPI_PROD', True)) 'production',
environ.get('HALFAPI_PROD', True)))
LOGLEVEL = config.get('project', 'loglevel', LOGLEVEL = CONFIG.get('project', {}).get(
fallback=environ.get('HALFAPI_LOGLEVEL', 'info')).lower() 'loglevel',
environ.get('HALFAPI_LOGLEVEL', 'info')).lower()
BASE_DIR = config.get('project', 'base_dir', BASE_DIR = CONFIG.get('project', {}).get(
fallback=environ.get('HALFAPI_BASE_DIR', '.')) 'base_dir',
environ.get('HALFAPI_BASE_DIR', '.'))
CONFIG = { CONFIG = {
'project_name': PROJECT_NAME, 'project_name': PROJECT_NAME,
@ -146,4 +148,5 @@ CONFIG = {
'host': HOST, 'host': HOST,
'port': PORT, 'port': PORT,
'dryrun': DRYRUN, 'dryrun': DRYRUN,
'domain': {}
} }

57
halfapi/half_domain.py Normal file
View File

@ -0,0 +1,57 @@
import importlib
from starlette.applications import Starlette
from starlette.routing import Router
from .half_route import HalfRoute
from .lib.routes import gen_domain_routes, gen_schema_routes
from .lib.domain_middleware import DomainMiddleware
from .logging import logger
class HalfDomain(Starlette):
def __init__(self, app, domain, router=None, config={}):
self.app = app
self.m_domain = importlib.import_module(domain)
self.name = getattr('__name__', domain, domain)
if not router:
self.router = getattr('__router__', domain, '.routers')
else:
self.router = router
self.m_router = importlib.import_module(self.router, domain)
self.m_acl = importlib.import_module(f'{domain}.acl')
self.config = config
"""
if domain:
m_domain = importlib.import_module(domain)
if not router:
router = getattr('__router__', domain, '.routers')
m_domain_router = importlib.import_module(router, domain)
m_domain_acl = importlib.import_module(f'{domain}.acl')
if not(m_domain and m_domain_router and m_domain_acl):
raise Exception('Cannot import domain')
self.schema = domain_schema(m_domain)
routes = [ Route('/', JSONRoute(self.schema)) ]
"""
logger.info('HalfDomain creation %s %s', domain, config)
super().__init__(
routes=gen_domain_routes(self.m_router),
middleware=[
(DomainMiddleware,
{
'domain': self.name,
'config': self.config
}
)
]
)

View File

@ -40,13 +40,14 @@ from .lib.domain import domain_schema_dict, NoDomainsException, domain_schema
from .lib.routes import gen_domain_routes, gen_schema_routes, JSONRoute from .lib.routes import gen_domain_routes, gen_schema_routes, JSONRoute
from .lib.schemas import schema_json, get_acls from .lib.schemas import schema_json, get_acls
from .logging import logger, config_logging from .logging import logger, config_logging
from .half_domain import HalfDomain
from halfapi import __version__ from halfapi import __version__
class HalfAPI: class HalfAPI:
def __init__(self, config, def __init__(self, config,
routes_dict=None): d_routes=None):
config_logging(logging.DEBUG) config_logging(logging.DEBUG)
SECRET = config.get('secret') SECRET = config.get('secret')
@ -55,27 +56,28 @@ class HalfAPI:
DRYRUN = config.get('dryrun', False) DRYRUN = config.get('dryrun', False)
self.PRODUCTION = PRODUCTION self.PRODUCTION = PRODUCTION
self.CONFIG = CONFIG self.CONFIG = config
self.SECRET = SECRET self.SECRET = SECRET
self.__application = None self.__application = None
# Domains
""" HalfAPI routes (if not PRODUCTION, includes debug routes) """ HalfAPI routes (if not PRODUCTION, includes debug routes)
""" """
routes = [] routes = []
routes.append( routes.append(
Route('/', JSONRoute({})) Mount('/halfapi', routes=list(self.halfapi_routes()))
) )
routes.append( logger.info('Config: %s', config)
Mount('/halfapi', routes=list(self.routes())) logger.info('Active domains: %s', config.get('domain', {}))
)
if routes_dict: if d_routes:
# Mount the routes from the routes_dict argument - domain-less mode # Mount the routes from the d_routes argument - domain-less mode
logger.info('Domain-less mode : the given schema defines the activated routes') logger.info('Domain-less mode : the given schema defines the activated routes')
for route in gen_schema_routes(routes_dict): for route in gen_schema_routes(d_routes):
routes.append(route) routes.append(route)
else: else:
""" """
@ -104,6 +106,24 @@ class HalfAPI:
on_startup=startup_fcts on_startup=startup_fcts
) )
for key, domain in config.get('domain', {}).items():
dom_name = domain.get('name', key)
if domain.get('prefix', False):
path = f'/{dom_name}'
else:
path = '/'
self.__application.mount(path,
Mount('/',
HalfDomain(
self.application,
domain.get('name', key),
domain.get('router'),
config=domain.get('config', {})
)
)
)
""" """
self.__application.add_middleware( self.__application.add_middleware(
DomainMiddleware, DomainMiddleware,
@ -139,7 +159,7 @@ class HalfAPI:
def application(self): def application(self):
return self.__application return self.__application
def routes(self): def halfapi_routes(self):
""" Halfapi default routes """ Halfapi default routes
""" """

View File

@ -56,9 +56,8 @@ def route_decorator(fct: FunctionType, ret_type: str = 'json') -> Coroutine:
fct_args['halfapi'] = { fct_args['halfapi'] = {
'user': request.user if 'user': request.user if
'user' in request else None, 'user' in request else None,
'config': request.scope['config'], 'config': request.scope.get('config', {}),
'domain': request.scope['domain'], 'domain': request.scope.get('domain', 'unknown'),
} }

View File

@ -32,8 +32,7 @@ class DomainMiddleware(BaseHTTPMiddleware):
""" """
request.scope['domain'] = self.domain request.scope['domain'] = self.domain
request.scope['config'] = self.config['domain_config'][self.domain] \ request.scope['config'] = self.config.copy()
if self.domain in self.config.get('domain_config', {}) else {}
response = await call_next(request) response = await call_next(request)

View File

@ -19,11 +19,11 @@ from types import ModuleType, FunctionType
import yaml import yaml
from .domain import gen_router_routes, domain_acls, route_decorator from .domain import gen_router_routes, domain_acls, route_decorator, domain_schema_dict
from .responses import ORJSONResponse from .responses import ORJSONResponse
from .acl import args_check from .acl import args_check
from ..half_route import HalfRoute from ..half_route import HalfRoute
from ..conf import DOMAINSDICT from . import acl
from ..logging import logger from ..logging import logger
@ -58,6 +58,11 @@ def gen_domain_routes(m_domain: ModuleType):
Returns: Returns:
Generator(HalfRoute) Generator(HalfRoute)
""" """
yield HalfRoute(f'/',
JSONRoute(domain_schema_dict(m_domain)),
[{'acl': acl.public}],
'GET'
)
for path, method, m_router, fct, params in gen_router_routes(m_domain, []): for path, method, m_router, fct, params in gen_router_routes(m_domain, []):
yield HalfRoute(f'/{path}', fct, params, method) yield HalfRoute(f'/{path}', fct, params, method)
@ -144,9 +149,11 @@ def api_routes(m_dom: ModuleType) -> Tuple[Dict, Dict]:
def api_acls(request): def api_acls(request):
""" Returns the list of possible ACLs """ Returns the list of possible ACLs
# TODO: Rewrite
""" """
res = {} res = {}
domains = DOMAINSDICT() domains = {}
doc = 'doc' in request.query_params doc = 'doc' in request.query_params
for domain, m_domain in domains.items(): for domain, m_domain in domains.items():
res[domain] = {} res[domain] = {}

View File

@ -50,7 +50,8 @@ setup(
"orjson>=3.4.7,<4", "orjson>=3.4.7,<4",
"pyyaml>=5.3.1,<6", "pyyaml>=5.3.1,<6",
"timing-asgi>=0.2.1,<1", "timing-asgi>=0.2.1,<1",
"schema>=0.7.4,<1" "schema>=0.7.4,<1",
"toml>=0.7.1,<0.8"
], ],
classifiers=[ classifiers=[
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",

View File

@ -12,7 +12,7 @@ def test_run_noproject(cli_runner):
result = cli_runner.invoke(cli, ['run']) result = cli_runner.invoke(cli, ['run'])
try: try:
assert result.exit_code == 1 assert result.exit_code == 0
except AssertionError as exc: except AssertionError as exc:
print(result.stdout) print(result.stdout)
raise exc raise exc

View File

@ -39,7 +39,10 @@ from halfapi.lib.jwt_middleware import (
def dummy_domain(): def dummy_domain():
yield { yield {
'name': 'dummy_domain', 'name': 'dummy_domain',
'router': 'dummy_domain.routers' 'router': 'dummy_domain.routers',
'config': {
'test': True
}
} }
@pytest.fixture @pytest.fixture
@ -253,10 +256,10 @@ def dummy_project():
f'secret = {halfapi_secret}\n', f'secret = {halfapi_secret}\n',
'port = 3050\n', 'port = 3050\n',
'loglevel = debug\n', 'loglevel = debug\n',
'[domain]\n', '[domain.dummy_domain]\n',
f'name = {domain}\n', f'name = {domain}\n',
'router = dummy_domain.routers\n', 'router = dummy_domain.routers\n',
f'[{domain}]\n', f'[domain.dummy_domain.config]\n',
'test = True' 'test = True'
]) ])
@ -271,8 +274,10 @@ def application_debug(project_runner):
'secret':'turlututu', 'secret':'turlututu',
'production':False, 'production':False,
'domain': { 'domain': {
'name': 'test_domain', 'domain': {
'router': 'test_domain.routers' 'name': 'test_domain',
'router': 'test_domain.routers'
}
}, },
'config':{ 'config':{
'domain_config': {'test_domain': {'test': True}} 'domain_config': {'test_domain': {'test': True}}
@ -288,9 +293,13 @@ def application_domain(dummy_domain):
return HalfAPI({ return HalfAPI({
'secret':'turlututu', 'secret':'turlututu',
'production':True, 'production':True,
'domain': dummy_domain, 'domain': {
'config':{ 'domain': {
'domain_config': {'dummy_domain': {'test': True}} **dummy_domain,
'config': {
'test': True
}
}
} }
}).application }).application

View File

@ -8,6 +8,7 @@ from halfapi.lib.domain import NoDomainsException
def test_halfapi_dummy_domain(dummy_domain): def test_halfapi_dummy_domain(dummy_domain):
with patch('starlette.applications.Starlette') as mock: with patch('starlette.applications.Starlette') as mock:
mock.return_value = MagicMock() mock.return_value = MagicMock()
halfapi = HalfAPI({ config = {}
'domain': dummy_domain config['domain'] = {}
}) config['domain'][dummy_domain['name']] = dummy_domain
halfapi = HalfAPI(config)

View File

@ -5,10 +5,12 @@ from halfapi.halfapi import HalfAPI
class TestConf(TestCase): class TestConf(TestCase):
def setUp(self): def setUp(self):
self.args = { self.args = {
'domain': { 'domain': {
'name': 'dummy_domain', 'dummy_domain': {
'router': 'dummy_domain.routers' 'name': 'dummy_domain',
'router': '.routers'
}
} }
} }
def tearDown(self): def tearDown(self):
@ -39,7 +41,6 @@ class TestConf(TestCase):
CONFIG, CONFIG,
SCHEMA, SCHEMA,
SECRET, SECRET,
DOMAINSDICT,
PROJECT_NAME, PROJECT_NAME,
HOST, HOST,
PORT, PORT,
@ -49,9 +50,8 @@ class TestConf(TestCase):
assert isinstance(CONFIG, dict) assert isinstance(CONFIG, dict)
assert isinstance(SCHEMA, dict) assert isinstance(SCHEMA, dict)
assert isinstance(SECRET, str) assert isinstance(SECRET, str)
assert isinstance(DOMAINSDICT(), dict)
assert isinstance(PROJECT_NAME, str) assert isinstance(PROJECT_NAME, str)
assert isinstance(HOST, str) assert isinstance(HOST, str)
assert isinstance(PORT, str) assert isinstance(PORT, int)
assert str(int(PORT)) == PORT assert int(str(int(PORT))) == PORT
assert isinstance(CONF_DIR, str) assert isinstance(CONF_DIR, str)

View File

@ -2,6 +2,5 @@ from halfapi.halfapi import HalfAPI
def test_methods(): def test_methods():
assert 'application' in dir(HalfAPI) assert 'application' in dir(HalfAPI)
assert 'routes' in dir(HalfAPI)
assert 'version' in dir(HalfAPI) assert 'version' in dir(HalfAPI)
assert 'version_async' in dir(HalfAPI) assert 'version_async' in dir(HalfAPI)