commit initial
This commit is contained in:
commit
110b8b0969
|
@ -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).
|
|
@ -0,0 +1 @@
|
||||||
|
__version__ = '0.0.0'
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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
|
|
@ -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]
|
||||||
|
)
|
|
@ -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.
|
@ -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
|
|
@ -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)
|
|
@ -0,0 +1,3 @@
|
||||||
|
starlette
|
||||||
|
uvicorn
|
||||||
|
apidb @ git+ssh://git@gite.lirmm.fr/newsi/db/hop_api.git
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
Loading…
Reference in New Issue