Compare commits

..

No commits in common. "master" and "23-dry-run-mode" have entirely different histories.

52 changed files with 1095 additions and 2540 deletions

View File

@ -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:alpine3.18 image: python:3.9-bullseye
# 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,36 +33,32 @@ 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
- python3 -m venv /tmp/venv - pip3 install pipenv
- /tmp/venv/bin/pip3 install .["tests","pyexcel"] - pipenv install --dev --skip-lock
- /tmp/venv/bin/pip3 install coverage pytest
test: test:
image: python:alpine${ALPINEVERSION} image: python:${PYVERSION}-bullseye
stage: test stage: test
<<: *test <<: *test
parallel: parallel:
matrix: matrix:
- ALPINEVERSION: ["3.16", "3.17", "3.18", "3.19"] - PYVERSION: ["3.7", "3.8", "3.9", "3.10"]
script: script:
- /tmp/venv/bin/pytest --version - pipenv run pytest --version
- PYTHONPATH=./tests/ /tmp/venv/bin/coverage run --source halfapi -m pytest - PYTHONPATH=./tests/ pipenv run pytest -v ./tests
- /tmp/venv/bin/coverage xml - pipenv run halfapi --version
- /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
- python3 -m venv /tmp/venv - pip3 install pipenv
- /tmp/venv/bin/pip3 install . - pipenv install --dev --skip-lock
- pipenv run python -m build --sdist
- pipenv run python -m build --wheel
artifacts: artifacts:
paths: paths:
- dist/*.whl - dist/*.whl

View File

@ -1,162 +1,5 @@
# 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
Dependency update version
- starlette v0.23
- orjson v3.8.5
- click v8
- pyJWT v2.6
- pyYAML v6
- toml v0.10
## 0.6.22
- IMPORTANT : Fix bug introduced with 0.6.20 (fix arguments handling)
- BREAKING : A domain should now include it's meta datas in a "domain" dictionary located in the __init__.py file of the domain's root. Consider looking in 'tests/dummy_domain/__init__.py'
- Add *html* return type as default argument ret_type
- Add *txt* return type
- Log unhandled exceptions
- Log HTTPException with statuscode 500 as critical
- PyJWT >=2.4.0,<2.5.0
## 0.6.21
- Store only domain's config in halfapi['config']
- Should run halfapi domain with config_file argument
- Testing : You can specify a "MODULE" attribute to point out the path to the Api's base module
- Testing : You can specify a "CONFIG" attribute to set the domain's testing configuration
- Environment : HALFAPI_DOMAIN_MODULE can be set to specify Api's base module
- Config : 'module' attribute can be set to specify Api's base module
## 0.6.20
- Fix arguments handling
## 0.6.19 ## 0.6.19
- Allow file sending in multipart request (#32) - Allow file sending in multipart request (#32)

View File

@ -1,11 +1,9 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM python:alpine3.19 FROM docker.io/python:3.8.12-slim-bullseye
COPY . /halfapi COPY . /halfapi
WORKDIR /halfapi WORKDIR /halfapi
ENV VENV_DIR=/opt/venv RUN apt-get update > /dev/null && apt-get -y install git > /dev/null
RUN mkdir -p $VENV_DIR RUN pip install gunicorn uvicorn
RUN python -m venv $VENV_DIR RUN pip install .
RUN $VENV_DIR/bin/pip install gunicorn uvicorn CMD gunicorn halfapi.app
RUN $VENV_DIR/bin/pip install .
RUN ln -s $VENV_DIR/bin/halfapi /usr/local/bin/
CMD $VENV_DIR/bin/gunicorn halfapi.app

16
Pipfile
View File

@ -13,21 +13,17 @@ twine = "*"
pyflakes = "*" pyflakes = "*"
vulture = "*" vulture = "*"
virtualenv = "*" virtualenv = "*"
httpx = "*"
openapi-schema-validator = "*"
openapi-spec-validator = "*"
coverage = "*"
[packages] [packages]
click = ">=8,<9" click = ">=7.1,<8"
starlette = ">=0.37,<0.38" starlette = ">=0.17,<0.18"
uvicorn = ">=0.13,<1" uvicorn = ">=0.13,<1"
orjson = ">=3.8.5,<4" orjson = ">=3.4.7,<4"
pyjwt = ">=2.6.0,<2.7.0" pyjwt = ">=2.3.0,<2.4.0"
pyyaml = ">=6.0.1,<7" pyyaml = ">=5.3.1,<6"
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 = "*"
pip = "*" pip = "*"
packaging = ">=19.0" packaging = ">=19.0"
python-multipart = "*" python-multipart = "*"

1814
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -23,15 +23,17 @@ 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 a **toml** file that contains at least two sections, project and domains. It's an **ini** 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.
@ -41,28 +43,12 @@ The main configuration options without which HalfAPI cannot be run.
### Domains ### Domains
Specify the domains configurations in the following form : The name of the options should be the name of the domains' module, the value is the
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
```
Specific configuration can be done under the "config" section : dummy_domain = .routers
```
[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
@ -76,17 +62,9 @@ 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.
``` ```
PYTHONPATH=$PWD/tests python -m halfapi domain dummy_domain python -m halfapi routes --export --noheader dummy_domain.routers | python -m halfapi run -
``` ```
### CLI documentation
Use the CLI help.
```
python -m halfapi --help
python -m halfapi domain --help
```
## API Testing ## API Testing

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
__version__ = '0.6.30' __version__ = '0.6.19'
def version(): def version():
return f'HalfAPI version:{__version__}' return f'HalfAPI version:{__version__}'

View File

@ -1,7 +1,9 @@
import os
from .halfapi import HalfAPI from .halfapi import HalfAPI
from .conf import CONFIG, SCHEMA
from .logging import logger from .logging import logger
def application(): logger.info('CONFIG: %s', CONFIG)
from .conf import CONFIG logger.info('SCHEMA: %s', SCHEMA)
return HalfAPI(CONFIG).application
application = HalfAPI(
CONFIG, SCHEMA or None).application

View File

@ -8,12 +8,8 @@ import sys
import importlib import importlib
import subprocess import subprocess
import json
import toml
import click import click
import orjson import orjson
import uvicorn
from .cli import cli from .cli import cli
@ -23,7 +19,6 @@ 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
@ -121,22 +116,13 @@ def list_api_routes():
# list_routes(domain, m_dom) # list_routes(domain, m_dom)
@click.option('--devel',default=None, is_flag=True) @click.option('--read',default=True, 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('domain',default=None, required=False) @click.argument('domain',default=None, required=False)
@cli.command() @cli.command()
def domain(domain, config_file, delete, update, create, conftest, read, run, dry_run, log_level, port, production, watch, devel): def domain(domain, delete, update, create, read): #, domains, read, create, update, delete):
""" """
The "halfapi domain" command The "halfapi domain" command
@ -153,90 +139,27 @@ def domain(domain, config_file, delete, update, create, conftest, read, run, dry
# 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:
if config_file:
ARG_CONFIG = toml.load(config_file.name)
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 raise NotImplementedError
elif update: if delete:
raise NotImplementedError raise NotImplementedError
elif delete: if read:
raise NotImplementedError from ..conf import CONFIG
elif read:
from ..halfapi import HalfAPI from ..halfapi import HalfAPI
try:
config_domain = CONFIG.pop('domain').get(domain, {})
except KeyError:
config_domain = {}
halfapi = HalfAPI(CONFIG) halfapi = HalfAPI(CONFIG)
half_domain = halfapi.add_domain(domain, config=config_domain)
click.echo(orjson.dumps( click.echo(orjson.dumps(
halfapi.domains[domain].schema(), half_domain.schema(),
option=orjson.OPT_NON_STR_KEYS, option=orjson.OPT_NON_STR_KEYS,
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)

View File

@ -36,7 +36,7 @@ It follows the following format :
""" """
from .logging import logger import logging
import os import os
from os import environ from os import environ
import sys import sys
@ -46,51 +46,21 @@ 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 = {}
DEFAULT_CONF = { CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api')
# 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(
BASE_DIR, '.halfapi', 'config') os.getcwd(), '.halfapi', 'config')
HALFAPI_CONFIG_FILES = [] HALFAPI_CONFIG_FILES = []
@ -98,61 +68,35 @@ 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.info('Cannot find a configuration file under %s', HALFAPI_ETC_FILE) logger.error('Cannot find a configuration file under %s', HALFAPI_DOT_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.info('Cannot find a configuration file under %s', HALFAPI_DOT_FILE) logger.error('Cannot find a configuration file under %s', HALFAPI_DOT_FILE)
ENVIRONMENT = {} def read_config():
# 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', filenames) logger.info('Reading config files %s', HALFAPI_CONFIG_FILES)
for CONF_FILE in filenames: for CONF_FILE in HALFAPI_CONFIG_FILES:
if os.path.isfile(CONF_FILE): d_res.update( toml.load(HALFAPI_CONFIG_FILES) )
conf_dict = toml.load(CONF_FILE)
d_res.update(conf_dict)
logger.info('Read config files (result) %s', d_res) logger.info('Reading 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',
os.environ.get('HALFAPI_PROJECT_NAME', DEFAULT_CONF['PROJECT_NAME'])) environ.get('HALFAPI_PROJECT_NAME', os.getcwd().split('/')[-1]))
if os.environ.get('HALFAPI_DOMAIN_NAME'): if environ.get('HALFAPI_DOMAIN_NAME'):
# Force enabled domain by environment variable DOMAIN_NAME = environ.get('HALFAPI_DOMAIN_NAME')
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]:
@ -170,36 +114,53 @@ if os.environ.get('HALFAPI_DOMAIN_NAME'):
CONFIG['domain'][DOMAIN_NAME]['config'] = domain_config CONFIG['domain'][DOMAIN_NAME]['config'] = domain_config
if os.environ.get('HALFAPI_DOMAIN_MODULE'): if environ.get('HALFAPI_DOMAIN_MODULE'):
# Specify the pythonpath to import the specified domain (defaults to global) dom_module = environ.get('HALFAPI_DOMAIN_MODULE')
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
if 'secret' not in CONFIG: SECRET = CONFIG.get(
'secret',
environ.get('HALFAPI_SECRET'))
if not SECRET:
# TODO: Create a temporary secret # TODO: Create a temporary secret
CONFIG['secret'] = DEFAULT_CONF['SECRET'] _, SECRET = tempfile.mkstemp()
with open(CONFIG['secret'], 'w') as secret_file: with open(SECRET, 'w') as secret_file:
secret_file.write(str(uuid.uuid4())) secret_file.write(str(uuid.uuid4()))
try: try:
with open(CONFIG['secret'], 'r') as secret_file: with open(SECRET, 'r') as secret_file:
CONFIG['secret'] = CONFIG['secret'].strip() CONFIG['secret'] = SECRET.strip()
except FileNotFoundError as exc: except FileNotFoundError as exc:
logger.warning('Running without secret file: %s', CONFIG['secret'] or 'no file specified') logger.info('Running without secret file: %s', SECRET or 'no file specified')
CONFIG.setdefault('project_name', DEFAULT_CONF['PROJECT_NAME']) PRODUCTION = bool(CONFIG.get(
CONFIG.setdefault('production', DEFAULT_CONF['PRODUCTION']) 'production',
CONFIG.setdefault('host', DEFAULT_CONF['HOST']) environ.get('HALFAPI_PROD', True)))
CONFIG.setdefault('port', DEFAULT_CONF['PORT'])
CONFIG.setdefault('loglevel', DEFAULT_CONF['LOGLEVEL'])
CONFIG.setdefault('dryrun', DEFAULT_CONF['DRYRUN'])
# !!!TO REMOVE!!! LOGLEVEL = CONFIG.get(
SECRET = CONFIG.get('secret') 'loglevel',
PRODUCTION = CONFIG.get('production') environ.get('HALFAPI_LOGLEVEL', 'info')).lower()
# !!!
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

View File

@ -11,12 +11,7 @@ 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.middleware import Middleware from starlette.routing import Router
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
@ -24,10 +19,8 @@ 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 as lib_acl from .lib import 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
@ -45,16 +38,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
self.d_domain = getattr(self.m_domain, 'domain', domain) self.name = getattr(self.m_domain, '__name__', domain)
self.name = self.d_domain['name'] self.id = getattr(self.m_domain, '__id__')
self.id = self.d_domain['id'] self.version = getattr(self.m_domain, '__version__', '0.0.0')
self.version = self.d_domain['version'] # TODO: Check if given domain halfapi_version matches with __version__
self.halfapi_version = self.d_domain.get('halfapi_version', __version__) self.halfapi_version = getattr(self.m_domain, '__halfapi_version__', __version__)
self.deps = self.d_domain.get('deps', tuple())
self.schema_components = self.d_domain.get('schema_components', dict()) self.deps = getattr(self.m_domain, '__deps__', tuple())
if not router: if not router:
self.router = self.d_domain.get('routers', '.routers') self.router = getattr(domain, '__router__', '.routers')
else: else:
self.router = router self.router = router
@ -84,26 +77,18 @@ class HalfDomain(Starlette):
super().__init__( super().__init__(
routes=self.gen_domain_routes(), routes=self.gen_domain_routes(),
middleware=[ middleware=[
Middleware( (DomainMiddleware, {
DomainMiddleware, 'domain': {
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
@ -120,14 +105,9 @@ class HalfDomain(Starlette):
""" """
m_acl = HalfDomain.m_acl(module, acl) m_acl = HalfDomain.m_acl(module, acl)
try: try:
return [ return getattr(m_acl, 'ACLS')
lib_acl.ACL(*elt) except AttributeError:
for elt in getattr(m_acl, 'ACLS') raise Exception(f'Missing acl.ACLS constant in module {m_acl.__package__}')
]
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):
@ -139,6 +119,7 @@ class HalfDomain(Starlette):
[acl_name]: { [acl_name]: {
callable: fct_reference, callable: fct_reference,
docs: fct_docstring, docs: fct_docstring,
result: fct_result
} }
} }
""" """
@ -151,84 +132,24 @@ class HalfDomain(Starlette):
m_acl = HalfDomain.m_acl(module, acl) m_acl = HalfDomain.m_acl(module, acl)
for elt in HalfDomain.acls(module, acl=acl): for acl_name, doc, order in HalfDomain.acls(
module,
fct = getattr(m_acl, elt.name) acl=acl):
fct = getattr(m_acl, acl_name)
d_res[elt.name] = { d_res[acl_name] = {
'callable': fct, 'callable': fct,
'docs': elt.documentation 'docs': doc,
'result': None
} }
return d_res return d_res
@staticmethod # def schema(self):
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], params: List[Dict]) -> Tuple[FunctionType, 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
@ -260,13 +181,6 @@ 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 ''))
@ -275,11 +189,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 lib_acl.args_check(fct), params return acl.args_check(fct), params
@staticmethod @staticmethod
def gen_router_routes(m_router, path: List[str], PATH_PARAMS={}) -> \ def gen_router_routes(m_router, path: List[str]) -> \
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)
@ -307,32 +221,17 @@ 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], PATH_PARAMS) *HalfDomain.gen_routes(m_router, verb, path, params[verb])
) )
for subroute in params.get('SUBROUTES', []): for subroute in params.get('SUBROUTES', []):
subroute_module = importlib.import_module(f'.{subroute}', m_router.__name__) #logger.debug('Processing subroute **%s** - %s', 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(
parameter_name, param_match.groups()[0].lower(),
parameter_type, param_match.groups()[1]))
)
)
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:
@ -340,19 +239,14 @@ class HalfDomain(Starlette):
try: try:
yield from HalfDomain.gen_router_routes( yield from HalfDomain.gen_router_routes(
subroute_module, importlib.import_module(f'.{subroute}', m_router.__name__),
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()
@ -424,8 +318,8 @@ class HalfDomain(Starlette):
Generator(HalfRoute) Generator(HalfRoute)
""" """
yield HalfRoute('/', yield HalfRoute('/',
self.schema_openapi(), JSONRoute([ self.schema() ]),
[{'acl': lib_acl.public}], [{'acl': acl.public}],
'GET' 'GET'
) )
@ -478,36 +372,3 @@ 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

View File

@ -84,8 +84,6 @@ class HalfRoute(Route):
logger.debug( logger.debug(
'Args for current route (%s)', param.get('args')) 'Args for current route (%s)', param.get('args'))
if 'out' in param:
req.scope['out'] = param['out']
if 'out' in param: if 'out' in param:
req.scope['out'] = param['out'].copy() req.scope['out'] = param['out'].copy()

View File

@ -9,7 +9,6 @@ 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
@ -20,7 +19,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 Router, Route, Mount from starlette.routing import 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
@ -33,10 +32,10 @@ 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, on_auth_error from .lib.jwt_middleware import JWTAuthenticationBackend
from .lib.responses import (ORJSONResponse, UnauthorizedResponse, from .lib.responses import (ORJSONResponse, UnauthorizedResponse,
NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse, NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse,
ServiceUnavailableResponse, gen_exception_route) ServiceUnavailableResponse)
from .lib.domain import NoDomainsException from .lib.domain import NoDomainsException
from .lib.routes import gen_schema_routes, JSONRoute from .lib.routes import gen_schema_routes, JSONRoute
from .lib.schemas import schema_json from .lib.schemas import schema_json
@ -45,24 +44,14 @@ from .half_domain import HalfDomain
from halfapi import __version__ from halfapi import __version__
class HalfAPI(Starlette): class HalfAPI(Starlette):
def __init__(self, def __init__(self, config,
config,
d_routes=None): d_routes=None):
# Set log level (defaults to debug) config_logging(logging.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
@ -76,15 +65,11 @@ class HalfAPI(Starlette):
Mount('/halfapi', routes=list(self.halfapi_routes())) Mount('/halfapi', routes=list(self.halfapi_routes()))
) )
logger.debug('Config: %s', self.config) logger.info('Config: %s', self.config)
logger.info('Active domains: %s',
domains = { list(filter(
key: elt lambda n: n.get('enabled', False),
for key, elt in self.config.get('domain', {}).items() self.config.get('domain', {}).values())))
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
@ -105,19 +90,20 @@ class HalfAPI(Starlette):
debug=not PRODUCTION, debug=not PRODUCTION,
routes=routes, routes=routes,
exception_handlers={ exception_handlers={
401: gen_exception_route(UnauthorizedResponse), 401: UnauthorizedResponse,
404: gen_exception_route(NotFoundResponse), 404: NotFoundResponse,
500: gen_exception_route(HalfAPI.exception), 500: InternalServerErrorResponse,
501: gen_exception_route(NotImplementedResponse), 501: NotImplementedResponse,
503: gen_exception_route(ServiceUnavailableResponse) 503: ServiceUnavailableResponse
} },
on_startup=startup_fcts
) )
schemas = [] schemas = []
self.__domains = {} self.__domains = {}
for key, domain in domains.items(): for key, domain in self.config.get('domain', {}).items():
if not isinstance(domain, dict): if not isinstance(domain, dict):
continue continue
@ -126,7 +112,7 @@ class HalfAPI(Starlette):
continue continue
if not domain.get('prefix', False): if not domain.get('prefix', False):
if len(domains.keys()) > 1: if len(self.config.get('domain').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:
@ -136,12 +122,12 @@ class HalfAPI(Starlette):
domain_key = domain.get('name', key) domain_key = domain.get('name', key)
add_domain_args = { self.add_domain(
**domain, domain_key,
'path': path domain.get('module'),
} domain.get('router'),
domain.get('acl'),
self.add_domain(**add_domain_args) path)
schemas.append(self.__domains[domain_key].schema()) schemas.append(self.__domains[domain_key].schema())
@ -151,11 +137,10 @@ 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 and TIMINGMIDDLEWARE: if not PRODUCTION:
self.add_middleware( self.add_middleware(
TimingMiddleware, TimingMiddleware,
client=HTimingClient(), client=HTimingClient(),
@ -163,24 +148,15 @@ class HalfAPI(Starlette):
starlette_app=self) starlette_app=self)
) )
@property @property
def version(self): def version(self):
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
async def exception(request: Request, exc: HTTPException):
logger.critical(exc, exc_info=True)
return InternalServerErrorResponse()
@property @property
def application(self): def application(self):
return self return self
@ -190,21 +166,11 @@ 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 Mount('/acls', self.acls_router()) yield Route('/acls', self.acls_route())
yield Route('/version', self.version_async) yield Route('/version', self.version_async)
""" Halfapi debug routes definition """ Halfapi debug routes definition
""" """
@ -246,51 +212,62 @@ class HalfAPI(Starlette):
time.sleep(1) time.sleep(1)
sys.exit(0) sys.exit(0)
def acls_router(self): def acls_route(self):
mounts = {} module = None
res = {
for domain, domain_conf in self.config.get('domain', {}).items(): domain: HalfDomain.acls_route(
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)
}
if len(mounts) > 1: async def wrapped(req, *args, **kwargs):
return Router([ for domain, domain_acls in res.items():
Mount(f'/{domain}', acls_router) for acl_name, d_acl in domain_acls.items():
for domain, acls_router in mounts.items() fct = d_acl['callable']
]) if not callable(fct):
elif len(mounts) == 1: raise Exception(
return Mount('/', mounts.popitem()[1]) 'No callable function in acl definition %s',
else: acl_name)
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):
return self.__domains return self.__domains
def add_domain(self, **kwargs): def add_domain(self, name, module=None, router=None, acl=None, path='/', config=None):
if not kwargs.get('enabled'): # logger.debug('HalfApi.add_domain %s %s %s %s %s',
raise Exception(f'Domain not enabled ({kwargs})') # name,
# module,
# router,
# acl,
# path,
# config)
name = kwargs['name'] if config:
self.config['domain'][name] = config
self.config['domain'][name] = kwargs.get('config', {}) if not module:
if not kwargs.get('module'):
module = name module = name
else:
module = kwargs.get('module')
try: try:
self.__domains[name] = HalfDomain( self.__domains[name] = HalfDomain(
name, name,
module=importlib.import_module(module), module=importlib.import_module(module),
router=kwargs.get('router'), router=router,
acl=kwargs.get('acl'), acl=acl,
app=self app=self
) )
@ -302,15 +279,6 @@ class HalfAPI(Starlette):
)) ))
raise exc raise exc
self.mount(kwargs.get('path', name), self.__domains[name]) self.mount(path, self.__domains[name])
return self.__domains[name] return self.__domains[name]
def __main__():
return HalfAPI(CONFIG).application
if __name__ == '__main__':
__main__()

View File

@ -2,14 +2,10 @@
""" """
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
@ -74,7 +70,7 @@ def args_check(fct):
pass pass
elif content_type in [ elif content_type in [
'multipart/form-data', 'application/x-www-form-urlencoded']: 'multipart/form-data', 'application/x-www-form-urlencoded']:
data_ = dict(await req.form()) data_ = await req.form()
else: else:
data_ = await req.body() data_ = await req.body()
@ -112,9 +108,8 @@ def args_check(fct):
kwargs['data'] = data kwargs['data'] = data
out_s = req.scope.get('out') if req.scope.get('out'):
if out_s: kwargs['out'] = req.scope.get('out').copy()
kwargs['out'] = list(out_s)
return await fct(req, *args, **kwargs) return await fct(req, *args, **kwargs)
@ -122,57 +117,7 @@ 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', private.__doc__, 0, True), ('private', public.__doc__, 0),
('public', public.__doc__, 999, True) ('public', public.__doc__, 999)
) )
@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)

View File

@ -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, Optional(bool)] [str, str, int]
] ]
}) })

View File

@ -16,7 +16,7 @@ import yaml
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from halfapi.lib import acl from halfapi.lib import acl
from halfapi.lib.responses import ORJSONResponse, ODSResponse, XLSXResponse, PlainTextResponse, HTMLResponse from halfapi.lib.responses import ORJSONResponse, ODSResponse, XLSXResponse
# from halfapi.lib.router import read_router # from halfapi.lib.router import read_router
from halfapi.lib.constants import VERBS from halfapi.lib.constants import VERBS
@ -52,19 +52,13 @@ class NoDomainsException(Exception):
""" """
pass pass
def route_decorator(fct: FunctionType) -> Coroutine: def route_decorator(fct: FunctionType, ret_type: str = 'json') -> Coroutine:
""" Returns an async function that can be mounted on a router """ Returns an async function that can be mounted on a router
""" """
@wraps(fct) @wraps(fct)
@acl.args_check @acl.args_check
async def wrapped(request, *args, **kwargs): async def wrapped(request, *args, **kwargs):
fct_args_spec = inspect.getfullargspec(fct).args fct_args_spec = inspect.getfullargspec(fct).args
fct_args_defaults = inspect.getfullargspec(fct).defaults or []
fct_args_defaults_dict = dict(list(zip(
reversed(fct_args_spec),
reversed(fct_args_defaults)
)))
fct_args = request.path_params.copy() fct_args = request.path_params.copy()
if 'halfapi' in fct_args_spec: if 'halfapi' in fct_args_spec:
@ -73,33 +67,21 @@ 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_defaults_dict:
fct_args['data'] = fct_args_defaults_dict['data']
else:
fct_args['data'] = {}
fct_args['data'].update(kwargs.get('data', {})) if 'data' in fct_args_spec:
fct_args['data'] = kwargs.get('data')
if 'out' in fct_args_spec: if 'out' in fct_args_spec:
fct_args['out'] = kwargs.get('out') fct_args['out'] = kwargs.get('out')
""" If format argument is specified (either by get, post param or function argument)
""" If format argument is specified (either by get or by post param)
""" """
if 'ret_type' in fct_args_defaults_dict:
ret_type = fct_args_defaults_dict['ret_type']
else:
ret_type = fct_args.get('data', {}).get('format', 'json') ret_type = fct_args.get('data', {}).get('format', 'json')
logger.debug('Return type {} (defaults: {})'.format(ret_type,
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))
@ -119,26 +101,12 @@ def route_decorator(fct: FunctionType) -> Coroutine:
return XLSXResponse(res) return XLSXResponse(res)
if ret_type in ['html', 'xhtml']:
res = fct(**fct_args)
assert isinstance(res, str)
return HTMLResponse(res)
if ret_type in 'txt':
res = fct(**fct_args)
assert isinstance(res, str)
return PlainTextResponse(res)
raise NotImplementedError raise NotImplementedError
except NotImplementedError as exc: except NotImplementedError as exc:
raise HTTPException(501) from exc raise HTTPException(501) from exc
except Exception as exc: except Exception as exc:
# TODO: Write tests # TODO: Write tests
logger.error(exc, exc_info=True)
if not isinstance(exc, HTTPException): if not isinstance(exc, HTTPException):
raise HTTPException(500) from exc raise HTTPException(500) from exc
raise exc raise exc

View File

@ -17,7 +17,7 @@ class DomainMiddleware(BaseHTTPMiddleware):
- acl - acl
""" """
def __init__(self, app, domain=None): def __init__(self, app, domain):
""" app: HalfAPI instance """ app: HalfAPI instance
""" """
logger.info('DomainMiddleware app:%s domain:%s', app, domain) logger.info('DomainMiddleware app:%s domain:%s', app, domain)
@ -35,21 +35,7 @@ class DomainMiddleware(BaseHTTPMiddleware):
request.scope['domain'] = self.domain['name'] request.scope['domain'] = self.domain['name']
if hasattr(request.app, 'config') \ if hasattr(request.app, 'config') \
and isinstance(request.app.config, dict): and isinstance(request.app.config, dict):
# Set the config scope to the domain's config request.scope['config'] = { **request.app.config }
request.scope['config'] = request.app.config.get(
'domain', {}
).get(
self.domain['name'], {}
).copy()
# TODO: Remove in 0.7.0
config = request.scope['config'].copy()
request.scope['config']['domain'] = {}
request.scope['config']['domain'][self.domain['name']] = {}
request.scope['config']['domain'][self.domain['name']]['config'] = config
else: else:
logger.debug('%s', request.app) logger.debug('%s', request.app)
logger.debug('%s', getattr(request.app, 'config', None)) logger.debug('%s', getattr(request.app, 'config', None))

View File

@ -14,18 +14,16 @@ 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, Request from starlette.requests import HTTPConnection
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
@ -36,20 +34,6 @@ 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'):
@ -68,22 +52,17 @@ 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: if token and not is_fake_user_id:
payload = jwt.decode(token, payload = jwt.decode(token,
key=self.secret_key, key=self.secret_key,
algorithms=[self.algorithm], algorithms=[self.algorithm],
@ -92,6 +71,14 @@ 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'])
@ -102,8 +89,6 @@ 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:

View File

@ -56,8 +56,10 @@ 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')
# starlette.exceptions.HTTPException: 400 Traceback (most recent call last):
...
starlette.exceptions.HTTPException: 400
""" """

View File

@ -23,9 +23,7 @@ from io import BytesIO
import orjson import orjson
# asgi framework # asgi framework
from starlette.responses import PlainTextResponse, Response, JSONResponse, HTMLResponse from starlette.responses import PlainTextResponse, Response, JSONResponse
from starlette.requests import Request
from starlette.exceptions import HTTPException
from .user import JWTUser, Nobody from .user import JWTUser, Nobody
from ..logging import logger from ..logging import logger
@ -159,9 +157,3 @@ class ODSResponse(Response):
class XLSXResponse(ODSResponse): class XLSXResponse(ODSResponse):
file_type = 'xlsx' file_type = 'xlsx'
def gen_exception_route(response_cls):
async def exception_route(req: Request, exc: HTTPException):
return response_cls()
return exception_route

View File

@ -13,7 +13,6 @@ 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
@ -28,12 +27,8 @@ SCHEMAS = SchemaGenerator(
async def schema_json(request, *args, **kwargs): async def schema_json(request, *args, **kwargs):
""" """
description: | description: Returns the current API routes description (OpenAPI v3)
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))
@ -115,23 +110,3 @@ 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]
}
})

View File

@ -1,10 +1,8 @@
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=default_level, format=default_format, datefmt=default_datefmt): def config_logging(level=logging.INFO):
# 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:
@ -14,8 +12,8 @@ def config_logging(level=default_level, format=default_format, datefmt=default_d
# 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=format, format='%(asctime)s [%(process)d] [%(levelname)s] %(message)s',
datefmt=datefmt, datefmt='[%Y-%m-%d %H:%M:%S %z]',
level=level) level=level)
# When run by 'gunicorn -k uvicorn.workers.UvicornWorker ...', # When run by 'gunicorn -k uvicorn.workers.UvicornWorker ...',
@ -29,4 +27,5 @@ def config_logging(level=default_level, format=default_format, datefmt=default_d
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()

View File

@ -4,38 +4,18 @@ 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
class TestDomain(TestCase): class TestDomain(TestCase):
@property
def domain_name(self):
return getattr(self, 'DOMAIN')
@property
def module_name(self):
return getattr(self, 'MODULE', self.domain_name)
@property
def acl_path(self):
return getattr(self, 'ACL', '.acl')
@property
def router_path(self):
return getattr(self, 'ROUTERS', '.routers')
@property @property
def router_module(self): def router_module(self):
return '.'.join((self.module_name, self.ROUTERS)) return '.'.join((self.DOMAIN, self.ROUTERS))
def setUp(self): def setUp(self):
# CLI # CLI
@ -63,33 +43,29 @@ 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,
'domain': {} 'domain': {}
} }
self.halfapi_conf['domain'][self.domain_name] = { self.halfapi_conf['domain'][self.DOMAIN] = {
'name': self.domain_name, 'name': self.DOMAIN,
'router': self.router_path, 'router': self.ROUTERS,
'acl': self.acl_path, 'acl': self.ACL,
'module': self.module_name,
'prefix': False, 'prefix': False,
'enabled': True, 'enabled': True,
'config': getattr(self, 'CONFIG', {}) 'config': {
'test': True
}
} }
_, self.config_file = tempfile.mkstemp()
with open(self.config_file, 'w') as fh:
fh.write(toml.dumps(self.halfapi_conf))
self.halfapi = HalfAPI(self.halfapi_conf) self.halfapi = HalfAPI(self.halfapi_conf)
self.client = TestClient(self.halfapi.application) self.client = TestClient(self.halfapi.application)
self.module = importlib.import_module( self.module = importlib.import_module(
self.module_name getattr(self, 'MODULE', self.DOMAIN)
) )
@ -101,13 +77,13 @@ 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', '--read', self.DOMAIN, self.config_file]) result = self.runner.invoke(cli, ['domain', self.DOMAIN])
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'])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
# result = self.runner.invoke(cli, ['run', '--dryrun', self.DOMAIN]) result = self.runner.invoke(cli, ['run', '--dryrun', self.DOMAIN])
# self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
except AssertionError as exc: except AssertionError as exc:
print(f'Result {result}') print(f'Result {result}')
print(f'Stdout {result.stdout}') print(f'Stdout {result.stdout}')
@ -123,30 +99,26 @@ class TestDomain(TestCase):
return result_d return result_d
def check_routes(self): def check_routes(self):
r = self.client.request('get', '/') r = self.client.get('/')
assert r.status_code == 200 assert r.status_code == 200
schema = r.json() schemas = 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.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()
ACLS = HalfDomain.acls(self.module, self.acl_path) assert self.DOMAIN in d_r.keys()
assert len(ACLS) == len(d_r[self.domain_name])
for acl_rule in ACLS: ACLS = HalfDomain.acls(self.module, self.ACL)
assert len(acl_rule.name) > 0 assert len(ACLS) == len(d_r[self.DOMAIN])
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: for acl_name in ACLS:
r = self.client.request('get', f'/halfapi/acls/{acl_rule.name}') assert acl_name[0] in d_r[self.DOMAIN]
assert r.status_code in [200, 401]

View File

@ -1,5 +1,5 @@
[pytest] [pytest]
testpaths = tests halfapi testpaths = tests
addopts = --doctest-modules addopts = --doctest-modules
doctest_optionflags = ELLIPSIS IGNORE_EXCEPTION_DETAIL doctest_optionflags = ELLIPSIS
pythonpath = ./tests pythonpath = ./tests

View File

@ -43,15 +43,15 @@ setup(
packages=get_packages(module_name), packages=get_packages(module_name),
python_requires=">=3.8", python_requires=">=3.8",
install_requires=[ install_requires=[
"PyJWT>=2.6.0,<2.7.0", "PyJWT>=2.3.0,<2.4.0",
"starlette>=0.33,<0.34", "starlette>=0.17,<0.18",
"click>=8,<9", "click>=7.1,<8",
"uvicorn>=0.13,<1", "uvicorn>=0.13,<1",
"orjson>=3.8.5,<4", "orjson>=3.4.7,<4",
"pyyaml>=6,<7", "pyyaml>=5.3.1,<6",
"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.7.1,<0.8",
"packaging>=19.0", "packaging>=19.0",
"python-multipart" "python-multipart"
], ],
@ -68,13 +68,9 @@ setup(
extras_require={ extras_require={
"tests":[ "tests":[
"pytest>=7,<8", "pytest>=7,<8",
"pytest-asyncio",
"pylint",
"requests", "requests",
"httpx", "pytest-asyncio",
"openapi-schema-validator", "pylint"
"openapi-spec-validator",
"coverage"
], ],
"pyexcel":[ "pyexcel":[
"pyexcel", "pyexcel",

View File

@ -4,13 +4,10 @@ import subprocess
import importlib import importlib
import tempfile import tempfile
from unittest.mock import patch from unittest.mock import patch
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')
@ -25,51 +22,10 @@ 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() r = project_runner('domain dummy_domain')
with open(tmp_conf, 'w') as fh:
fh.write(
toml.dumps(test_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

View File

@ -1,11 +1,9 @@
import pytest
from click.testing import CliRunner from click.testing import CliRunner
from halfapi.cli.cli import cli from halfapi.cli.cli import cli
import os import os
from unittest.mock import patch from unittest.mock import patch
@pytest.mark.skip
def test_run_noproject(cli_runner): def test_run_noproject(cli_runner):
with cli_runner.isolated_filesystem(): with cli_runner.isolated_filesystem():
result = cli_runner.invoke(cli, ['config']) result = cli_runner.invoke(cli, ['config'])

View File

@ -1,27 +1,12 @@
from halfapi import __version__ as halfapi_version from halfapi import __version__ as halfapi_version
domain = { __name__ = 'dummy_domain'
'name': 'dummy_domain', __version__ = '0.0.0'
'version': '0.0.0', __patch_release__ = '0.0.0'
'id': '8b88e60a625369235b36c2d6d70756a0c02c1c7fb169fcee6dc820bcf9723f5a', __routers__ = '.routers'
'deps': ( __id__ = '8b88e60a625369235b36c2d6d70756a0c02c1c7fb169fcee6dc820bcf9723f5a'
__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'}
}
}
}
}
}

View File

@ -1,5 +1,5 @@
from halfapi.lib import acl from halfapi.lib import acl
from halfapi.lib.acl import public, private, ACLS from halfapi.lib.acl import public, private
from random import randint from random import randint
def random(*args): def random(*args):
@ -8,6 +8,7 @@ def random(*args):
return randint(0,1) == 1 return randint(0,1) == 1
ACLS = ( ACLS = (
*ACLS, ('public', public.__doc__, 999),
('random', random.__doc__, 10) ('random', random.__doc__, 10),
('private', private.__doc__, 0)
) )

View File

@ -12,9 +12,6 @@ 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))
@ -22,9 +19,6 @@ def post(test):
""" """
description: description:
returns the path parameter returns the path parameter
responses:
200:
description: test response
""" """
return str(test) return str(test)
@ -32,9 +26,6 @@ def patch(test):
""" """
description: description:
returns the path parameter returns the path parameter
responses:
200:
description: test response
""" """
return str(test) return str(test)
@ -42,9 +33,6 @@ def put(test):
""" """
description: description:
returns the path parameter returns the path parameter
responses:
200:
description: test response
""" """
return str(test) return str(test)
@ -52,8 +40,5 @@ def delete(test):
""" """
description: description:
returns the path parameter returns the path parameter
responses:
200:
description: test response
""" """
return str(test) return str(test)

View File

@ -1,21 +1,10 @@
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: The pinnochio guy description:
responses: Not implemented
200:
description: test response
content:
application/json:
schema:
$ref: "#/components/schemas/Pinnochio"
""" """
return { raise NotImplementedError
'id': str(uuid4()),
'name': 'pinnochio',
'nose_size': 42
}

View File

@ -47,8 +47,7 @@ ACLS = {
'foo', 'baz' 'foo', 'baz'
}, },
'optional': { 'optional': {
'truebidoo', 'truebidoo'
'z'
} }
} }
}, },
@ -56,24 +55,18 @@ ACLS = {
} }
def get(data): def get(halfapi, data):
""" """
description: description:
returns the arguments passed in returns the configuration of the domain
responses:
200:
description: test response
""" """
logger.error('%s', data['foo']) logger.error('%s', data['foo'])
return data return {'foo': data['foo'], 'bar': data['bar']}
def post(data): def post(halfapi, data):
""" """
description: description:
returns the arguments passed in returns the configuration of the domain
responses:
200:
description: test response
""" """
logger.error('%s', data) logger.error('%s', data)
return data return {'foo': data['foo'], 'bar': data.get('bar', data.get('baz'))}

View File

@ -25,43 +25,24 @@ 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'),

View File

@ -12,22 +12,6 @@ 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 return halfapi['config']['domain']['dummy_domain']['config']
try:
assert 'test' in halfapi['config']['domain']['dummy_domain']['config']
except AssertionError as exc:
logger.error('No TEST in halfapi[config][domain][dummy_domain][config]')
raise exc
try:
assert 'test' in halfapi['config']
except AssertionError as exc:
logger.error('No TEST in halfapi[config]')
raise exc
return halfapi['config']

View File

@ -1,8 +0,0 @@
param_docstring = """
name: second
in: path
description: second parameter description test
required: true
schema:
type: string
"""

View File

@ -1,20 +0,0 @@
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 ''

View File

@ -1,13 +0,0 @@
from halfapi.lib import acl
ACLS = {
'GET': [{'acl':acl.public}]
}
def get(ext, ret_type='html'):
"""
responses:
200:
description: dummy abc.alphabet route
"""
return '\n'.join(('trololo', '', 'ololotr'))

View File

@ -1,31 +0,0 @@
from halfapi.lib import acl
ACLS = {
'GET': [{'acl':acl.public}],
'POST': [
{
'acl':acl.public,
'args': {
'required': {'trou'},
'optional': {'troo'}
}
}
]
}
def get(ext, halfapi={}, ret_type='html'):
"""
responses:
200:
description: dummy abc.alphabet route
"""
return '\n'.join(('trololo', '', 'ololotr'))
def post(ext, data={'troo': 'fidget'}, halfapi={}, ret_type='html'):
"""
responses:
200:
description: dummy abc.alphabet route
"""
print(data)
return '\n'.join(('trololo', '', 'ololotr'))

View File

@ -1,13 +0,0 @@
from halfapi.lib import acl
ACLS = {
'GET': [{'acl':acl.public}]
}
def get(ret_type='html'):
"""
responses:
200:
description: dummy abc.alphabet route
"""
return '\n'.join(('trololo', '', 'ololotr'))

View File

@ -1,4 +0,0 @@
from . import get
def test_get():
assert isinstance(get(), str)

View File

@ -17,7 +17,7 @@ def test_acl_Check(dummy_app, token_debug_false_builder):
dummy_app.add_route('/test_public', test_route_public) dummy_app.add_route('/test_public', test_route_public)
test_client = TestClient(dummy_app) test_client = TestClient(dummy_app)
resp = test_client.request('get', '/test_public?check') resp = test_client.get('/test_public?check')
assert resp.status_code == 200 assert resp.status_code == 200
@HalfRoute.acl_decorator(params=[{'acl':acl.private}]) @HalfRoute.acl_decorator(params=[{'acl':acl.private}])
@ -28,10 +28,10 @@ def test_acl_Check(dummy_app, token_debug_false_builder):
dummy_app.add_route('/test_private', test_route_private) dummy_app.add_route('/test_private', test_route_private)
test_client = TestClient(dummy_app) test_client = TestClient(dummy_app)
resp = test_client.request('get', '/test_private') resp = test_client.get('/test_private')
assert resp.status_code == 401 assert resp.status_code == 401
resp = test_client.request('get', '/test_private?check') resp = test_client.get('/test_private?check')
assert resp.status_code == 200 assert resp.status_code == 200

View File

@ -7,10 +7,6 @@ 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
@ -19,7 +15,7 @@ def test_halfapi_whoami(application_debug):
# So we use a single function with fixture "application debug" # So we use a single function with fixture "application debug"
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.request('get', '/halfapi/whoami') r = c.get('/halfapi/whoami')
assert r.status_code == 200 assert r.status_code == 200
def test_halfapi_log(application_debug): def test_halfapi_log(application_debug):
@ -28,7 +24,7 @@ def test_halfapi_log(application_debug):
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.request('get', '/halfapi/log') r = c.get('/halfapi/log')
assert r.status_code == 200 assert r.status_code == 200
def test_halfapi_error_400(application_debug): def test_halfapi_error_400(application_debug):
@ -37,7 +33,7 @@ def test_halfapi_error_400(application_debug):
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.request('get', '/halfapi/error/400') r = c.get('/halfapi/error/400')
assert r.status_code == 400 assert r.status_code == 400
def test_halfapi_error_404(application_debug): def test_halfapi_error_404(application_debug):
@ -46,7 +42,7 @@ def test_halfapi_error_404(application_debug):
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.request('get', '/halfapi/error/404') r = c.get('/halfapi/error/404')
assert r.status_code == 404 assert r.status_code == 404
def test_halfapi_error_500(application_debug): def test_halfapi_error_500(application_debug):
@ -55,13 +51,15 @@ def test_halfapi_error_500(application_debug):
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.request('get', '/halfapi/error/500') r = c.get('/halfapi/error/500')
assert r.status_code == 500 assert r.status_code == 500
def test_schema(application_debug): def test_schema(application_debug):
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.request('get', '/') r = c.get('/')
schema = r.json() schemas = r.json()
assert isinstance(schemas, list)
for schema in schemas:
assert isinstance(schema, dict) assert isinstance(schema, dict)
openapi_spec_validator.validate_spec(schema) assert API_SCHEMA.validate(schema)

View File

@ -1,106 +1,15 @@
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 __name__, __routers__
__name__ = domain.get('name')
__routers__ = domain.get('routers')
DOMAIN = __name__ DOMAIN = __name__
CONFIG = {'test': True} ROUTERS = __routers__
ACL = '.acl'
def test_domain(self): def test_domain(self):
self.check_domain() self.check_domain()
def test_routes(self): def test_routes(self):
self.check_routes() self.check_routes()
def test_html_route(self):
res = self.client.request('get', '/ret_type')
assert res.status_code == 200
assert isinstance(res.content.decode(), str)
assert res.headers['content-type'].split(';')[0] == 'text/html'
res = self.client.request('get', '/ret_type/h24')
assert res.status_code == 200
assert isinstance(res.content.decode(), str)
assert res.headers['content-type'].split(';')[0] == 'text/html'
res = self.client.request('get', '/ret_type/h24/config')
assert res.status_code == 200
assert isinstance(res.content.decode(), str)
assert res.headers['content-type'].split(';')[0] == 'text/html'
res = self.client.request('post', '/ret_type/h24/config', json={
'trou': 'glet'
})
assert res.status_code == 200
assert isinstance(res.content.decode(), str)
assert res.headers['content-type'].split(';')[0] == 'text/html'
def test_arguments__get_routes(self):
res = self.client.request('post', '/arguments?foo=1&x=3')
assert res.status_code == 400
arg_dict = {'foo': '1', 'bar': '2', 'x': '3'}
res = self.client.request('get', '/arguments?foo=1&bar=2&x=3')
assert res.json() == arg_dict
res = self.client.request('get', '/arguments?foo=1&bar=2&x=3&y=4')
assert res.json() == arg_dict
def test_arguments_post_routes(self):
arg_dict = {}
res = self.client.request('post', '/arguments', json=arg_dict)
assert res.status_code == 400
arg_dict = {'foo': '1', 'bar': '3'}
res = self.client.request('post', '/arguments', json=arg_dict)
assert res.status_code == 400
arg_dict = {'foo': '1', 'baz': '3'}
res = self.client.request('post', '/arguments', json=arg_dict)
assert res.json() == arg_dict
arg_dict = {'foo': '1', 'baz': '3', 'truebidoo': '4'}
res = self.client.request('post', '/arguments', json=arg_dict)
assert res.json() == arg_dict
res = self.client.request('post', '/arguments', json={ **arg_dict, 'y': '4'})
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'

View File

@ -1,5 +1,4 @@
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

View File

@ -4,17 +4,16 @@ import importlib
import subprocess import subprocess
import time import time
import pytest import pytest
import json
from pprint import pprint from pprint import pprint
from starlette.routing import Route from starlette.routing import Route
from starlette.testclient import TestClient from starlette.testclient import TestClient
def test_get_config_route(dummy_project, application_domain): def test_get_config_route(dummy_project, application_domain):
c = TestClient(application_domain) c = TestClient(application_domain)
r = c.request('get', '/') r = c.get('/')
assert r.status_code == 200 assert r.status_code == 200
pprint(r.json()) pprint(r.json())
r = c.request('get', '/config') r = c.get('/config')
assert r.status_code == 200 assert r.status_code == 200
pprint(r.json()) pprint(r.json())
assert 'test' in r.json() assert 'test' in r.json()
@ -37,15 +36,15 @@ def test_get_route(dummy_project, application_domain):
print(route_path) print(route_path)
try: try:
if verb.lower() == 'get': if verb.lower() == 'get':
r = c.request('get', route_path) r = c.get(route_path)
elif verb.lower() == 'post': elif verb.lower() == 'post':
r = c.request('post', route_path) r = c.post(route_path)
elif verb.lower() == 'patch': elif verb.lower() == 'patch':
r = c.request('patch', route_path) r = c.patch(route_path)
elif verb.lower() == 'put': elif verb.lower() == 'put':
r = c.request('put', route_path) r = c.put(route_path)
elif verb.lower() == 'delete': elif verb.lower() == 'delete':
r = c.request('delete', route_path) r = c.delete(route_path)
else: else:
raise Exception(verb) raise Exception(verb)
try: try:
@ -70,7 +69,7 @@ def test_get_route(dummy_project, application_domain):
path = path.format(test=str(test_uuid)) path = path.format(test=str(test_uuid))
route_path = f'/{path}' route_path = f'/{path}'
if verb.lower() == 'get': if verb.lower() == 'get':
r = c.request('get', f'{route_path}') r = c.get(f'{route_path}')
assert r.status_code == 200 assert r.status_code == 200
@ -79,7 +78,7 @@ def test_delete_route(dummy_project, application_domain):
c = TestClient(application_domain) c = TestClient(application_domain)
from uuid import uuid4 from uuid import uuid4
arg = str(uuid4()) arg = str(uuid4())
r = c.request('delete', f'/abc/alphabet/{arg}') r = c.delete(f'/abc/alphabet/{arg}')
assert r.status_code == 200 assert r.status_code == 200
assert isinstance(r.json(), str) assert isinstance(r.json(), str)
@ -87,23 +86,23 @@ def test_arguments_route(dummy_project, application_domain):
c = TestClient(application_domain) c = TestClient(application_domain)
path = '/arguments' path = '/arguments'
r = c.request('get', path) r = c.get(path)
assert r.status_code == 400 assert r.status_code == 400
r = c.request('get', path, params={'foo':True}) r = c.get(path, params={'foo':True})
assert r.status_code == 400 assert r.status_code == 400
arg = {'foo':True, 'bar':True} arg = {'foo':True, 'bar':True}
r = c.request('get', path, params=arg) r = c.get(path, params=arg)
assert r.status_code == 200 assert r.status_code == 200
for key, val in arg.items(): for key, val in arg.items():
assert json.loads(r.json()[key]) == val assert r.json()[key] == str(val)
path = '/async_router/arguments' path = '/async_router/arguments'
r = c.request('get', path) r = c.get(path)
assert r.status_code == 400 assert r.status_code == 400
r = c.request('get', path, params={'foo':True}) r = c.get(path, params={'foo':True})
assert r.status_code == 400 assert r.status_code == 400
arg = {'foo':True, 'bar':True} arg = {'foo':True, 'bar':True}
r = c.request('get', path, params=arg) r = c.get(path, params=arg)
assert r.status_code == 200 assert r.status_code == 200
for key, val in arg.items(): for key, val in arg.items():
assert json.loads(r.json()[key]) == val assert r.json()[key] == str(val)

View File

@ -38,7 +38,7 @@ def test_jwt_NoToken(dummy_app):
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') resp = test_client.get('/test')
assert resp.status_code == 200 assert resp.status_code == 200
def test_jwt_Token(dummy_app, token_builder): def test_jwt_Token(dummy_app, token_builder):
@ -50,20 +50,13 @@ 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', resp = test_client.get('/test',
cookies={
'Authorization': token_builder
})
assert resp.status_code == 200
resp = test_client.request('get', '/test',
headers={ headers={
'Authorization': token_builder 'Authorization': 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)
@ -72,13 +65,7 @@ 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', resp = test_client.get('/test',
cookies={
'Authorization': token_debug_false_builder
})
assert resp.status_code == 200
resp = test_client.request('get', '/test',
headers={ headers={
'Authorization': token_debug_false_builder 'Authorization': token_debug_false_builder
}) })
@ -95,13 +82,7 @@ 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', resp = test_client.get('/test',
cookies={
'Authorization': token_debug_true_builder
})
assert resp.status_code == 400
resp = test_client.request('get', '/test',
headers={ headers={
'Authorization': token_debug_true_builder 'Authorization': token_debug_true_builder
}) })
@ -119,13 +100,7 @@ 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', resp = test_client.get('/test',
cookies={
'Authorization': token_debug_true_builder
})
assert resp.status_code == 200
resp = test_client.request('get', '/test',
headers={ headers={
'Authorization': token_debug_true_builder 'Authorization': token_debug_true_builder
}) })

View File

@ -49,76 +49,3 @@
#  # 
#  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() == ''

View File

@ -12,27 +12,26 @@ def test_init():
def test_call(application_debug): def test_call(application_debug):
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.request('get', '/abc/alphabet') r = c.get('/abc/alphabet')
assert r.status_code == 200 assert r.status_code == 200
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'
r = c.request('get', '/arguments') r = c.get('/arguments')
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'].split(',') assert 'foo' in r.headers['x-args-required']
assert 'bar' in r.headers['x-args-required'].split(',') assert 'bar' in r.headers['x-args-required']
assert r.headers['x-args-optional'] == 'x' assert r.headers['x-args-optional'] == 'x'
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.request('post', '/arguments') r = c.post('/arguments')
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'].split(',') assert 'foo' in r.headers['x-args-required']
assert 'baz' in r.headers['x-args-required'].split(',') assert 'baz' in r.headers['x-args-required']
assert 'truebidoo' in r.headers['x-args-optional'].split(',') assert r.headers['x-args-optional'] == 'truebidoo'
assert 'z' in r.headers['x-args-optional'].split(',')