commit 110b8b0969453e4fc6a20359078c5f64ad122b29 Author: Maxime Alves LIRMM Date: Tue Jun 30 17:50:35 2020 +0200 commit initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4367af --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# halfAPI + +This Python-based ASGI application aims to provide the core functionality to +multiple API domains. + +It's developed at the [LIRMM](https://lirmm.fr) in Montpellier, France. + +The name "halfAPI" comes from the deep relationship between it and +[halfORM](https://gite.lirmm.fr/newsi/halfORM), a project authored by +[Joël Maizi](https://gite.lirmm.fr/maizi). diff --git a/halfapi/__init__.py b/halfapi/__init__.py new file mode 100644 index 0000000..c57bfd5 --- /dev/null +++ b/halfapi/__init__.py @@ -0,0 +1 @@ +__version__ = '0.0.0' diff --git a/halfapi/__pycache__/__init__.cpython-38.pyc b/halfapi/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..f26421b Binary files /dev/null and b/halfapi/__pycache__/__init__.cpython-38.pyc differ diff --git a/halfapi/__pycache__/acl.cpython-38.pyc b/halfapi/__pycache__/acl.cpython-38.pyc new file mode 100644 index 0000000..8f7ba11 Binary files /dev/null and b/halfapi/__pycache__/acl.cpython-38.pyc differ diff --git a/halfapi/__pycache__/app.cpython-38.pyc b/halfapi/__pycache__/app.cpython-38.pyc new file mode 100644 index 0000000..fdd6c6a Binary files /dev/null and b/halfapi/__pycache__/app.cpython-38.pyc differ diff --git a/halfapi/__pycache__/cli.cpython-38.pyc b/halfapi/__pycache__/cli.cpython-38.pyc new file mode 100644 index 0000000..b4b73f9 Binary files /dev/null and b/halfapi/__pycache__/cli.cpython-38.pyc differ diff --git a/halfapi/acl.py b/halfapi/acl.py new file mode 100644 index 0000000..380d77f --- /dev/null +++ b/halfapi/acl.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +class BaseACL: + """ Base ACL class that contains generic methods for domains ACL + """ + + def public(self, *args) -> bool: + "Unlimited access" + return True diff --git a/halfapi/app.py b/halfapi/app.py new file mode 100644 index 0000000..1d6920a --- /dev/null +++ b/halfapi/app.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# builtins +import importlib +import sys + +# asgi framework +from starlette.applications import Starlette +from starlette.exceptions import HTTPException +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route, Match, Mount +from starlette.types import ASGIApp, Receive, Scope, Send + +# typing +from typing import Any, Awaitable, Callable, MutableMapping +RequestResponseEndpoint = Callable[ [Request], Awaitable[Response] ] + +# hop-generated classes +from apidb.api.acl_function import AclFunction +from apidb.api.domain import Domain +from apidb.api.route import Route +from apidb.api.view.acl import Acl as AclView +from apidb.api.view.route import Route as RouteView + +# module libraries +from .lib.responses import ForbiddenResponse, NotFoundResponse + +def match_route(app: ASGIApp, scope: Scope): + """ Checks all routes from "app" and checks if it matches with the one from + scope + + Parameters: + + - app (ASGIApp): The Starlette object + - scope (MutableMapping[str, Any]): The requests scope + + Returns: + + - (dict, dict): The first dict of the tuple is the details on the + route, the second one is the path parameters + + Raises: + + HTTPException + """ + + """ The *result* variable is fitted to the filter that will be applied when + searching the route in the database. + Refer to the database documentation for more details on the api.route + table. + """ + result = { + 'domain': None, + 'name': None, + 'http_verb': None, + 'version': None + } + + try: + """ Identification of the parts of the path + + Examples : + version : v4 + domain : organigramme + path : laboratoire/personnel + """ + _, result['version'], result['domain'], path = scope['path'].split('/', 3) + except ValueError as e: + #404 Not found + raise HTTPException(404) + + # Prefix the path with "/" + path = f'/{path}' + + for route in app.routes: + # Parse all routes + match = route.matches(scope) + if match[0] != Match.FULL: + continue + + if type(route) != Mount: + """ The root app should not have exposed routes, + only the mounted domains have some. + """ + continue + + """ Clone the scope to assign the path to the path without the + matching domain. + """ + subscope = scope.copy() + subscope['path'] = path + + for mount_route in route.routes: + # Parse all domain routes + submatch = mount_route.matches(subscope) + if submatch[0] != Match.FULL: + continue + + # Route matches + result['name'] = submatch[1]['endpoint'].__name__ + result['http_verb'] = scope['method'] + + return result, submatch[1]['path_params'] + + raise HTTPException(404) + + +class AclCallerMiddleware(BaseHTTPMiddleware): + async def __call__(self, scope:Scope, receive: Receive, send: Send) -> None: + """ Points out to the domain which ACL function it should call + + Parameters : + + - request (Request): The current request + + - call_next (RequestResponseEndpoint): The next middleware/route function + + Return: + Response + """ + print('Hit AclCallerMiddleware of API') + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + + if scope['path'].split('/')[-1] not in ['docs','openapi.json','redoc']: + # routes in the the database, the others being + # docs/openapi.json/redoc + try: + d_match, path_params = match_route(app, scope) + except HTTPException: + return NotFoundResponse() + + d_match, path_params = match_route(app, scope) + + try: + scope['acls'] = [] + """ + for acl in AclView(**d_match).select(): + if ('acl_function_name' not in acl.keys() + or 'domain' not in acl.keys()): + continue + + scope['acls'].append(acl['acl_function_name']) + acl_module = importlib.import_module( + '.acl', + 'organigramme' + ) + + try: + acl_functions.append( + getattr(acl_module.acl, acl_function_name)) + except AttributeError: + + + if True: #function(AUTH, path_params): + response = await call_next(request) + break + """ + except StopIteration: + # TODO : No ACL sur une route existante, prevenir l'admin? + print("No ACL") + pass + + return await self.app(scope, receive, send) + + +def mount_domains(app: Starlette, domains: list): + """ Procedure to mount the registered domains on their prefixes + + Parameters: + + - app (FastAPI): The FastAPI object + - domains (list): The domains to mount, retrieved from the database + with their attributes "version", "name" + + Returns: Nothing + """ + + for domain in domains: + if 'name' not in domain.keys() or 'version' not in domain.keys(): + continue + + # Retrieve domain app according to domain details + try: + domain_app = importlib.import_module( + f'{domain["name"]}.app').app + except ModuleNotFoundError: + sys.stderr.write( + f'Could not find module *{domain["name"]}* in sys.path\n') + continue + except ImportError: + sys.stderr.write(f'Could not import *app* from *{domain}*') + continue + + # Alter the openapi_url so the /docs page doesn't try to get + # /{name}/openapi.json (@TODO : retport the bug to FastAPI) + # domain_app.openapi_url = '/../api/{version}/{name}/openapi.json'.format(**domain) + + # Mount the domain app on the prefix + # e.g. : /v4/organigramme + app.mount('/{version}/{name}'.format(**domain), domain_app) + + +def startup(): + # Mount the registered domains + try: + domains_list = [elt for elt in Domain().select()] + mount_domains(app, domains_list) + except Exception as e: + sys.stderr.write('Error in the *domains* retrieval') + sys.stderr.write(str(e)) + sys.exit(1) + +app = Starlette( + middleware=[Middleware(AclCallerMiddleware)], + on_startup=[startup] +) diff --git a/halfapi/cli.py b/halfapi/cli.py new file mode 100755 index 0000000..b33bda5 --- /dev/null +++ b/halfapi/cli.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +import click +import uvicorn +import os +import sys + +CONTEXT_SETTINGS={ + 'default_map':{'run': {'port': 8000}} +} + +@click.group(invoke_without_command=True, context_settings=CONTEXT_SETTINGS) +@click.pass_context +def cli(ctx): + if ctx.invoked_subcommand is None: + return run() + +@click.option('--host', default='127.0.0.1') +@click.option('--port', default='8000') +@click.option('--debug', default=False) +@click.option('--dev', default=True) +@cli.command() +def run(host, port, debug, dev): + if dev: + debug = True + reload = True + log_level = 'debug' + else: + reload = False + log_level = 'info' + + click.echo('Launching application with default parameters') + click.echo(f'''Parameters : \n + Host : {host} + Port : {port} + Debug : {debug} + Dev : {dev}''') + + sys.path.insert(0, os.getcwd()) + click.echo(sys.path) + uvicorn.run('halfapi.app:app', + host=host, + port=int(port), + log_level=log_level, + reload=reload) + +if __name__ == '__main__': + cli() + diff --git a/halfapi/lib/__pycache__/query.cpython-38.pyc b/halfapi/lib/__pycache__/query.cpython-38.pyc new file mode 100644 index 0000000..16dcebd Binary files /dev/null and b/halfapi/lib/__pycache__/query.cpython-38.pyc differ diff --git a/halfapi/lib/__pycache__/responses.cpython-38.pyc b/halfapi/lib/__pycache__/responses.cpython-38.pyc new file mode 100644 index 0000000..4a87cbc Binary files /dev/null and b/halfapi/lib/__pycache__/responses.cpython-38.pyc differ diff --git a/halfapi/lib/query.py b/halfapi/lib/query.py new file mode 100644 index 0000000..9150aee --- /dev/null +++ b/halfapi/lib/query.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +from starlette.exceptions import HTTPException +from .responses import CSVResponse + +""" +This is the *query* library that contains all the useful functions to treat our +queries +""" + +def parse_query(q: str = ""): + """ + Returns the fitting Response object according to query parameters. + + The parse_query function handles the following arguments in the query + string : format, limit, and offset + It returns a callable function that returns the desired Response object. + + Parameters: + q (str): The query string "q" parameter, in the format + key0:value0|...|keyN:valueN + + Returns: + Callable[[half_orm.model.Model], Response] + + Available query arguments: + format: + - csv + - json + limit: int > 0 + offset: int > 0 + + + Examples: + + >>> parse_query() + .select at 0x...> + + >>> parse_query('format:csv') + .select at 0x...> + + >>> parse_query('format:json') + .select at 0x...> + + >>> parse_query('format:csv|limit:10') + .select at 0x...> + + >>> parse_query('format:csv|offset:10') + .select at 0x...> + + >>> parse_query('format:csv|limit:10|offset:10') + .select at 0x...> + + >>> parse_query('limit:10') + .select at 0x...> + + >>> parse_query('limit=10') + Traceback (most recent call last): + ... + fastapi.exceptions.HTTPException: 400 + + + """ + + params = {} + if len(q) > 0: + try: + split_ = lambda x : x.split(':') + params = dict(map(split_, q.split('|'))) + except ValueError: + raise HTTPException(400) + split_ = lambda x : x.split(':') + params = dict(map(split_, q.split('|'))) + + def select(obj): + + if 'limit' in params and int(params['limit']) > 0: + obj.limit(int(params['limit'])) + + if 'offset' in params and int(params['offset']) > 0: + obj.offset(int(params['offset'])) + + if 'format' in params and params['format'] == 'csv': + return CSVResponse([elt for elt in obj.select()]) + + return [elt for elt in obj.select()] + + return select diff --git a/halfapi/lib/responses.py b/halfapi/lib/responses.py new file mode 100644 index 0000000..0f298fa --- /dev/null +++ b/halfapi/lib/responses.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# builtins +import csv +from datetime import date +from io import TextIOBase, StringIO + +# asgi framework +from starlette.responses import Response + +class NotFoundResponse(Response): + """ The 404 Not Found default Response + """ + def __init__(self): + super().__init__(status_code=404) + +class ForbiddenResponse(Response): + """ The 401 Not Found default Response + """ + def __init__(self): + super().__init__(status_code = 401) + +class CSVResponse(Response): + def __init__(self, obj): + + with StringIO() as csv_file: + csv_obj = csv.writer(csv_file, dialect="excel") + csv_obj.writerows([elt.values() for elt in obj]) + filename = f'Personnels_LIRMM-{date.today()}.csv' + + super().__init__( + content=csv_file.getvalue(), + headers={ + 'Content-Type': 'text/csv; charset=UTF-8', + 'Content-Disposition': f'attachment; filename="{filename}"'}, + status_code = 200) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..69a309f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +starlette +uvicorn +apidb @ git+ssh://git@gite.lirmm.fr/newsi/db/hop_api.git diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..07d6a38 --- /dev/null +++ b/setup.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import re + +from setuptools import setup, find_packages + + +def get_version(package): + """ + Return package version as listed in `__version__` in `init.py`. + """ + with open(os.path.join(package, "__init__.py")) as f: + return re.search("__version__ = ['\"]([^'\"]+)['\"]", f.read()).group(1) + + +def get_long_description(): + """ + Return the README. + """ + with open("README.md", encoding="utf8") as f: + return f.read() + + +def get_packages(package): + """ + Return root package and all sub-packages. + """ + return [ + dirpath + for dirpath, dirnames, filenames in os.walk(package) + if os.path.exists(os.path.join(dirpath, "__init__.py")) + ] + +module_name="halfapi" +setup( + name=module_name, + python_requires=">=3.7", + version=get_version(module_name), + url="https://gite.lirmm.fr/newsi/api/halfapi", + long_description=get_long_description(), + long_description_content_type="text/markdown", + packages=get_packages(module_name), + package_data={ + 'halfapi': ['lib/*'] + }, + install_requires=[ + "apidb", + "click", + "starlette", + "uvicorn"], + extras_require={ + "tests":["pytest", "requests"] + }, + entry_points={ + "console_scripts":[ + "halfapi=halfapi.cli:cli" + ] + } +)