commit initial

This commit is contained in:
Maxime Alves LIRMM 2020-06-30 17:50:35 +02:00
commit 110b8b0969
15 changed files with 474 additions and 0 deletions

10
README.md Normal file
View File

@ -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).

1
halfapi/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__ = '0.0.0'

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

8
halfapi/acl.py Normal file
View File

@ -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

221
halfapi/app.py Normal file
View File

@ -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]
)

48
halfapi/cli.py Executable file
View File

@ -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()

Binary file not shown.

Binary file not shown.

87
halfapi/lib/query.py Normal file
View File

@ -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()
<function parse_query.<locals>.select at 0x...>
>>> parse_query('format:csv')
<function parse_query.<locals>.select at 0x...>
>>> parse_query('format:json')
<function parse_query.<locals>.select at 0x...>
>>> parse_query('format:csv|limit:10')
<function parse_query.<locals>.select at 0x...>
>>> parse_query('format:csv|offset:10')
<function parse_query.<locals>.select at 0x...>
>>> parse_query('format:csv|limit:10|offset:10')
<function parse_query.<locals>.select at 0x...>
>>> parse_query('limit:10')
<function parse_query.<locals>.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

35
halfapi/lib/responses.py Normal file
View File

@ -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)

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
starlette
uvicorn
apidb @ git+ssh://git@gite.lirmm.fr/newsi/db/hop_api.git

61
setup.py Executable file
View File

@ -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"
]
}
)