Compare commits
368 Commits
Author | SHA1 | Date |
---|---|---|
maxime | c808ba21ab | |
maxime | 9f908d3cee | |
maxime | 7b6d9e994a | |
maxime | 8f6330bca7 | |
maxime | 8506aa5322 | |
maxime | b683e80959 | |
maxime | b412d249a1 | |
Maxime Alves LIRMM | f4f9a0fc66 | |
Maxime Alves LIRMM | c855cce013 | |
Maxime Alves LIRMM | e083c4386e | |
Maxime Alves LIRMM | 476ae29792 | |
Maxime Alves LIRMM | 673097adeb | |
Maxime Alves LIRMM | 1cc1bbd5ef | |
Maxime Alves LIRMM | 135d6e86e4 | |
Maxime Alves LIRMM | 0fcf433ec6 | |
Maxime Alves LIRMM | 45cf32de2b | |
Maxime Alves LIRMM | 1b713c3816 | |
Maxime Alves LIRMM | 59889e1e31 | |
Maxime Alves LIRMM | 28a1a69435 | |
maxime | 65ecf9817c | |
maxime | 3b7d3bda5c | |
maxime | e19f27f306 | |
maxime | e9c84c9f7c | |
maxime | b1595beb14 | |
maxime | 60ff99d0fb | |
maxime | 9657f0f9ec | |
maxime | f646b4d663 | |
maxime | 0817882558 | |
maxime | 896ce58731 | |
maxime | 87856cfb42 | |
maxime | 4856f80b99 | |
maxime | eac602f0a5 | |
maxime | 14e051bd91 | |
maxime | 20563081f5 | |
maxime | 7949b3206c | |
maxime | c4583b7187 | |
maxime | 2413436104 | |
Maxime Alves LIRMM | 54cc6c17c9 | |
Maxime Alves LIRMM | ff3a39c740 | |
Maxime Alves LIRMM@home | 8d254bafa0 | |
Maxime Alves LIRMM | 0a385661b9 | |
Maxime Alves LIRMM | e065fe04e4 | |
Maxime Alves LIRMM | b7c5704c95 | |
Maxime Alves LIRMM | dd83a337e9 | |
Maxime Alves LIRMM | f6d08e8309 | |
Maxime Alves LIRMM | 262de901a8 | |
Maxime Alves LIRMM | e5c25ede1f | |
Maxime Alves LIRMM | b4c37ea999 | |
Maxime Alves LIRMM | 5a7e51ae94 | |
Maxime Alves LIRMM | 69129fd7af | |
Maxime Alves LIRMM | a3fc6dc830 | |
Maxime Alves LIRMM@home | 064127dc16 | |
Maxime Alves LIRMM@home | c2eb95533c | |
Maxime Alves LIRMM | bbb027cd0d | |
Maxime Alves LIRMM | c9ecc1f8d2 | |
Maxime Alves LIRMM | d1a8351997 | |
Maxime Alves LIRMM | dc29abea84 | |
Maxime Alves LIRMM | 4c966e7151 | |
Maxime Alves LIRMM | 7deb353b4b | |
Maxime Alves LIRMM | 3986083725 | |
Maxime Alves LIRMM | 301b0eeab5 | |
Maxime Alves LIRMM | cc0566542b | |
Maxime Alves LIRMM | 4f4dac0ff2 | |
Maxime Alves LIRMM | 8b8caa2e15 | |
Maxime Alves LIRMM | 5a70c00541 | |
Maxime Alves LIRMM | 7723acb812 | |
Maxime Alves LIRMM | fd682ba0e0 | |
Maxime Alves LIRMM@home | e9c57049dd | |
Maxime Alves LIRMM | e47ffcddb9 | |
Maxime Alves LIRMM | f0c898ba20 | |
Maxime Alves LIRMM@home | b7e678e00f | |
Maxime Alves LIRMM@home | d0ec030ce9 | |
Maxime Alves LIRMM@home | 039bc2c8fe | |
Maxime Alves LIRMM@home | 5e21d4c24f | |
Maxime Alves LIRMM@home | 8bdf5cab82 | |
Maxime Alves LIRMM | 910e1e1497 | |
Maxime Alves LIRMM | 53a691d985 | |
Maxime Alves LIRMM@home | 5d1b88daca | |
Maxime Alves LIRMM@home | c0bd6ddc43 | |
Maxime Alves LIRMM | 779dd2d519 | |
Maxime Alves LIRMM | 84747e3f73 | |
Maxime Alves LIRMM | 18748808c9 | |
Maxime Alves LIRMM | 34ede09fe2 | |
Maxime Alves LIRMM | 6ebdf765bd | |
Maxime Alves LIRMM | 2d47789f61 | |
maxime | 3ec6d7514e | |
maxime | 3c97d39cdc | |
maxime | f96c712aa8 | |
maxime | 23a93026aa | |
maxime | 05cf37c775 | |
maxime | a46b045ca6 | |
maxime | f68b7e59b8 | |
Maxime Alves LIRMM@home | b17ce623f4 | |
Maxime Alves LIRMM@home | 7dd7d00625 | |
Maxime Alves LIRMM@home | 389823db82 | |
Maxime Alves LIRMM@home | e70239433f | |
Maxime Alves LIRMM@home | 739ffc9afa | |
Maxime Alves LIRMM@home | 99d4aaeb8d | |
Maxime Alves LIRMM | 0d9dc2a018 | |
Maxime Alves LIRMM | b63b0f52c6 | |
Maxime Alves LIRMM | 63d6d1e8ea | |
Maxime Alves LIRMM | e8c99e6012 | |
Maxime Alves LIRMM | 568aea9ea8 | |
Maxime Alves LIRMM | 988a1e5bab | |
Maxime Alves LIRMM | de72e469d2 | |
Maxime Alves LIRMM | f7879c6388 | |
Maxime Alves LIRMM | 84179743a6 | |
Maxime Alves LIRMM | adf7f872b6 | |
Maxime Alves LIRMM | b96f4908c6 | |
Maxime Alves LIRMM | a388faf1d8 | |
Maxime Alves LIRMM | 4d6e935813 | |
Maxime Alves LIRMM | 4e080f805f | |
Maxime Alves LIRMM | 91dd4cbaa8 | |
Maxime Alves LIRMM | 209c6ef40a | |
Maxime Alves LIRMM | a1c1bf04df | |
Maxime Alves LIRMM | 90203b2edf | |
Maxime Alves LIRMM | a69d2b7639 | |
Maxime Alves LIRMM | c3153921f7 | |
Maxime Alves LIRMM | 97fee8ca96 | |
Maxime Alves LIRMM | d33c82e348 | |
Maxime Alves LIRMM@home | 058121d985 | |
Maxime Alves LIRMM@home | 2e5680d29a | |
Maxime Alves LIRMM@home | 979007f287 | |
Maxime Alves LIRMM@home | df555c7d26 | |
Maxime Alves LIRMM@home | be312d4b7a | |
Maxime Alves LIRMM | f02a97fbf8 | |
Maxime Alves LIRMM@home | e9ffb553c8 | |
Maxime Alves LIRMM@home | 776cc8c85e | |
Maxime Alves LIRMM@home | 5d5ffdfb7c | |
Maxime Alves LIRMM@home | 7c0f5717f4 | |
Maxime Alves LIRMM | 7fb5e25411 | |
Maxime Alves LIRMM | a0dbbca04d | |
Maxime Alves LIRMM | 95fb267e81 | |
Maxime Alves LIRMM | e9bf94a607 | |
Maxime Alves LIRMM | f82cd5552b | |
Maxime Alves LIRMM | bdbad9e296 | |
Maxime Alves LIRMM | 76e942ab91 | |
Maxime Alves LIRMM | 8fff1f5372 | |
Maxime Alves LIRMM | 048c9f1bab | |
Maxime Alves LIRMM | d5f39a7929 | |
Maxime Alves LIRMM | 648841d90f | |
Maxime Alves LIRMM | c658815eb5 | |
Maxime Alves LIRMM | 46e62575ae | |
Maxime Alves LIRMM | 7001cec86e | |
Maxime Alves LIRMM@home | b4157c4a7d | |
Maxime Alves LIRMM@home | 96f78e76c5 | |
Maxime Alves LIRMM@home | d54dcd641d | |
Maxime Alves LIRMM | 7060d201ec | |
Maxime Alves LIRMM | dbca2f28fb | |
Maxime Alves LIRMM@home | d06857bf49 | |
Maxime Alves LIRMM@home | 3dc951c81e | |
Maxime Alves LIRMM@home | a8e5cfc0ff | |
Maxime Alves LIRMM@home | 20cada4fa0 | |
Maxime Alves LIRMM@home | c1bb637be7 | |
Maxime Alves LIRMM@home | a37c2356d6 | |
Maxime Alves LIRMM@home | 038715e94a | |
Maxime Alves LIRMM@home | 2f9005a1a5 | |
Maxime Alves LIRMM@home | a2d79f49b9 | |
Maxime Alves LIRMM@home | cf98b08fa5 | |
Maxime Alves LIRMM@home | c1191bbb0e | |
Maxime Alves LIRMM@home | 837c646bc5 | |
Maxime Alves LIRMM@home | 49c13c56ac | |
Maxime Alves LIRMM@home | 1ccfa0d10e | |
Maxime Alves LIRMM@home | 238bd99bd3 | |
Maxime Alves LIRMM@home | 429a90d786 | |
Maxime Alves LIRMM@home | 1ec244b60f | |
Maxime Alves LIRMM@home | 53ecbb58fc | |
Maxime Alves LIRMM@home | 7e7bbb3a62 | |
Maxime Alves LIRMM@home | 5e88109b3e | |
Maxime Alves LIRMM@home | e293ac3867 | |
Maxime Alves LIRMM@home | a98aa27485 | |
Maxime Alves LIRMM@home | 15794327f9 | |
Maxime Alves LIRMM@home | cf20b76959 | |
Maxime Alves LIRMM | 7e1cc21b8c | |
Maxime Alves LIRMM | ec26438340 | |
Maxime Alves LIRMM | 7230316296 | |
Maxime Alves LIRMM | f5b7e3392a | |
Maxime Alves LIRMM | 18dbbdd584 | |
Maxime Alves LIRMM | b3b32b47f8 | |
Maxime Alves LIRMM | 189fcf86f7 | |
Maxime Alves LIRMM | 55878df260 | |
Maxime Alves LIRMM | a6985fa9bf | |
Maxime Alves LIRMM | ed6dcb0513 | |
Maxime Alves LIRMM | 7017827b2b | |
Maxime Alves LIRMM | 7c2bf60812 | |
Maxime Alves LIRMM | 1fda2ab15d | |
Maxime Alves LIRMM | c9639ddbc0 | |
Maxime Alves LIRMM | 24bd3f5653 | |
Maxime Alves LIRMM@home | e203552876 | |
Maxime Alves LIRMM@home | ac4aadc2df | |
Maxime Alves LIRMM@home | 4dae2f3676 | |
Maxime Alves LIRMM@home | c27ed3a966 | |
Maxime Alves LIRMM@home | 47d81c048f | |
Maxime Alves LIRMM | ad6877a7e9 | |
Maxime Alves LIRMM | e7e1bfed1b | |
Maxime Alves LIRMM | f0e662e060 | |
Maxime Alves LIRMM | ea8e7ede65 | |
Maxime Alves LIRMM | f4880f1f9c | |
Maxime Alves LIRMM | 4a8cb008e6 | |
Maxime Alves LIRMM | 049860fce5 | |
Maxime Alves LIRMM | 908eab5fdc | |
Maxime Alves LIRMM | ce672eeb30 | |
Maxime Alves LIRMM | 1f20a336e2 | |
Maxime Alves LIRMM | 0173eb6d72 | |
Maxime Alves LIRMM | ad9bd45ba0 | |
Maxime Alves LIRMM | 00c7b5caf4 | |
Maxime Alves LIRMM | 5184ab4411 | |
Maxime Alves LIRMM | 159d38cb94 | |
Maxime Alves LIRMM@home | 0cad726f8c | |
Maxime Alves LIRMM@home | 94e09a546b | |
Maxime Alves LIRMM@home | 0643af5cca | |
Maxime Alves LIRMM@home | 339c910c86 | |
Maxime Alves LIRMM@home | 948372fcbc | |
Maxime Alves LIRMM@home | 5a0509a114 | |
Maxime Alves LIRMM@home | c99e636d6e | |
Maxime Alves LIRMM@home | 8b88d7f1b4 | |
Maxime Alves LIRMM@home | f27b68e350 | |
Maxime Alves LIRMM@home | f3c12f516e | |
Maxime Alves LIRMM | 55109e271c | |
Maxime Alves LIRMM | 51877b271e | |
Maxime Alves LIRMM | 061c966072 | |
Maxime Alves LIRMM | cdd2214043 | |
Maxime Alves LIRMM | 0470f9fa89 | |
Maxime Alves LIRMM | 74b79120ba | |
Maxime Alves LIRMM | bc556854ac | |
Maxime Alves LIRMM | 865a4dffd1 | |
Maxime Alves LIRMM | 5e2ccceedf | |
Maxime Alves LIRMM | 844f6a8f14 | |
Maxime Alves LIRMM | 43b7fe21df | |
Maxime Alves LIRMM | c9ba99c1df | |
Maxime Alves LIRMM | 18a1f71d99 | |
Maxime Alves LIRMM | c2cea298bf | |
Maxime Alves LIRMM | 387fc01f44 | |
Maxime Alves LIRMM | c2054e9aa9 | |
Maxime Alves LIRMM@home | 0e669b81b0 | |
Maxime Alves LIRMM@home | b45c0bf746 | |
Maxime Alves LIRMM@home | c920531610 | |
Maxime Alves LIRMM@home | bb50fae186 | |
Maxime Alves LIRMM | 0c3aeb532f | |
Maxime Alves LIRMM | 8a9f93b9e0 | |
Maxime Alves LIRMM | 9381e1582e | |
Maxime Alves LIRMM | a539212faf | |
Maxime Alves LIRMM | a14285475e | |
Maxime Alves LIRMM@home | 15d69efd60 | |
Maxime Alves LIRMM@home | 2819483070 | |
Maxime Alves LIRMM@home | 81f6cf8b39 | |
Maxime Alves LIRMM | eb68d06ac0 | |
Maxime Alves LIRMM | 3fb6fb4ded | |
Maxime Alves LIRMM | e5a278c84c | |
Maxime Alves LIRMM | 904783b134 | |
Maxime Alves LIRMM | e0b06f51d5 | |
Maxime Alves LIRMM | 8ca94ab7ed | |
Maxime Alves LIRMM | e4e04c6ac1 | |
Maxime Alves LIRMM | 0e5a8ede9d | |
Maxime Alves LIRMM | a0c41d7d78 | |
Maxime Alves LIRMM | a82fd6def0 | |
Maxime Alves LIRMM | 9a9bc16bbc | |
Maxime Alves LIRMM | ea1f54cb82 | |
Maxime Alves LIRMM | 3c6713b5e2 | |
Maxime Alves LIRMM | c7e29e399b | |
Maxime Alves LIRMM | 89a5f3aa52 | |
Maxime Alves LIRMM@home | 933f456c86 | |
Maxime Alves LIRMM@home | 5276833afe | |
Maxime Alves LIRMM@home | 10b1960f4e | |
Maxime Alves LIRMM@home | a2fb70f84b | |
Maxime Alves LIRMM@home | 795ca3dcc0 | |
Maxime Alves LIRMM@home | 5b67d938e2 | |
Maxime Alves LIRMM@home | 89ec439d3e | |
Maxime Alves LIRMM@home | b5ef4a12d1 | |
Maxime Alves LIRMM@home | 4eb23fd189 | |
Maxime Alves LIRMM@home | e1c3d61207 | |
Maxime Alves LIRMM@home | cd0df35496 | |
Maxime Alves LIRMM@home | 607a288e28 | |
Maxime Alves LIRMM@home | a3d546905c | |
Maxime Alves LIRMM@home | fecdaa29e5 | |
Joël Maïzi | f36a2d8e06 | |
Maxime Alves LIRMM@home | 54e215b6ff | |
Joël Maïzi | a5300962ad | |
Maxime Alves LIRMM | d21ee175e9 | |
Maxime Alves LIRMM@home | f8e546007c | |
Maxime Alves LIRMM@home | 8d414f2bdd | |
Maxime Alves LIRMM@home | e61dd7eeaa | |
Maxime Alves LIRMM@home | f4ba64f186 | |
Maxime Alves LIRMM@home | d4a6bb1a04 | |
Maxime Alves LIRMM@home | 5d4b8d38b4 | |
Maxime Alves LIRMM@home | 9516eaa6d7 | |
Maxime Alves LIRMM@home | 56657036e4 | |
Maxime Alves LIRMM@home | 73d49031a7 | |
Maxime Alves LIRMM@home | 4782764059 | |
Maxime Alves LIRMM@home | 61aec6871a | |
Maxime Alves LIRMM@home | 9a4f90d36b | |
Maxime Alves LIRMM | 64e60343bf | |
Maxime Alves LIRMM | 4df34b5d87 | |
Maxime Alves LIRMM | 24c68b51f2 | |
Maxime Alves LIRMM | d31efe3cc4 | |
Maxime Alves LIRMM | e590bc31fe | |
Maxime Alves LIRMM | 3959e6d614 | |
Maxime Alves LIRMM | 1e1ff2fb69 | |
Maxime Alves LIRMM | 63b73a2bc1 | |
Maxime Alves LIRMM | 710d390b49 | |
Maxime Alves LIRMM | cb5724b4fa | |
Maxime Alves LIRMM | 23bd876c4c | |
Maxime Alves LIRMM | 40547ddf30 | |
Maxime Alves LIRMM | f5ebabbcd4 | |
Maxime Alves LIRMM | 781736c151 | |
Maxime Alves LIRMM | acb0a46904 | |
Maxime Alves LIRMM | b651d90f0e | |
Maxime Alves LIRMM | 0a34948b98 | |
Maxime Alves LIRMM | c41d0d8f8f | |
Maxime Alves LIRMM | 91ea25791b | |
Maxime Alves LIRMM | 0cd7c987e5 | |
Joël Maïzi | 6653a0cd0a | |
Maxime Alves LIRMM | 2d51f260cd | |
Maxime Alves LIRMM | 8fc1ba6c91 | |
Maxime Alves LIRMM | 9753f4be95 | |
Maxime Alves LIRMM | 584e0b6584 | |
Maxime Alves LIRMM | 82e2ccbdbc | |
Maxime Alves LIRMM | a6f2187032 | |
Maxime Alves LIRMM | 1ee5da1588 | |
Maxime Alves LIRMM | 1d0d3563cb | |
Maxime Alves LIRMM | 91581f9ae6 | |
Maxime Alves LIRMM | 6ac0b5acd9 | |
Maxime Alves LIRMM | 77598b8453 | |
Maxime Alves LIRMM | ad1fed7117 | |
Maxime Alves LIRMM | 59ed5884ce | |
Maxime Alves LIRMM | 5c4e81d5d2 | |
Maxime Alves LIRMM | 21950aa6cd | |
Joël Maïzi | abfaf2e1ea | |
Maxime Alves LIRMM | 333aca9e2c | |
Maxime Alves LIRMM | b6e511a96d | |
Maxime Alves LIRMM | deb41be3e8 | |
Maxime Alves LIRMM | 429b26dec6 | |
Maxime Alves LIRMM | c603727190 | |
Maxime Alves LIRMM | 74e0b3dc54 | |
Joël Maïzi | 31878d971e | |
Maxime Alves LIRMM | b89e03746f | |
Maxime Alves LIRMM | 79210e503e | |
Maxime Alves LIRMM | 360f59b6ba | |
Joël Maïzi | 7d6bc2c181 | |
Joël Maïzi | 0a94f71dad | |
Maxime Alves LIRMM | e223c0791c | |
Joël Maïzi | d93fb23bba | |
Maxime Alves LIRMM | 3530f53820 | |
Maxime Alves LIRMM@home | f0d980e035 | |
Maxime Alves LIRMM@home | 246c9224e3 | |
Maxime Alves LIRMM@home | 70723ea580 | |
Maxime Alves LIRMM@home | 4ef7ae377a | |
Maxime Alves LIRMM@home | 6a81c61649 | |
Maxime Alves LIRMM | 2ad0b3a14b | |
Maxime Alves LIRMM | 2610d9f089 | |
Maxime Alves LIRMM | 65797873da | |
Maxime Alves LIRMM | d6075de2eb | |
Maxime Alves LIRMM | 39d455b682 | |
Maxime Alves LIRMM | a78e6ebc75 | |
Maxime Alves LIRMM | 5f7d66d4d6 | |
Maxime Alves LIRMM | 38798549f6 | |
Maxime Alves LIRMM | ba44a01a45 | |
Maxime Alves LIRMM | c54101c3e6 | |
Maxime Alves LIRMM | 7337246fc1 | |
Maxime Alves LIRMM | 142ea24630 | |
Maxime Alves LIRMM | bcc4a3e9d8 | |
Maxime Alves LIRMM | e8ed06a9b6 | |
Joël Maïzi | 31a8f68a0a | |
Maxime Alves LIRMM | c97fa3b4c2 | |
Maxime Alves LIRMM | 87cc59849a | |
Maxime Alves LIRMM@home | 1b40b95d19 | |
Maxime Alves LIRMM@home | 51722b73f8 | |
Maxime Alves LIRMM@home | d944d45bbf |
|
@ -137,3 +137,7 @@ dmypy.json
|
||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
domains/
|
domains/
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Vim swap files
|
||||||
|
*.swp
|
||||||
|
|
|
@ -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
|
228
CHANGELOG.md
228
CHANGELOG.md
|
@ -1,5 +1,233 @@
|
||||||
# HalfAPI
|
# HalfAPI
|
||||||
|
|
||||||
|
## 0.6.30
|
||||||
|
|
||||||
|
Dependencies updates
|
||||||
|
|
||||||
|
- pyYAML v6.0.1
|
||||||
|
- starlette v0.37.2
|
||||||
|
|
||||||
|
Warning : the on_startup halfAPI argument is now removed, use the lifeSpan
|
||||||
|
|
||||||
|
## 0.6.29
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Starlette version bumped to 0.33.
|
||||||
|
|
||||||
|
## 0.6.28
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Starlette version bumped to 0.31 (had to disable a test to make it work but
|
||||||
|
seems not important).
|
||||||
|
|
||||||
|
### Development dependencies
|
||||||
|
|
||||||
|
Python 3.7 is no longer supported (openapi_spec_validator is not compatible).
|
||||||
|
|
||||||
|
If you are a developper, you should update dev dependencies in your virtual
|
||||||
|
environment.
|
||||||
|
|
||||||
|
### OpenAPI schemas
|
||||||
|
|
||||||
|
This release improves OpenAPI specification in routes, and gives a default
|
||||||
|
"parameters" field for routes that have path parameters.
|
||||||
|
|
||||||
|
Also, if you use halfAPI for multi-domain setups, you may be annoyed by the
|
||||||
|
change in the return value of the "/" route that differs from "/domain" route.
|
||||||
|
|
||||||
|
An HalfAPI instance should return one and only one OpenAPI Schema, so you can
|
||||||
|
rely on it to connect to other software.
|
||||||
|
|
||||||
|
The version number that is contained under the "info" dictionnary is now the "version"
|
||||||
|
of the Api domain, as specified in the domain dictionnary specified at the root
|
||||||
|
of the Domain.
|
||||||
|
|
||||||
|
The title field of the "info" dictionnary is now the Domain's name.
|
||||||
|
|
||||||
|
The ACLs list is now available under the "info.x-acls" attribute of the schema.
|
||||||
|
It is still accessible from the "/halfapi/acls" route.
|
||||||
|
|
||||||
|
#### Schema Components
|
||||||
|
|
||||||
|
You can now specify a dict in the domain's metadata dict that follows the
|
||||||
|
"components" key of an OpenAPI schema.
|
||||||
|
|
||||||
|
Use it to define models that are used in your API. You can see an exemple of
|
||||||
|
it's use in the "tests/dummy_domain/__init__.py" file.
|
||||||
|
|
||||||
|
|
||||||
|
### ACLs
|
||||||
|
|
||||||
|
The use of an "HEAD" request to check an ACL is now the norm. Please change all
|
||||||
|
the occurrences of your calls on theses routes with the GET method.
|
||||||
|
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
Domain command update :
|
||||||
|
|
||||||
|
The `--conftest` flag is now allowed when running the `domain` command, it dumps the current configuration as a TOML string.
|
||||||
|
|
||||||
|
`halfapi domain --conftest my_domain`
|
||||||
|
|
||||||
|
|
||||||
|
The `--dry-run` flag was buggy and is now fixed when using the `domai ` command with the `--run` flag.
|
||||||
|
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The `port` option in a `domain.my_domain` section in the TOML config file is now prefered to the one in the `project` section.
|
||||||
|
|
||||||
|
The `project` section is used as a default section for the whole configuration file. - Tests still have to be written -
|
||||||
|
|
||||||
|
The standard configuration precedence is fixed, in this order from the hight to the lower :
|
||||||
|
|
||||||
|
- Argument value (i.e. : --log-level)
|
||||||
|
- Environment value (i.e. : HALFAPI_LOGLEVEL)
|
||||||
|
- Configuration value under "domain" key
|
||||||
|
- Configuration value under "project" key
|
||||||
|
- Default configuration value given in the "DEFAULT_CONF" dictionary of halfapi/conf.py
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
Small cleanup of the logs levels. If you don't want the config to be dumped, just set the HALFAPI_LOGLEVEL to something different than "DEBUG".
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Check an ACL based on a decorator on "/halfapi/acls/MY_ACL"
|
||||||
|
|
||||||
|
## 0.6.27
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
- ACLs definition can now include a "public" parameter that defines if there should be an automatic creation of a route to check this acls
|
||||||
|
- /halfapi/acls does not return the "result", it just returns if there is a public route to check the ACL on /halfapi/acls/acl_name
|
||||||
|
=======
|
||||||
|
argument of starlette instead.
|
||||||
|
>>>>>>> a8c59c6 ([release] halfapi 0.6.27)
|
||||||
|
|
||||||
|
## 0.6.26
|
||||||
|
|
||||||
|
- Adds the "base_url", "cookies" and "url" to the "halfapi" argument of route definitions
|
||||||
|
|
||||||
|
## 0.6.25
|
||||||
|
|
||||||
|
- Deletes the "Authorization" cookie on authentication error
|
||||||
|
- Do not raise an exception on signature expiration, use "Nobody" user instead
|
||||||
|
|
||||||
|
## 0.6.24
|
||||||
|
|
||||||
|
- Uses the "Authorization" cookie to read authentication token additionnaly to the "Authorization" header
|
||||||
|
- CLI : allows to run a single domain using the "halfapi domain --run domain_name" command
|
||||||
|
|
||||||
|
## 0.6.23
|
||||||
|
|
||||||
|
Dependency update version
|
||||||
|
|
||||||
|
- starlette v0.23
|
||||||
|
- orjson v3.8.5
|
||||||
|
- click v8
|
||||||
|
- pyJWT v2.6
|
||||||
|
- pyYAML v6
|
||||||
|
- toml v0.10
|
||||||
|
|
||||||
|
## 0.6.22
|
||||||
|
|
||||||
|
- IMPORTANT : Fix bug introduced with 0.6.20 (fix arguments handling)
|
||||||
|
- BREAKING : A domain should now include it's meta datas in a "domain" dictionary located in the __init__.py file of the domain's root. Consider looking in 'tests/dummy_domain/__init__.py'
|
||||||
|
- Add *html* return type as default argument ret_type
|
||||||
|
- Add *txt* return type
|
||||||
|
- Log unhandled exceptions
|
||||||
|
- Log HTTPException with statuscode 500 as critical
|
||||||
|
- PyJWT >=2.4.0,<2.5.0
|
||||||
|
|
||||||
|
|
||||||
|
## 0.6.21
|
||||||
|
|
||||||
|
- Store only domain's config in halfapi['config']
|
||||||
|
- Should run halfapi domain with config_file argument
|
||||||
|
- Testing : You can specify a "MODULE" attribute to point out the path to the Api's base module
|
||||||
|
- Testing : You can specify a "CONFIG" attribute to set the domain's testing configuration
|
||||||
|
- Environment : HALFAPI_DOMAIN_MODULE can be set to specify Api's base module
|
||||||
|
- Config : 'module' attribute can be set to specify Api's base module
|
||||||
|
|
||||||
|
## 0.6.20
|
||||||
|
|
||||||
|
- Fix arguments handling
|
||||||
|
|
||||||
|
## 0.6.19
|
||||||
|
|
||||||
|
- Allow file sending in multipart request (#32)
|
||||||
|
- Add python-multipart dependency
|
||||||
|
|
||||||
|
## 0.6.18
|
||||||
|
|
||||||
|
- Fix config coming from .halfapi/config when using HALFAPI_DOMAIN_NAME environment variable
|
||||||
|
|
||||||
|
## 0.6.17
|
||||||
|
|
||||||
|
- Fix 0.6.16
|
||||||
|
- Errata : HALFAPI_DOMAIN is HALFAPI_DOMAIN_NAME
|
||||||
|
- Testing : You can now specify "MODULE" class attribute for your "test_domain"
|
||||||
|
subclasses
|
||||||
|
|
||||||
|
## 0.6.16
|
||||||
|
|
||||||
|
- The definition of "HALFAPI_DOMAIN_MODULE" environment variable allows to
|
||||||
|
specify the base module for a domain structure. It is formatted as a python
|
||||||
|
import path.
|
||||||
|
The "HALFAPI_DOMAIN" specifies the "name" of the module
|
||||||
|
|
||||||
|
## 0.6.15
|
||||||
|
|
||||||
|
- Allows to define a "__acl__" variable in the API module's __init__.py, to
|
||||||
|
specify how to import the acl lib. You can also specify "acl" in the domain's
|
||||||
|
config
|
||||||
|
|
||||||
|
## 0.6.14
|
||||||
|
|
||||||
|
- Add XLSXResponse (with format argument set to "xlsx"), to return .xlsx files
|
||||||
|
|
||||||
|
## 0.6.13
|
||||||
|
|
||||||
|
- (rollback from 0.6.12) Remove pytest from dependencies in Docker file and
|
||||||
|
remove tests
|
||||||
|
- (dep) Add "packaging" dependency
|
||||||
|
- Add dependency check capability when instantiating a domain (__deps__
|
||||||
|
variable, see in dummy_domain)
|
||||||
|
|
||||||
|
## 0.6.12
|
||||||
|
|
||||||
|
- Installs pytest with dependencies in Docker image, tests when building image
|
||||||
|
|
||||||
|
## 0.6.11
|
||||||
|
|
||||||
|
- Fix "request" has no "path_params" attribute bug
|
||||||
|
|
||||||
|
## 0.6.10
|
||||||
|
|
||||||
|
- Add "x-out" field in HTTP headers when "out" parameters are specified for a
|
||||||
|
route
|
||||||
|
- Add "out" kwarg for not-async functions that specify it
|
||||||
|
|
||||||
|
## 0.6.9
|
||||||
|
|
||||||
|
- Hide data fields in args_check logs
|
||||||
|
|
||||||
|
## 0.6.8
|
||||||
|
|
||||||
|
- Fix testing lib for domains (add default secret and debug option)
|
||||||
|
|
||||||
|
## 0.6.2
|
||||||
|
|
||||||
|
- Domains now need to include the following variables in their __init__.py
|
||||||
|
- __name__ (str, optional)
|
||||||
|
- __id__ (str, optional)
|
||||||
|
- halfapi domain
|
||||||
|
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1.0
|
||||||
|
|
||||||
- Mounts domain routers with their ACLs as decorator
|
- Mounts domain routers with their ACLs as decorator
|
||||||
|
|
|
@ -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
|
|
@ -1,4 +1,5 @@
|
||||||
Copyright (c) 2015-2020 Joël Maïzi <joel.maizi@collorg.org>
|
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
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
33
Pipfile
33
Pipfile
|
@ -4,12 +4,33 @@ url = "https://pypi.org/simple"
|
||||||
verify_ssl = true
|
verify_ssl = true
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
pytest = ">=7,<8"
|
||||||
|
requests = "*"
|
||||||
|
pytest-asyncio = "*"
|
||||||
|
pylint = "*"
|
||||||
|
build = "*"
|
||||||
|
twine = "*"
|
||||||
|
pyflakes = "*"
|
||||||
|
vulture = "*"
|
||||||
|
virtualenv = "*"
|
||||||
|
httpx = "*"
|
||||||
|
openapi-schema-validator = "*"
|
||||||
|
openapi-spec-validator = "*"
|
||||||
|
coverage = "*"
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
click = "*"
|
click = ">=8,<9"
|
||||||
starlette = "*"
|
starlette = ">=0.37,<0.38"
|
||||||
uvicorn = "*"
|
uvicorn = ">=0.13,<1"
|
||||||
jwt = "*"
|
orjson = ">=3.8.5,<4"
|
||||||
|
pyjwt = ">=2.6.0,<2.7.0"
|
||||||
|
pyyaml = ">=6.0.1,<7"
|
||||||
|
timing-asgi = ">=0.2.1,<1"
|
||||||
|
schema = ">=0.7.4,<1"
|
||||||
|
toml = ">=0.10,<0.11"
|
||||||
|
pip = "*"
|
||||||
|
packaging = ">=19.0"
|
||||||
|
python-multipart = "*"
|
||||||
|
|
||||||
[requires]
|
[scripts]
|
||||||
python_version = "3.8"
|
halfapi = "python -m halfapi"
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
128
README.md
128
README.md
|
@ -1,18 +1,9 @@
|
||||||
# HalfAPI
|
# HalfAPI
|
||||||
|
|
||||||
|
Base tools to develop complex API with rights management.
|
||||||
|
|
||||||
This Python-based ASGI application aims to provide the core functionality to
|
This project was developped by Maxime Alves and Joël Maïzi. The name was chosen
|
||||||
multiple API domains.
|
to reference [HalfORM](https://github.com/collorg/halfORM), a project written by Joël Maïzi.
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
You'll need a database with the API details. You can find the database model in halfapi/models/api.sql
|
|
||||||
|
|
||||||
**TODO :** include a token generation tool for testing purpose.
|
|
||||||
|
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
@ -20,67 +11,94 @@ You'll need a database with the API details. You can find the database model in
|
||||||
- python3
|
- python3
|
||||||
- python3-pip
|
- python3-pip
|
||||||
- libgit2-dev
|
- libgit2-dev
|
||||||
|
- starlette
|
||||||
|
- PyJWT
|
||||||
|
- click
|
||||||
|
- uvicorn
|
||||||
|
- orjson
|
||||||
|
- pyyaml
|
||||||
|
|
||||||
|
|
||||||
## Installing
|
## Configuration
|
||||||
|
|
||||||
|
Configure HalfAPI in the file : .halfapi/config .
|
||||||
|
|
||||||
|
It's a **toml** file that contains at least two sections, project and domains.
|
||||||
|
|
||||||
|
https://toml.io/en/
|
||||||
|
|
||||||
|
|
||||||
With local folder :
|
### Project
|
||||||
|
|
||||||
`pip3 install -r requirements.txt .[cli]`
|
The main configuration options without which HalfAPI cannot be run.
|
||||||
|
|
||||||
|
**secret** : The file containing the secret to decode the user's tokens.
|
||||||
|
|
||||||
|
**port** : The port for the test server.
|
||||||
|
|
||||||
|
**loglevel** : The log level (info, debug, critical, ...)
|
||||||
|
|
||||||
|
|
||||||
From the repository :
|
### Domains
|
||||||
|
|
||||||
`pip3 install git+ssh://git@gite.lirmm.fr:malves/halfapi.git[cli]`
|
|
||||||
|
|
||||||
|
|
||||||
## CLI usage
|
|
||||||
|
|
||||||
|
|
||||||
## Running
|
|
||||||
|
|
||||||
### Development mode
|
|
||||||
|
|
||||||
In the project's folder :
|
|
||||||
|
|
||||||
`halfapi run`
|
|
||||||
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
The production server may use different init systems. As our main server is Debian-based, we use systemd services to manage the api server. Find the right service files and configure them properly in order to make your production setup perfect.
|
|
||||||
|
|
||||||
|
Specify the domains configurations in the following form :
|
||||||
|
|
||||||
```
|
```
|
||||||
cp conf/systemd/lirmm_api* /etc/systemd/system/
|
[domains.DOMAIN_NAME]
|
||||||
systemctl daemon-reload
|
name = "DOMAIN_NAME"
|
||||||
systemctl start lirmm_api
|
enabled = true
|
||||||
|
prefix = "/prefix"
|
||||||
|
module = "domain_name.path.to.api.root"
|
||||||
|
port = 1002
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Specific configuration can be done under the "config" section :
|
||||||
To make it start at boot :
|
|
||||||
|
|
||||||
|
|
||||||
`systemctl enable lirmm_api`
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
To get the logs :
|
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
journalctl -f --unit lirmm_api
|
[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.
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Installing
|
## Usage
|
||||||
|
|
||||||
pip3 install .[cli][tests]
|
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.
|
||||||
|
|
||||||
### Running
|
Run the project by using the `halfapi run` command.
|
||||||
|
|
||||||
pytest -v
|
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
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
__version__ = '0.1.0'
|
__version__ = '0.6.30'
|
||||||
|
|
||||||
def version():
|
def version():
|
||||||
return f'HalfAPI version:{__version__}'
|
return f'HalfAPI version:{__version__}'
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .cli.cli import cli
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli()
|
|
@ -1,45 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
import os
|
||||||
# asgi framework
|
from .halfapi import HalfAPI
|
||||||
from starlette.applications import Starlette
|
from .logging import logger
|
||||||
from starlette.authentication import UnauthenticatedUser
|
|
||||||
from starlette.middleware import Middleware
|
|
||||||
from starlette.responses import Response, JSONResponse
|
|
||||||
from starlette.routing import Route
|
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
||||||
|
|
||||||
# typing
|
def application():
|
||||||
from typing import Any, Awaitable, Callable, MutableMapping
|
from .conf import CONFIG
|
||||||
|
return HalfAPI(CONFIG).application
|
||||||
# module libraries
|
|
||||||
from halfapi.conf import HOST, PORT, DB_NAME, SECRET, PRODUCTION
|
|
||||||
|
|
||||||
from halfapi.lib.jwt_middleware import JWTAuthenticationBackend
|
|
||||||
|
|
||||||
from halfapi.lib.responses import *
|
|
||||||
from halfapi.lib.routes import get_routes
|
|
||||||
|
|
||||||
|
|
||||||
debug_routes = [
|
|
||||||
Route('/', lambda request, *args, **kwargs: PlainTextResponse('It Works!')),
|
|
||||||
Route('/user', lambda request, *args, **kwargs:
|
|
||||||
JSONResponse({'user':request.user.json})
|
|
||||||
if type(request.user) != UnauthenticatedUser
|
|
||||||
else JSONResponse({'user':False})),
|
|
||||||
Route('/payload', lambda request, *args, **kwargs:
|
|
||||||
JSONResponse({'payload':str(request.payload)}))
|
|
||||||
] if not PRODUCTION else []
|
|
||||||
|
|
||||||
application = Starlette(
|
|
||||||
debug=not PRODUCTION,
|
|
||||||
routes=debug_routes + get_routes(),
|
|
||||||
middleware=[
|
|
||||||
Middleware(AuthenticationMiddleware,
|
|
||||||
backend=JWTAuthenticationBackend(secret_key=SECRET))
|
|
||||||
],
|
|
||||||
exception_handlers={
|
|
||||||
401: UnauthorizedResponse,
|
|
||||||
404: NotFoundResponse,
|
|
||||||
500: InternalServerErrorResponse,
|
|
||||||
501: NotImplementedResponse
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
#!/usr/bin/env
|
|
|
@ -1,20 +1,28 @@
|
||||||
#!/usr/bin/env python3
|
#!/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
|
# builtins
|
||||||
import click
|
import click
|
||||||
from halfapi.conf import IS_PROJECT
|
|
||||||
|
|
||||||
@click.group(invoke_without_command=True)
|
@click.group(invoke_without_command=True)
|
||||||
@click.option('--version', is_flag=True)
|
@click.option('--version', is_flag=True)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx, version):
|
def cli(ctx, version):
|
||||||
|
"""
|
||||||
|
HalfAPI Cli entry point
|
||||||
|
|
||||||
|
It uses the Click library
|
||||||
|
"""
|
||||||
if version:
|
if version:
|
||||||
import halfapi
|
from halfapi import version
|
||||||
return click.echo(halfapi.version())
|
click.echo(version())
|
||||||
|
|
||||||
if IS_PROJECT:
|
from . import config
|
||||||
import halfapi.cli.config
|
from . import domain
|
||||||
import halfapi.cli.domain
|
from . import run
|
||||||
import halfapi.cli.run
|
from . import init
|
||||||
|
from . import routes
|
||||||
else:
|
|
||||||
import halfapi.cli.init
|
|
||||||
|
|
|
@ -1,34 +1,31 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
cli/config.py Contains the .halfapi/config
|
||||||
|
|
||||||
|
Defines the "halfapi config" command
|
||||||
|
"""
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from .cli import cli
|
from .cli import cli
|
||||||
from halfapi.conf import (
|
from ..conf import CONFIG
|
||||||
PROJECT_NAME,
|
|
||||||
DOMAINS,
|
|
||||||
CONF_DIR,
|
|
||||||
HOST,
|
|
||||||
PORT,
|
|
||||||
DB_NAME,
|
|
||||||
PRODUCTION,
|
|
||||||
BASE_DIR
|
|
||||||
)
|
|
||||||
|
|
||||||
CONF_STR=f"""
|
DOMAIN_CONF_STR="""
|
||||||
|
[domain]
|
||||||
|
name = {name}
|
||||||
|
router = {router}
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONF_STR="""
|
||||||
[project]
|
[project]
|
||||||
name = {PROJECT_NAME}
|
host = {host}
|
||||||
host = {HOST}
|
port = {port}
|
||||||
port = {PORT}
|
production = {production}
|
||||||
production = {PRODUCTION}
|
"""
|
||||||
base_dir = {BASE_DIR}
|
|
||||||
|
|
||||||
[domains]"""
|
|
||||||
|
|
||||||
for dom in DOMAINS:
|
|
||||||
CONF_STR = '\n'.join((CONF_STR, dom))
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
def config():
|
def config():
|
||||||
"""
|
"""
|
||||||
Lists config parameters and their values
|
Lists config parameters and their values
|
||||||
"""
|
"""
|
||||||
click.echo(CONF_STR)
|
click.echo(CONF_STR.format(**CONFIG))
|
||||||
|
|
|
@ -1,303 +1,242 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
cli/domain.py Defines the "halfapi domain" cli commands
|
||||||
|
"""
|
||||||
# builtins
|
# builtins
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import logging
|
|
||||||
import click
|
|
||||||
import importlib
|
import importlib
|
||||||
from .cli import cli
|
import subprocess
|
||||||
from halfapi.conf import DOMAINS, BASE_DIR
|
|
||||||
from halfapi.db import (
|
|
||||||
Domain,
|
|
||||||
APIRouter,
|
|
||||||
APIRoute,
|
|
||||||
AclFunction,
|
|
||||||
Acl)
|
|
||||||
|
|
||||||
logger = logging.getLogger('halfapi')
|
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 #
|
# domain create #
|
||||||
#################
|
#################
|
||||||
def create_domain():
|
def create_domain(domain_name: str, module_path: str):
|
||||||
sys.exit(0)
|
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 #
|
# domain read #
|
||||||
###############
|
###############
|
||||||
def list_routes(domain):
|
def list_routes(domain, m_dom):
|
||||||
click.echo(f'\nDomain : {domain}')
|
|
||||||
routers = APIRouter(domain=domain)
|
|
||||||
for router in routers.select():
|
|
||||||
routes = APIRoute(domain=domain, router=router['name'])
|
|
||||||
click.echo('# /{name}'.format(**router))
|
|
||||||
for route in routes.select():
|
|
||||||
route.pop('fct_name')
|
|
||||||
acls = ', '.join([ acl['acl_fct_name'] for acl in Acl(**route).select() ])
|
|
||||||
route['acls'] = acls
|
|
||||||
click.echo('- [{http_verb}] {path} ({acls})'.format(**route))
|
|
||||||
|
|
||||||
#################
|
|
||||||
# domain update #
|
|
||||||
#################
|
|
||||||
def update_db(domain):
|
|
||||||
|
|
||||||
def add_domain():
|
|
||||||
"""
|
"""
|
||||||
Inserts Domain into database
|
Echoes the list of the **m_dom** active routes
|
||||||
"""
|
|
||||||
new_domain = Domain(name=domain)
|
|
||||||
if len(new_domain) == 0:
|
|
||||||
click.echo(f'New domain {domain}')
|
|
||||||
new_domain.insert()
|
|
||||||
|
|
||||||
|
|
||||||
def add_router(name):
|
|
||||||
"""
|
|
||||||
Inserts Router into database
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- name (str): The Router's name
|
|
||||||
"""
|
|
||||||
router = APIRouter()
|
|
||||||
router.name = name
|
|
||||||
router.domain = domain
|
|
||||||
|
|
||||||
if len(router) == 0:
|
|
||||||
router.insert()
|
|
||||||
|
|
||||||
|
|
||||||
def add_acl_fct(fct):
|
|
||||||
"""
|
|
||||||
Inserts ACL function into database
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- fct (Callable): The ACL function reference
|
|
||||||
"""
|
|
||||||
acl = AclFunction()
|
|
||||||
acl.domain = domain
|
|
||||||
acl.name = fct.__name__
|
|
||||||
if len(acl) == 0:
|
|
||||||
acl.insert()
|
|
||||||
|
|
||||||
|
|
||||||
def add_acls(acls, **route):
|
|
||||||
"""
|
|
||||||
Inserts ACL into database
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- acls (List[Callable]): List of the Route's ACL's
|
|
||||||
- route (dict): The Route
|
|
||||||
"""
|
|
||||||
route.pop('fct_name')
|
|
||||||
acl = Acl(**route)
|
|
||||||
|
|
||||||
for fct in acls:
|
|
||||||
acl.acl_fct_name = fct.__name__
|
|
||||||
|
|
||||||
if len(acl) == 0:
|
|
||||||
if fct is not None:
|
|
||||||
add_acl_fct(fct)
|
|
||||||
|
|
||||||
acl.insert()
|
|
||||||
|
|
||||||
elif fct is None:
|
|
||||||
acl.delete()
|
|
||||||
|
|
||||||
|
|
||||||
def get_fct_name(http_verb, path):
|
|
||||||
"""
|
|
||||||
Returns the predictable name of the function for a route
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- http_verb (str): The Route's HTTP method (GET, POST, ...)
|
|
||||||
- path (str): A path beginning by '/' for the route
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The *unique* function name for a route and it's verb
|
|
||||||
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
>>> get_fct_name('foo', 'bar')
|
|
||||||
Traceback (most recent call last):
|
|
||||||
...
|
|
||||||
Exception: Malformed path
|
|
||||||
|
|
||||||
>>> get_fct_name('get', '/')
|
|
||||||
'get_'
|
|
||||||
|
|
||||||
>>> get_fct_name('GET', '/')
|
|
||||||
'get_'
|
|
||||||
|
|
||||||
>>> get_fct_name('POST', '/foo')
|
|
||||||
'post_foo'
|
|
||||||
|
|
||||||
>>> get_fct_name('POST', '/foo/bar')
|
|
||||||
'post_foo_bar'
|
|
||||||
|
|
||||||
>>> get_fct_name('DEL', '/foo/{boo}/{far}/bar')
|
|
||||||
'del_foo_BOO_FAR_bar'
|
|
||||||
|
|
||||||
>>> get_fct_name('DEL', '/foo/{boo:zoo}')
|
|
||||||
'del_foo_BOO'
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if path[0] != '/':
|
click.echo(f'\nDomain : {domain}\n')
|
||||||
raise Exception('Malformed path')
|
|
||||||
|
|
||||||
elts = path[1:].split('/')
|
routes = api_routes(m_dom)[0]
|
||||||
|
if len(routes):
|
||||||
fct_name = [http_verb.lower()]
|
for key, item in routes.items():
|
||||||
for elt in elts:
|
methods = '|'.join(list(item.keys()))
|
||||||
if elt and elt[0] == '{':
|
click.echo(f'\t{key} : {methods}')
|
||||||
fct_name.append(elt[1:-1].split(':')[0].upper())
|
|
||||||
else:
|
else:
|
||||||
fct_name.append(elt)
|
click.echo('\t**No ROUTES**')
|
||||||
|
raise Exception('Routeless domain')
|
||||||
return '_'.join(fct_name)
|
|
||||||
|
|
||||||
|
|
||||||
def add_route(http_verb, path, router, acls):
|
|
||||||
|
def list_api_routes():
|
||||||
"""
|
"""
|
||||||
Inserts Route into database
|
Echoes the list of all active domains.
|
||||||
|
|
||||||
Parameters:
|
TODO: Rewrite function
|
||||||
- http_verb (str): The Route's HTTP method (GET, POST, ...)
|
|
||||||
- path (str): A path beginning by '/' for the route
|
|
||||||
- router (str): The Route's Router name
|
|
||||||
- acls (List[Callable]): The list of ACL functions for this Route
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
click.echo(f'Adding route /{domain}/{router}{path}')
|
click.echo('# API Routes')
|
||||||
|
# for domain, m_dom in DOMAINSDICT().items():
|
||||||
route = APIRoute()
|
# list_routes(domain, m_dom)
|
||||||
# Route definition
|
|
||||||
route.http_verb = http_verb
|
|
||||||
route.path = path
|
|
||||||
route.fct_name = get_fct_name(http_verb, path)
|
|
||||||
route.router = router
|
|
||||||
route.domain = domain
|
|
||||||
|
|
||||||
if len(route) == 0:
|
|
||||||
route.insert()
|
|
||||||
|
|
||||||
add_acls(acls, **route.to_dict())
|
|
||||||
|
|
||||||
|
|
||||||
sys.path.insert(0, BASE_DIR)
|
|
||||||
|
|
||||||
# Reset Domain relations
|
|
||||||
delete_domain(domain)
|
|
||||||
|
|
||||||
acl_set = set()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Module retrieval
|
|
||||||
dom_mod = importlib.import_module(domain)
|
|
||||||
except ImportError:
|
|
||||||
# Domain is not available in current PYTHONPATH
|
|
||||||
click.echo(f"Can't import *{domain}*", err=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
add_domain()
|
|
||||||
except Exception as e:
|
|
||||||
# Could not insert Domain
|
|
||||||
# @TODO : Insertion exception handling
|
|
||||||
click.echo(e)
|
|
||||||
click.echo(f"Could not insert *{domain}*", err=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# add sub routers
|
|
||||||
try:
|
|
||||||
ROUTERS = dom_mod.ROUTERS
|
|
||||||
except AttributeError:
|
|
||||||
# No ROUTERS variable in current domain, check domain/__init__.py
|
|
||||||
click.echo(f'The domain {domain} has no *ROUTERS* variable', err=True)
|
|
||||||
|
|
||||||
for router_name in dom_mod.ROUTERS:
|
|
||||||
try:
|
|
||||||
router_mod = getattr(dom_mod.routers, router_name)
|
|
||||||
except AttributError:
|
|
||||||
# Missing router, continue
|
|
||||||
click.echo(f'The domain {domain} has no *{router_name}* router', err=True)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
add_router(router_name)
|
|
||||||
except Exception as e:
|
|
||||||
# Could not insert Router
|
|
||||||
# @TODO : Insertion exception handling
|
|
||||||
print(e)
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
for route_path, route_params in router_mod.ROUTES.items():
|
|
||||||
for http_verb, acls in route_params.items():
|
|
||||||
try:
|
|
||||||
# Insert a route and it's ACLS
|
|
||||||
add_route(http_verb, route_path, router_name, acls)
|
|
||||||
except Exception as e:
|
|
||||||
# Could not insert route
|
|
||||||
# @TODO : Insertion exception handling
|
|
||||||
print(e)
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
#################
|
|
||||||
# domain delete #
|
|
||||||
#################
|
|
||||||
def delete_domain(domain):
|
|
||||||
d = Domain(name=domain)
|
|
||||||
if len(d) != 1:
|
|
||||||
return False
|
|
||||||
|
|
||||||
d.delete(delete_all=True)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
|
@click.option('--devel',default=None, is_flag=True)
|
||||||
|
@click.option('--watch',default=False, is_flag=True)
|
||||||
|
@click.option('--production',default=None, is_flag=True)
|
||||||
|
@click.option('--port',default=None, type=int)
|
||||||
|
@click.option('--log-level',default=None, type=str)
|
||||||
|
@click.option('--dry-run',default=False, is_flag=True)
|
||||||
|
@click.option('--run',default=False, is_flag=True)
|
||||||
@click.option('--read',default=False, is_flag=True)
|
@click.option('--read',default=False, is_flag=True)
|
||||||
|
@click.option('--conftest',default=False, is_flag=True)
|
||||||
@click.option('--create',default=False, is_flag=True)
|
@click.option('--create',default=False, is_flag=True)
|
||||||
@click.option('--update',default=False, is_flag=True)
|
@click.option('--update',default=False, is_flag=True)
|
||||||
@click.option('--delete',default=False, is_flag=True)
|
@click.option('--delete',default=False, is_flag=True)
|
||||||
@click.option('--domains',default=None)
|
@click.argument('config_file', type=click.File(mode='rb'), required=False)
|
||||||
|
@click.argument('domain',default=None, required=False)
|
||||||
@cli.command()
|
@cli.command()
|
||||||
def domain(domains, delete, update, create, read): #, domains, read, create, update, delete):
|
def domain(domain, config_file, delete, update, create, conftest, read, run, dry_run, log_level, port, production, watch, devel):
|
||||||
"""
|
"""
|
||||||
Lists routes for the specified domains, or update them in the database
|
The "halfapi domain" command
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
domain (List[str]|None): The list of the domains to list/update
|
domain (str|None): The domain name
|
||||||
|
|
||||||
The parameter has a misleading name as it is a multiple option
|
The parameter has a misleading name as it is a multiple option
|
||||||
but this would be strange to use it several times named as "domains"
|
but this would be strange to use it several times named as "domains"
|
||||||
|
|
||||||
update (boolean): If set, update the database for the selected domains
|
update (boolean): If set, update the database for the selected domains
|
||||||
"""
|
"""
|
||||||
|
if not domain:
|
||||||
if not domains:
|
|
||||||
if create:
|
if create:
|
||||||
return create_domain()
|
# 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)
|
||||||
|
)
|
||||||
|
|
||||||
domains = DOMAINS
|
|
||||||
else:
|
else:
|
||||||
domains_ = []
|
if dry_run:
|
||||||
for domain_name in domains.split(','):
|
CONFIG['dryrun'] = True
|
||||||
if domain_name in DOMAINS:
|
|
||||||
domains.append(domain_name)
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
domains = CONFIG.get('domain')
|
||||||
|
for key in domains.keys():
|
||||||
|
if key != domain:
|
||||||
|
domains[key]['enabled'] = False
|
||||||
|
else:
|
||||||
|
domains[key]['enabled'] = True
|
||||||
|
|
||||||
|
if not log_level:
|
||||||
|
log_level = CONFIG.get('domain', {}).get('loglevel', CONFIG.get('loglevel', False))
|
||||||
|
else:
|
||||||
|
CONFIG['loglevel'] = log_level
|
||||||
|
|
||||||
|
if not port:
|
||||||
|
port = CONFIG.get('domain', {}).get('port', CONFIG.get('port', False))
|
||||||
|
else:
|
||||||
|
CONFIG['port'] = port
|
||||||
|
|
||||||
|
if devel is None and production is not None and (production is False or production is True):
|
||||||
|
CONFIG['production'] = production
|
||||||
|
|
||||||
|
if devel is not None:
|
||||||
|
CONFIG['production'] = False
|
||||||
|
CONFIG['loglevel'] = 'debug'
|
||||||
|
|
||||||
|
|
||||||
|
if conftest:
|
||||||
click.echo(
|
click.echo(
|
||||||
f'Domain {domain_name}s is not activated in the configuration')
|
toml.dumps(CONFIG)
|
||||||
|
)
|
||||||
|
|
||||||
domains = domains_
|
|
||||||
|
|
||||||
for domain in domains:
|
|
||||||
if update:
|
|
||||||
update_db(domain)
|
|
||||||
if delete:
|
|
||||||
delete_domain(domain)
|
|
||||||
else:
|
else:
|
||||||
list_routes(domain)
|
# 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)
|
||||||
|
|
|
@ -1,19 +1,24 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
cli/init.py Defines the "halfapi init" cli commands
|
||||||
|
|
||||||
|
Helps the user to create a new project
|
||||||
|
"""
|
||||||
# builtins
|
# builtins
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
import click
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from halfapi import __version__
|
import click
|
||||||
from halfapi.cli.lib.db import ProjectDB
|
|
||||||
|
from .. import __version__
|
||||||
|
from ..conf import CONF_DIR
|
||||||
|
|
||||||
from .cli import cli
|
from .cli import cli
|
||||||
|
|
||||||
logger = logging.getLogger('halfapi')
|
from ..logging import logger
|
||||||
|
|
||||||
TMPL_HALFAPI_ETC = """[project]
|
TMPL_HALFAPI_ETC = """[project]
|
||||||
name = {project}
|
|
||||||
host = 127.0.0.1
|
host = 127.0.0.1
|
||||||
port = 8000
|
port = 8000
|
||||||
secret = /path/to/secret_file
|
secret = /path/to/secret_file
|
||||||
|
@ -21,20 +26,18 @@ production = False
|
||||||
base_dir = {base_dir}
|
base_dir = {base_dir}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def format_halfapi_etc(project, path):
|
|
||||||
return TMPL_HALFAPI_ETC.format(
|
|
||||||
project=project,
|
|
||||||
base_dir=path
|
|
||||||
)
|
|
||||||
|
|
||||||
TMPL_HALFAPI_CONFIG = """[project]
|
TMPL_HALFAPI_CONFIG = """[project]
|
||||||
name = {name}
|
|
||||||
halfapi_version = {halfapi_version}
|
halfapi_version = {halfapi_version}
|
||||||
|
|
||||||
|
[domain]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@click.argument('project')
|
@click.argument('project')
|
||||||
@cli.command()
|
@cli.command()
|
||||||
def init(project):
|
def init(project):
|
||||||
|
"""
|
||||||
|
The "halfapi init" command
|
||||||
|
"""
|
||||||
if not re.match('^[a-z0-9_]+$', project, re.I):
|
if not re.match('^[a-z0-9_]+$', project, re.I):
|
||||||
click.echo('Project name must match "^[a-z0-9_]+$", retry.', err=True)
|
click.echo('Project name must match "^[a-z0-9_]+$", retry.', err=True)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
@ -44,28 +47,15 @@ def init(project):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
click.echo(f'create directory {project}')
|
logger.debug('Create directory %s', project)
|
||||||
os.mkdir(project)
|
os.mkdir(project)
|
||||||
|
|
||||||
try:
|
logger.debug('Create directory %s/.halfapi', project)
|
||||||
pdb = ProjectDB(project)
|
os.mkdir(f'{project}/.halfapi')
|
||||||
pdb.init()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
logger.debug(os.environ.get('HALFORM_CONF_DIR'))
|
|
||||||
raise e
|
|
||||||
|
|
||||||
os.mkdir(os.path.join(project, '.halfapi'))
|
with open(f'{project}/.halfapi/config', 'w') as conf_file:
|
||||||
open(os.path.join(project, '.halfapi', 'domains'), 'w').write('[domains]\n')
|
conf_file.write(TMPL_HALFAPI_CONFIG.format(
|
||||||
config_file = os.path.join(project, '.halfapi', 'config')
|
halfapi_version=__version__))
|
||||||
with open(config_file, 'w') as f:
|
|
||||||
f.write(TMPL_HALFAPI_CONFIG.format(
|
|
||||||
name=project,
|
|
||||||
halfapi_version=__version__
|
|
||||||
))
|
|
||||||
|
|
||||||
click.echo(f'Insert this into the HALFAPI_CONF_DIR/{project} file')
|
|
||||||
click.echo(format_halfapi_etc(
|
|
||||||
project,
|
|
||||||
os.path.abspath(project)))
|
|
||||||
|
|
||||||
|
click.echo(f'Configure halfapi project in {CONF_DIR}/{project}')
|
||||||
|
|
|
@ -1,115 +0,0 @@
|
||||||
import click
|
|
||||||
from half_orm.model import Model, CONF_DIR
|
|
||||||
from half_orm.model_errors import MissingConfigFile
|
|
||||||
import psycopg2
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
_DB_SCHEMA = """
|
|
||||||
create schema api;
|
|
||||||
|
|
||||||
create type verb as enum ('POST', 'GET', 'PUT', 'DELETE');
|
|
||||||
|
|
||||||
create table api.domain (
|
|
||||||
name text,
|
|
||||||
primary key (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
create table api.router (
|
|
||||||
name text,
|
|
||||||
domain text,
|
|
||||||
primary key (name, domain)
|
|
||||||
);
|
|
||||||
|
|
||||||
alter table api.router add constraint router_domain_fkey foreign key (domain) references api.domain(name) on update cascade on delete cascade;
|
|
||||||
|
|
||||||
create table api.route (
|
|
||||||
http_verb verb,
|
|
||||||
path text, -- relative to /<domain>/<router>
|
|
||||||
fct_name text,
|
|
||||||
router text,
|
|
||||||
domain text,
|
|
||||||
primary key (http_verb, path, router, domain)
|
|
||||||
);
|
|
||||||
|
|
||||||
alter table api.route add constraint route_router_fkey foreign key (router, domain) references api.router(name, domain) on update cascade on delete cascade;
|
|
||||||
|
|
||||||
create table api.acl_function (
|
|
||||||
name text,
|
|
||||||
description text,
|
|
||||||
domain text,
|
|
||||||
primary key (name, domain)
|
|
||||||
);
|
|
||||||
|
|
||||||
alter table api.acl_function add constraint acl_function_domain_fkey foreign key (domain) references api.domain(name) on update cascade on delete cascade;
|
|
||||||
|
|
||||||
create table api.acl (
|
|
||||||
http_verb verb,
|
|
||||||
path text not null,
|
|
||||||
router text,
|
|
||||||
domain text,
|
|
||||||
acl_fct_name text,
|
|
||||||
primary key (http_verb, path, router, domain, acl_fct_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
alter table api.acl add constraint acl_route_fkey foreign key (http_verb, path,
|
|
||||||
router, domain) references api.route(http_verb, path, router, domain) on update cascade on delete cascade;
|
|
||||||
alter table api.acl add constraint acl_function_fkey foreign key (acl_fct_name, domain) references api.acl_function(name, domain) on update cascade on delete cascade;
|
|
||||||
|
|
||||||
create schema "api.view";
|
|
||||||
|
|
||||||
create view "api.view".route as
|
|
||||||
select
|
|
||||||
route.*,
|
|
||||||
'/'::text || route.domain || '/'::text || route.router || route.path AS abs_path
|
|
||||||
from
|
|
||||||
api.route
|
|
||||||
join api.domain on
|
|
||||||
route.domain = domain.name
|
|
||||||
;
|
|
||||||
|
|
||||||
create view "api.view".acl as
|
|
||||||
select
|
|
||||||
acl.*,
|
|
||||||
'/'::text || route.domain || '/'::text || route.router || route.path AS abs_path
|
|
||||||
from
|
|
||||||
api.acl
|
|
||||||
join api.acl_function on
|
|
||||||
acl.acl_fct_name = acl_function.name
|
|
||||||
join api.route on
|
|
||||||
acl.domain = route.domain
|
|
||||||
and acl.router = route.router
|
|
||||||
and acl.path = route.path;
|
|
||||||
"""
|
|
||||||
|
|
||||||
HOP_CONF = """[database]
|
|
||||||
name = {}
|
|
||||||
"""
|
|
||||||
|
|
||||||
class ProjectDB:
|
|
||||||
def __init__(self, project_name):
|
|
||||||
self.__project_name = project_name
|
|
||||||
self.__db_name = f'halfapi_{self.__project_name}'
|
|
||||||
self.__db = self._get_db()
|
|
||||||
|
|
||||||
def _get_db(self):
|
|
||||||
from subprocess import PIPE
|
|
||||||
hop_conf_file = f'{CONF_DIR}/{self.__db_name}'
|
|
||||||
try:
|
|
||||||
return Model(self.__db_name)
|
|
||||||
except psycopg2.OperationalError as err:
|
|
||||||
"créer la base de données"
|
|
||||||
ret = subprocess.run(['/usr/bin/createdb', self.__db_name])
|
|
||||||
if ret.returncode != 0:
|
|
||||||
raise Exception(f"Can't create {self.__db_name}")
|
|
||||||
except MissingConfigFile:
|
|
||||||
print(f"demande validation de {CONF_DIR} {self.__db_name}")
|
|
||||||
print("demande validation création de fichier de CONF")
|
|
||||||
open(hop_conf_file, 'w').write(HOP_CONF.format(self.__db_name))
|
|
||||||
return self._get_db()
|
|
||||||
|
|
||||||
def init(self):
|
|
||||||
"""
|
|
||||||
"""
|
|
||||||
self.__db.execute_query(_DB_SCHEMA)
|
|
||||||
self.__db._connection.close()
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,36 +1,87 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
cli/domain.py Defines the "halfapi run" cli command
|
||||||
|
"""
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import click
|
import click
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from .cli import cli
|
from .cli import cli
|
||||||
from halfapi.cli.domain import list_routes
|
from .domain import list_api_routes
|
||||||
from halfapi.conf import (HOST, PORT,
|
from ..conf import CONFIG, SCHEMA
|
||||||
PRODUCTION, BASE_DIR, DOMAINS)
|
from ..logging import logger
|
||||||
|
from ..lib.schemas import schema_csv_dict
|
||||||
|
from ..half_domain import HalfDomain
|
||||||
|
|
||||||
@click.option('--host', default=None)
|
@click.option('--host', default=CONFIG.get('host'))
|
||||||
@click.option('--port', default=None)
|
@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()
|
@cli.command()
|
||||||
def run(host, port):
|
def run(host, port, reload, secret, production, loglevel, prefix, check, dryrun,
|
||||||
|
schema, domain):
|
||||||
if not host:
|
"""
|
||||||
host = HOST
|
The "halfapi run" command
|
||||||
|
"""
|
||||||
if not port:
|
logger.debug('[run] host=%s port=%s reload=%s secret=%s production=%s loglevel=%s prefix=%s schema=%s',
|
||||||
port = PORT
|
host, port, reload, secret, production, loglevel, prefix, schema
|
||||||
|
)
|
||||||
|
|
||||||
port = int(port)
|
port = int(port)
|
||||||
|
|
||||||
debug = reload = not PRODUCTION
|
if production and reload:
|
||||||
log_level = 'info' if PRODUCTION else 'debug'
|
reload = False
|
||||||
|
raise Exception('Can\'t use live code reload in production')
|
||||||
|
|
||||||
click.echo('Launching application')
|
click.echo(f'Launching application')
|
||||||
|
|
||||||
sys.path.insert(0, BASE_DIR)
|
if secret:
|
||||||
|
CONFIG['secret'] = secret
|
||||||
|
|
||||||
[ list_routes(domain) for domain in DOMAINS ]
|
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',
|
uvicorn.run('halfapi.app:application',
|
||||||
host=host,
|
host=host,
|
||||||
port=int(port),
|
port=int(port),
|
||||||
log_level=log_level,
|
log_level=loglevel,
|
||||||
reload=reload)
|
reload=reload)
|
||||||
|
|
228
halfapi/conf.py
228
halfapi/conf.py
|
@ -1,61 +1,205 @@
|
||||||
#!/usr/bin/env python3
|
#!/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
|
import os
|
||||||
from os import environ
|
from os import environ
|
||||||
import sys
|
import sys
|
||||||
from configparser import ConfigParser
|
import importlib
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
IS_PROJECT = os.path.isfile('.halfapi/config')
|
import toml
|
||||||
|
|
||||||
if IS_PROJECT:
|
SCHEMA = {}
|
||||||
|
|
||||||
default_config = {
|
DEFAULT_CONF = {
|
||||||
'project': {
|
# Default configuration values
|
||||||
'host': '127.0.0.1',
|
'SECRET': tempfile.mkstemp()[1],
|
||||||
'port': '8000',
|
'PROJECT_NAME': os.getcwd().split('/')[-1],
|
||||||
'secret': '',
|
'PRODUCTION': True,
|
||||||
'base_dir': '',
|
'HOST': '127.0.0.1',
|
||||||
'production': 'no'
|
'PORT': 3000,
|
||||||
}
|
'LOGLEVEL': 'info',
|
||||||
|
'BASE_DIR': os.getcwd(),
|
||||||
|
'CONF_FILE': '.halfapi/config',
|
||||||
|
'CONF_DIR': '/etc/half_api',
|
||||||
|
'DRYRUN': None
|
||||||
}
|
}
|
||||||
|
|
||||||
config = ConfigParser(allow_no_value=True)
|
PROJECT_LEVEL_KEYS = {
|
||||||
config.read_dict(default_config)
|
# Allowed keys in "project" section of configuration file
|
||||||
config.read(filenames=['.halfapi/config'])
|
'project_name',
|
||||||
|
'production',
|
||||||
|
'secret',
|
||||||
|
'host',
|
||||||
|
'port',
|
||||||
|
'loglevel',
|
||||||
|
'dryrun'
|
||||||
|
}
|
||||||
|
|
||||||
PROJECT_NAME = config.get('project', 'name')
|
DOMAIN_LEVEL_KEYS = PROJECT_LEVEL_KEYS | {
|
||||||
|
# Allowed keys in "domain" section of configuration file
|
||||||
|
'name',
|
||||||
|
'module',
|
||||||
|
'prefix',
|
||||||
|
'enabled'
|
||||||
|
}
|
||||||
|
|
||||||
if len(PROJECT_NAME) == 0:
|
CONF_FILE = os.environ.get('HALFAPI_CONF_FILE', DEFAULT_CONF['CONF_FILE'])
|
||||||
raise Exception('Need a project name as argument')
|
CONF_DIR = os.environ.get('HALFAPI_CONF_DIR', DEFAULT_CONF['CONF_DIR'])
|
||||||
|
|
||||||
DOMAINS = [domain for domain, _ in config.items('domains')] \
|
HALFAPI_ETC_FILE=os.path.join(
|
||||||
if config.has_section('domains') \
|
CONF_DIR, 'config'
|
||||||
else []
|
|
||||||
|
|
||||||
CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api')
|
|
||||||
|
|
||||||
HALFAPI_CONF_FILE=os.path.join(
|
|
||||||
CONF_DIR,
|
|
||||||
PROJECT_NAME
|
|
||||||
)
|
)
|
||||||
if not os.path.isfile(HALFAPI_CONF_FILE):
|
|
||||||
print(f'Missing {HALFAPI_CONF_FILE}, exiting')
|
|
||||||
sys.exit(1)
|
|
||||||
config.read(filenames=[HALFAPI_CONF_FILE])
|
|
||||||
|
|
||||||
HOST = config.get('project', 'host')
|
BASE_DIR = os.environ.get('HALFAPI_BASE_DIR', DEFAULT_CONF['BASE_DIR'])
|
||||||
PORT = config.getint('project', 'port')
|
HALFAPI_DOT_FILE=os.path.join(
|
||||||
DB_NAME = f'halfapi_{PROJECT_NAME}'
|
BASE_DIR, '.halfapi', 'config')
|
||||||
|
|
||||||
|
HALFAPI_CONFIG_FILES = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(config.get('project', 'secret')) as secret_file:
|
with open(HALFAPI_ETC_FILE, 'r'):
|
||||||
SECRET = secret_file.read()
|
HALFAPI_CONFIG_FILES.append(HALFAPI_ETC_FILE)
|
||||||
# Set the secret so we can use it in domains
|
|
||||||
os.environ['HALFAPI_SECRET'] = SECRET
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('There is no file like {}'.format(config.get('project', 'secret')))
|
logger.info('Cannot find a configuration file under %s', HALFAPI_ETC_FILE)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
PRODUCTION = config.getboolean('project', 'production') or False
|
try:
|
||||||
os.environ['HALFAPI_PROD'] = str(PRODUCTION)
|
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)
|
||||||
|
|
||||||
BASE_DIR = config.get('project', 'base_dir')
|
|
||||||
|
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')
|
||||||
|
# !!!
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
from halfapi.conf import DB_NAME
|
|
||||||
|
|
||||||
# DB
|
|
||||||
from half_orm.model import Model
|
|
||||||
db = Model(DB_NAME)
|
|
||||||
Domain = db.get_relation_class('api.domain')
|
|
||||||
APIRouter = db.get_relation_class('api.router')
|
|
||||||
APIRoute = db.get_relation_class('api.route')
|
|
||||||
AclFunction = db.get_relation_class('api.acl_function')
|
|
||||||
Acl = db.get_relation_class('api.acl')
|
|
||||||
RouteACL = db.get_relation_class('api.view.acl')
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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__()
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,178 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Base ACL module that contains generic functions for domains ACL
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from json import JSONDecodeError
|
||||||
|
import yaml
|
||||||
from starlette.authentication import UnauthenticatedUser
|
from starlette.authentication import UnauthenticatedUser
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
""" Base ACL module that contains generic functions for domains ACL
|
from ..logging import logger
|
||||||
"""
|
|
||||||
|
|
||||||
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')
|
|
||||||
or type(req.user) == UnauthenticatedUser
|
|
||||||
or not hasattr(req.user, 'is_authenticated')):
|
|
||||||
return False
|
|
||||||
return func(req, **kwargs)
|
|
||||||
|
|
||||||
return caller
|
|
||||||
|
|
||||||
def public(*args, **kwargs) -> bool:
|
def public(*args, **kwargs) -> bool:
|
||||||
"Unlimited access"
|
"Unlimited access"
|
||||||
return True
|
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)
|
||||||
|
|
||||||
|
|
|
@ -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)]
|
||||||
|
}
|
||||||
|
})
|
|
@ -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)
|
||||||
|
|
|
@ -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,118 +1,162 @@
|
||||||
__LICENSE__ = """
|
"""
|
||||||
BSD 3-Clause License
|
JWT Middleware module
|
||||||
|
|
||||||
Copyright (c) 2018, Amit Ripshtos
|
Classes:
|
||||||
All rights reserved.
|
- JWTUser : goes in request.user
|
||||||
|
- JWTAuthenticationBackend
|
||||||
|
- JWTWebSocketAuthenticationBackend
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Raises:
|
||||||
modification, are permitted provided that the following conditions are met:
|
Exception: If configuration has no SECRET
|
||||||
|
|
||||||
* 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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import jwt
|
from os import environ
|
||||||
|
import typing
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
import jwt
|
||||||
from starlette.authentication import (
|
from starlette.authentication import (
|
||||||
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
|
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
|
||||||
UnauthenticatedUser)
|
UnauthenticatedUser)
|
||||||
|
from starlette.requests import HTTPConnection, Request
|
||||||
|
from starlette.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):
|
SECRET=None
|
||||||
def __init__(self, id: UUID, token: str, payload: dict) -> None:
|
|
||||||
self.__id = id
|
|
||||||
self.token = token
|
|
||||||
self.payload = payload
|
|
||||||
|
|
||||||
def __str__(self):
|
try:
|
||||||
return str(self.json)
|
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')
|
||||||
|
|
||||||
@property
|
def cookies_from_scope(scope):
|
||||||
def json(self):
|
cookie = dict(scope.get("headers") or {}).get(b"cookie")
|
||||||
return {
|
if not cookie:
|
||||||
'id' : str(self.__id),
|
return {}
|
||||||
'token': self.token,
|
|
||||||
'payload': self.payload
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
simple_cookie = SimpleCookie()
|
||||||
def is_authenticated(self) -> bool:
|
simple_cookie.load(cookie.decode("utf8"))
|
||||||
return True
|
return {key: morsel.value for key, morsel in simple_cookie.items()}
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self) -> str:
|
|
||||||
return self.__id
|
|
||||||
|
|
||||||
|
def on_auth_error(request: Request, exc: Exception):
|
||||||
|
response = ORJSONResponse({"error": str(exc)}, status_code=401)
|
||||||
|
response.delete_cookie('Authorization')
|
||||||
|
return response
|
||||||
|
|
||||||
class JWTAuthenticationBackend(AuthenticationBackend):
|
class JWTAuthenticationBackend(AuthenticationBackend):
|
||||||
def __init__(self, secret_key: str, algorithm: str = 'HS256', prefix: str = 'JWT', name: str = 'name'):
|
def __init__(self, secret_key: str = SECRET,
|
||||||
|
algorithm: str = 'HS256', prefix: str = 'JWT'):
|
||||||
|
|
||||||
if secret_key is None:
|
if secret_key is None:
|
||||||
raise Exception('Missing secret_key argument for JWTAuthenticationBackend')
|
raise Exception('Missing secret_key argument for JWTAuthenticationBackend')
|
||||||
self.secret_key = secret_key
|
self.secret_key = secret_key
|
||||||
self.algorithm = algorithm
|
self.algorithm = algorithm
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
self.id = id
|
|
||||||
|
|
||||||
async def authenticate(self, request):
|
@property
|
||||||
if "Authorization" not in request.headers:
|
def id(self) -> str:
|
||||||
return None
|
return self.__id
|
||||||
|
|
||||||
|
async def authenticate(
|
||||||
|
self, conn: HTTPConnection
|
||||||
|
) -> typing.Optional[typing.Tuple['AuthCredentials', 'BaseUser']]:
|
||||||
|
|
||||||
|
# Standard way to authenticate via API
|
||||||
|
# https://datatracker.ietf.org/doc/html/rfc7235#section-4.2
|
||||||
|
token = conn.headers.get('Authorization')
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
payload = jwt.decode(token, key=self.secret_key, algorithms=self.algorithm)
|
if token:
|
||||||
except jwt.InvalidTokenError as e:
|
payload = jwt.decode(token,
|
||||||
raise AuthenticationError(str(e))
|
key=self.secret_key,
|
||||||
except Exception as e:
|
algorithms=[self.algorithm],
|
||||||
print(e)
|
options={
|
||||||
raise e
|
'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(
|
return AuthCredentials(["authenticated"]), JWTUser(
|
||||||
id=payload['id'], token=token, payload=payload)
|
user_id=payload['user_id'], token=token, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class JWTWebSocketAuthenticationBackend(AuthenticationBackend):
|
class JWTWebSocketAuthenticationBackend(AuthenticationBackend):
|
||||||
|
|
||||||
def __init__(self, secret_key: str, algorithm: str = 'HS256', query_param_name: str = 'jwt',
|
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.secret_key = secret_key
|
||||||
self.algorithm = algorithm
|
self.algorithm = algorithm
|
||||||
self.query_param_name = query_param_name
|
self.query_param_name = query_param_name
|
||||||
self.id = id
|
self.__id = user_id
|
||||||
self.audience = audience
|
self.audience = audience
|
||||||
self.options = options
|
|
||||||
|
|
||||||
|
|
||||||
async def authenticate(self, request):
|
async def authenticate(
|
||||||
if self.query_param_name not in request.query_params:
|
self, conn: HTTPConnection
|
||||||
return AuthCredentials(), UnauthenticatedUser()
|
) -> 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:
|
try:
|
||||||
payload = jwt.decode(token, key=self.secret_key, algorithms=self.algorithm,
|
payload = jwt.decode(
|
||||||
audience=self.audience, options=self.options)
|
token,
|
||||||
except jwt.InvalidTokenError as e:
|
key=self.secret_key,
|
||||||
raise AuthenticationError(str(e))
|
algorithms=[self.algorithm],
|
||||||
|
audience=self.audience,
|
||||||
|
options={
|
||||||
|
'verify_signature': bool(PRODUCTION)
|
||||||
|
})
|
||||||
|
|
||||||
return AuthCredentials(["authenticated"]), JWTUser(id = payload['id'],
|
if PRODUCTION and 'debug' in payload.keys() and payload['debug']:
|
||||||
token=token, payload=payload)
|
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,12 +1,16 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
This is the *query* library that contains all the useful functions to treat our
|
This is the *query* library that contains all the useful functions to treat our
|
||||||
queries
|
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.
|
Returns the fitting Response object according to query parameters.
|
||||||
|
|
||||||
|
@ -15,7 +19,7 @@ def parse_query(q: str = ""):
|
||||||
It returns a callable function that returns the desired Response object.
|
It returns a callable function that returns the desired Response object.
|
||||||
|
|
||||||
Parameters:
|
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
|
key0:value0|...|keyN:valueN
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
@ -52,25 +56,23 @@ def parse_query(q: str = ""):
|
||||||
>>> parse_query('limit:10')
|
>>> parse_query('limit:10')
|
||||||
<function parse_query.<locals>.select at 0x...>
|
<function parse_query.<locals>.select at 0x...>
|
||||||
|
|
||||||
>>> parse_query('limit=10')
|
# >>> parse_query('limit=10')
|
||||||
Traceback (most recent call last):
|
# starlette.exceptions.HTTPException: 400
|
||||||
...
|
|
||||||
starlette.exceptions.HTTPException: 400
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = {}
|
params = {}
|
||||||
if len(q) > 0:
|
if len(q_string) > 0:
|
||||||
try:
|
try:
|
||||||
split_ = lambda x : x.split(':')
|
split_ = lambda x : x.split(':')
|
||||||
params = dict(map(split_, q.split('|')))
|
params = dict(map(split_, q_string.split('|')))
|
||||||
except ValueError:
|
except ValueError as exc:
|
||||||
raise HTTPException(400)
|
raise HTTPException(400) from exc
|
||||||
split_ = lambda x : x.split(':')
|
split_ = lambda x : x.split(':')
|
||||||
params = dict(map(split_, q.split('|')))
|
params = dict(map(split_, q_string.split('|')))
|
||||||
|
|
||||||
def select(obj, fields = []):
|
def select(obj, fields):
|
||||||
|
|
||||||
if 'limit' in params and int(params['limit']) > 0:
|
if 'limit' in params and int(params['limit']) > 0:
|
||||||
obj.limit(int(params['limit']))
|
obj.limit(int(params['limit']))
|
||||||
|
@ -78,6 +80,6 @@ def parse_query(q: str = ""):
|
||||||
if 'offset' in params and int(params['offset']) > 0:
|
if 'offset' in params and int(params['offset']) > 0:
|
||||||
obj.offset(int(params['offset']))
|
obj.offset(int(params['offset']))
|
||||||
|
|
||||||
return [elt for elt in obj.select(*fields)]
|
return list(obj.select(*fields))
|
||||||
|
|
||||||
return select
|
return select
|
||||||
|
|
|
@ -1,19 +1,44 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# builtins
|
# builtins
|
||||||
import numbers
|
""" Response module
|
||||||
import csv
|
|
||||||
|
Contains some base response classes
|
||||||
|
|
||||||
|
Classes :
|
||||||
|
- HJSONResponse
|
||||||
|
- InternalServerErrorResponse
|
||||||
|
- NotFoundResponse
|
||||||
|
- NotImplementedResponse
|
||||||
|
- ORJSONResponse
|
||||||
|
- PlainTextResponse
|
||||||
|
- ServiceUnavailableResponse
|
||||||
|
- UnauthorizedResponse
|
||||||
|
- ODSResponse
|
||||||
|
|
||||||
|
"""
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from io import TextIOBase, StringIO
|
import decimal
|
||||||
from half_orm.null import NULL
|
import typing
|
||||||
|
from io import BytesIO
|
||||||
|
import orjson
|
||||||
|
|
||||||
# asgi framework
|
# asgi framework
|
||||||
from starlette.responses import PlainTextResponse, Response, JSONResponse
|
from starlette.responses import PlainTextResponse, Response, JSONResponse, HTMLResponse
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from .user import JWTUser, Nobody
|
||||||
|
from ..logging import logger
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'HJSONResponse',
|
||||||
'InternalServerErrorResponse',
|
'InternalServerErrorResponse',
|
||||||
'NotFoundResponse',
|
'NotFoundResponse',
|
||||||
'NotImplementedResponse',
|
'NotImplementedResponse',
|
||||||
|
'ORJSONResponse',
|
||||||
'PlainTextResponse',
|
'PlainTextResponse',
|
||||||
|
'ServiceUnavailableResponse',
|
||||||
'UnauthorizedResponse']
|
'UnauthorizedResponse']
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,6 +62,11 @@ class NotImplementedResponse(Response):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(status_code=501)
|
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):
|
class UnauthorizedResponse(Response):
|
||||||
""" The 401 Not Found default Response
|
""" The 401 Not Found default Response
|
||||||
|
@ -44,26 +74,94 @@ class UnauthorizedResponse(Response):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(status_code = 401)
|
super().__init__(status_code = 401)
|
||||||
|
|
||||||
class HJSONResponse(JSONResponse):
|
|
||||||
def __init__(self, obj):
|
class ORJSONResponse(JSONResponse):
|
||||||
obj = self.__serialize(obj)
|
""" 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__(
|
super().__init__(
|
||||||
content=obj,
|
content=ods_file.getvalue(),
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'application/vnd.oasis.opendocument.spreadsheet; charset=UTF-8',
|
||||||
|
'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||||
status_code = 200)
|
status_code = 200)
|
||||||
|
|
||||||
def __serialize(self, obj):
|
|
||||||
if isinstance(obj, dict):
|
class XLSXResponse(ODSResponse):
|
||||||
robj = dict()
|
file_type = 'xlsx'
|
||||||
for key, value in obj.items():
|
|
||||||
robj[key] = self.__serialize(value)
|
def gen_exception_route(response_cls):
|
||||||
return robj
|
async def exception_route(req: Request, exc: HTTPException):
|
||||||
if isinstance(obj, list):
|
return response_cls()
|
||||||
robj = []
|
|
||||||
for value in obj:
|
return exception_route
|
||||||
robj.append(self.__serialize(value))
|
|
||||||
return robj
|
|
||||||
if isinstance(obj, numbers.Number) or isinstance(obj, str):
|
|
||||||
return obj
|
|
||||||
if obj == NULL:
|
|
||||||
return None
|
|
||||||
return str(obj)
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -1,84 +1,136 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from functools import wraps
|
"""
|
||||||
import importlib
|
Routes module
|
||||||
import sys
|
|
||||||
|
|
||||||
from halfapi.conf import (PROJECT_NAME, DB_NAME, HOST, PORT,
|
Classes :
|
||||||
PRODUCTION, DOMAINS)
|
- JSONRoute
|
||||||
|
|
||||||
from halfapi.db import (
|
Fonctions :
|
||||||
Domain,
|
- gen_domain_routes
|
||||||
APIRouter,
|
- gen_schema_routes
|
||||||
APIRoute,
|
- api_routes
|
||||||
AclFunction,
|
|
||||||
Acl)
|
Exception :
|
||||||
from halfapi.lib.responses import *
|
- DomainNotFoundError
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
from starlette.routing import Mount, Route
|
"""
|
||||||
from starlette.requests import Request
|
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):
|
class DomainNotFoundError(Exception):
|
||||||
pass
|
""" Exception when a domain is not importable
|
||||||
|
|
||||||
def get_routes(domains=None):
|
|
||||||
""" 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 "name"
|
|
||||||
|
|
||||||
Returns: Nothing
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def JSONRoute(data: Any) -> Coroutine:
|
||||||
|
"""
|
||||||
|
Returns a route function that returns the data as JSON
|
||||||
|
|
||||||
def route_decorator(fct, acls_mod, acls):
|
Parameters:
|
||||||
@wraps(fct)
|
data (Any):
|
||||||
async def caller(req: Request, *args, **kwargs):
|
The data to return
|
||||||
for acl_fct_name in acls:
|
|
||||||
acl_fct = getattr(acls_mod, acl_fct_name)
|
|
||||||
if acl_fct(req, *args, **kwargs):
|
|
||||||
return await fct(req, *args, **kwargs)
|
|
||||||
|
|
||||||
raise HTTPException(401)
|
Returns:
|
||||||
|
async function
|
||||||
|
"""
|
||||||
|
async def wrapped(request, *args, **kwargs):
|
||||||
|
return ORJSONResponse(data)
|
||||||
|
|
||||||
return caller
|
return wrapped
|
||||||
|
|
||||||
app_routes = []
|
|
||||||
for domain_name in DOMAINS:
|
|
||||||
try:
|
|
||||||
domain = next(Domain(name=domain_name).select())
|
|
||||||
except StopIteration:
|
|
||||||
raise DomainNotFoundError(
|
|
||||||
f"Domain '{domain_name}' not found in '{DB_NAME}' database!")
|
|
||||||
domain_acl_mod = importlib.import_module(f'{domain["name"]}.acl')
|
|
||||||
domain_routes = []
|
|
||||||
for router in APIRouter(domain=domain['name']).select():
|
|
||||||
router_routes = []
|
|
||||||
|
|
||||||
router_mod = importlib.import_module(
|
def gen_domain_routes(m_domain: ModuleType):
|
||||||
'{domain}.routers.{name}'.format(**router))
|
"""
|
||||||
|
Yields the Route objects for a domain
|
||||||
|
|
||||||
with APIRoute(domain=domain['name'],
|
Parameters:
|
||||||
router=router['name']) as routes:
|
m_domains: ModuleType
|
||||||
for route in routes.select():
|
|
||||||
fct_name = route.pop('fct_name')
|
|
||||||
acls = [ list(elt.values()).pop()
|
|
||||||
for elt in Acl(**route).select('acl_fct_name') ]
|
|
||||||
|
|
||||||
router_routes.append(
|
Returns:
|
||||||
Route(route['path'],
|
Generator(HalfRoute)
|
||||||
route_decorator(
|
"""
|
||||||
getattr(router_mod, fct_name),
|
yield HalfRoute('/',
|
||||||
domain_acl_mod,
|
JSONRoute(domain_schema(m_domain)),
|
||||||
acls
|
[{'acl': acl.public}],
|
||||||
), methods=[route['http_verb']])
|
'GET'
|
||||||
)
|
)
|
||||||
|
|
||||||
domain_routes.append(
|
for path, method, m_router, fct, params in gen_router_routes(m_domain, []):
|
||||||
Mount('/{name}'.format(**router), routes=router_routes))
|
yield HalfRoute(f'/{path}', fct, params, method)
|
||||||
|
|
||||||
app_routes.append(Mount('/{name}'.format(**domain),
|
|
||||||
routes=domain_routes))
|
def gen_schema_routes(schema: Dict):
|
||||||
return app_routes
|
"""
|
||||||
|
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
|
||||||
|
|
|
@ -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]
|
||||||
|
}
|
||||||
|
})
|
|
@ -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))
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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,74 +0,0 @@
|
||||||
create schema api;
|
|
||||||
|
|
||||||
create type verb as enum ('POST', 'GET', 'PUT', 'DELETE');
|
|
||||||
|
|
||||||
create table api.domain (
|
|
||||||
name text,
|
|
||||||
primary key (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
create table api.router (
|
|
||||||
name text,
|
|
||||||
domain text,
|
|
||||||
primary key (name, domain)
|
|
||||||
);
|
|
||||||
|
|
||||||
alter table api.router add constraint router_domain_fkey foreign key (domain) references api.domain(name) on update cascade on delete cascade;
|
|
||||||
|
|
||||||
create table api.route (
|
|
||||||
http_verb verb,
|
|
||||||
path text, -- relative to /<domain>/<router>
|
|
||||||
fct_name text,
|
|
||||||
router text,
|
|
||||||
domain text,
|
|
||||||
primary key (http_verb, path, router, domain)
|
|
||||||
);
|
|
||||||
|
|
||||||
alter table api.route add constraint route_router_fkey foreign key (router, domain) references api.router(name, domain) on update cascade on delete cascade;
|
|
||||||
|
|
||||||
create table api.acl_function (
|
|
||||||
name text,
|
|
||||||
description text,
|
|
||||||
domain text,
|
|
||||||
primary key (name, domain)
|
|
||||||
);
|
|
||||||
|
|
||||||
alter table api.acl_function add constraint acl_function_domain_fkey foreign key (domain) references api.domain(name) on update cascade on delete cascade;
|
|
||||||
|
|
||||||
create table api.acl (
|
|
||||||
http_verb verb,
|
|
||||||
path text not null,
|
|
||||||
router text,
|
|
||||||
domain text,
|
|
||||||
acl_fct_name text,
|
|
||||||
primary key (http_verb, path, router, domain, acl_fct_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
alter table api.acl add constraint acl_route_fkey foreign key (http_verb, path,
|
|
||||||
router, domain) references api.route(http_verb, path, router, domain) on update cascade on delete cascade;
|
|
||||||
alter table api.acl add constraint acl_function_fkey foreign key (acl_fct_name, domain) references api.acl_function(name, domain) on update cascade on delete cascade;
|
|
||||||
|
|
||||||
create schema "api.view";
|
|
||||||
|
|
||||||
create view "api.view".route as
|
|
||||||
select
|
|
||||||
route.*,
|
|
||||||
'/'::text || route.domain || '/'::text || route.router || route.path AS abs_path
|
|
||||||
from
|
|
||||||
api.route
|
|
||||||
join api.domain on
|
|
||||||
route.domain = domain.name
|
|
||||||
;
|
|
||||||
|
|
||||||
create view "api.view".acl as
|
|
||||||
select
|
|
||||||
acl.*,
|
|
||||||
'/'::text || route.domain || '/'::text || route.router || route.path AS abs_path
|
|
||||||
from
|
|
||||||
api.acl
|
|
||||||
join api.acl_function on
|
|
||||||
acl.acl_fct_name = acl_function.name
|
|
||||||
join api.route on
|
|
||||||
acl.domain = route.domain
|
|
||||||
and acl.router = route.router
|
|
||||||
and acl.path = route.path;
|
|
|
@ -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_(mix_stderr=False)
|
||||||
|
|
||||||
|
# HTTP
|
||||||
|
# Fake default values of default configuration
|
||||||
|
self.halfapi_conf = {
|
||||||
|
'secret': 'testsecret',
|
||||||
|
'production': False,
|
||||||
|
'domain': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.halfapi_conf['domain'][self.domain_name] = {
|
||||||
|
'name': self.domain_name,
|
||||||
|
'router': self.router_path,
|
||||||
|
'acl': self.acl_path,
|
||||||
|
'module': self.module_name,
|
||||||
|
'prefix': False,
|
||||||
|
'enabled': True,
|
||||||
|
'config': getattr(self, 'CONFIG', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, self.config_file = tempfile.mkstemp()
|
||||||
|
with open(self.config_file, 'w') as fh:
|
||||||
|
fh.write(toml.dumps(self.halfapi_conf))
|
||||||
|
|
||||||
|
self.halfapi = HalfAPI(self.halfapi_conf)
|
||||||
|
|
||||||
|
self.client = TestClient(self.halfapi.application)
|
||||||
|
|
||||||
|
self.module = importlib.import_module(
|
||||||
|
self.module_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def check_domain(self):
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
result = self.runner.invoke(cli, '--version')
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
result = self.runner.invoke(cli, ['domain', '--read', self.DOMAIN, self.config_file])
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
result_d = json.loads(result.stdout)
|
||||||
|
result = self.runner.invoke(cli, ['run', '--help'])
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
# result = self.runner.invoke(cli, ['run', '--dryrun', self.DOMAIN])
|
||||||
|
# self.assertEqual(result.exit_code, 0)
|
||||||
|
except AssertionError as exc:
|
||||||
|
print(f'Result {result}')
|
||||||
|
print(f'Stdout {result.stdout}')
|
||||||
|
print(f'Stderr {result.stderr}')
|
||||||
|
raise exc
|
||||||
|
except JSONDecodeError as exc:
|
||||||
|
print(f'Result {result}')
|
||||||
|
print(f'Stdout {result.stdout}')
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return result_d
|
||||||
|
|
||||||
|
def check_routes(self):
|
||||||
|
r = self.client.request('get', '/')
|
||||||
|
assert r.status_code == 200
|
||||||
|
schema = r.json()
|
||||||
|
assert isinstance(schema, dict)
|
||||||
|
assert 'openapi' in schema
|
||||||
|
assert 'info' in schema
|
||||||
|
assert 'paths' in schema
|
||||||
|
|
||||||
|
r = self.client.request('get', '/halfapi/acls')
|
||||||
|
assert r.status_code == 200
|
||||||
|
d_r = r.json()
|
||||||
|
assert isinstance(d_r, dict)
|
||||||
|
assert self.domain_name in d_r.keys()
|
||||||
|
|
||||||
|
ACLS = HalfDomain.acls(self.module, self.acl_path)
|
||||||
|
assert len(ACLS) == len(d_r[self.domain_name])
|
||||||
|
|
||||||
|
for acl_rule in ACLS:
|
||||||
|
assert len(acl_rule.name) > 0
|
||||||
|
assert acl_rule.name in d_r[self.domain_name]
|
||||||
|
assert len(acl_rule.documentation) > 0
|
||||||
|
assert isinstance(acl_rule.priority, int)
|
||||||
|
assert acl_rule.priority >= 0
|
||||||
|
|
||||||
|
if acl_rule.public is True:
|
||||||
|
r = self.client.request('get', f'/halfapi/acls/{acl_rule.name}')
|
||||||
|
assert r.status_code in [200, 401]
|
|
@ -0,0 +1,3 @@
|
||||||
|
pyexcel>=0.6.3,<1
|
||||||
|
pyexcel-ods>=0.5.6,<1
|
||||||
|
pyexcel-xlsx=0.6.0,<1
|
|
@ -1,4 +1,5 @@
|
||||||
[pytest]
|
[pytest]
|
||||||
testpaths = tests halfapi
|
testpaths = tests halfapi
|
||||||
addopts = --doctest-modules -rP --log-level debug
|
addopts = --doctest-modules
|
||||||
doctest_optionflags = ELLIPSIS
|
doctest_optionflags = ELLIPSIS IGNORE_EXCEPTION_DETAIL
|
||||||
|
pythonpath = ./tests
|
||||||
|
|
|
@ -1,6 +1,61 @@
|
||||||
click
|
alog==0.9.13
|
||||||
starlette
|
anyio==3.4.0
|
||||||
uvicorn
|
asgiref==3.4.1
|
||||||
PyJWT
|
astroid==2.9.0
|
||||||
half_orm @ git+ssh://git@gite.lirmm.fr/newsi/halfORM.git
|
attrs==21.2.0
|
||||||
pygit2==0.28.2
|
bleach==4.1.0
|
||||||
|
build==0.7.0
|
||||||
|
certifi==2021.10.8
|
||||||
|
cffi==1.15.0
|
||||||
|
charset-normalizer==2.0.9
|
||||||
|
click==7.1.2
|
||||||
|
colorama==0.4.4
|
||||||
|
contextlib2==21.6.0
|
||||||
|
cryptography==36.0.1
|
||||||
|
docutils==0.18.1
|
||||||
|
h11==0.12.0
|
||||||
|
idna==3.3
|
||||||
|
importlib-metadata==4.8.2
|
||||||
|
iniconfig==1.1.1
|
||||||
|
isort==5.10.1
|
||||||
|
jeepney==0.7.1
|
||||||
|
keyring==23.4.0
|
||||||
|
lazy-object-proxy==1.7.1
|
||||||
|
mccabe==0.6.1
|
||||||
|
orjson==3.6.5
|
||||||
|
packaging==21.3
|
||||||
|
pep517==0.12.0
|
||||||
|
pkginfo==1.8.2
|
||||||
|
platformdirs==2.4.0
|
||||||
|
pluggy==1.0.0
|
||||||
|
py==1.11.0
|
||||||
|
pycparser==2.21
|
||||||
|
pyflakes==2.4.0
|
||||||
|
Pygments==2.10.0
|
||||||
|
PyJWT==2.3.0
|
||||||
|
pylint==2.12.2
|
||||||
|
pyparsing==3.0.6
|
||||||
|
pytest==6.2.5
|
||||||
|
pytest-asyncio==0.16.0
|
||||||
|
pytest-pythonpath==0.7.3
|
||||||
|
PyYAML==5.4.1
|
||||||
|
readme-renderer==32.0
|
||||||
|
requests==2.26.0
|
||||||
|
requests-toolbelt==0.9.1
|
||||||
|
rfc3986==1.5.0
|
||||||
|
schema==0.7.5
|
||||||
|
SecretStorage==3.3.1
|
||||||
|
six==1.16.0
|
||||||
|
sniffio==1.2.0
|
||||||
|
starlette==0.17.1
|
||||||
|
timing-asgi==0.2.1
|
||||||
|
toml==0.10.2
|
||||||
|
tomli==2.0.0
|
||||||
|
tqdm==4.62.3
|
||||||
|
twine==3.7.1
|
||||||
|
urllib3==1.26.7
|
||||||
|
uvicorn==0.16.0
|
||||||
|
vulture==2.3
|
||||||
|
webencodings==0.5.1
|
||||||
|
wrapt==1.13.3
|
||||||
|
zipp==3.6.0
|
||||||
|
|
|
@ -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')
|
65
setup.py
65
setup.py
|
@ -8,6 +8,9 @@ from setuptools import setup, find_packages
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
here = pathlib.Path(__file__).parent.resolve()
|
||||||
|
long_description = (here / 'README.md').read_text(encoding='utf-8')
|
||||||
|
|
||||||
def get_version(package):
|
def get_version(package):
|
||||||
"""
|
"""
|
||||||
Return package version as listed in `__version__` in `init.py`.
|
Return package version as listed in `__version__` in `init.py`.
|
||||||
|
@ -16,13 +19,6 @@ def get_version(package):
|
||||||
return re.search("__version__ = ['\"]([^'\"]+)['\"]", f.read()).group(1)
|
return re.search("__version__ = ['\"]([^'\"]+)['\"]", f.read()).group(1)
|
||||||
|
|
||||||
|
|
||||||
def get_long_description():
|
|
||||||
"""
|
|
||||||
Return the README.
|
|
||||||
"""
|
|
||||||
here = pathlib.Path(__file__).parent.resolve()
|
|
||||||
long_description = (here / 'README.md').read_text(encoding='utf-8')
|
|
||||||
|
|
||||||
def get_packages(package):
|
def get_packages(package):
|
||||||
"""
|
"""
|
||||||
Return root package and all sub-packages.
|
Return root package and all sub-packages.
|
||||||
|
@ -37,27 +33,62 @@ module_name="halfapi"
|
||||||
setup(
|
setup(
|
||||||
name=module_name,
|
name=module_name,
|
||||||
version=get_version(module_name),
|
version=get_version(module_name),
|
||||||
url="https://gite.lirmm.fr/malves/halfapi",
|
url="https://github.com/halfAPI/halfapi",
|
||||||
long_description=get_long_description(),
|
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",
|
long_description_content_type="text/markdown",
|
||||||
packages=get_packages(module_name),
|
packages=get_packages(module_name),
|
||||||
python_requires=">=3.7",
|
python_requires=">=3.8",
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"PyJWT>=1.7.1",
|
"PyJWT>=2.6.0,<2.7.0",
|
||||||
"starlette>=0.13,<1",
|
"starlette>=0.33,<0.34",
|
||||||
"half_orm @ git+ssh://git@gite.lirmm.fr/maizi/halfORM.git",
|
"click>=8,<9",
|
||||||
"click>=7.1,<8",
|
"uvicorn>=0.13,<1",
|
||||||
"uvicorn>=0.11,<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={
|
extras_require={
|
||||||
"tests":[
|
"tests":[
|
||||||
"pytest",
|
"pytest>=7,<8",
|
||||||
"requests"
|
"pytest-asyncio",
|
||||||
|
"pylint",
|
||||||
|
"requests",
|
||||||
|
"httpx",
|
||||||
|
"openapi-schema-validator",
|
||||||
|
"openapi-spec-validator",
|
||||||
|
"coverage"
|
||||||
|
],
|
||||||
|
"pyexcel":[
|
||||||
|
"pyexcel",
|
||||||
|
"pyexcel-ods3",
|
||||||
|
"pyexcel-xlsx"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
entry_points={
|
entry_points={
|
||||||
"console_scripts":[
|
"console_scripts":[
|
||||||
"halfapi=halfapi.cli.cli:cli"
|
"halfapi=halfapi.cli.cli:cli"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
keywords="web-api development boilerplate",
|
||||||
|
project_urls={
|
||||||
|
"Source": "https://github.com/halfAPI/halfapi",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
|
@ -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')
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
"""
|
|
@ -1,57 +1,120 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import logging
|
||||||
|
import functools
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import importlib
|
import importlib
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import patch
|
|
||||||
from typing import Dict, Tuple
|
from typing import Dict, Tuple
|
||||||
import pytest
|
from uuid import uuid1, uuid4, UUID
|
||||||
from uuid import uuid1
|
|
||||||
from click.testing import CliRunner
|
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 import __version__
|
||||||
from halfapi.cli import cli
|
from halfapi.halfapi import HalfAPI
|
||||||
from halfapi.cli.init import format_halfapi_etc
|
from halfapi.cli.cli import cli
|
||||||
Cli = cli.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')
|
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
|
@pytest.fixture
|
||||||
def runner():
|
def runner():
|
||||||
return CliRunner()
|
return CliRunner(mix_stderr=False)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def dropdb():
|
def cli_runner():
|
||||||
p = subprocess.Popen(['dropdb', f'halfapi_{PROJNAME}'])
|
"""Yield a click.testing.CliRunner to invoke the CLI."""
|
||||||
p.wait()
|
class_ = CliRunner
|
||||||
yield
|
|
||||||
|
|
||||||
p = subprocess.Popen(['dropdb', f'halfapi_{PROJNAME}'])
|
def invoke_wrapper(f):
|
||||||
p.wait()
|
"""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_(mix_stderr=False)
|
||||||
|
|
||||||
|
yield cli_runner_
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def createdb():
|
def halfapicli(cli_runner):
|
||||||
p = subprocess.Popen(['createdb', f'halfapi_{PROJNAME}'])
|
def caller(*args):
|
||||||
p.wait()
|
return cli_runner.invoke(cli, ' '.join(args))
|
||||||
return
|
|
||||||
|
|
||||||
def confdir(dirname):
|
yield caller
|
||||||
d = os.environ.get(dirname)
|
|
||||||
if not d:
|
|
||||||
os.environ[dirname] = tempfile.mkdtemp(prefix='halfapi_')
|
|
||||||
return os.environ.get(dirname)
|
|
||||||
if not os.path.isdir(d):
|
|
||||||
os.mkdir(d)
|
|
||||||
return d
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def halform_conf_dir():
|
|
||||||
return confdir('HALFORM_CONF_DIR')
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def halfapi_conf_dir():
|
|
||||||
return confdir('HALFAPI_CONF_DIR')
|
|
||||||
|
|
||||||
# store history of failures per test class name and per index in parametrize (if
|
# store history of failures per test class name and per index in parametrize (if
|
||||||
# parametrize used)
|
# parametrize used)
|
||||||
|
@ -103,26 +166,157 @@ def pytest_runtest_setup(item):
|
||||||
pytest.xfail("previous test failed ({})".format(test_name))
|
pytest.xfail("previous test failed ({})".format(test_name))
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def project_runner(runner, dropdb, createdb, halform_conf_dir, halfapi_conf_dir):
|
def project_runner(runner, halfapicli, tree):
|
||||||
env = {
|
|
||||||
'HALFORM_CONF_DIR': halform_conf_dir,
|
|
||||||
'HALFAPI_CONF_DIR': halfapi_conf_dir
|
|
||||||
}
|
|
||||||
with runner.isolated_filesystem():
|
with runner.isolated_filesystem():
|
||||||
res = runner.invoke(Cli, ['init', PROJNAME],
|
res = halfapicli('init', PROJNAME)
|
||||||
env=env,
|
|
||||||
catch_exceptions=True)
|
|
||||||
assert res.exit_code == 0
|
|
||||||
|
|
||||||
os.chdir(PROJNAME)
|
os.chdir(PROJNAME)
|
||||||
|
|
||||||
|
fs_path = os.getcwd()
|
||||||
|
sys.path.insert(0, fs_path)
|
||||||
|
|
||||||
secret = tempfile.mkstemp()
|
secret = tempfile.mkstemp()
|
||||||
SECRET_PATH = secret[1]
|
SECRET_PATH = secret[1]
|
||||||
with open(SECRET_PATH, 'w') as f:
|
with open(SECRET_PATH, 'w') as f:
|
||||||
f.write(str(uuid1()))
|
f.write(str(uuid1()))
|
||||||
|
|
||||||
with open(os.path.join(halfapi_conf_dir, PROJNAME), 'w') as f:
|
"""
|
||||||
|
with open(os.path.join('.halfapi', PROJNAME), 'w') as halfapi_etc:
|
||||||
PROJ_CONFIG = re.sub('secret = .*', f'secret = {SECRET_PATH}',
|
PROJ_CONFIG = re.sub('secret = .*', f'secret = {SECRET_PATH}',
|
||||||
format_halfapi_etc(PROJNAME, os.getcwd()))
|
format_halfapi_etc(PROJNAME, os.getcwd()))
|
||||||
f.write(PROJ_CONFIG)
|
halfapi_etc.write(PROJ_CONFIG)
|
||||||
|
"""
|
||||||
|
|
||||||
yield lambda args: runner.invoke(Cli, args, env=env)
|
|
||||||
|
###
|
||||||
|
# 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
|
||||||
|
|
|
@ -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'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,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)
|
|
@ -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')
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
|
@ -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')
|
||||||
|
})
|
|
@ -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,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'))
|
|
@ -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'))
|
|
@ -0,0 +1,4 @@
|
||||||
|
from . import get
|
||||||
|
|
||||||
|
def test_get():
|
||||||
|
assert isinstance(get(), str)
|
|
@ -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=[]
|
||||||
|
)
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
@ -1,96 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import importlib
|
|
||||||
import tempfile
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
from configparser import ConfigParser
|
|
||||||
|
|
||||||
from halfapi import __version__
|
|
||||||
from halfapi.cli import cli
|
|
||||||
Cli = cli.cli
|
|
||||||
|
|
||||||
PROJNAME = os.environ.get('PROJ','tmp_api')
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.incremental
|
|
||||||
class TestCli():
|
|
||||||
def test_options(self, runner, dropdb, createdb):
|
|
||||||
# Wrong command
|
|
||||||
with runner.isolated_filesystem():
|
|
||||||
r = runner.invoke(Cli, ['foobar'])
|
|
||||||
assert r.exit_code == 2
|
|
||||||
|
|
||||||
# Test existing commands
|
|
||||||
with runner.isolated_filesystem():
|
|
||||||
r = runner.invoke(Cli, ['--help'])
|
|
||||||
assert r.exit_code == 0
|
|
||||||
|
|
||||||
with runner.isolated_filesystem():
|
|
||||||
r = runner.invoke(Cli, ['--version'])
|
|
||||||
assert r.exit_code == 0
|
|
||||||
|
|
||||||
with runner.isolated_filesystem():
|
|
||||||
r = runner.invoke(Cli, ['init', '--help'])
|
|
||||||
assert r.exit_code == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_project_fail(self, runner, dropdb):
|
|
||||||
# Missing argument (project)
|
|
||||||
testproject = 'testproject'
|
|
||||||
r = runner.invoke(Cli, ['init'])
|
|
||||||
assert r.exit_code == 2
|
|
||||||
|
|
||||||
with runner.isolated_filesystem():
|
|
||||||
# Fail : Wrong project name
|
|
||||||
r = runner.invoke(Cli, ['init', 'test*-project'])
|
|
||||||
assert r.exit_code == 1
|
|
||||||
|
|
||||||
with runner.isolated_filesystem():
|
|
||||||
# Fail : Already existing folder
|
|
||||||
os.mkdir(testproject)
|
|
||||||
r = runner.invoke(Cli, ['init', testproject])
|
|
||||||
assert r.exit_code == 1
|
|
||||||
|
|
||||||
with runner.isolated_filesystem():
|
|
||||||
# Fail : Already existing nod
|
|
||||||
os.mknod(testproject)
|
|
||||||
r = runner.invoke(Cli, ['init', testproject])
|
|
||||||
assert r.exit_code == 1
|
|
||||||
|
|
||||||
def test_init_project(self, runner, dropdb, createdb, halform_conf_dir, halfapi_conf_dir):
|
|
||||||
cp = ConfigParser()
|
|
||||||
with runner.isolated_filesystem():
|
|
||||||
env = {
|
|
||||||
'HALFORM_CONF_DIR': halform_conf_dir,
|
|
||||||
'HALFAPI_CONF_DIR': halfapi_conf_dir
|
|
||||||
}
|
|
||||||
|
|
||||||
res = runner.invoke(Cli, ['init', PROJNAME], env=env)
|
|
||||||
try:
|
|
||||||
assert os.path.isdir(PROJNAME)
|
|
||||||
assert os.path.isdir(os.path.join(PROJNAME, '.halfapi'))
|
|
||||||
|
|
||||||
|
|
||||||
# .halfapi/config check
|
|
||||||
assert os.path.isfile(os.path.join(PROJNAME, '.halfapi', 'config'))
|
|
||||||
cp.read(os.path.join(PROJNAME, '.halfapi', 'config'))
|
|
||||||
assert cp.has_section('project')
|
|
||||||
assert cp.has_option('project', 'name')
|
|
||||||
assert cp.get('project', 'name') == PROJNAME
|
|
||||||
assert cp.get('project', 'halfapi_version') == __version__
|
|
||||||
|
|
||||||
# .halfapi/domains check
|
|
||||||
assert os.path.isfile(os.path.join(PROJNAME, '.halfapi', 'domains'))
|
|
||||||
cp.read(os.path.join(PROJNAME, '.halfapi', 'domains'))
|
|
||||||
assert cp.has_section('domains')
|
|
||||||
except AssertionError as e:
|
|
||||||
subprocess.run(['tree', '-a', os.getcwd()])
|
|
||||||
raise e
|
|
||||||
|
|
||||||
assert res.exit_code == 0
|
|
||||||
assert res.exception is None
|
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import importlib
|
|
||||||
import tempfile
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
from configparser import ConfigParser
|
|
||||||
|
|
||||||
PROJNAME = os.environ.get('PROJ','tmp_api')
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def subproc(project_runner):
|
|
||||||
def caller(cmd):
|
|
||||||
proc = subprocess.Popen(cmd.split(' '))
|
|
||||||
return proc.wait()
|
|
||||||
return caller
|
|
||||||
|
|
||||||
@pytest.mark.incremental
|
|
||||||
class TestCliProj():
|
|
||||||
def test_cmds(self, subproc):
|
|
||||||
assert subproc('halfapi run --help') == 0
|
|
||||||
assert subproc('halfapi domain --help') == 0
|
|
||||||
|
|
||||||
def test_config_commands(self, subproc):
|
|
||||||
res = subproc('halfapi config pr00t')
|
|
||||||
assert res == 2
|
|
||||||
res = subproc('halfapi config --help')
|
|
||||||
assert res == 0
|
|
||||||
res = subproc('halfapi config')
|
|
||||||
assert res == 0
|
|
||||||
|
|
||||||
def test_domain_commands(self, subproc):
|
|
||||||
res = subproc('halfapi domain foobar')
|
|
||||||
assert res == 2
|
|
||||||
res = subproc('halfapi domain --help')
|
|
||||||
assert res == 0
|
|
||||||
|
|
||||||
def test_domain_create(self, subproc):
|
|
||||||
DOMNAME='tmp_domain'
|
|
||||||
res = subproc(f'halfapi domain --create')
|
|
||||||
assert res == 0
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -2,14 +2,66 @@
|
||||||
import pytest
|
import pytest
|
||||||
from starlette.authentication import UnauthenticatedUser
|
from starlette.authentication import UnauthenticatedUser
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
from halfapi.app import app
|
import subprocess
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pprint
|
||||||
|
import openapi_spec_validator
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger()
|
||||||
|
|
||||||
def test_itworks():
|
from halfapi.lib.constants import API_SCHEMA
|
||||||
c = TestClient(app)
|
|
||||||
r = c.get('/')
|
|
||||||
assert r.text == 'It Works!'
|
|
||||||
|
|
||||||
def test_user():
|
|
||||||
c = TestClient(app)
|
def test_halfapi_whoami(application_debug):
|
||||||
r = c.get('/user')
|
# @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
|
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)
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
import pytest
|
||||||
|
from halfapi.testing.test_domain import TestDomain
|
||||||
|
from pprint import pprint
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger()
|
||||||
|
|
||||||
|
class TestDummyDomain(TestDomain):
|
||||||
|
from .dummy_domain import domain
|
||||||
|
__name__ = domain.get('name')
|
||||||
|
__routers__ = domain.get('routers')
|
||||||
|
|
||||||
|
DOMAIN = __name__
|
||||||
|
CONFIG = {'test': True}
|
||||||
|
|
||||||
|
def test_domain(self):
|
||||||
|
self.check_domain()
|
||||||
|
|
||||||
|
def test_routes(self):
|
||||||
|
self.check_routes()
|
||||||
|
|
||||||
|
def test_html_route(self):
|
||||||
|
res = self.client.request('get', '/ret_type')
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert isinstance(res.content.decode(), str)
|
||||||
|
assert res.headers['content-type'].split(';')[0] == 'text/html'
|
||||||
|
|
||||||
|
res = self.client.request('get', '/ret_type/h24')
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert isinstance(res.content.decode(), str)
|
||||||
|
assert res.headers['content-type'].split(';')[0] == 'text/html'
|
||||||
|
|
||||||
|
res = self.client.request('get', '/ret_type/h24/config')
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert isinstance(res.content.decode(), str)
|
||||||
|
assert res.headers['content-type'].split(';')[0] == 'text/html'
|
||||||
|
|
||||||
|
res = self.client.request('post', '/ret_type/h24/config', json={
|
||||||
|
'trou': 'glet'
|
||||||
|
})
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert isinstance(res.content.decode(), str)
|
||||||
|
assert res.headers['content-type'].split(';')[0] == 'text/html'
|
||||||
|
|
||||||
|
def test_arguments__get_routes(self):
|
||||||
|
res = self.client.request('post', '/arguments?foo=1&x=3')
|
||||||
|
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
arg_dict = {'foo': '1', 'bar': '2', 'x': '3'}
|
||||||
|
res = self.client.request('get', '/arguments?foo=1&bar=2&x=3')
|
||||||
|
assert res.json() == arg_dict
|
||||||
|
|
||||||
|
res = self.client.request('get', '/arguments?foo=1&bar=2&x=3&y=4')
|
||||||
|
assert res.json() == arg_dict
|
||||||
|
|
||||||
|
def test_arguments_post_routes(self):
|
||||||
|
arg_dict = {}
|
||||||
|
res = self.client.request('post', '/arguments', json=arg_dict)
|
||||||
|
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
arg_dict = {'foo': '1', 'bar': '3'}
|
||||||
|
res = self.client.request('post', '/arguments', json=arg_dict)
|
||||||
|
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
arg_dict = {'foo': '1', 'baz': '3'}
|
||||||
|
res = self.client.request('post', '/arguments', json=arg_dict)
|
||||||
|
|
||||||
|
assert res.json() == arg_dict
|
||||||
|
|
||||||
|
arg_dict = {'foo': '1', 'baz': '3', 'truebidoo': '4'}
|
||||||
|
res = self.client.request('post', '/arguments', json=arg_dict)
|
||||||
|
|
||||||
|
assert res.json() == arg_dict
|
||||||
|
|
||||||
|
res = self.client.request('post', '/arguments', json={ **arg_dict, 'y': '4'})
|
||||||
|
assert res.json() == arg_dict
|
||||||
|
|
||||||
|
res = self.client.request('post', '/arguments', json={ **arg_dict, 'z': True})
|
||||||
|
assert res.json() == {**arg_dict, 'z': True}
|
||||||
|
|
||||||
|
def test_schema_path_params(self):
|
||||||
|
res = self.client.request('get', '/halfapi/schema')
|
||||||
|
schema = res.json()
|
||||||
|
|
||||||
|
logger.debug(schema)
|
||||||
|
|
||||||
|
assert len(schema['paths']) > 0
|
||||||
|
route = schema['paths']['/path_params/{first}/one/{second}/two/{third}']
|
||||||
|
|
||||||
|
assert 'parameters' in route['get']
|
||||||
|
parameters = route['get']['parameters']
|
||||||
|
|
||||||
|
assert len(parameters) == 3
|
||||||
|
|
||||||
|
param_map = {
|
||||||
|
elt['name']: elt
|
||||||
|
for elt in parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
assert param_map['second']['description'] == 'second parameter description test'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import importlib
|
||||||
|
from halfapi.testing.test_domain import TestDomain
|
||||||
|
|
||||||
|
def test_dummy_domain():
|
||||||
|
from . import dummy_domain
|
||||||
|
from .dummy_domain import acl
|
||||||
|
assert acl.public() is True
|
||||||
|
assert isinstance(acl.random(), int)
|
||||||
|
assert acl.private() is False
|
||||||
|
|
||||||
|
|
||||||
|
from .dummy_domain import routers
|
||||||
|
from .dummy_domain.routers.arguments import get
|
||||||
|
from .dummy_domain.routers.abc.alphabet.TEST_uuid import get
|
||||||
|
from .dummy_domain.routers.abc.pinnochio import get
|
||||||
|
from .dummy_domain.routers.config import get
|
||||||
|
from .dummy_domain.routers.config import get
|
||||||
|
from .dummy_domain.routers import async_router
|
||||||
|
from .dummy_domain.routers.async_router import ROUTES, get_abc_alphabet_TEST, get_abc_pinnochio, get_config, get_arguments
|
|
@ -0,0 +1,109 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import importlib
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from pprint import pprint
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
def test_get_config_route(dummy_project, application_domain):
|
||||||
|
c = TestClient(application_domain)
|
||||||
|
r = c.request('get', '/')
|
||||||
|
assert r.status_code == 200
|
||||||
|
pprint(r.json())
|
||||||
|
r = c.request('get', '/config')
|
||||||
|
assert r.status_code == 200
|
||||||
|
pprint(r.json())
|
||||||
|
assert 'test' in r.json()
|
||||||
|
|
||||||
|
def test_get_route(dummy_project, application_domain):
|
||||||
|
c = TestClient(application_domain)
|
||||||
|
path = verb = params = None
|
||||||
|
dummy_domain_routes = [
|
||||||
|
('config','GET'),
|
||||||
|
('config','GET'),
|
||||||
|
('async_router/abc/pinnochio','GET'),
|
||||||
|
('async_router/config','GET'),
|
||||||
|
# ('abc/pinnochio','GET'),
|
||||||
|
# ('abc/alphabet','GET'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for route_def in dummy_domain_routes:
|
||||||
|
path, verb = route_def[0], route_def[1]
|
||||||
|
route_path = '/{}'.format(path)
|
||||||
|
print(route_path)
|
||||||
|
try:
|
||||||
|
if verb.lower() == 'get':
|
||||||
|
r = c.request('get', route_path)
|
||||||
|
elif verb.lower() == 'post':
|
||||||
|
r = c.request('post', route_path)
|
||||||
|
elif verb.lower() == 'patch':
|
||||||
|
r = c.request('patch', route_path)
|
||||||
|
elif verb.lower() == 'put':
|
||||||
|
r = c.request('put', route_path)
|
||||||
|
elif verb.lower() == 'delete':
|
||||||
|
r = c.request('delete', route_path)
|
||||||
|
else:
|
||||||
|
raise Exception(verb)
|
||||||
|
try:
|
||||||
|
assert r.status_code in [200, 501]
|
||||||
|
except AssertionError as exc:
|
||||||
|
print('{} [{}] {}'.format(str(r.status_code), verb, route_path))
|
||||||
|
raise exc from exc
|
||||||
|
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dummy_domain_path_routes = [
|
||||||
|
('abc/alphabet/{test}','GET'),
|
||||||
|
]
|
||||||
|
|
||||||
|
#for route_def in dummy_domain_path_routes:
|
||||||
|
for route_def in []:#dummy_domain_routes:
|
||||||
|
from uuid import uuid4
|
||||||
|
test_uuid = uuid4()
|
||||||
|
for route_def in dummy_domain_path_routes:
|
||||||
|
path, verb = route_def[0], route_def[1]
|
||||||
|
path = path.format(test=str(test_uuid))
|
||||||
|
route_path = f'/{path}'
|
||||||
|
if verb.lower() == 'get':
|
||||||
|
r = c.request('get', f'{route_path}')
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_route(dummy_project, application_domain):
|
||||||
|
c = TestClient(application_domain)
|
||||||
|
from uuid import uuid4
|
||||||
|
arg = str(uuid4())
|
||||||
|
r = c.request('delete', f'/abc/alphabet/{arg}')
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json(), str)
|
||||||
|
|
||||||
|
def test_arguments_route(dummy_project, application_domain):
|
||||||
|
c = TestClient(application_domain)
|
||||||
|
|
||||||
|
path = '/arguments'
|
||||||
|
r = c.request('get', path)
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = c.request('get', path, params={'foo':True})
|
||||||
|
assert r.status_code == 400
|
||||||
|
arg = {'foo':True, 'bar':True}
|
||||||
|
r = c.request('get', path, params=arg)
|
||||||
|
assert r.status_code == 200
|
||||||
|
for key, val in arg.items():
|
||||||
|
assert json.loads(r.json()[key]) == val
|
||||||
|
path = '/async_router/arguments'
|
||||||
|
r = c.request('get', path)
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = c.request('get', path, params={'foo':True})
|
||||||
|
assert r.status_code == 400
|
||||||
|
arg = {'foo':True, 'bar':True}
|
||||||
|
r = c.request('get', path, params=arg)
|
||||||
|
assert r.status_code == 200
|
||||||
|
for key, val in arg.items():
|
||||||
|
assert json.loads(r.json()[key]) == val
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
from halfapi.halfapi import HalfAPI
|
||||||
|
|
||||||
|
def test_methods():
|
||||||
|
assert 'application' in dir(HalfAPI)
|
||||||
|
assert 'version' in dir(HalfAPI)
|
||||||
|
assert 'version_async' in dir(HalfAPI)
|
|
@ -1,92 +1,132 @@
|
||||||
|
import os
|
||||||
import jwt
|
import jwt
|
||||||
import requests
|
from requests import Request
|
||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
import json
|
import json
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
import sys
|
import sys
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from starlette.testclient import TestClient
|
from uuid import uuid4, UUID
|
||||||
|
|
||||||
from halfapi.app import app
|
from starlette.testclient import TestClient
|
||||||
from halfapi.lib.jwt_middleware import (JWTUser, JWTAuthenticationBackend,
|
from starlette.authentication import (
|
||||||
|
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
|
||||||
|
UnauthenticatedUser)
|
||||||
|
from starlette.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from halfapi.lib.jwt_middleware import (
|
||||||
|
JWTUser, JWTAuthenticationBackend,
|
||||||
JWTWebSocketAuthenticationBackend)
|
JWTWebSocketAuthenticationBackend)
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def token():
|
|
||||||
# This fixture needs to have a running auth-lirmm on 127.0.0.1:3000
|
|
||||||
# Sets a valid token
|
|
||||||
|
|
||||||
r = requests.post('http://127.0.0.1:3000/',
|
def test_JWTUser():
|
||||||
data={'email':'maizi', 'password':'a'})
|
uid = uuid4()
|
||||||
|
token = '{}'
|
||||||
|
payload = {}
|
||||||
|
user = JWTUser(uid, token, payload)
|
||||||
|
assert user.id == uid
|
||||||
|
assert user.token == token
|
||||||
|
assert user.payload == payload
|
||||||
|
assert user.is_authenticated == True
|
||||||
|
|
||||||
if len(r.text) <= 0:
|
def test_jwt_NoToken(dummy_app):
|
||||||
raise Exception('No result in token retrieval')
|
async def test_route(request):
|
||||||
|
assert isinstance(request.user, UnauthenticatedUser)
|
||||||
|
return PlainTextResponse('ok')
|
||||||
|
|
||||||
try:
|
dummy_app.add_route('/test', test_route)
|
||||||
res = json.loads(r.text)
|
test_client = TestClient(dummy_app)
|
||||||
except JSONDecodeError:
|
resp = test_client.request('get', '/test')
|
||||||
raise Exception('Malformed response from token retrieval')
|
assert resp.status_code == 200
|
||||||
|
|
||||||
if 'token' not in res.keys():
|
def test_jwt_Token(dummy_app, token_builder):
|
||||||
raise Exception('Missing token in token request')
|
async def test_route(request):
|
||||||
|
assert isinstance(request.user, JWTUser)
|
||||||
|
print(request.scope['app'].debug)
|
||||||
|
return PlainTextResponse('ok')
|
||||||
|
|
||||||
return res['token']
|
dummy_app.add_route('/test', test_route)
|
||||||
|
test_client = TestClient(dummy_app)
|
||||||
|
|
||||||
@pytest.fixture
|
resp = test_client.request('get', '/test',
|
||||||
def token_dirser():
|
cookies={
|
||||||
# This fixture needs to have a running auth-lirmm on 127.0.0.1:3000
|
'Authorization': token_builder
|
||||||
# Sets a valid token
|
|
||||||
|
|
||||||
r = requests.post('http://127.0.0.1:3000/',
|
|
||||||
data={'email':'dhenaut', 'password':'a'})
|
|
||||||
|
|
||||||
if len(r.text) <= 0:
|
|
||||||
raise Exception('No result in token retrieval')
|
|
||||||
|
|
||||||
try:
|
|
||||||
res = json.loads(r.text)
|
|
||||||
except JSONDecodeError:
|
|
||||||
raise Exception('Malformed response from token retrieval')
|
|
||||||
|
|
||||||
if 'token' not in res.keys():
|
|
||||||
raise Exception('Missing token in token request')
|
|
||||||
|
|
||||||
return res['token']
|
|
||||||
|
|
||||||
|
|
||||||
def test_token(token):
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
r = client.get('/user', headers={'Authorization':token})
|
|
||||||
res = False
|
|
||||||
try:
|
|
||||||
res = json.loads(r.text)
|
|
||||||
except JSONDecodeError:
|
|
||||||
raise Exception('Malformed response from /user request')
|
|
||||||
|
|
||||||
assert 'user' in res.keys()
|
|
||||||
assert 'id' in res['user'].keys()
|
|
||||||
assert 'token' in res['user'].keys()
|
|
||||||
assert 'payload' in res['user'].keys()
|
|
||||||
|
|
||||||
def test_labopers(token, token_dirser):
|
|
||||||
res = requests.get('http://127.0.0.1:8080/api/v4/organigramme/laboratoire/personnel',
|
|
||||||
params={
|
|
||||||
'q': 'limit:10|format:csv'
|
|
||||||
},
|
|
||||||
headers={
|
|
||||||
'Authorization': token
|
|
||||||
})
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
assert res.status_code == 401
|
resp = test_client.request('get', '/test',
|
||||||
|
|
||||||
res = requests.get('http://127.0.0.1:8080/api/v4/organigramme/laboratoire/personnel',
|
|
||||||
params={
|
|
||||||
'q': 'limit:10|format:csv'
|
|
||||||
},
|
|
||||||
headers={
|
headers={
|
||||||
'Authorization': token_dirser
|
'Authorization': token_builder
|
||||||
})
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
|
def test_jwt_DebugFalse(dummy_app, token_debug_false_builder):
|
||||||
|
async def test_route(request):
|
||||||
|
assert isinstance(request.user, JWTUser)
|
||||||
|
return PlainTextResponse('ok')
|
||||||
|
|
||||||
|
dummy_app.add_route('/test', test_route)
|
||||||
|
test_client = TestClient(dummy_app)
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
cookies={
|
||||||
|
'Authorization': token_debug_false_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
headers={
|
||||||
|
'Authorization': token_debug_false_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_DebugTrue(dummy_app, token_debug_true_builder):
|
||||||
|
"""
|
||||||
|
A debug token should return a 400 status code with a non debug app
|
||||||
|
"""
|
||||||
|
async def test_route(request):
|
||||||
|
return PlainTextResponse('ok')
|
||||||
|
|
||||||
|
dummy_app.add_route('/test', test_route)
|
||||||
|
test_client = TestClient(dummy_app)
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
cookies={
|
||||||
|
'Authorization': token_debug_true_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
headers={
|
||||||
|
'Authorization': token_debug_true_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_DebugTrue_DebugApp(dummy_debug_app, token_debug_true_builder):
|
||||||
|
"""
|
||||||
|
A debug token should return a 200 status code with a debug app
|
||||||
|
"""
|
||||||
|
async def test_route(request):
|
||||||
|
assert isinstance(request.user, JWTUser)
|
||||||
|
return PlainTextResponse('ok')
|
||||||
|
|
||||||
|
dummy_debug_app.add_route('/test', test_route)
|
||||||
|
test_client = TestClient(dummy_debug_app)
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
cookies={
|
||||||
|
'Authorization': token_debug_true_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = test_client.request('get', '/test',
|
||||||
|
headers={
|
||||||
|
'Authorization': token_debug_true_builder
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
# #!/usr/bin/env python3
|
||||||
|
# import importlib
|
||||||
|
# from halfapi.lib.domain import VERBS, gen_routes, gen_router_routes, \
|
||||||
|
# MissingAclError, domain_schema_dict, domain_schema_list
|
||||||
|
#
|
||||||
|
# from types import FunctionType
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# def test_gen_router_routes():
|
||||||
|
# from .dummy_domain import routers
|
||||||
|
# for path, verb, m_router, fct, params in gen_router_routes(routers, ['dummy_domain']):
|
||||||
|
# assert isinstance(path, str)
|
||||||
|
# assert verb in VERBS
|
||||||
|
# assert len(params) > 0
|
||||||
|
# assert hasattr(fct, '__call__')
|
||||||
|
# assert len(m_router.__file__) > 0
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# def test_gen_routes():
|
||||||
|
# from .dummy_domain.routers.abc.alphabet import TEST_uuid
|
||||||
|
# try:
|
||||||
|
# gen_routes(
|
||||||
|
# TEST_uuid,
|
||||||
|
# 'get',
|
||||||
|
# ['abc', 'alphabet', 'TEST_uuid', ''],
|
||||||
|
# [])
|
||||||
|
# except MissingAclError:
|
||||||
|
# assert True
|
||||||
|
#
|
||||||
|
# fct, params = gen_routes(
|
||||||
|
# TEST_uuid,
|
||||||
|
# 'get',
|
||||||
|
# ['abc', 'alphabet', 'TEST_uuid', ''],
|
||||||
|
# TEST_uuid.ACLS['GET'])
|
||||||
|
#
|
||||||
|
# assert isinstance(fct, FunctionType)
|
||||||
|
# assert isinstance(params, list)
|
||||||
|
# assert len(TEST_uuid.ACLS['GET']) == len(params)
|
||||||
|
#
|
||||||
|
# def test_domain_schema_dict():
|
||||||
|
# from .dummy_domain import routers
|
||||||
|
# d_res = domain_schema_dict(routers)
|
||||||
|
#
|
||||||
|
# assert isinstance(d_res, dict)
|
||||||
|
#
|
||||||
|
# def test_domain_schema_list():
|
||||||
|
# from .dummy_domain import routers
|
||||||
|
# res = domain_schema_list(routers)
|
||||||
|
#
|
||||||
|
# assert isinstance(res, list)
|
||||||
|
# assert len(res) > 0
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from starlette.responses import Response
|
||||||
|
from starlette.routing import Router, Route
|
||||||
|
|
||||||
|
from halfapi.lib.domain import route_decorator
|
||||||
|
from halfapi.lib.user import Nobody
|
||||||
|
|
||||||
|
def test_route_decorator():
|
||||||
|
""" It should decorate an async function that fullfills its arguments
|
||||||
|
"""
|
||||||
|
def route(halfapi, data, out, ret_type='txt'):
|
||||||
|
for key in ['user', 'config', 'domain', 'cookies', 'base_url', 'url']:
|
||||||
|
assert key in halfapi
|
||||||
|
|
||||||
|
assert halfapi['user'] is None
|
||||||
|
assert isinstance(halfapi['config'], dict)
|
||||||
|
assert len(halfapi['config']) == 0
|
||||||
|
assert isinstance(halfapi['domain'], str)
|
||||||
|
assert halfapi['domain'] == 'unknown'
|
||||||
|
assert isinstance(halfapi['cookies'], dict)
|
||||||
|
assert len(halfapi['cookies']) == 0
|
||||||
|
assert len(str(halfapi['base_url'])) > 0
|
||||||
|
assert str(halfapi['base_url']) == 'http://testserver/'
|
||||||
|
assert len(str(halfapi['url'])) > 0
|
||||||
|
assert str(halfapi['url']) == 'http://testserver/'
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert len(data) == 0
|
||||||
|
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
assert ret_type is 'txt'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
async_route = route_decorator(route)
|
||||||
|
app = Router([Route('/', endpoint=async_route, methods=['GET'])])
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get('/')
|
||||||
|
assert response.is_success
|
||||||
|
assert response.content.decode() == ''
|
||||||
|
|
||||||
|
def route(data, out, ret_type='txt'):
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert len(data) == 0
|
||||||
|
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
assert ret_type is 'txt'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
async_route = route_decorator(route)
|
||||||
|
app = Router([Route('/', endpoint=async_route, methods=['GET'])])
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get('/')
|
||||||
|
assert response.is_success
|
||||||
|
assert response.content.decode() == ''
|
||||||
|
|
||||||
|
def route(data):
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data['toto'] == 'tata'
|
||||||
|
assert data['bouboul'] == True
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
async_route = route_decorator(route)
|
||||||
|
app = Router([Route('/', endpoint=async_route, methods=['POST'])])
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post('/', json={'toto': 'tata', 'bouboul': True})
|
||||||
|
assert response.is_success
|
||||||
|
assert response.json() == ''
|
|
@ -0,0 +1,38 @@
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from unittest.mock import patch
|
||||||
|
from halfapi.lib.domain_middleware import DomainMiddleware
|
||||||
|
|
||||||
|
def test_init():
|
||||||
|
with patch('starlette.middleware.base.BaseHTTPMiddleware.__init__') as init:
|
||||||
|
mw = DomainMiddleware('app', 'domain')
|
||||||
|
init.assert_called_once_with('app')
|
||||||
|
assert mw.domain == 'domain'
|
||||||
|
assert mw.request == None
|
||||||
|
|
||||||
|
def test_call(application_debug):
|
||||||
|
c = TestClient(application_debug)
|
||||||
|
r = c.request('get', '/abc/alphabet')
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers['x-domain'] == 'dummy_domain'
|
||||||
|
assert r.headers['x-acl'] == 'public'
|
||||||
|
|
||||||
|
r = c.request('get', '/arguments')
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.headers['x-domain'] == 'dummy_domain'
|
||||||
|
assert r.headers['x-acl'] == 'public'
|
||||||
|
assert 'foo' in r.headers['x-args-required'].split(',')
|
||||||
|
assert 'bar' in r.headers['x-args-required'].split(',')
|
||||||
|
assert r.headers['x-args-optional'] == 'x'
|
||||||
|
|
||||||
|
c = TestClient(application_debug)
|
||||||
|
r = c.request('post', '/arguments')
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.headers['x-domain'] == 'dummy_domain'
|
||||||
|
assert r.headers['x-acl'] == 'public'
|
||||||
|
assert 'foo' in r.headers['x-args-required'].split(',')
|
||||||
|
assert 'baz' in r.headers['x-args-required'].split(',')
|
||||||
|
assert 'truebidoo' in r.headers['x-args-optional'].split(',')
|
||||||
|
assert 'z' in r.headers['x-args-optional'].split(',')
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
import json
|
||||||
|
import decimal
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from starlette.responses import Response
|
||||||
|
from halfapi.lib.responses import *
|
||||||
|
|
||||||
|
def test_orjson():
|
||||||
|
test_obj = {
|
||||||
|
"ok": "ko",
|
||||||
|
"dec": decimal.Decimal(42),
|
||||||
|
"set": {0, 4, 2},
|
||||||
|
"date": datetime.date(1,1,1),
|
||||||
|
"datetime": datetime.datetime(1,1,1),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = ORJSONResponse(test_obj)
|
||||||
|
body = resp.body.decode()
|
||||||
|
test_obj_dec = json.loads(body)
|
||||||
|
print(test_obj_dec)
|
||||||
|
assert 'ok' in test_obj_dec.keys()
|
||||||
|
assert isinstance(test_obj_dec['ok'], str)
|
||||||
|
assert isinstance(test_obj_dec['dec'], str)
|
||||||
|
assert isinstance(test_obj_dec['set'], list)
|
||||||
|
assert isinstance(test_obj_dec['date'], str)
|
||||||
|
assert test_obj_dec['date'] == '0001-01-01'
|
||||||
|
assert test_obj_dec['datetime'] == '0001-01-01T00:00:00'
|
||||||
|
|
||||||
|
|
||||||
|
def test_responses():
|
||||||
|
resp = HJSONResponse('')
|
||||||
|
assert isinstance(resp, Response)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = ORJSONResponse('')
|
||||||
|
assert isinstance(resp, Response)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = PlainTextResponse()
|
||||||
|
assert isinstance(resp, Response)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_errors():
|
||||||
|
resp = ServiceUnavailableResponse()
|
||||||
|
assert isinstance(resp, Response)
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
resp = UnauthorizedResponse()
|
||||||
|
assert isinstance(resp, Response)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
resp = InternalServerErrorResponse()
|
||||||
|
assert isinstance(resp, Response)
|
||||||
|
assert resp.status_code == 500
|
||||||
|
|
||||||
|
resp = NotFoundResponse()
|
||||||
|
assert isinstance(resp, Response)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
resp = NotImplementedResponse()
|
||||||
|
assert isinstance(resp, Response)
|
||||||
|
assert resp.status_code == 501
|
|
@ -0,0 +1,57 @@
|
||||||
|
# import os
|
||||||
|
# import pytest
|
||||||
|
# from schema import SchemaError
|
||||||
|
# from halfapi.lib.router import read_router
|
||||||
|
# from halfapi.lib.constants import ROUTER_SCHEMA, ROUTER_ACLS_SCHEMA
|
||||||
|
#
|
||||||
|
# def test_read_router_routers():
|
||||||
|
# from .dummy_domain import routers
|
||||||
|
#
|
||||||
|
# router_d = read_router(routers)
|
||||||
|
# assert '' in router_d
|
||||||
|
# assert 'SUBROUTES' in router_d['']
|
||||||
|
# assert isinstance(router_d['']['SUBROUTES'], list)
|
||||||
|
#
|
||||||
|
# for elt in os.scandir(routers.__path__[0]):
|
||||||
|
# if elt.is_dir():
|
||||||
|
# assert elt.name in router_d['']['SUBROUTES']
|
||||||
|
#
|
||||||
|
# def test_read_router_abc():
|
||||||
|
# from .dummy_domain.routers import abc
|
||||||
|
# router_d = read_router(abc)
|
||||||
|
#
|
||||||
|
# assert '' in router_d
|
||||||
|
# assert 'SUBROUTES' in router_d['']
|
||||||
|
# assert isinstance(router_d['']['SUBROUTES'], list)
|
||||||
|
#
|
||||||
|
# def test_read_router_alphabet():
|
||||||
|
# from .dummy_domain.routers.abc import alphabet
|
||||||
|
# router_d = read_router(alphabet)
|
||||||
|
#
|
||||||
|
# assert '' in router_d
|
||||||
|
# assert 'SUBROUTES' in router_d['']
|
||||||
|
# assert isinstance(router_d['']['SUBROUTES'], list)
|
||||||
|
#
|
||||||
|
# ROUTER_SCHEMA.validate(router_d)
|
||||||
|
#
|
||||||
|
# with pytest.raises(SchemaError):
|
||||||
|
# """ Test that we cannot specify wrong method in ROUTES or ACLS
|
||||||
|
#
|
||||||
|
# TODO: Write more errors
|
||||||
|
# """
|
||||||
|
# router_d['']['TEG'] = {}
|
||||||
|
# ROUTER_SCHEMA.validate(router_d)
|
||||||
|
#
|
||||||
|
# def test_read_router_TEST():
|
||||||
|
# from .dummy_domain.routers.abc.alphabet import TEST_uuid
|
||||||
|
# router_d = read_router(TEST_uuid)
|
||||||
|
#
|
||||||
|
# print(router_d)
|
||||||
|
# assert '' in router_d
|
||||||
|
# assert 'SUBROUTES' in router_d['']
|
||||||
|
# assert isinstance(router_d['']['GET'], list)
|
||||||
|
# assert isinstance(router_d['']['POST'], list)
|
||||||
|
# assert isinstance(router_d['']['PATCH'], list)
|
||||||
|
# assert isinstance(router_d['']['PUT'], list)
|
||||||
|
#
|
||||||
|
#
|
|
@ -0,0 +1,34 @@
|
||||||
|
# from starlette.routing import Route
|
||||||
|
# from halfapi.lib.routes import gen_starlette_routes, gen_router_routes
|
||||||
|
#
|
||||||
|
# def test_gen_starlette_routes():
|
||||||
|
# from .dummy_domain import routers
|
||||||
|
# for route in gen_starlette_routes({
|
||||||
|
# 'dummy_domain': routers }):
|
||||||
|
#
|
||||||
|
# assert isinstance(route, Route)
|
||||||
|
#
|
||||||
|
# import pytest
|
||||||
|
#
|
||||||
|
# @pytest.mark.skip
|
||||||
|
# def test_api_routes():
|
||||||
|
# from . import dummy_domain
|
||||||
|
# d_res, d_acls = api_routes(dummy_domain)
|
||||||
|
# assert isinstance(d_res, dict)
|
||||||
|
# assert isinstance(d_acls, dict)
|
||||||
|
#
|
||||||
|
# yielded = False
|
||||||
|
#
|
||||||
|
# for path, verb, m_router, fct, params in gen_router_routes(dummy_domain, []):
|
||||||
|
# if not yielded:
|
||||||
|
# yielded = True
|
||||||
|
#
|
||||||
|
# assert path in d_res
|
||||||
|
# assert verb in d_res[path]
|
||||||
|
# assert 'docs' in d_res[path][verb]
|
||||||
|
# assert 'acls' in d_res[path][verb]
|
||||||
|
# assert isinstance(d_res[path][verb]['docs'], dict)
|
||||||
|
# assert isinstance(d_res[path][verb]['acls'], list)
|
||||||
|
# assert len(d_res[path][verb]['acls']) == len(params)
|
||||||
|
#
|
||||||
|
# assert yielded is True
|
Loading…
Reference in New Issue