├── .flaskenv ├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── application.py ├── apps ├── __init__.py ├── api │ ├── __init__.py │ ├── account │ │ ├── __init__.py │ │ ├── adapters │ │ │ ├── __init__.py │ │ │ ├── presenters │ │ │ │ └── __init__.py │ │ │ ├── request │ │ │ │ └── __init__.py │ │ │ ├── response │ │ │ │ └── __init__.py │ │ │ └── validators │ │ │ │ └── __init__.py │ │ └── controllers │ │ │ ├── __init__.py │ │ │ └── security.py │ ├── account_v1.py │ ├── profile │ │ ├── __init__.py │ │ ├── adapters │ │ │ ├── __init__.py │ │ │ ├── presenters │ │ │ │ ├── __init__.py │ │ │ │ └── profile_presenter.py │ │ │ ├── request │ │ │ │ ├── __init__.py │ │ │ │ └── profile_request.py │ │ │ ├── response │ │ │ │ ├── __init__.py │ │ │ │ └── profile_response.py │ │ │ └── validators │ │ │ │ └── __init__.py │ │ └── controllers │ │ │ ├── __init__.py │ │ │ ├── avatar.py │ │ │ ├── member.py │ │ │ └── search.py │ └── profile_v1.py └── shared │ ├── __init__.py │ └── global_exception.py ├── config ├── __init__.py ├── base_config.py ├── dev_config.py └── prod_config.py ├── core ├── __init__.py ├── domain │ ├── __init__.py │ ├── account │ │ ├── __init__.py │ │ ├── entity │ │ │ └── __init__.py │ │ ├── repository │ │ │ ├── __init__.py │ │ │ └── authorize_repository.py │ │ └── use_case │ │ │ ├── __init__.py │ │ │ └── authorize_user.py │ └── profile │ │ ├── __init__.py │ │ ├── entity │ │ ├── __init__.py │ │ ├── profile.py │ │ └── user.py │ │ ├── exception.py │ │ ├── repository │ │ ├── __init__.py │ │ └── profile_repository.py │ │ └── use_case │ │ ├── __init__.py │ │ └── get_user_profile.py └── kernel │ ├── __init__.py │ ├── entity.py │ ├── exception.py │ ├── port.py │ ├── repository.py │ └── use_case.py ├── extensions ├── __init__.py ├── config_extension.py ├── database_extension.py ├── exception_extension.py ├── hooks_extension.py ├── injector_extension.py ├── log_extension.py ├── oidc_extension.py └── routes_extension.py ├── infra ├── __init__.py ├── mock │ ├── __init__.py │ └── repository.py └── sql │ ├── __init__.py │ ├── account │ ├── __init__.py │ ├── orm.py │ └── repository │ │ └── __init__.py │ └── profile │ ├── __init__.py │ ├── orm.py │ └── repository │ ├── __init__.py │ └── sql_profile_repository.py ├── run.py └── tests └── __init__.py /.flaskenv: -------------------------------------------------------------------------------- 1 | # .flaskenv 2 | FLASK_APP = "run" 3 | FLASK_ENV = "development" 4 | FLASK_RUN_HOST = '0.0.0.0' 5 | FLASK_RUN_PORT = '5000' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 chonhan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pylint = "*" 8 | autopep8 = "*" 9 | 10 | [packages] 11 | flask = "*" 12 | python-dotenv = "*" 13 | authlib = "*" 14 | flask-restplus = "*" 15 | werkzeug = "==0.16.1" 16 | simplejson = "*" 17 | flask-injector = "*" 18 | attrs = "*" 19 | cattrs = "*" 20 | 21 | [requires] 22 | python_version = "3" 23 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "17aa20b3ad62a0cb2ee0391d6589b7005fcd5962acc235ae8053badff2b69c6a" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aniso8601": { 20 | "hashes": [ 21 | "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f", 22 | "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973" 23 | ], 24 | "version": "==9.0.1" 25 | }, 26 | "attrs": { 27 | "hashes": [ 28 | "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", 29 | "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" 30 | ], 31 | "index": "pypi", 32 | "version": "==20.3.0" 33 | }, 34 | "authlib": { 35 | "hashes": [ 36 | "sha256:0f6af3a38d37dd77361808dd3f2e258b647668dac6d2cefcefc4c4ebc3c7d2b2", 37 | "sha256:7dde11ba45db51e97169c261362fab3193073100b7387e60c159db1eec470bbc" 38 | ], 39 | "index": "pypi", 40 | "version": "==0.15.3" 41 | }, 42 | "cattrs": { 43 | "hashes": [ 44 | "sha256:12688f56fbb7f54cf647d031669840e1ab0b9a198bf374a217fcb5be821855df", 45 | "sha256:f92ca39ccb7373289f9cccf71b86849a29a2d75370bc983e7bf579ce95bfccd8" 46 | ], 47 | "index": "pypi", 48 | "version": "==1.3.0" 49 | }, 50 | "cffi": { 51 | "hashes": [ 52 | "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", 53 | "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", 54 | "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", 55 | "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", 56 | "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", 57 | "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", 58 | "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", 59 | "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", 60 | "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", 61 | "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", 62 | "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", 63 | "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", 64 | "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", 65 | "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", 66 | "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", 67 | "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", 68 | "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", 69 | "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", 70 | "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", 71 | "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", 72 | "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", 73 | "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", 74 | "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", 75 | "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", 76 | "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", 77 | "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", 78 | "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", 79 | "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", 80 | "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", 81 | "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", 82 | "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", 83 | "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", 84 | "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", 85 | "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", 86 | "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", 87 | "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", 88 | "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", 89 | "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", 90 | "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", 91 | "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", 92 | "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", 93 | "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", 94 | "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", 95 | "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", 96 | "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", 97 | "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", 98 | "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", 99 | "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", 100 | "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", 101 | "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", 102 | "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", 103 | "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", 104 | "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", 105 | "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", 106 | "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", 107 | "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", 108 | "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", 109 | "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", 110 | "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", 111 | "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", 112 | "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", 113 | "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", 114 | "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", 115 | "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" 116 | ], 117 | "version": "==1.15.1" 118 | }, 119 | "click": { 120 | "hashes": [ 121 | "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", 122 | "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" 123 | ], 124 | "markers": "python_version >= '3.7'", 125 | "version": "==8.1.3" 126 | }, 127 | "cryptography": { 128 | "hashes": [ 129 | "sha256:0f8da300b5c8af9f98111ffd512910bc792b4c77392a9523624680f7956a99d4", 130 | "sha256:35f7c7d015d474f4011e859e93e789c87d21f6f4880ebdc29896a60403328f1f", 131 | "sha256:5aa67414fcdfa22cf052e640cb5ddc461924a045cacf325cd164e65312d99502", 132 | "sha256:5d2d8b87a490bfcd407ed9d49093793d0f75198a35e6eb1a923ce1ee86c62b41", 133 | "sha256:6687ef6d0a6497e2b58e7c5b852b53f62142cfa7cd1555795758934da363a965", 134 | "sha256:6f8ba7f0328b79f08bdacc3e4e66fb4d7aab0c3584e0bd41328dce5262e26b2e", 135 | "sha256:706843b48f9a3f9b9911979761c91541e3d90db1ca905fd63fee540a217698bc", 136 | "sha256:807ce09d4434881ca3a7594733669bd834f5b2c6d5c7e36f8c00f691887042ad", 137 | "sha256:83e17b26de248c33f3acffb922748151d71827d6021d98c70e6c1a25ddd78505", 138 | "sha256:96f1157a7c08b5b189b16b47bc9db2332269d6680a196341bf30046330d15388", 139 | "sha256:aec5a6c9864be7df2240c382740fcf3b96928c46604eaa7f3091f58b878c0bb6", 140 | "sha256:b0afd054cd42f3d213bf82c629efb1ee5f22eba35bf0eec88ea9ea7304f511a2", 141 | "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac", 142 | "sha256:d1f6198ee6d9148405e49887803907fe8962a23e6c6f83ea7d98f1c0de375695", 143 | "sha256:e124352fd3db36a9d4a21c1aa27fd5d051e621845cb87fb851c08f4f75ce8be6", 144 | "sha256:e422abdec8b5fa8462aa016786680720d78bdce7a30c652b7fadf83a4ba35336", 145 | "sha256:ef8b72fa70b348724ff1218267e7f7375b8de4e8194d1636ee60510aae104cd0", 146 | "sha256:f0c64d1bd842ca2633e74a1a28033d139368ad959872533b1bab8c80e8240a0c", 147 | "sha256:f24077a3b5298a5a06a8e0536e3ea9ec60e4c7ac486755e5fb6e6ea9b3500106", 148 | "sha256:fdd188c8a6ef8769f148f88f859884507b954cc64db6b52f66ef199bb9ad660a", 149 | "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8" 150 | ], 151 | "index": "pypi", 152 | "version": "==39.0.1" 153 | }, 154 | "flask": { 155 | "hashes": [ 156 | "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", 157 | "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" 158 | ], 159 | "index": "pypi", 160 | "version": "==1.1.2" 161 | }, 162 | "flask-injector": { 163 | "hashes": [ 164 | "sha256:82f5bf1245f6fbdbd19eeea4289fe35a3417a8ce05f710847a2f96b771a8085c", 165 | "sha256:d0a5449b7d5f67f259a97e27dfb1111b48cfa418bce29d01c3beaa3493e248e4" 166 | ], 167 | "index": "pypi", 168 | "version": "==0.12.3" 169 | }, 170 | "flask-restplus": { 171 | "hashes": [ 172 | "sha256:a15d251923a8feb09a5d805c2f4d188555910a42c64d58f7dd281b8cac095f1b", 173 | "sha256:a66e442d0bca08f389fc3d07b4d808fc89961285d12fb8013f7cf15516fa9f5c" 174 | ], 175 | "index": "pypi", 176 | "version": "==0.13.0" 177 | }, 178 | "injector": { 179 | "hashes": [ 180 | "sha256:8661b49a2f8309ce61e3a6a82b7acb5e225c4bde8e17d1610c893a670dff223a", 181 | "sha256:f8fc5994176a8cf6b0455a8d1558c588733474ef17795553464e7e9d2f94eaf5" 182 | ], 183 | "version": "==0.20.1" 184 | }, 185 | "itsdangerous": { 186 | "hashes": [ 187 | "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", 188 | "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a" 189 | ], 190 | "markers": "python_version >= '3.7'", 191 | "version": "==2.1.2" 192 | }, 193 | "jinja2": { 194 | "hashes": [ 195 | "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", 196 | "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" 197 | ], 198 | "markers": "python_version >= '3.7'", 199 | "version": "==3.1.2" 200 | }, 201 | "jsonschema": { 202 | "hashes": [ 203 | "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d", 204 | "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6" 205 | ], 206 | "markers": "python_version >= '3.7'", 207 | "version": "==4.17.3" 208 | }, 209 | "markupsafe": { 210 | "hashes": [ 211 | "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed", 212 | "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc", 213 | "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2", 214 | "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460", 215 | "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7", 216 | "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0", 217 | "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1", 218 | "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa", 219 | "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03", 220 | "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323", 221 | "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65", 222 | "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013", 223 | "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036", 224 | "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f", 225 | "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4", 226 | "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419", 227 | "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2", 228 | "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619", 229 | "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a", 230 | "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a", 231 | "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd", 232 | "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7", 233 | "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666", 234 | "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65", 235 | "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859", 236 | "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625", 237 | "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff", 238 | "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156", 239 | "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd", 240 | "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba", 241 | "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f", 242 | "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1", 243 | "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094", 244 | "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a", 245 | "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513", 246 | "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed", 247 | "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d", 248 | "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3", 249 | "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147", 250 | "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c", 251 | "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603", 252 | "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601", 253 | "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a", 254 | "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1", 255 | "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d", 256 | "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3", 257 | "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54", 258 | "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2", 259 | "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6", 260 | "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58" 261 | ], 262 | "markers": "python_version >= '3.7'", 263 | "version": "==2.1.2" 264 | }, 265 | "pycparser": { 266 | "hashes": [ 267 | "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", 268 | "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" 269 | ], 270 | "version": "==2.21" 271 | }, 272 | "pyrsistent": { 273 | "hashes": [ 274 | "sha256:016ad1afadf318eb7911baa24b049909f7f3bb2c5b1ed7b6a8f21db21ea3faa8", 275 | "sha256:1a2994773706bbb4995c31a97bc94f1418314923bd1048c6d964837040376440", 276 | "sha256:20460ac0ea439a3e79caa1dbd560344b64ed75e85d8703943e0b66c2a6150e4a", 277 | "sha256:3311cb4237a341aa52ab8448c27e3a9931e2ee09561ad150ba94e4cfd3fc888c", 278 | "sha256:3a8cb235fa6d3fd7aae6a4f1429bbb1fec1577d978098da1252f0489937786f3", 279 | "sha256:3ab2204234c0ecd8b9368dbd6a53e83c3d4f3cab10ecaf6d0e772f456c442393", 280 | "sha256:42ac0b2f44607eb92ae88609eda931a4f0dfa03038c44c772e07f43e738bcac9", 281 | "sha256:49c32f216c17148695ca0e02a5c521e28a4ee6c5089f97e34fe24163113722da", 282 | "sha256:4b774f9288dda8d425adb6544e5903f1fb6c273ab3128a355c6b972b7df39dcf", 283 | "sha256:4c18264cb84b5e68e7085a43723f9e4c1fd1d935ab240ce02c0324a8e01ccb64", 284 | "sha256:5a474fb80f5e0d6c9394d8db0fc19e90fa540b82ee52dba7d246a7791712f74a", 285 | "sha256:64220c429e42a7150f4bfd280f6f4bb2850f95956bde93c6fda1b70507af6ef3", 286 | "sha256:878433581fc23e906d947a6814336eee031a00e6defba224234169ae3d3d6a98", 287 | "sha256:99abb85579e2165bd8522f0c0138864da97847875ecbd45f3e7e2af569bfc6f2", 288 | "sha256:a2471f3f8693101975b1ff85ffd19bb7ca7dd7c38f8a81701f67d6b4f97b87d8", 289 | "sha256:aeda827381f5e5d65cced3024126529ddc4289d944f75e090572c77ceb19adbf", 290 | "sha256:b735e538f74ec31378f5a1e3886a26d2ca6351106b4dfde376a26fc32a044edc", 291 | "sha256:c147257a92374fde8498491f53ffa8f4822cd70c0d85037e09028e478cababb7", 292 | "sha256:c4db1bd596fefd66b296a3d5d943c94f4fac5bcd13e99bffe2ba6a759d959a28", 293 | "sha256:c74bed51f9b41c48366a286395c67f4e894374306b197e62810e0fdaf2364da2", 294 | "sha256:c9bb60a40a0ab9aba40a59f68214eed5a29c6274c83b2cc206a359c4a89fa41b", 295 | "sha256:cc5d149f31706762c1f8bda2e8c4f8fead6e80312e3692619a75301d3dbb819a", 296 | "sha256:ccf0d6bd208f8111179f0c26fdf84ed7c3891982f2edaeae7422575f47e66b64", 297 | "sha256:e42296a09e83028b3476f7073fcb69ffebac0e66dbbfd1bd847d61f74db30f19", 298 | "sha256:e8f2b814a3dc6225964fa03d8582c6e0b6650d68a232df41e3cc1b66a5d2f8d1", 299 | "sha256:f0774bf48631f3a20471dd7c5989657b639fd2d285b861237ea9e82c36a415a9", 300 | "sha256:f0e7c4b2f77593871e918be000b96c8107da48444d57005b6a6bc61fb4331b2c" 301 | ], 302 | "markers": "python_version >= '3.7'", 303 | "version": "==0.19.3" 304 | }, 305 | "python-dotenv": { 306 | "hashes": [ 307 | "sha256:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e", 308 | "sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0" 309 | ], 310 | "index": "pypi", 311 | "version": "==0.15.0" 312 | }, 313 | "pytz": { 314 | "hashes": [ 315 | "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0", 316 | "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a" 317 | ], 318 | "version": "==2022.7.1" 319 | }, 320 | "simplejson": { 321 | "hashes": [ 322 | "sha256:034550078a11664d77bc1a8364c90bb7eef0e44c2dbb1fd0a4d92e3997088667", 323 | "sha256:05b43d568300c1cd43f95ff4bfcff984bc658aa001be91efb3bb21df9d6288d3", 324 | "sha256:0dd9d9c738cb008bfc0862c9b8fa6743495c03a0ed543884bf92fb7d30f8d043", 325 | "sha256:10fc250c3edea4abc15d930d77274ddb8df4803453dde7ad50c2f5565a18a4bb", 326 | "sha256:2862beabfb9097a745a961426fe7daf66e1714151da8bb9a0c430dde3d59c7c0", 327 | "sha256:292c2e3f53be314cc59853bd20a35bf1f965f3bc121e007ab6fd526ed412a85d", 328 | "sha256:2d3eab2c3fe52007d703a26f71cf649a8c771fcdd949a3ae73041ba6797cfcf8", 329 | "sha256:2e7b57c2c146f8e4dadf84977a83f7ee50da17c8861fd7faf694d55e3274784f", 330 | "sha256:311f5dc2af07361725033b13cc3d0351de3da8bede3397d45650784c3f21fbcf", 331 | "sha256:344e2d920a7f27b4023c087ab539877a1e39ce8e3e90b867e0bfa97829824748", 332 | "sha256:3fabde09af43e0cbdee407555383063f8b45bfb52c361bc5da83fcffdb4fd278", 333 | "sha256:42b8b8dd0799f78e067e2aaae97e60d58a8f63582939af60abce4c48631a0aa4", 334 | "sha256:4b3442249d5e3893b90cb9f72c7d6ce4d2ea144d2c0d9f75b9ae1e5460f3121a", 335 | "sha256:55d65f9cc1b733d85ef95ab11f559cce55c7649a2160da2ac7a078534da676c8", 336 | "sha256:5c659a0efc80aaaba57fcd878855c8534ecb655a28ac8508885c50648e6e659d", 337 | "sha256:72d8a3ffca19a901002d6b068cf746be85747571c6a7ba12cbcf427bfb4ed971", 338 | "sha256:75ecc79f26d99222a084fbdd1ce5aad3ac3a8bd535cd9059528452da38b68841", 339 | "sha256:76ac9605bf2f6d9b56abf6f9da9047a8782574ad3531c82eae774947ae99cc3f", 340 | "sha256:7d276f69bfc8c7ba6c717ba8deaf28f9d3c8450ff0aa8713f5a3280e232be16b", 341 | "sha256:7f10f8ba9c1b1430addc7dd385fc322e221559d3ae49b812aebf57470ce8de45", 342 | "sha256:8042040af86a494a23c189b5aa0ea9433769cc029707833f261a79c98e3375f9", 343 | "sha256:813846738277729d7db71b82176204abc7fdae2f566e2d9fcf874f9b6472e3e6", 344 | "sha256:845a14f6deb124a3bcb98a62def067a67462a000e0508f256f9c18eff5847efc", 345 | "sha256:869a183c8e44bc03be1b2bbcc9ec4338e37fa8557fc506bf6115887c1d3bb956", 346 | "sha256:8acf76443cfb5c949b6e781c154278c059b09ac717d2757a830c869ba000cf8d", 347 | "sha256:8f713ea65958ef40049b6c45c40c206ab363db9591ff5a49d89b448933fa5746", 348 | "sha256:934115642c8ba9659b402c8bdbdedb48651fb94b576e3b3efd1ccb079609b04a", 349 | "sha256:9551f23e09300a9a528f7af20e35c9f79686d46d646152a0c8fc41d2d074d9b0", 350 | "sha256:9a2b7543559f8a1c9ed72724b549d8cc3515da7daf3e79813a15bdc4a769de25", 351 | "sha256:a55c76254d7cf8d4494bc508e7abb993a82a192d0db4552421e5139235604625", 352 | "sha256:ad8f41c2357b73bc9e8606d2fa226233bf4d55d85a8982ecdfd55823a6959995", 353 | "sha256:af4868da7dd53296cd7630687161d53a7ebe2e63814234631445697bd7c29f46", 354 | "sha256:afebfc3dd3520d37056f641969ce320b071bc7a0800639c71877b90d053e087f", 355 | "sha256:b59aa298137ca74a744c1e6e22cfc0bf9dca3a2f41f51bc92eb05695155d905a", 356 | "sha256:bc00d1210567a4cdd215ac6e17dc00cb9893ee521cee701adfd0fa43f7c73139", 357 | "sha256:c1cb29b1fced01f97e6d5631c3edc2dadb424d1f4421dad079cb13fc97acb42f", 358 | "sha256:c94dc64b1a389a416fc4218cd4799aa3756f25940cae33530a4f7f2f54f166da", 359 | "sha256:ceaa28a5bce8a46a130cd223e895080e258a88d51bf6e8de2fc54a6ef7e38c34", 360 | "sha256:cff6453e25204d3369c47b97dd34783ca820611bd334779d22192da23784194b", 361 | "sha256:d0b64409df09edb4c365d95004775c988259efe9be39697d7315c42b7a5e7e94", 362 | "sha256:d4813b30cb62d3b63ccc60dd12f2121780c7a3068db692daeb90f989877aaf04", 363 | "sha256:da3c55cdc66cfc3fffb607db49a42448785ea2732f055ac1549b69dcb392663b", 364 | "sha256:e058c7656c44fb494a11443191e381355388443d543f6fc1a245d5d238544396", 365 | "sha256:fed0f22bf1313ff79c7fc318f7199d6c2f96d4de3234b2f12a1eab350e597c06", 366 | "sha256:ffd4e4877a78c84d693e491b223385e0271278f5f4e1476a4962dca6824ecfeb" 367 | ], 368 | "index": "pypi", 369 | "version": "==3.17.2" 370 | }, 371 | "six": { 372 | "hashes": [ 373 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 374 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 375 | ], 376 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 377 | "version": "==1.16.0" 378 | }, 379 | "werkzeug": { 380 | "hashes": [ 381 | "sha256:1e0dedc2acb1f46827daa2e399c1485c8fa17c0d8e70b6b875b4e7f54bf408d2", 382 | "sha256:b353856d37dec59d6511359f97f6a4b2468442e454bd1c98298ddce53cac1f04" 383 | ], 384 | "index": "pypi", 385 | "version": "==0.16.1" 386 | } 387 | }, 388 | "develop": { 389 | "astroid": { 390 | "hashes": [ 391 | "sha256:2476b7f0d6cec13f4c1f53b54bea2ce072310ac9fc7acb669d5270190c748042", 392 | "sha256:f0c8bfebc3da61bde8e3e3df1e879dc16e6d26d6078ddc34813fcb07045782d3" 393 | ], 394 | "markers": "python_version ~= '3.6'", 395 | "version": "==2.5.8" 396 | }, 397 | "autopep8": { 398 | "hashes": [ 399 | "sha256:5454e6e9a3d02aae38f866eec0d9a7de4ab9f93c10a273fb0340f3d6d09f7514", 400 | "sha256:f01b06a6808bc31698db907761e5890eb2295e287af53f6693b39ce55454034a" 401 | ], 402 | "index": "pypi", 403 | "version": "==1.5.6" 404 | }, 405 | "isort": { 406 | "hashes": [ 407 | "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", 408 | "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" 409 | ], 410 | "markers": "python_full_version >= '3.8.0'", 411 | "version": "==5.12.0" 412 | }, 413 | "lazy-object-proxy": { 414 | "hashes": [ 415 | "sha256:09763491ce220c0299688940f8dc2c5d05fd1f45af1e42e636b2e8b2303e4382", 416 | "sha256:0a891e4e41b54fd5b8313b96399f8b0e173bbbfc03c7631f01efbe29bb0bcf82", 417 | "sha256:189bbd5d41ae7a498397287c408617fe5c48633e7755287b21d741f7db2706a9", 418 | "sha256:18b78ec83edbbeb69efdc0e9c1cb41a3b1b1ed11ddd8ded602464c3fc6020494", 419 | "sha256:1aa3de4088c89a1b69f8ec0dcc169aa725b0ff017899ac568fe44ddc1396df46", 420 | "sha256:212774e4dfa851e74d393a2370871e174d7ff0ebc980907723bb67d25c8a7c30", 421 | "sha256:2d0daa332786cf3bb49e10dc6a17a52f6a8f9601b4cf5c295a4f85854d61de63", 422 | "sha256:5f83ac4d83ef0ab017683d715ed356e30dd48a93746309c8f3517e1287523ef4", 423 | "sha256:659fb5809fa4629b8a1ac5106f669cfc7bef26fbb389dda53b3e010d1ac4ebae", 424 | "sha256:660c94ea760b3ce47d1855a30984c78327500493d396eac4dfd8bd82041b22be", 425 | "sha256:66a3de4a3ec06cd8af3f61b8e1ec67614fbb7c995d02fa224813cb7afefee701", 426 | "sha256:721532711daa7db0d8b779b0bb0318fa87af1c10d7fe5e52ef30f8eff254d0cd", 427 | "sha256:7322c3d6f1766d4ef1e51a465f47955f1e8123caee67dd641e67d539a534d006", 428 | "sha256:79a31b086e7e68b24b99b23d57723ef7e2c6d81ed21007b6281ebcd1688acb0a", 429 | "sha256:81fc4d08b062b535d95c9ea70dbe8a335c45c04029878e62d744bdced5141586", 430 | "sha256:8fa02eaab317b1e9e03f69aab1f91e120e7899b392c4fc19807a8278a07a97e8", 431 | "sha256:9090d8e53235aa280fc9239a86ae3ea8ac58eff66a705fa6aa2ec4968b95c821", 432 | "sha256:946d27deaff6cf8452ed0dba83ba38839a87f4f7a9732e8f9fd4107b21e6ff07", 433 | "sha256:9990d8e71b9f6488e91ad25f322898c136b008d87bf852ff65391b004da5e17b", 434 | "sha256:9cd077f3d04a58e83d04b20e334f678c2b0ff9879b9375ed107d5d07ff160171", 435 | "sha256:9e7551208b2aded9c1447453ee366f1c4070602b3d932ace044715d89666899b", 436 | "sha256:9f5fa4a61ce2438267163891961cfd5e32ec97a2c444e5b842d574251ade27d2", 437 | "sha256:b40387277b0ed2d0602b8293b94d7257e17d1479e257b4de114ea11a8cb7f2d7", 438 | "sha256:bfb38f9ffb53b942f2b5954e0f610f1e721ccebe9cce9025a38c8ccf4a5183a4", 439 | "sha256:cbf9b082426036e19c6924a9ce90c740a9861e2bdc27a4834fd0a910742ac1e8", 440 | "sha256:d9e25ef10a39e8afe59a5c348a4dbf29b4868ab76269f81ce1674494e2565a6e", 441 | "sha256:db1c1722726f47e10e0b5fdbf15ac3b8adb58c091d12b3ab713965795036985f", 442 | "sha256:e7c21c95cae3c05c14aafffe2865bbd5e377cfc1348c4f7751d9dc9a48ca4bda", 443 | "sha256:e8c6cfb338b133fbdbc5cfaa10fe3c6aeea827db80c978dbd13bc9dd8526b7d4", 444 | "sha256:ea806fd4c37bf7e7ad82537b0757999264d5f70c45468447bb2b91afdbe73a6e", 445 | "sha256:edd20c5a55acb67c7ed471fa2b5fb66cb17f61430b7a6b9c3b4a1e40293b1671", 446 | "sha256:f0117049dd1d5635bbff65444496c90e0baa48ea405125c088e93d9cf4525b11", 447 | "sha256:f0705c376533ed2a9e5e97aacdbfe04cecd71e0aa84c7c0595d02ef93b6e4455", 448 | "sha256:f12ad7126ae0c98d601a7ee504c1122bcef553d1d5e0c3bfa77b16b3968d2734", 449 | "sha256:f2457189d8257dd41ae9b434ba33298aec198e30adf2dcdaaa3a28b9994f6adb", 450 | "sha256:f699ac1c768270c9e384e4cbd268d6e67aebcfae6cd623b4d7c3bfde5a35db59" 451 | ], 452 | "markers": "python_version >= '3.7'", 453 | "version": "==1.9.0" 454 | }, 455 | "mccabe": { 456 | "hashes": [ 457 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 458 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 459 | ], 460 | "version": "==0.6.1" 461 | }, 462 | "pycodestyle": { 463 | "hashes": [ 464 | "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", 465 | "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" 466 | ], 467 | "markers": "python_version >= '3.6'", 468 | "version": "==2.10.0" 469 | }, 470 | "pylint": { 471 | "hashes": [ 472 | "sha256:0e21d3b80b96740909d77206d741aa3ce0b06b41be375d92e1f3244a274c1f8a", 473 | "sha256:d09b0b07ba06bcdff463958f53f23df25e740ecd81895f7d2699ec04bbd8dc3b" 474 | ], 475 | "index": "pypi", 476 | "version": "==2.7.2" 477 | }, 478 | "toml": { 479 | "hashes": [ 480 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 481 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 482 | ], 483 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 484 | "version": "==0.10.2" 485 | }, 486 | "wrapt": { 487 | "hashes": [ 488 | "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" 489 | ], 490 | "version": "==1.12.1" 491 | } 492 | } 493 | } 494 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask REST API Clean Architecture Practice 2 | A Clean Architecture Practice with Flask REST API. 3 | 4 | This is a practice project I used to learn Clean Architecture by implementing the REST API with a full Authentication/Authorization Protocols, Dependency Injection and furthermore the Swagger documentation. 5 | 6 | ## Basic Folder Structure 7 | 8 | > application.py 9 | 10 | The major application declaration file. 11 | 12 | > apps 13 | 14 | The Application Layer that defines API controller endpoint, global exceptions and also Request/Response/Presenter/Validator adapters. 15 | 16 | > config 17 | 18 | Application Configuration files are here. 19 | 20 | > core 21 | 22 | The core concept of the Clean Architecture practice. The `kernel` part is about the interface and abstract class definitions. The `core` part contains the business logic and the domains objects such as **entity**, **value object**, **use case** and also the **repository**. 23 | 24 | > extensions 25 | 26 | Some configuration and plugins I used, to make the application itself cleaner. 27 | 28 | > infra 29 | 30 | The Infrastructure Layer that provides the actual implementation of network, persistent, cache layer... etc. 31 | 32 | > tests 33 | 34 | Test folder, not implemented yet. 35 | 36 | ## Dependencies 37 | * `flask`: Base Web Framework 38 | * `werkzeug`: Utility Library under Flask 39 | * `authlib`: OpenID Connect Provider Library 40 | * `flask-restplus` = REST API, Swagger Library 41 | * `flask-injector` = Dependency Injection 42 | * `attrs` = Data Classes Utility LIbrary 43 | * `cattrs` = Serialization / Deserialization 44 | 45 | ## Run the project 46 | ``` 47 | > pipenv sync 48 | ``` 49 | 50 | Enter Shell 51 | ``` 52 | > pipenv shell 53 | ``` 54 | 55 | Start App 56 | ``` 57 | > flask run 58 | ``` 59 | 60 | Sample API request 61 | 62 | - http://localhost:5000/profile/v1/member/111/ 63 | 64 | 65 | API Documentation 66 | 67 | - http://localhost:5000/account/v1/doc/ 68 | 69 | - http://localhost:5000/profile/v1/doc/ 70 | 71 | 72 | ## TODO Items for POC 73 | - [x] Apply Clean Architecture 74 | - [x] Layer Abstraction 75 | - [x] Dependency Injection 76 | - [x] UseCase Implementation 77 | - [x] Serialization / Deserialization 78 | - [x] Mock Repo Implementation 79 | - [x] Handle Exceptions 80 | - [x] Response Marshalling 81 | - [x] Review usecase.execute() with Req/Resp 82 | - [x] Review API Documentation 83 | - [ ] Implement Full Story with Entity, ValueObject 84 | - [ ] Request Validation with Marshmallow 85 | - [ ] Deal with Date/DateTime 86 | - [ ] Database with SQLAlchemy 87 | - [ ] Logging 88 | - [ ] OAuth2 with Authlib Implementation 89 | - [ ] Authentication to Resource API 90 | - [ ] Dev/Prod Configuration 91 | - [ ] Apply Tests 92 | - [ ] WSGI Settings 93 | 94 | ## Reference 95 | 96 | - [Flask](https://flask.palletsprojects.com/en/1.1.x/) 97 | - [Flask RESTPlus](https://flask-restplus.readthedocs.io/en/stable/) 98 | - [Injector](https://github.com/alecthomas/injector) 99 | - [Flask Injector](https://github.com/alecthomas/flask_injector) 100 | - [attrs](https://www.attrs.org/en/stable/) 101 | - [cattrs](https://github.com/Tinche/cattrs) 102 | - [Authlib](https://docs.authlib.org/en/latest/) 103 | - [marshmallow](https://marshmallow.readthedocs.io/en/stable/) 104 | - [SQLAlchemy](https://www.sqlalchemy.org/) 105 | -------------------------------------------------------------------------------- /application.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from extensions.routes_extension import register_routes 4 | from extensions.injector_extension import register_dependency_injection 5 | from extensions.exception_extension import register_exception_handler 6 | 7 | 8 | def create_app(): 9 | app = Flask(__name__) 10 | 11 | # will move to register_config soon 12 | app.config['ERROR_404_HELP'] = False 13 | 14 | register_routes(app) 15 | register_exception_handler(app) 16 | register_dependency_injection(app) 17 | return app 18 | -------------------------------------------------------------------------------- /apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/__init__.py -------------------------------------------------------------------------------- /apps/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/__init__.py -------------------------------------------------------------------------------- /apps/api/account/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/account/__init__.py -------------------------------------------------------------------------------- /apps/api/account/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/account/adapters/__init__.py -------------------------------------------------------------------------------- /apps/api/account/adapters/presenters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/account/adapters/presenters/__init__.py -------------------------------------------------------------------------------- /apps/api/account/adapters/request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/account/adapters/request/__init__.py -------------------------------------------------------------------------------- /apps/api/account/adapters/response/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/account/adapters/response/__init__.py -------------------------------------------------------------------------------- /apps/api/account/adapters/validators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/account/adapters/validators/__init__.py -------------------------------------------------------------------------------- /apps/api/account/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/account/controllers/__init__.py -------------------------------------------------------------------------------- /apps/api/account/controllers/security.py: -------------------------------------------------------------------------------- 1 | from flask_restplus import Namespace, Resource 2 | 3 | from extensions.log_extension import get_logger 4 | 5 | api = Namespace('security', description='Security Endpoints') 6 | logger = get_logger(__name__) 7 | 8 | 9 | @api.route('/authorize') 10 | class Authorize(Resource): 11 | @api.doc('Authorize Requests') 12 | def get(self): 13 | logger.info('authorize') 14 | return ['authorize'] 15 | 16 | 17 | @api.route('/logout') 18 | class Logout(Resource): 19 | @api.doc('Logout Endpoint') 20 | def get(self): 21 | logger.warn('logout') 22 | return {'logout': True} 23 | 24 | 25 | @api.route('/inquiry') 26 | class Inquiry(Resource): 27 | @api.doc('Query Account') 28 | def get(self): 29 | logger.debug('inquiry') 30 | return ['Query'] 31 | 32 | 33 | @api.route('/token') 34 | class Token(Resource): 35 | @api.doc('Exchange Tokens') 36 | def get(self): 37 | logger.info('token') 38 | return ['Token'] 39 | -------------------------------------------------------------------------------- /apps/api/account_v1.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_restplus import Api 3 | from werkzeug.exceptions import HTTPException 4 | 5 | from .account.controllers.security import api as security_api 6 | 7 | blueprint = Blueprint('account_api', __name__, url_prefix='/account/v1') 8 | 9 | api = Api(blueprint, 10 | doc='/doc/', 11 | title='Resource API - Account', 12 | version='1.0', 13 | description='A description' 14 | ) 15 | 16 | api.add_namespace(security_api) 17 | 18 | 19 | @api.errorhandler(HTTPException) 20 | def handle_error(error: HTTPException): 21 | """ Handle BluePrint JSON Error Response """ 22 | response = { 23 | 'error': error.__class__.__name__, 24 | 'message': error.description, 25 | } 26 | return response, error.code 27 | -------------------------------------------------------------------------------- /apps/api/profile/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/profile/__init__.py -------------------------------------------------------------------------------- /apps/api/profile/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/profile/adapters/__init__.py -------------------------------------------------------------------------------- /apps/api/profile/adapters/presenters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/profile/adapters/presenters/__init__.py -------------------------------------------------------------------------------- /apps/api/profile/adapters/presenters/profile_presenter.py: -------------------------------------------------------------------------------- 1 | from apps.shared.global_exception import NotFoundError 2 | 3 | from core.domain.profile.use_case.get_user_profile import GetUserProfileResponse 4 | from core.kernel.exception import BaseNotFoundException 5 | from core.kernel.port import JsonContentResult 6 | from core.kernel.use_case import UseCaseOutputPort 7 | 8 | 9 | class GetUserProfilePresenter(UseCaseOutputPort[GetUserProfileResponse], JsonContentResult): 10 | 11 | def handle(self, response: GetUserProfileResponse) -> None: 12 | if not response.is_succeeded: 13 | if isinstance(response.error, BaseNotFoundException): 14 | raise NotFoundError(response.error.message) 15 | self.content_result = response 16 | -------------------------------------------------------------------------------- /apps/api/profile/adapters/request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/profile/adapters/request/__init__.py -------------------------------------------------------------------------------- /apps/api/profile/adapters/request/profile_request.py: -------------------------------------------------------------------------------- 1 | from flask_restplus import fields 2 | 3 | from apps.api.profile.controllers import member_api 4 | 5 | member_id_request = member_api.model('MemberIdRequest', { 6 | 'id': fields.Integer 7 | }) 8 | -------------------------------------------------------------------------------- /apps/api/profile/adapters/response/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/profile/adapters/response/__init__.py -------------------------------------------------------------------------------- /apps/api/profile/adapters/response/profile_response.py: -------------------------------------------------------------------------------- 1 | from flask_restplus import fields 2 | 3 | from apps.api.profile.controllers import member_api 4 | 5 | entity = member_api.model('Entity', { 6 | 'id': fields.Integer 7 | }) 8 | 9 | basic_profile = member_api.model('BasicProfile', { 10 | 'real_name': fields.String, 11 | 'gender': fields.String, 12 | # 'birthday': fields.String 13 | }) 14 | 15 | extra_profile = member_api.model('ExtraProfile', { 16 | 'profile_category': fields.String, 17 | '*': fields.Wildcard(fields.String) 18 | }) 19 | 20 | user_profile = member_api.clone('UserProfile', entity, { 21 | 'user_type': fields.String, 22 | 'user_name': fields.String, 23 | 'user_status': fields.String, 24 | }) 25 | 26 | member_profile = member_api.clone('MemberProfile', user_profile, { 27 | 'basic_profile': fields.Nested(basic_profile), 28 | 'extra_profile': fields.List(fields.Nested(extra_profile)) 29 | }) 30 | 31 | -------------------------------------------------------------------------------- /apps/api/profile/adapters/validators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/api/profile/adapters/validators/__init__.py -------------------------------------------------------------------------------- /apps/api/profile/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_restplus import Namespace 2 | 3 | avatar_api = Namespace('avatar', description='Avatar Image Endpoints') 4 | member_api = Namespace('member', description='Member Resource Endpoints') 5 | search_api = Namespace('search', description='Profile Search Endpoints') 6 | -------------------------------------------------------------------------------- /apps/api/profile/controllers/avatar.py: -------------------------------------------------------------------------------- 1 | from flask_restplus import Resource 2 | 3 | from apps.api.profile.controllers import avatar_api as api 4 | 5 | 6 | @api.route('/') 7 | class AvatarList(Resource): 8 | @api.doc('Upload Avatar Image') 9 | def post(self): 10 | return ['avatar_post'] 11 | 12 | 13 | @api.route('/') 14 | class Avatar(Resource): 15 | @api.doc('Get Avatar Image') 16 | def get(self, avatar_id: str): 17 | return [avatar_id] 18 | -------------------------------------------------------------------------------- /apps/api/profile/controllers/member.py: -------------------------------------------------------------------------------- 1 | from flask_restplus import Resource 2 | from injector import inject 3 | 4 | from apps.api.profile.adapters.presenters.profile_presenter import GetUserProfilePresenter 5 | from apps.api.profile.adapters.response.profile_response import member_profile 6 | from apps.api.profile.controllers import member_api as api 7 | from apps.shared.global_exception import CustomError, BadRequestError, NotFoundError 8 | from core.domain.profile.use_case.get_user_profile import GetUserProfileUseCase, GetUserProfileRequest 9 | 10 | 11 | @api.route('/') 12 | class MemberList(Resource): 13 | @api.doc('Create Member') 14 | @api.response(400, 'Bad Request') 15 | def post(self): 16 | return ['create member'] 17 | 18 | 19 | @api.route('//') 20 | class Member(Resource): 21 | 22 | @inject 23 | def __init__(self, uc_get_profile: GetUserProfileUseCase, *args, **kwargs): 24 | super().__init__(*args, **kwargs) 25 | self._uc_get_profile = uc_get_profile 26 | 27 | @api.doc('Get Member') 28 | @api.response(404, 'User Not Found') 29 | @api.marshal_with(member_profile) 30 | def get(self, member_id: int): 31 | uc_request = GetUserProfileRequest(member_id, "member") 32 | presenter = GetUserProfilePresenter() 33 | self._uc_get_profile.execute(uc_request, presenter) 34 | return presenter.content_result 35 | 36 | 37 | @api.route('//basic') 38 | class MemberBasic(Resource): 39 | @api.doc('Get Member Basic info') 40 | @api.response(404, 'User Not Found') 41 | def put(self, member_id: int): 42 | # return ['put member basic'] 43 | raise CustomError('Bad key123') 44 | 45 | 46 | @api.route('//extra') 47 | class MemberExtra(Resource): 48 | @api.doc('Get Member Extra Info') 49 | @api.response(404, 'User Not Found') 50 | def put(self, member_id: int): 51 | # return ['put member extra'] 52 | raise BadRequestError('wrong parameter abc') 53 | -------------------------------------------------------------------------------- /apps/api/profile/controllers/search.py: -------------------------------------------------------------------------------- 1 | from flask_restplus import Resource 2 | 3 | from apps.api.profile.controllers import search_api as api 4 | 5 | 6 | @api.route('/member') 7 | class MemberSearch(Resource): 8 | @api.doc('Search Member') 9 | def post(self): 10 | return ['search member'] 11 | 12 | 13 | @api.route('/newcomer') 14 | class NewcomerSearch(Resource): 15 | @api.doc('Search Newcomer') 16 | def post(self): 17 | return ['search newcomer'] 18 | -------------------------------------------------------------------------------- /apps/api/profile_v1.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_restplus import Api 3 | from werkzeug.exceptions import HTTPException 4 | 5 | from .profile.controllers.avatar import api as avatar_api 6 | from .profile.controllers.member import api as member_api 7 | from .profile.controllers.search import api as search_api 8 | 9 | blueprint = Blueprint('profile_api', __name__, url_prefix='/profile/v1') 10 | 11 | api = Api(blueprint, 12 | doc='/doc/', 13 | title='Resource API - Profile', 14 | version='1.0', 15 | description='A description' 16 | ) 17 | 18 | api.add_namespace(avatar_api) 19 | api.add_namespace(search_api) 20 | api.add_namespace(member_api) 21 | 22 | 23 | @api.errorhandler(HTTPException) 24 | def handle_error(error: HTTPException): 25 | """ Handle BluePrint JSON Error Response """ 26 | response = { 27 | 'error': error.__class__.__name__, 28 | 'message': error.description, 29 | } 30 | return response, error.code 31 | -------------------------------------------------------------------------------- /apps/shared/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/apps/shared/__init__.py -------------------------------------------------------------------------------- /apps/shared/global_exception.py: -------------------------------------------------------------------------------- 1 | from werkzeug.exceptions import ( 2 | HTTPException, 3 | BadRequest, 4 | NotFound 5 | ) 6 | 7 | 8 | class CustomError(HTTPException): 9 | """ Custom Error Exception """ 10 | code = 409 11 | description = "custom error" 12 | 13 | 14 | class BadRequestError(BadRequest): 15 | """ Wrap BadRequest Exception """ 16 | 17 | 18 | class NotFoundError(NotFound): 19 | """ Wrap NotFound Exception """ 20 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | from .prod_config import ProductionConfig 2 | from .dev_config import DevelopmentConfig 3 | 4 | configurations = { 5 | 'production': ProductionConfig, 6 | 'development': DevelopmentConfig, 7 | 'default': DevelopmentConfig 8 | } 9 | -------------------------------------------------------------------------------- /config/base_config.py: -------------------------------------------------------------------------------- 1 | """Flask config class.""" 2 | import os 3 | 4 | 5 | class BaseConfig: 6 | """Base config vars.""" 7 | SECRET_KEY = os.environ.get('SECRET_KEY') 8 | SESSION_COOKIE_NAME = os.environ.get('SESSION_COOKIE_NAME') 9 | -------------------------------------------------------------------------------- /config/dev_config.py: -------------------------------------------------------------------------------- 1 | """Flask config class.""" 2 | import os 3 | from .base_config import BaseConfig 4 | 5 | 6 | class ProductionConfig(BaseConfig): 7 | DEBUG = False 8 | TESTING = False 9 | DATABASE_URI = os.environ.get('PROD_DATABASE_URI') 10 | -------------------------------------------------------------------------------- /config/prod_config.py: -------------------------------------------------------------------------------- 1 | """Flask config class.""" 2 | import os 3 | from .base_config import BaseConfig 4 | 5 | 6 | class ProductionConfig(BaseConfig): 7 | DEBUG = False 8 | TESTING = False 9 | DATABASE_URI = os.environ.get('PROD_DATABASE_URI') 10 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/__init__.py -------------------------------------------------------------------------------- /core/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/domain/__init__.py -------------------------------------------------------------------------------- /core/domain/account/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/domain/account/__init__.py -------------------------------------------------------------------------------- /core/domain/account/entity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/domain/account/entity/__init__.py -------------------------------------------------------------------------------- /core/domain/account/repository/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/domain/account/repository/__init__.py -------------------------------------------------------------------------------- /core/domain/account/repository/authorize_repository.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class AuthorizeRepository(ABC): 5 | 6 | @abstractmethod 7 | def get_user(self): 8 | return NotImplemented 9 | -------------------------------------------------------------------------------- /core/domain/account/use_case/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/domain/account/use_case/__init__.py -------------------------------------------------------------------------------- /core/domain/account/use_case/authorize_user.py: -------------------------------------------------------------------------------- 1 | from core.kernel.use_case import UseCase 2 | from core.kernel.port import UseCaseRequest, UseCasePresenter 3 | from core.domain.account.repository.authorize_repository import AuthorizeRepository 4 | 5 | 6 | class AuthorizeUser(UseCase): 7 | 8 | _auth_repo = None 9 | 10 | def __init__(self, auth_repo: AuthorizeRepository): 11 | self._auth_repo = auth_repo 12 | 13 | def execute(self, uc_request: UseCaseRequest, uc_presenter: UseCasePresenter) -> None: 14 | pass 15 | -------------------------------------------------------------------------------- /core/domain/profile/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/domain/profile/__init__.py -------------------------------------------------------------------------------- /core/domain/profile/entity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/domain/profile/entity/__init__.py -------------------------------------------------------------------------------- /core/domain/profile/entity/profile.py: -------------------------------------------------------------------------------- 1 | import attr 2 | from datetime import date 3 | 4 | from core.kernel.entity import ValueObject 5 | 6 | 7 | @attr.s(auto_attribs=True) 8 | class UserBasicProfile(ValueObject): 9 | """ User Basic Profile """ 10 | real_name: str = None 11 | gender: str = None 12 | birthday: str = None 13 | 14 | 15 | @attr.s(auto_attribs=True) 16 | class UserExtraProfile(ValueObject): 17 | """ Base User Extra Profile """ 18 | profile_category: str = None 19 | 20 | 21 | @attr.s(auto_attribs=True) 22 | class EducationExtraProfile(UserExtraProfile): 23 | """ Education Extra Profile """ 24 | profile_category: str = attr.ib(default="education", init=False) 25 | school: str = None 26 | department: str = None 27 | 28 | 29 | @attr.s(auto_attribs=True) 30 | class CareerExtraProfile(UserExtraProfile): 31 | """ Career Extra Profile """ 32 | profile_category: str = attr.ib(default="career", init=False) 33 | career: str = None 34 | job_title: str = None 35 | -------------------------------------------------------------------------------- /core/domain/profile/entity/user.py: -------------------------------------------------------------------------------- 1 | import attr 2 | from typing import List 3 | 4 | from core.kernel.entity import Entity 5 | from .profile import UserBasicProfile, UserExtraProfile 6 | 7 | 8 | @attr.s(auto_attribs=True) 9 | class User(Entity): 10 | """ Base User Entity """ 11 | user_type: str = None 12 | user_name: str = None 13 | user_status: str = "enabled" 14 | basic_profile: UserBasicProfile = None 15 | extra_profile: List[UserExtraProfile] = [] 16 | 17 | 18 | @attr.s(auto_attribs=True) 19 | class Member(User): 20 | """ Member Entity Extends User """ 21 | user_type: str = attr.ib(default="member", init=False) 22 | 23 | 24 | @attr.s(auto_attribs=True) 25 | class Newcomer(User): 26 | """ Newcomer Entity Extends User """ 27 | user_type: str = attr.ib(default="newcomer", init=False) 28 | -------------------------------------------------------------------------------- /core/domain/profile/exception.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from core.kernel.exception import BaseNotFoundException 4 | 5 | 6 | @attr.s(auto_attribs=True) 7 | class UserNotFound(BaseNotFoundException): 8 | """ User Not Found Exception """ 9 | message: str = "User Not Found" 10 | -------------------------------------------------------------------------------- /core/domain/profile/repository/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/domain/profile/repository/__init__.py -------------------------------------------------------------------------------- /core/domain/profile/repository/profile_repository.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List 3 | 4 | from core.domain.profile.entity.user import User, UserBasicProfile, UserExtraProfile 5 | 6 | 7 | class ProfileRepository(ABC): 8 | 9 | @abstractmethod 10 | def get_user(self, user_type: str, user_id: int) -> User: 11 | return NotImplemented 12 | 13 | @abstractmethod 14 | def create_user(self, user: User) -> None: 15 | return NotImplemented 16 | 17 | @abstractmethod 18 | def update_user_basic(self, user_type: str, user_id: int, basic_profile: UserBasicProfile) -> None: 19 | return NotImplemented 20 | 21 | @abstractmethod 22 | def update_user_extra(self, user_type: str, user_id: int, extra_profiles: List[UserExtraProfile]) -> None: 23 | return NotImplemented 24 | -------------------------------------------------------------------------------- /core/domain/profile/use_case/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/domain/profile/use_case/__init__.py -------------------------------------------------------------------------------- /core/domain/profile/use_case/get_user_profile.py: -------------------------------------------------------------------------------- 1 | import attr 2 | from injector import inject 3 | 4 | from core.domain.profile.exception import UserNotFound 5 | from core.domain.profile.repository.profile_repository import ProfileRepository 6 | from core.kernel.port import UseCaseRequest, UseCaseResponse, UseCaseOutputPort 7 | from core.kernel.use_case import UseCase 8 | 9 | 10 | @attr.s(auto_attribs=True) 11 | class GetUserProfileRequest(UseCaseRequest): 12 | user_id: int = None 13 | user_type: str = None 14 | 15 | 16 | @attr.s(auto_attribs=True) 17 | class GetUserProfileResponse(UseCaseResponse): 18 | """ Extends UseCase Response """ 19 | 20 | 21 | class GetUserProfileUseCase(UseCase): 22 | _profile_repo = None 23 | 24 | @inject 25 | def __init__(self, profile_repo: ProfileRepository): 26 | self._profile_repo = profile_repo 27 | 28 | def execute(self, uc_request: GetUserProfileRequest, 29 | uc_output_port: UseCaseOutputPort[GetUserProfileResponse]) -> None: 30 | response = GetUserProfileResponse() 31 | user = self._profile_repo.get_user(uc_request.user_type, uc_request.user_id) 32 | if not user: 33 | response.error = UserNotFound("This user does not exist") 34 | else: 35 | response.result = user 36 | uc_output_port.handle(response) 37 | -------------------------------------------------------------------------------- /core/kernel/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/kernel/__init__.py -------------------------------------------------------------------------------- /core/kernel/entity.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4, UUID 2 | 3 | import attr 4 | 5 | 6 | @attr.s(auto_attribs=True) 7 | class Entity(object): 8 | """ Int ID Entity """ 9 | id: int = None 10 | 11 | 12 | @attr.s(auto_attribs=True) 13 | class UuidEntity(object): 14 | """ UUID Entity """ 15 | id: UUID = uuid4() 16 | 17 | 18 | @attr.s(auto_attribs=True) 19 | class ValueObject(object): 20 | """ Value Object """ 21 | -------------------------------------------------------------------------------- /core/kernel/exception.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | 4 | @attr.s(auto_attribs=True) 5 | class UseCaseException(Exception): 6 | """ Base UseCase Error """ 7 | message: str = None 8 | 9 | 10 | @attr.s(auto_attribs=True) 11 | class BaseNotFoundException(UseCaseException): 12 | """ Base Not Found Exception Abstraction """ 13 | -------------------------------------------------------------------------------- /core/kernel/port.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import TypeVar, Generic, Dict, Any 3 | 4 | import attr 5 | import cattr 6 | 7 | from .exception import UseCaseException 8 | 9 | T = TypeVar('T') 10 | 11 | 12 | @attr.s(auto_attribs=True) 13 | class UseCaseRequest(ABC): 14 | """ Base UseCase Request """ 15 | 16 | 17 | @attr.s(auto_attribs=True) 18 | class UseCaseResponse(object): 19 | result: Any = None 20 | error: UseCaseException = None 21 | 22 | @property 23 | def is_succeeded(self): 24 | return self.error is None or self.result is not None 25 | 26 | 27 | class UseCaseOutputPort(Generic[T]): 28 | 29 | def __str__(self): 30 | return f'{__class__.__name__} with Type: {T}' 31 | 32 | @abstractmethod 33 | def handle(self, response: T) -> None: 34 | return NotImplemented 35 | 36 | 37 | class JsonContentResult(object): 38 | __content_result: Dict = {} 39 | 40 | def __init__(self, content: UseCaseResponse = None) -> None: 41 | if content and content.is_succeeded: 42 | self.__content_result = cattr.unstructure(content.result) 43 | 44 | @property 45 | def content_result(self) -> Dict: 46 | return self.__content_result 47 | 48 | @content_result.setter 49 | def content_result(self, content: UseCaseResponse) -> None: 50 | if content and content.is_succeeded: 51 | self.__content_result = cattr.unstructure(content.result) 52 | -------------------------------------------------------------------------------- /core/kernel/repository.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/core/kernel/repository.py -------------------------------------------------------------------------------- /core/kernel/use_case.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from .port import UseCaseRequest, UseCaseResponse, UseCaseOutputPort 4 | 5 | 6 | class UseCase(ABC): 7 | 8 | @abstractmethod 9 | def execute(self, uc_request: UseCaseRequest, uc_output_port: UseCaseOutputPort[UseCaseResponse]) -> None: 10 | return NotImplemented 11 | -------------------------------------------------------------------------------- /extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/extensions/__init__.py -------------------------------------------------------------------------------- /extensions/config_extension.py: -------------------------------------------------------------------------------- 1 | from config import configurations 2 | 3 | 4 | def register_config(app): 5 | pass 6 | -------------------------------------------------------------------------------- /extensions/database_extension.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/extensions/database_extension.py -------------------------------------------------------------------------------- /extensions/exception_extension.py: -------------------------------------------------------------------------------- 1 | from werkzeug.exceptions import HTTPException 2 | 3 | 4 | def handle_global_error(error: HTTPException): 5 | """ Make JSON Error Response instead of Web Page """ 6 | response = { 7 | 'error': error.__class__.__name__, 8 | 'message': error.description, 9 | } 10 | return response, error.code 11 | 12 | 13 | def register_exception_handler(app): 14 | app.register_error_handler(HTTPException, handle_global_error) 15 | -------------------------------------------------------------------------------- /extensions/hooks_extension.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/extensions/hooks_extension.py -------------------------------------------------------------------------------- /extensions/injector_extension.py: -------------------------------------------------------------------------------- 1 | from flask_injector import FlaskInjector 2 | from injector import singleton, Binder 3 | 4 | from core.domain.profile.repository.profile_repository import ProfileRepository 5 | from core.domain.profile.use_case.get_user_profile import GetUserProfileUseCase 6 | from infra.sql.profile.repository.sql_profile_repository import SqlProfileRepository 7 | # from infra.mock.repository import MockProfileRepository 8 | 9 | 10 | def configure_binding(binder: Binder) -> Binder: 11 | binder.bind(GetUserProfileUseCase, to=GetUserProfileUseCase, scope=singleton) 12 | # binder.bind(ProfileRepository, to=MockProfileRepository, scope=singleton) 13 | binder.bind(ProfileRepository, to=SqlProfileRepository, scope=singleton) 14 | return binder 15 | 16 | 17 | def register_dependency_injection(app): 18 | FlaskInjector(app=app, modules=[configure_binding]) 19 | -------------------------------------------------------------------------------- /extensions/log_extension.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | FORMAT = '%(asctime)-15s %(name)s %(levelname)-8s %(message)s' 4 | logging.basicConfig(format=FORMAT) 5 | 6 | 7 | def get_logger(name): 8 | logger = logging.getLogger(name) 9 | return logger 10 | -------------------------------------------------------------------------------- /extensions/oidc_extension.py: -------------------------------------------------------------------------------- 1 | from authlib.integrations.flask_oauth2 import AuthorizationServer, ResourceProtector 2 | from authlib.oauth2.rfc6749.grants import AuthorizationCodeGrant as _AuthorizationCodeGrant 3 | from authlib.oidc.core import UserInfo 4 | from authlib.oidc.core.grants import ( 5 | OpenIDCode as _OpenIDCode, 6 | OpenIDImplicitGrant as _OpenIDImplicitGrant, 7 | OpenIDHybridGrant as _OpenIDHybridGrant 8 | ) 9 | from werkzeug.security import gen_salt 10 | 11 | DUMMY_JWT_CONFIG = { 12 | 'key': 'secret-key', 13 | 'alg': 'HS256', 14 | 'iss': 'https://authlib.org', 15 | 'exp': 3600, 16 | } 17 | 18 | 19 | def exists_nonce(nonce, req): 20 | # exists = OAuth2AuthorizationCode.query.filter_by( 21 | # client_id=req.client_id, nonce=nonce 22 | # ).first() 23 | # return bool(exists) 24 | return True 25 | 26 | 27 | def generate_user_info(user, scope): 28 | return UserInfo(sub=str(user.id), name=user.username) 29 | 30 | 31 | def create_authorization_code(client, grant_user, request): 32 | code = gen_salt(48) 33 | nonce = request.data.get('nonce') 34 | # item = OAuth2AuthorizationCode( 35 | # code=code, 36 | # client_id=client.client_id, 37 | # redirect_uri=request.redirect_uri, 38 | # scope=request.scope, 39 | # user_id=grant_user.id, 40 | # nonce=nonce, 41 | # ) 42 | # db.session.add(item) 43 | # db.session.commit() 44 | return code 45 | 46 | 47 | class AuthorizationCodeGrant(_AuthorizationCodeGrant): 48 | def create_authorization_code(self, client, grant_user, request): 49 | return create_authorization_code(client, grant_user, request) 50 | 51 | def parse_authorization_code(self, code, client): 52 | item = None 53 | # item = OAuth2AuthorizationCode.query.filter_by( 54 | # code=code, client_id=client.client_id).first() 55 | if item and not item.is_expired(): 56 | return item 57 | 58 | def delete_authorization_code(self, authorization_code): 59 | # db.session.delete(authorization_code) 60 | # db.session.commit() 61 | pass 62 | 63 | def authenticate_user(self, authorization_code): 64 | # return User.query.get(authorization_code.user_id) 65 | pass 66 | 67 | 68 | class OpenIDCode(_OpenIDCode): 69 | def exists_nonce(self, nonce, request): 70 | return exists_nonce(nonce, request) 71 | 72 | def get_jwt_config(self, grant): 73 | return DUMMY_JWT_CONFIG 74 | 75 | def generate_user_info(self, user, scope): 76 | return generate_user_info(user, scope) 77 | 78 | 79 | class ImplicitGrant(_OpenIDImplicitGrant): 80 | def exists_nonce(self, nonce, request): 81 | return exists_nonce(nonce, request) 82 | 83 | def get_jwt_config(self, grant): 84 | return DUMMY_JWT_CONFIG 85 | 86 | def generate_user_info(self, user, scope): 87 | return generate_user_info(user, scope) 88 | 89 | 90 | class HybridGrant(_OpenIDHybridGrant): 91 | def create_authorization_code(self, client, grant_user, request): 92 | return create_authorization_code(client, grant_user, request) 93 | 94 | def exists_nonce(self, nonce, request): 95 | return exists_nonce(nonce, request) 96 | 97 | def get_jwt_config(self): 98 | return DUMMY_JWT_CONFIG 99 | 100 | def generate_user_info(self, user, scope): 101 | return generate_user_info(user, scope) 102 | 103 | 104 | authorization = AuthorizationServer() 105 | require_oauth = ResourceProtector() 106 | 107 | 108 | def query_client(): 109 | pass 110 | 111 | 112 | def save_token(): 113 | pass 114 | 115 | 116 | def bearer_cls(): 117 | pass 118 | 119 | 120 | def config_oauth(app): 121 | # query_client = create_query_client_func(db.session, OAuth2Client) 122 | # save_token = create_save_token_func(db.session, OAuth2Token) 123 | authorization.init_app( 124 | app, 125 | query_client=query_client, 126 | save_token=save_token 127 | ) 128 | 129 | # support all openid grants 130 | authorization.register_grant(AuthorizationCodeGrant, [ 131 | OpenIDCode(require_nonce=True), 132 | ]) 133 | authorization.register_grant(ImplicitGrant) 134 | authorization.register_grant(HybridGrant) 135 | 136 | # protect resource 137 | # bearer_cls = create_bearer_token_validator(db.session, OAuth2Token) 138 | require_oauth.register_token_validator(bearer_cls()) 139 | -------------------------------------------------------------------------------- /extensions/routes_extension.py: -------------------------------------------------------------------------------- 1 | from apps.api.account_v1 import blueprint as account_api 2 | from apps.api.profile_v1 import blueprint as profile_api 3 | 4 | 5 | def register_routes(app): 6 | """ 7 | Register routes with blueprint and namespace 8 | """ 9 | app.register_blueprint(account_api) 10 | app.register_blueprint(profile_api) 11 | -------------------------------------------------------------------------------- /infra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/infra/__init__.py -------------------------------------------------------------------------------- /infra/mock/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/infra/mock/__init__.py -------------------------------------------------------------------------------- /infra/mock/repository.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from core.domain.profile.entity.profile import ( 4 | UserBasicProfile, EducationExtraProfile, CareerExtraProfile, UserExtraProfile 5 | ) 6 | from core.domain.profile.entity.user import User, Member, Newcomer 7 | from core.domain.profile.exception import UserNotFound 8 | from core.domain.profile.repository.profile_repository import ProfileRepository 9 | 10 | 11 | class MockProfileRepository(ProfileRepository): 12 | _users: List[User] = [] 13 | 14 | def __init__(self): 15 | education_profile = EducationExtraProfile(school="College", department="Art") 16 | career_profile = CareerExtraProfile(career="Developer", job_title="Sr. Developer II") 17 | member = Member( 18 | id=111, 19 | user_name="Mock", 20 | user_status="enabled", 21 | basic_profile=UserBasicProfile(real_name="Hello Mock", gender="Male", birthday=""), 22 | extra_profile=[education_profile, career_profile] 23 | ) 24 | newcomer = Newcomer( 25 | id=112, 26 | user_name="Nancy", 27 | user_status="enabled", 28 | basic_profile=UserBasicProfile(real_name="Hello Nancy", gender="Female", birthday=""), 29 | extra_profile=[career_profile] 30 | ) 31 | self._users.append(member) 32 | self._users.append(newcomer) 33 | 34 | def get_user(self, user_type: str, user_id: int) -> User: 35 | user = next((x for x in self._users if x.id == user_id and x.user_type == user_type), None) 36 | return user 37 | 38 | def create_user(self, user: User) -> None: 39 | self._users.append(user) 40 | 41 | def update_user_basic(self, user_type: str, user_id: int, basic_profile: UserBasicProfile) -> None: 42 | user = next((x for x in self._users if x.id == user_id and x.user_type == user_type), None) 43 | if user: 44 | user.basic_profile = basic_profile 45 | else: 46 | raise UserNotFound() 47 | 48 | def update_user_extra(self, user_type: str, user_id: int, extra_profiles: List[UserExtraProfile]) -> None: 49 | user = next((x for x in self._users if x.id == user_id and x.user_type == user_type), None) 50 | if user: 51 | user.extra_profile = extra_profiles 52 | else: 53 | raise UserNotFound() 54 | -------------------------------------------------------------------------------- /infra/sql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/infra/sql/__init__.py -------------------------------------------------------------------------------- /infra/sql/account/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/infra/sql/account/__init__.py -------------------------------------------------------------------------------- /infra/sql/account/orm.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/infra/sql/account/orm.py -------------------------------------------------------------------------------- /infra/sql/account/repository/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/infra/sql/account/repository/__init__.py -------------------------------------------------------------------------------- /infra/sql/profile/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/infra/sql/profile/__init__.py -------------------------------------------------------------------------------- /infra/sql/profile/orm.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/infra/sql/profile/orm.py -------------------------------------------------------------------------------- /infra/sql/profile/repository/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/infra/sql/profile/repository/__init__.py -------------------------------------------------------------------------------- /infra/sql/profile/repository/sql_profile_repository.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from core.domain.profile.entity.profile import ( 4 | UserBasicProfile, EducationExtraProfile, CareerExtraProfile, UserExtraProfile 5 | ) 6 | from core.domain.profile.entity.user import User, Member, Newcomer 7 | from core.domain.profile.exception import UserNotFound 8 | from core.domain.profile.repository.profile_repository import ProfileRepository 9 | 10 | 11 | class SqlProfileRepository(ProfileRepository): 12 | _users: List[User] = [] 13 | 14 | def __init__(self): 15 | education_profile = EducationExtraProfile(school="College", department="Art") 16 | career_profile = CareerExtraProfile(career="Developer", job_title="Sr. Developer II") 17 | member = Member( 18 | id=111, 19 | user_name="Mike", 20 | user_status="enabled", 21 | basic_profile=UserBasicProfile(real_name="Hello Mike", gender="Male", birthday=""), 22 | extra_profile=[education_profile, career_profile] 23 | ) 24 | newcomer = Newcomer( 25 | id=112, 26 | user_name="Nicole", 27 | user_status="enabled", 28 | basic_profile=UserBasicProfile(real_name="Hello Nicole", gender="Female", birthday=""), 29 | extra_profile=[career_profile] 30 | ) 31 | self._users.append(member) 32 | self._users.append(newcomer) 33 | 34 | def get_user(self, user_type: str, user_id: int) -> User: 35 | user = next((x for x in self._users if x.id == user_id and x.user_type == user_type), None) 36 | return user 37 | 38 | def create_user(self, user: User) -> None: 39 | self._users.append(user) 40 | 41 | def update_user_basic(self, user_type: str, user_id: int, basic_profile: UserBasicProfile) -> None: 42 | user = next((x for x in self._users if x.id == user_id and x.user_type == user_type), None) 43 | if user: 44 | user.basic_profile = basic_profile 45 | else: 46 | raise UserNotFound() 47 | 48 | def update_user_extra(self, user_type: str, user_id: int, extra_profiles: List[UserExtraProfile]) -> None: 49 | user = next((x for x in self._users if x.id == user_id and x.user_type == user_type), None) 50 | if user: 51 | user.extra_profile = extra_profiles 52 | else: 53 | raise UserNotFound() 54 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from application import create_app 2 | # from dotenv import load_dotenv 3 | 4 | # load_dotenv(dotenv_path='.flaskenv') 5 | app = create_app() 6 | 7 | def main(): 8 | app.run() 9 | 10 | if __name__ == '__main__': 11 | main() 12 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chonhan/flask_restapi_clean_architecture/539994825013d45712817da72dd96e0c6f8ee56b/tests/__init__.py --------------------------------------------------------------------------------