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