Compare commits
445 Commits
auth-jwt-t
...
master
Author | SHA1 | Date | |
---|---|---|---|
41ba577afa | |||
f9bef2b124 | |||
ee3bd4413c | |||
50a8431ca3 | |||
c808ba21ab | |||
9f908d3cee | |||
7b6d9e994a | |||
8f6330bca7 | |||
8506aa5322 | |||
b683e80959 | |||
b412d249a1 | |||
|
f4f9a0fc66 | ||
|
c855cce013 | ||
|
e083c4386e | ||
|
476ae29792 | ||
|
673097adeb | ||
|
1cc1bbd5ef | ||
|
135d6e86e4 | ||
|
0fcf433ec6 | ||
|
45cf32de2b | ||
|
1b713c3816 | ||
|
59889e1e31 | ||
|
28a1a69435 | ||
65ecf9817c | |||
3b7d3bda5c | |||
e19f27f306 | |||
e9c84c9f7c | |||
b1595beb14 | |||
60ff99d0fb | |||
9657f0f9ec | |||
f646b4d663 | |||
0817882558 | |||
896ce58731 | |||
87856cfb42 | |||
4856f80b99 | |||
eac602f0a5 | |||
14e051bd91 | |||
20563081f5 | |||
7949b3206c | |||
c4583b7187 | |||
2413436104 | |||
|
54cc6c17c9 | ||
|
ff3a39c740 | ||
|
8d254bafa0 | ||
|
0a385661b9 | ||
|
e065fe04e4 | ||
|
b7c5704c95 | ||
|
dd83a337e9 | ||
|
f6d08e8309 | ||
|
262de901a8 | ||
|
e5c25ede1f | ||
|
b4c37ea999 | ||
|
5a7e51ae94 | ||
|
69129fd7af | ||
|
a3fc6dc830 | ||
|
064127dc16 | ||
|
c2eb95533c | ||
|
bbb027cd0d | ||
|
c9ecc1f8d2 | ||
|
d1a8351997 | ||
|
dc29abea84 | ||
|
4c966e7151 | ||
|
7deb353b4b | ||
|
3986083725 | ||
|
301b0eeab5 | ||
|
cc0566542b | ||
|
4f4dac0ff2 | ||
|
8b8caa2e15 | ||
|
5a70c00541 | ||
|
7723acb812 | ||
|
fd682ba0e0 | ||
|
e9c57049dd | ||
|
e47ffcddb9 | ||
|
f0c898ba20 | ||
|
b7e678e00f | ||
|
d0ec030ce9 | ||
|
039bc2c8fe | ||
|
5e21d4c24f | ||
|
8bdf5cab82 | ||
|
910e1e1497 | ||
|
53a691d985 | ||
|
5d1b88daca | ||
|
c0bd6ddc43 | ||
|
779dd2d519 | ||
|
84747e3f73 | ||
|
18748808c9 | ||
|
34ede09fe2 | ||
|
6ebdf765bd | ||
|
2d47789f61 | ||
3ec6d7514e | |||
3c97d39cdc | |||
f96c712aa8 | |||
23a93026aa | |||
05cf37c775 | |||
a46b045ca6 | |||
f68b7e59b8 | |||
|
b17ce623f4 | ||
|
7dd7d00625 | ||
|
389823db82 | ||
|
e70239433f | ||
|
739ffc9afa | ||
|
99d4aaeb8d | ||
|
0d9dc2a018 | ||
|
b63b0f52c6 | ||
|
63d6d1e8ea | ||
|
e8c99e6012 | ||
|
568aea9ea8 | ||
|
988a1e5bab | ||
|
de72e469d2 | ||
|
f7879c6388 | ||
|
84179743a6 | ||
|
adf7f872b6 | ||
|
b96f4908c6 | ||
|
a388faf1d8 | ||
|
4d6e935813 | ||
|
4e080f805f | ||
|
91dd4cbaa8 | ||
|
209c6ef40a | ||
|
a1c1bf04df | ||
|
90203b2edf | ||
|
a69d2b7639 | ||
|
c3153921f7 | ||
|
97fee8ca96 | ||
|
d33c82e348 | ||
|
058121d985 | ||
|
2e5680d29a | ||
|
979007f287 | ||
|
df555c7d26 | ||
|
be312d4b7a | ||
|
f02a97fbf8 | ||
|
e9ffb553c8 | ||
|
776cc8c85e | ||
|
5d5ffdfb7c | ||
|
7c0f5717f4 | ||
|
7fb5e25411 | ||
|
a0dbbca04d | ||
|
95fb267e81 | ||
|
e9bf94a607 | ||
|
f82cd5552b | ||
|
bdbad9e296 | ||
|
76e942ab91 | ||
|
8fff1f5372 | ||
|
048c9f1bab | ||
|
d5f39a7929 | ||
|
648841d90f | ||
|
c658815eb5 | ||
|
46e62575ae | ||
|
7001cec86e | ||
|
b4157c4a7d | ||
|
96f78e76c5 | ||
|
d54dcd641d | ||
|
7060d201ec | ||
|
dbca2f28fb | ||
|
d06857bf49 | ||
|
3dc951c81e | ||
|
a8e5cfc0ff | ||
|
20cada4fa0 | ||
|
c1bb637be7 | ||
|
a37c2356d6 | ||
|
038715e94a | ||
|
2f9005a1a5 | ||
|
a2d79f49b9 | ||
|
cf98b08fa5 | ||
|
c1191bbb0e | ||
|
837c646bc5 | ||
|
49c13c56ac | ||
|
1ccfa0d10e | ||
|
238bd99bd3 | ||
|
429a90d786 | ||
|
1ec244b60f | ||
|
53ecbb58fc | ||
|
7e7bbb3a62 | ||
|
5e88109b3e | ||
|
e293ac3867 | ||
|
a98aa27485 | ||
|
15794327f9 | ||
|
cf20b76959 | ||
|
7e1cc21b8c | ||
|
ec26438340 | ||
|
7230316296 | ||
|
f5b7e3392a | ||
|
18dbbdd584 | ||
|
b3b32b47f8 | ||
|
189fcf86f7 | ||
|
55878df260 | ||
|
a6985fa9bf | ||
|
ed6dcb0513 | ||
|
7017827b2b | ||
|
7c2bf60812 | ||
|
1fda2ab15d | ||
|
c9639ddbc0 | ||
|
24bd3f5653 | ||
|
e203552876 | ||
|
ac4aadc2df | ||
|
4dae2f3676 | ||
|
c27ed3a966 | ||
|
47d81c048f | ||
|
ad6877a7e9 | ||
|
e7e1bfed1b | ||
|
f0e662e060 | ||
|
ea8e7ede65 | ||
|
f4880f1f9c | ||
|
4a8cb008e6 | ||
|
049860fce5 | ||
|
908eab5fdc | ||
|
ce672eeb30 | ||
|
1f20a336e2 | ||
|
0173eb6d72 | ||
|
ad9bd45ba0 | ||
|
00c7b5caf4 | ||
|
5184ab4411 | ||
|
159d38cb94 | ||
|
0cad726f8c | ||
|
94e09a546b | ||
|
0643af5cca | ||
|
339c910c86 | ||
|
948372fcbc | ||
|
5a0509a114 | ||
|
c99e636d6e | ||
|
8b88d7f1b4 | ||
|
f27b68e350 | ||
|
f3c12f516e | ||
|
55109e271c | ||
|
51877b271e | ||
|
061c966072 | ||
|
cdd2214043 | ||
|
0470f9fa89 | ||
|
74b79120ba | ||
|
bc556854ac | ||
|
865a4dffd1 | ||
|
5e2ccceedf | ||
|
844f6a8f14 | ||
|
43b7fe21df | ||
|
c9ba99c1df | ||
|
18a1f71d99 | ||
|
c2cea298bf | ||
|
387fc01f44 | ||
|
c2054e9aa9 | ||
|
0e669b81b0 | ||
|
b45c0bf746 | ||
|
c920531610 | ||
|
bb50fae186 | ||
|
0c3aeb532f | ||
|
8a9f93b9e0 | ||
|
9381e1582e | ||
|
a539212faf | ||
|
a14285475e | ||
|
15d69efd60 | ||
|
2819483070 | ||
|
81f6cf8b39 | ||
|
eb68d06ac0 | ||
|
3fb6fb4ded | ||
|
e5a278c84c | ||
|
904783b134 | ||
|
e0b06f51d5 | ||
|
8ca94ab7ed | ||
|
e4e04c6ac1 | ||
|
0e5a8ede9d | ||
|
a0c41d7d78 | ||
|
a82fd6def0 | ||
|
9a9bc16bbc | ||
|
ea1f54cb82 | ||
|
3c6713b5e2 | ||
|
c7e29e399b | ||
|
89a5f3aa52 | ||
|
933f456c86 | ||
|
5276833afe | ||
|
10b1960f4e | ||
|
a2fb70f84b | ||
|
795ca3dcc0 | ||
|
5b67d938e2 | ||
|
89ec439d3e | ||
|
b5ef4a12d1 | ||
|
4eb23fd189 | ||
|
e1c3d61207 | ||
|
cd0df35496 | ||
|
607a288e28 | ||
|
a3d546905c | ||
|
fecdaa29e5 | ||
|
f36a2d8e06 | ||
|
54e215b6ff | ||
|
a5300962ad | ||
|
d21ee175e9 | ||
|
f8e546007c | ||
|
8d414f2bdd | ||
|
e61dd7eeaa | ||
|
f4ba64f186 | ||
|
d4a6bb1a04 | ||
|
5d4b8d38b4 | ||
|
9516eaa6d7 | ||
|
56657036e4 | ||
|
73d49031a7 | ||
|
4782764059 | ||
|
61aec6871a | ||
|
9a4f90d36b | ||
|
64e60343bf | ||
|
4df34b5d87 | ||
|
24c68b51f2 | ||
|
d31efe3cc4 | ||
|
e590bc31fe | ||
|
3959e6d614 | ||
|
1e1ff2fb69 | ||
|
63b73a2bc1 | ||
|
710d390b49 | ||
|
cb5724b4fa | ||
|
23bd876c4c | ||
|
40547ddf30 | ||
|
f5ebabbcd4 | ||
|
781736c151 | ||
|
acb0a46904 | ||
|
b651d90f0e | ||
|
0a34948b98 | ||
|
c41d0d8f8f | ||
|
91ea25791b | ||
|
0cd7c987e5 | ||
|
6653a0cd0a | ||
|
2d51f260cd | ||
|
8fc1ba6c91 | ||
|
9753f4be95 | ||
|
584e0b6584 | ||
|
82e2ccbdbc | ||
|
a6f2187032 | ||
|
1ee5da1588 | ||
|
1d0d3563cb | ||
|
91581f9ae6 | ||
|
6ac0b5acd9 | ||
|
77598b8453 | ||
|
ad1fed7117 | ||
|
59ed5884ce | ||
|
5c4e81d5d2 | ||
|
21950aa6cd | ||
|
abfaf2e1ea | ||
|
333aca9e2c | ||
|
b6e511a96d | ||
|
deb41be3e8 | ||
|
429b26dec6 | ||
|
c603727190 | ||
|
74e0b3dc54 | ||
|
31878d971e | ||
|
b89e03746f | ||
|
79210e503e | ||
|
360f59b6ba | ||
|
7d6bc2c181 | ||
|
0a94f71dad | ||
|
e223c0791c | ||
|
d93fb23bba | ||
|
3530f53820 | ||
|
f0d980e035 | ||
|
246c9224e3 | ||
|
70723ea580 | ||
|
4ef7ae377a | ||
|
6a81c61649 | ||
|
2ad0b3a14b | ||
|
2610d9f089 | ||
|
65797873da | ||
|
d6075de2eb | ||
|
39d455b682 | ||
|
a78e6ebc75 | ||
|
5f7d66d4d6 | ||
|
38798549f6 | ||
|
ba44a01a45 | ||
|
c54101c3e6 | ||
|
7337246fc1 | ||
|
142ea24630 | ||
|
bcc4a3e9d8 | ||
|
e8ed06a9b6 | ||
|
31a8f68a0a | ||
|
c97fa3b4c2 | ||
|
87cc59849a | ||
|
1b40b95d19 | ||
|
51722b73f8 | ||
|
d944d45bbf | ||
|
ed54127c81 | ||
|
8d2be99068 | ||
|
2389d67749 | ||
|
30651ec19b | ||
|
bdc131434b | ||
|
3849ba4500 | ||
|
05a95d069b | ||
|
63793a94e5 | ||
|
51c319de20 | ||
|
976ba9b808 | ||
|
cdcf8d034d | ||
|
b4302f50e4 | ||
|
c4872ec0b3 | ||
|
ad1304f8d4 | ||
|
7c4eafb40c | ||
|
63bd4c8db6 | ||
|
914cb149e1 | ||
|
6a65aaeaef | ||
|
155bab1e8f | ||
|
60877762c0 | ||
|
d164ad001a | ||
|
446db4ee27 | ||
|
7ff8d803ee | ||
|
048d06de37 | ||
|
d47f735828 | ||
|
50314e6656 | ||
|
7f0e2d0a07 | ||
|
ceaf2774bf | ||
|
15a63f3713 | ||
|
d86dab8de1 | ||
|
36f87849ca | ||
|
b34631cdd0 | ||
|
74c2c4e056 | ||
|
a3c3d7c816 | ||
|
14ab8bd346 | ||
|
b8f1d3a35e | ||
|
f28e11e051 | ||
|
4eba987eb9 | ||
|
3a81dfae96 | ||
|
d5fcfa0f24 | ||
|
7ef6e78010 | ||
|
4d02cf4acd | ||
|
527d5c2e93 | ||
|
e289f6ad6b | ||
|
be1e5ed722 | ||
|
cf9b5168b4 | ||
|
f5a210a855 | ||
|
9e4c3506d5 | ||
|
ad3792340c | ||
|
e4d0b6c17e | ||
|
0fffab106f | ||
|
7f3fd74a31 | ||
|
98f0536a1a | ||
|
c68dfda96c | ||
|
0282da6e3d | ||
|
5d9f4631be | ||
|
8930e065d2 | ||
|
ceb0b075de | ||
|
5bc97a2a58 | ||
|
5debe56349 | ||
|
c9fa127cd8 | ||
|
f5caaf8b86 | ||
|
2b86a78a4e | ||
|
ac2b00e200 | ||
|
1aba6b5623 | ||
|
32ed99deb9 | ||
|
6c5e64c202 | ||
|
9eead5cd85 | ||
|
1983383071 | ||
|
a9b109d17b | ||
|
2f283db823 | ||
|
1087804e8a | ||
|
7d2bb39593 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -137,3 +137,7 @@ dmypy.json
|
||||
cython_debug/
|
||||
|
||||
domains/
|
||||
.vscode
|
||||
|
||||
# Vim swap files
|
||||
*.swp
|
||||
|
90
.gitlab-ci.yml
Normal file
90
.gitlab-ci.yml
Normal file
@ -0,0 +1,90 @@
|
||||
# This file is a template, and might need editing before it works on your project.
|
||||
# To contribute improvements to CI/CD templates, please follow the Development guide at:
|
||||
# https://docs.gitlab.com/ee/development/cicd/templates.html
|
||||
# This specific template is located at:
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Python.gitlab-ci.yml
|
||||
|
||||
# Official language image. Look for the different tagged releases at:
|
||||
# https://hub.docker.com/r/library/python/tags/
|
||||
image: python:alpine3.18
|
||||
|
||||
# Change pip's cache directory to be inside the project directory since we can
|
||||
# only cache local items.
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||
# REGISTRY_USERNAME - secret variable
|
||||
# REGISTRY_PASSWORD - secret variable
|
||||
# REGISTRY_SERVER - secret variable
|
||||
IMAGE_NAME: ${REGISTRY_SERVER}/malves/${CI_PROJECT_NAME}
|
||||
|
||||
# Pip's cache doesn't store the python packages
|
||||
# https://pip.pypa.io/en/stable/reference/pip_install/#caching
|
||||
#
|
||||
# If you want to also cache the installed packages, you have to install
|
||||
# them in a virtualenv and cache it as well.
|
||||
cache:
|
||||
paths:
|
||||
- .cache/pip
|
||||
- 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:
|
||||
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:
|
||||
- python3 -V # Print out python version for debugging
|
||||
- python3 -m venv /tmp/venv
|
||||
- /tmp/venv/bin/pip3 install .
|
||||
artifacts:
|
||||
paths:
|
||||
- dist/*.whl
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG != ""'
|
||||
variables:
|
||||
TAG: $CI_COMMIT_TAG
|
||||
|
||||
build_container:
|
||||
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
|
||||
script:
|
||||
- echo "Will upload image halfapi:\"$IMGTAG\""
|
||||
- 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"
|
||||
artifacts:
|
||||
paths:
|
||||
- /kaniko/.docker/config.json
|
248
CHANGELOG.md
Normal file
248
CHANGELOG.md
Normal file
@ -0,0 +1,248 @@
|
||||
# HalfAPI
|
||||
|
||||
## 0.6.31
|
||||
|
||||
Dependencies updates
|
||||
|
||||
- orjson v3.10
|
||||
- starlette v0.46.2
|
||||
- schema v0.7.7
|
||||
- pyjwt v2.10.0
|
||||
|
||||
Warning : the on_startup halfAPI argument is now removed, use the lifeSpan
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
- Mounts domain routers with their ACLs as decorator
|
||||
- Configuration example files for systemd and a system-wide halfapi install
|
||||
- Runs projects
|
||||
- Handles JWT authentication middleware
|
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@ -0,0 +1,11 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:alpine3.19
|
||||
COPY . /halfapi
|
||||
WORKDIR /halfapi
|
||||
ENV VENV_DIR=/opt/venv
|
||||
RUN mkdir -p $VENV_DIR
|
||||
RUN python -m venv $VENV_DIR
|
||||
RUN $VENV_DIR/bin/pip install gunicorn uvicorn
|
||||
RUN $VENV_DIR/bin/pip install .
|
||||
RUN ln -s $VENV_DIR/bin/halfapi /usr/local/bin/
|
||||
CMD $VENV_DIR/bin/gunicorn halfapi.app
|
15
LICENSE.txt
Normal file
15
LICENSE.txt
Normal file
@ -0,0 +1,15 @@
|
||||
Copyright (c) 2020-2021 Maxime ALVES <maxime@freepoteries.fr>, Joël Maïzi
|
||||
<joel.maizi@collorg.org>
|
||||
|
||||
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
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
7
MANIFEST.in
Normal file
7
MANIFEST.in
Normal file
@ -0,0 +1,7 @@
|
||||
include pyproject.toml
|
||||
|
||||
# Include the README
|
||||
include README.md
|
||||
|
||||
# Include the license file
|
||||
include LICENSE
|
36
Pipfile
Normal file
36
Pipfile
Normal file
@ -0,0 +1,36 @@
|
||||
[[source]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
|
||||
[dev-packages]
|
||||
pytest = ">=7,<8"
|
||||
requests = "*"
|
||||
pytest-asyncio = "*"
|
||||
pylint = "*"
|
||||
build = "*"
|
||||
twine = "*"
|
||||
pyflakes = "*"
|
||||
vulture = "*"
|
||||
virtualenv = "*"
|
||||
httpx = "*"
|
||||
openapi-schema-validator = "*"
|
||||
openapi-spec-validator = "*"
|
||||
coverage = "*"
|
||||
|
||||
[packages]
|
||||
click = ">=8,<9"
|
||||
starlette = ">=0.46,<0.47"
|
||||
uvicorn = ">=0.13,<1"
|
||||
orjson = ">=3.10,<4"
|
||||
pyjwt = ">=2.10.0,<2.11.0"
|
||||
pyyaml = ">=6.0.1,<7"
|
||||
timing-asgi = ">=0.2.1,<1"
|
||||
schema = ">=0.7.7,<1"
|
||||
toml = ">=0.10,<0.11"
|
||||
pip = "*"
|
||||
packaging = ">=19.0"
|
||||
python-multipart = "*"
|
||||
|
||||
[scripts]
|
||||
halfapi = "python -m halfapi"
|
1254
Pipfile.lock
generated
Normal file
1254
Pipfile.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
122
README.md
122
README.md
@ -1,62 +1,104 @@
|
||||
# halfAPI
|
||||
# HalfAPI
|
||||
|
||||
This Python-based ASGI application aims to provide the core functionality to
|
||||
multiple API domains.
|
||||
Base tools to develop complex API with rights management.
|
||||
|
||||
It's developed at the [LIRMM](https://lirmm.fr) in Montpellier, France.
|
||||
|
||||
The name "halfAPI" comes from the deep relationship between it and
|
||||
[halfORM](https://gite.lirmm.fr/newsi/halfORM), a project authored by
|
||||
[Joël Maizi](https://gite.lirmm.fr/maizi).
|
||||
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.
|
||||
|
||||
|
||||
## how-to
|
||||
## Dependencies
|
||||
|
||||
As the project uses the [poetry]() package manager, you first have to install it globally. It will replace virtualenv, pip, etc...
|
||||
|
||||
`pip install poetry`
|
||||
|
||||
Be sur to include the bin directory of pip in your PATH.
|
||||
- python3
|
||||
- python3-pip
|
||||
- libgit2-dev
|
||||
- starlette
|
||||
- PyJWT
|
||||
- click
|
||||
- uvicorn
|
||||
- orjson
|
||||
- pyyaml
|
||||
|
||||
|
||||
Then, cd in the halfapi repo, and chose your python version :
|
||||
## Configuration
|
||||
|
||||
`poetry env use 3.8`
|
||||
Configure HalfAPI in the file : .halfapi/config .
|
||||
|
||||
- at this point, every time you come into the folder, when you launch something with "poetry run", it is launched within the virtual environment for this project -
|
||||
It's a **toml** file that contains at least two sections, project and domains.
|
||||
|
||||
And install the deps :
|
||||
|
||||
`poetry install`
|
||||
|
||||
If you need a domain, i.e: organigramme, just add *-E organigramme* avec *install*.
|
||||
|
||||
`poetry install -E organigramme`
|
||||
|
||||
Then, to run the server :
|
||||
|
||||
`poetry run halfapi`
|
||||
https://toml.io/en/
|
||||
|
||||
|
||||
If you need to launch the test suite (only works if you have pytest installed) :
|
||||
### Project
|
||||
|
||||
`poetry run pytest`
|
||||
The main configuration options without which HalfAPI cannot be run.
|
||||
|
||||
# API database
|
||||
**secret** : The file containing the secret to decode the user's tokens.
|
||||
|
||||
You just need to run the following command to insert the right data into the api database :
|
||||
**port** : The port for the test server.
|
||||
|
||||
`poetry run halfapi dbupdate`
|
||||
**loglevel** : The log level (info, debug, critical, ...)
|
||||
|
||||
# API database configuration
|
||||
|
||||
You can set the HALFORM_SECRET and HALFORM_DSN variables to setup the way you connect to the API database.
|
||||
### Domains
|
||||
|
||||
HALFORM_SECRET="wtfqwertz"
|
||||
HALFORM_DSN="dbname=api user=api password=api host=127.0.0.1 port=5432"
|
||||
Specify the domains configurations in the following form :
|
||||
|
||||
## Warning
|
||||
```
|
||||
[domains.DOMAIN_NAME]
|
||||
name = "DOMAIN_NAME"
|
||||
enabled = true
|
||||
prefix = "/prefix"
|
||||
module = "domain_name.path.to.api.root"
|
||||
port = 1002
|
||||
```
|
||||
|
||||
For the domains' databases, for now the database connection string is hardcoded (check app.mount_domains).
|
||||
Specific configuration can be done under the "config" section :
|
||||
|
||||
@TODO find a modular way to configure the database connection for each mounted domain
|
||||
```
|
||||
[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
|
||||
|
||||
Develop an HalfAPI domain by following the examples located in
|
||||
tests/dummy_domain . An HalfAPI domain should be an importable python module
|
||||
that is available in the python path.
|
||||
|
||||
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
|
||||
|
||||
@TODO
|
||||
|
||||
|
||||
### Example
|
||||
|
||||
Check out the [sample project](https://github.com/halfAPI/halfapi_sample_project)
|
||||
that helps you to build your own domain.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
@TODO
|
||||
|
3
conf/env.mallirmm
Normal file
3
conf/env.mallirmm
Normal file
@ -0,0 +1,3 @@
|
||||
DEV=1
|
||||
DEBUG=1
|
||||
DEBUG_ACL=public
|
3
conf/env.merles
Normal file
3
conf/env.merles
Normal file
@ -0,0 +1,3 @@
|
||||
DEV=1
|
||||
DEBUG=1
|
||||
DEBUG_ACL=public
|
20
conf/nginx/api
Normal file
20
conf/nginx/api
Normal file
@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 8080;
|
||||
client_max_body_size 4G;
|
||||
|
||||
server_name api.lirmm.fr;
|
||||
|
||||
location / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_redirect off;
|
||||
proxy_buffering off;
|
||||
proxy_pass http://uvicorn;
|
||||
}
|
||||
}
|
||||
|
||||
upstream uvicorn {
|
||||
server unix:/var/lib/api/lirmm_api.sock;
|
||||
}
|
||||
|
10
conf/systemd/halfapi_example_api
Normal file
10
conf/systemd/halfapi_example_api
Normal file
@ -0,0 +1,10 @@
|
||||
GUNICORN_CMD_ARGS="--daemon \
|
||||
--bind unix:/var/lib/halfapi/example_api.sock \
|
||||
--max-requests 200 \
|
||||
--max-requests-jitter 20 \
|
||||
--workers 4 \
|
||||
--log-syslog-facility daemon \
|
||||
--worker-class uvicorn.workers.UvicornWorker
|
||||
|
||||
HALFORM_CONF_DIR=/etc/half_orm
|
||||
HALFAPI_CONF_DIR=/etc/half_api
|
19
conf/systemd/halfapi_example_api.service
Normal file
19
conf/systemd/halfapi_example_api.service
Normal file
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=HalfAPI - Project : Example API Service
|
||||
Requires=halfapi_example_api.socket
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=halfapi
|
||||
Group=halfapi
|
||||
WorkingDirectory=/var/lib/halfapi/example_api
|
||||
EnvironmentFile=/etc/default/gunicorn/halfapi_example_api
|
||||
ExecStart=/usr/bin/env gunicorn halfapi
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
KillMode=mixed
|
||||
TimeoutStopSec=5
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
13
conf/systemd/halfapi_example_api.socket
Normal file
13
conf/systemd/halfapi_example_api.socket
Normal file
@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=HalfAPI - Project : Example API Socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/var/lib/halfapi/example_api.sock
|
||||
User=halfapi
|
||||
SocketUser=halfapi
|
||||
SocketGroup=www-data
|
||||
# Optionally restrict the socket permissions even more.
|
||||
# Mode=600
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
@ -1 +1,8 @@
|
||||
__version__ = '0.0.0'
|
||||
#!/usr/bin/env python3
|
||||
__version__ = '0.6.31'
|
||||
|
||||
def version():
|
||||
return f'HalfAPI version:{__version__}'
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(version())
|
||||
|
3
halfapi/__main__.py
Normal file
3
halfapi/__main__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .cli.cli import cli
|
||||
if __name__ == '__main__':
|
||||
cli()
|
@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from functools import wraps
|
||||
""" Base ACL module that contains generic functions for domains ACL
|
||||
"""
|
||||
|
||||
def connected(func):
|
||||
""" Decorator that checks if the user object of the request has been set
|
||||
"""
|
||||
@wraps(func)
|
||||
def caller(req, *args, **kwargs):
|
||||
if not hasattr(req.user, 'is_authenticated'):
|
||||
return False
|
||||
return func(req, **kwargs)
|
||||
|
||||
return caller
|
||||
|
||||
def public(*args, **kwargs) -> bool:
|
||||
"Unlimited access"
|
||||
return True
|
129
halfapi/app.py
129
halfapi/app.py
@ -1,124 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# builtins
|
||||
import importlib
|
||||
import sys
|
||||
from os import environ
|
||||
import os
|
||||
from .halfapi import HalfAPI
|
||||
from .logging import logger
|
||||
|
||||
# asgi framework
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, JSONResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.types import ASGIApp
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
|
||||
# typing
|
||||
from typing import Any, Awaitable, Callable, MutableMapping
|
||||
RequestResponseEndpoint = Callable[ [Request], Awaitable[Response] ]
|
||||
|
||||
# hop-generated classes
|
||||
from .models.api.domain import Domain
|
||||
|
||||
# module libraries
|
||||
from .lib.jwt_middleware import JWTAuthenticationBackend
|
||||
from .lib.acl_caller_middleware import AclCallerMiddleware
|
||||
|
||||
from .lib.responses import *
|
||||
|
||||
|
||||
def mount_domains(app: ASGIApp, domains: list):
|
||||
""" Procedure to mount the registered domains on their prefixes
|
||||
|
||||
Parameters:
|
||||
|
||||
- app (ASGIApp): The Starlette instance
|
||||
- domains (list): The domains to mount, retrieved from the database
|
||||
with their attributes "version", "name"
|
||||
|
||||
Returns: Nothing
|
||||
"""
|
||||
|
||||
for domain in domains:
|
||||
if 'name' not in domain.keys() or 'version' not in domain.keys():
|
||||
continue
|
||||
|
||||
# Retrieve domain app according to domain details
|
||||
try:
|
||||
print(f'Will import {domain["name"]}.app:app')
|
||||
# @TODO 4-configuration
|
||||
# Store domain-specific information in a configuration file
|
||||
environ["HALFORM_DSN"] = "dbname=si user=si"
|
||||
domain_mod = importlib.import_module(
|
||||
f'{domain["name"]}.app')
|
||||
domain_app = domain_mod.app
|
||||
except ModuleNotFoundError:
|
||||
sys.stderr.write(
|
||||
f'Could not find module *{domain["name"]}* in sys.path\n')
|
||||
continue
|
||||
except ImportError:
|
||||
sys.stderr.write(f'Could not import *app* from *{domain}*')
|
||||
continue
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'Error in import *{domain["name"]}*\n')
|
||||
print(e)
|
||||
continue
|
||||
|
||||
|
||||
# Alter the openapi_url so the /docs page doesn't try to get
|
||||
# /openapi.json (@TODO : report the bug to FastAPI)
|
||||
domain_app.openapi_url = '/api/{version}/{name}/openapi.json'.format(**domain)
|
||||
|
||||
# Mount the domain app on the prefix
|
||||
# e.g. : /v4/organigramme
|
||||
try:
|
||||
app.mount('/{version}/{name}'.format(**domain), app=domain_app)
|
||||
except Exception as e:
|
||||
print(f'Failed to mount *{domain}*\n')
|
||||
|
||||
|
||||
def startup():
|
||||
# This function is called at the instanciation of *app*
|
||||
global app
|
||||
|
||||
# Mount the registered domains
|
||||
try:
|
||||
domains_list = [elt for elt in Domain().select()]
|
||||
mount_domains(app, domains_list)
|
||||
except Exception as e:
|
||||
sys.stderr.write('Error in the *domains* retrieval\n')
|
||||
raise e
|
||||
|
||||
async def root(request):
|
||||
return JSONResponse({'payload': request.payload})
|
||||
|
||||
def check_conf():
|
||||
if not environ.get('HALFORM_SECRET', False):
|
||||
environ['HALFORM_SECRET'] = open('/etc/half_orm/secret').read()
|
||||
print('Missing HALFORM_SECRET variable from configuration, seting to default')
|
||||
|
||||
CONFIG={
|
||||
'DEBUG' : 'DEBUG' in environ.keys()
|
||||
}
|
||||
|
||||
debug_routes = [
|
||||
Route('/', lambda request, *args, **kwargs: PlainTextResponse('It Works!')),
|
||||
Route('/user', lambda request, *args, **kwargs: JSONResponse({'user':str(request.user)})),
|
||||
Route('/payload', lambda request, *args, **kwargs: JSONResponse({'payload':str(request.payload)}))
|
||||
] if CONFIG['DEBUG'] is True else []
|
||||
|
||||
app = Starlette(
|
||||
debug=CONFIG['DEBUG'],
|
||||
routes=debug_routes,
|
||||
middleware=[
|
||||
Middleware(AuthenticationMiddleware, backend=JWTAuthenticationBackend(secret_key=environ.get('HALFORM_SECRET'))),
|
||||
Middleware(AclCallerMiddleware),
|
||||
],
|
||||
exception_handlers={
|
||||
401: UnauthorizedResponse,
|
||||
404: NotFoundResponse,
|
||||
500: InternalServerErrorResponse,
|
||||
501: NotImplementedResponse
|
||||
},
|
||||
on_startup=[startup],
|
||||
)
|
||||
def application():
|
||||
from .conf import CONFIG
|
||||
return HalfAPI(CONFIG).application
|
||||
|
211
halfapi/cli.py
211
halfapi/cli.py
@ -1,211 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# builtins
|
||||
import click
|
||||
import uvicorn
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
|
||||
# database
|
||||
import psycopg2
|
||||
|
||||
# hop-generated classes
|
||||
from .models.api.version import Version
|
||||
from .models.api.domain import Domain
|
||||
from .models.api.route import Route
|
||||
from .models.api.acl_function import AclFunction
|
||||
from .models.api.acl import Acl
|
||||
|
||||
# module libraries
|
||||
from .app import check_conf
|
||||
|
||||
HALFORM_DSN=''
|
||||
HALFORM_SECRET=''
|
||||
CONTEXT_SETTINGS={
|
||||
'default_map':{'run': {'port': 8000}}
|
||||
}
|
||||
|
||||
@click.group(invoke_without_command=True, context_settings=CONTEXT_SETTINGS)
|
||||
@click.pass_context
|
||||
def cli(ctx):
|
||||
if ctx.invoked_subcommand is None:
|
||||
return run()
|
||||
|
||||
@click.option('--envfile', default=None)
|
||||
@click.option('--host', default='127.0.0.1')
|
||||
@click.option('--port', default='8000')
|
||||
@cli.command()
|
||||
def run(envfile, host, port):
|
||||
local_env = {}
|
||||
if envfile:
|
||||
try:
|
||||
with open(envfile) as f:
|
||||
print('Will use the following env parameters :')
|
||||
local_env = dict([ tuple(line.strip().split('=', 1))
|
||||
for line in f.readlines() ])
|
||||
print(local_env)
|
||||
except FileNotFoundError:
|
||||
print(f'No file named {envfile}')
|
||||
envfile = None
|
||||
|
||||
if 'DEV' in local_env.keys():
|
||||
debug = True
|
||||
reload = True
|
||||
log_level = 'debug'
|
||||
else:
|
||||
reload = False
|
||||
log_level = 'info'
|
||||
|
||||
click.echo('Launching application')
|
||||
|
||||
check_conf()
|
||||
|
||||
sys.path.insert(0, os.getcwd())
|
||||
click.echo(f'current python_path : {sys.path}')
|
||||
|
||||
uvicorn.run('halfapi.app:app',
|
||||
env_file=envfile,
|
||||
host=host,
|
||||
port=int(port),
|
||||
log_level=log_level,
|
||||
reload=reload)
|
||||
|
||||
@click.option('--dbname', default='api')
|
||||
@click.option('--host', default='127.0.0.1')
|
||||
@click.option('--port', default=5432)
|
||||
@click.option('--user', default='api')
|
||||
@click.option('--password', default='')
|
||||
@click.option('--domain', default='organigramme')
|
||||
@click.option('--drop', is_flag=true, default=false)
|
||||
@cli.command()
|
||||
def dbupdate(dbname, host, port, user, password, domain, drop):
|
||||
|
||||
def dropdb():
|
||||
if not click.confirm(f'will now drop database {dbname}', default=true):
|
||||
return false
|
||||
|
||||
conn = psycopg2.connect({
|
||||
'dbname': dbname,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'password': password
|
||||
})
|
||||
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(f'drop database {dbname};')
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return true
|
||||
|
||||
def delete_domain():
|
||||
d = domain(name=domain)
|
||||
if len(d) < 1:
|
||||
return False
|
||||
|
||||
acl = Acl(domain=domain)
|
||||
acl.delete()
|
||||
|
||||
fct = AclFunction(domain=domain)
|
||||
fct.delete()
|
||||
|
||||
route = Route(domain=domain)
|
||||
route.delete()
|
||||
|
||||
d.delete()
|
||||
|
||||
return True
|
||||
|
||||
def add_acl_fct(fct):
|
||||
acl = AclFunction()
|
||||
acl.version = version
|
||||
acl.domain = domain
|
||||
acl.name = fct.__name__
|
||||
if len(acl) == 0:
|
||||
acl.insert()
|
||||
|
||||
def add_acl(name, **kwargs):
|
||||
acl = Acl()
|
||||
acl.version = version
|
||||
acl.domain = domain
|
||||
acl.name = name
|
||||
acl.path = kwargs['path']
|
||||
acl.http_verb = kwargs['verb']
|
||||
for fct in kwargs['acl']:
|
||||
acl.function = fct.__name__
|
||||
|
||||
if len(acl) == 0:
|
||||
if fct is not None:
|
||||
add_acl_fct(fct)
|
||||
|
||||
acl.insert()
|
||||
|
||||
elif fct is None:
|
||||
acl.delete()
|
||||
|
||||
|
||||
def add_route(name, **kwargs):
|
||||
print(f'Adding route {version}/{domain}/{name}')
|
||||
route = Route()
|
||||
route.version = version
|
||||
route.domain = domain
|
||||
route.path = kwargs['path']
|
||||
if len(route) == 0:
|
||||
route.insert()
|
||||
|
||||
def add_routes_and_acl(routes):
|
||||
for name, route_params in routes.items():
|
||||
add_route(name, **route_params)
|
||||
add_acl(name, **route_params)
|
||||
|
||||
|
||||
def add_domain():
|
||||
new_domain = Domain(name=domain)
|
||||
new_domain.version = version
|
||||
if len(new_domain) == 0:
|
||||
print(f'New domain {domain}')
|
||||
new_domain.insert()
|
||||
|
||||
|
||||
if drop:
|
||||
dropdb()
|
||||
|
||||
delete_domain()
|
||||
|
||||
acl_set = set()
|
||||
|
||||
try:
|
||||
|
||||
# module retrieval
|
||||
dom_mod = importlib.import_module(domain)
|
||||
|
||||
version = dom_mod.API_VERSION
|
||||
add_domain()
|
||||
|
||||
# add main routes
|
||||
ROUTES = dom_mod.ROUTES
|
||||
add_routes_and_acl(dom_mod.ROUTES)
|
||||
|
||||
# add sub routers
|
||||
ROUTERS = dom_mod.ROUTERS
|
||||
|
||||
for router_name in dom_mod.ROUTERS:
|
||||
router_mod = importlib.import_module(f'.routers.{router_name}', domain)
|
||||
add_routes_and_acl(router_mod.ROUTES)
|
||||
|
||||
except ImportError:
|
||||
click.echo(f'The domain {domain} has no *ROUTES* variable', err=True)
|
||||
except Exception as e:
|
||||
click.echo(e, err=True)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
||||
|
28
halfapi/cli/cli.py
Normal file
28
halfapi/cli/cli.py
Normal file
@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
cli/cli.py Main entry point of halfapi cli tool
|
||||
|
||||
The init command is the only command loaded if not in a *project dir*, and it is
|
||||
not loaded otherwise.
|
||||
"""
|
||||
# builtins
|
||||
import click
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option('--version', is_flag=True)
|
||||
@click.pass_context
|
||||
def cli(ctx, version):
|
||||
"""
|
||||
HalfAPI Cli entry point
|
||||
|
||||
It uses the Click library
|
||||
"""
|
||||
if version:
|
||||
from halfapi import version
|
||||
click.echo(version())
|
||||
|
||||
from . import config
|
||||
from . import domain
|
||||
from . import run
|
||||
from . import init
|
||||
from . import routes
|
31
halfapi/cli/config.py
Normal file
31
halfapi/cli/config.py
Normal file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
cli/config.py Contains the .halfapi/config
|
||||
|
||||
Defines the "halfapi config" command
|
||||
"""
|
||||
import click
|
||||
|
||||
from .cli import cli
|
||||
from ..conf import CONFIG
|
||||
|
||||
DOMAIN_CONF_STR="""
|
||||
[domain]
|
||||
name = {name}
|
||||
router = {router}
|
||||
"""
|
||||
|
||||
CONF_STR="""
|
||||
[project]
|
||||
host = {host}
|
||||
port = {port}
|
||||
production = {production}
|
||||
"""
|
||||
|
||||
|
||||
@cli.command()
|
||||
def config():
|
||||
"""
|
||||
Lists config parameters and their values
|
||||
"""
|
||||
click.echo(CONF_STR.format(**CONFIG))
|
242
halfapi/cli/domain.py
Normal file
242
halfapi/cli/domain.py
Normal file
@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
cli/domain.py Defines the "halfapi domain" cli commands
|
||||
"""
|
||||
# builtins
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
import subprocess
|
||||
|
||||
import json
|
||||
import toml
|
||||
|
||||
import click
|
||||
import orjson
|
||||
import uvicorn
|
||||
|
||||
|
||||
from .cli import cli
|
||||
from ..conf import CONFIG
|
||||
|
||||
from ..half_domain import HalfDomain
|
||||
|
||||
from ..lib.routes import api_routes
|
||||
from ..lib.responses import ORJSONResponse
|
||||
from ..conf import CONFIG, PROJECT_LEVEL_KEYS
|
||||
|
||||
|
||||
from ..logging import logger
|
||||
|
||||
#################
|
||||
# domain create #
|
||||
#################
|
||||
def create_domain(domain_name: str, module_path: str):
|
||||
logger.info('Will add **%s** (%s) to current halfAPI project',
|
||||
domain_name, module_path)
|
||||
|
||||
#if domain_name in DOMAINSDICT():
|
||||
# logger.warning('Domain **%s** is already in project', domain_name)
|
||||
# sys.exit(1)
|
||||
|
||||
def domain_tree_create():
|
||||
def create_init(path):
|
||||
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))
|
||||
"""
|
||||
|
||||
|
||||
|
||||
###############
|
||||
# domain read #
|
||||
###############
|
||||
def list_routes(domain, m_dom):
|
||||
"""
|
||||
Echoes the list of the **m_dom** active routes
|
||||
"""
|
||||
|
||||
click.echo(f'\nDomain : {domain}\n')
|
||||
|
||||
routes = api_routes(m_dom)[0]
|
||||
if len(routes):
|
||||
for key, item in routes.items():
|
||||
methods = '|'.join(list(item.keys()))
|
||||
click.echo(f'\t{key} : {methods}')
|
||||
else:
|
||||
click.echo('\t**No ROUTES**')
|
||||
raise Exception('Routeless domain')
|
||||
|
||||
|
||||
|
||||
def list_api_routes():
|
||||
"""
|
||||
Echoes the list of all active domains.
|
||||
|
||||
TODO: Rewrite function
|
||||
"""
|
||||
|
||||
click.echo('# API Routes')
|
||||
# for domain, m_dom in DOMAINSDICT().items():
|
||||
# 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('--conftest',default=False, is_flag=True)
|
||||
@click.option('--create',default=False, is_flag=True)
|
||||
@click.option('--update',default=False, is_flag=True)
|
||||
@click.option('--delete',default=False, is_flag=True)
|
||||
@click.argument('config_file', type=click.File(mode='rb'), required=False)
|
||||
@click.argument('domain',default=None, required=False)
|
||||
@cli.command()
|
||||
def domain(domain, config_file, delete, update, create, conftest, read, run, dry_run, log_level, port, production, watch, devel):
|
||||
"""
|
||||
The "halfapi domain" command
|
||||
|
||||
Parameters:
|
||||
domain (str|None): The domain name
|
||||
|
||||
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"
|
||||
|
||||
update (boolean): If set, update the database for the selected domains
|
||||
"""
|
||||
if not domain:
|
||||
if create:
|
||||
# TODO: Connect to the create_domain function
|
||||
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)
|
||||
)
|
||||
|
||||
else:
|
||||
if dry_run:
|
||||
CONFIG['dryrun'] = True
|
||||
|
||||
domains = CONFIG.get('domain')
|
||||
for key in domains.keys():
|
||||
if key != domain:
|
||||
domains[key]['enabled'] = False
|
||||
else:
|
||||
domains[key]['enabled'] = True
|
||||
|
||||
if not log_level:
|
||||
log_level = CONFIG.get('domain', {}).get('loglevel', CONFIG.get('loglevel', False))
|
||||
else:
|
||||
CONFIG['loglevel'] = log_level
|
||||
|
||||
if not port:
|
||||
port = CONFIG.get('domain', {}).get('port', CONFIG.get('port', False))
|
||||
else:
|
||||
CONFIG['port'] = port
|
||||
|
||||
if devel is None and production is not None and (production is False or production is True):
|
||||
CONFIG['production'] = production
|
||||
|
||||
if devel is not None:
|
||||
CONFIG['production'] = False
|
||||
CONFIG['loglevel'] = 'debug'
|
||||
|
||||
|
||||
if conftest:
|
||||
click.echo(
|
||||
toml.dumps(CONFIG)
|
||||
)
|
||||
|
||||
else:
|
||||
# domain section port is preferred, if it doesn't exist we use the global one
|
||||
|
||||
uvicorn_kwargs = {}
|
||||
|
||||
if CONFIG.get('port'):
|
||||
uvicorn_kwargs['port'] = CONFIG['port']
|
||||
|
||||
if CONFIG.get('loglevel'):
|
||||
uvicorn_kwargs['log_level'] = CONFIG['loglevel'].lower()
|
||||
|
||||
if watch:
|
||||
uvicorn_kwargs['reload'] = True
|
||||
|
||||
uvicorn.run(
|
||||
'halfapi.app:application',
|
||||
factory=True,
|
||||
**uvicorn_kwargs
|
||||
)
|
||||
|
||||
sys.exit(0)
|
61
halfapi/cli/init.py
Normal file
61
halfapi/cli/init.py
Normal file
@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
cli/init.py Defines the "halfapi init" cli commands
|
||||
|
||||
Helps the user to create a new project
|
||||
"""
|
||||
# builtins
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
import click
|
||||
|
||||
from .. import __version__
|
||||
from ..conf import CONF_DIR
|
||||
|
||||
from .cli import cli
|
||||
|
||||
from ..logging import logger
|
||||
|
||||
TMPL_HALFAPI_ETC = """[project]
|
||||
host = 127.0.0.1
|
||||
port = 8000
|
||||
secret = /path/to/secret_file
|
||||
production = False
|
||||
base_dir = {base_dir}
|
||||
"""
|
||||
|
||||
TMPL_HALFAPI_CONFIG = """[project]
|
||||
halfapi_version = {halfapi_version}
|
||||
|
||||
[domain]
|
||||
"""
|
||||
|
||||
@click.argument('project')
|
||||
@cli.command()
|
||||
def init(project):
|
||||
"""
|
||||
The "halfapi init" command
|
||||
"""
|
||||
if not re.match('^[a-z0-9_]+$', project, re.I):
|
||||
click.echo('Project name must match "^[a-z0-9_]+$", retry.', err=True)
|
||||
sys.exit(1)
|
||||
|
||||
if os.path.exists(project):
|
||||
click.echo(f'A file named {project} already exists, abort.', err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
logger.debug('Create directory %s', project)
|
||||
os.mkdir(project)
|
||||
|
||||
logger.debug('Create directory %s/.halfapi', project)
|
||||
os.mkdir(f'{project}/.halfapi')
|
||||
|
||||
with open(f'{project}/.halfapi/config', 'w') as conf_file:
|
||||
conf_file.write(TMPL_HALFAPI_CONFIG.format(
|
||||
halfapi_version=__version__))
|
||||
|
||||
|
||||
click.echo(f'Configure halfapi project in {CONF_DIR}/{project}')
|
54
halfapi/cli/routes.py
Normal file
54
halfapi/cli/routes.py
Normal file
@ -0,0 +1,54 @@
|
||||
#!/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
|
||||
|
87
halfapi/cli/run.py
Normal file
87
halfapi/cli/run.py
Normal file
@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
cli/domain.py Defines the "halfapi run" cli command
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import click
|
||||
import uvicorn
|
||||
|
||||
from .cli import cli
|
||||
from .domain import list_api_routes
|
||||
from ..conf import CONFIG, SCHEMA
|
||||
from ..logging import logger
|
||||
from ..lib.schemas import schema_csv_dict
|
||||
from ..half_domain import HalfDomain
|
||||
|
||||
@click.option('--host', default=CONFIG.get('host'))
|
||||
@click.option('--port', default=CONFIG.get('port'))
|
||||
@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()
|
||||
def run(host, port, reload, secret, production, loglevel, prefix, check, dryrun,
|
||||
schema, domain):
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
port = int(port)
|
||||
|
||||
if production and reload:
|
||||
reload = False
|
||||
raise Exception('Can\'t use live code reload in production')
|
||||
|
||||
click.echo(f'Launching application')
|
||||
|
||||
if secret:
|
||||
CONFIG['secret'] = secret
|
||||
|
||||
if schema:
|
||||
# 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:
|
||||
# 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' \
|
||||
f'host: {host}\n' \
|
||||
f'port: {port}\n' \
|
||||
f'log_level: {loglevel}\n' \
|
||||
f'reload: {reload}\n'
|
||||
)
|
||||
|
||||
if dryrun:
|
||||
CONFIG['dryrun'] = True
|
||||
|
||||
uvicorn.run('halfapi.app:application',
|
||||
host=host,
|
||||
port=int(port),
|
||||
log_level=loglevel,
|
||||
reload=reload)
|
205
halfapi/conf.py
Normal file
205
halfapi/conf.py
Normal file
@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
conf.py reads the current configuration files
|
||||
|
||||
It uses the following environment variables :
|
||||
|
||||
- HALFAPI_CONF_DIR (default: /etc/half_api)
|
||||
|
||||
|
||||
It defines the following globals :
|
||||
|
||||
- PROJECT_NAME (str) - HALFAPI_PROJECT_NAME
|
||||
- PRODUCTION (bool) - HALFAPI_PRODUCTION
|
||||
- LOGLEVEL (str) - HALFAPI_LOGLEVEL
|
||||
- BASE_DIR (str) - HALFAPI_BASE_DIR
|
||||
- HOST (str) - HALFAPI_HOST
|
||||
- PORT (int) - HALFAPI_PORT
|
||||
- CONF_DIR (str) - HALFAPI_CONF_DIR
|
||||
- DRYRUN (bool) - HALFAPI_DRYRUN
|
||||
|
||||
It reads the following ressource :
|
||||
|
||||
- ./.halfapi/config
|
||||
|
||||
It follows the following format :
|
||||
|
||||
[project]
|
||||
halfapi_version = HALFAPI_VERSION
|
||||
|
||||
[domain.domain_name]
|
||||
name = domain_name
|
||||
routers = routers
|
||||
|
||||
[domain.domain_name.config]
|
||||
option = Argh
|
||||
|
||||
"""
|
||||
|
||||
from .logging import logger
|
||||
import os
|
||||
from os import environ
|
||||
import sys
|
||||
import importlib
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
import toml
|
||||
|
||||
SCHEMA = {}
|
||||
|
||||
DEFAULT_CONF = {
|
||||
# Default configuration values
|
||||
'SECRET': tempfile.mkstemp()[1],
|
||||
'PROJECT_NAME': os.getcwd().split('/')[-1],
|
||||
'PRODUCTION': True,
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': 3000,
|
||||
'LOGLEVEL': 'info',
|
||||
'BASE_DIR': os.getcwd(),
|
||||
'CONF_FILE': '.halfapi/config',
|
||||
'CONF_DIR': '/etc/half_api',
|
||||
'DRYRUN': None
|
||||
}
|
||||
|
||||
PROJECT_LEVEL_KEYS = {
|
||||
# Allowed keys in "project" section of configuration file
|
||||
'project_name',
|
||||
'production',
|
||||
'secret',
|
||||
'host',
|
||||
'port',
|
||||
'loglevel',
|
||||
'dryrun'
|
||||
}
|
||||
|
||||
DOMAIN_LEVEL_KEYS = PROJECT_LEVEL_KEYS | {
|
||||
# Allowed keys in "domain" section of configuration file
|
||||
'name',
|
||||
'module',
|
||||
'prefix',
|
||||
'enabled'
|
||||
}
|
||||
|
||||
CONF_FILE = os.environ.get('HALFAPI_CONF_FILE', DEFAULT_CONF['CONF_FILE'])
|
||||
CONF_DIR = os.environ.get('HALFAPI_CONF_DIR', DEFAULT_CONF['CONF_DIR'])
|
||||
|
||||
HALFAPI_ETC_FILE=os.path.join(
|
||||
CONF_DIR, 'config'
|
||||
)
|
||||
|
||||
BASE_DIR = os.environ.get('HALFAPI_BASE_DIR', DEFAULT_CONF['BASE_DIR'])
|
||||
HALFAPI_DOT_FILE=os.path.join(
|
||||
BASE_DIR, '.halfapi', 'config')
|
||||
|
||||
HALFAPI_CONFIG_FILES = []
|
||||
|
||||
try:
|
||||
with open(HALFAPI_ETC_FILE, 'r'):
|
||||
HALFAPI_CONFIG_FILES.append(HALFAPI_ETC_FILE)
|
||||
except FileNotFoundError:
|
||||
logger.info('Cannot find a configuration file under %s', HALFAPI_ETC_FILE)
|
||||
|
||||
try:
|
||||
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 = {}
|
||||
# Load environment variables allowed in configuration
|
||||
|
||||
if 'HALFAPI_DRYRUN' in os.environ:
|
||||
ENVIRONMENT['dryrun'] = True
|
||||
|
||||
if 'HALFAPI_PROD' in os.environ:
|
||||
ENVIRONMENT['production'] = bool(os.environ.get('HALFAPI_PROD'))
|
||||
|
||||
if 'HALFAPI_LOGLEVEL' in os.environ:
|
||||
ENVIRONMENT['loglevel'] = os.environ.get('HALFAPI_LOGLEVEL').lower()
|
||||
|
||||
if 'HALFAPI_SECRET' in os.environ:
|
||||
ENVIRONMENT['secret'] = os.environ.get('HALFAPI_SECRET')
|
||||
|
||||
if 'HALFAPI_HOST' in os.environ:
|
||||
ENVIRONMENT['host'] = os.environ.get('HALFAPI_HOST')
|
||||
|
||||
if 'HALFAPI_PORT' in os.environ:
|
||||
ENVIRONMENT['port'] = int(os.environ.get('HALFAPI_PORT'))
|
||||
|
||||
def read_config(filenames=HALFAPI_CONFIG_FILES):
|
||||
"""
|
||||
The highest index in "filenames" are the highest priorty
|
||||
"""
|
||||
d_res = {}
|
||||
|
||||
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:
|
||||
# TODO: Create a temporary secret
|
||||
CONFIG['secret'] = DEFAULT_CONF['SECRET']
|
||||
with open(CONFIG['secret'], 'w') as secret_file:
|
||||
secret_file.write(str(uuid.uuid4()))
|
||||
|
||||
try:
|
||||
with open(CONFIG['secret'], 'r') as secret_file:
|
||||
CONFIG['secret'] = CONFIG['secret'].strip()
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning('Running without secret file: %s', CONFIG['secret'] or 'no file specified')
|
||||
|
||||
CONFIG.setdefault('project_name', DEFAULT_CONF['PROJECT_NAME'])
|
||||
CONFIG.setdefault('production', DEFAULT_CONF['PRODUCTION'])
|
||||
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!!!
|
||||
SECRET = CONFIG.get('secret')
|
||||
PRODUCTION = CONFIG.get('production')
|
||||
# !!!
|
513
halfapi/half_domain.py
Normal file
513
halfapi/half_domain.py
Normal file
@ -0,0 +1,513 @@
|
||||
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
|
||||
|
109
halfapi/half_route.py
Normal file
109
halfapi/half_route.py
Normal file
@ -0,0 +1,109 @@
|
||||
""" 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
|
316
halfapi/halfapi.py
Normal file
316
halfapi/halfapi.py
Normal file
@ -0,0 +1,316 @@
|
||||
#!/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__()
|
||||
|
||||
|
178
halfapi/lib/acl.py
Normal file
178
halfapi/lib/acl.py
Normal file
@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Base ACL module that contains generic functions for domains ACL
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from json import JSONDecodeError
|
||||
import yaml
|
||||
from starlette.authentication import UnauthenticatedUser
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.routing import Route
|
||||
from starlette.responses import Response
|
||||
|
||||
from ..logging import logger
|
||||
|
||||
def public(*args, **kwargs) -> bool:
|
||||
"Unlimited access"
|
||||
return True
|
||||
|
||||
def private(*args, **kwargs) -> bool:
|
||||
"Forbidden access"
|
||||
return False
|
||||
|
||||
|
||||
def connected(fct=public):
|
||||
""" Decorator that checks if the user object of the request has been set
|
||||
"""
|
||||
@wraps(fct)
|
||||
def caller(req, *args, **kwargs):
|
||||
if (not hasattr(req, 'user')
|
||||
or isinstance(req.user, UnauthenticatedUser)
|
||||
or not hasattr(req.user, 'is_authenticated')):
|
||||
return False
|
||||
|
||||
if hasattr(req, 'path_params'):
|
||||
return fct(req, **{**kwargs, **req.path_params})
|
||||
return fct(req, **{**kwargs})
|
||||
|
||||
return caller
|
||||
|
||||
def args_check(fct):
|
||||
""" Decorator that puts required and optional arguments in scope
|
||||
|
||||
For GET requests it uses the query_params
|
||||
|
||||
For POST requests it uses the body as JSON
|
||||
|
||||
If "check" is present in the query params, nothing is done.
|
||||
|
||||
If some required arguments are missing, a 400 status code is sent.
|
||||
"""
|
||||
@wraps(fct)
|
||||
async def caller(req, *args, **kwargs):
|
||||
if 'check' in req.query_params:
|
||||
# Check query param should not read the "args"
|
||||
return await fct(req, *args, **kwargs)
|
||||
|
||||
data_ = {}
|
||||
if req.method == 'GET':
|
||||
data_ = dict(req.query_params)
|
||||
|
||||
elif 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:
|
||||
data_ = await req.json()
|
||||
except JSONDecodeError as exc:
|
||||
logger.debug('Posted data was not JSON')
|
||||
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:
|
||||
return 's' if len(array) > 1 else ''
|
||||
def comma_list(array: list) -> str:
|
||||
return ', '.join(array)
|
||||
|
||||
|
||||
args_d = req.scope.get('args')
|
||||
if args_d is not None and isinstance(data_, dict):
|
||||
required = args_d.get('required', set())
|
||||
|
||||
missing = []
|
||||
data = {}
|
||||
|
||||
for key in required:
|
||||
data[key] = data_.get(key, None)
|
||||
if data[key] is None:
|
||||
missing.append(key)
|
||||
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Missing value{plural(missing)} for: {comma_list(missing)}!")
|
||||
|
||||
optional = args_d.get('optional', set())
|
||||
for key in optional:
|
||||
if key in data_:
|
||||
data[key] = data_[key]
|
||||
else:
|
||||
""" Unsafe mode, without specified arguments, or plain text mode
|
||||
"""
|
||||
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 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)
|
||||
|
@ -1,158 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from os import environ
|
||||
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Match, Mount
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from halfapi.models.api.view.acl import Acl as AclView
|
||||
|
||||
class DebugRouteException(Exception):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(self)
|
||||
|
||||
def match_route(app: ASGIApp, scope: Scope):
|
||||
""" Checks all routes from "app" and checks if it matches with the one from
|
||||
scope
|
||||
|
||||
Parameters:
|
||||
|
||||
- app (ASGIApp): The Starlette instance
|
||||
- scope (MutableMapping[str, Any]): The requests scope
|
||||
|
||||
Returns:
|
||||
|
||||
- (dict, dict): The first dict of the tuple is the details on the
|
||||
route, the second one is the path parameters
|
||||
|
||||
Raises:
|
||||
|
||||
HTTPException
|
||||
"""
|
||||
|
||||
""" The *result* variable is fitted to the filter that will be applied when
|
||||
searching the route in the database.
|
||||
Refer to the database documentation for more details on the api.route
|
||||
table.
|
||||
"""
|
||||
|
||||
print('3')
|
||||
from ..app import CONFIG
|
||||
print(CONFIG)
|
||||
|
||||
result = {
|
||||
'domain': None,
|
||||
'name': None,
|
||||
'http_verb': None,
|
||||
'version': None
|
||||
}
|
||||
|
||||
if 'DEBUG' in CONFIG.keys() and len(scope['path'].split('/')) <= 3:
|
||||
raise DebugRouteException()
|
||||
|
||||
try:
|
||||
""" Identification of the parts of the path
|
||||
|
||||
Examples :
|
||||
version : v4
|
||||
domain : organigramme
|
||||
path : laboratoire/personnel
|
||||
"""
|
||||
_, result['domain'], path = scope['path'].split('/', 2)
|
||||
except ValueError as e:
|
||||
#404 Not found
|
||||
raise HTTPException(404)
|
||||
# Prefix the path with "/"
|
||||
path = f'/{path}'
|
||||
|
||||
for route in app.routes:
|
||||
|
||||
if type(route) != Mount:
|
||||
""" The root app should not have exposed routes,
|
||||
only the mounted domains have some.
|
||||
"""
|
||||
continue
|
||||
|
||||
""" Clone the scope to assign the path to the path without the
|
||||
matching domain, be careful to the "root_path" of the mounted domain.
|
||||
|
||||
@TODO
|
||||
Also, improper array unpacking may make crash the program without any
|
||||
explicit error, we may have to improve this as we only rely on this
|
||||
function to accomplish all the routing
|
||||
"""
|
||||
subscope = scope.copy()
|
||||
_, result['domain'], subpath = path.split('/', 2)
|
||||
subscope['path'] = f'/{subpath}'
|
||||
|
||||
for mount_route in route.routes:
|
||||
# Parse all domain routes
|
||||
submatch = mount_route.matches(subscope)
|
||||
if submatch[0] != Match.FULL:
|
||||
continue
|
||||
|
||||
# Route matches
|
||||
try:
|
||||
result['name'] = submatch[1]['endpoint'].__name__
|
||||
result['http_verb'] = scope['method']
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
return result, submatch[1]['path_params']
|
||||
|
||||
raise HTTPException(404)
|
||||
|
||||
|
||||
class AclCallerMiddleware(BaseHTTPMiddleware):
|
||||
async def __call__(self, scope:Scope, receive: Receive, send: Send) -> None:
|
||||
""" Points out to the domain which ACL function it should call
|
||||
|
||||
Parameters :
|
||||
|
||||
- request (Request): The current request
|
||||
|
||||
- call_next (RequestResponseEndpoint): The next middleware/route function
|
||||
|
||||
Return:
|
||||
Response
|
||||
"""
|
||||
print('Hit AclCallerMiddleware of API')
|
||||
if scope['type'] != 'http':
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
app = self.app
|
||||
while True:
|
||||
if not hasattr(app, 'app'):
|
||||
break
|
||||
app = app.app
|
||||
|
||||
if scope['path'].split('/')[-1] not in ['docs','openapi.json','redoc']:
|
||||
# routes in the the database, the others being
|
||||
# docs/openapi.json/redoc
|
||||
|
||||
try:
|
||||
d_match, path_params = match_route(app, scope)
|
||||
scope['acls'] = []
|
||||
for acl in AclView(**d_match).select():
|
||||
# retrieve related ACLs
|
||||
|
||||
if ('acl_function_name' not in acl.keys()
|
||||
or 'domain' not in acl.keys()):
|
||||
continue
|
||||
|
||||
scope['acls'].append(acl['acl_function_name'])
|
||||
|
||||
except StopIteration:
|
||||
# TODO : No ACL sur une route existante, prevenir l'admin?
|
||||
print("No ACL")
|
||||
pass
|
||||
except DebugRouteException:
|
||||
print("Debug route")
|
||||
if 'DEBUG_ACL' in environ.keys():
|
||||
scope['acls'] = environ['DEBUG_ACL'].split(':')
|
||||
else:
|
||||
scope['acls'] = []
|
||||
|
||||
return await self.app(scope, receive, send)
|
@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from starlette.requests import Request
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
class AclMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, acl_module):
|
||||
super().__init__(app)
|
||||
self.acl_module = acl_module
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
""" Checks the "acls" key in the scope and applies the
|
||||
corresponding functions in the current module's acl lib.
|
||||
|
||||
Raises an exception if no acl function returns True
|
||||
"""
|
||||
print(f'Hit acl {__name__} middleware')
|
||||
|
||||
for acl_fct_name in request.scope['acls']:
|
||||
print(f'Will apply {acl_fct_name}')
|
||||
try:
|
||||
fct = getattr(self.acl_module, acl_fct_name)
|
||||
if fct(request) is True:
|
||||
return await call_next(request)
|
||||
|
||||
except AttributeError as e:
|
||||
print(f'No ACL function "{acl_fct_name}" in {__name__} module')
|
||||
print(e)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(500)
|
||||
|
||||
raise HTTPException(401)
|
71
halfapi/lib/constants.py
Normal file
71
halfapi/lib/constants.py
Normal file
@ -0,0 +1,71 @@
|
||||
import re
|
||||
from schema import Schema, Optional, Or
|
||||
from .. import __version__
|
||||
|
||||
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)]
|
||||
}
|
||||
})
|
192
halfapi/lib/domain.py
Normal file
192
halfapi/lib/domain.py
Normal file
@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
lib/domain.py The domain-scoped utility functions
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import importlib
|
||||
import inspect
|
||||
from functools import wraps
|
||||
from types import ModuleType, FunctionType
|
||||
from typing import Coroutine, Generator
|
||||
from typing import Dict, List, Tuple
|
||||
import yaml
|
||||
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from halfapi.lib import acl
|
||||
from halfapi.lib.responses import ORJSONResponse, ODSResponse, XLSXResponse, PlainTextResponse, HTMLResponse
|
||||
# from halfapi.lib.router import read_router
|
||||
from halfapi.lib.constants import VERBS
|
||||
|
||||
from ..logging import logger
|
||||
|
||||
class MissingAclError(Exception):
|
||||
""" Exception to use when no acl are specified for a route
|
||||
"""
|
||||
pass
|
||||
|
||||
class PathError(Exception):
|
||||
""" Exception to use when the path for a route is malformed
|
||||
"""
|
||||
pass
|
||||
|
||||
class UnknownPathParameterType(Exception):
|
||||
""" Exception to use when the path parameter for a route is not supported
|
||||
"""
|
||||
pass
|
||||
|
||||
class UndefinedRoute(Exception):
|
||||
""" Exception to use when the route definition cannot be found
|
||||
"""
|
||||
pass
|
||||
|
||||
class UndefinedFunction(Exception):
|
||||
""" Exception to use when a function definition cannot be found
|
||||
"""
|
||||
pass
|
||||
|
||||
class NoDomainsException(Exception):
|
||||
""" 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
|
||||
"""
|
||||
@wraps(fct)
|
||||
@acl.args_check
|
||||
async def wrapped(request, *args, **kwargs):
|
||||
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()
|
||||
|
||||
if 'halfapi' in fct_args_spec:
|
||||
fct_args['halfapi'] = {
|
||||
'user': request.user if
|
||||
'user' in request else None,
|
||||
'config': request.scope.get('config', {}),
|
||||
'domain': request.scope.get('domain', 'unknown'),
|
||||
'cookies': request.cookies,
|
||||
'base_url': request.base_url,
|
||||
'url': request.url
|
||||
}
|
||||
|
||||
if 'data' in fct_args_spec:
|
||||
if 'data' in fct_args_defaults_dict:
|
||||
fct_args['data'] = fct_args_defaults_dict['data']
|
||||
else:
|
||||
fct_args['data'] = {}
|
||||
|
||||
fct_args['data'].update(kwargs.get('data', {}))
|
||||
|
||||
if '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:
|
||||
logger.debug('FCT_ARGS***** %s', fct_args)
|
||||
if ret_type == 'json':
|
||||
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:
|
||||
raise HTTPException(501) from exc
|
||||
except Exception as exc:
|
||||
# TODO: Write tests
|
||||
logger.error(exc, exc_info=True)
|
||||
if not isinstance(exc, HTTPException):
|
||||
raise HTTPException(500) from exc
|
||||
raise exc
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def get_fct_name(http_verb: str, path: str) -> str:
|
||||
"""
|
||||
Returns the predictable name of the function for a route
|
||||
|
||||
Parameters:
|
||||
- http_verb (str): The Route's HTTP method (GET, POST, ...)
|
||||
- path (str): The functions path
|
||||
|
||||
Returns:
|
||||
str: The *unique* function name for a route and it's verb
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
>>> get_fct_name('get', '')
|
||||
'get'
|
||||
|
||||
>>> get_fct_name('GET', '')
|
||||
'get'
|
||||
|
||||
>>> get_fct_name('POST', 'foo')
|
||||
'post_foo'
|
||||
|
||||
>>> get_fct_name('POST', 'bar')
|
||||
'post_bar'
|
||||
|
||||
>>> get_fct_name('DEL', 'foo/{boo}')
|
||||
'del_foo_BOO'
|
||||
|
||||
>>> get_fct_name('DEL', '{boo:zoo}/far')
|
||||
'del_BOO_far'
|
||||
"""
|
||||
if path and path[0] == '/':
|
||||
path = path[1:]
|
||||
|
||||
fct_name = [http_verb.lower()]
|
||||
for elt in path.split('/'):
|
||||
if elt and elt[0] == '{':
|
||||
fct_name.append(elt[1:-1].split(':')[0].upper())
|
||||
elif elt:
|
||||
fct_name.append(elt)
|
||||
|
||||
return '_'.join(fct_name)
|
||||
|
80
halfapi/lib/domain_middleware.py
Normal file
80
halfapi/lib/domain_middleware.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""
|
||||
DomainMiddleware
|
||||
"""
|
||||
from starlette.datastructures import URL
|
||||
from starlette.middleware.base import (BaseHTTPMiddleware,
|
||||
RequestResponseEndpoint)
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from ..logging import logger
|
||||
|
||||
class DomainMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
DomainMiddleware adds the api routes and acls to the following scope keys :
|
||||
|
||||
- api
|
||||
- acl
|
||||
"""
|
||||
|
||||
def __init__(self, app, domain=None):
|
||||
""" app: HalfAPI instance
|
||||
"""
|
||||
logger.info('DomainMiddleware app:%s domain:%s', app, domain)
|
||||
super().__init__(app)
|
||||
self.domain = domain
|
||||
self.request = None
|
||||
|
||||
|
||||
async def dispatch(self, request: Request,
|
||||
call_next: RequestResponseEndpoint) -> Response:
|
||||
"""
|
||||
Call of the route fonction (decorated or not)
|
||||
"""
|
||||
|
||||
request.scope['domain'] = self.domain['name']
|
||||
if hasattr(request.app, 'config') \
|
||||
and isinstance(request.app.config, dict):
|
||||
# Set the config scope to the domain's config
|
||||
request.scope['config'] = request.app.config.get(
|
||||
'domain', {}
|
||||
).get(
|
||||
self.domain['name'], {}
|
||||
).copy()
|
||||
|
||||
# TODO: Remove in 0.7.0
|
||||
config = request.scope['config'].copy()
|
||||
request.scope['config']['domain'] = {}
|
||||
request.scope['config']['domain'][self.domain['name']] = {}
|
||||
request.scope['config']['domain'][self.domain['name']]['config'] = config
|
||||
|
||||
|
||||
|
||||
else:
|
||||
logger.debug('%s', request.app)
|
||||
logger.debug('%s', getattr(request.app, 'config', None))
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
if 'acl_pass' in request.scope:
|
||||
# Set the http header "x-acl" if an acl was used on the route
|
||||
response.headers['x-acl'] = request.scope['acl_pass']
|
||||
|
||||
if 'args' in request.scope:
|
||||
# Set the http headers "x-args-required" and "x-args-optional"
|
||||
|
||||
if len(request.scope['args'].get('required', set())):
|
||||
response.headers['x-args-required'] = \
|
||||
','.join(request.scope['args']['required'])
|
||||
if len(request.scope['args'].get('optional', set())):
|
||||
response.headers['x-args-optional'] = \
|
||||
','.join(request.scope['args']['optional'])
|
||||
|
||||
if len(request.scope.get('out', set())):
|
||||
response.headers['x-out'] = \
|
||||
','.join(request.scope['out'])
|
||||
|
||||
|
||||
response.headers['x-domain'] = self.domain['name']
|
||||
|
||||
return response
|
@ -1,110 +1,162 @@
|
||||
__LICENSE__ = """
|
||||
BSD 3-Clause License
|
||||
"""
|
||||
JWT Middleware module
|
||||
|
||||
Copyright (c) 2018, Amit Ripshtos
|
||||
All rights reserved.
|
||||
Classes:
|
||||
- JWTUser : goes in request.user
|
||||
- JWTAuthenticationBackend
|
||||
- JWTWebSocketAuthenticationBackend
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Raises:
|
||||
Exception: If configuration has no SECRET
|
||||
"""
|
||||
|
||||
import jwt
|
||||
from os import environ
|
||||
import typing
|
||||
from uuid import UUID
|
||||
|
||||
from http.cookies import SimpleCookie
|
||||
import jwt
|
||||
from starlette.authentication import (
|
||||
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
|
||||
UnauthenticatedUser)
|
||||
from starlette.requests import HTTPConnection, Request
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from .user import CheckUser, JWTUser, Nobody
|
||||
from ..logging import logger
|
||||
from ..conf import CONFIG
|
||||
from ..lib.responses import ORJSONResponse
|
||||
|
||||
class JWTUser(BaseUser):
|
||||
def __init__(self, id: UUID, token: str, payload: dict) -> None:
|
||||
self.__id = id
|
||||
self.token = token
|
||||
self.payload = payload
|
||||
SECRET=None
|
||||
|
||||
def __str__(self):
|
||||
return str({
|
||||
'id' : str(self.__id),
|
||||
'token': self.token,
|
||||
'payload': self.payload
|
||||
})
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
try:
|
||||
with open(CONFIG.get('secret', ''), 'r') as secret_file:
|
||||
SECRET = secret_file.read().strip()
|
||||
except FileNotFoundError:
|
||||
logger.error('Could not import SECRET variable from conf module,'\
|
||||
' using HALFAPI_SECRET environment variable')
|
||||
|
||||
def cookies_from_scope(scope):
|
||||
cookie = dict(scope.get("headers") or {}).get(b"cookie")
|
||||
if not cookie:
|
||||
return {}
|
||||
|
||||
simple_cookie = SimpleCookie()
|
||||
simple_cookie.load(cookie.decode("utf8"))
|
||||
return {key: morsel.value for key, morsel in simple_cookie.items()}
|
||||
|
||||
def on_auth_error(request: Request, exc: Exception):
|
||||
response = ORJSONResponse({"error": str(exc)}, status_code=401)
|
||||
response.delete_cookie('Authorization')
|
||||
return response
|
||||
|
||||
class JWTAuthenticationBackend(AuthenticationBackend):
|
||||
def __init__(self, secret_key: str = SECRET,
|
||||
algorithm: str = 'HS256', prefix: str = 'JWT'):
|
||||
|
||||
if secret_key is None:
|
||||
raise Exception('Missing secret_key argument for JWTAuthenticationBackend')
|
||||
self.secret_key = secret_key
|
||||
self.algorithm = algorithm
|
||||
self.prefix = prefix
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self.__id
|
||||
|
||||
async def authenticate(
|
||||
self, conn: HTTPConnection
|
||||
) -> typing.Optional[typing.Tuple['AuthCredentials', 'BaseUser']]:
|
||||
|
||||
class JWTAuthenticationBackend(AuthenticationBackend):
|
||||
def __init__(self, secret_key: str, algorithm: str = 'HS256', prefix: str = 'JWT', name: str = 'name'):
|
||||
self.secret_key = secret_key
|
||||
self.algorithm = algorithm
|
||||
self.prefix = prefix
|
||||
self.id = id
|
||||
# Standard way to authenticate via API
|
||||
# https://datatracker.ietf.org/doc/html/rfc7235#section-4.2
|
||||
token = conn.headers.get('Authorization')
|
||||
|
||||
async def authenticate(self, request):
|
||||
if "Authorization" not in request.headers:
|
||||
return None
|
||||
if not token:
|
||||
token = cookies_from_scope(conn.scope).get('Authorization')
|
||||
|
||||
is_check_call = 'check' in conn.query_params
|
||||
|
||||
PRODUCTION = conn.scope['app'].debug == False
|
||||
|
||||
if not token and not is_check_call:
|
||||
return AuthCredentials(), Nobody()
|
||||
|
||||
token = request.headers["Authorization"]
|
||||
try:
|
||||
payload = jwt.decode(token, key=self.secret_key, algorithms=self.algorithm)
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise AuthenticationError(str(e))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
if token:
|
||||
payload = jwt.decode(token,
|
||||
key=self.secret_key,
|
||||
algorithms=[self.algorithm],
|
||||
options={
|
||||
'verify_signature': True
|
||||
})
|
||||
|
||||
if is_check_call:
|
||||
if token:
|
||||
return AuthCredentials(), CheckUser(payload['user_id'])
|
||||
|
||||
return AuthCredentials(), Nobody()
|
||||
|
||||
|
||||
if PRODUCTION and 'debug' in payload.keys() and payload['debug']:
|
||||
raise AuthenticationError(
|
||||
'Trying to connect using *DEBUG* token in *PRODUCTION* mode')
|
||||
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
return AuthCredentials(), Nobody()
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise AuthenticationError(str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.error('Authentication error : %s', exc)
|
||||
raise exc
|
||||
|
||||
|
||||
return AuthCredentials(["authenticated"]), JWTUser(
|
||||
id=payload['id'], token=token, payload=payload)
|
||||
user_id=payload['user_id'], token=token, payload=payload)
|
||||
|
||||
|
||||
|
||||
class JWTWebSocketAuthenticationBackend(AuthenticationBackend):
|
||||
|
||||
def __init__(self, secret_key: str, algorithm: str = 'HS256', query_param_name: str = 'jwt',
|
||||
id: UUID = None, audience = None, options = {}):
|
||||
user_id: UUID = None, audience = None):
|
||||
self.secret_key = secret_key
|
||||
self.algorithm = algorithm
|
||||
self.query_param_name = query_param_name
|
||||
self.id = id
|
||||
self.__id = user_id
|
||||
self.audience = audience
|
||||
self.options = options
|
||||
|
||||
|
||||
async def authenticate(self, request):
|
||||
if self.query_param_name not in request.query_params:
|
||||
return AuthCredentials(), UnauthenticatedUser()
|
||||
async def authenticate(
|
||||
self, conn: HTTPConnection
|
||||
) -> typing.Optional[typing.Tuple["AuthCredentials", "BaseUser"]]:
|
||||
|
||||
token = request.query_params[self.query_param_name]
|
||||
if self.query_param_name not in conn.query_params:
|
||||
return AuthCredentials(), Nobody()
|
||||
|
||||
token = conn.query_params[self.query_param_name]
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, key=self.secret_key, algorithms=self.algorithm,
|
||||
audience=self.audience, options=self.options)
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise AuthenticationError(str(e))
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
key=self.secret_key,
|
||||
algorithms=[self.algorithm],
|
||||
audience=self.audience,
|
||||
options={
|
||||
'verify_signature': bool(PRODUCTION)
|
||||
})
|
||||
|
||||
return AuthCredentials(["authenticated"]), JWTUser(id = payload['id'],
|
||||
token=token, payload=payload)
|
||||
if PRODUCTION and 'debug' in payload.keys() and payload['debug']:
|
||||
raise AuthenticationError(
|
||||
'Trying to connect using *DEBUG* token in *PRODUCTION* mode')
|
||||
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise AuthenticationError(str(exc)) from exc
|
||||
|
||||
return (
|
||||
AuthCredentials(["authenticated"]),
|
||||
JWTUser(
|
||||
user_id=payload['id'],
|
||||
token=token,
|
||||
payload=payload)
|
||||
)
|
||||
|
@ -1,22 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
from starlette.exceptions import HTTPException
|
||||
from .responses import CSVResponse
|
||||
|
||||
"""
|
||||
This is the *query* library that contains all the useful functions to treat our
|
||||
queries
|
||||
|
||||
Fonction:
|
||||
- parse_query
|
||||
"""
|
||||
|
||||
def parse_query(q: str = ""):
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
|
||||
def parse_query(q_string: str = ""):
|
||||
"""
|
||||
Returns the fitting Response object according to query parameters.
|
||||
|
||||
The parse_query function handles the following arguments in the query
|
||||
The parse_query function handles the following arguments in the query
|
||||
string : format, limit, and offset
|
||||
It returns a callable function that returns the desired Response object.
|
||||
|
||||
Parameters:
|
||||
q (str): The query string "q" parameter, in the format
|
||||
q_string (str): The query string "q" parameter, in the format
|
||||
key0:value0|...|keyN:valueN
|
||||
|
||||
Returns:
|
||||
@ -53,25 +56,23 @@ def parse_query(q: str = ""):
|
||||
>>> parse_query('limit:10')
|
||||
<function parse_query.<locals>.select at 0x...>
|
||||
|
||||
>>> parse_query('limit=10')
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
starlette.exceptions.HTTPException: 400
|
||||
# >>> parse_query('limit=10')
|
||||
# starlette.exceptions.HTTPException: 400
|
||||
|
||||
|
||||
"""
|
||||
|
||||
params = {}
|
||||
if len(q) > 0:
|
||||
if len(q_string) > 0:
|
||||
try:
|
||||
split_ = lambda x : x.split(':')
|
||||
params = dict(map(split_, q.split('|')))
|
||||
except ValueError:
|
||||
raise HTTPException(400)
|
||||
params = dict(map(split_, q_string.split('|')))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400) from exc
|
||||
split_ = lambda x : x.split(':')
|
||||
params = dict(map(split_, q.split('|')))
|
||||
params = dict(map(split_, q_string.split('|')))
|
||||
|
||||
def select(obj):
|
||||
def select(obj, fields):
|
||||
|
||||
if 'limit' in params and int(params['limit']) > 0:
|
||||
obj.limit(int(params['limit']))
|
||||
@ -79,9 +80,6 @@ def parse_query(q: str = ""):
|
||||
if 'offset' in params and int(params['offset']) > 0:
|
||||
obj.offset(int(params['offset']))
|
||||
|
||||
if 'format' in params and params['format'] == 'csv':
|
||||
return CSVResponse([elt for elt in obj.select()])
|
||||
|
||||
return [elt for elt in obj.select()]
|
||||
return list(obj.select(*fields))
|
||||
|
||||
return select
|
||||
|
@ -1,59 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# builtins
|
||||
import csv
|
||||
""" Response module
|
||||
|
||||
Contains some base response classes
|
||||
|
||||
Classes :
|
||||
- HJSONResponse
|
||||
- InternalServerErrorResponse
|
||||
- NotFoundResponse
|
||||
- NotImplementedResponse
|
||||
- ORJSONResponse
|
||||
- PlainTextResponse
|
||||
- ServiceUnavailableResponse
|
||||
- UnauthorizedResponse
|
||||
- ODSResponse
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from io import TextIOBase, StringIO
|
||||
import decimal
|
||||
import typing
|
||||
from io import BytesIO
|
||||
import orjson
|
||||
|
||||
# asgi framework
|
||||
from starlette.responses import PlainTextResponse, Response
|
||||
from starlette.responses import PlainTextResponse, Response, JSONResponse, HTMLResponse
|
||||
from starlette.requests import Request
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
__all__ = ['CSVResponse',
|
||||
from .user import JWTUser, Nobody
|
||||
from ..logging import logger
|
||||
|
||||
|
||||
__all__ = [
|
||||
'HJSONResponse',
|
||||
'InternalServerErrorResponse',
|
||||
'NotFoundResponse',
|
||||
'NotImplementedResponse',
|
||||
'ORJSONResponse',
|
||||
'PlainTextResponse',
|
||||
'ServiceUnavailableResponse',
|
||||
'UnauthorizedResponse']
|
||||
|
||||
class CSVResponse(Response):
|
||||
def __init__(self, obj):
|
||||
|
||||
with StringIO() as csv_file:
|
||||
csv_obj = csv.writer(csv_file, dialect="excel")
|
||||
csv_obj.writerows([elt.values() for elt in obj])
|
||||
filename = f'Personnels_LIRMM-{date.today()}.csv'
|
||||
|
||||
super().__init__(
|
||||
content=csv_file.getvalue(),
|
||||
headers={
|
||||
'Content-Type': 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
status_code = 200)
|
||||
|
||||
|
||||
class InternalServerErrorResponse(Response):
|
||||
""" The 500 Internal Server Error default Response
|
||||
""" The 500 Internal Server Error default Response
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(status_code=500)
|
||||
|
||||
|
||||
class NotFoundResponse(Response):
|
||||
""" The 404 Not Found default Response
|
||||
""" The 404 Not Found default Response
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(status_code=404)
|
||||
|
||||
|
||||
class NotImplementedResponse(Response):
|
||||
""" The 501 Not Implemented default Response
|
||||
""" The 501 Not Implemented default Response
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(status_code=501)
|
||||
|
||||
class ServiceUnavailableResponse(Response):
|
||||
""" The 503 Service Unavailable default Response
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(status_code=503)
|
||||
|
||||
class UnauthorizedResponse(Response):
|
||||
""" The 401 Not Found default Response
|
||||
""" The 401 Not Found default Response
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(status_code = 401)
|
||||
|
||||
|
||||
class ORJSONResponse(JSONResponse):
|
||||
""" The response that encodes data into JSON
|
||||
"""
|
||||
def __init__(self, content, default=None, **kwargs):
|
||||
self.default = default if default is not None else ORJSONResponse.default_cast
|
||||
super().__init__(content, **kwargs)
|
||||
|
||||
def render(self, content: typing.Any) -> bytes:
|
||||
return orjson.dumps(content,
|
||||
option=orjson.OPT_NON_STR_KEYS,
|
||||
default=self.default)
|
||||
|
||||
@staticmethod
|
||||
def default_cast(typ):
|
||||
""" Cast the data in JSON-serializable type
|
||||
"""
|
||||
str_types = {
|
||||
decimal.Decimal
|
||||
}
|
||||
list_types = {
|
||||
set
|
||||
}
|
||||
jsonable_types = {
|
||||
JWTUser, Nobody
|
||||
}
|
||||
|
||||
if callable(typ):
|
||||
return typ.__name__
|
||||
if type(typ) in str_types:
|
||||
return str(typ)
|
||||
if type(typ) in list_types:
|
||||
return list(typ)
|
||||
if type(typ) in jsonable_types:
|
||||
return typ.json
|
||||
|
||||
raise TypeError(f'Type {type(typ)} is not handled by ORJSONResponse')
|
||||
|
||||
|
||||
class HJSONResponse(ORJSONResponse):
|
||||
""" The response that encodes generator data into JSON
|
||||
"""
|
||||
def render(self, content: typing.Generator):
|
||||
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
|
||||
|
12
halfapi/lib/router.py
Normal file
12
halfapi/lib/router.py
Normal file
@ -0,0 +1,12 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from types import ModuleType
|
||||
from typing import Dict
|
||||
from pprint import pprint
|
||||
|
||||
from schema import SchemaError
|
||||
from .constants import VERBS, ROUTER_SCHEMA
|
||||
from ..logging import logger
|
||||
|
||||
|
136
halfapi/lib/routes.py
Normal file
136
halfapi/lib/routes.py
Normal file
@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Routes module
|
||||
|
||||
Classes :
|
||||
- JSONRoute
|
||||
|
||||
Fonctions :
|
||||
- gen_domain_routes
|
||||
- gen_schema_routes
|
||||
- api_routes
|
||||
|
||||
Exception :
|
||||
- DomainNotFoundError
|
||||
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from typing import Coroutine, Dict, Generator, Tuple, Any
|
||||
from types import ModuleType, FunctionType
|
||||
|
||||
import yaml
|
||||
|
||||
# from .domain import gen_router_routes, domain_acls, route_decorator, domain_schema
|
||||
from .responses import ORJSONResponse
|
||||
from .acl import args_check
|
||||
from ..half_route import HalfRoute
|
||||
from . import acl
|
||||
|
||||
from ..logging import logger
|
||||
|
||||
class DomainNotFoundError(Exception):
|
||||
""" Exception when a domain is not importable
|
||||
"""
|
||||
|
||||
def JSONRoute(data: Any) -> Coroutine:
|
||||
"""
|
||||
Returns a route function that returns the data as JSON
|
||||
|
||||
Parameters:
|
||||
data (Any):
|
||||
The data to return
|
||||
|
||||
Returns:
|
||||
async function
|
||||
"""
|
||||
async def wrapped(request, *args, **kwargs):
|
||||
return ORJSONResponse(data)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def gen_domain_routes(m_domain: ModuleType):
|
||||
"""
|
||||
Yields the Route objects for a domain
|
||||
|
||||
Parameters:
|
||||
m_domains: ModuleType
|
||||
|
||||
Returns:
|
||||
Generator(HalfRoute)
|
||||
"""
|
||||
yield HalfRoute('/',
|
||||
JSONRoute(domain_schema(m_domain)),
|
||||
[{'acl': acl.public}],
|
||||
'GET'
|
||||
)
|
||||
|
||||
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]:
|
||||
"""
|
||||
Yields the description objects for HalfAPI app routes
|
||||
|
||||
Parameters:
|
||||
m_dom (ModuleType): the halfapi module
|
||||
|
||||
Returns:
|
||||
(Dict, Dict)
|
||||
"""
|
||||
|
||||
d_acls = {}
|
||||
|
||||
def str_acl(params):
|
||||
l_params = []
|
||||
|
||||
for param in params:
|
||||
|
||||
if 'acl' not in param.keys() or not param['acl']:
|
||||
continue
|
||||
|
||||
l_params.append(param.copy())
|
||||
l_params[-1]['acl'] = param['acl'].__name__
|
||||
|
||||
if param['acl'] not in d_acls.keys():
|
||||
d_acls[param['acl'].__name__] = param['acl']
|
||||
|
||||
return l_params
|
||||
|
||||
d_res = {}
|
||||
for path, verb, m_router, fct, params in gen_router_routes(m_dom, []):
|
||||
try:
|
||||
if path not in d_res:
|
||||
d_res[path] = {}
|
||||
|
||||
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
|
137
halfapi/lib/schemas.py
Normal file
137
halfapi/lib/schemas.py
Normal file
@ -0,0 +1,137 @@
|
||||
""" Schemas module
|
||||
|
||||
Functions :
|
||||
- schema_json
|
||||
- schema_dict_dom
|
||||
- get_acls
|
||||
|
||||
Constant :
|
||||
SCHEMAS (starlette.schemas.SchemaGenerator)
|
||||
"""
|
||||
|
||||
import os
|
||||
import importlib
|
||||
from typing import Dict, Coroutine, List
|
||||
from types import ModuleType
|
||||
import yaml
|
||||
|
||||
from starlette.schemas import SchemaGenerator
|
||||
|
||||
from .. import __version__
|
||||
from ..logging import logger
|
||||
from .routes import api_routes
|
||||
from .responses import ORJSONResponse
|
||||
|
||||
SCHEMAS = SchemaGenerator(
|
||||
{"openapi": "3.0.0", "info": {"title": "HalfAPI", "version": __version__}}
|
||||
)
|
||||
|
||||
async def schema_json(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(
|
||||
SCHEMAS.get_schema(routes=request.app.routes))
|
||||
|
||||
|
||||
def schema_csv_dict(csv: List[str], prefix='/') -> 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 = {
|
||||
'str': 'string',
|
||||
'uuid': 'string',
|
||||
'path': 'string',
|
||||
'int': 'number',
|
||||
'float': 'number'
|
||||
}
|
||||
return yaml.dump({
|
||||
'name': name,
|
||||
'in': 'path',
|
||||
'description': f'default description for path parameter {name}',
|
||||
'required': True,
|
||||
'schema': {
|
||||
'type': type_map[type]
|
||||
}
|
||||
})
|
23
halfapi/lib/timing.py
Normal file
23
halfapi/lib/timing.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""
|
||||
Timing module
|
||||
|
||||
Helpers to gathers stats on requests timing
|
||||
|
||||
class HTimingClient
|
||||
"""
|
||||
import logging
|
||||
|
||||
from timing_asgi import TimingClient
|
||||
|
||||
from ..logging import logger
|
||||
|
||||
class HTimingClient(TimingClient):
|
||||
""" Used to redefine TimingClient.timing
|
||||
"""
|
||||
def timing(self, metric_name, timing, tags):
|
||||
tags_d = dict(map(lambda elt: elt.split(':'), tags))
|
||||
|
||||
logger.debug('[TIME:%s][%s] %s %s - %sms',
|
||||
tags_d['time'], metric_name,
|
||||
tags_d['http_method'], tags_d['http_status'],
|
||||
round(timing*1000, 2))
|
79
halfapi/lib/user.py
Normal file
79
halfapi/lib/user.py
Normal file
@ -0,0 +1,79 @@
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
32
halfapi/logging.py
Normal file
32
halfapi/logging.py
Normal file
@ -0,0 +1,32 @@
|
||||
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):
|
||||
# When run by 'uvicorn ...', a root handler is already
|
||||
# configured and the basicConfig below does nothing.
|
||||
# To get the desired formatting:
|
||||
logging.getLogger().handlers.clear()
|
||||
|
||||
# 'uvicorn --log-config' is broken so we configure in the app.
|
||||
# https://github.com/encode/uvicorn/issues/511
|
||||
logging.basicConfig(
|
||||
# match gunicorn format
|
||||
format=format,
|
||||
datefmt=datefmt,
|
||||
level=level)
|
||||
|
||||
# When run by 'gunicorn -k uvicorn.workers.UvicornWorker ...',
|
||||
# These loggers are already configured and propogating.
|
||||
# So we have double logging with a root logger.
|
||||
# (And setting propagate = False hurts the other usage.)
|
||||
logging.getLogger('uvicorn.asgi').handlers.clear()
|
||||
logging.getLogger('uvicorn.access').handlers.clear()
|
||||
logging.getLogger('uvicorn.error').handlers.clear()
|
||||
logging.getLogger('uvicorn.asgi').propagate = True
|
||||
logging.getLogger('uvicorn.access').propagate = True
|
||||
logging.getLogger('uvicorn.error').propagate = True
|
||||
|
||||
logger = logging.getLogger()
|
@ -1,11 +0,0 @@
|
||||
"""This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
'api',
|
||||
'db_connector'
|
||||
]
|
@ -1,73 +0,0 @@
|
||||
create schema api;
|
||||
|
||||
create type verb as enum ('POST', 'GET', 'PUT', 'DELETE');
|
||||
|
||||
create table api.version (
|
||||
name text primary key,
|
||||
server cidr not null default '127.0.0.1',
|
||||
port integer not null
|
||||
);
|
||||
|
||||
create table api.domain (
|
||||
version text references api.version(name),
|
||||
name text,
|
||||
primary key (version, name)
|
||||
);
|
||||
|
||||
create table api.route (
|
||||
path text, -- relative to /api/<version>/<domain>
|
||||
version text,
|
||||
domain text,
|
||||
primary key (path, domain, version)
|
||||
);
|
||||
|
||||
alter table api.route add constraint route_domain_fkey foreign key (version, domain) references api.domain(version, name) on update cascade on delete cascade;
|
||||
|
||||
create table api.acl_function (
|
||||
name text,
|
||||
description text,
|
||||
version text,
|
||||
domain text,
|
||||
primary key (name, version, domain)
|
||||
);
|
||||
|
||||
alter table api.acl_function add constraint acl_function_domain_fkey foreign key (version, domain) references api.domain(version, name) on update cascade on delete cascade;
|
||||
|
||||
create table api.acl (
|
||||
name text,
|
||||
http_verb verb,
|
||||
path text not null,
|
||||
version text,
|
||||
domain text not null,
|
||||
function text not null,
|
||||
primary key (name, version, domain, function)
|
||||
);
|
||||
|
||||
alter table api.acl add constraint acl_route_fkey foreign key (path, version, domain) references api.route(path, version, domain) on update cascade on delete cascade;
|
||||
alter table api.acl add constraint acl_function_fkey foreign key (function, version, domain) references api.acl_function(name, version, domain) on update cascade on delete cascade;
|
||||
|
||||
create schema "api.view";
|
||||
|
||||
create view "api.view".route as
|
||||
select
|
||||
route.*,
|
||||
version.name,
|
||||
version.server,
|
||||
version.port,
|
||||
'/'::text || route.domain || route.path AS abs_path
|
||||
from
|
||||
api.route
|
||||
join api.domain on
|
||||
route.domain = domain.name
|
||||
join api.version on
|
||||
domain.version = version.name;
|
||||
|
||||
create view "api.view".acl as
|
||||
select
|
||||
acl.*,
|
||||
acl_function.name as acl_function_name,
|
||||
'/'::text || acl.domain || acl.path AS abs_path
|
||||
from
|
||||
api.acl
|
||||
join api.acl_function on
|
||||
acl.function = acl_function.name;
|
@ -1,15 +0,0 @@
|
||||
"""This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
'acl',
|
||||
'acl_function',
|
||||
'domain',
|
||||
'route',
|
||||
'version',
|
||||
'view'
|
||||
]
|
@ -1,52 +0,0 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
# pylint: disable=wrong-import-order
|
||||
|
||||
"""The module apidb.api.acl povides the Acl class.
|
||||
|
||||
WARNING!
|
||||
|
||||
This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
|
||||
|
||||
DO NOT REMOVE OR MODIFY THE LINES BEGINING WITH:
|
||||
#>>> PLACE YOUR CODE BELOW...
|
||||
#<<< PLACE YOUR CODE ABOVE...
|
||||
|
||||
MAKE SURE YOU PLACE YOUR CODE BETWEEN THESE LINES OR AT THE END OF THE FILE.
|
||||
halfORM ONLY PRESERVES THE CODE BETWEEN THESE MARKS WHEN IT IS RUN.
|
||||
"""
|
||||
|
||||
from ..db_connector import base_relation_class
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
#<<< PLACE YOUR CODE ABOVE THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
__RCLS = base_relation_class('api.acl')
|
||||
|
||||
class Acl( __RCLS):
|
||||
"""
|
||||
__RCLS: <class 'half_orm.relation.Table_ApiApiAcl'>
|
||||
This class allows you to manipulate the data in the PG relation:
|
||||
TABLE: "api"."api"."acl"
|
||||
FIELDS:
|
||||
- name: (text) PK
|
||||
- http_verb: (verb)
|
||||
- path: (text) NOT NULL
|
||||
- version: (text) PK
|
||||
- domain: (text) PK
|
||||
- function: (text) PK
|
||||
FOREIGN KEYS:
|
||||
- acl_route_fkey: (path, version, domain)
|
||||
↳ "api"."api"."route"(path, version, domain)
|
||||
- acl_function_fkey: (function, version, domain)
|
||||
↳ "api"."api"."acl_function"(name, version, domain)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super(Acl, self).__init__(**kwargs)
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
@ -1,50 +0,0 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
# pylint: disable=wrong-import-order
|
||||
|
||||
"""The module apidb.api.acl_function povides the AclFunction class.
|
||||
|
||||
WARNING!
|
||||
|
||||
This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
|
||||
|
||||
DO NOT REMOVE OR MODIFY THE LINES BEGINING WITH:
|
||||
#>>> PLACE YOUR CODE BELOW...
|
||||
#<<< PLACE YOUR CODE ABOVE...
|
||||
|
||||
MAKE SURE YOU PLACE YOUR CODE BETWEEN THESE LINES OR AT THE END OF THE FILE.
|
||||
halfORM ONLY PRESERVES THE CODE BETWEEN THESE MARKS WHEN IT IS RUN.
|
||||
"""
|
||||
|
||||
from ..db_connector import base_relation_class
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
#<<< PLACE YOUR CODE ABOVE THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
__RCLS = base_relation_class('api.acl_function')
|
||||
|
||||
class AclFunction( __RCLS):
|
||||
"""
|
||||
__RCLS: <class 'half_orm.relation.Table_ApiApiAcl_function'>
|
||||
This class allows you to manipulate the data in the PG relation:
|
||||
TABLE: "api"."api"."acl_function"
|
||||
FIELDS:
|
||||
- name: (text) PK
|
||||
- description: (text)
|
||||
- version: (text) PK
|
||||
- domain: (text) PK
|
||||
FOREIGN KEYS:
|
||||
- _reverse_fkey_api_api_acl_function_version_domain: (name, version, domain)
|
||||
↳ "api"."api"."acl"(function, version, domain)
|
||||
- acl_function_domain_fkey: (version, domain)
|
||||
↳ "api"."api"."domain"(version, name)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super(AclFunction, self).__init__(**kwargs)
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
@ -1,50 +0,0 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
# pylint: disable=wrong-import-order
|
||||
|
||||
"""The module apidb.api.domain povides the Domain class.
|
||||
|
||||
WARNING!
|
||||
|
||||
This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
|
||||
|
||||
DO NOT REMOVE OR MODIFY THE LINES BEGINING WITH:
|
||||
#>>> PLACE YOUR CODE BELOW...
|
||||
#<<< PLACE YOUR CODE ABOVE...
|
||||
|
||||
MAKE SURE YOU PLACE YOUR CODE BETWEEN THESE LINES OR AT THE END OF THE FILE.
|
||||
halfORM ONLY PRESERVES THE CODE BETWEEN THESE MARKS WHEN IT IS RUN.
|
||||
"""
|
||||
|
||||
from ..db_connector import base_relation_class
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
#<<< PLACE YOUR CODE ABOVE THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
__RCLS = base_relation_class('api.domain')
|
||||
|
||||
class Domain( __RCLS):
|
||||
"""
|
||||
__RCLS: <class 'half_orm.relation.Table_ApiApiDomain'>
|
||||
This class allows you to manipulate the data in the PG relation:
|
||||
TABLE: "api"."api"."domain"
|
||||
FIELDS:
|
||||
- version: (text) PK
|
||||
- name: (text) PK
|
||||
FOREIGN KEYS:
|
||||
- _reverse_fkey_api_api_acl_function_version_domain: (version, name)
|
||||
↳ "api"."api"."acl_function"(version, domain)
|
||||
- domain_version_fkey: (version)
|
||||
↳ "api"."api"."version"(name)
|
||||
- _reverse_fkey_api_api_route_version_domain: (version, name)
|
||||
↳ "api"."api"."route"(version, domain)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super(Domain, self).__init__(**kwargs)
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
@ -1,49 +0,0 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
# pylint: disable=wrong-import-order
|
||||
|
||||
"""The module apidb.api.route povides the Route class.
|
||||
|
||||
WARNING!
|
||||
|
||||
This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
|
||||
|
||||
DO NOT REMOVE OR MODIFY THE LINES BEGINING WITH:
|
||||
#>>> PLACE YOUR CODE BELOW...
|
||||
#<<< PLACE YOUR CODE ABOVE...
|
||||
|
||||
MAKE SURE YOU PLACE YOUR CODE BETWEEN THESE LINES OR AT THE END OF THE FILE.
|
||||
halfORM ONLY PRESERVES THE CODE BETWEEN THESE MARKS WHEN IT IS RUN.
|
||||
"""
|
||||
|
||||
from ..db_connector import base_relation_class
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
#<<< PLACE YOUR CODE ABOVE THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
__RCLS = base_relation_class('api.route')
|
||||
|
||||
class Route( __RCLS):
|
||||
"""
|
||||
__RCLS: <class 'half_orm.relation.Table_ApiApiRoute'>
|
||||
This class allows you to manipulate the data in the PG relation:
|
||||
TABLE: "api"."api"."route"
|
||||
FIELDS:
|
||||
- path: (text) PK
|
||||
- version: (text) PK
|
||||
- domain: (text) PK
|
||||
FOREIGN KEYS:
|
||||
- _reverse_fkey_api_api_acl_path_version_domain: (path, version, domain)
|
||||
↳ "api"."api"."acl"(path, version, domain)
|
||||
- route_domain_fkey: (version, domain)
|
||||
↳ "api"."api"."domain"(version, name)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super(Route, self).__init__(**kwargs)
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
@ -1,47 +0,0 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
# pylint: disable=wrong-import-order
|
||||
|
||||
"""The module apidb.api.version povides the Version class.
|
||||
|
||||
WARNING!
|
||||
|
||||
This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
|
||||
|
||||
DO NOT REMOVE OR MODIFY THE LINES BEGINING WITH:
|
||||
#>>> PLACE YOUR CODE BELOW...
|
||||
#<<< PLACE YOUR CODE ABOVE...
|
||||
|
||||
MAKE SURE YOU PLACE YOUR CODE BETWEEN THESE LINES OR AT THE END OF THE FILE.
|
||||
halfORM ONLY PRESERVES THE CODE BETWEEN THESE MARKS WHEN IT IS RUN.
|
||||
"""
|
||||
|
||||
from ..db_connector import base_relation_class
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
#<<< PLACE YOUR CODE ABOVE THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
__RCLS = base_relation_class('api.version')
|
||||
|
||||
class Version( __RCLS):
|
||||
"""
|
||||
__RCLS: <class 'half_orm.relation.Table_ApiApiVersion'>
|
||||
This class allows you to manipulate the data in the PG relation:
|
||||
TABLE: "api"."api"."version"
|
||||
FIELDS:
|
||||
- name: (text) PK
|
||||
- server: (cidr) NOT NULL
|
||||
- port: (int4) NOT NULL
|
||||
FOREIGN KEY:
|
||||
- _reverse_fkey_api_api_domain_version: (name)
|
||||
↳ "api"."api"."domain"(version)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super(Version, self).__init__(**kwargs)
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
@ -1,11 +0,0 @@
|
||||
"""This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
'acl',
|
||||
'route'
|
||||
]
|
@ -1,49 +0,0 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
# pylint: disable=wrong-import-order
|
||||
|
||||
"""The module apidb.api.view.acl povides the Acl class.
|
||||
|
||||
WARNING!
|
||||
|
||||
This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
|
||||
|
||||
DO NOT REMOVE OR MODIFY THE LINES BEGINING WITH:
|
||||
#>>> PLACE YOUR CODE BELOW...
|
||||
#<<< PLACE YOUR CODE ABOVE...
|
||||
|
||||
MAKE SURE YOU PLACE YOUR CODE BETWEEN THESE LINES OR AT THE END OF THE FILE.
|
||||
halfORM ONLY PRESERVES THE CODE BETWEEN THESE MARKS WHEN IT IS RUN.
|
||||
"""
|
||||
|
||||
from ...db_connector import base_relation_class
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
#<<< PLACE YOUR CODE ABOVE THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
__RCLS = base_relation_class('api.view.acl')
|
||||
|
||||
class Acl( __RCLS):
|
||||
"""
|
||||
__RCLS: <class 'half_orm.relation.View_ApiApiviewAcl'>
|
||||
This class allows you to manipulate the data in the PG relation:
|
||||
VIEW: "api"."api.view"."acl"
|
||||
FIELDS:
|
||||
- name: (text)
|
||||
- http_verb: (verb)
|
||||
- path: (text)
|
||||
- version: (text)
|
||||
- domain: (text)
|
||||
- function: (text)
|
||||
- acl_function_name: (text)
|
||||
- abs_path: (text)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super(Acl, self).__init__(**kwargs)
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
@ -1,48 +0,0 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
# pylint: disable=wrong-import-order
|
||||
|
||||
"""The module apidb.api.view.route povides the Route class.
|
||||
|
||||
WARNING!
|
||||
|
||||
This file is part of the apidb package. It has been generated by the
|
||||
command halfORM. To keep it in sync with your database structure, just rerun
|
||||
halfORM.
|
||||
|
||||
More information on the half_orm library on https://github.com/collorg/halfORM.
|
||||
|
||||
|
||||
DO NOT REMOVE OR MODIFY THE LINES BEGINING WITH:
|
||||
#>>> PLACE YOUR CODE BELOW...
|
||||
#<<< PLACE YOUR CODE ABOVE...
|
||||
|
||||
MAKE SURE YOU PLACE YOUR CODE BETWEEN THESE LINES OR AT THE END OF THE FILE.
|
||||
halfORM ONLY PRESERVES THE CODE BETWEEN THESE MARKS WHEN IT IS RUN.
|
||||
"""
|
||||
|
||||
from ...db_connector import base_relation_class
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
#<<< PLACE YOUR CODE ABOVE THIS LINE. DO NOT REMOVE THIS LINE!
|
||||
|
||||
__RCLS = base_relation_class('api.view.route')
|
||||
|
||||
class Route( __RCLS):
|
||||
"""
|
||||
__RCLS: <class 'half_orm.relation.View_ApiApiviewRoute'>
|
||||
This class allows you to manipulate the data in the PG relation:
|
||||
VIEW: "api"."api.view"."route"
|
||||
FIELDS:
|
||||
- path: (text)
|
||||
- version: (text)
|
||||
- domain: (text)
|
||||
- name: (text)
|
||||
- server: (cidr)
|
||||
- port: (int4)
|
||||
- abs_path: (text)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super(Route, self).__init__(**kwargs)
|
||||
|
||||
#>>> PLACE YOUR CODE BELLOW THIS LINE. DO NOT REMOVE THIS LINE!
|
@ -1,17 +0,0 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
"""This module exports the fonction base_relation_class which is
|
||||
imported by all modules in the package apidb.
|
||||
"""
|
||||
|
||||
from half_orm.model import Model
|
||||
|
||||
__all__ = ['base_relation_class']
|
||||
|
||||
MODEL = Model('api', scope=__name__)
|
||||
|
||||
def base_relation_class(qrn):
|
||||
"""Returns the class corresponding to the QRN (qualified relation name).
|
||||
"""
|
||||
cls = MODEL.get_relation_class(qrn)
|
||||
return cls
|
0
halfapi/testing/__init__.py
Normal file
0
halfapi/testing/__init__.py
Normal file
152
halfapi/testing/test_domain.py
Normal file
152
halfapi/testing/test_domain.py
Normal file
@ -0,0 +1,152 @@
|
||||
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_()
|
||||
|
||||
# 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]
|
3
optional-requirements.txt
Normal file
3
optional-requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
pyexcel>=0.6.3,<1
|
||||
pyexcel-ods>=0.5.6,<1
|
||||
pyexcel-xlsx=0.6.0,<1
|
647
poetry.lock
generated
647
poetry.lock
generated
@ -1,647 +0,0 @@
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Atomic file writes."
|
||||
marker = "sys_platform == \"win32\""
|
||||
name = "atomicwrites"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "1.4.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Classes Without Boilerplate"
|
||||
name = "attrs"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "19.3.0"
|
||||
|
||||
[package.extras]
|
||||
azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
|
||||
dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
|
||||
docs = ["sphinx", "zope.interface"]
|
||||
tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
name = "certifi"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "2020.6.20"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Universal encoding detector for Python 2 and 3"
|
||||
name = "chardet"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "3.0.4"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Composable command line interface toolkit"
|
||||
name = "click"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "7.1.2"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Cross-platform colored terminal text."
|
||||
marker = "sys_platform == \"win32\""
|
||||
name = "colorama"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "0.4.3"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
name = "fastapi"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
version = "0.58.1"
|
||||
|
||||
[package.dependencies]
|
||||
pydantic = ">=0.32.2,<2.0.0"
|
||||
starlette = "0.13.4"
|
||||
|
||||
[package.extras]
|
||||
all = ["requests", "aiofiles", "jinja2", "python-multipart", "itsdangerous", "pyyaml", "graphene", "ujson", "orjson", "email-validator", "uvicorn", "async-exit-stack", "async-generator"]
|
||||
dev = ["pyjwt", "passlib", "autoflake", "flake8", "uvicorn", "graphene"]
|
||||
doc = ["mkdocs", "mkdocs-material", "markdown-include", "typer", "typer-cli", "pyyaml"]
|
||||
test = ["pytest (5.4.3)", "pytest-cov (2.10.0)", "mypy", "black", "isort", "requests", "email-validator", "sqlalchemy", "peewee", "databases", "orjson", "async-exit-stack", "async-generator", "python-multipart", "aiofiles", "flask"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
name = "h11"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "A simple ORM in Python only dealing with the DML part of SQL."
|
||||
name = "half-orm"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.2.0"
|
||||
|
||||
[package.dependencies]
|
||||
PyYAML = "*"
|
||||
psycopg2-binary = "*"
|
||||
|
||||
[package.source]
|
||||
reference = "fe53195abb637d2192857e8b4878f4865b0fcce4"
|
||||
type = "git"
|
||||
url = "git@gite.lirmm.fr:newsi/halfORM.git"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "A collection of framework independent HTTP protocol utils."
|
||||
marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
|
||||
name = "httptools"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.1.1"
|
||||
|
||||
[package.extras]
|
||||
test = ["Cython (0.29.14)"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
name = "idna"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "2.10"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Read metadata from Python packages"
|
||||
marker = "python_version < \"3.8\""
|
||||
name = "importlib-metadata"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
||||
version = "1.7.0"
|
||||
|
||||
[package.dependencies]
|
||||
zipp = ">=0.5"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx", "rst.linker"]
|
||||
testing = ["packaging", "pep517", "importlib-resources (>=1.3)"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "More routines for operating on iterables, beyond itertools"
|
||||
name = "more-itertools"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
version = "8.4.0"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = ""
|
||||
name = "organigramme"
|
||||
optional = true
|
||||
python-versions = "^3.7"
|
||||
version = "0.1.0"
|
||||
|
||||
[package.dependencies]
|
||||
fastapi = "^0"
|
||||
half-orm = "branch master"
|
||||
halfapi = "branch master"
|
||||
sidb = "branch master"
|
||||
starlette = "^0"
|
||||
uvicorn = "^0"
|
||||
|
||||
[package.source]
|
||||
reference = "f26fc6dcde165bfbb88d4664f842de33aacfb1f8"
|
||||
type = "git"
|
||||
url = "git@gite.lirmm.fr:newsi/api/organigramme.git"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Core utilities for Python packages"
|
||||
name = "packaging"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "20.4"
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.0.2"
|
||||
six = "*"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
name = "pluggy"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "0.13.1"
|
||||
|
||||
[package.dependencies]
|
||||
[package.dependencies.importlib-metadata]
|
||||
python = "<3.8"
|
||||
version = ">=0.12"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "psycopg2 - Python-PostgreSQL Database Adapter"
|
||||
name = "psycopg2-binary"
|
||||
optional = false
|
||||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
|
||||
version = "2.8.5"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||
name = "py"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "1.9.0"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Data validation and settings management using python 3.6 type hinting"
|
||||
name = "pydantic"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
version = "1.5.1"
|
||||
|
||||
[package.extras]
|
||||
dotenv = ["python-dotenv (>=0.10.4)"]
|
||||
email = ["email-validator (>=1.0.3)"]
|
||||
typing_extensions = ["typing-extensions (>=3.7.2)"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "JSON Web Token implementation in Python"
|
||||
name = "pyjwt"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.7.1"
|
||||
|
||||
[package.extras]
|
||||
crypto = ["cryptography (>=1.4)"]
|
||||
flake8 = ["flake8", "flake8-import-order", "pep8-naming"]
|
||||
test = ["pytest (>=4.0.1,<5.0.0)", "pytest-cov (>=2.6.0,<3.0.0)", "pytest-runner (>=4.2,<5.0.0)"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python parsing module"
|
||||
name = "pyparsing"
|
||||
optional = false
|
||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
version = "2.4.7"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
name = "pytest"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
version = "5.4.3"
|
||||
|
||||
[package.dependencies]
|
||||
atomicwrites = ">=1.0"
|
||||
attrs = ">=17.4.0"
|
||||
colorama = "*"
|
||||
more-itertools = ">=4.0.0"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<1.0"
|
||||
py = ">=1.5.0"
|
||||
wcwidth = "*"
|
||||
|
||||
[package.dependencies.importlib-metadata]
|
||||
python = "<3.8"
|
||||
version = ">=0.12"
|
||||
|
||||
[package.extras]
|
||||
checkqa-mypy = ["mypy (v0.761)"]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "py.test plugin that allows you to add environment variables."
|
||||
name = "pytest-env"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.6.2"
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=2.6.0"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Add .env support to your django/flask apps in development and deployments"
|
||||
name = "python-dotenv"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.14.0"
|
||||
|
||||
[package.extras]
|
||||
cli = ["click (>=5.0)"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "YAML parser and emitter for Python"
|
||||
name = "pyyaml"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "5.3.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python HTTP for Humans."
|
||||
name = "requests"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "2.24.0"
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
chardet = ">=3.0.2,<4"
|
||||
idna = ">=2.5,<3"
|
||||
urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26"
|
||||
|
||||
[package.extras]
|
||||
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
|
||||
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Package for si PG"
|
||||
name = "sidb"
|
||||
optional = true
|
||||
python-versions = "*"
|
||||
version = "0.0.0"
|
||||
|
||||
[package.dependencies]
|
||||
half_orm = "*"
|
||||
|
||||
[package.source]
|
||||
reference = "d0f14a9631eecd29098d13a3f34e9cd533145f24"
|
||||
type = "git"
|
||||
url = "git@gite.lirmm.fr:newsi/sidb_halfORM.git"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python 2 and 3 compatibility utilities"
|
||||
name = "six"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
version = "1.15.0"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "The little ASGI library that shines."
|
||||
name = "starlette"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "0.13.4"
|
||||
|
||||
[package.extras]
|
||||
full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
name = "urllib3"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
|
||||
version = "1.25.9"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotlipy (>=0.6.0)"]
|
||||
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"]
|
||||
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "The lightning-fast ASGI server."
|
||||
name = "uvicorn"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.11.5"
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=7.0.0,<8.0.0"
|
||||
h11 = ">=0.8,<0.10"
|
||||
httptools = ">=0.1.0,<0.2.0"
|
||||
uvloop = ">=0.14.0"
|
||||
websockets = ">=8.0.0,<9.0.0"
|
||||
|
||||
[package.extras]
|
||||
watchgodreload = ["watchgod (>=0.6,<0.7)"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Fast implementation of asyncio event loop on top of libuv"
|
||||
marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
|
||||
name = "uvloop"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.14.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Measures the displayed width of unicode strings in a terminal"
|
||||
name = "wcwidth"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.2.5"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
|
||||
name = "websockets"
|
||||
optional = false
|
||||
python-versions = ">=3.6.1"
|
||||
version = "8.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
||||
marker = "python_version < \"3.8\""
|
||||
name = "zipp"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "3.1.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
|
||||
testing = ["jaraco.itertools", "func-timeout"]
|
||||
|
||||
[extras]
|
||||
organigramme = ["fastapi", "organigramme"]
|
||||
|
||||
[metadata]
|
||||
content-hash = "b801de5aea1ab2defb9265cc25773b55f7c00e36e7206e19173cef5a0ee99eb4"
|
||||
python-versions = "^3.7"
|
||||
|
||||
[metadata.files]
|
||||
atomicwrites = [
|
||||
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
|
||||
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
|
||||
]
|
||||
attrs = [
|
||||
{file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
|
||||
{file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
|
||||
]
|
||||
certifi = [
|
||||
{file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"},
|
||||
{file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"},
|
||||
]
|
||||
chardet = [
|
||||
{file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
|
||||
{file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
|
||||
]
|
||||
click = [
|
||||
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
|
||||
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
|
||||
]
|
||||
colorama = [
|
||||
{file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
|
||||
{file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
|
||||
]
|
||||
fastapi = [
|
||||
{file = "fastapi-0.58.1-py3-none-any.whl", hash = "sha256:d7499761d5ca901cdf5b6b73018d14729593f8ab1ea22d241f82fa574fc406ad"},
|
||||
{file = "fastapi-0.58.1.tar.gz", hash = "sha256:92e59b77eef7d6eaa80b16d275adda06b5f33b12d777e3fc5521b2f7f4718e13"},
|
||||
]
|
||||
h11 = [
|
||||
{file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"},
|
||||
{file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"},
|
||||
]
|
||||
half-orm = []
|
||||
httptools = [
|
||||
{file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"},
|
||||
{file = "httptools-0.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4"},
|
||||
{file = "httptools-0.1.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6"},
|
||||
{file = "httptools-0.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c"},
|
||||
{file = "httptools-0.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a"},
|
||||
{file = "httptools-0.1.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f"},
|
||||
{file = "httptools-0.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2"},
|
||||
{file = "httptools-0.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009"},
|
||||
{file = "httptools-0.1.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"},
|
||||
{file = "httptools-0.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d"},
|
||||
{file = "httptools-0.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be"},
|
||||
{file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
|
||||
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
|
||||
]
|
||||
importlib-metadata = [
|
||||
{file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"},
|
||||
{file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"},
|
||||
]
|
||||
more-itertools = [
|
||||
{file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"},
|
||||
{file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"},
|
||||
]
|
||||
organigramme = []
|
||||
packaging = [
|
||||
{file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"},
|
||||
{file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"},
|
||||
]
|
||||
pluggy = [
|
||||
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
|
||||
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
|
||||
]
|
||||
psycopg2-binary = [
|
||||
{file = "psycopg2-binary-2.8.5.tar.gz", hash = "sha256:ccdc6a87f32b491129ada4b87a43b1895cf2c20fdb7f98ad979647506ffc41b6"},
|
||||
{file = "psycopg2_binary-2.8.5-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:96d3038f5bd061401996614f65d27a4ecb62d843eb4f48e212e6d129171a721f"},
|
||||
{file = "psycopg2_binary-2.8.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:08507efbe532029adee21b8d4c999170a83760d38249936038bd0602327029b5"},
|
||||
{file = "psycopg2_binary-2.8.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:b9a8b391c2b0321e0cd7ec6b4cfcc3dd6349347bd1207d48bcb752aa6c553a66"},
|
||||
{file = "psycopg2_binary-2.8.5-cp27-cp27m-win32.whl", hash = "sha256:3286541b9d85a340ee4ed42732d15fc1bb441dc500c97243a768154ab8505bb5"},
|
||||
{file = "psycopg2_binary-2.8.5-cp27-cp27m-win_amd64.whl", hash = "sha256:008da3ab51adc70a5f1cfbbe5db3a22607ab030eb44bcecf517ad11a0c2b3cac"},
|
||||
{file = "psycopg2_binary-2.8.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ba13346ff6d3eb2dca0b6fa0d8a9d999eff3dcd9b55f3a890f12b0b6362b2b38"},
|
||||
{file = "psycopg2_binary-2.8.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:c8830b7d5f16fd79d39b21e3d94f247219036b29b30c8270314c46bf8b732389"},
|
||||
{file = "psycopg2_binary-2.8.5-cp34-cp34m-win32.whl", hash = "sha256:51f7823f1b087d2020d8e8c9e6687473d3d239ba9afc162d9b2ab6e80b53f9f9"},
|
||||
{file = "psycopg2_binary-2.8.5-cp34-cp34m-win_amd64.whl", hash = "sha256:107d9be3b614e52a192719c6bf32e8813030020ea1d1215daa86ded9a24d8b04"},
|
||||
{file = "psycopg2_binary-2.8.5-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:930315ac53dc65cbf52ab6b6d27422611f5fb461d763c531db229c7e1af6c0b3"},
|
||||
{file = "psycopg2_binary-2.8.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:6bb2dd006a46a4a4ce95201f836194eb6a1e863f69ee5bab506673e0ca767057"},
|
||||
{file = "psycopg2_binary-2.8.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3939cf75fc89c5e9ed836e228c4a63604dff95ad19aed2bbf71d5d04c15ed5ce"},
|
||||
{file = "psycopg2_binary-2.8.5-cp35-cp35m-win32.whl", hash = "sha256:a20299ee0ea2f9cca494396ac472d6e636745652a64a418b39522c120fd0a0a4"},
|
||||
{file = "psycopg2_binary-2.8.5-cp35-cp35m-win_amd64.whl", hash = "sha256:cc30cb900f42c8a246e2cb76539d9726f407330bc244ca7729c41a44e8d807fb"},
|
||||
{file = "psycopg2_binary-2.8.5-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:40abc319f7f26c042a11658bf3dd3b0b3bceccf883ec1c565d5c909a90204434"},
|
||||
{file = "psycopg2_binary-2.8.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:702f09d8f77dc4794651f650828791af82f7c2efd8c91ae79e3d9fe4bb7d4c98"},
|
||||
{file = "psycopg2_binary-2.8.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d1a8b01f6a964fec702d6b6dac1f91f2b9f9fe41b310cbb16c7ef1fac82df06d"},
|
||||
{file = "psycopg2_binary-2.8.5-cp36-cp36m-win32.whl", hash = "sha256:17a0ea0b0eabf07035e5e0d520dabc7950aeb15a17c6d36128ba99b2721b25b1"},
|
||||
{file = "psycopg2_binary-2.8.5-cp36-cp36m-win_amd64.whl", hash = "sha256:e004db88e5a75e5fdab1620fb9f90c9598c2a195a594225ac4ed2a6f1c23e162"},
|
||||
{file = "psycopg2_binary-2.8.5-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:a34826d6465c2e2bbe9d0605f944f19d2480589f89863ed5f091943be27c9de4"},
|
||||
{file = "psycopg2_binary-2.8.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cac918cd7c4c498a60f5d2a61d4f0a6091c2c9490d81bc805c963444032d0dab"},
|
||||
{file = "psycopg2_binary-2.8.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7b832d76cc65c092abd9505cc670c4e3421fd136fb6ea5b94efbe4c146572505"},
|
||||
{file = "psycopg2_binary-2.8.5-cp37-cp37m-win32.whl", hash = "sha256:bb0608694a91db1e230b4a314e8ed00ad07ed0c518f9a69b83af2717e31291a3"},
|
||||
{file = "psycopg2_binary-2.8.5-cp37-cp37m-win_amd64.whl", hash = "sha256:eb2f43ae3037f1ef5e19339c41cf56947021ac892f668765cd65f8ab9814192e"},
|
||||
{file = "psycopg2_binary-2.8.5-cp38-cp38-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:07cf82c870ec2d2ce94d18e70c13323c89f2f2a2628cbf1feee700630be2519a"},
|
||||
{file = "psycopg2_binary-2.8.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a69970ee896e21db4c57e398646af9edc71c003bc52a3cc77fb150240fefd266"},
|
||||
{file = "psycopg2_binary-2.8.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7036ccf715925251fac969f4da9ad37e4b7e211b1e920860148a10c0de963522"},
|
||||
{file = "psycopg2_binary-2.8.5-cp38-cp38-win32.whl", hash = "sha256:8f74e631b67482d504d7e9cf364071fc5d54c28e79a093ff402d5f8f81e23bfa"},
|
||||
{file = "psycopg2_binary-2.8.5-cp38-cp38-win_amd64.whl", hash = "sha256:fa466306fcf6b39b8a61d003123d442b23707d635a5cb05ac4e1b62cc79105cd"},
|
||||
]
|
||||
py = [
|
||||
{file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"},
|
||||
{file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"},
|
||||
]
|
||||
pydantic = [
|
||||
{file = "pydantic-1.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2a6904e9f18dea58f76f16b95cba6a2f20b72d787abd84ecd67ebc526e61dce6"},
|
||||
{file = "pydantic-1.5.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:da8099fca5ee339d5572cfa8af12cf0856ae993406f0b1eb9bb38c8a660e7416"},
|
||||
{file = "pydantic-1.5.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:68dece67bff2b3a5cc188258e46b49f676a722304f1c6148ae08e9291e284d98"},
|
||||
{file = "pydantic-1.5.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ab863853cb502480b118187d670f753be65ec144e1654924bec33d63bc8b3ce2"},
|
||||
{file = "pydantic-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:2007eb062ed0e57875ce8ead12760a6e44bf5836e6a1a7ea81d71eeecf3ede0f"},
|
||||
{file = "pydantic-1.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:20a15a303ce1e4d831b4e79c17a4a29cb6740b12524f5bba3ea363bff65732bc"},
|
||||
{file = "pydantic-1.5.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:473101121b1bd454c8effc9fe66d54812fdc128184d9015c5aaa0d4e58a6d338"},
|
||||
{file = "pydantic-1.5.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:9be755919258d5d168aeffbe913ed6e8bd562e018df7724b68cabdee3371e331"},
|
||||
{file = "pydantic-1.5.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:b96ce81c4b5ca62ab81181212edfd057beaa41411cd9700fbcb48a6ba6564b4e"},
|
||||
{file = "pydantic-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:93b9f265329d9827f39f0fca68f5d72cc8321881cdc519a1304fa73b9f8a75bd"},
|
||||
{file = "pydantic-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2c753d355126ddd1eefeb167fa61c7037ecd30b98e7ebecdc0d1da463b4ea09"},
|
||||
{file = "pydantic-1.5.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:8433dbb87246c0f562af75d00fa80155b74e4f6924b0db6a2078a3cd2f11c6c4"},
|
||||
{file = "pydantic-1.5.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0a1cdf24e567d42dc762d3fed399bd211a13db2e8462af9dfa93b34c41648efb"},
|
||||
{file = "pydantic-1.5.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:8be325fc9da897029ee48d1b5e40df817d97fe969f3ac3fd2434ba7e198c55d5"},
|
||||
{file = "pydantic-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:3714a4056f5bdbecf3a41e0706ec9b228c9513eee2ad884dc2c568c4dfa540e9"},
|
||||
{file = "pydantic-1.5.1-py36.py37.py38-none-any.whl", hash = "sha256:70f27d2f0268f490fe3de0a9b6fca7b7492b8fd6623f9fecd25b221ebee385e3"},
|
||||
{file = "pydantic-1.5.1.tar.gz", hash = "sha256:f0018613c7a0d19df3240c2a913849786f21b6539b9f23d85ce4067489dfacfa"},
|
||||
]
|
||||
pyjwt = [
|
||||
{file = "PyJWT-1.7.1-py2.py3-none-any.whl", hash = "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e"},
|
||||
{file = "PyJWT-1.7.1.tar.gz", hash = "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"},
|
||||
]
|
||||
pyparsing = [
|
||||
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
|
||||
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
|
||||
]
|
||||
pytest = [
|
||||
{file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"},
|
||||
{file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"},
|
||||
]
|
||||
pytest-env = [
|
||||
{file = "pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"},
|
||||
]
|
||||
python-dotenv = [
|
||||
{file = "python-dotenv-0.14.0.tar.gz", hash = "sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d"},
|
||||
{file = "python_dotenv-0.14.0-py2.py3-none-any.whl", hash = "sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"},
|
||||
]
|
||||
pyyaml = [
|
||||
{file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"},
|
||||
{file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"},
|
||||
{file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"},
|
||||
{file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"},
|
||||
{file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"},
|
||||
{file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"},
|
||||
{file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"},
|
||||
{file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"},
|
||||
{file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"},
|
||||
{file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"},
|
||||
{file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"},
|
||||
{file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"},
|
||||
]
|
||||
sidb = []
|
||||
six = [
|
||||
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
|
||||
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
|
||||
]
|
||||
starlette = [
|
||||
{file = "starlette-0.13.4-py3-none-any.whl", hash = "sha256:0fb4b38d22945b46acb880fedee7ee143fd6c0542992501be8c45c0ed737dd1a"},
|
||||
{file = "starlette-0.13.4.tar.gz", hash = "sha256:04fe51d86fd9a594d9b71356ed322ccde5c9b448fc716ac74155e5821a922f8d"},
|
||||
]
|
||||
urllib3 = [
|
||||
{file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"},
|
||||
{file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"},
|
||||
]
|
||||
uvicorn = [
|
||||
{file = "uvicorn-0.11.5-py3-none-any.whl", hash = "sha256:50577d599775dac2301bac8bd5b540d19a9560144143c5bdab13cba92783b6e7"},
|
||||
{file = "uvicorn-0.11.5.tar.gz", hash = "sha256:596eaa8645b6dbc24d6610e335f8ddf5f925b4c4b86fdc7146abb0bf0da65d17"},
|
||||
]
|
||||
uvloop = [
|
||||
{file = "uvloop-0.14.0-cp35-cp35m-macosx_10_11_x86_64.whl", hash = "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd"},
|
||||
{file = "uvloop-0.14.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726"},
|
||||
{file = "uvloop-0.14.0-cp36-cp36m-macosx_10_11_x86_64.whl", hash = "sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7"},
|
||||
{file = "uvloop-0.14.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362"},
|
||||
{file = "uvloop-0.14.0-cp37-cp37m-macosx_10_11_x86_64.whl", hash = "sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891"},
|
||||
{file = "uvloop-0.14.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95"},
|
||||
{file = "uvloop-0.14.0-cp38-cp38-macosx_10_11_x86_64.whl", hash = "sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5"},
|
||||
{file = "uvloop-0.14.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09"},
|
||||
{file = "uvloop-0.14.0.tar.gz", hash = "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e"},
|
||||
]
|
||||
wcwidth = [
|
||||
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
|
||||
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
|
||||
]
|
||||
websockets = [
|
||||
{file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"},
|
||||
{file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"},
|
||||
{file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"},
|
||||
{file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"},
|
||||
{file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"},
|
||||
{file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"},
|
||||
{file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"},
|
||||
{file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"},
|
||||
{file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"},
|
||||
{file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"},
|
||||
{file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"},
|
||||
{file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"},
|
||||
{file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"},
|
||||
{file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"},
|
||||
{file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"},
|
||||
{file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"},
|
||||
{file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"},
|
||||
{file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"},
|
||||
{file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"},
|
||||
{file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"},
|
||||
{file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"},
|
||||
{file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"},
|
||||
]
|
||||
zipp = [
|
||||
{file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"},
|
||||
{file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"},
|
||||
]
|
@ -1,32 +1,5 @@
|
||||
[tool.poetry]
|
||||
name = "halfapi"
|
||||
version = "0.1.0"
|
||||
description = "The core module for an halfORM API"
|
||||
authors = ["Joël Maizi <joel.maizi@lirmm.fr>", "Maxime Alves <maxime.alves@lirmm.fr>"]
|
||||
homepage = "https://gite.lirmm.fr/newsi/api/halfapi"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
click = "^7"
|
||||
half-orm = { git = "git@gite.lirmm.fr:newsi/halfORM.git" }
|
||||
PyJWT = "^1"
|
||||
python = "^3.7"
|
||||
starlette = "^0"
|
||||
uvicorn = { version = "^0" }
|
||||
fastapi = { version = "^0", optional = true }
|
||||
organigramme = { git = "git@gite.lirmm.fr:newsi/api/organigramme.git", optional = true }
|
||||
python-dotenv = "^0.14.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^5"
|
||||
requests = "^2"
|
||||
pytest-env = "^0.6.2"
|
||||
|
||||
[tool.poetry.extras]
|
||||
organigramme = [ "fastapi", "organigramme" ]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
halfapi = 'halfapi.cli:cli'
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
|
||||
# These are the assumed default build requirements from pip:
|
||||
# https://pip.pypa.io/en/stable/reference/pip/#pep-517-and-518-support
|
||||
requires = ["setuptools>=40.8.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
@ -1,6 +1,5 @@
|
||||
[pytest]
|
||||
testpaths = tests halfapi
|
||||
addopts = --doctest-modules
|
||||
doctest_optionflags = ELLIPSIS
|
||||
env =
|
||||
DEBUG=TRUE
|
||||
doctest_optionflags = ELLIPSIS IGNORE_EXCEPTION_DETAIL
|
||||
pythonpath = ./tests
|
||||
|
@ -1,5 +1,61 @@
|
||||
starlette
|
||||
uvicorn
|
||||
jwt
|
||||
half_orm @ git+ssh://git@gite.lirmm.fr/newsi/halfORM.git
|
||||
organigramme @ git+ssh://git@gite.lirmm.fr/api/newsi/halfapi.git
|
||||
alog==0.9.13
|
||||
anyio==3.4.0
|
||||
asgiref==3.4.1
|
||||
astroid==2.9.0
|
||||
attrs==21.2.0
|
||||
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
|
||||
|
9
scripts/get_token.py
Executable file
9
scripts/get_token.py
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/python3
|
||||
import requests
|
||||
import json
|
||||
|
||||
r = requests.post('http://127.0.0.1:3000/auth',
|
||||
data={'email':'dhenaut', 'password':'a'})
|
||||
r_obj = json.loads(r.text)
|
||||
token = r_obj['token']
|
||||
print(token)
|
1
scripts/whoami.sh
Normal file
1
scripts/whoami.sh
Normal file
@ -0,0 +1 @@
|
||||
http 127.0.0.1:3000/halfapi/whoami Authorization:$(http 127.0.0.1:3000/authentication/check email=malves password=papa|jq -r '.token')
|
94
setup.py
Executable file
94
setup.py
Executable file
@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
import pathlib
|
||||
|
||||
here = pathlib.Path(__file__).parent.resolve()
|
||||
long_description = (here / 'README.md').read_text(encoding='utf-8')
|
||||
|
||||
def get_version(package):
|
||||
"""
|
||||
Return package version as listed in `__version__` in `init.py`.
|
||||
"""
|
||||
with open(os.path.join(package, "__init__.py")) as f:
|
||||
return re.search("__version__ = ['\"]([^'\"]+)['\"]", f.read()).group(1)
|
||||
|
||||
|
||||
def get_packages(package):
|
||||
"""
|
||||
Return root package and all sub-packages.
|
||||
"""
|
||||
return [
|
||||
dirpath
|
||||
for dirpath, dirnames, filenames in os.walk(package)
|
||||
if os.path.exists(os.path.join(dirpath, "__init__.py"))
|
||||
]
|
||||
|
||||
module_name="halfapi"
|
||||
setup(
|
||||
name=module_name,
|
||||
version=get_version(module_name),
|
||||
url="https://github.com/halfAPI/halfapi",
|
||||
description="Core to write deep APIs using a module's tree",
|
||||
author="Maxime ALVES",
|
||||
author_email="maxime@freepoteries.fr",
|
||||
license="GPLv3",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
packages=get_packages(module_name),
|
||||
python_requires=">=3.8",
|
||||
install_requires=[
|
||||
"PyJWT>=2.6.0,<2.7.0",
|
||||
"starlette>=0.33,<0.34",
|
||||
"click>=8,<9",
|
||||
"uvicorn>=0.13,<1",
|
||||
"orjson>=3.8.5,<4",
|
||||
"pyyaml>=6,<7",
|
||||
"timing-asgi>=0.2.1,<1",
|
||||
"schema>=0.7.4,<1",
|
||||
"toml>=0.10,<0.11",
|
||||
"packaging>=19.0",
|
||||
"python-multipart"
|
||||
],
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10"
|
||||
],
|
||||
extras_require={
|
||||
"tests":[
|
||||
"pytest>=7,<8",
|
||||
"pytest-asyncio",
|
||||
"pylint",
|
||||
"requests",
|
||||
"httpx",
|
||||
"openapi-schema-validator",
|
||||
"openapi-spec-validator",
|
||||
"coverage"
|
||||
],
|
||||
"pyexcel":[
|
||||
"pyexcel",
|
||||
"pyexcel-ods3",
|
||||
"pyexcel-xlsx"
|
||||
]
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts":[
|
||||
"halfapi=halfapi.cli.cli:cli"
|
||||
]
|
||||
},
|
||||
keywords="web-api development boilerplate",
|
||||
project_urls={
|
||||
"Source": "https://github.com/halfAPI/halfapi",
|
||||
}
|
||||
)
|
93
tests/cli/test_cli.py
Normal file
93
tests/cli/test_cli.py
Normal file
@ -0,0 +1,93 @@
|
||||
#!/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
|
9
tests/cli/test_cli_config.py
Normal file
9
tests/cli/test_cli_config.py
Normal file
@ -0,0 +1,9 @@
|
||||
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')
|
84
tests/cli/test_cli_proj.py
Normal file
84
tests/cli/test_cli_proj.py
Normal file
@ -0,0 +1,84 @@
|
||||
#!/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
|
||||
|
33
tests/cli/test_cli_run.py
Normal file
33
tests/cli/test_cli_run.py
Normal file
@ -0,0 +1,33 @@
|
||||
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()
|
||||
"""
|
322
tests/conftest.py
Normal file
322
tests/conftest.py
Normal file
@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import functools
|
||||
import re
|
||||
import os
|
||||
import subprocess
|
||||
import importlib
|
||||
import tempfile
|
||||
from typing import Dict, Tuple
|
||||
from uuid import uuid1, uuid4, UUID
|
||||
from click.testing import CliRunner
|
||||
import jwt
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from starlette.testclient import TestClient
|
||||
from halfapi import __version__
|
||||
from halfapi.halfapi import HalfAPI
|
||||
from halfapi.cli.cli import cli
|
||||
from halfapi.cli.init import init
|
||||
from halfapi.cli.domain import domain, create_domain
|
||||
from halfapi.lib.responses import ORJSONResponse
|
||||
from halfapi.lib.jwt_middleware import JWTAuthenticationBackend
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
PROJNAME = os.environ.get('PROJ','tmp_api')
|
||||
|
||||
SECRET = 'dummysecret'
|
||||
|
||||
from halfapi.lib.jwt_middleware import (
|
||||
JWTUser, JWTAuthenticationBackend,
|
||||
JWTWebSocketAuthenticationBackend)
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_domain():
|
||||
yield {
|
||||
'name': 'dummy_domain',
|
||||
'router': '.routers',
|
||||
'enabled': True,
|
||||
'prefix': False,
|
||||
'config': {
|
||||
'test': True
|
||||
}
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def token_builder():
|
||||
yield jwt.encode({
|
||||
'name':'xxx',
|
||||
'user_id': str(uuid4())},
|
||||
key=SECRET
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def token_debug_false_builder():
|
||||
yield jwt.encode({
|
||||
'name':'xxx',
|
||||
'user_id': str(uuid4()),
|
||||
'debug': False},
|
||||
key=SECRET
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_debug_true_builder():
|
||||
yield jwt.encode({
|
||||
'name':'xxx',
|
||||
'user_id': str(uuid4()),
|
||||
'debug': True},
|
||||
key=SECRET
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_runner():
|
||||
"""Yield a click.testing.CliRunner to invoke the 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)
|
||||
cli_runner_ = class_()
|
||||
|
||||
yield cli_runner_
|
||||
|
||||
@pytest.fixture
|
||||
def halfapicli(cli_runner):
|
||||
def caller(*args):
|
||||
return cli_runner.invoke(cli, ' '.join(args))
|
||||
|
||||
yield caller
|
||||
|
||||
|
||||
# store history of failures per test class name and per index in parametrize (if
|
||||
# parametrize used)
|
||||
_test_failed_incremental: Dict[str, Dict[Tuple[int, ...], str]] = {}
|
||||
|
||||
|
||||
def pytest_runtest_makereport(item, call):
|
||||
if "incremental" in item.keywords:
|
||||
# incremental marker is used
|
||||
if call.excinfo is not None:
|
||||
# the test has failed
|
||||
# retrieve the class name of the test
|
||||
cls_name = str(item.cls)
|
||||
# retrieve the index of the test (if parametrize is used in
|
||||
# combination with incremental)
|
||||
parametrize_index = (
|
||||
tuple(item.callspec.indices.values())
|
||||
if hasattr(item, "callspec")
|
||||
else ()
|
||||
)
|
||||
# retrieve the name of the test function
|
||||
test_name = item.originalname or item.name
|
||||
# store in _test_failed_incremental the original name of the failed
|
||||
# test
|
||||
_test_failed_incremental.setdefault(cls_name, {}).setdefault(
|
||||
parametrize_index, test_name
|
||||
)
|
||||
|
||||
|
||||
def pytest_runtest_setup(item):
|
||||
if "incremental" in item.keywords:
|
||||
# retrieve the class name of the test
|
||||
cls_name = str(item.cls)
|
||||
# check if a previous test has failed for this class
|
||||
if cls_name in _test_failed_incremental:
|
||||
# retrieve the index of the test (if parametrize is used in
|
||||
# combination with incremental)
|
||||
parametrize_index = (
|
||||
tuple(item.callspec.indices.values())
|
||||
if hasattr(item, "callspec")
|
||||
else ()
|
||||
)
|
||||
# retrieve the name of the first test function to fail for this
|
||||
# class name and index
|
||||
test_name = _test_failed_incremental[cls_name].get(parametrize_index, None)
|
||||
# if name found, test has failed for the combination of class name &
|
||||
# test name
|
||||
if test_name is not None:
|
||||
pytest.xfail("previous test failed ({})".format(test_name))
|
||||
|
||||
@pytest.fixture
|
||||
def project_runner(runner, halfapicli, tree):
|
||||
with runner.isolated_filesystem():
|
||||
res = halfapicli('init', PROJNAME)
|
||||
|
||||
os.chdir(PROJNAME)
|
||||
|
||||
fs_path = os.getcwd()
|
||||
sys.path.insert(0, fs_path)
|
||||
|
||||
secret = tempfile.mkstemp()
|
||||
SECRET_PATH = secret[1]
|
||||
with open(SECRET_PATH, 'w') as f:
|
||||
f.write(str(uuid1()))
|
||||
|
||||
"""
|
||||
with open(os.path.join('.halfapi', PROJNAME), 'w') as halfapi_etc:
|
||||
PROJ_CONFIG = re.sub('secret = .*', f'secret = {SECRET_PATH}',
|
||||
format_halfapi_etc(PROJNAME, os.getcwd()))
|
||||
halfapi_etc.write(PROJ_CONFIG)
|
||||
"""
|
||||
|
||||
|
||||
###
|
||||
# add dummy domain
|
||||
###
|
||||
create_domain('test_domain', 'test_domain.routers')
|
||||
###
|
||||
|
||||
yield halfapicli
|
||||
|
||||
while fs_path in sys.path:
|
||||
sys.path.remove(fs_path)
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_app():
|
||||
app = Starlette()
|
||||
app.add_route('/',
|
||||
lambda request, *args, **kwargs: PlainTextResponse('Hello test!'))
|
||||
app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=JWTAuthenticationBackend(secret_key='dummysecret')
|
||||
)
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_debug_app():
|
||||
app = Starlette(debug=True)
|
||||
app.add_route('/',
|
||||
lambda request, *args, **kwargs: PlainTextResponse('Hello test!'))
|
||||
app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=JWTAuthenticationBackend(secret_key='dummysecret')
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(dummy_app):
|
||||
return TestClient(dummy_app)
|
||||
|
||||
@pytest.fixture
|
||||
def create_route():
|
||||
def wrapped(domain_path, method, path):
|
||||
stack = [domain_path, *path.split('/')[1:]]
|
||||
for i in range(len(stack)):
|
||||
if len(stack[i]) == 0:
|
||||
continue
|
||||
|
||||
path = os.path.join(*stack[0:i+1])
|
||||
if os.path.isdir(os.path.join(path)):
|
||||
continue
|
||||
os.mkdir(path)
|
||||
init_path = os.path.join(*stack, '__init__.py')
|
||||
with open(init_path, 'a+') as f:
|
||||
f.write(f'\ndef {method}():\n raise NotImplementedError')
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_project():
|
||||
halfapi_config = tempfile.mktemp()
|
||||
halfapi_secret = tempfile.mktemp()
|
||||
domain = 'dummy_domain'
|
||||
|
||||
with open(halfapi_config, 'w') as f:
|
||||
f.writelines([
|
||||
'[project]\n',
|
||||
'name = lirmm_api\n',
|
||||
'halfapi_version = 0.5.0\n',
|
||||
f'secret = {halfapi_secret}\n',
|
||||
'port = 3050\n',
|
||||
'loglevel = debug\n',
|
||||
'[domain.dummy_domain]\n',
|
||||
f'name = {domain}\n',
|
||||
'router = dummy_domain.routers\n',
|
||||
f'[domain.dummy_domain.config]\n',
|
||||
'test = True'
|
||||
])
|
||||
|
||||
with open(halfapi_secret, 'w') as f:
|
||||
f.write('turlututu')
|
||||
|
||||
return (halfapi_config, 'dummy_domain', 'dummy_domain.routers')
|
||||
|
||||
@pytest.fixture
|
||||
def application_debug(project_runner):
|
||||
halfAPI = HalfAPI({
|
||||
'secret':'turlututu',
|
||||
'production':False,
|
||||
'domain': {
|
||||
'dummy_domain': {
|
||||
'name': 'dummy_domain',
|
||||
'router': '.routers',
|
||||
'enabled': True,
|
||||
'prefix': False,
|
||||
'config':{
|
||||
'test': True
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
assert isinstance(halfAPI, HalfAPI)
|
||||
yield halfAPI.application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def application_domain(dummy_domain):
|
||||
return HalfAPI({
|
||||
'secret':'turlututu',
|
||||
'production':True,
|
||||
'domain': {
|
||||
'dummy_domain': {
|
||||
**dummy_domain,
|
||||
'config': {
|
||||
'test': True
|
||||
}
|
||||
}
|
||||
}
|
||||
}).application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree():
|
||||
def wrapped(path):
|
||||
list_dirs = os.walk(path)
|
||||
for root, dirs, files in list_dirs:
|
||||
for d in dirs:
|
||||
print(os.path.join(root, d))
|
||||
for f in files:
|
||||
print(os.path.join(root, f))
|
||||
return wrapped
|
27
tests/dummy_domain/__init__.py
Normal file
27
tests/dummy_domain/__init__.py
Normal file
@ -0,0 +1,27 @@
|
||||
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'}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
13
tests/dummy_domain/acl.py
Normal file
13
tests/dummy_domain/acl.py
Normal file
@ -0,0 +1,13 @@
|
||||
from halfapi.lib import acl
|
||||
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)
|
||||
)
|
0
tests/dummy_domain/routers/__init__.py
Normal file
0
tests/dummy_domain/routers/__init__.py
Normal file
0
tests/dummy_domain/routers/abc/__init__.py
Normal file
0
tests/dummy_domain/routers/abc/__init__.py
Normal file
@ -0,0 +1,59 @@
|
||||
from halfapi.lib import acl
|
||||
from halfapi.lib.responses import ORJSONResponse
|
||||
ACLS = {
|
||||
'GET': [{'acl':acl.public}],
|
||||
'POST': [{'acl':acl.public}],
|
||||
'PATCH': [{'acl':acl.public}],
|
||||
'PUT': [{'acl':acl.public}],
|
||||
'DELETE': [{'acl':acl.public}]
|
||||
}
|
||||
|
||||
async def get(test):
|
||||
"""
|
||||
description:
|
||||
returns the path parameter
|
||||
responses:
|
||||
200:
|
||||
description: test response
|
||||
"""
|
||||
return ORJSONResponse(str(test))
|
||||
|
||||
def post(test):
|
||||
"""
|
||||
description:
|
||||
returns the path parameter
|
||||
responses:
|
||||
200:
|
||||
description: test response
|
||||
"""
|
||||
return str(test)
|
||||
|
||||
def patch(test):
|
||||
"""
|
||||
description:
|
||||
returns the path parameter
|
||||
responses:
|
||||
200:
|
||||
description: test response
|
||||
"""
|
||||
return str(test)
|
||||
|
||||
def put(test):
|
||||
"""
|
||||
description:
|
||||
returns the path parameter
|
||||
responses:
|
||||
200:
|
||||
description: test response
|
||||
"""
|
||||
return str(test)
|
||||
|
||||
def delete(test):
|
||||
"""
|
||||
description:
|
||||
returns the path parameter
|
||||
responses:
|
||||
200:
|
||||
description: test response
|
||||
"""
|
||||
return str(test)
|
14
tests/dummy_domain/routers/abc/alphabet/__init__.py
Normal file
14
tests/dummy_domain/routers/abc/alphabet/__init__.py
Normal file
@ -0,0 +1,14 @@
|
||||
from starlette.responses import PlainTextResponse
|
||||
from halfapi.lib import acl
|
||||
|
||||
ACLS = {
|
||||
'GET': [{'acl':acl.public}]
|
||||
}
|
||||
|
||||
async def get(request, *args, **kwargs):
|
||||
"""
|
||||
responses:
|
||||
200:
|
||||
description: dummy abc.alphabet route
|
||||
"""
|
||||
return PlainTextResponse('True')
|
21
tests/dummy_domain/routers/abc/pinnochio/__init__.py
Normal file
21
tests/dummy_domain/routers/abc/pinnochio/__init__.py
Normal file
@ -0,0 +1,21 @@
|
||||
from uuid import uuid4
|
||||
from halfapi.lib import acl
|
||||
ACLS = {
|
||||
'GET' : [{'acl':acl.public}]
|
||||
}
|
||||
def get():
|
||||
"""
|
||||
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
|
||||
}
|
79
tests/dummy_domain/routers/arguments/__init__.py
Normal file
79
tests/dummy_domain/routers/arguments/__init__.py
Normal file
@ -0,0 +1,79 @@
|
||||
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
|
69
tests/dummy_domain/routers/async_router/__init__.py
Normal file
69
tests/dummy_domain/routers/async_router/__init__.py
Normal file
@ -0,0 +1,69 @@
|
||||
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')
|
||||
})
|
33
tests/dummy_domain/routers/config/__init__.py
Normal file
33
tests/dummy_domain/routers/config/__init__.py
Normal file
@ -0,0 +1,33 @@
|
||||
from ... import acl
|
||||
from halfapi.logging import logger
|
||||
|
||||
ACLS = {
|
||||
'GET' : [
|
||||
{'acl':acl.public},
|
||||
{'acl':acl.random},
|
||||
]
|
||||
}
|
||||
|
||||
def get(halfapi):
|
||||
"""
|
||||
description:
|
||||
returns the configuration of the domain
|
||||
responses:
|
||||
200:
|
||||
description: test response
|
||||
"""
|
||||
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']
|
@ -0,0 +1,8 @@
|
||||
param_docstring = """
|
||||
name: second
|
||||
in: path
|
||||
description: second parameter description test
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
"""
|
@ -0,0 +1,20 @@
|
||||
from uuid import UUID
|
||||
from halfapi.lib import acl
|
||||
ACLS = {
|
||||
'GET': [{'acl': acl.public}]
|
||||
}
|
||||
|
||||
def get(first, second, third):
|
||||
"""
|
||||
description: a Test route for path parameters
|
||||
responses:
|
||||
200:
|
||||
description: The test passed!
|
||||
500:
|
||||
description: The test did not pass :(
|
||||
"""
|
||||
assert isintance(first, str)
|
||||
assert isintance(second, UUID)
|
||||
assert isintance(third, int)
|
||||
|
||||
return ''
|
0
tests/dummy_domain/routers/path_params/__init__.py
Normal file
0
tests/dummy_domain/routers/path_params/__init__.py
Normal file
13
tests/dummy_domain/routers/ret_type/EXT_str/__init__.py
Normal file
13
tests/dummy_domain/routers/ret_type/EXT_str/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
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'))
|
@ -0,0 +1,31 @@
|
||||
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'))
|
13
tests/dummy_domain/routers/ret_type/__init__.py
Normal file
13
tests/dummy_domain/routers/ret_type/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
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'))
|
4
tests/dummy_domain/routers/ret_type/_test.py
Normal file
4
tests/dummy_domain/routers/ret_type/_test.py
Normal file
@ -0,0 +1,4 @@
|
||||
from . import get
|
||||
|
||||
def test_get():
|
||||
assert isinstance(get(), str)
|
25
tests/setup.py
Normal file
25
tests/setup.py
Normal file
@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
|
||||
def get_packages(package):
|
||||
"""
|
||||
Return root package and all sub-packages.
|
||||
"""
|
||||
return [
|
||||
dirpath
|
||||
for dirpath, dirnames, filenames in os.walk(package)
|
||||
if os.path.exists(os.path.join(dirpath, "__init__.py"))
|
||||
]
|
||||
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
module_name="dummy_domain"
|
||||
setup(
|
||||
name=module_name,
|
||||
version='0',
|
||||
url="https://gite.lirmm.fr/malves/halfapi",
|
||||
packages=get_packages(module_name),
|
||||
python_requires=">=3.7",
|
||||
install_requires=[]
|
||||
)
|
37
tests/test_acl.py
Normal file
37
tests/test_acl.py
Normal file
@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.testclient import TestClient
|
||||
from halfapi.half_route import HalfRoute
|
||||
from halfapi.lib import acl
|
||||
|
||||
def test_acl_Check(dummy_app, token_debug_false_builder):
|
||||
"""
|
||||
A request with ?check should always return a 200 status code
|
||||
"""
|
||||
|
||||
@HalfRoute.acl_decorator(params=[{'acl':acl.public}])
|
||||
async def test_route_public(request, **kwargs):
|
||||
raise Exception('Should not raise')
|
||||
return PlainTextResponse('ok')
|
||||
|
||||
dummy_app.add_route('/test_public', test_route_public)
|
||||
test_client = TestClient(dummy_app)
|
||||
|
||||
resp = test_client.request('get', '/test_public?check')
|
||||
assert resp.status_code == 200
|
||||
|
||||
@HalfRoute.acl_decorator(params=[{'acl':acl.private}])
|
||||
async def test_route_private(request, **kwargs):
|
||||
raise Exception('Should not raise')
|
||||
return PlainTextResponse('ok')
|
||||
|
||||
dummy_app.add_route('/test_private', test_route_private)
|
||||
test_client = TestClient(dummy_app)
|
||||
|
||||
resp = test_client.request('get', '/test_private')
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = test_client.request('get', '/test_private?check')
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
15
tests/test_app.py
Normal file
15
tests/test_app.py
Normal file
@ -0,0 +1,15 @@
|
||||
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)
|
52
tests/test_conf.py
Normal file
52
tests/test_conf.py
Normal file
@ -0,0 +1,52 @@
|
||||
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)
|
14
tests/test_constants.py
Normal file
14
tests/test_constants.py
Normal file
@ -0,0 +1,14 @@
|
||||
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)
|
67
tests/test_debug_routes.py
Normal file
67
tests/test_debug_routes.py
Normal file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
import pytest
|
||||
from starlette.authentication import UnauthenticatedUser
|
||||
from starlette.testclient import TestClient
|
||||
import subprocess
|
||||
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):
|
||||
# @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/whoami')
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_halfapi_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)
|
||||
|
||||
r = c.request('get', '/halfapi/log')
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_halfapi_error_400(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/400')
|
||||
assert r.status_code == 400
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def test_schema(application_debug):
|
||||
c = TestClient(application_debug)
|
||||
|
||||
r = c.request('get', '/')
|
||||
schema = r.json()
|
||||
assert isinstance(schema, dict)
|
||||
openapi_spec_validator.validate_spec(schema)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user