Compare commits

..

No commits in common. "master" and "gitlab-ci" have entirely different histories.

81 changed files with 1713 additions and 5040 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.8
# 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.
@ -27,64 +27,30 @@ cache:
- .cache/pip - .cache/pip
- venv/ - venv/
stages:
- test
- build
.before_script_template: &test
before_script:
- python3 -V # Print out python version for debugging
- python3 -m venv /tmp/venv
- /tmp/venv/bin/pip3 install .["tests","pyexcel"]
- /tmp/venv/bin/pip3 install coverage pytest
test: test:
image: python:alpine${ALPINEVERSION}
stage: test
<<: *test
parallel:
matrix:
- ALPINEVERSION: ["3.16", "3.17", "3.18", "3.19"]
script:
- /tmp/venv/bin/pytest --version
- PYTHONPATH=./tests/ /tmp/venv/bin/coverage run --source halfapi -m pytest
- /tmp/venv/bin/coverage xml
- /tmp/venv/bin/halfapi --version
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
build_pypi:
stage: build
script: script:
- 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 pytest -v
- pipenv run halfapi --version
run:
script:
- python3 -V # Print out python version for debugging
- pip3 install pipenv
- 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
rules:
- if: '$CI_COMMIT_TAG != ""'
variables:
TAG: $CI_COMMIT_TAG
build_container: build:
rules:
- if: '$CI_COMMIT_TAG != ""'
variables:
IMGTAG: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "devel"'
variables:
IMGTAG: "latest"
stage: build
image: $CI_REGISTRY/devtools/kaniko image: $CI_REGISTRY/devtools/kaniko
script: script:
- echo "Will upload image halfapi:\"$IMGTAG\""
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor --force --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY/malves/halfapi:"$IMGTAG" - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY/malves/halfapi:latest
artifacts: artifacts:
paths: paths:
- /kaniko/.docker/config.json - /kaniko/.docker/config.json

View File

