Compare commits
56 Commits
refactor_s
...
master
Author | SHA1 | Date |
---|---|---|
maxime | c808ba21ab | |
maxime | 9f908d3cee | |
maxime | 7b6d9e994a | |
maxime | 8f6330bca7 | |
maxime | 8506aa5322 | |
maxime | b683e80959 | |
maxime | b412d249a1 | |
Maxime Alves LIRMM | f4f9a0fc66 | |
Maxime Alves LIRMM | c855cce013 | |
Maxime Alves LIRMM | e083c4386e | |
Maxime Alves LIRMM | 476ae29792 | |
Maxime Alves LIRMM | 673097adeb | |
Maxime Alves LIRMM | 1cc1bbd5ef | |
Maxime Alves LIRMM | 135d6e86e4 | |
Maxime Alves LIRMM | 0fcf433ec6 | |
Maxime Alves LIRMM | 45cf32de2b | |
Maxime Alves LIRMM | 1b713c3816 | |
Maxime Alves LIRMM | 59889e1e31 | |
Maxime Alves LIRMM | 28a1a69435 | |
maxime | 65ecf9817c | |
maxime | 3b7d3bda5c | |
maxime | e19f27f306 | |
maxime | e9c84c9f7c | |
maxime | b1595beb14 | |
maxime | 60ff99d0fb | |
maxime | 9657f0f9ec | |
maxime | f646b4d663 | |
maxime | 0817882558 | |
maxime | 896ce58731 | |
maxime | 87856cfb42 | |
maxime | 4856f80b99 | |
maxime | eac602f0a5 | |
maxime | 14e051bd91 | |
maxime | 20563081f5 | |
maxime | 7949b3206c | |
maxime | c4583b7187 | |
maxime | 2413436104 | |
Maxime Alves LIRMM | 54cc6c17c9 | |
Maxime Alves LIRMM | ff3a39c740 | |
Maxime Alves LIRMM@home | 8d254bafa0 | |
Maxime Alves LIRMM | 0a385661b9 | |
Maxime Alves LIRMM | e065fe04e4 | |
Maxime Alves LIRMM | b7c5704c95 | |
Maxime Alves LIRMM | dd83a337e9 | |
Maxime Alves LIRMM | f6d08e8309 | |
Maxime Alves LIRMM | 262de901a8 | |
Maxime Alves LIRMM | e5c25ede1f | |
Maxime Alves LIRMM | b4c37ea999 | |
Maxime Alves LIRMM | 5a7e51ae94 | |
Maxime Alves LIRMM | 69129fd7af | |
Maxime Alves LIRMM | a3fc6dc830 | |
Maxime Alves LIRMM@home | 064127dc16 | |
Maxime Alves LIRMM@home | c2eb95533c | |
Maxime Alves LIRMM | bbb027cd0d | |
Maxime Alves LIRMM | c9ecc1f8d2 | |
Maxime Alves LIRMM | d1a8351997 |
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
# Official language image. Look for the different tagged releases at:
|
# Official language image. Look for the different tagged releases at:
|
||||||
# https://hub.docker.com/r/library/python/tags/
|
# https://hub.docker.com/r/library/python/tags/
|
||||||
image: python:3.9-bullseye
|
image: python:alpine3.18
|
||||||
|
|
||||||
# Change pip's cache directory to be inside the project directory since we can
|
# Change pip's cache directory to be inside the project directory since we can
|
||||||
# only cache local items.
|
# only cache local items.
|
||||||
|
@ -33,32 +33,36 @@ stages:
|
||||||
|
|
||||||
.before_script_template: &test
|
.before_script_template: &test
|
||||||
before_script:
|
before_script:
|
||||||
- apt-get update && apt-get -y install python3-venv
|
|
||||||
- python3 -V # Print out python version for debugging
|
- python3 -V # Print out python version for debugging
|
||||||
- pip3 install pipenv
|
- python3 -m venv /tmp/venv
|
||||||
- pipenv install --dev --skip-lock
|
- /tmp/venv/bin/pip3 install .["tests","pyexcel"]
|
||||||
|
- /tmp/venv/bin/pip3 install coverage pytest
|
||||||
|
|
||||||
test:
|
test:
|
||||||
image: python:${PYVERSION}-bullseye
|
image: python:alpine${ALPINEVERSION}
|
||||||
stage: test
|
stage: test
|
||||||
<<: *test
|
<<: *test
|
||||||
parallel:
|
parallel:
|
||||||
matrix:
|
matrix:
|
||||||
- PYVERSION: ["3.7", "3.8", "3.9", "3.10"]
|
- ALPINEVERSION: ["3.16", "3.17", "3.18", "3.19"]
|
||||||
script:
|
script:
|
||||||
- pipenv run pytest --version
|
- /tmp/venv/bin/pytest --version
|
||||||
- PYTHONPATH=./tests/ pipenv run pytest -v ./tests
|
- PYTHONPATH=./tests/ /tmp/venv/bin/coverage run --source halfapi -m pytest
|
||||||
- pipenv run halfapi --version
|
- /tmp/venv/bin/coverage xml
|
||||||
|
- /tmp/venv/bin/halfapi --version
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
reports:
|
||||||
|
coverage_report:
|
||||||
|
coverage_format: cobertura
|
||||||
|
path: coverage.xml
|
||||||
|
|
||||||
build_pypi:
|
build_pypi:
|
||||||
stage: build
|
stage: build
|
||||||
script:
|
script:
|
||||||
- apt-get update && apt-get -y install python3-venv
|
|
||||||
- python3 -V # Print out python version for debugging
|
- python3 -V # Print out python version for debugging
|
||||||
- pip3 install pipenv
|
- python3 -m venv /tmp/venv
|
||||||
- pipenv install --dev --skip-lock
|
- /tmp/venv/bin/pip3 install .
|
||||||
- pipenv run python -m build --sdist
|
|
||||||
- pipenv run python -m build --wheel
|
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- dist/*.whl
|
- dist/*.whl
|
||||||
|
|
122
CHANGELOG.md
122
CHANGELOG.md
|
@ -1,5 +1,127 @@
|
||||||
# HalfAPI
|
# HalfAPI
|
||||||
|
|
||||||
|
## 0.6.30
|
||||||
|
|
||||||
|
Dependencies updates
|
||||||
|
|
||||||
|
- pyYAML v6.0.1
|
||||||
|
- starlette v0.37.2
|
||||||
|
|
||||||
|
Warning : the on_startup halfAPI argument is now removed, use the lifeSpan
|
||||||
|
|
||||||
|
## 0.6.29
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Starlette version bumped to 0.33.
|
||||||
|
|
||||||
|
## 0.6.28
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Starlette version bumped to 0.31 (had to disable a test to make it work but
|
||||||
|
seems not important).
|
||||||
|
|
||||||
|
### Development dependencies
|
||||||
|
|
||||||
|
Python 3.7 is no longer supported (openapi_spec_validator is not compatible).
|
||||||
|
|
||||||
|
If you are a developper, you should update dev dependencies in your virtual
|
||||||
|
environment.
|
||||||
|
|
||||||
|
### OpenAPI schemas
|
||||||
|
|
||||||
|
This release improves OpenAPI specification in routes, and gives a default
|
||||||
|
"parameters" field for routes that have path parameters.
|
||||||
|
|
||||||
|
Also, if you use halfAPI for multi-domain setups, you may be annoyed by the
|
||||||
|
change in the return value of the "/" route that differs from "/domain" route.
|
||||||
|
|
||||||
|
An HalfAPI instance should return one and only one OpenAPI Schema, so you can
|
||||||
|
rely on it to connect to other software.
|
||||||
|
|
||||||
|
The version number that is contained under the "info" dictionnary is now the "version"
|
||||||
|
of the Api domain, as specified in the domain dictionnary specified at the root
|
||||||
|
of the Domain.
|
||||||
|
|
||||||
|
The title field of the "info" dictionnary is now the Domain's name.
|
||||||
|
|
||||||
|
The ACLs list is now available under the "info.x-acls" attribute of the schema.
|
||||||
|
It is still accessible from the "/halfapi/acls" route.
|
||||||
|
|
||||||
|
#### Schema Components
|
||||||
|
|
||||||
|
You can now specify a dict in the domain's metadata dict that follows the
|
||||||
|
"components" key of an OpenAPI schema.
|
||||||
|
|
||||||
|
Use it to define models that are used in your API. You can see an exemple of
|
||||||
|
it's use in the "tests/dummy_domain/__init__.py" file.
|
||||||
|
|
||||||
|
|
||||||
|
### ACLs
|
||||||
|
|
||||||
|
The use of an "HEAD" request to check an ACL is now the norm. Please change all
|
||||||
|
the occurrences of your calls on theses routes with the GET method.
|
||||||
|
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
Domain command update :
|
||||||
|
|
||||||
|
The `--conftest` flag is now allowed when running the `domain` command, it dumps the current configuration as a TOML string.
|
||||||
|
|
||||||
|
`halfapi domain --conftest my_domain`
|
||||||
|
|
||||||
|
|
||||||
|
The `--dry-run` flag was buggy and is now fixed when using the `domai ` command with the `--run` flag.
|
||||||
|
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The `port` option in a `domain.my_domain` section in the TOML config file is now prefered to the one in the `project` section.
|
||||||
|
|
||||||
|
The `project` section is used as a default section for the whole configuration file. - Tests still have to be written -
|
||||||
|
|
||||||
|
The standard configuration precedence is fixed, in this order from the hight to the lower :
|
||||||
|
|
||||||
|
- Argument value (i.e. : --log-level)
|
||||||
|
- Environment value (i.e. : HALFAPI_LOGLEVEL)
|
||||||
|
- Configuration value under "domain" key
|
||||||
|
- Configuration value under "project" key
|
||||||
|
- Default configuration value given in the "DEFAULT_CONF" dictionary of halfapi/conf.py
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
Small cleanup of the logs levels. If you don't want the config to be dumped, just set the HALFAPI_LOGLEVEL to something different than "DEBUG".
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Check an ACL based on a decorator on "/halfapi/acls/MY_ACL"
|
||||||
|
|
||||||
|
## 0.6.27
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
- ACLs definition can now include a "public" parameter that defines if there should be an automatic creation of a route to check this acls
|
||||||
|
- /halfapi/acls does not return the "result", it just returns if there is a public route to check the ACL on /halfapi/acls/acl_name
|
||||||
|
=======
|
||||||
|
argument of starlette instead.
|
||||||
|
>>>>>>> a8c59c6 ([release] halfapi 0.6.27)
|
||||||
|
|
||||||
|
## 0.6.26
|
||||||
|
|
||||||
|
- Adds the "base_url", "cookies" and "url" to the "halfapi" argument of route definitions
|
||||||
|
|
||||||
|
## 0.6.25
|
||||||
|
|
||||||
|
- Deletes the "Authorization" cookie on authentication error
|
||||||
|
- Do not raise an exception on signature expiration, use "Nobody" user instead
|
||||||
|
|
||||||
|
## 0.6.24
|
||||||
|
|
||||||
|
- Uses the "Authorization" cookie to read authentication token additionnaly to the "Authorization" header
|
||||||
|
- CLI : allows to run a single domain using the "halfapi domain --run domain_name" command
|
||||||
|
|
||||||
## 0.6.23
|
## 0.6.23
|
||||||
|
|
||||||
Dependency update version
|
Dependency update version
|
||||||
|
|
14
Dockerfile
14
Dockerfile
|
@ -1,9 +1,11 @@
|
||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM docker.io/python:3.10.5-slim-bullseye
|
FROM python:alpine3.19
|
||||||
COPY . /halfapi
|
COPY . /halfapi
|
||||||
WORKDIR /halfapi
|
WORKDIR /halfapi
|
||||||
RUN apt-get update > /dev/null && apt-get -y install git > /dev/null
|
ENV VENV_DIR=/opt/venv
|
||||||
RUN pip install gunicorn uvicorn
|
RUN mkdir -p $VENV_DIR
|
||||||
RUN pip install .
|
RUN python -m venv $VENV_DIR
|
||||||
CMD gunicorn halfapi.app
|
RUN $VENV_DIR/bin/pip install gunicorn uvicorn
|
||||||
|
RUN $VENV_DIR/bin/pip install .
|
||||||
|
RUN ln -s $VENV_DIR/bin/halfapi /usr/local/bin/
|
||||||
|
CMD $VENV_DIR/bin/gunicorn halfapi.app
|
||||||
|
|
7
Pipfile
7
Pipfile
|
@ -14,14 +14,17 @@ pyflakes = "*"
|
||||||
vulture = "*"
|
vulture = "*"
|
||||||
virtualenv = "*"
|
virtualenv = "*"
|
||||||
httpx = "*"
|
httpx = "*"
|
||||||
|
openapi-schema-validator = "*"
|
||||||
|
openapi-spec-validator = "*"
|
||||||
|
coverage = "*"
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
click = ">=8,<9"
|
click = ">=8,<9"
|
||||||
starlette = ">=0.23,<0.24"
|
starlette = ">=0.37,<0.38"
|
||||||
uvicorn = ">=0.13,<1"
|
uvicorn = ">=0.13,<1"
|
||||||
orjson = ">=3.8.5,<4"
|
orjson = ">=3.8.5,<4"
|
||||||
pyjwt = ">=2.6.0,<2.7.0"
|
pyjwt = ">=2.6.0,<2.7.0"
|
||||||
pyyaml = ">=6,<7"
|
pyyaml = ">=6.0.1,<7"
|
||||||
timing-asgi = ">=0.2.1,<1"
|
timing-asgi = ">=0.2.1,<1"
|
||||||
schema = ">=0.7.4,<1"
|
schema = ">=0.7.4,<1"
|
||||||
toml = ">=0.10,<0.11"
|
toml = ">=0.10,<0.11"
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
42
README.md
42
README.md
|
@ -23,17 +23,15 @@ to reference [HalfORM](https://github.com/collorg/halfORM), a project written by
|
||||||
|
|
||||||
Configure HalfAPI in the file : .halfapi/config .
|
Configure HalfAPI in the file : .halfapi/config .
|
||||||
|
|
||||||
It's an **ini** file that contains at least two sections, project and domains.
|
It's a **toml** file that contains at least two sections, project and domains.
|
||||||
|
|
||||||
|
https://toml.io/en/
|
||||||
|
|
||||||
|
|
||||||
### Project
|
### Project
|
||||||
|
|
||||||
The main configuration options without which HalfAPI cannot be run.
|
The main configuration options without which HalfAPI cannot be run.
|
||||||
|
|
||||||
**name** : Project's name
|
|
||||||
|
|
||||||
**halfapi_version** : The HalfAPI version on which you work
|
|
||||||
|
|
||||||
**secret** : The file containing the secret to decode the user's tokens.
|
**secret** : The file containing the secret to decode the user's tokens.
|
||||||
|
|
||||||
**port** : The port for the test server.
|
**port** : The port for the test server.
|
||||||
|
@ -43,12 +41,28 @@ The main configuration options without which HalfAPI cannot be run.
|
||||||
|
|
||||||
### Domains
|
### Domains
|
||||||
|
|
||||||
The name of the options should be the name of the domains' module, the value is the
|
Specify the domains configurations in the following form :
|
||||||
submodule which contains the routers.
|
|
||||||
|
|
||||||
Example :
|
```
|
||||||
|
[domains.DOMAIN_NAME]
|
||||||
|
name = "DOMAIN_NAME"
|
||||||
|
enabled = true
|
||||||
|
prefix = "/prefix"
|
||||||
|
module = "domain_name.path.to.api.root"
|
||||||
|
port = 1002
|
||||||
|
```
|
||||||
|
|
||||||
dummy_domain = .routers
|
Specific configuration can be done under the "config" section :
|
||||||
|
|
||||||
|
```
|
||||||
|
[domains.DOMAIN_NAME.config]
|
||||||
|
boolean_option = false
|
||||||
|
string_value = "String"
|
||||||
|
answer = 42
|
||||||
|
listylist = ["hello", "world"]
|
||||||
|
```
|
||||||
|
|
||||||
|
And can be accessed through the app's "config" dictionnary.
|
||||||
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
@ -62,9 +76,17 @@ Run the project by using the `halfapi run` command.
|
||||||
You can try the dummy_domain with the following command.
|
You can try the dummy_domain with the following command.
|
||||||
|
|
||||||
```
|
```
|
||||||
python -m halfapi routes --export --noheader dummy_domain.routers | python -m halfapi run -
|
PYTHONPATH=$PWD/tests python -m halfapi domain dummy_domain
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CLI documentation
|
||||||
|
|
||||||
|
Use the CLI help.
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m halfapi --help
|
||||||
|
python -m halfapi domain --help
|
||||||
|
```
|
||||||
|
|
||||||
## API Testing
|
## API Testing
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
__version__ = '0.6.23'
|
__version__ = '0.6.30'
|
||||||
|
|
||||||
def version():
|
def version():
|
||||||
return f'HalfAPI version:{__version__}'
|
return f'HalfAPI version:{__version__}'
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
|
import os
|
||||||
from .halfapi import HalfAPI
|
from .halfapi import HalfAPI
|
||||||
from .conf import CONFIG, SCHEMA
|
|
||||||
from .logging import logger
|
from .logging import logger
|
||||||
|
|
||||||
logger.info('CONFIG: %s', CONFIG)
|
def application():
|
||||||
logger.info('SCHEMA: %s', SCHEMA)
|
from .conf import CONFIG
|
||||||
|
return HalfAPI(CONFIG).application
|
||||||
application = HalfAPI(
|
|
||||||
CONFIG, SCHEMA or None).application
|
|
||||||
|
|
|
@ -9,9 +9,11 @@ import importlib
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import toml
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import orjson
|
import orjson
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
from .cli import cli
|
from .cli import cli
|
||||||
|
@ -21,6 +23,7 @@ from ..half_domain import HalfDomain
|
||||||
|
|
||||||
from ..lib.routes import api_routes
|
from ..lib.routes import api_routes
|
||||||
from ..lib.responses import ORJSONResponse
|
from ..lib.responses import ORJSONResponse
|
||||||
|
from ..conf import CONFIG, PROJECT_LEVEL_KEYS
|
||||||
|
|
||||||
|
|
||||||
from ..logging import logger
|
from ..logging import logger
|
||||||
|
@ -118,14 +121,22 @@ def list_api_routes():
|
||||||
# list_routes(domain, m_dom)
|
# list_routes(domain, m_dom)
|
||||||
|
|
||||||
|
|
||||||
@click.option('--read',default=True, is_flag=True)
|
@click.option('--devel',default=None, is_flag=True)
|
||||||
|
@click.option('--watch',default=False, is_flag=True)
|
||||||
|
@click.option('--production',default=None, is_flag=True)
|
||||||
|
@click.option('--port',default=None, type=int)
|
||||||
|
@click.option('--log-level',default=None, type=str)
|
||||||
|
@click.option('--dry-run',default=False, is_flag=True)
|
||||||
|
@click.option('--run',default=False, is_flag=True)
|
||||||
|
@click.option('--read',default=False, is_flag=True)
|
||||||
|
@click.option('--conftest',default=False, is_flag=True)
|
||||||
@click.option('--create',default=False, is_flag=True)
|
@click.option('--create',default=False, is_flag=True)
|
||||||
@click.option('--update',default=False, is_flag=True)
|
@click.option('--update',default=False, is_flag=True)
|
||||||
@click.option('--delete',default=False, is_flag=True)
|
@click.option('--delete',default=False, is_flag=True)
|
||||||
@click.argument('config_file', type=click.File(mode='rb'), required=False)
|
@click.argument('config_file', type=click.File(mode='rb'), required=False)
|
||||||
@click.argument('domain',default=None, required=False)
|
@click.argument('domain',default=None, required=False)
|
||||||
@cli.command()
|
@cli.command()
|
||||||
def domain(domain, config_file, delete, update, create, read): #, domains, read, create, update, delete):
|
def domain(domain, config_file, delete, update, create, conftest, read, run, dry_run, log_level, port, production, watch, devel):
|
||||||
"""
|
"""
|
||||||
The "halfapi domain" command
|
The "halfapi domain" command
|
||||||
|
|
||||||
|
@ -142,18 +153,30 @@ def domain(domain, config_file, delete, update, create, read): #, domains, read
|
||||||
# TODO: Connect to the create_domain function
|
# TODO: Connect to the create_domain function
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
raise Exception('Missing domain name')
|
raise Exception('Missing domain name')
|
||||||
if update:
|
|
||||||
raise NotImplementedError
|
|
||||||
if delete:
|
|
||||||
raise NotImplementedError
|
|
||||||
if read:
|
|
||||||
from ..conf import CONFIG
|
|
||||||
from ..halfapi import HalfAPI
|
|
||||||
|
|
||||||
if config_file:
|
if config_file:
|
||||||
CONFIG = json.loads(''.join(
|
ARG_CONFIG = toml.load(config_file.name)
|
||||||
[ line.decode() for line in config_file.readlines() ]
|
|
||||||
))
|
if 'project' in ARG_CONFIG:
|
||||||
|
for key, value in ARG_CONFIG['project'].items():
|
||||||
|
if key in PROJECT_LEVEL_KEYS:
|
||||||
|
CONFIG[key] = value
|
||||||
|
|
||||||
|
if 'domain' in ARG_CONFIG and domain in ARG_CONFIG['domain']:
|
||||||
|
for key, value in ARG_CONFIG['domain'][domain].items():
|
||||||
|
if key in PROJECT_LEVEL_KEYS:
|
||||||
|
CONFIG[key] = value
|
||||||
|
|
||||||
|
CONFIG['domain'].update(ARG_CONFIG['domain'])
|
||||||
|
|
||||||
|
if create:
|
||||||
|
raise NotImplementedError
|
||||||
|
elif update:
|
||||||
|
raise NotImplementedError
|
||||||
|
elif delete:
|
||||||
|
raise NotImplementedError
|
||||||
|
elif read:
|
||||||
|
from ..halfapi import HalfAPI
|
||||||
|
|
||||||
halfapi = HalfAPI(CONFIG)
|
halfapi = HalfAPI(CONFIG)
|
||||||
click.echo(orjson.dumps(
|
click.echo(orjson.dumps(
|
||||||
|
@ -162,4 +185,58 @@ def domain(domain, config_file, delete, update, create, read): #, domains, read
|
||||||
default=ORJSONResponse.default_cast)
|
default=ORJSONResponse.default_cast)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if dry_run:
|
||||||
|
CONFIG['dryrun'] = True
|
||||||
|
|
||||||
|
domains = CONFIG.get('domain')
|
||||||
|
for key in domains.keys():
|
||||||
|
if key != domain:
|
||||||
|
domains[key]['enabled'] = False
|
||||||
|
else:
|
||||||
|
domains[key]['enabled'] = True
|
||||||
|
|
||||||
|
if not log_level:
|
||||||
|
log_level = CONFIG.get('domain', {}).get('loglevel', CONFIG.get('loglevel', False))
|
||||||
|
else:
|
||||||
|
CONFIG['loglevel'] = log_level
|
||||||
|
|
||||||
|
if not port:
|
||||||
|
port = CONFIG.get('domain', {}).get('port', CONFIG.get('port', False))
|
||||||
|
else:
|
||||||
|
CONFIG['port'] = port
|
||||||
|
|
||||||
|
if devel is None and production is not None and (production is False or production is True):
|
||||||
|
CONFIG['production'] = production
|
||||||
|
|
||||||
|
if devel is not None:
|
||||||
|
CONFIG['production'] = False
|
||||||
|
CONFIG['loglevel'] = 'debug'
|
||||||
|
|
||||||
|
|
||||||
|
if conftest:
|
||||||
|
click.echo(
|
||||||
|
toml.dumps(CONFIG)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# domain section port is preferred, if it doesn't exist we use the global one
|
||||||
|
|
||||||
|
uvicorn_kwargs = {}
|
||||||
|
|
||||||
|
if CONFIG.get('port'):
|
||||||
|
uvicorn_kwargs['port'] = CONFIG['port']
|
||||||
|
|
||||||
|
if CONFIG.get('loglevel'):
|
||||||
|
uvicorn_kwargs['log_level'] = CONFIG['loglevel'].lower()
|
||||||
|
|
||||||
|
if watch:
|
||||||
|
uvicorn_kwargs['reload'] = True
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
'halfapi.app:application',
|
||||||
|
factory=True,
|
||||||
|
**uvicorn_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
151
halfapi/conf.py
151
halfapi/conf.py
|
@ -36,7 +36,7 @@ It follows the following format :
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from .logging import logger
|
||||||
import os
|
import os
|
||||||
from os import environ
|
from os import environ
|
||||||
import sys
|
import sys
|
||||||
|
@ -46,21 +46,51 @@ import uuid
|
||||||
|
|
||||||
import toml
|
import toml
|
||||||
|
|
||||||
from .logging import logger
|
|
||||||
|
|
||||||
PRODUCTION = True
|
|
||||||
LOGLEVEL = 'info'
|
|
||||||
CONF_FILE = os.environ.get('HALFAPI_CONF_FILE', '.halfapi/config')
|
|
||||||
DRYRUN = bool(os.environ.get('HALFAPI_DRYRUN', False))
|
|
||||||
|
|
||||||
SCHEMA = {}
|
SCHEMA = {}
|
||||||
|
|
||||||
CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api')
|
DEFAULT_CONF = {
|
||||||
|
# Default configuration values
|
||||||
|
'SECRET': tempfile.mkstemp()[1],
|
||||||
|
'PROJECT_NAME': os.getcwd().split('/')[-1],
|
||||||
|
'PRODUCTION': True,
|
||||||
|
'HOST': '127.0.0.1',
|
||||||
|
'PORT': 3000,
|
||||||
|
'LOGLEVEL': 'info',
|
||||||
|
'BASE_DIR': os.getcwd(),
|
||||||
|
'CONF_FILE': '.halfapi/config',
|
||||||
|
'CONF_DIR': '/etc/half_api',
|
||||||
|
'DRYRUN': None
|
||||||
|
}
|
||||||
|
|
||||||
|
PROJECT_LEVEL_KEYS = {
|
||||||
|
# Allowed keys in "project" section of configuration file
|
||||||
|
'project_name',
|
||||||
|
'production',
|
||||||
|
'secret',
|
||||||
|
'host',
|
||||||
|
'port',
|
||||||
|
'loglevel',
|
||||||
|
'dryrun'
|
||||||
|
}
|
||||||
|
|
||||||
|
DOMAIN_LEVEL_KEYS = PROJECT_LEVEL_KEYS | {
|
||||||
|
# Allowed keys in "domain" section of configuration file
|
||||||
|
'name',
|
||||||
|
'module',
|
||||||
|
'prefix',
|
||||||
|
'enabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
CONF_FILE = os.environ.get('HALFAPI_CONF_FILE', DEFAULT_CONF['CONF_FILE'])
|
||||||
|
CONF_DIR = os.environ.get('HALFAPI_CONF_DIR', DEFAULT_CONF['CONF_DIR'])
|
||||||
|
|
||||||
HALFAPI_ETC_FILE=os.path.join(
|
HALFAPI_ETC_FILE=os.path.join(
|
||||||
CONF_DIR, 'config'
|
CONF_DIR, 'config'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BASE_DIR = os.environ.get('HALFAPI_BASE_DIR', DEFAULT_CONF['BASE_DIR'])
|
||||||
HALFAPI_DOT_FILE=os.path.join(
|
HALFAPI_DOT_FILE=os.path.join(
|
||||||
os.getcwd(), '.halfapi', 'config')
|
BASE_DIR, '.halfapi', 'config')
|
||||||
|
|
||||||
HALFAPI_CONFIG_FILES = []
|
HALFAPI_CONFIG_FILES = []
|
||||||
|
|
||||||
|
@ -68,35 +98,61 @@ try:
|
||||||
with open(HALFAPI_ETC_FILE, 'r'):
|
with open(HALFAPI_ETC_FILE, 'r'):
|
||||||
HALFAPI_CONFIG_FILES.append(HALFAPI_ETC_FILE)
|
HALFAPI_CONFIG_FILES.append(HALFAPI_ETC_FILE)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error('Cannot find a configuration file under %s', HALFAPI_DOT_FILE)
|
logger.info('Cannot find a configuration file under %s', HALFAPI_ETC_FILE)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(HALFAPI_DOT_FILE, 'r'):
|
with open(HALFAPI_DOT_FILE, 'r'):
|
||||||
HALFAPI_CONFIG_FILES.append(HALFAPI_DOT_FILE)
|
HALFAPI_CONFIG_FILES.append(HALFAPI_DOT_FILE)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error('Cannot find a configuration file under %s', HALFAPI_DOT_FILE)
|
logger.info('Cannot find a configuration file under %s', HALFAPI_DOT_FILE)
|
||||||
|
|
||||||
|
|
||||||
def read_config():
|
ENVIRONMENT = {}
|
||||||
|
# Load environment variables allowed in configuration
|
||||||
|
|
||||||
|
if 'HALFAPI_DRYRUN' in os.environ:
|
||||||
|
ENVIRONMENT['dryrun'] = True
|
||||||
|
|
||||||
|
if 'HALFAPI_PROD' in os.environ:
|
||||||
|
ENVIRONMENT['production'] = bool(os.environ.get('HALFAPI_PROD'))
|
||||||
|
|
||||||
|
if 'HALFAPI_LOGLEVEL' in os.environ:
|
||||||
|
ENVIRONMENT['loglevel'] = os.environ.get('HALFAPI_LOGLEVEL').lower()
|
||||||
|
|
||||||
|
if 'HALFAPI_SECRET' in os.environ:
|
||||||
|
ENVIRONMENT['secret'] = os.environ.get('HALFAPI_SECRET')
|
||||||
|
|
||||||
|
if 'HALFAPI_HOST' in os.environ:
|
||||||
|
ENVIRONMENT['host'] = os.environ.get('HALFAPI_HOST')
|
||||||
|
|
||||||
|
if 'HALFAPI_PORT' in os.environ:
|
||||||
|
ENVIRONMENT['port'] = int(os.environ.get('HALFAPI_PORT'))
|
||||||
|
|
||||||
|
def read_config(filenames=HALFAPI_CONFIG_FILES):
|
||||||
"""
|
"""
|
||||||
The highest index in "filenames" are the highest priorty
|
The highest index in "filenames" are the highest priorty
|
||||||
"""
|
"""
|
||||||
d_res = {}
|
d_res = {}
|
||||||
|
|
||||||
logger.info('Reading config files %s', HALFAPI_CONFIG_FILES)
|
logger.info('Reading config files %s', filenames)
|
||||||
for CONF_FILE in HALFAPI_CONFIG_FILES:
|
for CONF_FILE in filenames:
|
||||||
d_res.update( toml.load(HALFAPI_CONFIG_FILES) )
|
if os.path.isfile(CONF_FILE):
|
||||||
|
conf_dict = toml.load(CONF_FILE)
|
||||||
|
d_res.update(conf_dict)
|
||||||
|
|
||||||
logger.info('Reading config files (result) %s', d_res)
|
logger.info('Read config files (result) %s', d_res)
|
||||||
return { **d_res.get('project', {}), 'domain': d_res.get('domain', {}) }
|
return { **d_res.get('project', {}), 'domain': d_res.get('domain', {}) }
|
||||||
|
|
||||||
CONFIG = read_config()
|
CONFIG = read_config()
|
||||||
|
CONFIG.update(**ENVIRONMENT)
|
||||||
|
|
||||||
PROJECT_NAME = CONFIG.get('project_name',
|
PROJECT_NAME = CONFIG.get('project_name',
|
||||||
environ.get('HALFAPI_PROJECT_NAME', os.getcwd().split('/')[-1]))
|
os.environ.get('HALFAPI_PROJECT_NAME', DEFAULT_CONF['PROJECT_NAME']))
|
||||||
|
|
||||||
if environ.get('HALFAPI_DOMAIN_NAME'):
|
if os.environ.get('HALFAPI_DOMAIN_NAME'):
|
||||||
DOMAIN_NAME = environ.get('HALFAPI_DOMAIN_NAME')
|
# Force enabled domain by environment variable
|
||||||
|
|
||||||
|
DOMAIN_NAME = os.environ.get('HALFAPI_DOMAIN_NAME')
|
||||||
if 'domain' in CONFIG and DOMAIN_NAME in CONFIG['domain'] \
|
if 'domain' in CONFIG and DOMAIN_NAME in CONFIG['domain'] \
|
||||||
and 'config' in CONFIG['domain'][DOMAIN_NAME]:
|
and 'config' in CONFIG['domain'][DOMAIN_NAME]:
|
||||||
|
|
||||||
|
@ -114,53 +170,36 @@ if environ.get('HALFAPI_DOMAIN_NAME'):
|
||||||
|
|
||||||
CONFIG['domain'][DOMAIN_NAME]['config'] = domain_config
|
CONFIG['domain'][DOMAIN_NAME]['config'] = domain_config
|
||||||
|
|
||||||
if environ.get('HALFAPI_DOMAIN_MODULE'):
|
if os.environ.get('HALFAPI_DOMAIN_MODULE'):
|
||||||
dom_module = environ.get('HALFAPI_DOMAIN_MODULE')
|
# Specify the pythonpath to import the specified domain (defaults to global)
|
||||||
|
dom_module = os.environ.get('HALFAPI_DOMAIN_MODULE')
|
||||||
CONFIG['domain'][DOMAIN_NAME]['module'] = dom_module
|
CONFIG['domain'][DOMAIN_NAME]['module'] = dom_module
|
||||||
|
|
||||||
if len(CONFIG.get('domain', {}).keys()) == 0:
|
if len(CONFIG.get('domain', {}).keys()) == 0:
|
||||||
logger.info('No domains')
|
logger.info('No domains')
|
||||||
|
|
||||||
# Bind
|
|
||||||
HOST = CONFIG.get('host',
|
|
||||||
environ.get('HALFAPI_HOST', '127.0.0.1'))
|
|
||||||
PORT = int(CONFIG.get(
|
|
||||||
'port',
|
|
||||||
environ.get('HALFAPI_PORT', '3000')))
|
|
||||||
|
|
||||||
|
|
||||||
# Secret
|
# Secret
|
||||||
SECRET = CONFIG.get(
|
if 'secret' not in CONFIG:
|
||||||
'secret',
|
|
||||||
environ.get('HALFAPI_SECRET'))
|
|
||||||
|
|
||||||
if not SECRET:
|
|
||||||
# TODO: Create a temporary secret
|
# TODO: Create a temporary secret
|
||||||
_, SECRET = tempfile.mkstemp()
|
CONFIG['secret'] = DEFAULT_CONF['SECRET']
|
||||||
with open(SECRET, 'w') as secret_file:
|
with open(CONFIG['secret'], 'w') as secret_file:
|
||||||
secret_file.write(str(uuid.uuid4()))
|
secret_file.write(str(uuid.uuid4()))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(SECRET, 'r') as secret_file:
|
with open(CONFIG['secret'], 'r') as secret_file:
|
||||||
CONFIG['secret'] = SECRET.strip()
|
CONFIG['secret'] = CONFIG['secret'].strip()
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
logger.info('Running without secret file: %s', SECRET or 'no file specified')
|
logger.warning('Running without secret file: %s', CONFIG['secret'] or 'no file specified')
|
||||||
|
|
||||||
PRODUCTION = bool(CONFIG.get(
|
CONFIG.setdefault('project_name', DEFAULT_CONF['PROJECT_NAME'])
|
||||||
'production',
|
CONFIG.setdefault('production', DEFAULT_CONF['PRODUCTION'])
|
||||||
environ.get('HALFAPI_PROD', True)))
|
CONFIG.setdefault('host', DEFAULT_CONF['HOST'])
|
||||||
|
CONFIG.setdefault('port', DEFAULT_CONF['PORT'])
|
||||||
|
CONFIG.setdefault('loglevel', DEFAULT_CONF['LOGLEVEL'])
|
||||||
|
CONFIG.setdefault('dryrun', DEFAULT_CONF['DRYRUN'])
|
||||||
|
|
||||||
LOGLEVEL = CONFIG.get(
|
# !!!TO REMOVE!!!
|
||||||
'loglevel',
|
SECRET = CONFIG.get('secret')
|
||||||
environ.get('HALFAPI_LOGLEVEL', 'info')).lower()
|
PRODUCTION = CONFIG.get('production')
|
||||||
|
# !!!
|
||||||
BASE_DIR = CONFIG.get(
|
|
||||||
'base_dir',
|
|
||||||
environ.get('HALFAPI_BASE_DIR', '.'))
|
|
||||||
|
|
||||||
CONFIG['project_name'] = PROJECT_NAME
|
|
||||||
CONFIG['production'] = PRODUCTION
|
|
||||||
CONFIG['secret'] = SECRET
|
|
||||||
CONFIG['host'] = HOST
|
|
||||||
CONFIG['port'] = PORT
|
|
||||||
CONFIG['dryrun'] = DRYRUN
|
|
||||||
|
|
|
@ -11,7 +11,12 @@ from types import ModuleType, FunctionType
|
||||||
from schema import SchemaError
|
from schema import SchemaError
|
||||||
|
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.routing import Router
|
from starlette.middleware import Middleware
|
||||||
|
from starlette.routing import Router, Route
|
||||||
|
from starlette.schemas import SchemaGenerator
|
||||||
|
|
||||||
|
from .lib.acl import AclRoute
|
||||||
|
from .lib.responses import ORJSONResponse
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
@ -19,8 +24,10 @@ import yaml
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .lib.constants import API_SCHEMA_DICT, ROUTER_SCHEMA, VERBS
|
from .lib.constants import API_SCHEMA_DICT, ROUTER_SCHEMA, VERBS
|
||||||
from .half_route import HalfRoute
|
from .half_route import HalfRoute
|
||||||
from .lib import acl
|
from .lib import acl as lib_acl
|
||||||
|
from .lib.responses import PlainTextResponse
|
||||||
from .lib.routes import JSONRoute
|
from .lib.routes import JSONRoute
|
||||||
|
from .lib.schemas import param_docstring_default
|
||||||
from .lib.domain import MissingAclError, PathError, UnknownPathParameterType, \
|
from .lib.domain import MissingAclError, PathError, UnknownPathParameterType, \
|
||||||
UndefinedRoute, UndefinedFunction, get_fct_name, route_decorator
|
UndefinedRoute, UndefinedFunction, get_fct_name, route_decorator
|
||||||
from .lib.domain_middleware import DomainMiddleware
|
from .lib.domain_middleware import DomainMiddleware
|
||||||
|
@ -38,15 +45,16 @@ class HalfDomain(Starlette):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
self.m_domain = importlib.import_module(domain) if module is None else module
|
self.m_domain = importlib.import_module(domain) if module is None else module
|
||||||
d_domain = getattr(self.m_domain, 'domain', domain)
|
self.d_domain = getattr(self.m_domain, 'domain', domain)
|
||||||
self.name = d_domain['name']
|
self.name = self.d_domain['name']
|
||||||
self.id = d_domain['id']
|
self.id = self.d_domain['id']
|
||||||
self.version = d_domain['version']
|
self.version = self.d_domain['version']
|
||||||
self.halfapi_version = d_domain.get('halfapi_version', __version__)
|
self.halfapi_version = self.d_domain.get('halfapi_version', __version__)
|
||||||
self.deps = d_domain.get('deps', tuple())
|
self.deps = self.d_domain.get('deps', tuple())
|
||||||
|
self.schema_components = self.d_domain.get('schema_components', dict())
|
||||||
|
|
||||||
if not router:
|
if not router:
|
||||||
self.router = d_domain.get('routers', '.routers')
|
self.router = self.d_domain.get('routers', '.routers')
|
||||||
else:
|
else:
|
||||||
self.router = router
|
self.router = router
|
||||||
|
|
||||||
|
@ -76,18 +84,26 @@ class HalfDomain(Starlette):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
routes=self.gen_domain_routes(),
|
routes=self.gen_domain_routes(),
|
||||||
middleware=[
|
middleware=[
|
||||||
(DomainMiddleware, {
|
Middleware(
|
||||||
'domain': {
|
DomainMiddleware,
|
||||||
|
domain={
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'version': self.version,
|
'version': self.version,
|
||||||
'halfapi_version': self.halfapi_version,
|
'halfapi_version': self.halfapi_version,
|
||||||
'config': self.config.get('domain', {}).get(self.name, {}).get('config', {})
|
'config': self.config.get('domain', {}).get(self.name, {}).get('config', {})
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def name(module):
|
||||||
|
""" Returns the name declared in the 'domain' dict at the root of the package
|
||||||
|
"""
|
||||||
|
return module.domain['name']
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def m_acl(module, acl=None):
|
def m_acl(module, acl=None):
|
||||||
""" Returns the imported acl module for the domain module
|
""" Returns the imported acl module for the domain module
|
||||||
|
@ -104,9 +120,14 @@ class HalfDomain(Starlette):
|
||||||
"""
|
"""
|
||||||
m_acl = HalfDomain.m_acl(module, acl)
|
m_acl = HalfDomain.m_acl(module, acl)
|
||||||
try:
|
try:
|
||||||
return getattr(m_acl, 'ACLS')
|
return [
|
||||||
except AttributeError:
|
lib_acl.ACL(*elt)
|
||||||
raise Exception(f'Missing acl.ACLS constant in module {m_acl.__package__}')
|
for elt in getattr(m_acl, 'ACLS')
|
||||||
|
]
|
||||||
|
except AttributeError as exc:
|
||||||
|
logger.error(exc)
|
||||||
|
raise Exception(
|
||||||
|
f'Missing acl.ACLS constant in module {m_acl.__package__}') from exc
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def acls_route(domain, module_path=None, acl=None):
|
def acls_route(domain, module_path=None, acl=None):
|
||||||
|
@ -118,7 +139,6 @@ class HalfDomain(Starlette):
|
||||||
[acl_name]: {
|
[acl_name]: {
|
||||||
callable: fct_reference,
|
callable: fct_reference,
|
||||||
docs: fct_docstring,
|
docs: fct_docstring,
|
||||||
result: fct_result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
@ -131,24 +151,84 @@ class HalfDomain(Starlette):
|
||||||
|
|
||||||
m_acl = HalfDomain.m_acl(module, acl)
|
m_acl = HalfDomain.m_acl(module, acl)
|
||||||
|
|
||||||
for acl_name, doc, order in HalfDomain.acls(
|
for elt in HalfDomain.acls(module, acl=acl):
|
||||||
module,
|
|
||||||
acl=acl):
|
fct = getattr(m_acl, elt.name)
|
||||||
fct = getattr(m_acl, acl_name)
|
|
||||||
d_res[acl_name] = {
|
d_res[elt.name] = {
|
||||||
'callable': fct,
|
'callable': fct,
|
||||||
'docs': doc,
|
'docs': elt.documentation
|
||||||
'result': None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return d_res
|
return d_res
|
||||||
|
|
||||||
# def schema(self):
|
@staticmethod
|
||||||
|
def acls_router(domain, module_path=None, acl=None):
|
||||||
|
""" Returns a Router object with the following routes :
|
||||||
|
|
||||||
|
/ : The "acls" field of the API metadatas
|
||||||
|
/{acl_name} : If the ACL is defined as public, a route that returns either status code 200 or 401 on HEAD/GET request
|
||||||
|
"""
|
||||||
|
|
||||||
|
routes = []
|
||||||
|
d_res = {}
|
||||||
|
|
||||||
|
module = importlib.import_module(domain) \
|
||||||
|
if module_path is None \
|
||||||
|
else importlib.import_module(module_path)
|
||||||
|
|
||||||
|
|
||||||
|
m_acl = HalfDomain.m_acl(module, acl)
|
||||||
|
|
||||||
|
for elt in HalfDomain.acls(module, acl=acl):
|
||||||
|
|
||||||
|
fct = getattr(m_acl, elt.name)
|
||||||
|
|
||||||
|
d_res[elt.name] = {
|
||||||
|
'callable': fct,
|
||||||
|
'docs': elt.documentation,
|
||||||
|
'public': elt.public
|
||||||
|
}
|
||||||
|
|
||||||
|
if elt.public:
|
||||||
|
try:
|
||||||
|
if inspect.iscoroutinefunction(fct):
|
||||||
|
logger.warning('async decorator are not yet supported')
|
||||||
|
else:
|
||||||
|
inner = fct()
|
||||||
|
|
||||||
|
if inspect.iscoroutinefunction(fct) or callable(inner):
|
||||||
|
fct = inner
|
||||||
|
|
||||||
|
except TypeError:
|
||||||
|
# Fct is not a decorator or is not well called (has no default arguments)
|
||||||
|
# We can ignore this
|
||||||
|
pass
|
||||||
|
|
||||||
|
routes.append(
|
||||||
|
AclRoute(f'/{elt.name}', fct, elt)
|
||||||
|
)
|
||||||
|
|
||||||
|
d_res_under_domain_name = {}
|
||||||
|
d_res_under_domain_name[HalfDomain.name(module)] = d_res
|
||||||
|
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
'/',
|
||||||
|
JSONRoute(d_res_under_domain_name),
|
||||||
|
methods=['GET']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Router(routes)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def gen_routes(m_router: ModuleType,
|
def gen_routes(m_router: ModuleType,
|
||||||
verb: str,
|
verb: str,
|
||||||
path: List[str],
|
path: List[str],
|
||||||
params: List[Dict]) -> Tuple[FunctionType, Dict]:
|
params: List[Dict],
|
||||||
|
path_param_docstrings: Dict[str, str] = {}) -> Tuple[FunctionType, Dict]:
|
||||||
"""
|
"""
|
||||||
Returns a tuple of the function associatied to the verb and path arguments,
|
Returns a tuple of the function associatied to the verb and path arguments,
|
||||||
and the dictionary of it's acls
|
and the dictionary of it's acls
|
||||||
|
@ -180,6 +260,13 @@ class HalfDomain(Starlette):
|
||||||
fct_name = get_fct_name(verb, path[-1])
|
fct_name = get_fct_name(verb, path[-1])
|
||||||
if hasattr(m_router, fct_name):
|
if hasattr(m_router, fct_name):
|
||||||
fct = getattr(m_router, fct_name)
|
fct = getattr(m_router, fct_name)
|
||||||
|
fct_docstring_obj = yaml.safe_load(fct.__doc__)
|
||||||
|
if 'parameters' not in fct_docstring_obj and path_param_docstrings:
|
||||||
|
fct_docstring_obj['parameters'] = list(map(
|
||||||
|
yaml.safe_load,
|
||||||
|
path_param_docstrings.values()))
|
||||||
|
|
||||||
|
fct.__doc__ = yaml.dump(fct_docstring_obj)
|
||||||
else:
|
else:
|
||||||
raise UndefinedFunction('{}.{}'.format(m_router.__name__, fct_name or ''))
|
raise UndefinedFunction('{}.{}'.format(m_router.__name__, fct_name or ''))
|
||||||
|
|
||||||
|
@ -188,11 +275,11 @@ class HalfDomain(Starlette):
|
||||||
return route_decorator(fct), params
|
return route_decorator(fct), params
|
||||||
|
|
||||||
# TODO: Remove when using only sync functions
|
# TODO: Remove when using only sync functions
|
||||||
return acl.args_check(fct), params
|
return lib_acl.args_check(fct), params
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def gen_router_routes(m_router, path: List[str]) -> \
|
def gen_router_routes(m_router, path: List[str], PATH_PARAMS={}) -> \
|
||||||
Iterator[Tuple[str, str, ModuleType, Coroutine, List]]:
|
Iterator[Tuple[str, str, ModuleType, Coroutine, List]]:
|
||||||
"""
|
"""
|
||||||
Recursive generator that parses a router (or a subrouter)
|
Recursive generator that parses a router (or a subrouter)
|
||||||
|
@ -220,17 +307,32 @@ class HalfDomain(Starlette):
|
||||||
yield ('/'.join(filter(lambda x: len(x) > 0, path)),
|
yield ('/'.join(filter(lambda x: len(x) > 0, path)),
|
||||||
verb,
|
verb,
|
||||||
m_router,
|
m_router,
|
||||||
*HalfDomain.gen_routes(m_router, verb, path, params[verb])
|
*HalfDomain.gen_routes(m_router, verb, path, params[verb], PATH_PARAMS)
|
||||||
)
|
)
|
||||||
|
|
||||||
for subroute in params.get('SUBROUTES', []):
|
for subroute in params.get('SUBROUTES', []):
|
||||||
#logger.debug('Processing subroute **%s** - %s', subroute, m_router.__name__)
|
subroute_module = importlib.import_module(f'.{subroute}', m_router.__name__)
|
||||||
param_match = re.fullmatch('^([A-Z_]+)_([a-z]+)$', subroute)
|
param_match = re.fullmatch('^([A-Z_]+)_([a-z]+)$', subroute)
|
||||||
|
parameter_name = None
|
||||||
if param_match is not None:
|
if param_match is not None:
|
||||||
try:
|
try:
|
||||||
|
parameter_name = param_match.groups()[0].lower()
|
||||||
|
if parameter_name in PATH_PARAMS:
|
||||||
|
raise Exception(f'Duplicate parameter name in same path! {subroute} : {parameter_name}')
|
||||||
|
|
||||||
|
parameter_type = param_match.groups()[1]
|
||||||
path.append('{{{}:{}}}'.format(
|
path.append('{{{}:{}}}'.format(
|
||||||
param_match.groups()[0].lower(),
|
parameter_name,
|
||||||
param_match.groups()[1]))
|
parameter_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
PATH_PARAMS[parameter_name] = subroute_module.param_docstring
|
||||||
|
except AttributeError as exc:
|
||||||
|
PATH_PARAMS[parameter_name] = param_docstring_default(parameter_name, parameter_type)
|
||||||
|
|
||||||
except AssertionError as exc:
|
except AssertionError as exc:
|
||||||
raise UnknownPathParameterType(subroute) from exc
|
raise UnknownPathParameterType(subroute) from exc
|
||||||
else:
|
else:
|
||||||
|
@ -238,14 +340,19 @@ class HalfDomain(Starlette):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield from HalfDomain.gen_router_routes(
|
yield from HalfDomain.gen_router_routes(
|
||||||
importlib.import_module(f'.{subroute}', m_router.__name__),
|
subroute_module,
|
||||||
path)
|
path,
|
||||||
|
PATH_PARAMS
|
||||||
|
)
|
||||||
|
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
logger.error('Failed to import subroute **{%s}**', subroute)
|
logger.error('Failed to import subroute **{%s}**', subroute)
|
||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
path.pop()
|
path.pop()
|
||||||
|
if parameter_name:
|
||||||
|
PATH_PARAMS.pop(parameter_name)
|
||||||
|
|
||||||
|
|
||||||
path.pop()
|
path.pop()
|
||||||
|
|
||||||
|
@ -317,8 +424,8 @@ class HalfDomain(Starlette):
|
||||||
Generator(HalfRoute)
|
Generator(HalfRoute)
|
||||||
"""
|
"""
|
||||||
yield HalfRoute('/',
|
yield HalfRoute('/',
|
||||||
JSONRoute([ self.schema() ]),
|
self.schema_openapi(),
|
||||||
[{'acl': acl.public}],
|
[{'acl': lib_acl.public}],
|
||||||
'GET'
|
'GET'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -371,3 +478,36 @@ class HalfDomain(Starlette):
|
||||||
}
|
}
|
||||||
schema['paths'] = self.schema_dict()
|
schema['paths'] = self.schema_dict()
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
def schema_openapi(self) -> Route:
|
||||||
|
schema = SchemaGenerator(
|
||||||
|
{
|
||||||
|
'openapi': '3.0.0',
|
||||||
|
'info': {
|
||||||
|
'title': self.name,
|
||||||
|
'version': self.version,
|
||||||
|
'x-acls': tuple(getattr(self.m_acl, 'ACLS', ())),
|
||||||
|
**({
|
||||||
|
f'x-{key}': value
|
||||||
|
for key, value in self.d_domain.items()
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'components': self.schema_components
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
async def inner(request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
description: |
|
||||||
|
Returns the current API routes description (OpenAPI v3)
|
||||||
|
as a JSON object
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: API Schema in OpenAPI v3 format
|
||||||
|
"""
|
||||||
|
return ORJSONResponse(
|
||||||
|
schema.get_schema(routes=request.app.routes))
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ It defines the following globals :
|
||||||
- application (the asgi application itself - a starlette object)
|
- application (the asgi application itself - a starlette object)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import sys
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import importlib
|
import importlib
|
||||||
|
@ -19,7 +20,7 @@ from starlette.applications import Starlette
|
||||||
from starlette.authentication import UnauthenticatedUser
|
from starlette.authentication import UnauthenticatedUser
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
from starlette.middleware import Middleware
|
from starlette.middleware import Middleware
|
||||||
from starlette.routing import Route, Mount
|
from starlette.routing import Router, Route, Mount
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import Response, PlainTextResponse
|
from starlette.responses import Response, PlainTextResponse
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
|
@ -32,7 +33,7 @@ from timing_asgi.integrations import StarletteScopeToName
|
||||||
from .lib.constants import API_SCHEMA_DICT
|
from .lib.constants import API_SCHEMA_DICT
|
||||||
from .lib.domain_middleware import DomainMiddleware
|
from .lib.domain_middleware import DomainMiddleware
|
||||||
from .lib.timing import HTimingClient
|
from .lib.timing import HTimingClient
|
||||||
from .lib.jwt_middleware import JWTAuthenticationBackend
|
from .lib.jwt_middleware import JWTAuthenticationBackend, on_auth_error
|
||||||
from .lib.responses import (ORJSONResponse, UnauthorizedResponse,
|
from .lib.responses import (ORJSONResponse, UnauthorizedResponse,
|
||||||
NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse,
|
NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse,
|
||||||
ServiceUnavailableResponse, gen_exception_route)
|
ServiceUnavailableResponse, gen_exception_route)
|
||||||
|
@ -44,14 +45,24 @@ from .half_domain import HalfDomain
|
||||||
from halfapi import __version__
|
from halfapi import __version__
|
||||||
|
|
||||||
class HalfAPI(Starlette):
|
class HalfAPI(Starlette):
|
||||||
def __init__(self, config,
|
def __init__(self,
|
||||||
|
config,
|
||||||
d_routes=None):
|
d_routes=None):
|
||||||
config_logging(logging.DEBUG)
|
# Set log level (defaults to debug)
|
||||||
|
config_logging(
|
||||||
|
getattr(logging, config.get('loglevel', 'DEBUG').upper(), 'DEBUG')
|
||||||
|
)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
SECRET = self.config.get('secret')
|
SECRET = self.config.get('secret')
|
||||||
PRODUCTION = self.config.get('production', True)
|
PRODUCTION = self.config.get('production', True)
|
||||||
DRYRUN = self.config.get('dryrun', False)
|
DRYRUN = self.config.get('dryrun', False)
|
||||||
|
TIMINGMIDDLEWARE = self.config.get('timingmiddleware', False)
|
||||||
|
|
||||||
|
if DRYRUN:
|
||||||
|
logger.info('HalfAPI starting in dry-run mode')
|
||||||
|
else:
|
||||||
|
logger.info('HalfAPI starting')
|
||||||
|
|
||||||
|
|
||||||
self.PRODUCTION = PRODUCTION
|
self.PRODUCTION = PRODUCTION
|
||||||
self.SECRET = SECRET
|
self.SECRET = SECRET
|
||||||
|
@ -65,11 +76,15 @@ class HalfAPI(Starlette):
|
||||||
Mount('/halfapi', routes=list(self.halfapi_routes()))
|
Mount('/halfapi', routes=list(self.halfapi_routes()))
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info('Config: %s', self.config)
|
logger.debug('Config: %s', self.config)
|
||||||
logger.info('Active domains: %s',
|
|
||||||
list(filter(
|
domains = {
|
||||||
lambda n: n.get('enabled', False),
|
key: elt
|
||||||
self.config.get('domain', {}).values())))
|
for key, elt in self.config.get('domain', {}).items()
|
||||||
|
if elt.get('enabled', False)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Active domains: %s', domains)
|
||||||
|
|
||||||
if d_routes:
|
if d_routes:
|
||||||
# Mount the routes from the d_routes argument - domain-less mode
|
# Mount the routes from the d_routes argument - domain-less mode
|
||||||
|
@ -95,15 +110,14 @@ class HalfAPI(Starlette):
|
||||||
500: gen_exception_route(HalfAPI.exception),
|
500: gen_exception_route(HalfAPI.exception),
|
||||||
501: gen_exception_route(NotImplementedResponse),
|
501: gen_exception_route(NotImplementedResponse),
|
||||||
503: gen_exception_route(ServiceUnavailableResponse)
|
503: gen_exception_route(ServiceUnavailableResponse)
|
||||||
},
|
}
|
||||||
on_startup=startup_fcts
|
|
||||||
)
|
)
|
||||||
|
|
||||||
schemas = []
|
schemas = []
|
||||||
|
|
||||||
self.__domains = {}
|
self.__domains = {}
|
||||||
|
|
||||||
for key, domain in self.config.get('domain', {}).items():
|
for key, domain in domains.items():
|
||||||
if not isinstance(domain, dict):
|
if not isinstance(domain, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
@ -112,7 +126,7 @@ class HalfAPI(Starlette):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not domain.get('prefix', False):
|
if not domain.get('prefix', False):
|
||||||
if len(self.config.get('domain').keys()) > 1:
|
if len(domains.keys()) > 1:
|
||||||
raise Exception('Cannot use multiple domains and set prefix to false')
|
raise Exception('Cannot use multiple domains and set prefix to false')
|
||||||
path = '/'
|
path = '/'
|
||||||
else:
|
else:
|
||||||
|
@ -137,10 +151,11 @@ class HalfAPI(Starlette):
|
||||||
if SECRET:
|
if SECRET:
|
||||||
self.add_middleware(
|
self.add_middleware(
|
||||||
AuthenticationMiddleware,
|
AuthenticationMiddleware,
|
||||||
backend=JWTAuthenticationBackend()
|
backend=JWTAuthenticationBackend(),
|
||||||
|
on_error=on_auth_error
|
||||||
)
|
)
|
||||||
|
|
||||||
if not PRODUCTION:
|
if not PRODUCTION and TIMINGMIDDLEWARE:
|
||||||
self.add_middleware(
|
self.add_middleware(
|
||||||
TimingMiddleware,
|
TimingMiddleware,
|
||||||
client=HTimingClient(),
|
client=HTimingClient(),
|
||||||
|
@ -153,6 +168,12 @@ class HalfAPI(Starlette):
|
||||||
return __version__
|
return __version__
|
||||||
|
|
||||||
async def version_async(self, request, *args, **kwargs):
|
async def version_async(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
description: Version route
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Currently running HalfAPI's version
|
||||||
|
"""
|
||||||
return Response(self.version)
|
return Response(self.version)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -169,11 +190,21 @@ class HalfAPI(Starlette):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def get_user(request, *args, **kwargs):
|
async def get_user(request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
description: WhoAmI route
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: The currently logged-in user
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
"""
|
||||||
return ORJSONResponse({'user':request.user})
|
return ORJSONResponse({'user':request.user})
|
||||||
|
|
||||||
yield Route('/whoami', get_user)
|
yield Route('/whoami', get_user)
|
||||||
yield Route('/schema', schema_json)
|
yield Route('/schema', schema_json)
|
||||||
yield Route('/acls', self.acls_route())
|
yield Mount('/acls', self.acls_router())
|
||||||
yield Route('/version', self.version_async)
|
yield Route('/version', self.version_async)
|
||||||
""" Halfapi debug routes definition
|
""" Halfapi debug routes definition
|
||||||
"""
|
"""
|
||||||
|
@ -215,35 +246,26 @@ class HalfAPI(Starlette):
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
def acls_route(self):
|
def acls_router(self):
|
||||||
module = None
|
mounts = {}
|
||||||
res = {
|
|
||||||
domain: HalfDomain.acls_route(
|
for domain, domain_conf in self.config.get('domain', {}).items():
|
||||||
|
if isinstance(domain_conf, dict) and domain_conf.get('enabled', False):
|
||||||
|
mounts['domain'] = HalfDomain.acls_router(
|
||||||
domain,
|
domain,
|
||||||
module_path=domain_conf.get('module'),
|
module_path=domain_conf.get('module'),
|
||||||
acl=domain_conf.get('acl'))
|
acl=domain_conf.get('acl')
|
||||||
for domain, domain_conf in self.config.get('domain', {}).items()
|
)
|
||||||
if isinstance(domain_conf, dict) and domain_conf.get('enabled', False)
|
|
||||||
}
|
|
||||||
|
|
||||||
async def wrapped(req, *args, **kwargs):
|
if len(mounts) > 1:
|
||||||
for domain, domain_acls in res.items():
|
return Router([
|
||||||
for acl_name, d_acl in domain_acls.items():
|
Mount(f'/{domain}', acls_router)
|
||||||
fct = d_acl['callable']
|
for domain, acls_router in mounts.items()
|
||||||
if not callable(fct):
|
])
|
||||||
raise Exception(
|
elif len(mounts) == 1:
|
||||||
'No callable function in acl definition %s',
|
return Mount('/', mounts.popitem()[1])
|
||||||
acl_name)
|
else:
|
||||||
|
return Router()
|
||||||
fct_result = fct(req, *args, **kwargs)
|
|
||||||
if callable(fct_result):
|
|
||||||
fct_result = fct()(req, *args, **kwargs)
|
|
||||||
|
|
||||||
d_acl['result'] = fct_result
|
|
||||||
|
|
||||||
return ORJSONResponse(res)
|
|
||||||
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def domains(self):
|
def domains(self):
|
||||||
|
@ -283,3 +305,12 @@ class HalfAPI(Starlette):
|
||||||
self.mount(kwargs.get('path', name), self.__domains[name])
|
self.mount(kwargs.get('path', name), self.__domains[name])
|
||||||
|
|
||||||
return self.__domains[name]
|
return self.__domains[name]
|
||||||
|
|
||||||
|
|
||||||
|
def __main__():
|
||||||
|
return HalfAPI(CONFIG).application
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
__main__()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,14 @@
|
||||||
"""
|
"""
|
||||||
Base ACL module that contains generic functions for domains ACL
|
Base ACL module that contains generic functions for domains ACL
|
||||||
"""
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from json import JSONDecodeError
|
from json import JSONDecodeError
|
||||||
|
import yaml
|
||||||
from starlette.authentication import UnauthenticatedUser
|
from starlette.authentication import UnauthenticatedUser
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
from ..logging import logger
|
from ..logging import logger
|
||||||
|
|
||||||
|
@ -118,7 +122,57 @@ def args_check(fct):
|
||||||
|
|
||||||
# ACLS list for doc and priorities
|
# ACLS list for doc and priorities
|
||||||
# Write your own constant in your domain or import this one
|
# Write your own constant in your domain or import this one
|
||||||
|
# Format : (acl_name: str, acl_documentation: str, priority: int, [public=False])
|
||||||
|
#
|
||||||
|
# The 'priority' integer is greater than zero and the lower values means more
|
||||||
|
# priority. For a route, the order of declaration of the ACLs should respect
|
||||||
|
# their priority.
|
||||||
|
#
|
||||||
|
# When the 'public' boolean value is True, a route protected by this ACL is
|
||||||
|
# defined on the "/halfapi/acls/acl_name", that returns an empty response and
|
||||||
|
# the status code 200 or 401.
|
||||||
|
|
||||||
ACLS = (
|
ACLS = (
|
||||||
('private', public.__doc__, 0),
|
('private', private.__doc__, 0, True),
|
||||||
('public', public.__doc__, 999)
|
('public', public.__doc__, 999, True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ACL():
|
||||||
|
name: str
|
||||||
|
documentation: str
|
||||||
|
priority: int
|
||||||
|
public: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AclRoute(Route):
|
||||||
|
def __init__(self, path, acl_fct, acl: ACL):
|
||||||
|
self.acl_fct = acl_fct
|
||||||
|
self.name = acl.name
|
||||||
|
self.description = acl.documentation
|
||||||
|
|
||||||
|
self.docstring = yaml.dump({
|
||||||
|
'description': f'{self.name}: {self.description}',
|
||||||
|
'responses': {
|
||||||
|
'200': {
|
||||||
|
'description': 'ACL OK'
|
||||||
|
},
|
||||||
|
'401': {
|
||||||
|
'description': 'ACL FAIL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def endpoint(request, *args, **kwargs):
|
||||||
|
if request.method == 'GET':
|
||||||
|
logger.warning('Deprecated since 0.6.28, use HEAD method since now')
|
||||||
|
|
||||||
|
if self.acl_fct(request, *args, **kwargs) is True:
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
return Response(status_code=401)
|
||||||
|
|
||||||
|
endpoint.__doc__ = self.docstring
|
||||||
|
|
||||||
|
return super().__init__(path, methods=['HEAD', 'GET'], endpoint=endpoint)
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@ DOMAIN_SCHEMA = Schema({
|
||||||
Optional('version'): str,
|
Optional('version'): str,
|
||||||
Optional('patch_release'): str,
|
Optional('patch_release'): str,
|
||||||
Optional('acls'): [
|
Optional('acls'): [
|
||||||
[str, str, int]
|
[str, str, int, Optional(bool)]
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -73,9 +73,11 @@ def route_decorator(fct: FunctionType) -> Coroutine:
|
||||||
'user' in request else None,
|
'user' in request else None,
|
||||||
'config': request.scope.get('config', {}),
|
'config': request.scope.get('config', {}),
|
||||||
'domain': request.scope.get('domain', 'unknown'),
|
'domain': request.scope.get('domain', 'unknown'),
|
||||||
|
'cookies': request.cookies,
|
||||||
|
'base_url': request.base_url,
|
||||||
|
'url': request.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if 'data' in fct_args_spec:
|
if 'data' in fct_args_spec:
|
||||||
if 'data' in fct_args_defaults_dict:
|
if 'data' in fct_args_defaults_dict:
|
||||||
fct_args['data'] = fct_args_defaults_dict['data']
|
fct_args['data'] = fct_args_defaults_dict['data']
|
||||||
|
@ -97,6 +99,7 @@ def route_decorator(fct: FunctionType) -> Coroutine:
|
||||||
logger.debug('Return type {} (defaults: {})'.format(ret_type,
|
logger.debug('Return type {} (defaults: {})'.format(ret_type,
|
||||||
fct_args_defaults_dict))
|
fct_args_defaults_dict))
|
||||||
try:
|
try:
|
||||||
|
logger.debug('FCT_ARGS***** %s', fct_args)
|
||||||
if ret_type == 'json':
|
if ret_type == 'json':
|
||||||
return ORJSONResponse(fct(**fct_args))
|
return ORJSONResponse(fct(**fct_args))
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ class DomainMiddleware(BaseHTTPMiddleware):
|
||||||
- acl
|
- acl
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app, domain):
|
def __init__(self, app, domain=None):
|
||||||
""" app: HalfAPI instance
|
""" app: HalfAPI instance
|
||||||
"""
|
"""
|
||||||
logger.info('DomainMiddleware app:%s domain:%s', app, domain)
|
logger.info('DomainMiddleware app:%s domain:%s', app, domain)
|
||||||
|
|
|
@ -14,16 +14,18 @@ from os import environ
|
||||||
import typing
|
import typing
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
import jwt
|
import jwt
|
||||||
from starlette.authentication import (
|
from starlette.authentication import (
|
||||||
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
|
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
|
||||||
UnauthenticatedUser)
|
UnauthenticatedUser)
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection, Request
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from .user import CheckUser, JWTUser, Nobody
|
from .user import CheckUser, JWTUser, Nobody
|
||||||
from ..logging import logger
|
from ..logging import logger
|
||||||
from ..conf import CONFIG
|
from ..conf import CONFIG
|
||||||
|
from ..lib.responses import ORJSONResponse
|
||||||
|
|
||||||
SECRET=None
|
SECRET=None
|
||||||
|
|
||||||
|
@ -34,6 +36,20 @@ except FileNotFoundError:
|
||||||
logger.error('Could not import SECRET variable from conf module,'\
|
logger.error('Could not import SECRET variable from conf module,'\
|
||||||
' using HALFAPI_SECRET environment variable')
|
' using HALFAPI_SECRET environment variable')
|
||||||
|
|
||||||
|
def cookies_from_scope(scope):
|
||||||
|
cookie = dict(scope.get("headers") or {}).get(b"cookie")
|
||||||
|
if not cookie:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
simple_cookie = SimpleCookie()
|
||||||
|
simple_cookie.load(cookie.decode("utf8"))
|
||||||
|
return {key: morsel.value for key, morsel in simple_cookie.items()}
|
||||||
|
|
||||||
|
def on_auth_error(request: Request, exc: Exception):
|
||||||
|
response = ORJSONResponse({"error": str(exc)}, status_code=401)
|
||||||
|
response.delete_cookie('Authorization')
|
||||||
|
return response
|
||||||
|
|
||||||
class JWTAuthenticationBackend(AuthenticationBackend):
|
class JWTAuthenticationBackend(AuthenticationBackend):
|
||||||
def __init__(self, secret_key: str = SECRET,
|
def __init__(self, secret_key: str = SECRET,
|
||||||
algorithm: str = 'HS256', prefix: str = 'JWT'):
|
algorithm: str = 'HS256', prefix: str = 'JWT'):
|
||||||
|
@ -52,17 +68,22 @@ class JWTAuthenticationBackend(AuthenticationBackend):
|
||||||
self, conn: HTTPConnection
|
self, conn: HTTPConnection
|
||||||
) -> typing.Optional[typing.Tuple['AuthCredentials', 'BaseUser']]:
|
) -> typing.Optional[typing.Tuple['AuthCredentials', 'BaseUser']]:
|
||||||
|
|
||||||
|
# Standard way to authenticate via API
|
||||||
|
# https://datatracker.ietf.org/doc/html/rfc7235#section-4.2
|
||||||
token = conn.headers.get('Authorization')
|
token = conn.headers.get('Authorization')
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
token = cookies_from_scope(conn.scope).get('Authorization')
|
||||||
|
|
||||||
is_check_call = 'check' in conn.query_params
|
is_check_call = 'check' in conn.query_params
|
||||||
is_fake_user_id = is_check_call and 'user_id' in conn.query_params
|
|
||||||
PRODUCTION = conn.scope['app'].debug == False
|
PRODUCTION = conn.scope['app'].debug == False
|
||||||
|
|
||||||
if not token and not is_check_call:
|
if not token and not is_check_call:
|
||||||
return AuthCredentials(), Nobody()
|
return AuthCredentials(), Nobody()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if token and not is_fake_user_id:
|
if token:
|
||||||
payload = jwt.decode(token,
|
payload = jwt.decode(token,
|
||||||
key=self.secret_key,
|
key=self.secret_key,
|
||||||
algorithms=[self.algorithm],
|
algorithms=[self.algorithm],
|
||||||
|
@ -71,14 +92,6 @@ class JWTAuthenticationBackend(AuthenticationBackend):
|
||||||
})
|
})
|
||||||
|
|
||||||
if is_check_call:
|
if is_check_call:
|
||||||
if is_fake_user_id:
|
|
||||||
try:
|
|
||||||
fake_user_id = UUID(conn.query_params['user_id'])
|
|
||||||
|
|
||||||
return AuthCredentials(), CheckUser(fake_user_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(400, 'user_id parameter not an uuid')
|
|
||||||
|
|
||||||
if token:
|
if token:
|
||||||
return AuthCredentials(), CheckUser(payload['user_id'])
|
return AuthCredentials(), CheckUser(payload['user_id'])
|
||||||
|
|
||||||
|
@ -89,6 +102,8 @@ class JWTAuthenticationBackend(AuthenticationBackend):
|
||||||
raise AuthenticationError(
|
raise AuthenticationError(
|
||||||
'Trying to connect using *DEBUG* token in *PRODUCTION* mode')
|
'Trying to connect using *DEBUG* token in *PRODUCTION* mode')
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
return AuthCredentials(), Nobody()
|
||||||
except jwt.InvalidTokenError as exc:
|
except jwt.InvalidTokenError as exc:
|
||||||
raise AuthenticationError(str(exc)) from exc
|
raise AuthenticationError(str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
@ -56,10 +56,8 @@ def parse_query(q_string: str = ""):
|
||||||
>>> parse_query('limit:10')
|
>>> parse_query('limit:10')
|
||||||
<function parse_query.<locals>.select at 0x...>
|
<function parse_query.<locals>.select at 0x...>
|
||||||
|
|
||||||
>>> parse_query('limit=10')
|
# >>> parse_query('limit=10')
|
||||||
Traceback (most recent call last):
|
# starlette.exceptions.HTTPException: 400
|
||||||
...
|
|
||||||
starlette.exceptions.HTTPException: 400
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -13,6 +13,7 @@ import os
|
||||||
import importlib
|
import importlib
|
||||||
from typing import Dict, Coroutine, List
|
from typing import Dict, Coroutine, List
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
import yaml
|
||||||
|
|
||||||
from starlette.schemas import SchemaGenerator
|
from starlette.schemas import SchemaGenerator
|
||||||
|
|
||||||
|
@ -27,8 +28,12 @@ SCHEMAS = SchemaGenerator(
|
||||||
|
|
||||||
async def schema_json(request, *args, **kwargs):
|
async def schema_json(request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
description: Returns the current API routes description (OpenAPI v3)
|
description: |
|
||||||
|
Returns the current API routes description (OpenAPI v3)
|
||||||
as a JSON object
|
as a JSON object
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: API Schema in OpenAPI v3 format
|
||||||
"""
|
"""
|
||||||
return ORJSONResponse(
|
return ORJSONResponse(
|
||||||
SCHEMAS.get_schema(routes=request.app.routes))
|
SCHEMAS.get_schema(routes=request.app.routes))
|
||||||
|
@ -110,3 +115,23 @@ def schema_csv_dict(csv: List[str], prefix='/') -> Dict:
|
||||||
})
|
})
|
||||||
|
|
||||||
return schema_d
|
return schema_d
|
||||||
|
|
||||||
|
def param_docstring_default(name, type):
|
||||||
|
""" Returns a default docstring in OpenAPI format for a path parameter
|
||||||
|
"""
|
||||||
|
type_map = {
|
||||||
|
'str': 'string',
|
||||||
|
'uuid': 'string',
|
||||||
|
'path': 'string',
|
||||||
|
'int': 'number',
|
||||||
|
'float': 'number'
|
||||||
|
}
|
||||||
|
return yaml.dump({
|
||||||
|
'name': name,
|
||||||
|
'in': 'path',
|
||||||
|
'description': f'default description for path parameter {name}',
|
||||||
|
'required': True,
|
||||||
|
'schema': {
|
||||||
|
'type': type_map[type]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
default_level = logging.DEBUG
|
||||||
|
default_format = '%(asctime)s [%(process)d] [%(levelname)s] %(message)s'
|
||||||
|
default_datefmt = '[%Y-%m-%d %H:%M:%S %z]'
|
||||||
|
|
||||||
def config_logging(level=logging.INFO):
|
def config_logging(level=default_level, format=default_format, datefmt=default_datefmt):
|
||||||
|
|
||||||
# When run by 'uvicorn ...', a root handler is already
|
# When run by 'uvicorn ...', a root handler is already
|
||||||
# configured and the basicConfig below does nothing.
|
# configured and the basicConfig below does nothing.
|
||||||
# To get the desired formatting:
|
# To get the desired formatting:
|
||||||
|
@ -12,8 +14,8 @@ def config_logging(level=logging.INFO):
|
||||||
# https://github.com/encode/uvicorn/issues/511
|
# https://github.com/encode/uvicorn/issues/511
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
# match gunicorn format
|
# match gunicorn format
|
||||||
format='%(asctime)s [%(process)d] [%(levelname)s] %(message)s',
|
format=format,
|
||||||
datefmt='[%Y-%m-%d %H:%M:%S %z]',
|
datefmt=datefmt,
|
||||||
level=level)
|
level=level)
|
||||||
|
|
||||||
# When run by 'gunicorn -k uvicorn.workers.UvicornWorker ...',
|
# When run by 'gunicorn -k uvicorn.workers.UvicornWorker ...',
|
||||||
|
@ -27,5 +29,4 @@ def config_logging(level=logging.INFO):
|
||||||
logging.getLogger('uvicorn.access').propagate = True
|
logging.getLogger('uvicorn.access').propagate = True
|
||||||
logging.getLogger('uvicorn.error').propagate = True
|
logging.getLogger('uvicorn.error').propagate = True
|
||||||
|
|
||||||
config_logging()
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
|
@ -4,12 +4,14 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
import toml
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
from ..cli.cli import cli
|
from ..cli.cli import cli
|
||||||
from ..halfapi import HalfAPI
|
from ..halfapi import HalfAPI
|
||||||
from ..half_domain import HalfDomain
|
from ..half_domain import HalfDomain
|
||||||
|
from ..conf import DEFAULT_CONF
|
||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
@ -61,6 +63,7 @@ class TestDomain(TestCase):
|
||||||
self.runner = class_(mix_stderr=False)
|
self.runner = class_(mix_stderr=False)
|
||||||
|
|
||||||
# HTTP
|
# HTTP
|
||||||
|
# Fake default values of default configuration
|
||||||
self.halfapi_conf = {
|
self.halfapi_conf = {
|
||||||
'secret': 'testsecret',
|
'secret': 'testsecret',
|
||||||
'production': False,
|
'production': False,
|
||||||
|
@ -79,7 +82,7 @@ class TestDomain(TestCase):
|
||||||
|
|
||||||
_, self.config_file = tempfile.mkstemp()
|
_, self.config_file = tempfile.mkstemp()
|
||||||
with open(self.config_file, 'w') as fh:
|
with open(self.config_file, 'w') as fh:
|
||||||
fh.write(json.dumps(self.halfapi_conf))
|
fh.write(toml.dumps(self.halfapi_conf))
|
||||||
|
|
||||||
self.halfapi = HalfAPI(self.halfapi_conf)
|
self.halfapi = HalfAPI(self.halfapi_conf)
|
||||||
|
|
||||||
|
@ -98,7 +101,7 @@ class TestDomain(TestCase):
|
||||||
try:
|
try:
|
||||||
result = self.runner.invoke(cli, '--version')
|
result = self.runner.invoke(cli, '--version')
|
||||||
self.assertEqual(result.exit_code, 0)
|
self.assertEqual(result.exit_code, 0)
|
||||||
result = self.runner.invoke(cli, ['domain', self.DOMAIN, self.config_file])
|
result = self.runner.invoke(cli, ['domain', '--read', self.DOMAIN, self.config_file])
|
||||||
self.assertEqual(result.exit_code, 0)
|
self.assertEqual(result.exit_code, 0)
|
||||||
result_d = json.loads(result.stdout)
|
result_d = json.loads(result.stdout)
|
||||||
result = self.runner.invoke(cli, ['run', '--help'])
|
result = self.runner.invoke(cli, ['run', '--help'])
|
||||||
|
@ -122,26 +125,28 @@ class TestDomain(TestCase):
|
||||||
def check_routes(self):
|
def check_routes(self):
|
||||||
r = self.client.request('get', '/')
|
r = self.client.request('get', '/')
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
schemas = r.json()
|
schema = r.json()
|
||||||
assert isinstance(schemas, list)
|
|
||||||
for schema in schemas:
|
|
||||||
assert isinstance(schema, dict)
|
assert isinstance(schema, dict)
|
||||||
assert 'openapi' in schema
|
assert 'openapi' in schema
|
||||||
assert 'info' in schema
|
assert 'info' in schema
|
||||||
assert 'paths' in schema
|
assert 'paths' in schema
|
||||||
assert 'domain' in schema
|
|
||||||
|
|
||||||
r = self.client.request('get', '/halfapi/acls')
|
r = self.client.request('get', '/halfapi/acls')
|
||||||
"""
|
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
d_r = r.json()
|
d_r = r.json()
|
||||||
assert isinstance(d_r, dict)
|
assert isinstance(d_r, dict)
|
||||||
|
|
||||||
assert self.domain_name in d_r.keys()
|
assert self.domain_name in d_r.keys()
|
||||||
|
|
||||||
ACLS = HalfDomain.acls(self.module, self.acl_path)
|
ACLS = HalfDomain.acls(self.module, self.acl_path)
|
||||||
assert len(ACLS) == len(d_r[self.domain_name])
|
assert len(ACLS) == len(d_r[self.domain_name])
|
||||||
|
|
||||||
for acl_name in ACLS:
|
for acl_rule in ACLS:
|
||||||
assert acl_name[0] in d_r[self.domain_name]
|
assert len(acl_rule.name) > 0
|
||||||
"""
|
assert acl_rule.name in d_r[self.domain_name]
|
||||||
|
assert len(acl_rule.documentation) > 0
|
||||||
|
assert isinstance(acl_rule.priority, int)
|
||||||
|
assert acl_rule.priority >= 0
|
||||||
|
|
||||||
|
if acl_rule.public is True:
|
||||||
|
r = self.client.request('get', f'/halfapi/acls/{acl_rule.name}')
|
||||||
|
assert r.status_code in [200, 401]
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
[pytest]
|
[pytest]
|
||||||
testpaths = tests
|
testpaths = tests halfapi
|
||||||
addopts = --doctest-modules
|
addopts = --doctest-modules
|
||||||
doctest_optionflags = ELLIPSIS
|
doctest_optionflags = ELLIPSIS IGNORE_EXCEPTION_DETAIL
|
||||||
pythonpath = ./tests
|
pythonpath = ./tests
|
||||||
|
|
7
setup.py
7
setup.py
|
@ -44,7 +44,7 @@ setup(
|
||||||
python_requires=">=3.8",
|
python_requires=">=3.8",
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"PyJWT>=2.6.0,<2.7.0",
|
"PyJWT>=2.6.0,<2.7.0",
|
||||||
"starlette>=0.23,<0.24",
|
"starlette>=0.33,<0.34",
|
||||||
"click>=8,<9",
|
"click>=8,<9",
|
||||||
"uvicorn>=0.13,<1",
|
"uvicorn>=0.13,<1",
|
||||||
"orjson>=3.8.5,<4",
|
"orjson>=3.8.5,<4",
|
||||||
|
@ -71,7 +71,10 @@ setup(
|
||||||
"pytest-asyncio",
|
"pytest-asyncio",
|
||||||
"pylint",
|
"pylint",
|
||||||
"requests",
|
"requests",
|
||||||
"httpx"
|
"httpx",
|
||||||
|
"openapi-schema-validator",
|
||||||
|
"openapi-spec-validator",
|
||||||
|
"coverage"
|
||||||
],
|
],
|
||||||
"pyexcel":[
|
"pyexcel":[
|
||||||
"pyexcel",
|
"pyexcel",
|
||||||
|
|
|
@ -5,10 +5,12 @@ import importlib
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import json
|
import json
|
||||||
|
import toml
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
|
from halfapi.conf import DEFAULT_CONF, PROJECT_LEVEL_KEYS, DOMAIN_LEVEL_KEYS
|
||||||
|
|
||||||
PROJNAME = os.environ.get('PROJ','tmp_api')
|
PROJNAME = os.environ.get('PROJ','tmp_api')
|
||||||
|
|
||||||
|
@ -23,23 +25,51 @@ class TestCliProj():
|
||||||
def test_domain_commands(self, project_runner):
|
def test_domain_commands(self, project_runner):
|
||||||
""" TODO: Test create command
|
""" TODO: Test create command
|
||||||
"""
|
"""
|
||||||
|
test_conf = {
|
||||||
|
'project': {
|
||||||
|
'port': '3010',
|
||||||
|
'loglevel': 'warning'
|
||||||
|
},
|
||||||
|
'domain': {
|
||||||
|
'dummy_domain': {
|
||||||
|
'port': 4242,
|
||||||
|
'name': 'dummy_domain',
|
||||||
|
'enabled': True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
r = project_runner('domain')
|
r = project_runner('domain')
|
||||||
print(r.stdout)
|
print(r.stdout)
|
||||||
assert r.exit_code == 1
|
assert r.exit_code == 1
|
||||||
_, tmp_conf = tempfile.mkstemp()
|
_, tmp_conf = tempfile.mkstemp()
|
||||||
with open(tmp_conf, 'w') as fh:
|
with open(tmp_conf, 'w') as fh:
|
||||||
fh.write(
|
fh.write(
|
||||||
json.dumps({
|
toml.dumps(test_conf)
|
||||||
'domain': {
|
|
||||||
'dummy_domain': {
|
|
||||||
'name': 'dummy_domain',
|
|
||||||
'enabled': True
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
r = project_runner(f'domain dummy_domain {tmp_conf}')
|
r = project_runner(f'domain dummy_domain --conftest {tmp_conf}')
|
||||||
|
assert r.exit_code == 0
|
||||||
|
r_conf = toml.loads(r.stdout)
|
||||||
|
for key, value in r_conf.items():
|
||||||
|
if key == 'domain':
|
||||||
|
continue
|
||||||
|
assert key in PROJECT_LEVEL_KEYS
|
||||||
|
if key == 'port':
|
||||||
|
assert value == test_conf['domain']['dummy_domain']['port']
|
||||||
|
elif key == 'loglevel':
|
||||||
|
assert value == test_conf['project']['loglevel']
|
||||||
|
else:
|
||||||
|
assert value == DEFAULT_CONF[key.upper()]
|
||||||
|
|
||||||
|
|
||||||
|
assert json.dumps(test_conf['domain']) == json.dumps(r_conf['domain'])
|
||||||
|
|
||||||
|
for key in test_conf['domain']['dummy_domain'].keys():
|
||||||
|
assert key in DOMAIN_LEVEL_KEYS
|
||||||
|
|
||||||
|
# Default command "run"
|
||||||
|
r = project_runner(f'domain dummy_domain --dry-run {tmp_conf}')
|
||||||
print(r.stdout)
|
print(r.stdout)
|
||||||
assert r.exit_code == 0
|
assert r.exit_code == 0
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,22 @@ domain = {
|
||||||
'id': '8b88e60a625369235b36c2d6d70756a0c02c1c7fb169fcee6dc820bcf9723f5a',
|
'id': '8b88e60a625369235b36c2d6d70756a0c02c1c7fb169fcee6dc820bcf9723f5a',
|
||||||
'deps': (
|
'deps': (
|
||||||
('halfapi', '=={}'.format(halfapi_version)),
|
('halfapi', '=={}'.format(halfapi_version)),
|
||||||
)
|
),
|
||||||
|
'schema_components': {
|
||||||
|
'schemas': {
|
||||||
|
'Pinnochio': {
|
||||||
|
'type': 'object',
|
||||||
|
'required': {
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'nose_size'
|
||||||
|
},
|
||||||
|
'properties': {
|
||||||
|
'id': {'type': 'string'},
|
||||||
|
'name': {'type': 'string'},
|
||||||
|
'nose_size': {'type': 'number'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from halfapi.lib import acl
|
from halfapi.lib import acl
|
||||||
from halfapi.lib.acl import public, private
|
from halfapi.lib.acl import public, private, ACLS
|
||||||
from random import randint
|
from random import randint
|
||||||
|
|
||||||
def random(*args):
|
def random(*args):
|
||||||
|
@ -8,7 +8,6 @@ def random(*args):
|
||||||
return randint(0,1) == 1
|
return randint(0,1) == 1
|
||||||
|
|
||||||
ACLS = (
|
ACLS = (
|
||||||
('public', public.__doc__, 999),
|
*ACLS,
|
||||||
('random', random.__doc__, 10),
|
('random', random.__doc__, 10)
|
||||||
('private', private.__doc__, 0)
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -12,6 +12,9 @@ async def get(test):
|
||||||
"""
|
"""
|
||||||
description:
|
description:
|
||||||
returns the path parameter
|
returns the path parameter
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
return ORJSONResponse(str(test))
|
return ORJSONResponse(str(test))
|
||||||
|
|
||||||
|
@ -19,6 +22,9 @@ def post(test):
|
||||||
"""
|
"""
|
||||||
description:
|
description:
|
||||||
returns the path parameter
|
returns the path parameter
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
return str(test)
|
return str(test)
|
||||||
|
|
||||||
|
@ -26,6 +32,9 @@ def patch(test):
|
||||||
"""
|
"""
|
||||||
description:
|
description:
|
||||||
returns the path parameter
|
returns the path parameter
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
return str(test)
|
return str(test)
|
||||||
|
|
||||||
|
@ -33,6 +42,9 @@ def put(test):
|
||||||
"""
|
"""
|
||||||
description:
|
description:
|
||||||
returns the path parameter
|
returns the path parameter
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
return str(test)
|
return str(test)
|
||||||
|
|
||||||
|
@ -40,5 +52,8 @@ def delete(test):
|
||||||
"""
|
"""
|
||||||
description:
|
description:
|
||||||
returns the path parameter
|
returns the path parameter
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
return str(test)
|
return str(test)
|
||||||
|
|
|
@ -1,10 +1,21 @@
|
||||||
|
from uuid import uuid4
|
||||||
from halfapi.lib import acl
|
from halfapi.lib import acl
|
||||||
ACLS = {
|
ACLS = {
|
||||||
'GET' : [{'acl':acl.public}]
|
'GET' : [{'acl':acl.public}]
|
||||||
}
|
}
|
||||||
def get():
|
def get():
|
||||||
"""
|
"""
|
||||||
description:
|
description: The pinnochio guy
|
||||||
Not implemented
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Pinnochio"
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
return {
|
||||||
|
'id': str(uuid4()),
|
||||||
|
'name': 'pinnochio',
|
||||||
|
'nose_size': 42
|
||||||
|
}
|
||||||
|
|
|
@ -47,7 +47,8 @@ ACLS = {
|
||||||
'foo', 'baz'
|
'foo', 'baz'
|
||||||
},
|
},
|
||||||
'optional': {
|
'optional': {
|
||||||
'truebidoo'
|
'truebidoo',
|
||||||
|
'z'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -59,6 +60,9 @@ def get(data):
|
||||||
"""
|
"""
|
||||||
description:
|
description:
|
||||||
returns the arguments passed in
|
returns the arguments passed in
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
logger.error('%s', data['foo'])
|
logger.error('%s', data['foo'])
|
||||||
return data
|
return data
|
||||||
|
@ -67,6 +71,9 @@ def post(data):
|
||||||
"""
|
"""
|
||||||
description:
|
description:
|
||||||
returns the arguments passed in
|
returns the arguments passed in
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
logger.error('%s', data)
|
logger.error('%s', data)
|
||||||
return data
|
return data
|
||||||
|
|
|
@ -25,24 +25,43 @@ ROUTES = {
|
||||||
async def get_abc_alphabet_TEST(request, *args, **kwargs):
|
async def get_abc_alphabet_TEST(request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
description: Not implemented
|
description: Not implemented
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
|
parameters:
|
||||||
|
- name: test
|
||||||
|
in: path
|
||||||
|
description: Test parameter in route with "ROUTES" constant
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
"""
|
"""
|
||||||
return NotImplementedResponse()
|
return NotImplementedResponse()
|
||||||
|
|
||||||
async def get_abc_pinnochio(request, *args, **kwargs):
|
async def get_abc_pinnochio(request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
description: Not implemented
|
description: Not implemented
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
return NotImplementedResponse()
|
return NotImplementedResponse()
|
||||||
|
|
||||||
async def get_config(request, *args, **kwargs):
|
async def get_config(request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
description: Not implemented
|
description: Not implemented
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
return NotImplementedResponse()
|
return NotImplementedResponse()
|
||||||
|
|
||||||
async def get_arguments(request, *args, **kwargs):
|
async def get_arguments(request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
description: Liste des datatypes.
|
description: Liste des datatypes.
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
return ORJSONResponse({
|
return ORJSONResponse({
|
||||||
'foo': kwargs.get('data').get('foo'),
|
'foo': kwargs.get('data').get('foo'),
|
||||||
|
|
|
@ -12,6 +12,9 @@ def get(halfapi):
|
||||||
"""
|
"""
|
||||||
description:
|
description:
|
||||||
returns the configuration of the domain
|
returns the configuration of the domain
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: test response
|
||||||
"""
|
"""
|
||||||
logger.error('%s', halfapi)
|
logger.error('%s', halfapi)
|
||||||
# TODO: Remove in 0.7.0
|
# TODO: Remove in 0.7.0
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
param_docstring = """
|
||||||
|
name: second
|
||||||
|
in: path
|
||||||
|
description: second parameter description test
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"""
|
|
@ -0,0 +1,20 @@
|
||||||
|
from uuid import UUID
|
||||||
|
from halfapi.lib import acl
|
||||||
|
ACLS = {
|
||||||
|
'GET': [{'acl': acl.public}]
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(first, second, third):
|
||||||
|
"""
|
||||||
|
description: a Test route for path parameters
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: The test passed!
|
||||||
|
500:
|
||||||
|
description: The test did not pass :(
|
||||||
|
"""
|
||||||
|
assert isintance(first, str)
|
||||||
|
assert isintance(second, UUID)
|
||||||
|
assert isintance(third, int)
|
||||||
|
|
||||||
|
return ''
|
|
@ -7,6 +7,10 @@ import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import pprint
|
import pprint
|
||||||
|
import openapi_spec_validator
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger()
|
||||||
|
|
||||||
from halfapi.lib.constants import API_SCHEMA
|
from halfapi.lib.constants import API_SCHEMA
|
||||||
|
|
||||||
|
|
||||||
|
@ -58,8 +62,6 @@ def test_schema(application_debug):
|
||||||
c = TestClient(application_debug)
|
c = TestClient(application_debug)
|
||||||
|
|
||||||
r = c.request('get', '/')
|
r = c.request('get', '/')
|
||||||
schemas = r.json()
|
schema = r.json()
|
||||||
assert isinstance(schemas, list)
|
|
||||||
for schema in schemas:
|
|
||||||
assert isinstance(schema, dict)
|
assert isinstance(schema, dict)
|
||||||
assert API_SCHEMA.validate(schema)
|
openapi_spec_validator.validate_spec(schema)
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
from halfapi.testing.test_domain import TestDomain
|
from halfapi.testing.test_domain import TestDomain
|
||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger()
|
||||||
|
|
||||||
class TestDummyDomain(TestDomain):
|
class TestDummyDomain(TestDomain):
|
||||||
from .dummy_domain import domain
|
from .dummy_domain import domain
|
||||||
|
@ -32,7 +34,7 @@ class TestDummyDomain(TestDomain):
|
||||||
assert isinstance(res.content.decode(), str)
|
assert isinstance(res.content.decode(), str)
|
||||||
assert res.headers['content-type'].split(';')[0] == 'text/html'
|
assert res.headers['content-type'].split(';')[0] == 'text/html'
|
||||||
|
|
||||||
res = self.client.request('post', '/ret_type/h24/config', data={
|
res = self.client.request('post', '/ret_type/h24/config', json={
|
||||||
'trou': 'glet'
|
'trou': 'glet'
|
||||||
})
|
})
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
@ -53,24 +55,52 @@ class TestDummyDomain(TestDomain):
|
||||||
|
|
||||||
def test_arguments_post_routes(self):
|
def test_arguments_post_routes(self):
|
||||||
arg_dict = {}
|
arg_dict = {}
|
||||||
res = self.client.request('post', '/arguments', data=arg_dict)
|
res = self.client.request('post', '/arguments', json=arg_dict)
|
||||||
|
|
||||||
assert res.status_code == 400
|
assert res.status_code == 400
|
||||||
|
|
||||||
arg_dict = {'foo': '1', 'bar': '3'}
|
arg_dict = {'foo': '1', 'bar': '3'}
|
||||||
res = self.client.request('post', '/arguments', data=arg_dict)
|
res = self.client.request('post', '/arguments', json=arg_dict)
|
||||||
|
|
||||||
assert res.status_code == 400
|
assert res.status_code == 400
|
||||||
|
|
||||||
arg_dict = {'foo': '1', 'baz': '3'}
|
arg_dict = {'foo': '1', 'baz': '3'}
|
||||||
res = self.client.request('post', '/arguments', data=arg_dict)
|
res = self.client.request('post', '/arguments', json=arg_dict)
|
||||||
|
|
||||||
assert res.json() == arg_dict
|
assert res.json() == arg_dict
|
||||||
|
|
||||||
arg_dict = {'foo': '1', 'baz': '3', 'truebidoo': '4'}
|
arg_dict = {'foo': '1', 'baz': '3', 'truebidoo': '4'}
|
||||||
res = self.client.request('post', '/arguments', data=arg_dict)
|
res = self.client.request('post', '/arguments', json=arg_dict)
|
||||||
|
|
||||||
assert res.json() == arg_dict
|
assert res.json() == arg_dict
|
||||||
|
|
||||||
res = self.client.request('post', '/arguments', data={ **arg_dict, 'y': '4'})
|
res = self.client.request('post', '/arguments', json={ **arg_dict, 'y': '4'})
|
||||||
assert res.json() == arg_dict
|
assert res.json() == arg_dict
|
||||||
|
|
||||||
|
res = self.client.request('post', '/arguments', json={ **arg_dict, 'z': True})
|
||||||
|
assert res.json() == {**arg_dict, 'z': True}
|
||||||
|
|
||||||
|
def test_schema_path_params(self):
|
||||||
|
res = self.client.request('get', '/halfapi/schema')
|
||||||
|
schema = res.json()
|
||||||
|
|
||||||
|
logger.debug(schema)
|
||||||
|
|
||||||
|
assert len(schema['paths']) > 0
|
||||||
|
route = schema['paths']['/path_params/{first}/one/{second}/two/{third}']
|
||||||
|
|
||||||
|
assert 'parameters' in route['get']
|
||||||
|
parameters = route['get']['parameters']
|
||||||
|
|
||||||
|
assert len(parameters) == 3
|
||||||
|
|
||||||
|
param_map = {
|
||||||
|
elt['name']: elt
|
||||||
|
for elt in parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
assert param_map['second']['description'] == 'second parameter description test'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import importlib
|
import importlib
|
||||||
|
from halfapi.testing.test_domain import TestDomain
|
||||||
|
|
||||||
def test_dummy_domain():
|
def test_dummy_domain():
|
||||||
from . import dummy_domain
|
from . import dummy_domain
|
||||||
|
|
|
@ -50,6 +50,12 @@ def test_jwt_Token(dummy_app, token_builder):
|
||||||
dummy_app.add_route('/test', test_route)
|
dummy_app.add_route('/test', test_route)
|
||||||
test_client = TestClient(dummy_app)
|
test_client = TestClient(dummy_app)
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
cookies={
|
||||||
|
'Authorization': token_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
resp = test_client.request('get', '/test',
|
resp = test_client.request('get', '/test',
|
||||||
headers={
|
headers={
|
||||||
'Authorization': token_builder
|
'Authorization': token_builder
|
||||||
|
@ -57,6 +63,7 @@ def test_jwt_Token(dummy_app, token_builder):
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_jwt_DebugFalse(dummy_app, token_debug_false_builder):
|
def test_jwt_DebugFalse(dummy_app, token_debug_false_builder):
|
||||||
async def test_route(request):
|
async def test_route(request):
|
||||||
assert isinstance(request.user, JWTUser)
|
assert isinstance(request.user, JWTUser)
|
||||||
|
@ -65,6 +72,12 @@ def test_jwt_DebugFalse(dummy_app, token_debug_false_builder):
|
||||||
dummy_app.add_route('/test', test_route)
|
dummy_app.add_route('/test', test_route)
|
||||||
test_client = TestClient(dummy_app)
|
test_client = TestClient(dummy_app)
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
cookies={
|
||||||
|
'Authorization': token_debug_false_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
resp = test_client.request('get', '/test',
|
resp = test_client.request('get', '/test',
|
||||||
headers={
|
headers={
|
||||||
'Authorization': token_debug_false_builder
|
'Authorization': token_debug_false_builder
|
||||||
|
@ -82,6 +95,12 @@ def test_jwt_DebugTrue(dummy_app, token_debug_true_builder):
|
||||||
dummy_app.add_route('/test', test_route)
|
dummy_app.add_route('/test', test_route)
|
||||||
test_client = TestClient(dummy_app)
|
test_client = TestClient(dummy_app)
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
cookies={
|
||||||
|
'Authorization': token_debug_true_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
resp = test_client.request('get', '/test',
|
resp = test_client.request('get', '/test',
|
||||||
headers={
|
headers={
|
||||||
'Authorization': token_debug_true_builder
|
'Authorization': token_debug_true_builder
|
||||||
|
@ -100,6 +119,12 @@ def test_jwt_DebugTrue_DebugApp(dummy_debug_app, token_debug_true_builder):
|
||||||
dummy_debug_app.add_route('/test', test_route)
|
dummy_debug_app.add_route('/test', test_route)
|
||||||
test_client = TestClient(dummy_debug_app)
|
test_client = TestClient(dummy_debug_app)
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
cookies={
|
||||||
|
'Authorization': token_debug_true_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
resp = test_client.request('get', '/test',
|
resp = test_client.request('get', '/test',
|
||||||
headers={
|
headers={
|
||||||
'Authorization': token_debug_true_builder
|
'Authorization': token_debug_true_builder
|
||||||
|
|
|
@ -49,3 +49,76 @@
|
||||||
#
|
#
|
||||||
# assert isinstance(res, list)
|
# assert isinstance(res, list)
|
||||||
# assert len(res) > 0
|
# assert len(res) > 0
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from starlette.responses import Response
|
||||||
|
from starlette.routing import Router, Route
|
||||||
|
|
||||||
|
from halfapi.lib.domain import route_decorator
|
||||||
|
from halfapi.lib.user import Nobody
|
||||||
|
|
||||||
|
def test_route_decorator():
|
||||||
|
""" It should decorate an async function that fullfills its arguments
|
||||||
|
"""
|
||||||
|
def route(halfapi, data, out, ret_type='txt'):
|
||||||
|
for key in ['user', 'config', 'domain', 'cookies', 'base_url', 'url']:
|
||||||
|
assert key in halfapi
|
||||||
|
|
||||||
|
assert halfapi['user'] is None
|
||||||
|
assert isinstance(halfapi['config'], dict)
|
||||||
|
assert len(halfapi['config']) == 0
|
||||||
|
assert isinstance(halfapi['domain'], str)
|
||||||
|
assert halfapi['domain'] == 'unknown'
|
||||||
|
assert isinstance(halfapi['cookies'], dict)
|
||||||
|
assert len(halfapi['cookies']) == 0
|
||||||
|
assert len(str(halfapi['base_url'])) > 0
|
||||||
|
assert str(halfapi['base_url']) == 'http://testserver/'
|
||||||
|
assert len(str(halfapi['url'])) > 0
|
||||||
|
assert str(halfapi['url']) == 'http://testserver/'
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert len(data) == 0
|
||||||
|
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
assert ret_type is 'txt'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
async_route = route_decorator(route)
|
||||||
|
app = Router([Route('/', endpoint=async_route, methods=['GET'])])
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get('/')
|
||||||
|
assert response.is_success
|
||||||
|
assert response.content.decode() == ''
|
||||||
|
|
||||||
|
def route(data, out, ret_type='txt'):
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert len(data) == 0
|
||||||
|
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
assert ret_type is 'txt'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
async_route = route_decorator(route)
|
||||||
|
app = Router([Route('/', endpoint=async_route, methods=['GET'])])
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get('/')
|
||||||
|
assert response.is_success
|
||||||
|
assert response.content.decode() == ''
|
||||||
|
|
||||||
|
def route(data):
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data['toto'] == 'tata'
|
||||||
|
assert data['bouboul'] == True
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
async_route = route_decorator(route)
|
||||||
|
app = Router([Route('/', endpoint=async_route, methods=['POST'])])
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post('/', json={'toto': 'tata', 'bouboul': True})
|
||||||
|
assert response.is_success
|
||||||
|
assert response.json() == ''
|
||||||
|
|
|
@ -21,8 +21,8 @@ def test_call(application_debug):
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.headers['x-domain'] == 'dummy_domain'
|
assert r.headers['x-domain'] == 'dummy_domain'
|
||||||
assert r.headers['x-acl'] == 'public'
|
assert r.headers['x-acl'] == 'public'
|
||||||
assert 'foo' in r.headers['x-args-required']
|
assert 'foo' in r.headers['x-args-required'].split(',')
|
||||||
assert 'bar' in r.headers['x-args-required']
|
assert 'bar' in r.headers['x-args-required'].split(',')
|
||||||
assert r.headers['x-args-optional'] == 'x'
|
assert r.headers['x-args-optional'] == 'x'
|
||||||
|
|
||||||
c = TestClient(application_debug)
|
c = TestClient(application_debug)
|
||||||
|
@ -30,8 +30,9 @@ def test_call(application_debug):
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.headers['x-domain'] == 'dummy_domain'
|
assert r.headers['x-domain'] == 'dummy_domain'
|
||||||
assert r.headers['x-acl'] == 'public'
|
assert r.headers['x-acl'] == 'public'
|
||||||
assert 'foo' in r.headers['x-args-required']
|
assert 'foo' in r.headers['x-args-required'].split(',')
|
||||||
assert 'baz' in r.headers['x-args-required']
|
assert 'baz' in r.headers['x-args-required'].split(',')
|
||||||
assert r.headers['x-args-optional'] == 'truebidoo'
|
assert 'truebidoo' in r.headers['x-args-optional'].split(',')
|
||||||
|
assert 'z' in r.headers['x-args-optional'].split(',')
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue