From 81f6cf8b39f0a0547a3a4b51bc99557c2fffc392 Mon Sep 17 00:00:00 2001 From: "Maxime Alves LIRMM@home" Date: Thu, 17 Jun 2021 18:53:23 +0200 Subject: [PATCH] [0.5.3] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed commit of the following: commit ac935db6d62656713183707707d083298c1f34b0 Author: Maxime Alves LIRMM@home Date: Thu Jun 17 18:52:49 2021 +0200 [tests] remove dummy-domain from dependencies commit 4d50363c9b1502d1d8b7cbafc207e80cdbe247a4 Author: Maxime Alves LIRMM@home Date: Thu Jun 17 18:52:18 2021 +0200 [tests] update tests for 0.5.3 commit 6181592692464d21de9807e1e890b4ac92efc387 Author: Maxime Alves LIRMM@home Date: Thu Jun 17 18:17:51 2021 +0200 [lib.*] Refactor libs commit ed7485a8a16b60dde8acc0d2b9afca00a75fce3c Author: Maxime Alves LIRMM@home Date: Thu Jun 17 18:15:10 2021 +0200 [app] Use HalfAPI class to be able to use custom configuration à commit fa1ca6bf9df4ea17d7fa7dfdf3694c56746e9c7f Author: Maxime Alves LIRMM Date: Wed Jun 16 15:34:25 2021 +0200 [wip] tests dummy_domain commit 86e8dd3465e0bd0f3d49f28fd9e05a52874e969a Author: Maxime Alves LIRMM Date: Tue Jun 15 18:12:13 2021 +0200 [0.5.3] ajout de la config actuelle dans les arguments des routes commit aa7ec62c7a3b5a0ae0dc0cc79bd509eece44d5ff Author: Maxime Alves LIRMM Date: Tue Jun 15 11:16:23 2021 +0200 [lib.jwtMw] verify signature even if halfapi is in DEBUG mode commit e208728d7ec61b0de583c66c348f73ac7a884108 Author: Maxime Alves LIRMM Date: Tue Jun 15 10:49:46 2021 +0200 [lib.acl] args_check doesn't check required/optional arguments if "args" is not specified in request, if the target function is not async commit aa4c309778e4c969fe1314da305e02112d4641b7 Author: Maxime Alves LIRMM Date: Tue Jun 15 09:45:37 2021 +0200 [lib.domain] SUBROUTER can be a path parameter if including ":" commit 138420461d5c1ba0c7379dcda64e9a38aa5d768d Author: Maxime Alves LIRMM@home Date: Tue Jun 15 07:24:32 2021 +0200 [gitignore] *.swp commit 0c1e2849bad591d62f7399d820a044cf4e06de12 Author: Maxime Alves LIRMM@home Date: Tue Jun 15 07:24:14 2021 +0200 [tests] test get route with dummy projects commit 7227e2d7f105516b67f3acac27b242fe01b59215 Author: Maxime Alves LIRMM@home Date: Mon Jun 14 17:18:47 2021 +0200 [lib.domain] handle modules without ROUTES attribute commit 78c75cd60ead023e7a2d7fa2e5d1059fada0044f Author: Maxime Alves LIRMM@home Date: Mon Jun 14 16:34:58 2021 +0200 [tests] add dummy_project_router tests for path-based routers (without ROUTES variable) --- .gitignore | 3 + Pipfile | 1 - halfapi/__init__.py | 2 +- halfapi/app.py | 114 +++++++----- halfapi/conf.py | 25 ++- halfapi/lib/acl.py | 37 ++-- halfapi/lib/constants.py | 1 + halfapi/lib/domain.py | 176 ++++++++++++------ halfapi/lib/domain_middleware.py | 14 +- halfapi/lib/jwt_middleware.py | 2 +- halfapi/lib/router.py | 44 +++++ halfapi/lib/routes.py | 43 ++--- halfapi/lib/schemas.py | 17 ++ tests/conftest.py | 75 +++++++- .../{routers => }/act/__init__.py | 0 .../{routers => }/act/personne/__init__.py | 0 .../{routers => }/act/personne/eo.py | 0 tests/dummy_domain/routers/__init__.py | 5 - tests/dummy_domain/routers/abc/__init__.py | 5 - .../abc/alphabet/TEST_uuid/__init__.py | 19 ++ .../routers/abc/alphabet/__init__.py | 15 +- .../routers/abc/pinnochio/__init__.py | 6 +- tests/test_debug_routes.py | 26 +-- tests/test_dummy_project_router.py | 41 ++++ tests/test_lib_domain.py | 40 ++-- tests/test_lib_router.py | 44 +++++ tests/test_lib_schemas.py | 20 +- 27 files changed, 564 insertions(+), 211 deletions(-) create mode 100644 halfapi/lib/constants.py create mode 100644 halfapi/lib/router.py rename tests/dummy_domain/{routers => }/act/__init__.py (100%) rename tests/dummy_domain/{routers => }/act/personne/__init__.py (100%) rename tests/dummy_domain/{routers => }/act/personne/eo.py (100%) create mode 100644 tests/dummy_domain/routers/abc/alphabet/TEST_uuid/__init__.py create mode 100644 tests/test_dummy_project_router.py create mode 100644 tests/test_lib_router.py diff --git a/.gitignore b/.gitignore index 91a465b..add7360 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,6 @@ cython_debug/ domains/ .vscode + +# Vim swap files +*.swp diff --git a/Pipfile b/Pipfile index 6dcee8a..fca048a 100644 --- a/Pipfile +++ b/Pipfile @@ -8,7 +8,6 @@ pytest = "*" requests = "*" pytest-asyncio = "*" pylint = "*" -dummy-domain = {path = "./tests"} [packages] click = ">=7.1,<8" diff --git a/halfapi/__init__.py b/halfapi/__init__.py index 9274551..7876ed0 100644 --- a/halfapi/__init__.py +++ b/halfapi/__init__.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -__version__ = '0.5.2' +__version__ = '0.5.3' def version(): return f'HalfAPI version:{__version__}' diff --git a/halfapi/app.py b/halfapi/app.py index 5b471f3..bd7025f 100644 --- a/halfapi/app.py +++ b/halfapi/app.py @@ -23,7 +23,6 @@ from timing_asgi import TimingMiddleware from timing_asgi.integrations import StarletteScopeToName # module libraries -from halfapi.conf import config, SECRET, PRODUCTION, DOMAINSDICT from .lib.domain_middleware import DomainMiddleware from .lib.timing import HTimingClient @@ -34,61 +33,86 @@ from halfapi.lib.responses import (ORJSONResponse, UnauthorizedResponse, NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse) from halfapi.lib.routes import gen_starlette_routes, debug_routes -from halfapi.lib.schemas import get_api_routes, schema_json, get_acls +from halfapi.lib.schemas import get_api_routes, get_api_domain_routes, schema_json, get_acls logger = logging.getLogger('uvicorn.asgi') -routes = [ Route('/', get_api_routes) ] + +class HalfAPI: + def __init__(self, config=None): + if config: + SECRET = config.get('SECRET') + PRODUCTION = config.get('PRODUCTION') + DOMAINS = config.get('DOMAINS', {}) + CONFIG = config.get('CONFIG', { + 'domains': DOMAINS + }) + else: + from halfapi.conf import CONFIG, SECRET, PRODUCTION, DOMAINS -routes += [ - Route('/halfapi/schema', schema_json), - Route('/halfapi/acls', get_acls) -] - -routes += Route('/halfapi/current_user', lambda request, *args, **kwargs: - ORJSONResponse({'user':request.user.json}) - if SECRET and not isinstance(request.user, UnauthenticatedUser) - else ORJSONResponse({'user': None})), + routes = [ Route('/', get_api_routes) ] -if not PRODUCTION: - for route in debug_routes(): - routes.append( route ) + routes += [ + Route('/halfapi/schema', schema_json), + Route('/halfapi/acls', get_acls) + ] + + routes += Route('/halfapi/current_user', lambda request, *args, **kwargs: + ORJSONResponse({'user':request.user.json}) + if SECRET and not isinstance(request.user, UnauthenticatedUser) + else ORJSONResponse({'user': None})), -if DOMAINSDICT: - for route in gen_starlette_routes(DOMAINSDICT()): - routes.append(route) + if not PRODUCTION: + for route in debug_routes(): + routes.append( route ) -application = Starlette( - debug=not PRODUCTION, - routes=routes, - exception_handlers={ - 401: UnauthorizedResponse, - 404: NotFoundResponse, - 500: InternalServerErrorResponse, - 501: NotImplementedResponse - } -) + if DOMAINS: + for route in gen_starlette_routes(DOMAINS): + routes.append(route) -if DOMAINSDICT: - application.add_middleware( - DomainMiddleware, - config=config - ) + for domain in DOMAINS: + routes.append( + Route( + f'/{domain}', + get_api_domain_routes(domain) + ) + ) -if SECRET: - application.add_middleware( - AuthenticationMiddleware, - backend=JWTAuthenticationBackend(secret_key=SECRET) - ) -if not PRODUCTION: - application.add_middleware( - TimingMiddleware, - client=HTimingClient(), - metric_namer=StarletteScopeToName(prefix="halfapi", - starlette_app=application) - ) + self.application = Starlette( + debug=not PRODUCTION, + routes=routes, + exception_handlers={ + 401: UnauthorizedResponse, + 404: NotFoundResponse, + 500: InternalServerErrorResponse, + 501: NotImplementedResponse + } + ) + + self.application.add_middleware( + DomainMiddleware, + config=CONFIG + ) + + if SECRET: + self.application.add_middleware( + AuthenticationMiddleware, + backend=JWTAuthenticationBackend(secret_key=SECRET) + ) + + if not PRODUCTION: + self.application.add_middleware( + TimingMiddleware, + client=HTimingClient(), + metric_namer=StarletteScopeToName(prefix="halfapi", + starlette_app=self.application) + ) + + + +application = HalfAPI().application diff --git a/halfapi/conf.py b/halfapi/conf.py index 6d0d751..a89e09b 100644 --- a/halfapi/conf.py +++ b/halfapi/conf.py @@ -47,13 +47,15 @@ logger = logging.getLogger('halfapi') PROJECT_NAME = os.path.basename(os.getcwd()) DOMAINSDICT = lambda: {} +DOMAINS = {} PRODUCTION = False LOGLEVEL = 'info' HOST = '127.0.0.1' PORT = '3000' SECRET = '' +CONF_FILE = os.environ.get('HALFAPI_CONF_FILE', '.halfapi/config') -IS_PROJECT = os.path.isfile('.halfapi/config') +is_project = lambda: os.path.isfile(CONF_FILE) @@ -77,7 +79,7 @@ HALFAPI_ETC_FILE=os.path.join( HALFAPI_DOT_FILE=os.path.join( os.getcwd(), '.halfapi', 'config') -HALFAPI_CONFIG_FILES = [ HALFAPI_ETC_FILE, HALFAPI_DOT_FILE ] +HALFAPI_CONFIG_FILES = [ CONF_FILE, HALFAPI_DOT_FILE ] def conf_files(): return [ @@ -114,8 +116,11 @@ def read_config(): -if IS_PROJECT: - read_config() +CONFIG = {} +IS_PROJECT = False +if is_project(): + IS_PROJECT = True + CONFIG = read_config() PROJECT_NAME = config.get('project', 'name', fallback=PROJECT_NAME) @@ -123,12 +128,14 @@ if IS_PROJECT: raise Exception('Need a project name as argument') DOMAINSDICT = lambda: d_domains(config) + DOMAINS = DOMAINSDICT() HOST = config.get('project', 'host') PORT = config.getint('project', 'port') try: with open(config.get('project', 'secret')) as secret_file: - SECRET = secret_file.read() + SECRET = secret_file.read().strip() + CONFIG['secret'] = SECRET.strip() # Set the secret so we can use it in domains os.environ['HALFAPI_SECRET'] = SECRET except FileNotFoundError as exc: @@ -141,3 +148,11 @@ if IS_PROJECT: LOGLEVEL = config.get('project', 'loglevel').lower() or 'info' BASE_DIR = config.get('project', 'base_dir', fallback='.') #os.getcwd()) + + CONFIG = { + 'project_name': PROJECT_NAME, + 'production': PRODUCTION, + 'secret': SECRET, + 'domains': DOMAINS + } + diff --git a/halfapi/lib/acl.py b/halfapi/lib/acl.py index e6f3dd7..71f9713 100644 --- a/halfapi/lib/acl.py +++ b/halfapi/lib/acl.py @@ -66,26 +66,31 @@ def args_check(fct): return ', '.join(array) - args_d = kwargs.get('args', {}) - required = args_d.get('required', set()) + args_d = kwargs.get('args', None) + if args_d is not None: + required = args_d.get('required', set()) - missing = [] - data = {} + missing = [] + data = {} - for key in required: - data[key] = data_.pop(key, None) - if data[key] is None: - missing.append(key) + for key in required: + data[key] = data_.pop(key, None) + if data[key] is None: + missing.append(key) - if missing: - raise HTTPException( - 400, - f"Missing value{plural(missing)} for: {comma_list(missing)}!") + if missing: + raise HTTPException( + 400, + f"Missing value{plural(missing)} for: {comma_list(missing)}!") - optional = args_d.get('optional', set()) - for key in optional: - if key in data_: - data[key] = data_[key] + optional = args_d.get('optional', set()) + for key in optional: + if key in data_: + data[key] = data_[key] + else: + """ Unsafe mode, without specified arguments + """ + data = data_ kwargs['data'] = data diff --git a/halfapi/lib/constants.py b/halfapi/lib/constants.py new file mode 100644 index 0000000..562c174 --- /dev/null +++ b/halfapi/lib/constants.py @@ -0,0 +1 @@ +VERBS = ('GET', 'POST', 'PUT', 'PATCH', 'DELETE') diff --git a/halfapi/lib/domain.py b/halfapi/lib/domain.py index 5ca13e0..af1aa89 100644 --- a/halfapi/lib/domain.py +++ b/halfapi/lib/domain.py @@ -3,15 +3,71 @@ lib/domain.py The domain-scoped utility functions """ +import os +import re import sys import importlib +import inspect import logging from types import ModuleType, FunctionType -from typing import Generator, Dict, List +from typing import Any, Callable, Coroutine, Generator +from typing import Dict, List, Tuple, Iterator +import inspect + +from starlette.exceptions import HTTPException + +from halfapi.lib import acl +from halfapi.lib.responses import ORJSONResponse +from halfapi.lib.router import read_router +from halfapi.lib.constants import VERBS logger = logging.getLogger("uvicorn.asgi") -VERBS = ('GET', 'POST', 'PUT', 'PATCH', 'DELETE') +class MissingAclError(Exception): + pass + +class PathError(Exception): + pass + +class UnknownPathParameterType(Exception): + pass + +class UndefinedRoute(Exception): + pass + +class UndefinedFunction(Exception): + pass + +def route_decorator(fct: FunctionType, ret_type: str = 'json') -> Coroutine: + """ Returns an async function that can be mounted on a router + """ + if ret_type == 'json': + @acl.args_check + async def wrapped(request, *args, **kwargs): + fct_args_spec = inspect.getfullargspec(fct).args + fct_args = request.path_params.copy() + + if 'halfapi' in fct_args_spec: + fct_args['halfapi'] = { + 'user': request.user if + 'user' in request else None, + 'config': request.scope['config'] + } + + + if 'data' in fct_args_spec: + fct_args['data'] = kwargs.get('data') + + try: + return ORJSONResponse(fct(**fct_args)) + except NotImplementedError as exc: + raise HTTPException(501) from exc + + else: + raise Exception('Return type not available') + + return wrapped + def get_fct_name(http_verb: str, path: str) -> str: """ @@ -57,59 +113,53 @@ def get_fct_name(http_verb: str, path: str) -> str: return '_'.join(fct_name) -def gen_routes(route_params: Dict, path: List, m_router: ModuleType) -> Generator: +def gen_routes(m_router: ModuleType, + verb: str, + path: List[str], + params: List[Dict]) -> Tuple[FunctionType, Dict]: """ - Generates a tuple of the following form for a specific path: - - "/path/to/route", { - "GET": { - "fct": endpoint_fct, - "params": [ - { "acl": acl_fct, [...] } - ] - }, - [...] - } + Returns a tuple of the function associatied to the verb and path arguments, + and the dictionary of it's acls Parameters: + - m_router (ModuleType): The module containing the function definition - - route_params (Dict): Contains the following keys : - - one or more HTTP VERB (if none, route is not treated) - - one or zero FQTN (if none, fqtn is set to None) + - verb (str): The HTTP verb for the route (GET, POST, ...) - path (List): The route path, as a list (each item being a level of deepness), from the lowest level (domain) to the highest - - m_router (ModuleType): The parent router module + - params (Dict): The acl list of the following format : + [{'acl': Function, 'args': {'required': [], 'optional': []}}] - Yields: - (str, Dict): The path routes description + Returns: + + (Function, Dict): The destination function and the acl dictionary """ - d_res = {'fqtn': route_params.get('FQTN')} + if len(params) == 0: + raise MissingAclError('[{}] {}'.format(verb, '/'.join(path))) - for verb in VERBS: - params = route_params.get(verb) - if params is None: - continue - if len(params) == 0: - logger.error('No ACL for route [{%s}] %s', verb, "/".join(path)) + if len(path) == 0: + logger.error('Empty path for [{%s}]', verb) + raise PathError() - try: - fct_name = get_fct_name(verb, path[-1]) - fct = getattr(m_router, fct_name) - logger.debug('%s defined in %s', fct.__name__, m_router.__name__) - except AttributeError as exc: - logger.error('%s is not defined in %s', fct_name, m_router.__name__) - continue - - d_res[verb] = {'fct': fct, 'params': params} - - yield f"/{'/'.join([ elt for elt in path if elt ])}", d_res + fct_name = get_fct_name(verb, path[-1]) + if hasattr(m_router, fct_name): + fct = getattr(m_router, fct_name) + else: + raise UndefinedFunction('{}.{}'.format(m_router.__name__, fct_name or '')) -def gen_router_routes(m_router: ModuleType, path: List[str]) -> Generator: + if not inspect.iscoroutinefunction(fct): + return route_decorator(fct), params + else: + return fct, params + + +def gen_router_routes(m_router: ModuleType, path: List[str]) -> \ + Iterator[Tuple[str, str, Coroutine, List]]: """ Recursive generatore that parses a router (or a subrouter) and yields from gen_routes @@ -121,33 +171,43 @@ def gen_router_routes(m_router: ModuleType, path: List[str]) -> Generator: Yields: - (str, Dict): The path routes description from **gen_routes** + (str, str, Coroutine, List): A tuple containing the path, verb, + function and parameters of the route """ - if not hasattr(m_router, 'ROUTES'): - logger.error('Missing *ROUTES* constant in *%s*', m_router.__name__) - raise Exception(f'No ROUTES constant for {m_router.__name__}') - - - routes = m_router.ROUTES - - for subpath, route_params in routes.items(): + for subpath, params in read_router(m_router).items(): path.append(subpath) - yield from gen_routes(route_params, path, m_router) + for verb in VERBS: + if verb not in params: + continue + yield ('/'.join(filter(lambda x: len(x) > 0, path)), + verb, + *gen_routes(m_router, verb, path, params[verb]) + ) + + for subroute in params.get('SUBROUTES', []): + #logger.debug('Processing subroute **%s** - %s', subroute, m_router.__name__) + param_match = re.fullmatch('^([A-Z_]+)_([a-z]+)$', subroute) + if param_match is not None: + try: + path.append('{{{}:{}}}'.format( + param_match.groups()[0].lower(), + param_match.groups()[1])) + except AssertionError: + raise UnknownPathParameterType(subroute) + else: + path.append(subroute) - subroutes = route_params.get('SUBROUTES', []) - for subroute in subroutes: - logger.debug('Processing subroute **%s** - %s', subroute, m_router.__name__) - path.append(subroute) try: - submod = importlib.import_module(f'.{subroute}', m_router.__name__) + yield from gen_router_routes( + importlib.import_module(f'.{subroute}', m_router.__name__), + path) + except ImportError as exc: logger.error('Failed to import subroute **{%s}**', subroute) raise exc - yield from gen_router_routes(submod, path) - path.pop() path.pop() @@ -163,14 +223,14 @@ def d_domains(config) -> Dict[str, ModuleType]: dict[str, ModuleType] """ - if not config.has_section('domains'): + if not 'domains' in config: return {} try: sys.path.append('.') return { domain: importlib.import_module(''.join((domain, module))) - for domain, module in config.items('domains') + for domain, module in config['domains'].items() } except ImportError as exc: logger.error('Could not load a domain : %s', exc) diff --git a/halfapi/lib/domain_middleware.py b/halfapi/lib/domain_middleware.py index 5215fec..e5f10b9 100644 --- a/halfapi/lib/domain_middleware.py +++ b/halfapi/lib/domain_middleware.py @@ -40,10 +40,17 @@ class DomainMiddleware(BaseHTTPMiddleware): """ domain = scope['path'].split('/')[1] - self.domains = d_domains(self.config) + self.domains = self.config.get('domains', {}) - if domain in self.domains: + if len(domain) == 0: + for domain in self.domains: + self.api[domain], self.acl[domain] = api_routes(self.domains[domain]) + elif domain in self.domains: self.api[domain], self.acl[domain] = api_routes(self.domains[domain]) + else: + logger.error('domain not in self.domains %s / %s', + scope['path'], + self.domains) scope_ = scope.copy() scope_['domains'] = self.domains @@ -57,8 +64,7 @@ class DomainMiddleware(BaseHTTPMiddleware): current_domain = cur_path.split('/')[0] try: - config_section = self.config.items(current_domain) - scope_['config'] = dict(config_section) + scope_['config'] = self.config.copy() except configparser.NoSectionError: logger.debug( 'No specific configuration for domain **%s**', current_domain) diff --git a/halfapi/lib/jwt_middleware.py b/halfapi/lib/jwt_middleware.py index 31cb7d5..3f24229 100644 --- a/halfapi/lib/jwt_middleware.py +++ b/halfapi/lib/jwt_middleware.py @@ -140,7 +140,7 @@ class JWTAuthenticationBackend(AuthenticationBackend): key=self.secret_key, algorithms=[self.algorithm], options={ - 'verify_signature': bool(PRODUCTION) + 'verify_signature': True }) if is_check_call: diff --git a/halfapi/lib/router.py b/halfapi/lib/router.py new file mode 100644 index 0000000..cb3a6cf --- /dev/null +++ b/halfapi/lib/router.py @@ -0,0 +1,44 @@ +import os +from types import ModuleType +from typing import Dict + +from halfapi.lib.constants import VERBS + +def read_router(m_router: ModuleType) -> Dict: + """ + Reads a module and returns a router dict + """ + + if not hasattr(m_router, 'ROUTES'): + routes = {'':{}} + acls = getattr(m_router, 'ACLS') if hasattr(m_router, 'ACLS') else None + + if acls is not None: + for verb in VERBS: + if not hasattr(m_router, verb.lower()): + continue + + """ There is a "verb" route in the router + """ + + if verb.upper() not in acls: + continue + + routes[''][verb.upper()] = [] + routes[''][verb.upper()] = acls[verb.upper()].copy() + + routes['']['SUBROUTES'] = [] + if hasattr(m_router, '__path__'): + """ Module is a package + """ + m_path = getattr(m_router, '__path__') + if isinstance(m_path, list) and len(m_path) == 1: + routes['']['SUBROUTES'] = [ + elt.name + for elt in os.scandir(m_path[0]) + if elt.is_dir() + ] + else: + routes = getattr(m_router, 'ROUTES') + + return routes diff --git a/halfapi/lib/routes.py b/halfapi/lib/routes.py index 7faa342..f3f61d7 100644 --- a/halfapi/lib/routes.py +++ b/halfapi/lib/routes.py @@ -16,7 +16,7 @@ Exception : from datetime import datetime from functools import partial, wraps import logging -from typing import Callable, List, Dict, Generator +from typing import Callable, List, Dict, Generator, Tuple from types import ModuleType, FunctionType from starlette.exceptions import HTTPException @@ -105,22 +105,19 @@ def gen_starlette_routes(d_domains: Dict[str, ModuleType]) -> Generator: """ for domain_name, m_domain in d_domains.items(): - for path, d_route in gen_router_routes(m_domain, [domain_name]): - for verb in VERBS: - if verb not in d_route.keys(): - continue - - yield ( - Route(path, - route_acl_decorator( - d_route[verb]['fct'], - d_route[verb]['params'] - ), - methods=[verb]) - ) + for path, verb, fct, params in gen_router_routes(m_domain, []): + yield ( + Route(f'/{domain_name}/{path}', + route_acl_decorator( + fct, + params + ), + methods=[verb]) + ) -def api_routes(m_dom: ModuleType) -> Generator: + +def api_routes(m_dom: ModuleType) -> Tuple[Dict, Dict]: """ Yields the description objects for HalfAPI app routes @@ -128,7 +125,7 @@ def api_routes(m_dom: ModuleType) -> Generator: m_dom (ModuleType): the halfapi module Returns: - Generator(Dict) + (Dict, Dict) """ d_acls = {} @@ -137,6 +134,7 @@ def api_routes(m_dom: ModuleType) -> Generator: l_params = [] for param in params: + if 'acl' not in param.keys() or not param['acl']: continue @@ -149,13 +147,10 @@ def api_routes(m_dom: ModuleType) -> Generator: return l_params d_res = {} - for path, d_route in gen_router_routes(m_dom, [m_dom.__name__]): - d_res[path] = {'fqtn': d_route['fqtn'] } - - for verb in VERBS: - if verb not in d_route.keys(): - continue - d_res[path][verb] = str_acl(d_route[verb]['params']) + for path, verb, fct, params in gen_router_routes(m_dom, []): + if path not in d_res: + d_res[path] = {} + d_res[path][verb] = str_acl(params) return d_res, d_acls @@ -196,7 +191,7 @@ def debug_routes(): yield Route('/halfapi/log', debug_log) async def error_code(request: Request, *args, **kwargs): - code = request.path_params.get('code') + code = request.path_params['code'] raise HTTPException(code) yield Route('/halfapi/error/{code:int}', error_code) diff --git a/halfapi/lib/schemas.py b/halfapi/lib/schemas.py index b1f69a8..7d95ed5 100644 --- a/halfapi/lib/schemas.py +++ b/halfapi/lib/schemas.py @@ -10,13 +10,16 @@ Constant : SCHEMAS (starlette.schemas.SchemaGenerator) """ +import logging from typing import Dict from starlette.schemas import SchemaGenerator +from starlette.exceptions import HTTPException from .routes import gen_starlette_routes, api_acls from .responses import ORJSONResponse +logger = logging.getLogger('uvicorn.asgi') SCHEMAS = SchemaGenerator( {"openapi": "3.0.0", "info": {"title": "HalfAPI", "version": "1.0"}} ) @@ -28,6 +31,20 @@ async def get_api_routes(request, *args, **kwargs): """ return ORJSONResponse(request.scope['api']) +def get_api_domain_routes(domain): + async def wrapped(request, *args, **kwargs): + """ + description: Returns the current API routes description (HalfAPI 0.2.1) + as a JSON object + """ + if domain in request.scope['api']: + return ORJSONResponse(request.scope['api'][domain]) + else: + raise HTTPException(404) + + return wrapped + + async def schema_json(request, *args, **kwargs): """ diff --git a/tests/conftest.py b/tests/conftest.py index 0429703..c75a14c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ from uuid import uuid1, uuid4, UUID import click from click.testing import CliRunner import jwt +import sys from unittest.mock import patch import pytest from starlette.applications import Starlette @@ -212,8 +213,8 @@ def dummy_app(): backend=JWTAuthenticationBackend(secret_key='dummysecret') ) return app -@pytest.fixture +@pytest.fixture def dummy_debug_app(): app = Starlette(debug=True) app.add_route('/', @@ -228,3 +229,75 @@ def dummy_debug_app(): @pytest.fixture def test_client(dummy_app): return TestClient(dummy_app) + +@pytest.fixture +def create_route(): + def wrapped(domain_path, method, path): + stack = [domain_path, *path.split('/')[1:]] + for i in range(len(stack)): + if len(stack[i]) == 0: + continue + + path = os.path.join(*stack[0:i+1]) + if os.path.isdir(os.path.join(path)): + continue + os.mkdir(path) + init_path = os.path.join(*stack, '__init__.py') + with open(init_path, 'a+') as f: + f.write(f'\ndef {method}():\n raise NotImplementedError') + + return wrapped + + + +@pytest.fixture +def dummy_project(): + sys.path.insert(0, './tests') + halfapi_config = tempfile.mktemp() + halfapi_secret = tempfile.mktemp() + domain = 'dummy_domain' + + with open(halfapi_config, 'w') as f: + f.writelines([ + '[project]\n', + 'name = lirmm_api\n', + 'halfapi_version = 0.5.0\n', + f'secret = {halfapi_secret}\n', + 'port = 3050\n', + 'loglevel = debug\n', + '[domains]\n', + f'{domain}= .routers' + ]) + + with open(halfapi_secret, 'w') as f: + f.write('turlututu') + + return (halfapi_config, 'dummy_domain', 'routers') + +@pytest.fixture +def routers(): + sys.path.insert(0, './tests') + + from dummy_domain import routers + return routers + + +@pytest.fixture +def application_debug(): + from halfapi.app import HalfAPI + return HalfAPI({ + 'SECRET':'turlututu', + 'PRODUCTION':False + }).application + + +@pytest.fixture +def application_domain(routers): + from halfapi.app import HalfAPI + return HalfAPI({ + 'SECRET':'turlututu', + 'PRODUCTION':True, + 'DOMAINS':{'dummy_domain':routers} + }).application + + diff --git a/tests/dummy_domain/routers/act/__init__.py b/tests/dummy_domain/act/__init__.py similarity index 100% rename from tests/dummy_domain/routers/act/__init__.py rename to tests/dummy_domain/act/__init__.py diff --git a/tests/dummy_domain/routers/act/personne/__init__.py b/tests/dummy_domain/act/personne/__init__.py similarity index 100% rename from tests/dummy_domain/routers/act/personne/__init__.py rename to tests/dummy_domain/act/personne/__init__.py diff --git a/tests/dummy_domain/routers/act/personne/eo.py b/tests/dummy_domain/act/personne/eo.py similarity index 100% rename from tests/dummy_domain/routers/act/personne/eo.py rename to tests/dummy_domain/act/personne/eo.py diff --git a/tests/dummy_domain/routers/__init__.py b/tests/dummy_domain/routers/__init__.py index c90a1c1..e69de29 100644 --- a/tests/dummy_domain/routers/__init__.py +++ b/tests/dummy_domain/routers/__init__.py @@ -1,5 +0,0 @@ -ROUTES={ - '': { - 'SUBROUTES': ['abc','act'] - } -} diff --git a/tests/dummy_domain/routers/abc/__init__.py b/tests/dummy_domain/routers/abc/__init__.py index 82dc85e..e69de29 100644 --- a/tests/dummy_domain/routers/abc/__init__.py +++ b/tests/dummy_domain/routers/abc/__init__.py @@ -1,5 +0,0 @@ -ROUTES={ - '': { - 'SUBROUTES': ['alphabet', 'pinnochio'] - } -} diff --git a/tests/dummy_domain/routers/abc/alphabet/TEST_uuid/__init__.py b/tests/dummy_domain/routers/abc/alphabet/TEST_uuid/__init__.py new file mode 100644 index 0000000..64c08da --- /dev/null +++ b/tests/dummy_domain/routers/abc/alphabet/TEST_uuid/__init__.py @@ -0,0 +1,19 @@ +from halfapi.lib import acl +ACLS = { + 'GET': [{'acl':acl.public}], + 'POST': [{'acl':acl.public}], + 'PATCH': [{'acl':acl.public}], + 'PUT': [{'acl':acl.public}] +} + +def get(test): + return str(test) + +def post(test): + return str(test) + +def patch(test): + return str(test) + +def put(test): + return str(test) diff --git a/tests/dummy_domain/routers/abc/alphabet/__init__.py b/tests/dummy_domain/routers/abc/alphabet/__init__.py index 24a3c64..4c884ae 100644 --- a/tests/dummy_domain/routers/abc/alphabet/__init__.py +++ b/tests/dummy_domain/routers/abc/alphabet/__init__.py @@ -1,17 +1,8 @@ from starlette.responses import PlainTextResponse -from dummy_domain import acl - -ROUTES={ - '': { - 'GET': [{'acl':acl.public}] - }, - '{test:uuid}': { - 'GET': [{'acl':None}], - 'POST': [{'acl':None}], - 'PATCH': [{'acl':None}], - 'PUT': [{'acl':None}] - } +from halfapi.lib import acl +ACLS = { + 'GET': [{'acl':acl.public}] } async def get(request, *args, **kwargs): diff --git a/tests/dummy_domain/routers/abc/pinnochio/__init__.py b/tests/dummy_domain/routers/abc/pinnochio/__init__.py index 5925edf..151db9b 100644 --- a/tests/dummy_domain/routers/abc/pinnochio/__init__.py +++ b/tests/dummy_domain/routers/abc/pinnochio/__init__.py @@ -1,2 +1,6 @@ -ROUTES={ +from halfapi.lib import acl +ACLS = { + 'GET' : [{'acl':acl.public}] } +def get(): + raise NotImplementedError diff --git a/tests/test_debug_routes.py b/tests/test_debug_routes.py index 4369bad..0870717 100644 --- a/tests/test_debug_routes.py +++ b/tests/test_debug_routes.py @@ -2,31 +2,21 @@ import pytest from starlette.authentication import UnauthenticatedUser from starlette.testclient import TestClient -from halfapi.app import application import json -def test_get_api_routes(): - c = TestClient(application) - r = c.get('/') - d_r = r.json() - assert isinstance(d_r, dict) - -def test_current_user(project_runner): - """ - Missing HALFAPI_SECRET to give current user route - """ - c = TestClient(application) +def test_current_user(project_runner, application_debug): + c = TestClient(application_debug) r = c.get('/halfapi/current_user') assert r.status_code == 200 -def test_log(): - c = TestClient(application) +def test_log(application_debug): + c = TestClient(application_debug) r = c.get('/halfapi/log') assert r.status_code == 200 -def test_error(): - c = TestClient(application) +def test_error(application_debug): + c = TestClient(application_debug) r = c.get('/halfapi/error/400') assert r.status_code == 400 r = c.get('/halfapi/error/404') @@ -34,8 +24,8 @@ def test_error(): r = c.get('/halfapi/error/500') assert r.status_code == 500 -def test_exception(): - c = TestClient(application) +def test_exception(application_debug): + c = TestClient(application_debug) try: r = c.get('/halfapi/exception') assert r.status_code == 500 diff --git a/tests/test_dummy_project_router.py b/tests/test_dummy_project_router.py new file mode 100644 index 0000000..ac77729 --- /dev/null +++ b/tests/test_dummy_project_router.py @@ -0,0 +1,41 @@ +import os +import sys +import importlib +import subprocess +import time +import pytest +from starlette.routing import Route +from starlette.testclient import TestClient + +from halfapi.lib.domain import gen_router_routes + + +def test_get_route(dummy_project, application_domain, routers): + c = TestClient(application_domain) + path = verb = params = None + for path, verb, _, params in gen_router_routes(routers, []): + if len(params): + route_path = '/dummy_domain/{}'.format(path) + try: + if verb.lower() == 'get': + r = c.get(route_path) + elif verb.lower() == 'post': + r = c.post(route_path) + elif verb.lower() == 'patch': + r = c.patch(route_path) + elif verb.lower() == 'put': + r = c.put(route_path) + elif verb.lower() == 'delete': + r = c.delete(route_path) + else: + raise Exception(verb) + try: + assert r.status_code in [200, 501] + except AssertionError as exc: + print('{} [{}] {}'.format(str(r.status_code), verb, route_path)) + + except NotImplementedError: + pass + + if not path: + raise Exception('No route generated') diff --git a/tests/test_lib_domain.py b/tests/test_lib_domain.py index 2327deb..441ba0d 100644 --- a/tests/test_lib_domain.py +++ b/tests/test_lib_domain.py @@ -1,18 +1,36 @@ #!/usr/bin/env python3 import importlib -from halfapi.lib.domain import VERBS, gen_router_routes +from halfapi.lib.domain import VERBS, gen_routes, gen_router_routes, MissingAclError + +from types import FunctionType def test_gen_router_routes(): from .dummy_domain import routers - for path, d_route in gen_router_routes(routers, ['dummy_domain']): + for path, verb, fct, params in gen_router_routes(routers, ['dummy_domain']): assert isinstance(path, str) - for verb in VERBS: - if verb not in d_route.keys(): - continue - route = d_route[verb] - print(f'[{verb}] {path} {route["fct"]}') - assert len(route['params']) > 0 - assert hasattr(route['fct'], '__call__') - if 'fqtn' in route: - assert isinstance(route['fqtn'], str) + assert verb in VERBS + assert len(params) > 0 + assert hasattr(fct, '__call__') + +def test_gen_routes(): + from .dummy_domain.routers.abc.alphabet import TEST_uuid + try: + gen_routes( + TEST_uuid, + 'get', + ['abc', 'alphabet', 'TEST_uuid', ''], + []) + except MissingAclError: + assert True + + fct, params = gen_routes( + TEST_uuid, + 'get', + ['abc', 'alphabet', 'TEST_uuid', ''], + TEST_uuid.ACLS['GET']) + + assert isinstance(fct, FunctionType) + assert isinstance(params, list) + assert len(TEST_uuid.ACLS['GET']) == len(params) + diff --git a/tests/test_lib_router.py b/tests/test_lib_router.py new file mode 100644 index 0000000..d167bcf --- /dev/null +++ b/tests/test_lib_router.py @@ -0,0 +1,44 @@ +import os +from halfapi.lib.router import read_router + +def test_read_router_routers(): + from .dummy_domain import routers + + router_d = read_router(routers) + assert '' in router_d + assert 'SUBROUTES' in router_d[''] + assert isinstance(router_d['']['SUBROUTES'], list) + + for elt in os.scandir(routers.__path__[0]): + if elt.is_dir(): + assert elt.name in router_d['']['SUBROUTES'] + +def test_read_router_abc(): + from .dummy_domain.routers import abc + router_d = read_router(abc) + + assert '' in router_d + assert 'SUBROUTES' in router_d[''] + assert isinstance(router_d['']['SUBROUTES'], list) + +def test_read_router_alphabet(): + from .dummy_domain.routers.abc import alphabet + router_d = read_router(alphabet) + + assert '' in router_d + assert 'SUBROUTES' in router_d[''] + assert isinstance(router_d['']['SUBROUTES'], list) + +def test_read_router_TEST(): + from .dummy_domain.routers.abc.alphabet import TEST_uuid + router_d = read_router(TEST_uuid) + + print(router_d) + assert '' in router_d + assert 'SUBROUTES' in router_d[''] + assert isinstance(router_d['']['GET'], list) + assert isinstance(router_d['']['POST'], list) + assert isinstance(router_d['']['PATCH'], list) + assert isinstance(router_d['']['PUT'], list) + + diff --git a/tests/test_lib_schemas.py b/tests/test_lib_schemas.py index b3f9d8a..42298ff 100644 --- a/tests/test_lib_schemas.py +++ b/tests/test_lib_schemas.py @@ -5,7 +5,6 @@ from starlette.authentication import ( AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials, UnauthenticatedUser) -from halfapi.app import application from halfapi.lib.schemas import schema_dict_dom def test_schemas_dict_dom(): @@ -15,8 +14,23 @@ def test_schemas_dict_dom(): assert isinstance(schema, dict) -def test_get_api_routes(project_runner): - c = TestClient(application) +def test_get_api_routes(project_runner, application_debug): + c = TestClient(application_debug) r = c.get('/') d_r = r.json() assert isinstance(d_r, dict) + + +def test_get_api_dummy_domain_routes(application_domain, routers): + c = TestClient(application_domain) + r = c.get('/dummy_domain') + assert r.status_code == 200 + d_r = r.json() + assert isinstance(d_r, dict) + print(d_r) + assert 'abc/alphabet' in d_r + assert 'GET' in d_r['abc/alphabet'] + assert len(d_r['abc/alphabet']['GET']) > 0 + assert 'acl' in d_r['abc/alphabet']['GET'][0] + +