@ -1,233 +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
- Allow file sending in multipart request (#32)
- Add python-multipart dependency
## 0.6.18
- Fix config coming from .halfapi/config when using HALFAPI_DOMAIN_NAME environment variable
## 0.6.17
- Fix 0.6.16
- Errata : HALFAPI_DOMAIN is HALFAPI_DOMAIN_NAME
- Testing : You can now specify "MODULE" class attribute for your "test_domain"
subclasses
## 0.6.16
- The definition of "HALFAPI_DOMAIN_MODULE" environment variable allows to
specify the base module for a domain structure. It is formatted as a python
import path.
The "HALFAPI_DOMAIN" specifies the "name" of the module
## 0.6.15
- Allows to define a "__acl__" variable in the API module's __init__.py, to
specify how to import the acl lib. You can also specify "acl" in the domain's
config
## 0.6.14
- Add XLSXResponse (with format argument set to "xlsx"), to return .xlsx files
## 0.6.13
- (rollback from 0.6.12) Remove pytest from dependencies in Docker file and
remove tests
- (dep) Add "packaging" dependency
- Add dependency check capability when instantiating a domain (__deps__
variable, see in dummy_domain)
## 0.6.12
- Installs pytest with dependencies in Docker image, tests when building image
## 0.6.11
- Fix "request" has no "path_params" attribute bug
## 0.6.10
- Add "x-out" field in HTTP headers when "out" parameters are specified for a
route
- Add "out" kwarg for not-async functions that specify it
## 0.6.9
- Hide data fields in args_check logs
## 0.6.8
- Fix testing lib for domains (add default secret and debug option)
## 0.6.2
- Domains now need to include the following variables in their __init__.py
- __name__ (str, optional)
- __id__ (str, optional)
- halfapi domain
## 0.1.0 ## 0.1.0
- Mounts domain routers with their ACLs as decorator - Mounts domain routers with their ACLs as decorator

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

View File

@ -1,5 +1,4 @@
Copyright (c) 2020-2021 Maxime ALVES <maxime@freepoteries.fr>, Joël Maïzi Copyright (c) 2020-2021 Maxime ALVES <maxime@freepoteries.fr>
<joel.maizi@collorg.org>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by

28
Pipfile
View File

@ -4,33 +4,23 @@ url = "https://pypi.org/simple"
verify_ssl = true verify_ssl = true
[dev-packages] [dev-packages]
pytest = ">=7,<8" pytest = "*"
requests = "*" requests = "*"
pytest-asyncio = "*" pytest-asyncio = "*"
pylint = "*" pylint = "*"
build = "*" build = "*"
twine = "*"
pyflakes = "*"
vulture = "*"
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.16,<0.17"
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.0.1,<3"
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"
toml = ">=0.10,<0.11" [requires]
pip = "*" python_version = "3.8"
packaging = ">=19.0"
python-multipart = "*"
[scripts] [scripts]
halfapi = "python -m halfapi" halfapi = "python -m halfapi"

1387
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,6 @@
# HalfAPI # HalfAPI
Base tools to develop complex API with rights management. Base tools to develop comlex API with rights management.
This project was developped by Maxime Alves and Joël Maïzi. The name was chosen
to reference [HalfORM](https://github.com/collorg/halfORM), a project written by Joël Maïzi.
## Dependencies ## Dependencies
@ -23,15 +20,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 +40,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
@ -73,20 +56,6 @@ that is available in the python path.
Run the project by using the `halfapi run` command. Run the project by using the `halfapi run` command.
You can try the dummy_domain with the following command.
```
PYTHONPATH=$PWD/tests python -m halfapi domain dummy_domain
```
### CLI documentation
Use the CLI help.
```
python -m halfapi --help
python -m halfapi domain --help
```
## API Testing ## API Testing

View File

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

View File

@ -1,7 +1,138 @@
import os #!/usr/bin/env python3
from .halfapi import HalfAPI """
from .logging import logger app.py is the file that is read when launching the application using an asgi
runner.
def application(): It defines the following globals :
from .conf import CONFIG
return HalfAPI(CONFIG).application - routes (contains the Route objects for the application)
- application (the asgi application itself - a starlette object)
"""
import logging
import time
# asgi framework
from starlette.applications import Starlette
from starlette.authentication import UnauthenticatedUser
from starlette.middleware import Middleware
from starlette.routing import Route
from starlette.responses import Response, PlainTextResponse
from starlette.middleware.authentication import AuthenticationMiddleware
from timing_asgi import TimingMiddleware
from timing_asgi.integrations import StarletteScopeToName
# module libraries
from .lib.domain_middleware import DomainMiddleware
from .lib.timing import HTimingClient
from halfapi.lib.jwt_middleware import JWTAuthenticationBackend
from halfapi.lib.responses import (ORJSONResponse, UnauthorizedResponse,
NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse,
ServiceUnavailableResponse)
from halfapi.lib.routes import gen_starlette_routes, debug_routes
from halfapi.lib.schemas import get_api_routes, get_api_domain_routes, schema_json, get_acls
from halfapi.logging import logger, config_logging
from halfapi import __version__
class HalfAPI:
def __init__(self, config=None):
config_logging(logging.DEBUG)
if config:
SECRET = config.get('SECRET')
PRODUCTION = config.get('PRODUCTION')
DOMAINS = config.get('DOMAINS', {})
CONFIG = config.get('CONFIG', {
'domains': DOMAINS
})
else:
from halfapi.conf import CONFIG, SECRET, PRODUCTION, DOMAINS
routes = [ Route('/', get_api_routes(DOMAINS)) ]
for route in self.routes():
routes.append(route)
if not PRODUCTION:
for route in debug_routes():
routes.append( route )
if DOMAINS:
for route in gen_starlette_routes(DOMAINS):
routes.append(route)
for domain, m_domain in DOMAINS.items():
routes.append(
Route(
f'/{domain}',
get_api_domain_routes(m_domain)
)
)
self.application = Starlette(
debug=not PRODUCTION,
routes=routes,
exception_handlers={
401: UnauthorizedResponse,
404: NotFoundResponse,
500: InternalServerErrorResponse,
501: NotImplementedResponse,
503: ServiceUnavailableResponse
}
)
self.application.add_middleware(
DomainMiddleware,
config=CONFIG
)
if SECRET:
self.SECRET = SECRET
self.application.add_middleware(
AuthenticationMiddleware,
backend=JWTAuthenticationBackend(secret_key=SECRET)
)
if not PRODUCTION:
self.application.add_middleware(
TimingMiddleware,
client=HTimingClient(),
metric_namer=StarletteScopeToName(prefix="halfapi",
starlette_app=self.application)
)
logger.info('CONFIG:\n%s', CONFIG)
@property
def version(self):
return __version__
async def version_async(self, request, *args, **kwargs):
return Response(self.version)
def routes(self):
""" Halfapi default routes
"""
async def get_user(request, *args, **kwargs):
return ORJSONResponse({'user':request.user})
yield Route('/halfapi/whoami', get_user)
yield Route('/halfapi/schema', schema_json)
yield Route('/halfapi/acls', get_acls)
yield Route('/halfapi/version', self.version_async)
application = HalfAPI().application

View File

@ -8,6 +8,12 @@ not loaded otherwise.
# builtins # builtins
import click import click
IS_PROJECT = True
try:
from ..conf import DOMAINS
except Exception:
IS_PROJECT = False
@click.group(invoke_without_command=True) @click.group(invoke_without_command=True)
@click.option('--version', is_flag=True) @click.option('--version', is_flag=True)
@click.pass_context @click.pass_context
@ -21,8 +27,10 @@ def cli(ctx, version):
from halfapi import version from halfapi import version
click.echo(version()) click.echo(version())
if IS_PROJECT:
from . import config from . import config
from . import domain from . import domain
from . import run from . import run
else:
from . import init from . import init
from . import routes

View File

@ -1,26 +1,32 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
cli/config.py Contains the .halfapi/config cli/config.py Contains the .halfapi/config and HALFAPI_CONF_DIR/project_name templates
Defines the "halfapi config" command Defines the "halfapi config" command
""" """
import click import click
from .cli import cli from .cli import cli
from ..conf import CONFIG from ..conf import (
read_config,
PROJECT_NAME,
DOMAINSDICT,
HOST,
PORT,
PRODUCTION,
)
DOMAIN_CONF_STR=""" CONF_STR=f"""
[domain]
name = {name}
router = {router}
"""
CONF_STR="""
[project] [project]
host = {host} name = {PROJECT_NAME}
port = {port} host = {HOST}
production = {production} port = {PORT}
""" production = {PRODUCTION}
[domains]"""
for dom in DOMAINSDICT():
CONF_STR = '\n'.join((CONF_STR, dom))
@cli.command() @cli.command()
@ -28,4 +34,4 @@ def config():
""" """
Lists config parameters and their values Lists config parameters and their values
""" """
click.echo(CONF_STR.format(**CONFIG)) click.echo(read_config())

View File

@ -3,30 +3,20 @@
cli/domain.py Defines the "halfapi domain" cli commands cli/domain.py Defines the "halfapi domain" cli commands
""" """
# builtins # builtins
import os import logging
import sys import sys
import importlib
import subprocess
import json
import toml
import click import click
import orjson
import uvicorn
from .cli import cli from .cli import cli
from ..conf import CONFIG from ..conf import config, write_config, DOMAINSDICT
from ..half_domain import HalfDomain
from ..lib.schemas import schema_dict_dom
from ..lib.routes import api_routes from ..lib.routes import api_routes
from ..lib.responses import ORJSONResponse
from ..conf import CONFIG, PROJECT_LEVEL_KEYS
from ..logging import logger logger = logging.getLogger('halfapi')
################# #################
# domain create # # domain create #
@ -39,53 +29,11 @@ def create_domain(domain_name: str, module_path: str):
# logger.warning('Domain **%s** is already in project', domain_name) # logger.warning('Domain **%s** is already in project', domain_name)
# sys.exit(1) # sys.exit(1)
def domain_tree_create(): if not config.has_section('domains'):
def create_init(path): config.add_section('domains')
with open(os.path.join(os.getcwd(), path, '__init__.py'), 'w') as f:
f.writelines([
'"""',
f'name: {domain_name}',
f'router: {module_path}',
'"""'
])
logger.debug('created %s', os.path.join(os.getcwd(), path, '__init__.py'))
def create_acl(path):
with open(os.path.join(path, 'acl.py'), 'w') as f:
f.writelines([
'from halfapi.lib.acl import public, ACLS',
])
logger.debug('created %s', os.path.join(path, 'acl.py'))
os.mkdir(domain_name)
create_init(domain_name)
router_path = os.path.join(domain_name, 'routers')
create_acl(domain_name)
os.mkdir(router_path)
create_init(router_path)
# TODO: Generate config file
domain_tree_create()
"""
try:
importlib.import_module(module_path)
except ImportError:
logger.error('cannot import %s', domain_name)
domain_tree_create()
"""
"""
try:
importlib.import_module(domain_name)
except ImportError:
click.echo('Error in domain creation')
logger.debug('%s', subprocess.run(['tree', 'a', os.getcwd()]))
raise Exception('cannot create domain {}'.format(domain_name))
"""
config.set('domains', domain_name, module_path)
write_config()
############### ###############
@ -97,146 +45,68 @@ def list_routes(domain, m_dom):
""" """
click.echo(f'\nDomain : {domain}\n') click.echo(f'\nDomain : {domain}\n')
routes = api_routes(m_dom)[0] routes = api_routes(m_dom)[0]
if len(routes): if len(routes):
for key, item in routes.items(): for key, item in routes.items():
methods = '|'.join(list(item.keys())) methods = '|'.join(list(item.keys()))
click.echo(f'\t{key} : {methods}') click.echo(f'\t{key} : {methods}')
else: else:
click.echo('\t**No ROUTES**') click.echo(f'\t**No ROUTES**')
raise Exception('Routeless domain')
def list_api_routes(): def list_api_routes():
""" """
Echoes the list of all active domains. Echoes the list of all active domains.
TODO: Rewrite function
""" """
click.echo('# API Routes') click.echo('# API Routes')
# for domain, m_dom in DOMAINSDICT().items(): for domain, m_dom in DOMAINSDICT().items():
# list_routes(domain, m_dom) list_routes(domain, m_dom)
@click.option('--devel',default=None, is_flag=True)
@click.option('--watch',default=False, is_flag=True)
@click.option('--production',default=None, is_flag=True)
@click.option('--port',default=None, type=int)
@click.option('--log-level',default=None, type=str)
@click.option('--dry-run',default=False, is_flag=True)
@click.option('--run',default=False, is_flag=True)
@click.option('--read',default=False, is_flag=True) @click.option('--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.option('--domains',default=None)
@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(domains, delete, update, create, read): #, domains, read, create, update, delete):
""" """
The "halfapi domain" command The "halfapi domain" command
Parameters: Parameters:
domain (str|None): The domain name domain (List[str]|None): The list of the domains to list/update
The parameter has a misleading name as it is a multiple option The parameter has a misleading name as it is a multiple option
but this would be strange to use it several times named as "domains" but this would be strange to use it several times named as "domains"
update (boolean): If set, update the database for the selected domains update (boolean): If set, update the database for the selected domains
""" """
if not domain:
if not domains:
if create: if create:
# TODO: Connect to the create_domain function # TODO: Connect to the create_domain function
raise NotImplementedError raise NotImplementedError
raise Exception('Missing domain name')
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
elif update:
raise NotImplementedError
elif delete:
raise NotImplementedError
elif read:
from ..halfapi import HalfAPI
halfapi = HalfAPI(CONFIG)
click.echo(orjson.dumps(
halfapi.domains[domain].schema(),
option=orjson.OPT_NON_STR_KEYS,
default=ORJSONResponse.default_cast)
)
domains = DOMAINSDICT().keys()
else: else:
if dry_run: domains_ = []
CONFIG['dryrun'] = True for domain_name in domains.split(','):
if domain_name in DOMAINSDICT().keys():
domains_.append(domain_name)
continue
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( click.echo(
toml.dumps(CONFIG) f'Domain {domain_name}s is not activated in the configuration')
)
else: domains = domains_
# domain section port is preferred, if it doesn't exist we use the global one
uvicorn_kwargs = {} for domain_name in domains:
if update:
raise NotImplementedError
if delete:
raise NotImplementedError
if CONFIG.get('port'): if read:
uvicorn_kwargs['port'] = CONFIG['port'] list_api_routes()
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)

View File

@ -5,6 +5,7 @@ cli/init.py Defines the "halfapi init" cli commands
Helps the user to create a new project Helps the user to create a new project
""" """
# builtins # builtins
import logging
import os import os
import sys import sys
import re import re
@ -16,9 +17,10 @@ from ..conf import CONF_DIR
from .cli import cli from .cli import cli
from ..logging import logger logger = logging.getLogger('halfapi')
TMPL_HALFAPI_ETC = """[project] TMPL_HALFAPI_ETC = """[project]
name = {project}
host = 127.0.0.1 host = 127.0.0.1
port = 8000 port = 8000
secret = /path/to/secret_file secret = /path/to/secret_file
@ -26,10 +28,20 @@ production = False
base_dir = {base_dir} base_dir = {base_dir}
""" """
def format_halfapi_etc(project, path):
"""
Returns the formatted template for /etc/half_api/PROJECT_NAME
"""
return TMPL_HALFAPI_ETC.format(
project=project,
base_dir=path
)
TMPL_HALFAPI_CONFIG = """[project] TMPL_HALFAPI_CONFIG = """[project]
name = {name}
halfapi_version = {halfapi_version} halfapi_version = {halfapi_version}
[domain] [domains]
""" """
@click.argument('project') @click.argument('project')
@ -55,7 +67,9 @@ def init(project):
with open(f'{project}/.halfapi/config', 'w') as conf_file: with open(f'{project}/.halfapi/config', 'w') as conf_file:
conf_file.write(TMPL_HALFAPI_CONFIG.format( conf_file.write(TMPL_HALFAPI_CONFIG.format(
name=project,
halfapi_version=__version__)) halfapi_version=__version__))
click.echo(f'Configure halfapi project in {CONF_DIR}/{project}') click.echo(f'Configure halfapi project in {CONF_DIR}/{project}')
click.echo(format_halfapi_etc(project, CONF_DIR))

View File

@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""
cli/routes.py Defines the "halfapi routes" cli commands
"""
# builtins
import sys
import importlib
from pprint import pprint
import orjson
import click
from .cli import cli
from ..logging import logger
# from ..lib.domain import domain_schema_dict
from ..lib.constants import DOMAIN_SCHEMA, ROUTE_SCHEMA
from ..lib.responses import ORJSONResponse
@click.argument('module', required=True)
@click.option('--export', default=False, is_flag=True)
@click.option('--validate', default=False, is_flag=True)
@click.option('--check', default=False, is_flag=True)
@click.option('--noheader', default=False, is_flag=True)
@click.option('--schema', default=False, is_flag=True)
@cli.command()
def routes(module, export=False, validate=False, check=False, noheader=False, schema=False):
"""
The "halfapi routes" command
"""
# try:
#  mod = importlib.import_module(module)
# except ImportError as exc:
#  raise click.BadParameter('Cannot import this module', param=module) from exc
# if export:
#  click.echo(schema_to_csv(module, header=not noheader))
# if schema:
#  routes_d = domain_schema_dict(mod)
#  ROUTE_SCHEMA.validate(routes_d)
#  click.echo(orjson.dumps(routes_d,
#  option=orjson.OPT_NON_STR_KEYS,
#  default=ORJSONResponse.default_cast))
# if validate:
#  routes_d = domain_schema_dict(mod)
#  try:
#  ROUTE_SCHEMA.validate(routes_d)
#  except Exception as exc:
#  raise exc

View File

@ -9,79 +9,47 @@ import uvicorn
from .cli import cli from .cli import cli
from .domain import list_api_routes from .domain import list_api_routes
from ..conf import CONFIG, SCHEMA from ..conf import (PROJECT_NAME, HOST, PORT,
from ..logging import logger PRODUCTION, LOGLEVEL, DOMAINSDICT)
from ..lib.schemas import schema_csv_dict
from ..half_domain import HalfDomain
@click.option('--host', default=CONFIG.get('host')) @click.option('--host', default=None)
@click.option('--port', default=CONFIG.get('port')) @click.option('--port', default=None)
@click.option('--reload', default=False) @click.option('--reload', default=False)
@click.option('--secret', default=CONFIG.get('secret'))
@click.option('--production', default=CONFIG.get('secret'))
@click.option('--loglevel', default=CONFIG.get('loglevel'))
@click.option('--prefix', default='/')
@click.option('--check', default=True)
@click.option('--dryrun', default=False, is_flag=True)
@click.argument('schema', type=click.File('r'), required=False)
@click.argument('domain', required=False)
@cli.command() @cli.command()
def run(host, port, reload, secret, production, loglevel, prefix, check, dryrun, def run(host, port, reload):
schema, domain):
""" """
The "halfapi run" command The "halfapi run" command
""" """
logger.debug('[run] host=%s port=%s reload=%s secret=%s production=%s loglevel=%s prefix=%s schema=%s',
host, port, reload, secret, production, loglevel, prefix, schema if not host:
) host = HOST
if not port:
port = PORT
port = int(port) port = int(port)
if production and reload: if PRODUCTION and reload:
reload = False reload = False
raise Exception('Can\'t use live code reload in production') raise Exception('Can\'t use live code reload in production')
click.echo(f'Launching application') log_level = LOGLEVEL or 'info'
if secret: click.echo(f'Launching application {PROJECT_NAME}')
CONFIG['secret'] = secret
if schema: sys.path.insert(0, os.getcwd())
# Populate the SCHEMA global with the data from the given file
for key, val in schema_csv_dict(schema, prefix).items():
SCHEMA[key] = val
if domain: list_api_routes()
# If we specify a domain to run as argument
if 'domain' not in CONFIG:
CONFIG['domain'] = {}
# Disable all domains
keys = list(CONFIG.get('domain').keys())
for key in keys:
CONFIG['domain'].pop(key)
# And activate the desired one, mounted without prefix
CONFIG['domain'][domain] = {
'name': domain,
'prefix': False,
'enabled': True
}
# list_api_routes()
click.echo(f'uvicorn.run("halfapi.app:application"\n' \ click.echo(f'uvicorn.run("halfapi.app:application"\n' \
f'host: {host}\n' \ f'host: {host}\n' \
f'port: {port}\n' \ f'port: {port}\n' \
f'log_level: {loglevel}\n' \ f'log_level: {log_level}\n' \
f'reload: {reload}\n' f'reload: {reload}\n'
) )
if dryrun:
CONFIG['dryrun'] = True
uvicorn.run('halfapi.app:application', uvicorn.run('halfapi.app:application',
host=host, host=host,
port=int(port), port=int(port),
log_level=loglevel, log_level=log_level,
reload=reload) reload=reload)

View File

@ -10,13 +10,15 @@ It uses the following environment variables :
It defines the following globals : It defines the following globals :
- PROJECT_NAME (str) - HALFAPI_PROJECT_NAME - PROJECT_NAME (str) - HALFAPI_PROJECT_NAME
- DOMAINSDICT ({domain_name: domain_module}) - HALFAPI_DOMAIN_NAME / HALFAPI_DOMAIN_MODULE
- PRODUCTION (bool) - HALFAPI_PRODUCTION - PRODUCTION (bool) - HALFAPI_PRODUCTION
- LOGLEVEL (str) - HALFAPI_LOGLEVEL - LOGLEVEL (string) - HALFAPI_LOGLEVEL
- BASE_DIR (str) - HALFAPI_BASE_DIR - BASE_DIR (str) - HALFAPI_BASE_DIR
- HOST (str) - HALFAPI_HOST - HOST (str) - HALFAPI_HOST
- PORT (int) - HALFAPI_PORT - PORT (int) - HALFAPI_PORT
- CONF_DIR (str) - HALFAPI_CONF_DIR - CONF_DIR (str) - HALFAPI_CONF_DIR
- DRYRUN (bool) - HALFAPI_DRYRUN - IS_PROJECT (bool)
- config (ConfigParser)
It reads the following ressource : It reads the following ressource :
@ -25,181 +27,130 @@ It reads the following ressource :
It follows the following format : It follows the following format :
[project] [project]
name = PROJECT_NAME
halfapi_version = HALFAPI_VERSION halfapi_version = HALFAPI_VERSION
[domain.domain_name] [domains]
name = domain_name domain_name = requirements-like-url
routers = routers
[domain.domain_name.config]
option = Argh
""" """
from .logging import logger import logging
import os import os
from os import environ from os import environ
import sys import sys
from configparser import ConfigParser
import importlib import importlib
import tempfile
import uuid
import toml from .lib.domain import d_domains
from .logging import logger
SCHEMA = {}
DEFAULT_CONF = { PROJECT_NAME = environ.get('HALFAPI_PROJECT_NAME') or os.path.basename(os.getcwd())
# Default configuration values DOMAINSDICT = lambda: {}
'SECRET': tempfile.mkstemp()[1], DOMAINS = {}
'PROJECT_NAME': os.getcwd().split('/')[-1], PRODUCTION = False
'PRODUCTION': True, LOGLEVEL = 'info'
'HOST': '127.0.0.1', HOST = '127.0.0.1'
'PORT': 3000, PORT = '3000'
'LOGLEVEL': 'info', SECRET = ''
'BASE_DIR': os.getcwd(), CONF_FILE = os.environ.get('HALFAPI_CONF_FILE', '.halfapi/config')
'CONF_FILE': '.halfapi/config',
'CONF_DIR': '/etc/half_api',
'DRYRUN': None
}
PROJECT_LEVEL_KEYS = { is_project = lambda: os.path.isfile(CONF_FILE)
# 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'])
config = ConfigParser(allow_no_value=True)
CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api')
HALFAPI_ETC_FILE=os.path.join( HALFAPI_ETC_FILE=os.path.join(
CONF_DIR, 'config' CONF_DIR, 'default.ini'
) )
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 = [ CONF_FILE, HALFAPI_DOT_FILE ]
try: def conf_files():
with open(HALFAPI_ETC_FILE, 'r'): return [
HALFAPI_CONFIG_FILES.append(HALFAPI_ETC_FILE) os.path.join(
except FileNotFoundError: CONF_DIR, 'default.ini'
logger.info('Cannot find a configuration file under %s', HALFAPI_ETC_FILE) ),
os.path.join(
try: os.getcwd(), '.halfapi', 'config')]
with open(HALFAPI_DOT_FILE, 'r'):
HALFAPI_CONFIG_FILES.append(HALFAPI_DOT_FILE)
except FileNotFoundError:
logger.info('Cannot find a configuration file under %s', HALFAPI_DOT_FILE)
ENVIRONMENT = {} def write_config():
# Load environment variables allowed in configuration """
Writes the current config to the highest priority config file
"""
with open(conf_files()[-1], 'w') as halfapi_config:
config.write(halfapi_config)
if 'HALFAPI_DRYRUN' in os.environ: def config_dict():
ENVIRONMENT['dryrun'] = True """
The config object as a dict
"""
return {
section: dict(config.items(section))
for section in config.sections()
}
if 'HALFAPI_PROD' in os.environ: def read_config():
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 = {} config.read(HALFAPI_CONFIG_FILES)
logger.info('Reading config files %s', filenames)
for CONF_FILE in filenames:
if os.path.isfile(CONF_FILE):
conf_dict = toml.load(CONF_FILE)
d_res.update(conf_dict)
logger.info('Read config files (result) %s', d_res)
return { **d_res.get('project', {}), 'domain': d_res.get('domain', {}) }
CONFIG = read_config()
CONFIG.update(**ENVIRONMENT)
PROJECT_NAME = CONFIG.get('project_name',
os.environ.get('HALFAPI_PROJECT_NAME', DEFAULT_CONF['PROJECT_NAME']))
if os.environ.get('HALFAPI_DOMAIN_NAME'):
# Force enabled domain by environment variable
DOMAIN_NAME = os.environ.get('HALFAPI_DOMAIN_NAME')
if 'domain' in CONFIG and DOMAIN_NAME in CONFIG['domain'] \
and 'config' in CONFIG['domain'][DOMAIN_NAME]:
domain_config = CONFIG['domain'][DOMAIN_NAME]['config']
else:
domain_config = {}
CONFIG['domain'] = {}
CONFIG['domain'][DOMAIN_NAME] = {
'enabled': True,
'name': DOMAIN_NAME,
'prefix': False
}
CONFIG['domain'][DOMAIN_NAME]['config'] = domain_config
if os.environ.get('HALFAPI_DOMAIN_MODULE'):
# Specify the pythonpath to import the specified domain (defaults to global)
dom_module = os.environ.get('HALFAPI_DOMAIN_MODULE')
CONFIG['domain'][DOMAIN_NAME]['module'] = dom_module
if len(CONFIG.get('domain', {}).keys()) == 0:
logger.info('No domains')
# Secret
if 'secret' not in CONFIG: CONFIG = {}
# TODO: Create a temporary secret read_config()
CONFIG['secret'] = DEFAULT_CONF['SECRET']
with open(CONFIG['secret'], 'w') as secret_file: IS_PROJECT = True
secret_file.write(str(uuid.uuid4()))
PROJECT_NAME = config.get('project', 'name', fallback=PROJECT_NAME)
if len(PROJECT_NAME) == 0:
raise Exception('Need a project name as argument')
DOMAINSDICT = lambda: d_domains(config)
DOMAINS = DOMAINSDICT()
if len(DOMAINS) == 0:
logger.info('Domain-less instance %s', d_domains(config))
HOST = config.get('project', 'host', fallback=environ.get('HALFAPI_HOST', '127.0.0.1'))
PORT = config.getint('project', 'port', fallback=environ.get('HALFAPI_PORT', '3000'))
try: try:
with open(CONFIG['secret'], 'r') as secret_file: with open(config.get('project', 'secret',
CONFIG['secret'] = CONFIG['secret'].strip() fallback=environ.get('HALFAPI_SECRET', ''))) as secret_file:
SECRET = secret_file.read().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.error('Missing secret file: %s', exc)
CONFIG.setdefault('project_name', DEFAULT_CONF['PROJECT_NAME']) PRODUCTION = config.getboolean('project', 'production',
CONFIG.setdefault('production', DEFAULT_CONF['PRODUCTION']) fallback=environ.get('HALFAPI_PROD', False))
CONFIG.setdefault('host', DEFAULT_CONF['HOST'])
CONFIG.setdefault('port', DEFAULT_CONF['PORT'])
CONFIG.setdefault('loglevel', DEFAULT_CONF['LOGLEVEL'])
CONFIG.setdefault('dryrun', DEFAULT_CONF['DRYRUN'])
# !!!TO REMOVE!!! LOGLEVEL = config.get('project', 'loglevel',
SECRET = CONFIG.get('secret') fallback=environ.get('HALFAPI_LOGLEVEL', 'info')).lower()
PRODUCTION = CONFIG.get('production')
# !!! BASE_DIR = config.get('project', 'base_dir',
fallback=environ.get('HALFAPI_BASE_DIR', '.'))
CONFIG = {
'project_name': PROJECT_NAME,
'production': PRODUCTION,
'secret': SECRET,
'domains': DOMAINS,
'domain_config': {}
}
for domain in DOMAINS:
if domain not in config.sections():
continue
CONFIG['domain_config'][domain] = dict(config.items(domain))

View File

@ -1,513 +0,0 @@
import importlib
import inspect
import os
import re
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from typing import Coroutine, Dict, Iterator, List, Tuple
from types import ModuleType, FunctionType
from schema import SchemaError
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Router, Route
from starlette.schemas import SchemaGenerator
from .lib.acl import AclRoute
from .lib.responses import ORJSONResponse
import yaml
from . import __version__
from .lib.constants import API_SCHEMA_DICT, ROUTER_SCHEMA, VERBS
from .half_route import HalfRoute
from .lib import acl as lib_acl
from .lib.responses import PlainTextResponse
from .lib.routes import JSONRoute
from .lib.schemas import param_docstring_default
from .lib.domain import MissingAclError, PathError, UnknownPathParameterType, \
UndefinedRoute, UndefinedFunction, get_fct_name, route_decorator
from .lib.domain_middleware import DomainMiddleware
from .logging import logger
class HalfDomain(Starlette):
def __init__(self, domain, module=None, router=None, acl=None, app=None):
"""
Parameters:
domain (str): Module name (should be importable)
router (str): Router name (should be importable from domain module
defaults to __router__ variable from domain module)
app (HalfAPI): The app instance
"""
self.app = app
self.m_domain = importlib.import_module(domain) if module is None else module
self.d_domain = getattr(self.m_domain, 'domain', domain)
self.name = self.d_domain['name']
self.id = self.d_domain['id']
self.version = self.d_domain['version']
self.halfapi_version = self.d_domain.get('halfapi_version', __version__)
self.deps = self.d_domain.get('deps', tuple())
self.schema_components = self.d_domain.get('schema_components', dict())
if not router:
self.router = self.d_domain.get('routers', '.routers')
else:
self.router = router
self.m_router = None
try:
self.m_router = importlib.import_module(self.router, self.m_domain.__package__)
except AttributeError:
raise Exception('no router module')
self.m_acl = HalfDomain.m_acl(self.m_domain, acl)
self.config = { **app.config }
logger.info('HalfDomain creation %s %s', domain, self.config)
for elt in self.deps:
package, version = elt
specifier = SpecifierSet(version)
package_module = importlib.import_module(package)
if Version(package_module.__version__) not in specifier:
raise Exception(
'Wrong version for package {} version {} (excepting {})'.format(
package, package_module.__version__, specifier
))
super().__init__(
routes=self.gen_domain_routes(),
middleware=[
Middleware(
DomainMiddleware,
domain={
'name': self.name,
'id': self.id,
'version': self.version,
'halfapi_version': self.halfapi_version,
'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
def m_acl(module, acl=None):
""" Returns the imported acl module for the domain module
"""
if not acl:
acl = getattr(module, '__acl__', '.acl')
return importlib.import_module(acl, module.__package__)
@staticmethod
def acls(module, acl=None):
""" Returns the ACLS constant for the given domain
"""
m_acl = HalfDomain.m_acl(module, acl)
try:
return [
lib_acl.ACL(*elt)
for elt in getattr(m_acl, 'ACLS')
]
except AttributeError as exc:
logger.error(exc)
raise Exception(
f'Missing acl.ACLS constant in module {m_acl.__package__}') from exc
@staticmethod
def acls_route(domain, module_path=None, acl=None):
""" Dictionary of acls
Format :
{
[acl_name]: {
callable: fct_reference,
docs: fct_docstring,
}
}
"""
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
}
return d_res
@staticmethod
def acls_router(domain, module_path=None, acl=None):
""" Returns a Router object with the following routes :
/ : The "acls" field of the API metadatas
/{acl_name} : If the ACL is defined as public, a route that returns either status code 200 or 401 on HEAD/GET request
"""
routes = []
d_res = {}
module = importlib.import_module(domain) \
if module_path is None \
else importlib.import_module(module_path)
m_acl = HalfDomain.m_acl(module, acl)
for elt in HalfDomain.acls(module, acl=acl):
fct = getattr(m_acl, elt.name)
d_res[elt.name] = {
'callable': fct,
'docs': elt.documentation,
'public': elt.public
}
if elt.public:
try:
if inspect.iscoroutinefunction(fct):
logger.warning('async decorator are not yet supported')
else:
inner = fct()
if inspect.iscoroutinefunction(fct) or callable(inner):
fct = inner
except TypeError:
# Fct is not a decorator or is not well called (has no default arguments)
# We can ignore this
pass
routes.append(
AclRoute(f'/{elt.name}', fct, elt)
)
d_res_under_domain_name = {}
d_res_under_domain_name[HalfDomain.name(module)] = d_res
routes.append(
Route(
'/',
JSONRoute(d_res_under_domain_name),
methods=['GET']
)
)
return Router(routes)
@staticmethod
def gen_routes(m_router: ModuleType,
verb: str,
path: List[str],
params: List[Dict],
path_param_docstrings: Dict[str, str] = {}) -> Tuple[FunctionType, Dict]:
"""
Returns a tuple of the function associatied to the verb and path arguments,
and the dictionary of it's acls
Parameters:
- m_router (ModuleType): The module containing the function definition
- verb (str): The HTTP verb for the route (GET, POST, ...)
- path (List): The route path, as a list (each item being a level of
deepness), from the lowest level (domain) to the highest
- params (Dict): The acl list of the following format :
[{'acl': Function, 'args': {'required': [], 'optional': []}}]
Returns:
(Function, Dict): The destination function and the acl dictionary
"""
if len(params) == 0:
raise MissingAclError('[{}] {}'.format(verb, '/'.join(path)))
if len(path) == 0:
logger.error('Empty path for [{%s}]', verb)
raise PathError()
fct_name = get_fct_name(verb, path[-1])
if hasattr(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:
raise UndefinedFunction('{}.{}'.format(m_router.__name__, fct_name or ''))
if not inspect.iscoroutinefunction(fct):
return route_decorator(fct), params
# TODO: Remove when using only sync functions
return lib_acl.args_check(fct), params
@staticmethod
def gen_router_routes(m_router, path: List[str], PATH_PARAMS={}) -> \
Iterator[Tuple[str, str, ModuleType, Coroutine, List]]:
"""
Recursive generator that parses a router (or a subrouter)
and yields from gen_routes
Parameters:
- m_router (ModuleType): The currently treated router module
- path (List[str]): The current path stack
Yields:
(str, str, ModuleType, Coroutine, List): A tuple containing the path, verb,
router module, function reference and parameters of the route.
Function and parameters are yielded from then gen_routes function,
that decorates the endpoint function.
"""
for subpath, params in HalfDomain.read_router(m_router).items():
path.append(subpath)
for verb in VERBS:
if verb not in params:
continue
yield ('/'.join(filter(lambda x: len(x) > 0, path)),
verb,
m_router,
*HalfDomain.gen_routes(m_router, verb, path, params[verb], PATH_PARAMS)
)
for subroute in params.get('SUBROUTES', []):
subroute_module = importlib.import_module(f'.{subroute}', m_router.__name__)
param_match = re.fullmatch('^([A-Z_]+)_([a-z]+)$', subroute)
parameter_name = None
if param_match is not None:
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(
parameter_name,
parameter_type,
)
)
try:
PATH_PARAMS[parameter_name] = subroute_module.param_docstring
except AttributeError as exc:
PATH_PARAMS[parameter_name] = param_docstring_default(parameter_name, parameter_type)
except AssertionError as exc:
raise UnknownPathParameterType(subroute) from exc
else:
path.append(subroute)
try:
yield from HalfDomain.gen_router_routes(
subroute_module,
path,
PATH_PARAMS
)
except ImportError as exc:
logger.error('Failed to import subroute **{%s}**', subroute)
raise exc
path.pop()
if parameter_name:
PATH_PARAMS.pop(parameter_name)
path.pop()
@staticmethod
def read_router(m_router: ModuleType) -> Dict:
"""
Reads a module and returns a router dict
If the module has a "ROUTES" constant, it just returns this constant,
Else, if the module has an "ACLS" constant, it builds the accurate dict
TODO: May be another thing, may be not a part of halfAPI
"""
m_path = None
try:
if not hasattr(m_router, 'ROUTES'):
routes = {'':{}}
acls = getattr(m_router, 'ACLS') if hasattr(m_router, 'ACLS') else None
if acls is not None:
for method in acls.keys():
if method not in VERBS:
raise Exception(
'This method is not handled: {}'.format(method))
routes[''][method] = []
routes[''][method] = acls[method].copy()
routes['']['SUBROUTES'] = []
if hasattr(m_router, '__path__'):
""" Module is a package
"""
m_path = getattr(m_router, '__path__')
if isinstance(m_path, list) and len(m_path) == 1:
routes['']['SUBROUTES'] = [
elt.name
for elt in os.scandir(m_path[0])
if elt.is_dir()
]
else:
routes = getattr(m_router, 'ROUTES')
try:
ROUTER_SCHEMA.validate(routes)
except SchemaError as exc:
logger.error(routes)
raise exc
return routes
except ImportError as exc:
# TODO: Proper exception handling
raise exc
except FileNotFoundError as exc:
# TODO: Proper exception handling
logger.error(m_path)
raise exc
def gen_domain_routes(self):
"""
Yields the Route objects for a domain
Parameters:
m_domains: ModuleType
Returns:
Generator(HalfRoute)
"""
yield HalfRoute('/',
self.schema_openapi(),
[{'acl': lib_acl.public}],
'GET'
)
for path, method, m_router, fct, params in HalfDomain.gen_router_routes(self.m_router, []):
yield HalfRoute(f'/{path}', fct, params, method)
def schema_dict(self) -> Dict:
""" gen_router_routes return values as a dict
Parameters:
m_router (ModuleType): The domain routers' module
Returns:
Dict: Schema of dict is halfapi.lib.constants.DOMAIN_SCHEMA
@TODO: Should be a "router_schema_dict" function
"""
d_res = {}
for path, verb, m_router, fct, parameters in HalfDomain.gen_router_routes(self.m_router, []):
if path not in d_res:
d_res[path] = {}
if verb not in d_res[path]:
d_res[path][verb] = {}
d_res[path][verb]['callable'] = f'{m_router.__name__}:{fct.__name__}'
try:
d_res[path][verb]['docs'] = yaml.safe_load(fct.__doc__)
except AttributeError:
logger.error(
'Cannot read docstring from fct (fct=%s path=%s verb=%s', fct.__name__, path, verb)
d_res[path][verb]['acls'] = list(map(lambda elt: { **elt, 'acl': elt['acl'].__name__ },
parameters))
return d_res
def schema(self) -> Dict:
schema = { **API_SCHEMA_DICT }
schema['domain'] = {
'name': self.name,
'id': self.id,
'version': getattr(self.m_domain, '__version__', ''),
'patch_release': getattr(self.m_domain, '__patch_release__', ''),
'routers': self.m_router.__name__,
'acls': tuple(getattr(self.m_acl, 'ACLS', ()))
}
schema['paths'] = self.schema_dict()
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

@ -1,109 +0,0 @@
""" HalfRoute
Child class of starlette.routing.Route
"""
from functools import partial, wraps
from typing import Callable, Coroutine, List, Dict
from types import FunctionType
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.exceptions import HTTPException
from .logging import logger
from .lib.domain import MissingAclError, PathError, UnknownPathParameterType, \
UndefinedRoute, UndefinedFunction
class HalfRoute(Route):
""" HalfRoute
"""
def __init__(self, path: List[str], fct: Callable, params: List[Dict], method: str):
logger.info('HalfRoute creation: %s %s %s %s', path, fct, params, method)
if len(params) == 0:
raise MissingAclError('[{}] {}'.format(method, '/'.join(path)))
if len(path) == 0:
logger.error('Empty path for [{%s}]', method)
raise PathError()
super().__init__(
path,
HalfRoute.acl_decorator(
fct,
params
),
methods=[method])
@staticmethod
def acl_decorator(fct: Callable = None, params: List[Dict] = None) -> Coroutine:
"""
Decorator for async functions that calls pre-conditions functions
and appends kwargs to the target function
Parameters:
fct (Callable):
The function to decorate
params List[Dict]:
A list of dicts that have an "acl" key that points to a function
Returns:
async function
"""
if not params:
params = []
if not fct:
return partial(HalfRoute.acl_decorator, params=params)
@wraps(fct)
async def caller(req: Request, *args, **kwargs):
for param in params:
if param.get('acl'):
passed = param['acl'](req, *args, **kwargs)
if isinstance(passed, FunctionType):
passed = param['acl']()(req, *args, **kwargs)
if not passed:
logger.debug(
'ACL FAIL for current route (%s - %s)', fct, param.get('acl'))
continue
logger.debug(
'ACL OK for current route (%s - %s)', fct, param.get('acl'))
req.scope['acl_pass'] = param['acl'].__name__
if 'args' in param:
req.scope['args'] = param['args']
logger.debug(
'Args for current route (%s)', param.get('args'))
if 'out' in param:
req.scope['out'] = param['out']
if 'out' in param:
req.scope['out'] = param['out'].copy()
if 'check' in req.query_params:
return PlainTextResponse(param['acl'].__name__)
logger.debug('acl_decorator %s', param)
logger.debug('calling %s:%s %s %s', fct.__module__, fct.__name__, args, kwargs)
return await fct(
req, *args,
**{
**kwargs,
})
if 'check' in req.query_params:
return PlainTextResponse('')
raise HTTPException(401)
return caller

View File

@ -1,316 +0,0 @@
#!/usr/bin/env python3
"""
app.py is the file that is read when launching the application using an asgi
runner.
It defines the following globals :
- routes (contains the Route objects for the application)
- application (the asgi application itself - a starlette object)
"""
import sys
import logging
import time
import importlib
from datetime import datetime
# asgi framework
from starlette.applications import Starlette
from starlette.authentication import UnauthenticatedUser
from starlette.exceptions import HTTPException
from starlette.middleware import Middleware
from starlette.routing import Router, Route, Mount
from starlette.requests import Request
from starlette.responses import Response, PlainTextResponse
from starlette.middleware.authentication import AuthenticationMiddleware
from timing_asgi import TimingMiddleware
from timing_asgi.integrations import StarletteScopeToName
# module libraries
from .lib.constants import API_SCHEMA_DICT
from .lib.domain_middleware import DomainMiddleware
from .lib.timing import HTimingClient
from .lib.jwt_middleware import JWTAuthenticationBackend, on_auth_error
from .lib.responses import (ORJSONResponse, UnauthorizedResponse,
NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse,
ServiceUnavailableResponse, gen_exception_route)
from .lib.domain import NoDomainsException
from .lib.routes import gen_schema_routes, JSONRoute
from .lib.schemas import schema_json
from .logging import logger, config_logging
from .half_domain import HalfDomain
from halfapi import __version__
class HalfAPI(Starlette):
def __init__(self,
config,
d_routes=None):
# Set log level (defaults to debug)
config_logging(
getattr(logging, config.get('loglevel', 'DEBUG').upper(), 'DEBUG')
)
self.config = config
SECRET = self.config.get('secret')
PRODUCTION = self.config.get('production', True)
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.SECRET = SECRET
# Domains
""" HalfAPI routes (if not PRODUCTION, includes debug routes)
"""
routes = []
routes.append(
Mount('/halfapi', routes=list(self.halfapi_routes()))
)
logger.debug('Config: %s', self.config)
domains = {
key: elt
for key, elt in self.config.get('domain', {}).items()
if elt.get('enabled', False)
}
logger.debug('Active domains: %s', domains)
if d_routes:
# Mount the routes from the d_routes argument - domain-less mode
logger.info('Domain-less mode : the given schema defines the activated routes')
for route in gen_schema_routes(d_routes):
routes.append(route)
else:
pass
startup_fcts = []
if DRYRUN:
startup_fcts.append(
HalfAPI.wait_quit()
)
super().__init__(
debug=not PRODUCTION,
routes=routes,
exception_handlers={
401: gen_exception_route(UnauthorizedResponse),
404: gen_exception_route(NotFoundResponse),
500: gen_exception_route(HalfAPI.exception),
501: gen_exception_route(NotImplementedResponse),
503: gen_exception_route(ServiceUnavailableResponse)
}
)
schemas = []
self.__domains = {}
for key, domain in domains.items():
if not isinstance(domain, dict):
continue
dom_name = domain.get('name', key)
if not domain.get('enabled', False):
continue
if not domain.get('prefix', False):
if len(domains.keys()) > 1:
raise Exception('Cannot use multiple domains and set prefix to false')
path = '/'
else:
path = f'/{dom_name}'
logger.debug('Mounting domain %s on %s', domain.get('name'), path)
domain_key = domain.get('name', key)
add_domain_args = {
**domain,
'path': path
}
self.add_domain(**add_domain_args)
schemas.append(self.__domains[domain_key].schema())
self.add_route('/', JSONRoute(schemas))
if SECRET:
self.add_middleware(
AuthenticationMiddleware,
backend=JWTAuthenticationBackend(),
on_error=on_auth_error
)
if not PRODUCTION and TIMINGMIDDLEWARE:
self.add_middleware(
TimingMiddleware,
client=HTimingClient(),
metric_namer=StarletteScopeToName(prefix="halfapi",
starlette_app=self)
)
@property
def version(self):
return __version__
async def version_async(self, request, *args, **kwargs):
"""
description: Version route
responses:
200:
description: Currently running HalfAPI's version
"""
return Response(self.version)
@staticmethod
async def exception(request: Request, exc: HTTPException):
logger.critical(exc, exc_info=True)
return InternalServerErrorResponse()
@property
def application(self):
return self
def halfapi_routes(self):
""" Halfapi default routes
"""
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})
yield Route('/whoami', get_user)
yield Route('/schema', schema_json)
yield Mount('/acls', self.acls_router())
yield Route('/version', self.version_async)
""" Halfapi debug routes definition
"""
if self.PRODUCTION:
return
""" Debug routes
"""
async def debug_log(request: Request, *args, **kwargs):
logger.debug('debuglog# %s', {datetime.now().isoformat()})
logger.info('debuglog# %s', {datetime.now().isoformat()})
logger.warning('debuglog# %s', {datetime.now().isoformat()})
logger.error('debuglog# %s', {datetime.now().isoformat()})
logger.critical('debuglog# %s', {datetime.now().isoformat()})
return Response('')
yield Route('/log', debug_log)
async def error_code(request: Request, *args, **kwargs):
code = request.path_params['code']
raise HTTPException(code)
yield Route('/error/{code:int}', error_code)
async def exception(request: Request, *args, **kwargs):
raise Exception('Test exception')
yield Route('/exception', exception)
@staticmethod
def api_schema(domain):
pass
@staticmethod
def wait_quit():
""" sleeps 1 second and quits. used in dry-run mode
"""
import time
import sys
time.sleep(1)
sys.exit(0)
def acls_router(self):
mounts = {}
for domain, domain_conf in self.config.get('domain', {}).items():
if isinstance(domain_conf, dict) and domain_conf.get('enabled', False):
mounts['domain'] = HalfDomain.acls_router(
domain,
module_path=domain_conf.get('module'),
acl=domain_conf.get('acl')
)
if len(mounts) > 1:
return Router([
Mount(f'/{domain}', acls_router)
for domain, acls_router in mounts.items()
])
elif len(mounts) == 1:
return Mount('/', mounts.popitem()[1])
else:
return Router()
@property
def domains(self):
return self.__domains
def add_domain(self, **kwargs):
if not kwargs.get('enabled'):
raise Exception(f'Domain not enabled ({kwargs})')
name = kwargs['name']
self.config['domain'][name] = kwargs.get('config', {})
if not kwargs.get('module'):
module = name
else:
module = kwargs.get('module')
try:
self.__domains[name] = HalfDomain(
name,
module=importlib.import_module(module),
router=kwargs.get('router'),
acl=kwargs.get('acl'),
app=self
)
except ImportError as exc:
print(
'Cannot instantiate HalfDomain {} with module {}'.format(
name,
module
))
raise exc
self.mount(kwargs.get('path', name), self.__domains[name])
return self.__domains[name]
def __main__():
return HalfAPI(CONFIG).application
if __name__ == '__main__':
__main__()

View File

@ -2,16 +2,14 @@
""" """
Base ACL module that contains generic functions for domains ACL Base ACL module that contains generic functions for domains ACL
""" """
from dataclasses import dataclass import logging
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
logger = logging.getLogger('uvicorn.asgi')
def public(*args, **kwargs) -> bool: def public(*args, **kwargs) -> bool:
"Unlimited access" "Unlimited access"
@ -32,9 +30,7 @@ def connected(fct=public):
or not hasattr(req.user, 'is_authenticated')): or not hasattr(req.user, 'is_authenticated')):
return False return False
if hasattr(req, 'path_params'):
return fct(req, **{**kwargs, **req.path_params}) return fct(req, **{**kwargs, **req.path_params})
return fct(req, **{**kwargs})
return caller return caller
@ -57,26 +53,14 @@ def args_check(fct):
data_ = {} data_ = {}
if req.method == 'GET': if req.method == 'GET':
data_ = dict(req.query_params) data_ = req.query_params
elif req.method in ['POST', 'PATCH', 'PUT', 'DELETE']: if req.method in ['POST', 'PATCH', 'PUT', 'DELETE']:
if req.scope.get('headers'):
if b'content-type' not in dict(req.scope.get('headers')):
data_ = {}
else:
content_type = dict(req.scope.get('headers')).get(b'content-type').decode().split(';')[0]
if content_type == 'application/json':
try: try:
data_ = await req.json() data_ = await req.json()
except JSONDecodeError as exc: except JSONDecodeError as exc:
logger.debug('Posted data was not JSON') logger.debug('Posted data was not JSON')
pass pass
elif content_type in [
'multipart/form-data', 'application/x-www-form-urlencoded']:
data_ = dict(await req.form())
else:
data_ = await req.body()
def plural(array: list) -> str: def plural(array: list) -> str:
return 's' if len(array) > 1 else '' return 's' if len(array) > 1 else ''
@ -84,8 +68,8 @@ def args_check(fct):
return ', '.join(array) return ', '.join(array)
args_d = req.scope.get('args') args_d = kwargs.get('args', None)
if args_d is not None and isinstance(data_, dict): if args_d is not None:
required = args_d.get('required', set()) required = args_d.get('required', set())
missing = [] missing = []
@ -106,73 +90,12 @@ def args_check(fct):
if key in data_: if key in data_:
data[key] = data_[key] data[key] = data_[key]
else: else:
""" Unsafe mode, without specified arguments, or plain text mode """ Unsafe mode, without specified arguments
""" """
data = data_ data = data_
kwargs['data'] = data kwargs['data'] = data
out_s = req.scope.get('out')
if out_s:
kwargs['out'] = list(out_s)
return await fct(req, *args, **kwargs) return await fct(req, *args, **kwargs)
return caller return caller
# ACLS list for doc and priorities
# 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 = (
('private', private.__doc__, 0, True),
('public', public.__doc__, 999, True)
)
@dataclass
class ACL():
name: str
documentation: str
priority: int
public: bool = False
class AclRoute(Route):
def __init__(self, path, acl_fct, acl: ACL):
self.acl_fct = acl_fct
self.name = acl.name
self.description = acl.documentation
self.docstring = yaml.dump({
'description': f'{self.name}: {self.description}',
'responses': {
'200': {
'description': 'ACL OK'
},
'401': {
'description': 'ACL FAIL'
}
}
})
async def endpoint(request, *args, **kwargs):
if request.method == 'GET':
logger.warning('Deprecated since 0.6.28, use HEAD method since now')
if self.acl_fct(request, *args, **kwargs) is True:
return Response(status_code=200)
return Response(status_code=401)
endpoint.__doc__ = self.docstring
return super().__init__(path, methods=['HEAD', 'GET'], endpoint=endpoint)

View File

@ -1,71 +1 @@
import re
from schema import Schema, Optional, Or
from .. import __version__
VERBS = ('GET', 'POST', 'PUT', 'PATCH', 'DELETE') VERBS = ('GET', 'POST', 'PUT', 'PATCH', 'DELETE')
ITERABLE_STR = Or([ str ], { str }, ( str ))
ACLS_SCHEMA = Schema([{
'acl': str,
Optional('args'): {
Optional('required'): ITERABLE_STR,
Optional('optional'): ITERABLE_STR
},
Optional('out'): ITERABLE_STR
}])
ROUTER_ACLS_SCHEMA = Schema([{
'acl': lambda n: callable(n),
Optional('args'): {
Optional('required'): ITERABLE_STR,
Optional('optional'): ITERABLE_STR
},
Optional('out'): ITERABLE_STR
}])
is_callable_dotted_notation = lambda x: re.match(
r'^(([a-zA-Z_])+\.?)*:[a-zA-Z_]+$', 'ab_c.TEST:get')
ROUTE_SCHEMA = Schema({
Optional(str): { # path - Optional when no routes
str: { # method
'callable': is_callable_dotted_notation,
'docs': lambda n: True, # Should validate an openAPI spec
'acls': ACLS_SCHEMA
}
}
})
DOMAIN_SCHEMA = Schema({
'name': str,
'id': str,
Optional('routers'): str,
Optional('version'): str,
Optional('patch_release'): str,
Optional('acls'): [
[str, str, int, Optional(bool)]
]
})
API_SCHEMA_DICT = {
'openapi': '3.0.0',
'info': {
'title': 'HalfAPI',
'version': __version__
},
}
API_SCHEMA = Schema({
**API_SCHEMA_DICT,
'domain': DOMAIN_SCHEMA,
'paths': ROUTE_SCHEMA
})
ROUTER_SCHEMA = Schema({
Or('', str): {
# Optional('GET'): [],#ACLS_SCHEMA,
Optional(Or(*VERBS)): ROUTER_ACLS_SCHEMA,
Optional('SUBROUTES'): [Optional(str)]
}
})

View File

@ -3,146 +3,76 @@
lib/domain.py The domain-scoped utility functions lib/domain.py The domain-scoped utility functions
""" """
import os
import re import re
import sys import sys
import importlib import importlib
import inspect import inspect
from functools import wraps import logging
from types import ModuleType, FunctionType from types import ModuleType, FunctionType
from typing import Coroutine, Generator from typing import Coroutine, Generator
from typing import Dict, List, Tuple from typing import Dict, List, Tuple, Iterator
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
# 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
from ..logging import logger logger = logging.getLogger("uvicorn.asgi")
class MissingAclError(Exception): class MissingAclError(Exception):
""" Exception to use when no acl are specified for a route
"""
pass pass
class PathError(Exception): class PathError(Exception):
""" Exception to use when the path for a route is malformed
"""
pass pass
class UnknownPathParameterType(Exception): class UnknownPathParameterType(Exception):
""" Exception to use when the path parameter for a route is not supported
"""
pass pass
class UndefinedRoute(Exception): class UndefinedRoute(Exception):
""" Exception to use when the route definition cannot be found
"""
pass pass
class UndefinedFunction(Exception): class UndefinedFunction(Exception):
""" Exception to use when a function definition cannot be found
"""
pass pass
class NoDomainsException(Exception): def route_decorator(fct: FunctionType, ret_type: str = 'json') -> Coroutine:
""" The exception that is raised when HalfAPI is called without domains
"""
pass
def route_decorator(fct: FunctionType) -> 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) if ret_type == 'json':
@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:
fct_args['halfapi'] = { fct_args['halfapi'] = {
'user': request.user if 'user': request.user if
'user' in request else None, 'user' in request else None,
'config': request.scope.get('config', {}), 'config': request.scope['config'],
'domain': request.scope.get('domain', 'unknown'), 'domain': request.scope['domain'],
'cookies': request.cookies,
'base_url': request.base_url,
'url': request.url
} }
if 'data' in fct_args_spec: if 'data' in fct_args_spec:
if 'data' in fct_args_defaults_dict: fct_args['data'] = kwargs.get('data')
fct_args['data'] = fct_args_defaults_dict['data']
else:
fct_args['data'] = {}
fct_args['data'].update(kwargs.get('data', {}))
if 'out' in fct_args_spec:
fct_args['out'] = kwargs.get('out')
""" If format argument is specified (either by get, post param or function argument)
"""
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')
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':
return ORJSONResponse(fct(**fct_args)) return ORJSONResponse(fct(**fct_args))
if ret_type == 'ods':
res = fct(**fct_args)
assert isinstance(res, list)
for elt in res:
assert isinstance(elt, dict)
return ODSResponse(res)
if ret_type == 'xlsx':
res = fct(**fct_args)
assert isinstance(res, list)
for elt in res:
assert isinstance(elt, dict)
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
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
else:
raise Exception('Return type not available')
return wrapped return wrapped
@ -190,3 +120,170 @@ def get_fct_name(http_verb: str, path: str) -> str:
return '_'.join(fct_name) return '_'.join(fct_name)
def gen_routes(m_router: ModuleType,
verb: str,
path: List[str],
params: List[Dict]) -> Tuple[FunctionType, Dict]:
"""
Returns a tuple of the function associatied to the verb and path arguments,
and the dictionary of it's acls
Parameters:
- m_router (ModuleType): The module containing the function definition
- verb (str): The HTTP verb for the route (GET, POST, ...)
- path (List): The route path, as a list (each item being a level of
deepness), from the lowest level (domain) to the highest
- params (Dict): The acl list of the following format :
[{'acl': Function, 'args': {'required': [], 'optional': []}}]
Returns:
(Function, Dict): The destination function and the acl dictionary
"""
if len(params) == 0:
raise MissingAclError('[{}] {}'.format(verb, '/'.join(path)))
if len(path) == 0:
logger.error('Empty path for [{%s}]', verb)
raise PathError()
fct_name = get_fct_name(verb, path[-1])
if hasattr(m_router, fct_name):
fct = getattr(m_router, fct_name)
else:
raise UndefinedFunction('{}.{}'.format(m_router.__name__, fct_name or ''))
if not inspect.iscoroutinefunction(fct):
return route_decorator(fct), params
else:
return fct, params
def gen_router_routes(m_router: ModuleType, path: List[str]) -> \
Iterator[Tuple[str, str, Coroutine, List]]:
"""
Recursive generatore that parses a router (or a subrouter)
and yields from gen_routes
Parameters:
- m_router (ModuleType): The currently treated router module
- path (List[str]): The current path stack
Yields:
(str, str, Coroutine, List): A tuple containing the path, verb,
function and parameters of the route
"""
for subpath, params in read_router(m_router).items():
path.append(subpath)
for verb in VERBS:
if verb not in params:
continue
yield ('/'.join(filter(lambda x: len(x) > 0, path)),
verb,
*gen_routes(m_router, verb, path, params[verb])
)
for subroute in params.get('SUBROUTES', []):
#logger.debug('Processing subroute **%s** - %s', subroute, m_router.__name__)
param_match = re.fullmatch('^([A-Z_]+)_([a-z]+)$', subroute)
if param_match is not None:
try:
path.append('{{{}:{}}}'.format(
param_match.groups()[0].lower(),
param_match.groups()[1]))
except AssertionError as exc:
raise UnknownPathParameterType(subroute) from exc
else:
path.append(subroute)
try:
yield from gen_router_routes(
importlib.import_module(f'.{subroute}', m_router.__name__),
path)
except ImportError as exc:
logger.error('Failed to import subroute **{%s}**', subroute)
raise exc
path.pop()
path.pop()
def d_domains(config) -> Dict[str, ModuleType]:
"""
Parameters:
config (ConfigParser): The .halfapi/config based configparser object
Returns:
dict[str, ModuleType]
"""
domains = {}
if os.environ.get('HALFAPI_DOMAIN_NAME') and os.environ.get('HALFAPI_DOMAIN_MODULE', '.routers'):
domains[os.environ.get('HALFAPI_DOMAIN_NAME')] = os.environ.get('HALFAPI_DOMAIN_MODULE')
elif 'domains' in config:
domains = dict(config['domains'].items())
try:
sys.path.append('.')
return {
domain: importlib.import_module(''.join((domain, module)))
for domain, module in domains.items()
}
except ImportError as exc:
logger.error('Could not load a domain : %s', exc)
raise exc
def router_acls(route_params: Dict, path: List, m_router: ModuleType) -> Generator:
for verb in VERBS:
params = route_params.get(verb)
if params is None:
continue
if len(params) == 0:
logger.error('No ACL for route [{%s}] %s', verb, "/".join(path))
else:
for param in params:
fct_acl = param.get('acl')
if not isinstance(fct_acl, FunctionType):
continue
yield fct_acl.__name__, fct_acl
def domain_acls(m_router, path):
routes = read_router(m_router)
for subpath, route_params in routes.items():
path.append(subpath)
yield from router_acls(route_params, path, m_router)
subroutes = route_params.get('SUBROUTES', [])
for subroute in subroutes:
logger.debug('Processing subroute **%s** - %s', subroute, m_router.__name__)
path.append(subroute)
try:
submod = importlib.import_module(f'.{subroute}', m_router.__name__)
except ImportError as exc:
logger.error('Failed to import subroute **{%s}**', subroute)
raise exc
yield from domain_acls(submod, path)
path.pop()
path.pop()

View File

@ -1,28 +1,29 @@
""" """
DomainMiddleware DomainMiddleware
""" """
import logging
from starlette.datastructures import URL from starlette.datastructures import URL
from starlette.middleware.base import (BaseHTTPMiddleware, from starlette.middleware.base import (BaseHTTPMiddleware,
RequestResponseEndpoint) RequestResponseEndpoint)
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response from starlette.responses import Response
from ..logging import logger logger = logging.getLogger('uvicorn.asgi')
class DomainMiddleware(BaseHTTPMiddleware): class DomainMiddleware(BaseHTTPMiddleware):
""" """
DomainMiddleware adds the api routes and acls to the following scope keys : DomainMiddleware adds the api routes and acls to the following scope keys :
- domains
- api - api
- acl - acl
""" """
def __init__(self, app, domain=None): def __init__(self, app, config):
""" app: HalfAPI instance
"""
logger.info('DomainMiddleware app:%s domain:%s', app, domain)
super().__init__(app) super().__init__(app)
self.domain = domain self.config = config
self.domains = {}
self.request = None self.request = None
@ -32,27 +33,14 @@ class DomainMiddleware(BaseHTTPMiddleware):
Call of the route fonction (decorated or not) Call of the route fonction (decorated or not)
""" """
request.scope['domain'] = self.domain['name'] l_path = URL(scope=request.scope).path.split('/')
if hasattr(request.app, 'config') \ cur_domain = l_path[0]
and isinstance(request.app.config, dict): if len(cur_domain) == 0 and len(l_path) > 1:
# Set the config scope to the domain's config cur_domain = l_path[1]
request.scope['config'] = request.app.config.get(
'domain', {}
).get(
self.domain['name'], {}
).copy()
# TODO: Remove in 0.7.0 request.scope['domain'] = cur_domain
config = request.scope['config'].copy() request.scope['config'] = self.config['domain_config'][cur_domain] \
request.scope['config']['domain'] = {} if cur_domain in self.config.get('domain_config', {}) else {}
request.scope['config']['domain'][self.domain['name']] = {}
request.scope['config']['domain'][self.domain['name']]['config'] = config
else:
logger.debug('%s', request.app)
logger.debug('%s', getattr(request.app, 'config', None))
response = await call_next(request) response = await call_next(request)
@ -63,18 +51,13 @@ class DomainMiddleware(BaseHTTPMiddleware):
if 'args' in request.scope: if 'args' in request.scope:
# Set the http headers "x-args-required" and "x-args-optional" # Set the http headers "x-args-required" and "x-args-optional"
if len(request.scope['args'].get('required', set())): if 'required' in request.scope['args']:
response.headers['x-args-required'] = \ response.headers['x-args-required'] = \
','.join(request.scope['args']['required']) ','.join(request.scope['args']['required'])
if len(request.scope['args'].get('optional', set())): if 'optional' in request.scope['args']:
response.headers['x-args-optional'] = \ response.headers['x-args-optional'] = \
','.join(request.scope['args']['optional']) ','.join(request.scope['args']['optional'])
if len(request.scope.get('out', set())): response.headers['x-domain'] = cur_domain
response.headers['x-out'] = \
','.join(request.scope['out'])
response.headers['x-domain'] = self.domain['name']
return response return response

View File

@ -12,43 +12,99 @@ Raises:
from os import environ from os import environ
import typing import typing
import logging
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 logger = logging.getLogger('uvicorn.error')
from ..logging import logger
from ..conf import CONFIG
from ..lib.responses import ORJSONResponse
SECRET=None SECRET=None
try: try:
with open(CONFIG.get('secret', ''), 'r') as secret_file: from ..conf import SECRET
SECRET = secret_file.read().strip() except ImportError as exc:
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): class Nobody(UnauthenticatedUser):
cookie = dict(scope.get("headers") or {}).get(b"cookie") """ Nobody class
if not cookie:
return {} The default class when no token is passed
"""
@property
def json(self):
return {
'id' : '',
'token': '',
'payload': ''
}
class JWTUser(BaseUser):
""" JWTUser class
Is used to store authentication informations
"""
def __init__(self, user_id: UUID, token: str, payload: dict) -> None:
self.__id = user_id
self.token = token
self.payload = payload
def __str__(self):
return str(self.json)
@property
def json(self):
return {
'id' : str(self.__id),
'token': self.token,
'payload': self.payload
}
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
return ' '.join(
(self.payload.get('name'), self.payload.get('firstname')))
@property
def id(self) -> str:
return self.__id
class CheckUser(BaseUser):
""" CheckUser class
Is used to call checks with give user_id, to know if it passes the ACLs for
the given route.
It should never be able to run a route function.
"""
def __init__(self, user_id: UUID) -> None:
self.__id = user_id
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
return 'check_user'
@property
def id(self) -> str:
return self.__id
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,
@ -68,22 +124,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,9 +143,17 @@ 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'])
else:
return AuthCredentials(), Nobody() return AuthCredentials(), Nobody()
@ -102,8 +161,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

@ -13,22 +13,16 @@ Classes :
- PlainTextResponse - PlainTextResponse
- ServiceUnavailableResponse - ServiceUnavailableResponse
- UnauthorizedResponse - UnauthorizedResponse
- ODSResponse
""" """
from datetime import date
import decimal import decimal
import typing import typing
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 .jwt_middleware import JWTUser, Nobody
from ..logging import logger
__all__ = [ __all__ = [
@ -101,8 +95,6 @@ class ORJSONResponse(JSONResponse):
JWTUser, Nobody JWTUser, Nobody
} }
if callable(typ):
return typ.__name__
if type(typ) in str_types: if type(typ) in str_types:
return str(typ) return str(typ)
if type(typ) in list_types: if type(typ) in list_types:
@ -118,50 +110,3 @@ class HJSONResponse(ORJSONResponse):
""" """
def render(self, content: typing.Generator): def render(self, content: typing.Generator):
return super().render(list(content)) return super().render(list(content))
class ODSResponse(Response):
file_type = 'ods'
def __init__(self, d_rows: typing.List[typing.Dict]):
try:
import pyexcel as pe
except ImportError:
""" ODSResponse is not handled
"""
super().__init__(content=
'pyexcel is not installed, ods format not available'
)
return
with BytesIO() as ods_file:
rows = []
if len(d_rows):
rows_names = list(d_rows[0].keys())
for elt in d_rows:
rows.append(list(elt.values()))
rows.insert(0, rows_names)
self.sheet = pe.Sheet(rows)
self.sheet.save_to_memory(
file_type=self.file_type,
stream=ods_file)
filename = f'{date.today()}.{self.file_type}'
super().__init__(
content=ods_file.getvalue(),
headers={
'Content-Type': 'application/vnd.oasis.opendocument.spreadsheet; charset=UTF-8',
'Content-Disposition': f'attachment; filename="{filename}"'},
status_code = 200)
class XLSXResponse(ODSResponse):
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

@ -1,12 +1,44 @@
import os import os
import sys
import subprocess
from types import ModuleType from types import ModuleType
from typing import Dict from typing import Dict
from pprint import pprint
from schema import SchemaError from halfapi.lib.constants import VERBS
from .constants import VERBS, ROUTER_SCHEMA
from ..logging import logger
def read_router(m_router: ModuleType) -> Dict:
"""
Reads a module and returns a router dict
"""
if not hasattr(m_router, 'ROUTES'):
routes = {'':{}}
acls = getattr(m_router, 'ACLS') if hasattr(m_router, 'ACLS') else None
if acls is not None:
for verb in VERBS:
if not hasattr(m_router, verb.lower()):
continue
""" There is a "verb" route in the router
"""
if verb.upper() not in acls:
continue
routes[''][verb.upper()] = []
routes[''][verb.upper()] = acls[verb.upper()].copy()
routes['']['SUBROUTES'] = []
if hasattr(m_router, '__path__'):
""" Module is a package
"""
m_path = getattr(m_router, '__path__')
if isinstance(m_path, list) and len(m_path) == 1:
routes['']['SUBROUTES'] = [
elt.name
for elt in os.scandir(m_path[0])
if elt.is_dir()
]
else:
routes = getattr(m_router, 'ROUTES')
return routes

View File

@ -2,87 +2,122 @@
""" """
Routes module Routes module
Classes :
- JSONRoute
Fonctions : Fonctions :
- gen_domain_routes - route_acl_decorator
- gen_schema_routes - gen_starlette_routes
- api_routes - api_routes
- api_acls
- debug_routes
Exception : Exception :
- DomainNotFoundError - DomainNotFoundError
""" """
import inspect from datetime import datetime
from functools import partial, wraps
from typing import Coroutine, Dict, Generator, Tuple, Any import logging
from typing import Callable, List, Dict, Generator, Tuple
from types import ModuleType, FunctionType from types import ModuleType, FunctionType
import yaml from starlette.exceptions import HTTPException
from starlette.routing import Route
from starlette.requests import Request
from starlette.responses import Response, PlainTextResponse
# from .domain import gen_router_routes, domain_acls, route_decorator, domain_schema from halfapi.lib.domain import gen_router_routes, domain_acls
from .responses import ORJSONResponse from ..conf import DOMAINSDICT
from .acl import args_check
from ..half_route import HalfRoute
from . import acl
from ..logging import logger
logger = logging.getLogger('uvicorn.asgi')
class DomainNotFoundError(Exception): class DomainNotFoundError(Exception):
""" Exception when a domain is not importable """ Exception when a domain is not importable
""" """
def JSONRoute(data: Any) -> Coroutine: def route_acl_decorator(fct: Callable = None, params: List[Dict] = None):
""" """
Returns a route function that returns the data as JSON Decorator for async functions that calls pre-conditions functions
and appends kwargs to the target function
Parameters: Parameters:
data (Any): fct (Callable):
The data to return The function to decorate
params List[Dict]:
A list of dicts that have an "acl" key that points to a function
Returns: Returns:
async function async function
""" """
async def wrapped(request, *args, **kwargs):
return ORJSONResponse(data)
return wrapped if not params:
params = []
if not fct:
return partial(route_acl_decorator, params=params)
def gen_domain_routes(m_domain: ModuleType): @wraps(fct)
async def caller(req: Request, *args, **kwargs):
for param in params:
if param.get('acl'):
passed = param['acl'](req, *args, **kwargs)
if isinstance(passed, FunctionType):
passed = param['acl']()(req, *args, **kwargs)
if not passed:
logger.debug(
'ACL FAIL for current route (%s - %s)', fct, param.get('acl'))
continue
logger.debug(
'ACL OK for current route (%s - %s)', fct, param.get('acl'))
req.scope['acl_pass'] = param['acl'].__name__
if 'args' in param:
req.scope['args'] = param['args']
if 'check' in req.query_params:
return PlainTextResponse(param['acl'].__name__)
return await fct(
req, *args,
**{
**kwargs,
**param
})
if 'check' in req.query_params:
return PlainTextResponse('')
raise HTTPException(401)
return caller
def gen_starlette_routes(d_domains: Dict[str, ModuleType]) -> Generator:
""" """
Yields the Route objects for a domain Yields the Route objects for HalfAPI app
Parameters: Parameters:
m_domains: ModuleType d_domains (dict[str, ModuleType])
Returns: Returns:
Generator(HalfRoute) Generator(Route)
""" """
yield HalfRoute('/',
JSONRoute(domain_schema(m_domain)), for domain_name, m_domain in d_domains.items():
[{'acl': acl.public}], for path, verb, fct, params in gen_router_routes(m_domain, []):
'GET' yield (
Route(f'/{domain_name}/{path}',
route_acl_decorator(
fct,
params
),
methods=[verb])
) )
for path, method, m_router, fct, params in gen_router_routes(m_domain, []):
yield HalfRoute(f'/{path}', fct, params, method)
def gen_schema_routes(schema: Dict):
"""
Yields the Route objects according to a given schema
"""
for path, methods in schema.items():
for verb, definition in methods.items():
fct = definition.pop('fct')
acls = definition.pop('acls')
# TODO: Check what to do with gen_routes, it is almost the same function
if not inspect.iscoroutinefunction(fct):
yield HalfRoute(path, route_decorator(fct), acls, verb)
else:
yield HalfRoute(path, args_check(fct), acls, verb)
def api_routes(m_dom: ModuleType) -> Tuple[Dict, Dict]: def api_routes(m_dom: ModuleType) -> Tuple[Dict, Dict]:
@ -115,22 +150,56 @@ def api_routes(m_dom: ModuleType) -> Tuple[Dict, Dict]:
return l_params return l_params
d_res = {} d_res = {}
for path, verb, m_router, fct, params in gen_router_routes(m_dom, []): for path, verb, _, params in gen_router_routes(m_dom, []):
try:
if path not in d_res: if path not in d_res:
d_res[path] = {} d_res[path] = {}
d_res[path][verb] = str_acl(params)
d_res[path][verb] = {
'docs': yaml.load(fct.__doc__, Loader=yaml.FullLoader),
'acls': str_acl(params)
}
except Exception as exc:
logger.error("""Error in route generation
path:%s
verb:%s
router:%s
fct:%s
params:%s """, path, verb, m_router, fct, params)
raise exc
return d_res, d_acls return d_res, d_acls
def api_acls(request):
""" Returns the list of possible ACLs
"""
res = {}
domains = DOMAINSDICT()
doc = 'doc' in request.query_params
for domain, m_domain in domains.items():
res[domain] = {}
for acl_name, fct in domain_acls(m_domain, [domain]):
if not isinstance(fct, FunctionType):
continue
fct_result = fct.__doc__.strip() if doc and fct.__doc__ else fct(request)
if acl_name in res[domain]:
continue
if isinstance(fct_result, FunctionType):
fct_result = fct()(request)
res[domain][acl_name] = fct_result
return res
def debug_routes():
""" Halfapi debug routes definition
"""
async def debug_log(request: Request, *args, **kwargs):
logger.debug('debuglog# %s', {datetime.now().isoformat()})
logger.info('debuglog# %s', {datetime.now().isoformat()})
logger.warning('debuglog# %s', {datetime.now().isoformat()})
logger.error('debuglog# %s', {datetime.now().isoformat()})
logger.critical('debuglog# %s', {datetime.now().isoformat()})
return Response('')
yield Route('/halfapi/log', debug_log)
async def error_code(request: Request, *args, **kwargs):
code = request.path_params['code']
raise HTTPException(code)
yield Route('/halfapi/error/{code:int}', error_code)
async def exception(request: Request, *args, **kwargs):
raise Exception('Test exception')
yield Route('/halfapi/exception', exception)

View File

@ -1,6 +1,7 @@
""" Schemas module """ Schemas module
Functions : Functions :
- get_api_routes
- schema_json - schema_json
- schema_dict_dom - schema_dict_dom
- get_acls - get_acls
@ -10,128 +11,113 @@ Constant :
""" """
import os import os
import importlib import logging
from typing import Dict, Coroutine, List from typing import Dict, Coroutine
from types import ModuleType from types import ModuleType
import yaml
from starlette.schemas import SchemaGenerator from starlette.schemas import SchemaGenerator
from .. import __version__ from .. import __version__
from ..logging import logger from .routes import gen_starlette_routes, api_routes, api_acls
from .routes import api_routes
from .responses import ORJSONResponse from .responses import ORJSONResponse
logger = logging.getLogger('uvicorn.asgi')
SCHEMAS = SchemaGenerator( SCHEMAS = SchemaGenerator(
{"openapi": "3.0.0", "info": {"title": "HalfAPI", "version": __version__}} {"openapi": "3.0.0", "info": {"title": "HalfAPI", "version": __version__}}
) )
def get_api_routes(domains: Dict[str, ModuleType]) -> Coroutine:
"""
description: Returns the current API routes dictionary
as a JSON object
example: {
"dummy_domain": {
"abc/alphabet": {
"GET": [
{
"acl": "public"
}
]
},
"abc/alphabet/{test:uuid}": {
"GET": [
{
"acl": "public"
}
],
"POST": [
{
"acl": "public"
}
],
"PATCH": [
{
"acl": "public"
}
],
"PUT": [
{
"acl": "public"
}
]
}
}
}
"""
routes = {
domain: api_routes(m_domain)[0]
for domain, m_domain in domains.items()
}
async def wrapped(request, *args, **kwargs):
return ORJSONResponse(routes)
return wrapped
def get_api_domain_routes(m_domain: ModuleType) -> Coroutine:
routes, _ = api_routes(m_domain)
async def wrapped(request, *args, **kwargs):
"""
description: Returns the current API routes dictionary for a specific
domain as a JSON object
"""
return ORJSONResponse(routes)
return wrapped
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))
def schema_csv_dict(csv: List[str], prefix='/') -> Dict: def schema_dict_dom(d_domains: Dict[str, ModuleType]) -> Dict:
package = None
schema_d = {}
modules_d = {}
acl_modules_d = {}
for line in csv:
if not line:
continue
path, verb, router, acl_fct_name, args_req, args_opt, out = line.strip().split(';')
logger.info('schema_csv_dict %s %s %s', path, args_req, args_opt)
path = f'{prefix}{path}'
if path not in schema_d:
schema_d[path] = {}
if verb not in schema_d[path]:
mod_str = router.split(':')[0]
fct_str = router.split(':')[1]
if mod_str not in modules_d:
modules_d[mod_str] = importlib.import_module(mod_str)
if not hasattr(modules_d[mod_str], fct_str):
raise Exception(
'Missing function in module. module:{} function:{}'.format(
router, fct_str
)
)
fct = getattr(modules_d[mod_str], fct_str)
schema_d[path][verb] = {
'module': modules_d[mod_str],
'fct': fct,
'acls': []
}
if package and router.split('.')[0] != package:
raise Exception('Multi-domain is not allowed in that mode')
package = router.split('.')[0]
if not len(package):
raise Exception(
'Empty package name (router=%s)'.format(router))
acl_package = f'{package}.acl'
if acl_package not in acl_modules_d:
if acl_package not in modules_d:
modules_d[acl_package] = importlib.import_module(acl_package)
if not hasattr(modules_d[acl_package], acl_fct_name):
raise Exception(
'Missing acl function in module. module:{} acl:{}'.format(
acl_package, acl_fct_name
)
)
acl_modules_d[acl_package] = {}
acl_modules_d[acl_package][acl_fct_name] = getattr(modules_d[acl_package], acl_fct_name)
schema_d[path][verb]['acls'].append({
'acl': acl_modules_d[acl_package][acl_fct_name],
'args': {
'required': set(args_req.split(',')) if len(args_req) else set(),
'optional': set(args_opt.split(',')) if len(args_opt) else set()
}
})
return schema_d
def param_docstring_default(name, type):
""" Returns a default docstring in OpenAPI format for a path parameter
""" """
type_map = { Returns the API schema of the *m_domain* domain as a python dictionnary
'str': 'string',
'uuid': 'string', Parameters:
'path': 'string',
'int': 'number', d_domains (Dict[str, moduleType]): The module to scan for routes
'float': 'number'
} Returns:
return yaml.dump({
'name': name, Dict: A dictionnary containing the description of the API using the
'in': 'path', | OpenAPI standard
'description': f'default description for path parameter {name}', """
'required': True, return SCHEMAS.get_schema(
'schema': { routes=list(gen_starlette_routes(d_domains)))
'type': type_map[type]
}
}) async def get_acls(request, *args, **kwargs):
"""
description: A dictionnary of the domains and their acls, with the
result of the acls functions
"""
return ORJSONResponse(api_acls(request))

View File

@ -9,7 +9,7 @@ import logging
from timing_asgi import TimingClient from timing_asgi import TimingClient
from ..logging import logger logger = logging.getLogger('uvicorn.asgi')
class HTimingClient(TimingClient): class HTimingClient(TimingClient):
""" Used to redefine TimingClient.timing """ Used to redefine TimingClient.timing

View File

@ -1,79 +0,0 @@
from uuid import UUID
from starlette.authentication import BaseUser, UnauthenticatedUser
class Nobody(UnauthenticatedUser):
""" Nobody class
The default class when no token is passed
"""
@property
def json(self):
return {
'id' : '',
'token': '',
'payload': ''
}
class JWTUser(BaseUser):
""" JWTUser class
Is used to store authentication informations
"""
def __init__(self, user_id: UUID, token: str, payload: dict) -> None:
self.__id = user_id
self.token = token
self.payload = payload
def __str__(self):
return str(self.json)
@property
def json(self):
return {
'id' : str(self.__id),
'token': self.token,
'payload': self.payload
}
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
return ' '.join(
(self.payload.get('name'), self.payload.get('firstname')))
@property
def id(self) -> str:
return self.__id
class CheckUser(BaseUser):
""" CheckUser class
Is used to call checks with give user_id, to know if it passes the ACLs for
the given route.
It should never be able to run a route function.
"""
def __init__(self, user_id: UUID) -> None:
self.__id = user_id
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
return 'check_user'
@property
def id(self) -> str:
return self.__id

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
logger = logging.getLogger() config_logging()
logger = logging.getLogger('uvicorn.asgi')

75
halfapi/models/api.sql Normal file
View File

@ -0,0 +1,75 @@
create schema api;
create type verb as enum ('POST', 'GET', 'PUT', 'PATCH', 'DELETE');
create table api.domain (
name text,
primary key (name)
);
create table api.router (
name text,
domain text,
primary key (name, domain)
);
alter table api.router add constraint router_domain_fkey foreign key (domain) references api.domain(name) on update cascade on delete cascade;
create table api.route (
http_verb verb,
path text, -- relative to /<domain>/<router>
fct_name text,
router text,
domain text,
primary key (http_verb, path, router, domain)
);
alter table api.route add constraint route_router_fkey foreign key (router, domain) references api.router(name, domain) on update cascade on delete cascade;
create table api.acl_function (
name text,
description text,
domain text,
primary key (name, domain)
);
alter table api.acl_function add constraint acl_function_domain_fkey foreign key (domain) references api.domain(name) on update cascade on delete cascade;
create table api.acl (
http_verb verb,
path text not null,
router text,
domain text,
acl_fct_name text,
keys text[],
primary key (http_verb, path, router, domain, acl_fct_name)
);
alter table api.acl add constraint acl_route_fkey foreign key (http_verb, path,
router, domain) references api.route(http_verb, path, router, domain) on update cascade on delete cascade;
alter table api.acl add constraint acl_function_fkey foreign key (acl_fct_name, domain) references api.acl_function(name, domain) on update cascade on delete cascade;
create schema "api.view";
create view "api.view".route as
select
route.*,
'/'::text || route.domain || '/'::text || route.router || route.path AS abs_path
from
api.route
join api.domain on
route.domain = domain.name
;
create view "api.view".acl as
select
acl.*,
'/'::text || route.domain || '/'::text || route.router || route.path AS abs_path
from
api.acl
join api.acl_function on
acl.acl_fct_name = acl_function.name
join api.route on
acl.domain = route.domain
and acl.router = route.router
and acl.path = route.path;

View File

@ -1,152 +0,0 @@
import importlib
import functools
import os
import sys
import json
from json.decoder import JSONDecodeError
import toml
from unittest import TestCase
from starlette.testclient import TestClient
from click.testing import CliRunner
from ..cli.cli import cli
from ..halfapi import HalfAPI
from ..half_domain import HalfDomain
from ..conf import DEFAULT_CONF
from pprint import pprint
import tempfile
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
def router_module(self):
return '.'.join((self.module_name, self.ROUTERS))
def setUp(self):
# CLI
class_ = CliRunner
def invoke_wrapper(f):
"""Augment CliRunner.invoke to emit its output to stdout.
This enables pytest to show the output in its logs on test
failures.
"""
@functools.wraps(f)
def wrapper(*args, **kwargs):
echo = kwargs.pop('echo', False)
result = f(*args, **kwargs)
if echo is True:
sys.stdout.write(result.output)
return result
return wrapper
class_.invoke = invoke_wrapper(class_.invoke)
self.runner = class_(mix_stderr=False)
# HTTP
# Fake default values of default configuration
self.halfapi_conf = {
'secret': 'testsecret',
'production': False,
'domain': {}
}
self.halfapi_conf['domain'][self.domain_name] = {
'name': self.domain_name,
'router': self.router_path,
'acl': self.acl_path,
'module': self.module_name,
'prefix': False,
'enabled': True,
'config': getattr(self, 'CONFIG', {})
}
_, 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.client = TestClient(self.halfapi.application)
self.module = importlib.import_module(
self.module_name
)
def tearDown(self):
pass
def check_domain(self):
result = None
try:
result = self.runner.invoke(cli, '--version')
self.assertEqual(result.exit_code, 0)
result = self.runner.invoke(cli, ['domain', '--read', self.DOMAIN, self.config_file])
self.assertEqual(result.exit_code, 0)
result_d = json.loads(result.stdout)
result = self.runner.invoke(cli, ['run', '--help'])
self.assertEqual(result.exit_code, 0)
# result = self.runner.invoke(cli, ['run', '--dryrun', self.DOMAIN])
# self.assertEqual(result.exit_code, 0)
except AssertionError as exc:
print(f'Result {result}')
print(f'Stdout {result.stdout}')
print(f'Stderr {result.stderr}')
raise exc
except JSONDecodeError as exc:
print(f'Result {result}')
print(f'Stdout {result.stdout}')
raise exc
return result_d
def check_routes(self):
r = self.client.request('get', '/')
assert r.status_code == 200
schema = r.json()
assert isinstance(schema, dict)
assert 'openapi' in schema
assert 'info' in schema
assert 'paths' in schema
r = self.client.request('get', '/halfapi/acls')
assert r.status_code == 200
d_r = r.json()
assert isinstance(d_r, dict)
assert self.domain_name in d_r.keys()
ACLS = HalfDomain.acls(self.module, self.acl_path)
assert len(ACLS) == len(d_r[self.domain_name])
for acl_rule in ACLS:
assert len(acl_rule.name) > 0
assert acl_rule.name in d_r[self.domain_name]
assert len(acl_rule.documentation) > 0
assert isinstance(acl_rule.priority, int)
assert acl_rule.priority >= 0
if acl_rule.public is True:
r = self.client.request('get', f'/halfapi/acls/{acl_rule.name}')
assert r.status_code in [200, 401]

View File

@ -1,3 +0,0 @@
pyexcel>=0.6.3,<1
pyexcel-ods>=0.5.6,<1
pyexcel-xlsx=0.6.0,<1

View File

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

View File

@ -1,61 +1,5 @@
alog==0.9.13 click
anyio==3.4.0 starlette
asgiref==3.4.1 uvicorn
astroid==2.9.0 PyJWT
attrs==21.2.0 pygit2==0.28.2
bleach==4.1.0
build==0.7.0
certifi==2021.10.8
cffi==1.15.0
charset-normalizer==2.0.9
click==7.1.2
colorama==0.4.4
contextlib2==21.6.0
cryptography==36.0.1
docutils==0.18.1
h11==0.12.0
idna==3.3
importlib-metadata==4.8.2
iniconfig==1.1.1
isort==5.10.1
jeepney==0.7.1
keyring==23.4.0
lazy-object-proxy==1.7.1
mccabe==0.6.1
orjson==3.6.5
packaging==21.3
pep517==0.12.0
pkginfo==1.8.2
platformdirs==2.4.0
pluggy==1.0.0
py==1.11.0
pycparser==2.21
pyflakes==2.4.0
Pygments==2.10.0
PyJWT==2.3.0
pylint==2.12.2
pyparsing==3.0.6
pytest==6.2.5
pytest-asyncio==0.16.0
pytest-pythonpath==0.7.3
PyYAML==5.4.1
readme-renderer==32.0
requests==2.26.0
requests-toolbelt==0.9.1
rfc3986==1.5.0
schema==0.7.5
SecretStorage==3.3.1
six==1.16.0
sniffio==1.2.0
starlette==0.17.1
timing-asgi==0.2.1
toml==0.10.2
tomli==2.0.0
tqdm==4.62.3
twine==3.7.1
urllib3==1.26.7
uvicorn==0.16.0
vulture==2.3
webencodings==0.5.1
wrapt==1.13.3
zipp==3.6.0

View File

@ -43,43 +43,28 @@ 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.0.1",
"starlette>=0.33,<0.34", "starlette>=0.16,<0.17",
"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",
"toml>=0.10,<0.11",
"packaging>=19.0",
"python-multipart"
], ],
classifiers=[ classifiers=[
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Application Frameworks",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10"
], ],
extras_require={ extras_require={
"tests":[ "tests":[
"pytest>=7,<8", "pytest",
"pytest-asyncio",
"pylint",
"requests", "requests",
"httpx", "pytest-asyncio",
"openapi-schema-validator", "pylint"
"openapi-spec-validator",
"coverage"
],
"pyexcel":[
"pyexcel",
"pyexcel-ods3",
"pyexcel-xlsx"
] ]
}, },
entry_points={ entry_points={

View File

@ -1,93 +0,0 @@
#!/usr/bin/env python3
import os
import subprocess
import importlib
import tempfile
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from configparser import ConfigParser
from halfapi import __version__
from halfapi.cli import cli
Cli = cli.cli
PROJNAME = os.environ.get('PROJ','tmp_api')
def test_options(runner):
# Wrong command
with runner.isolated_filesystem():
r = runner.invoke(Cli, ['foobar'])
assert r.exit_code == 2
# Test existing commands
with runner.isolated_filesystem():
r = runner.invoke(Cli, ['--help'])
assert r.exit_code == 0
with runner.isolated_filesystem():
r = runner.invoke(Cli, ['--version'])
assert r.exit_code == 0
with runner.isolated_filesystem():
r = runner.invoke(Cli, ['init', '--help'])
assert r.exit_code == 0
@pytest.mark.skip
def test_init_project_fail(runner):
# Missing argument (project)
testproject = 'testproject'
r = runner.invoke(Cli, ['init'])
assert r.exit_code == 2
with runner.isolated_filesystem():
# Fail : Wrong project name
r = runner.invoke(Cli, ['init', 'test*-project'])
assert r.exit_code == 1
with runner.isolated_filesystem():
# Fail : Already existing folder
os.mkdir(testproject)
r = runner.invoke(Cli, ['init', testproject])
assert r.exit_code == 1
with runner.isolated_filesystem():
# Fail : Already existing nod
os.mknod(testproject)
r = runner.invoke(Cli, ['init', testproject])
assert r.exit_code == 1
@pytest.mark.skip
def test_init_project(runner):
"""
"""
cp = ConfigParser()
with runner.isolated_filesystem():
env = {
'HALFAPI_CONF_DIR': '.halfapi'
}
res = runner.invoke(Cli, ['init', PROJNAME], env=env)
try:
assert os.path.isdir(PROJNAME)
assert os.path.isdir(os.path.join(PROJNAME, '.halfapi'))
# .halfapi/config check
assert os.path.isfile(os.path.join(PROJNAME, '.halfapi', 'config'))
cp.read(os.path.join(PROJNAME, '.halfapi', 'config'))
assert cp.has_section('project')
assert cp.has_option('project', 'name')
assert cp.get('project', 'name') == PROJNAME
assert cp.get('project', 'halfapi_version') == __version__
# removal of domain section (0.6)
# assert cp.has_section('domain')
except AssertionError as exc:
subprocess.run(['tree', '-a', os.getcwd()])
raise exc
assert res.exit_code == 0
assert res.exception is None

View File

@ -1,9 +0,0 @@
from halfapi.cli.cli import cli
from configparser import ConfigParser
def test_config(cli_runner):
with cli_runner.isolated_filesystem():
result = cli_runner.invoke(cli, ['config'])
cp = ConfigParser()
cp.read_string(result.output)
assert cp.has_section('project')

View File

@ -1,84 +0,0 @@
#!/usr/bin/env python3
import os
import subprocess
import importlib
import tempfile
from unittest.mock import patch
import json
import toml
import pytest
from click.testing import CliRunner
from configparser import ConfigParser
from halfapi.conf import DEFAULT_CONF, PROJECT_LEVEL_KEYS, DOMAIN_LEVEL_KEYS
PROJNAME = os.environ.get('PROJ','tmp_api')
class TestCliProj():
def test_cmds(self, project_runner):
assert project_runner('--help').exit_code == 0
#assert project_runner('run', '--help').exit_code == 0
#assert project_runner('domain', '--help').exit_code == 0
def test_domain_commands(self, project_runner):
""" 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')
print(r.stdout)
assert r.exit_code == 1
_, tmp_conf = tempfile.mkstemp()
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)
assert r.exit_code == 0
def test_config_commands(self, project_runner):
try:
r = project_runner('config')
assert r.exit_code == 0
except AssertionError as exc:
subprocess.call(['tree', '-a'])
raise exc

View File

@ -1,33 +0,0 @@
import pytest
from click.testing import CliRunner
from halfapi.cli.cli import cli
import os
from unittest.mock import patch
@pytest.mark.skip
def test_run_noproject(cli_runner):
with cli_runner.isolated_filesystem():
result = cli_runner.invoke(cli, ['config'])
print(result.stdout)
assert result.exit_code == 0
result = cli_runner.invoke(cli, ['run', '--dryrun'])
try:
assert result.exit_code == 0
except AssertionError as exc:
print(result.stdout)
raise exc
"""
def test_run_empty_project(cli_runner):
with cli_runner.isolated_filesystem():
os.mkdir('dummy_domain')
result = cli_runner.invoke(cli, ['run', './dummy_domain'])
assert result.exit_code == 1
def test_run_dummy_project(project_runner):
with patch('uvicorn.run', autospec=True) as runMock:
result = project_runner.invoke(cli, ['run'])
runMock.assert_called_once()
"""

View File

@ -18,14 +18,12 @@ from starlette.responses import PlainTextResponse
from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.testclient import TestClient from starlette.testclient import TestClient
from halfapi import __version__ from halfapi import __version__
from halfapi.halfapi import HalfAPI
from halfapi.cli.cli import cli from halfapi.cli.cli import cli
from halfapi.cli.init import init from halfapi.cli.init import init, format_halfapi_etc
from halfapi.cli.domain import domain, create_domain from halfapi.cli.domain import domain, create_domain
from halfapi.lib.responses import ORJSONResponse from halfapi.lib.responses import ORJSONResponse
from halfapi.lib.jwt_middleware import JWTAuthenticationBackend from halfapi.lib.jwt_middleware import JWTAuthenticationBackend
logger = logging.getLogger('halfapitest')
logger = logging.getLogger()
PROJNAME = os.environ.get('PROJ','tmp_api') PROJNAME = os.environ.get('PROJ','tmp_api')
@ -35,18 +33,6 @@ from halfapi.lib.jwt_middleware import (
JWTUser, JWTAuthenticationBackend, JWTUser, JWTAuthenticationBackend,
JWTWebSocketAuthenticationBackend) JWTWebSocketAuthenticationBackend)
@pytest.fixture
def dummy_domain():
yield {
'name': 'dummy_domain',
'router': '.routers',
'enabled': True,
'prefix': False,
'config': {
'test': True
}
}
@pytest.fixture @pytest.fixture
def token_builder(): def token_builder():
yield jwt.encode({ yield jwt.encode({
@ -76,7 +62,7 @@ def token_debug_true_builder():
@pytest.fixture @pytest.fixture
def runner(): def runner():
return CliRunner(mix_stderr=False) return CliRunner()
@pytest.fixture @pytest.fixture
@ -104,10 +90,11 @@ def cli_runner():
return wrapper return wrapper
class_.invoke = invoke_wrapper(class_.invoke) class_.invoke = invoke_wrapper(class_.invoke)
cli_runner_ = class_(mix_stderr=False) cli_runner_ = class_()
yield cli_runner_ yield cli_runner_
@pytest.fixture @pytest.fixture
def halfapicli(cli_runner): def halfapicli(cli_runner):
def caller(*args): def caller(*args):
@ -115,6 +102,24 @@ def halfapicli(cli_runner):
yield caller yield caller
@pytest.fixture
def halfapi_conf_dir():
return confdir('HALFAPI_CONF_DIR')
def confdir(dirname):
d = os.environ.get(dirname)
if not d:
os.environ[dirname] = tempfile.mkdtemp(prefix='halfapi_')
return os.environ.get(dirname)
if not os.path.isdir(d):
os.mkdir(d)
return d
@pytest.fixture
def halform_conf_dir():
return confdir('HALFORM_CONF_DIR')
# store history of failures per test class name and per index in parametrize (if # store history of failures per test class name and per index in parametrize (if
# parametrize used) # parametrize used)
@ -166,39 +171,36 @@ def pytest_runtest_setup(item):
pytest.xfail("previous test failed ({})".format(test_name)) pytest.xfail("previous test failed ({})".format(test_name))
@pytest.fixture @pytest.fixture
def project_runner(runner, halfapicli, tree): def project_runner(runner, halfapicli, halfapi_conf_dir):
with runner.isolated_filesystem(): with runner.isolated_filesystem():
res = halfapicli('init', PROJNAME) res = halfapicli('init', PROJNAME)
try:
os.chdir(PROJNAME) os.chdir(PROJNAME)
except FileNotFoundError as exc:
subprocess.call('tree')
raise exc
fs_path = os.getcwd()
sys.path.insert(0, fs_path)
secret = tempfile.mkstemp() secret = tempfile.mkstemp()
SECRET_PATH = secret[1] SECRET_PATH = secret[1]
with open(SECRET_PATH, 'w') as f: with open(SECRET_PATH, 'w') as f:
f.write(str(uuid1())) f.write(str(uuid1()))
""" with open(os.path.join(halfapi_conf_dir, PROJNAME), 'w') as halfapi_etc:
with open(os.path.join('.halfapi', PROJNAME), 'w') as halfapi_etc:
PROJ_CONFIG = re.sub('secret = .*', f'secret = {SECRET_PATH}', PROJ_CONFIG = re.sub('secret = .*', f'secret = {SECRET_PATH}',
format_halfapi_etc(PROJNAME, os.getcwd())) format_halfapi_etc(PROJNAME, os.getcwd()))
halfapi_etc.write(PROJ_CONFIG) halfapi_etc.write(PROJ_CONFIG)
"""
### ###
# add dummy domain # add dummy domain
### ###
create_domain('test_domain', 'test_domain.routers') create_domain('tests', '.dummy_domain.routers')
### ###
yield halfapicli yield halfapicli
while fs_path in sys.path:
sys.path.remove(fs_path)
@pytest.fixture @pytest.fixture
def dummy_app(): def dummy_app():
app = Starlette() app = Starlette()
@ -248,6 +250,7 @@ def create_route():
@pytest.fixture @pytest.fixture
def dummy_project(): def dummy_project():
sys.path.insert(0, './tests')
halfapi_config = tempfile.mktemp() halfapi_config = tempfile.mktemp()
halfapi_secret = tempfile.mktemp() halfapi_secret = tempfile.mktemp()
domain = 'dummy_domain' domain = 'dummy_domain'
@ -260,63 +263,45 @@ def dummy_project():
f'secret = {halfapi_secret}\n', f'secret = {halfapi_secret}\n',
'port = 3050\n', 'port = 3050\n',
'loglevel = debug\n', 'loglevel = debug\n',
'[domain.dummy_domain]\n', '[domains]\n',
f'name = {domain}\n', f'{domain} = .routers',
'router = dummy_domain.routers\n', f'[{domain}]',
f'[domain.dummy_domain.config]\n',
'test = True' 'test = True'
]) ])
with open(halfapi_secret, 'w') as f: with open(halfapi_secret, 'w') as f:
f.write('turlututu') f.write('turlututu')
return (halfapi_config, 'dummy_domain', 'dummy_domain.routers') return (halfapi_config, 'dummy_domain', 'routers')
@pytest.fixture @pytest.fixture
def application_debug(project_runner): def routers():
halfAPI = HalfAPI({ sys.path.insert(0, './tests')
'secret':'turlututu',
'production':False,
'domain': {
'dummy_domain': {
'name': 'dummy_domain',
'router': '.routers',
'enabled': True,
'prefix': False,
'config':{
'test': True
}
}
},
})
assert isinstance(halfAPI, HalfAPI) from dummy_domain import routers
yield halfAPI.application return routers
@pytest.fixture @pytest.fixture
def application_domain(dummy_domain): def application_debug():
from halfapi.app import HalfAPI
return HalfAPI({ return HalfAPI({
'secret':'turlututu', 'SECRET':'turlututu',
'production':True, 'PRODUCTION':False
'domain': {
'dummy_domain': {
**dummy_domain,
'config': {
'test': True
}
}
}
}).application }).application
@pytest.fixture @pytest.fixture
def tree(): def application_domain(routers):
def wrapped(path): from halfapi.app import HalfAPI
list_dirs = os.walk(path) return HalfAPI({
for root, dirs, files in list_dirs: 'SECRET':'turlututu',
for d in dirs: 'PRODUCTION':True,
print(os.path.join(root, d)) 'DOMAINS':{'dummy_domain':routers},
for f in files: 'CONFIG':{
print(os.path.join(root, f)) 'domains': {'dummy_domain':routers},
return wrapped 'domain_config': {'dummy_domain': {'test': True}}
}
}).application

View File

@ -1,27 +0,0 @@
from halfapi import __version__ as halfapi_version
domain = {
'name': 'dummy_domain',
'version': '0.0.0',
'id': '8b88e60a625369235b36c2d6d70756a0c02c1c7fb169fcee6dc820bcf9723f5a',
'deps': (
('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,13 +1 @@
from halfapi.lib import acl public = lambda *args: True
from halfapi.lib.acl import public, private, ACLS
from random import randint
def random(*args):
""" Random access ACL
"""
return randint(0,1) == 1
ACLS = (
*ACLS,
('random', random.__doc__, 10)
)

View File

@ -0,0 +1,5 @@
ROUTES={
'': {
'SUBROUTES': ['personne']
}
}

View File

@ -0,0 +1,13 @@
ROUTES={
'': {
'GET': [
{'acl':None, 'out':('id')}
],
},
'{user_id:uuid}': {
'GET': [
{'acl':None, 'out':('id')}
],
'SUBROUTES': ['eo']
}
}

View File

@ -0,0 +1,10 @@
from starlette.responses import Response
ROUTES={
'': {
'GET': [{'acl': 'None', 'in': ['ok']}]
}
}
async def get_(req):
return Response()

View File

@ -1,5 +1,4 @@
from halfapi.lib import acl from halfapi.lib import acl
from halfapi.lib.responses import ORJSONResponse
ACLS = { ACLS = {
'GET': [{'acl':acl.public}], 'GET': [{'acl':acl.public}],
'POST': [{'acl':acl.public}], 'POST': [{'acl':acl.public}],
@ -8,52 +7,17 @@ ACLS = {
'DELETE': [{'acl':acl.public}] 'DELETE': [{'acl':acl.public}]
} }
async def get(test): def get(test):
""" return str(test)
description:
returns the path parameter
responses:
200:
description: test response
"""
return ORJSONResponse(str(test))
def post(test): def post(test):
"""
description:
returns the path parameter
responses:
200:
description: test response
"""
return str(test) return str(test)
def patch(test): def patch(test):
"""
description:
returns the path parameter
responses:
200:
description: test response
"""
return str(test) return str(test)
def put(test): def put(test):
"""
description:
returns the path parameter
responses:
200:
description: test response
"""
return str(test) return str(test)
def delete(test): def delete(test):
"""
description:
returns the path parameter
responses:
200:
description: test response
"""
return str(test) return str(test)

View File

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

View File

@ -1,79 +0,0 @@
from ... import acl
from halfapi.logging import logger
ACLS = {
'GET' : [
{
'acl':acl.public,
'args': {
'required': {
'foo', 'bar'
},
'optional': {
'x'
}
}
},
{
'acl':acl.random,
'args': {
'required': {
'foo', 'baz'
},
'optional': {
'truebidoo'
}
}
},
],
'POST' : [
{
'acl':acl.private,
'args': {
'required': {
'foo', 'bar'
},
'optional': {
'x'
}
}
},
{
'acl':acl.public,
'args': {
'required': {
'foo', 'baz'
},
'optional': {
'truebidoo',
'z'
}
}
},
]
}
def get(data):
"""
description:
returns the arguments passed in
responses:
200:
description: test response
"""
logger.error('%s', data['foo'])
return data
def post(data):
"""
description:
returns the arguments passed in
responses:
200:
description: test response
"""
logger.error('%s', data)
return data

View File

@ -1,69 +0,0 @@
from halfapi.lib.responses import ORJSONResponse, NotImplementedResponse
from ... import acl
ROUTES = {
'abc/alphabet/{test:uuid}': {
'GET': [{'acl': acl.public}]
},
'abc/pinnochio': {
'GET': [{'acl': acl.public}]
},
'config': {
'GET': [{'acl': acl.public}]
},
'arguments': {
'GET': [{
'acl': acl.public,
'args': {
'required': {'foo', 'bar'},
'optional': set()
}
}]
},
}
async def get_abc_alphabet_TEST(request, *args, **kwargs):
"""
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()
async def get_abc_pinnochio(request, *args, **kwargs):
"""
description: Not implemented
responses:
200:
description: test response
"""
return NotImplementedResponse()
async def get_config(request, *args, **kwargs):
"""
description: Not implemented
responses:
200:
description: test response
"""
return NotImplementedResponse()
async def get_arguments(request, *args, **kwargs):
"""
description: Liste des datatypes.
responses:
200:
description: test response
"""
return ORJSONResponse({
'foo': kwargs.get('data').get('foo'),
'bar': kwargs.get('data').get('bar')
})

View File

@ -1,33 +1,11 @@
from ... import acl from halfapi.lib import acl
from halfapi.logging import logger import logging
logger = logging.getLogger('uvicorn.asgi')
ACLS = { ACLS = {
'GET' : [ 'GET' : [{'acl':acl.public}]
{'acl':acl.public},
{'acl':acl.random},
]
} }
def get(halfapi): def get(halfapi):
"""
description:
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
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'] 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

@ -1,7 +1,7 @@
import pytest import pytest
from starlette.responses import PlainTextResponse from starlette.responses import PlainTextResponse
from starlette.testclient import TestClient from starlette.testclient import TestClient
from halfapi.half_route import HalfRoute from halfapi.lib.routes import route_acl_decorator
from halfapi.lib import acl from halfapi.lib import acl
def test_acl_Check(dummy_app, token_debug_false_builder): def test_acl_Check(dummy_app, token_debug_false_builder):
@ -9,7 +9,7 @@ def test_acl_Check(dummy_app, token_debug_false_builder):
A request with ?check should always return a 200 status code A request with ?check should always return a 200 status code
""" """
@HalfRoute.acl_decorator(params=[{'acl':acl.public}]) @route_acl_decorator(params=[{'acl':acl.public}])
async def test_route_public(request, **kwargs): async def test_route_public(request, **kwargs):
raise Exception('Should not raise') raise Exception('Should not raise')
return PlainTextResponse('ok') return PlainTextResponse('ok')
@ -17,10 +17,10 @@ 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}]) @route_acl_decorator(params=[{'acl':acl.private}])
async def test_route_private(request, **kwargs): async def test_route_private(request, **kwargs):
raise Exception('Should not raise') raise Exception('Should not raise')
return PlainTextResponse('ok') return PlainTextResponse('ok')
@ -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

@ -1,15 +0,0 @@
import os
from starlette.applications import Starlette
from unittest.mock import MagicMock, patch
from halfapi.halfapi import HalfAPI
from halfapi.lib.domain import NoDomainsException
def test_halfapi_dummy_domain(dummy_domain):
with patch('starlette.applications.Starlette') as mock:
mock.return_value = MagicMock()
config = {}
config['domain'] = {}
config['domain'][dummy_domain['name']] = dummy_domain
print(config)
halfapi = HalfAPI(config)

92
tests/test_cli.py Normal file
View File

@ -0,0 +1,92 @@
#!/usr/bin/env python3
import os
import subprocess
import importlib
import tempfile
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from configparser import ConfigParser
from halfapi import __version__
from halfapi.cli import cli
Cli = cli.cli
PROJNAME = os.environ.get('PROJ','tmp_api')
@pytest.mark.incremental
class TestCli():
def test_options(self, runner):
# Wrong command
with runner.isolated_filesystem():
r = runner.invoke(Cli, ['foobar'])
assert r.exit_code == 2
# Test existing commands
with runner.isolated_filesystem():
r = runner.invoke(Cli, ['--help'])
assert r.exit_code == 0
with runner.isolated_filesystem():
r = runner.invoke(Cli, ['--version'])
assert r.exit_code == 0
with runner.isolated_filesystem():
r = runner.invoke(Cli, ['init', '--help'])
assert r.exit_code == 0
def test_init_project_fail(self, runner):
# Missing argument (project)
testproject = 'testproject'
r = runner.invoke(Cli, ['init'])
assert r.exit_code == 2
with runner.isolated_filesystem():
# Fail : Wrong project name
r = runner.invoke(Cli, ['init', 'test*-project'])
assert r.exit_code == 1
with runner.isolated_filesystem():
# Fail : Already existing folder
os.mkdir(testproject)
r = runner.invoke(Cli, ['init', testproject])
assert r.exit_code == 1
with runner.isolated_filesystem():
# Fail : Already existing nod
os.mknod(testproject)
r = runner.invoke(Cli, ['init', testproject])
assert r.exit_code == 1
def test_init_project(self, runner, halfapi_conf_dir):
"""
"""
cp = ConfigParser()
with runner.isolated_filesystem():
env = {
'HALFAPI_CONF_DIR': halfapi_conf_dir
}
res = runner.invoke(Cli, ['init', PROJNAME], env=env)
try:
assert os.path.isdir(PROJNAME)
assert os.path.isdir(os.path.join(PROJNAME, '.halfapi'))
# .halfapi/config check
assert os.path.isfile(os.path.join(PROJNAME, '.halfapi', 'config'))
cp.read(os.path.join(PROJNAME, '.halfapi', 'config'))
assert cp.has_section('project')
assert cp.has_option('project', 'name')
assert cp.get('project', 'name') == PROJNAME
assert cp.get('project', 'halfapi_version') == __version__
assert cp.has_section('domains')
except AssertionError as exc:
subprocess.run(['tree', '-a', os.getcwd()])
raise exc
assert res.exit_code == 0
assert res.exception is None

35
tests/test_cli_proj.py Normal file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
import os
import subprocess
import importlib
import tempfile
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from configparser import ConfigParser
PROJNAME = os.environ.get('PROJ','tmp_api')
@pytest.mark.incremental
class TestCliProj():
def test_cmds(self, project_runner):
assert project_runner('--help').exit_code == 0
#assert project_runner('run', '--help').exit_code == 0
#assert project_runner('domain', '--help').exit_code == 0
def test_domain_commands(self, project_runner):
r = project_runner('domain')
assert r.exit_code == 0
@pytest.mark.skip
def test_config_commands(self, project_runner):
try:
r = project_runner('config')
assert r.exit_code == 0
except AssertionError as exc:
subprocess.call(['tree', '-a'])
raise exc

View File

@ -1,52 +0,0 @@
from unittest import TestCase
import sys
import pytest
from halfapi.halfapi import HalfAPI
class TestConf(TestCase):
def setUp(self):
self.args = {
'domain': {
'dummy_domain': {
'name': 'dummy_domain',
'router': '.routers',
'enabled': True,
'prefix': False,
}
}
}
def tearDown(self):
pass
def test_conf_production_default(self):
halfapi = HalfAPI({
**self.args
})
assert halfapi.PRODUCTION is True
def test_conf_production_true(self):
halfapi = HalfAPI({
**self.args,
'production': True,
})
assert halfapi.PRODUCTION is True
def test_conf_production_false(self):
halfapi = HalfAPI({
**self.args,
'production': False,
})
assert halfapi.PRODUCTION is False
def test_conf_variables(self):
from halfapi.conf import (
CONFIG,
SCHEMA,
)
assert isinstance(CONFIG, dict)
assert isinstance(CONFIG.get('project_name'), str)
assert isinstance(SCHEMA, dict)
assert isinstance(CONFIG.get('secret'), str)
assert isinstance(CONFIG.get('host'), str)
assert isinstance(CONFIG.get('port'), int)

View File

@ -1,14 +0,0 @@
from schema import Schema
def test_constants():
from halfapi.lib.constants import (
VERBS,
ACLS_SCHEMA,
ROUTE_SCHEMA,
DOMAIN_SCHEMA,
API_SCHEMA)
assert isinstance(VERBS, tuple)
assert isinstance(ACLS_SCHEMA, Schema)
assert isinstance(ROUTE_SCHEMA, Schema)
assert isinstance(DOMAIN_SCHEMA, Schema)
assert isinstance(API_SCHEMA, Schema)

View File

@ -2,66 +2,33 @@
import pytest import pytest
from starlette.authentication import UnauthenticatedUser from starlette.authentication import UnauthenticatedUser
from starlette.testclient import TestClient from starlette.testclient import TestClient
import subprocess
import json import json
import os
import sys
import pprint
import openapi_spec_validator
import logging
logger = logging.getLogger()
from halfapi.lib.constants import API_SCHEMA
def test_halfapi_whoami(application_debug): def test_whoami(project_runner, application_debug):
# @TODO : If we use isolated filesystem multiple times that creates a bug. # @TODO : test with fake login
# 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_log(application_debug):
# @TODO : If we use isolated filesystem multiple times that creates a bug.
# So we use a single function with fixture "application debug"
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.get('/halfapi/log')
r = c.request('get', '/halfapi/log')
assert r.status_code == 200 assert r.status_code == 200
def test_halfapi_error_400(application_debug): def test_error(application_debug):
# @TODO : If we use isolated filesystem multiple times that creates a bug.
# So we use a single function with fixture "application debug"
c = TestClient(application_debug) c = TestClient(application_debug)
r = c.get('/halfapi/error/400')
r = c.request('get', '/halfapi/error/400')
assert r.status_code == 400 assert r.status_code == 400
r = c.get('/halfapi/error/404')
def test_halfapi_error_404(application_debug):
# @TODO : If we use isolated filesystem multiple times that creates a bug.
# So we use a single function with fixture "application debug"
c = TestClient(application_debug)
r = c.request('get', '/halfapi/error/404')
assert r.status_code == 404 assert r.status_code == 404
r = c.get('/halfapi/error/500')
def test_halfapi_error_500(application_debug):
# @TODO : If we use isolated filesystem multiple times that creates a bug.
# So we use a single function with fixture "application debug"
c = TestClient(application_debug)
r = c.request('get', '/halfapi/error/500')
assert r.status_code == 500 assert r.status_code == 500
def test_schema(application_debug): def test_exception(application_debug):
c = TestClient(application_debug) c = TestClient(application_debug)
try:
r = c.request('get', '/') r = c.get('/halfapi/exception')
schema = r.json() assert r.status_code == 500
assert isinstance(schema, dict) except Exception:
openapi_spec_validator.validate_spec(schema) print('exception')

View File

@ -1,106 +0,0 @@
import pytest
from halfapi.testing.test_domain import TestDomain
from pprint import pprint
import logging
logger = logging.getLogger()
class TestDummyDomain(TestDomain):
from .dummy_domain import domain
__name__ = domain.get('name')
__routers__ = domain.get('routers')
DOMAIN = __name__
CONFIG = {'test': True}
def test_domain(self):
self.check_domain()
def test_routes(self):
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,19 +0,0 @@
import importlib
from halfapi.testing.test_domain import TestDomain
def test_dummy_domain():
from . import dummy_domain
from .dummy_domain import acl
assert acl.public() is True
assert isinstance(acl.random(), int)
assert acl.private() is False
from .dummy_domain import routers
from .dummy_domain.routers.arguments import get
from .dummy_domain.routers.abc.alphabet.TEST_uuid import get
from .dummy_domain.routers.abc.pinnochio import get
from .dummy_domain.routers.config import get
from .dummy_domain.routers.config import get
from .dummy_domain.routers import async_router
from .dummy_domain.routers.async_router import ROUTES, get_abc_alphabet_TEST, get_abc_pinnochio, get_config, get_arguments

View File

@ -4,106 +4,51 @@ import importlib
import subprocess import subprocess
import time import time
import pytest import pytest
import json
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): from halfapi.lib.domain import gen_router_routes
def test_get_config_route(dummy_project, application_domain, routers):
c = TestClient(application_domain) c = TestClient(application_domain)
r = c.request('get', '/') r = c.get('/dummy_domain/config')
assert r.status_code == 200
pprint(r.json())
r = c.request('get', '/config')
assert r.status_code == 200
pprint(r.json())
assert 'test' in r.json() assert 'test' in r.json()
def test_get_route(dummy_project, application_domain): def test_get_route(dummy_project, application_domain, routers):
c = TestClient(application_domain) c = TestClient(application_domain)
path = verb = params = None path = verb = params = None
dummy_domain_routes = [ for path, verb, _, params in gen_router_routes(routers, []):
('config','GET'), if len(params):
('config','GET'), route_path = '/dummy_domain/{}'.format(path)
('async_router/abc/pinnochio','GET'),
('async_router/config','GET'),
# ('abc/pinnochio','GET'),
# ('abc/alphabet','GET'),
]
for route_def in dummy_domain_routes:
path, verb = route_def[0], route_def[1]
route_path = '/{}'.format(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:
assert r.status_code in [200, 501] assert r.status_code in [200, 501]
except AssertionError as exc: except AssertionError as exc:
print('{} [{}] {}'.format(str(r.status_code), verb, route_path)) print('{} [{}] {}'.format(str(r.status_code), verb, route_path))
raise exc from exc
except NotImplementedError: except NotImplementedError:
pass pass
dummy_domain_path_routes = [ if not path:
('abc/alphabet/{test}','GET'), raise Exception('No route generated')
]
#for route_def in dummy_domain_path_routes:
for route_def in []:#dummy_domain_routes:
from uuid import uuid4
test_uuid = uuid4()
for route_def in dummy_domain_path_routes:
path, verb = route_def[0], route_def[1]
path = path.format(test=str(test_uuid))
route_path = f'/{path}'
if verb.lower() == 'get':
r = c.request('get', f'{route_path}')
assert r.status_code == 200
def test_delete_route(dummy_project, application_domain): def test_delete_route(dummy_project, application_domain, routers):
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'/dummy_domain/abc/alphabet/{arg}')
assert r.status_code == 200 assert r.status_code == 200
assert isinstance(r.json(), str) assert r.json() == arg
def test_arguments_route(dummy_project, application_domain):
c = TestClient(application_domain)
path = '/arguments'
r = c.request('get', path)
assert r.status_code == 400
r = c.request('get', path, params={'foo':True})
assert r.status_code == 400
arg = {'foo':True, 'bar':True}
r = c.request('get', path, params=arg)
assert r.status_code == 200
for key, val in arg.items():
assert json.loads(r.json()[key]) == val
path = '/async_router/arguments'
r = c.request('get', path)
assert r.status_code == 400
r = c.request('get', path, params={'foo':True})
assert r.status_code == 400
arg = {'foo':True, 'bar':True}
r = c.request('get', path, params=arg)
assert r.status_code == 200
for key, val in arg.items():
assert json.loads(r.json()[key]) == val

View File

@ -1,6 +0,0 @@
from halfapi.halfapi import HalfAPI
def test_methods():
assert 'application' in dir(HalfAPI)
assert 'version' in dir(HalfAPI)
assert 'version_async' in dir(HalfAPI)

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

@ -1,124 +1,36 @@
# #!/usr/bin/env python3 #!/usr/bin/env python3
# import importlib import importlib
# from halfapi.lib.domain import VERBS, gen_routes, gen_router_routes, \ from halfapi.lib.domain import VERBS, gen_routes, gen_router_routes, MissingAclError
#  MissingAclError, domain_schema_dict, domain_schema_list
# 
# from types import FunctionType
# 
# 
# def test_gen_router_routes():
#  from .dummy_domain import routers
#  for path, verb, m_router, fct, params in gen_router_routes(routers, ['dummy_domain']):
#  assert isinstance(path, str)
#  assert verb in VERBS
#  assert len(params) > 0
#  assert hasattr(fct, '__call__')
#  assert len(m_router.__file__) > 0
# 
# 
# def test_gen_routes():
#  from .dummy_domain.routers.abc.alphabet import TEST_uuid
#  try:
#  gen_routes(
#  TEST_uuid,
#  'get',
#  ['abc', 'alphabet', 'TEST_uuid', ''],
#  [])
#  except MissingAclError:
#  assert True
# 
#  fct, params = gen_routes(
#  TEST_uuid,
#  'get',
#  ['abc', 'alphabet', 'TEST_uuid', ''],
#  TEST_uuid.ACLS['GET'])
# 
#  assert isinstance(fct, FunctionType)
#  assert isinstance(params, list)
#  assert len(TEST_uuid.ACLS['GET']) == len(params)
# 
# def test_domain_schema_dict():
#  from .dummy_domain import routers
#  d_res = domain_schema_dict(routers)
# 
#  assert isinstance(d_res, dict)
# 
# def test_domain_schema_list():
#  from .dummy_domain import routers
#  res = domain_schema_list(routers)
# 
#  assert isinstance(res, list)
#  assert len(res) > 0
from starlette.testclient import TestClient from types import FunctionType
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(): def test_gen_router_routes():
""" It should decorate an async function that fullfills its arguments from .dummy_domain import routers
""" for path, verb, fct, params in gen_router_routes(routers, ['dummy_domain']):
def route(halfapi, data, out, ret_type='txt'): assert isinstance(path, str)
for key in ['user', 'config', 'domain', 'cookies', 'base_url', 'url']: assert verb in VERBS
assert key in halfapi assert len(params) > 0
assert hasattr(fct, '__call__')
assert halfapi['user'] is None def test_gen_routes():
assert isinstance(halfapi['config'], dict) from .dummy_domain.routers.abc.alphabet import TEST_uuid
assert len(halfapi['config']) == 0 try:
assert isinstance(halfapi['domain'], str) gen_routes(
assert halfapi['domain'] == 'unknown' TEST_uuid,
assert isinstance(halfapi['cookies'], dict) 'get',
assert len(halfapi['cookies']) == 0 ['abc', 'alphabet', 'TEST_uuid', ''],
assert len(str(halfapi['base_url'])) > 0 [])
assert str(halfapi['base_url']) == 'http://testserver/' except MissingAclError:
assert len(str(halfapi['url'])) > 0 assert True
assert str(halfapi['url']) == 'http://testserver/'
assert isinstance(data, dict)
assert len(data) == 0
assert out is None fct, params = gen_routes(
TEST_uuid,
'get',
['abc', 'alphabet', 'TEST_uuid', ''],
TEST_uuid.ACLS['GET'])
assert ret_type is 'txt' assert isinstance(fct, FunctionType)
assert isinstance(params, list)
assert len(TEST_uuid.ACLS['GET']) == len(params)
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

@ -1,38 +0,0 @@
from starlette.testclient import TestClient
from starlette.middleware.base import BaseHTTPMiddleware
from unittest.mock import patch
from halfapi.lib.domain_middleware import DomainMiddleware
def test_init():
with patch('starlette.middleware.base.BaseHTTPMiddleware.__init__') as init:
mw = DomainMiddleware('app', 'domain')
init.assert_called_once_with('app')
assert mw.domain == 'domain'
assert mw.request == None
def test_call(application_debug):
c = TestClient(application_debug)
r = c.request('get', '/abc/alphabet')
assert r.status_code == 200
assert r.headers['x-domain'] == 'dummy_domain'
assert r.headers['x-acl'] == 'public'
r = c.request('get', '/arguments')
assert r.status_code == 400
assert r.headers['x-domain'] == 'dummy_domain'
assert r.headers['x-acl'] == 'public'
assert 'foo' in r.headers['x-args-required'].split(',')
assert 'bar' in r.headers['x-args-required'].split(',')
assert r.headers['x-args-optional'] == 'x'
c = TestClient(application_debug)
r = c.request('post', '/arguments')
assert r.status_code == 400
assert r.headers['x-domain'] == 'dummy_domain'
assert r.headers['x-acl'] == 'public'
assert 'foo' in r.headers['x-args-required'].split(',')
assert 'baz' in r.headers['x-args-required'].split(',')
assert 'truebidoo' in r.headers['x-args-optional'].split(',')
assert 'z' in r.headers['x-args-optional'].split(',')

View File

@ -1,57 +1,44 @@
# import os import os
# import pytest from halfapi.lib.router import read_router
# from schema import SchemaError
# from halfapi.lib.router import read_router def test_read_router_routers():
# from halfapi.lib.constants import ROUTER_SCHEMA, ROUTER_ACLS_SCHEMA from .dummy_domain import routers
# 
# def test_read_router_routers(): router_d = read_router(routers)
#  from .dummy_domain import routers assert '' in router_d
#  assert 'SUBROUTES' in router_d['']
#  router_d = read_router(routers) assert isinstance(router_d['']['SUBROUTES'], list)
#  assert '' in router_d
#  assert 'SUBROUTES' in router_d[''] for elt in os.scandir(routers.__path__[0]):
#  assert isinstance(router_d['']['SUBROUTES'], list) if elt.is_dir():
#  assert elt.name in router_d['']['SUBROUTES']
#  for elt in os.scandir(routers.__path__[0]):
#  if elt.is_dir(): def test_read_router_abc():
#  assert elt.name in router_d['']['SUBROUTES'] from .dummy_domain.routers import abc
#  router_d = read_router(abc)
# def test_read_router_abc():
#  from .dummy_domain.routers import abc assert '' in router_d
#  router_d = read_router(abc) assert 'SUBROUTES' in router_d['']
#  assert isinstance(router_d['']['SUBROUTES'], list)
#  assert '' in router_d
#  assert 'SUBROUTES' in router_d[''] def test_read_router_alphabet():
#  assert isinstance(router_d['']['SUBROUTES'], list) from .dummy_domain.routers.abc import alphabet
#  router_d = read_router(alphabet)
# def test_read_router_alphabet():
#  from .dummy_domain.routers.abc import alphabet assert '' in router_d
#  router_d = read_router(alphabet) assert 'SUBROUTES' in router_d['']
#  assert isinstance(router_d['']['SUBROUTES'], list)
#  assert '' in router_d
#  assert 'SUBROUTES' in router_d[''] def test_read_router_TEST():
#  assert isinstance(router_d['']['SUBROUTES'], list) from .dummy_domain.routers.abc.alphabet import TEST_uuid
#  router_d = read_router(TEST_uuid)
#  ROUTER_SCHEMA.validate(router_d)
#  print(router_d)
#  with pytest.raises(SchemaError): assert '' in router_d
#  """ Test that we cannot specify wrong method in ROUTES or ACLS assert 'SUBROUTES' in router_d['']
#  assert isinstance(router_d['']['GET'], list)
#  TODO: Write more errors assert isinstance(router_d['']['POST'], list)
#  """ assert isinstance(router_d['']['PATCH'], list)
#  router_d['']['TEG'] = {} assert isinstance(router_d['']['PUT'], list)
#  ROUTER_SCHEMA.validate(router_d)
# 
# def test_read_router_TEST():
#  from .dummy_domain.routers.abc.alphabet import TEST_uuid
#  router_d = read_router(TEST_uuid)
# 
#  print(router_d)
#  assert '' in router_d
#  assert 'SUBROUTES' in router_d['']
#  assert isinstance(router_d['']['GET'], list)
#  assert isinstance(router_d['']['POST'], list)
#  assert isinstance(router_d['']['PATCH'], list)
#  assert isinstance(router_d['']['PUT'], list)
# 
# 

View File

@ -1,34 +1,9 @@
# from starlette.routing import Route from starlette.routing import Route
# from halfapi.lib.routes import gen_starlette_routes, gen_router_routes from halfapi.lib.routes import gen_starlette_routes
# 
# def test_gen_starlette_routes(): def test_gen_starlette_routes():
#  from .dummy_domain import routers from .dummy_domain import routers
#  for route in gen_starlette_routes({ for route in gen_starlette_routes({
#  'dummy_domain': routers }): 'dummy_domain': routers }):
# 
#  assert isinstance(route, Route) assert isinstance(route, Route)
# 
# import pytest
# 
# @pytest.mark.skip
# def test_api_routes():
#  from . import dummy_domain
#  d_res, d_acls = api_routes(dummy_domain)
#  assert isinstance(d_res, dict)
#  assert isinstance(d_acls, dict)
# 
#  yielded = False
# 
#  for path, verb, m_router, fct, params in gen_router_routes(dummy_domain, []):
#  if not yielded:
#  yielded = True
# 
#  assert path in d_res
#  assert verb in d_res[path]
#  assert 'docs' in d_res[path][verb]
#  assert 'acls' in d_res[path][verb]
#  assert isinstance(d_res[path][verb]['docs'], dict)
#  assert isinstance(d_res[path][verb]['acls'], list)
#  assert len(d_res[path][verb]['acls']) == len(params)
# 
#  assert yielded is True

47
tests/test_lib_schemas.py Normal file
View File

@ -0,0 +1,47 @@
import subprocess
from pprint import pprint
from starlette.testclient import TestClient
from starlette.authentication import (
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
UnauthenticatedUser)
from halfapi.lib.schemas import schema_dict_dom
from halfapi import __version__
def test_schemas_dict_dom():
from .dummy_domain import routers
schema = schema_dict_dom({
'dummy_domain':routers})
assert isinstance(schema, dict)
def test_get_api_routes(project_runner, application_debug):
c = TestClient(application_debug)
r = c.get('/')
d_r = r.json()
assert isinstance(d_r, dict)
def test_get_schema_route(project_runner, application_debug):
c = TestClient(application_debug)
r = c.get('/halfapi/schema')
d_r = r.json()
assert isinstance(d_r, dict)
assert 'openapi' in d_r.keys()
assert 'info' in d_r.keys()
assert d_r['info']['title'] == 'HalfAPI'
assert d_r['info']['version'] == __version__
assert 'paths' in d_r.keys()
def test_get_api_dummy_domain_routes(application_domain, routers):
c = TestClient(application_domain)
r = c.get('/dummy_domain')
assert r.status_code == 200
d_r = r.json()
assert isinstance(d_r, dict)
assert 'abc/alphabet' in d_r
assert 'GET' in d_r['abc/alphabet']
assert len(d_r['abc/alphabet']['GET']) > 0
assert 'acl' in d_r['abc/alphabet']['GET'][0]