├── .dockerignore ├── .gitignore ├── LICENSE ├── README.md ├── alembic.ini ├── docker-compose.yml ├── docker ├── api │ ├── Dockerfile │ ├── Dockerfile.dev │ ├── startup.dev.sh │ └── startup.sh ├── db │ └── Dockerfile └── redis │ └── Dockerfile ├── main.py ├── makefile ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ └── 88f2cb0d4fc9_base.py ├── poetry.lock ├── pyproject.toml ├── requirements.dev.txt ├── requirements.prod.txt ├── src └── application │ ├── __init__.py │ ├── api.py │ ├── celery_task │ ├── __init__.py │ └── tasks │ │ └── __init__.py │ ├── container.py │ ├── core │ ├── __init__.py │ ├── authority │ │ ├── __init__.py │ │ └── permissions.py │ ├── base_class │ │ ├── __init__.py │ │ ├── repository.py │ │ └── service.py │ ├── config │ │ ├── __init__.py │ │ ├── config_container.py │ │ └── settings_model.py │ ├── db │ │ ├── __init__.py │ │ ├── mixins │ │ │ ├── __init__.py │ │ │ └── timestamp_mixin.py │ │ ├── session_maker.py │ │ ├── standalone_session_maker.py │ │ └── transactional.py │ ├── dependencies │ │ ├── __init__.py │ │ └── permission.py │ ├── enums │ │ ├── __init__.py │ │ ├── repository.py │ │ ├── response_code.py │ │ └── service_status.py │ ├── exceptions │ │ ├── __init__.py │ │ ├── base.py │ │ ├── external_service.py │ │ ├── http.py │ │ ├── middleware.py │ │ └── token.py │ ├── external_service │ │ ├── __init__.py │ │ ├── auth_client.py │ │ └── http_client.py │ ├── fastapi │ │ ├── __init__.py │ │ ├── custom_json_response.py │ │ ├── log_route.py │ │ └── pydantic_models.py │ ├── helpers │ │ ├── __init__.py │ │ ├── cache │ │ │ ├── __init__.py │ │ │ ├── base │ │ │ │ ├── __init__.py │ │ │ │ ├── backend.py │ │ │ │ └── key_maker.py │ │ │ ├── cache_manager.py │ │ │ ├── cache_tag.py │ │ │ ├── custom_key_maker.py │ │ │ └── redis_backend.py │ │ └── logging.py │ ├── middlewares │ │ ├── __init__.py │ │ ├── authentication_external.py │ │ ├── authentication_internal.py │ │ └── sqlalchemy.py │ ├── repository │ │ └── __init__.py │ └── utils │ │ ├── __init__.py │ │ ├── camelcase.py │ │ └── token_helper.py │ ├── domain │ ├── __init__.py │ ├── auth │ │ ├── __init__.py │ │ ├── container.py │ │ ├── models.py │ │ ├── repository.py │ │ ├── service.py │ │ └── views.py │ ├── domain_structure │ │ ├── __init__.py │ │ ├── container.py │ │ ├── enums.py │ │ ├── models.py │ │ ├── repository.py │ │ ├── service.py │ │ └── views.py │ ├── home │ │ ├── __init__.py │ │ └── views.py │ ├── log │ │ ├── __init__.py │ │ ├── container.py │ │ ├── models.py │ │ ├── repository.py │ │ └── service.py │ └── user │ │ ├── __init__.py │ │ ├── container.py │ │ ├── enums.py │ │ ├── exceptions.py │ │ ├── models.py │ │ ├── repository.py │ │ ├── service.py │ │ └── views.py │ └── server.py └── tests ├── __init__.py ├── conftest.py ├── core └── __init__.py └── domain ├── __init__.py ├── auth ├── __init__.py ├── test_service.py └── test_view.py ├── log └── __init__.py └── user ├── __init__.py ├── services ├── __init__.py └── test_user.py ├── test_service.py └── test_view.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .git 3 | .gitignore 4 | .dockerignore 5 | #docker 6 | tests 7 | *.md 8 | *.sh 9 | *.yml 10 | scripts 11 | .env* 12 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 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 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env.prod 124 | .env* 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | .idea/ 162 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Boilerplate 2 | 3 | Source is from [Teamhide's fastapi-boilerplate](https://github.com/teamhide/fastapi-boilerplate) 4 | 5 | # Base Features 6 | - Async SQLAlchemy session 7 | - Custom user class 8 | - Dependencies for specific permissions 9 | - Celery 10 | - Dockerize(Hot reload) 11 | - Cache 12 | 13 | # Added Features 14 | 15 | - Code dependencies are managed by [Dependency Injector](https://python-dependency-injector.ets-labs.org/) under `src/application/container.py` 16 | - All projects settings are injected by `.env` file thorough pydantic Settings. (refer `src/application/core/config/settings_model.py`) 17 | - More simplified skeleton 18 | - separate src(fastapi app related code) and others(docker, tests) 19 | - [Netflix dispatcher style domain component](https://github.com/Netflix/dispatch) 20 | - Static type check(by mypy) is done. 21 | - Log router for request and response logging (`src/application/core/fastapi/log_router.py`) can log Internal Server Error 22 | - makefile (for easier development) 23 | - Separate poetry dependency group into 2, one is for production, other is for develop 24 | - Aiohttp client for request external service (`src/application/core/external_service/http_client`) 25 | - Authentication middleware for external auth server (`src/application/core/middlewares/authentication_external`) 26 | - Classified Exception Class (`src/application/core/exceptions`) 27 | - Json Encoder Extended CustomORJSONResponse for faster serialization(`src/application/core/fastapi/custom_json_response.py`) 28 | - Async test samples on pytest-asyncio with samples 29 | 30 | # Project Config 31 | make .env.local file by 32 | ``` 33 | touch .env.local 34 | ``` 35 | and fill out it with below. 36 | 37 | ```shell 38 | TITLE='FastAPI APP' 39 | DESCRIPTION='This is Local' 40 | VERSION='0.1.0' 41 | ENV='dev' 42 | DEBUG=True 43 | APP_HOST='0.0.0.0' 44 | APP_PORT=8000 45 | APP_DOMAIN='127.0.0.1' 46 | WRITER_DB_URL='postgresql+asyncpg://postgres:fastapi@127.0.0.1:5432/fastapi' 47 | READER_DB_URL='postgresql+asyncpg://postgres:fastapi@127.0.0.1:5432/fastapi' 48 | JWT_SECRET_KEY='fastapi' 49 | JWT_ALGORITHM='HS256' 50 | SENTRY_SDN=None 51 | CELERY_BROKER_URL='amqp://user:bitnami@localhost:5672/' 52 | CELERY_BACKEND_URL='redis://:password123@localhost:6379/0' 53 | REDIS_HOST='localhost' 54 | REDIS_PORT=6379 55 | 56 | CLIENT_TIME_OUT=5 57 | SIZE_POOL_AIOHTTP=100 58 | 59 | AUTH_BASE_URL='http://ext-auth-url.com' 60 | AUTH_CLIENT_ID='client' 61 | AUTH_CLIENT_SECRET='secret' 62 | AUTH_REFRESH_TOKEN_KEY='auth_related_value' 63 | AUTH_SCOPE='write,read' 64 | 65 | ALLOW_ORIGINS='*' 66 | ALLOW_CREDENTIALS=True 67 | ALLOW_METHODS='*' 68 | ALLOW_HEADERS='*' 69 | ``` 70 | 71 | ## Run 72 | 73 | ```python 74 | python3 main.py --env local|dev|prod --debug 75 | ``` 76 | or 77 | ```shell 78 | $ make run 79 | ``` 80 | 81 | ## SQLAlchemy for asyncio context 82 | 83 | ```python 84 | from application.core.db import Transactional 85 | from dependency_injector.wiring import Provide 86 | 87 | session = Provide["session"] 88 | 89 | @Transactional() 90 | async def create_user(self): 91 | session.add(User(email="padocon@naver.com")) 92 | ``` 93 | 94 | Do not use explicit `commit()`. `Transactional` class automatically do. 95 | 96 | ### Standalone session 97 | 98 | According to the current settings, the session is set through middleware. 99 | 100 | However, it doesn't go through middleware in tests or background tasks. 101 | 102 | So you need to use the `@standalone_session` decorator. 103 | 104 | ```python 105 | from application.core.db import standalone_session 106 | 107 | 108 | @standalone_session 109 | def test_something(): 110 | ... 111 | ``` 112 | 113 | ### Multiple databases 114 | 115 | Go to `core/config.py` and edit `WRITER_DB_URL` and `READER_DB_URL` in the config class. 116 | 117 | 118 | If you need additional logic to use the database, refer to the `get_bind()` method of `RoutingClass`. 119 | 120 | ## Custom user for authentication 121 | 122 | ```python 123 | from fastapi import Request 124 | 125 | 126 | @home_router.get("/") 127 | def home(request: Request): 128 | return request.user.id 129 | ``` 130 | 131 | **Note. you have to pass jwt token via header like `Authorization: Bearer 1234`** 132 | 133 | Custom user class automatically decodes header token and store user information into `request.user` 134 | 135 | If you want to modify custom user class, you have to update below files. 136 | 137 | 1. `core/fastapi/schemas/current_user.py` 138 | 2. `core/fastapi/middlewares/authentication.py` 139 | 140 | ### CurrentUser 141 | 142 | ```python 143 | class CurrentUser(BaseModel): 144 | id: int = Field(None, description="ID") 145 | ``` 146 | 147 | Simply add more fields based on your needs. 148 | 149 | ### AuthBackend 150 | 151 | ```python 152 | current_user = CurrentUser() 153 | ``` 154 | 155 | After line 18, assign values that you added on `CurrentUser`. 156 | 157 | ## Top-level dependency 158 | 159 | **Note. Available from version 0.62 or higher.** 160 | 161 | Set a callable function when initialize FastAPI() app through `dependencies` argument. 162 | 163 | Refer `Logging` class inside of `core/fastapi/dependencies/logging.py` 164 | 165 | ## Dependencies for specific permissions 166 | 167 | Permissions `IsAdmin`, `IsAuthenticated`, `AllowAll` have already been implemented. 168 | 169 | ```python 170 | from application.core.fastapi.dependencies import ( 171 | PermissionDependency, 172 | ) 173 | from application.core.authority.permissions import IsAdmin 174 | 175 | user_router = APIRouter() 176 | 177 | 178 | @user_router.get( 179 | "", 180 | response_model=List[GetUserListResponseSchema], 181 | response_model_exclude={"id"}, 182 | responses={"400": {"model": ExceptionResponseSchema}}, 183 | dependencies=[Depends(PermissionDependency([IsAdmin]))], # HERE 184 | ) 185 | async def get_user_list( 186 | limit: int = Query(10, description="Limit"), 187 | prev: int = Query(None, description="Prev ID"), 188 | ): 189 | pass 190 | ``` 191 | Insert permission through `dependencies` argument. 192 | 193 | If you want to make your own permission, inherit `BasePermission` and implement `has_permission()` function. 194 | 195 | **Note. In order to use swagger's authorize function, you must put `PermissionDependency` as an argument of `dependencies`.** 196 | 197 | ## Event dispatcher 198 | 199 | Refer the README of https://github.com/teamhide/fastapi-event 200 | 201 | ## Cache 202 | 203 | ### Caching by prefix 204 | 205 | ```python 206 | from application.core.helpers.cache import cached 207 | 208 | 209 | @cached(prefix="get_user", ttl=60) 210 | async def get_user(): 211 | ... 212 | ``` 213 | 214 | ### Caching by tag 215 | 216 | ```python 217 | from application.core.helpers.cache import cached, CacheTag 218 | 219 | 220 | @cached(tag=CacheTag.GET_USER_LIST, ttl=60) 221 | async def get_user(): 222 | ... 223 | ``` 224 | 225 | Use the `cached` decorator to cache the return value of a function. 226 | 227 | Depending on the argument of the function, caching is stored with a different value through internal processing. 228 | 229 | ### Custom Key builder 230 | 231 | ```python 232 | from application.core.helpers.cache.base import BaseKeyMaker 233 | 234 | 235 | class CustomKeyMaker(BaseKeyMaker): 236 | async def make(self, function: Callable, prefix: str) -> str: 237 | ... 238 | ``` 239 | 240 | If you want to create a custom key, inherit the BaseKeyMaker class and implement the make() method. 241 | 242 | ### Custom Backend 243 | 244 | ```python 245 | from application.core.helpers.cache.base import BaseBackend 246 | 247 | 248 | class RedisBackend(BaseBackend): 249 | async def get(self, key: str) -> Any: 250 | ... 251 | 252 | async def set(self, response: Any, key: str, ttl: int = 60) -> None: 253 | ... 254 | 255 | async def delete_startswith(self, value: str) -> None: 256 | ... 257 | ``` 258 | 259 | If you want to create a custom key, inherit the BaseBackend class and implement the `get()`, `set()`, `delete_startswith()` method. 260 | 261 | Set your custom backend or keymaker on (`src/core/container/app.py`) 262 | 263 | ```python 264 | # core/config/domain.py 265 | ... 266 | # this change applied to cache manager automatically 267 | redis_backend = providers.Factory(YourRedisBackend) 268 | redis_key_maker = providers.Factory(YourKeyMaker) 269 | ``` 270 | 271 | ### Remove all cache by prefix/tag 272 | 273 | ```python 274 | from application.core.helpers.cache import CacheTag 275 | 276 | from dependency_injector.wiring import Provide 277 | 278 | # You can get App's global obj from Provide["name"] by Dependency Injector 279 | cache_manager = Provide["cache_manager"] 280 | 281 | await cache_manager.remove_by_prefix(prefix="get_user_list") 282 | await cache_manager.remove_by_tag(tag=CacheTag.GET_USER_LIST) 283 | ``` 284 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = driver://user:pass@localhost/dbname 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = WARN 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | api: 5 | build: 6 | context: . 7 | dockerfile: docker/api/Dockerfile.dev 8 | container_name: api 9 | hostname: api 10 | env_file: 11 | - .env.docker 12 | ports: 13 | - '8000:8000' 14 | networks: 15 | - backend 16 | tty: true 17 | depends_on: 18 | - db 19 | volumes: 20 | - ./src:/home/docker_user/app 21 | - ./migrations:/home/docker_user/migrations 22 | - ./alembic.ini:/home/docker_user/domain/alembic.ini 23 | 24 | - ./alembic.ini:/home/docker_user/app/alembic.ini 25 | db: 26 | build: 27 | context: . 28 | dockerfile: docker/db/Dockerfile 29 | container_name: db 30 | hostname: db 31 | ports: 32 | - '5432:5432' 33 | networks: 34 | - backend 35 | 36 | redis: 37 | build: 38 | context: . 39 | dockerfile: docker/redis/Dockerfile 40 | container_name: redis 41 | hostname: redis 42 | ports: 43 | - '6379:6379' 44 | networks: 45 | - backend 46 | 47 | networks: 48 | backend: 49 | driver: 'bridge' 50 | -------------------------------------------------------------------------------- /docker/api/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1.5 2 | FROM python:3.11 as requirements-stage 3 | 4 | WORKDIR /tmp 5 | 6 | RUN --mount=type=cache,target=/root/.cache \ 7 | pip install poetry==1.5.1 8 | 9 | COPY ./pyproject.toml ./poetry.lock* /tmp/ 10 | 11 | RUN poetry export -f requirements.txt --output requirements.txt --without-hashes 12 | 13 | 14 | FROM python:3.11 as builder 15 | 16 | COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt 17 | 18 | RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt 19 | 20 | RUN apt-get update && \ 21 | apt-get install -y wget tar && \ 22 | apt-get clean && \ 23 | rm -rf /var/lib/apt/lists/* 24 | 25 | ENV DOCKERIZE_VERSION v0.6.1 26 | RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 27 | && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 28 | && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz 29 | 30 | FROM python:3.11-slim-bookworm 31 | 32 | RUN useradd --create-home docker_user 33 | 34 | COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages 35 | COPY --from=builder /usr/local/bin /usr/local/bin 36 | 37 | USER docker_user 38 | RUN mkdir /home/docker_user/app 39 | WORKDIR /home/docker_user/app 40 | 41 | ENV PYTHONUNBUFFERED=1 42 | ENV ENV=production 43 | ENV PYTHONPATH=/home/docker_user/app 44 | 45 | COPY --chown=docker_user /docker /home/docker_user/docker 46 | RUN chmod +x /home/docker_user/docker/api/startup.sh 47 | COPY /src /home/docker_user/app 48 | 49 | ENTRYPOINT ["/home/docker_user/docker/api/startup.sh"] 50 | -------------------------------------------------------------------------------- /docker/api/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1.5 2 | FROM python:3.11 as requirements-stage 3 | 4 | WORKDIR /tmp 5 | 6 | RUN --mount=type=cache,target=/root/.cache \ 7 | pip install poetry==1.5.1 8 | 9 | COPY ./pyproject.toml ./poetry.lock* /tmp/ 10 | 11 | RUN poetry export -f requirements.txt --output requirements.txt --without-hashes 12 | 13 | 14 | FROM python:3.11 as builder 15 | 16 | COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt 17 | 18 | RUN --mount=type=cache,target=/root/.cache \ 19 | pip install --no-cache-dir --upgrade -r /app/requirements.txt 20 | 21 | RUN apt-get update && \ 22 | apt-get install -y wget tar && \ 23 | apt-get clean && \ 24 | rm -rf /var/lib/apt/lists/* 25 | 26 | ENV DOCKERIZE_VERSION v0.6.1 27 | RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 28 | && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 29 | && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz 30 | 31 | FROM python:3.11-slim-bookworm 32 | 33 | ENV USER_NAME=docker_user 34 | 35 | RUN useradd --create-home ${USER_NAME} 36 | 37 | COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages 38 | COPY --from=builder /usr/local/bin /usr/local/bin 39 | 40 | USER docker_user 41 | RUN mkdir /home/${USER_NAME}/app 42 | WORKDIR /home/${USER_NAME} 43 | 44 | ENV PYTHONUNBUFFERED=1 45 | ENV ENV=dev 46 | ENV PYTHONPATH=/home/${USER_NAME}/app 47 | 48 | COPY --chown=${USER_NAME} /docker /home/${USER_NAME}/docker 49 | RUN chmod +x /home/${USER_NAME}/docker/api/startup.dev.sh 50 | 51 | COPY ./alembic.ini /home/${USER_NAME}/alembic.ini 52 | # COPY migrations /home/${USER_NAME}/migrations 53 | # COPY src /home/${USER_NAME}/app 54 | 55 | ENTRYPOINT /home/$USER_NAME/docker/api/startup.dev.sh 56 | -------------------------------------------------------------------------------- /docker/api/startup.dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | dockerize -wait tcp://db:5432 -timeout 20s 3 | 4 | alembic upgrade head && uvicorn --host 0.0.0.0 application.server:app --reload 5 | -------------------------------------------------------------------------------- /docker/api/startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # docker compose로 띄우는 것이 아니기 때문에, 아래 항목 필요 없음 4 | # dockerize -wait tcp://${db}:5432 -timeout 20s 5 | 6 | gunicorn --bind 0.0.0.0:8000 -w 4 -k uvicorn.workers.UvicornWorker application.server:app --worker-tmp-dir /dev/shm --reload 7 | -------------------------------------------------------------------------------- /docker/db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14.3 2 | 3 | ENV POSTGRES_DB=fastapi 4 | ENV POSTGRES_PASSWORD=fastapi 5 | EXPOSE 5432 6 | CMD ["postgres"] 7 | -------------------------------------------------------------------------------- /docker/redis/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM redis 2 | MAINTAINER Hide 3 | 4 | EXPOSE 6379 5 | CMD ["redis-server"] -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | import uvicorn 5 | 6 | from application.core.config.config_container import config 7 | 8 | 9 | @click.command() 10 | @click.option( 11 | "--env", 12 | type=click.Choice(["local", "dev", "prod"], case_sensitive=False), 13 | default="local", 14 | ) 15 | @click.option( 16 | "--debug", 17 | type=click.BOOL, 18 | is_flag=True, 19 | default=False, 20 | ) 21 | def main(env: str, debug: bool): 22 | os.environ["ENV_FILE"] = f".env.{env}" 23 | print(f"RUNNING ENV is {env}") 24 | os.environ["DEBUG"] = str(debug) 25 | uvicorn.run( 26 | app="application.server:app", 27 | host=config.APP_HOST(), 28 | port=config.APP_PORT(), 29 | reload=True if config.ENV() != "production" else False, 30 | workers=1, 31 | ) 32 | 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | export PYTHONPATH=${PWD}/src:${PWD} 2 | export TEST_IMAGE=pg-14.3-test 3 | 4 | lint: 5 | black src/ 6 | isort src/ 7 | 8 | format: 9 | make lint 10 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place src/ --exclude=__init__.py 11 | 12 | my: 13 | mypy src/ 14 | blc: 15 | black --check src/ 16 | isc: 17 | isort src -c 18 | 19 | 20 | main: 21 | python main.py --env local --debug 22 | 23 | al-rev-auto: 24 | alembic revision --autogenerate 25 | 26 | al-up: 27 | alembic upgrade head 28 | 29 | del-ds: 30 | find . -name .DS_Store -print0 | xargs rm 31 | 32 | 33 | define check_image_and_run 34 | if [ -z $$(docker images -q $1) ]; then \ 35 | echo \>\>\>\>\>\> Image $1 was not found ; \ 36 | echo \>\>\>\>\>\> Building $1 ... ; \ 37 | docker build -f docker/db/Dockerfile -t $1 . && \ 38 | docker run -d -p 5432:5432 --rm $1; \ 39 | else \ 40 | echo \>\>\>\>\>\> Image $1 was found ; \ 41 | echo \>\>\>\>\>\> Running $1 ... ; \ 42 | docker run -d -p 5432:5432 --rm $1; \ 43 | fi 44 | endef 45 | 46 | test-db-run: 47 | @echo ">>>>>> Running test db..." 48 | $(call check_image_and_run, $$TEST_IMAGE) 49 | 50 | test-db-stop: 51 | @echo ">>>>>> Stopping test db..." 52 | docker ps -qf ancestor=$$TEST_IMAGE | xargs docker stop 53 | 54 | test-db-rerun: 55 | @echo ">>>>>> Stop and Rerun test db..." 56 | make test-db-stop 57 | make test-db-run 58 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import sys 4 | from logging.config import fileConfig 5 | 6 | from alembic import context 7 | from sqlalchemy import pool 8 | from sqlalchemy.ext.asyncio import create_async_engine 9 | 10 | parent_dir = os.path.abspath(os.path.join(os.getcwd(), "..")) 11 | sys.path.append(parent_dir) 12 | 13 | # this is the Alembic Config object, which provides 14 | # access to the values within the .ini file in use. 15 | config = context.config 16 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) 17 | 18 | 19 | # Interpret the config file for Python logging. 20 | # This line sets up loggers basically. 21 | if config.config_file_name is not None: 22 | fileConfig(config.config_file_name) 23 | 24 | # add your model's MetaData object here 25 | # for 'autogenerate' support 26 | # from myapp import mymodel 27 | # target_metadata = mymodel.Base.metadata 28 | 29 | from application.domain.user.models import Base, User 30 | 31 | # For auto generate schemas 32 | from application.core.config.config_container import config as app_config 33 | from application.domain.auth.models import Token 34 | from application.domain.log.models import RequestResponseLog 35 | 36 | # For auto generate schemas 37 | from application.core.config.config_container import config as app_config 38 | 39 | target_metadata = Base.metadata 40 | 41 | # other values from the config, defined by the needs of env.py, 42 | # can be acquired: 43 | # my_important_option = config.get_main_option("my_important_option") 44 | # ... etc. 45 | 46 | 47 | def run_migrations_offline(): 48 | """Run migrations in 'offline' mode. 49 | This configures the context with just a URL 50 | and not an Engine, though an Engine is acceptable 51 | here as well. By skipping the Engine creation 52 | we don't even need a DBAPI to be available. 53 | Calls to context.execute() here emit the given string to the 54 | script output. 55 | """ 56 | # url = config.get_main_option("sqlalchemy.url") 57 | context.configure( 58 | url=app_config.WRITER_DB_URL(), 59 | target_metadata=target_metadata, 60 | literal_binds=True, 61 | dialect_opts={"paramstyle": "named"}, 62 | ) 63 | 64 | with context.begin_transaction(): 65 | context.run_migrations() 66 | 67 | 68 | def do_run_migrations(connection): 69 | context.configure(connection=connection, target_metadata=target_metadata) 70 | 71 | with context.begin_transaction(): 72 | context.run_migrations() 73 | 74 | 75 | async def run_migrations_online(): 76 | """Run migrations in 'online' mode. 77 | In this scenario we need to create an Engine 78 | and associate a connection with the context. 79 | """ 80 | connectable = create_async_engine( 81 | app_config.WRITER_DB_URL(), poolclass=pool.NullPool 82 | ) 83 | 84 | async with connectable.connect() as connection: 85 | await connection.run_sync(do_run_migrations) 86 | 87 | await connectable.dispose() 88 | 89 | 90 | if context.is_offline_mode(): 91 | run_migrations_offline() 92 | else: 93 | asyncio.run(run_migrations_online()) 94 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/88f2cb0d4fc9_base.py: -------------------------------------------------------------------------------- 1 | """base 2 | 3 | Revision ID: 88f2cb0d4fc9 4 | Revises: 5 | Create Date: 2023-07-29 21:02:34.147872 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlalchemy_utils 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '88f2cb0d4fc9' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('request_response_log', 22 | sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), 23 | sa.Column('user_id', sa.INTEGER(), nullable=True), 24 | sa.Column('ip', sa.VARCHAR(), nullable=False), 25 | sa.Column('port', sa.INTEGER(), nullable=False), 26 | sa.Column('agent', sa.VARCHAR(), nullable=False), 27 | sa.Column('method', sa.VARCHAR(length=20), nullable=False), 28 | sa.Column('path', sa.VARCHAR(length=20), nullable=False), 29 | sa.Column('response_status', sa.SMALLINT(), nullable=False), 30 | sa.Column('request_id', sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False), 31 | sa.Column('created_at', sa.DateTime(), nullable=False), 32 | sa.Column('updated_at', sa.DateTime(), nullable=False), 33 | sa.PrimaryKeyConstraint('id') 34 | ) 35 | op.create_table('tokens', 36 | sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), 37 | sa.Column('user_id', sa.Integer(), nullable=False), 38 | sa.Column('refresh_token', sa.String(), nullable=False), 39 | sa.Column('refresh_expires_at', sa.DateTime(timezone=True), nullable=False), 40 | sa.Column('is_valid', sa.Boolean(), nullable=False), 41 | sa.PrimaryKeyConstraint('id') 42 | ) 43 | op.create_index(op.f('ix_tokens_refresh_token'), 'tokens', ['refresh_token'], unique=False) 44 | op.create_table('users', 45 | sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), 46 | sa.Column('password', sa.Unicode(length=255), nullable=False), 47 | sa.Column('email', sa.Unicode(length=255), nullable=False), 48 | sa.Column('nickname', sa.Unicode(length=255), nullable=False), 49 | sa.Column('authority', sa.Enum('MASTER', 'ADMIN', 'USER', 'GUEST', name='authority'), nullable=False), 50 | sa.Column('is_admin', sa.Boolean(), nullable=True), 51 | sa.Column('created_at', sa.DateTime(), nullable=False), 52 | sa.Column('updated_at', sa.DateTime(), nullable=False), 53 | sa.PrimaryKeyConstraint('id'), 54 | sa.UniqueConstraint('email'), 55 | sa.UniqueConstraint('nickname') 56 | ) 57 | # ### end Alembic commands ### 58 | 59 | 60 | def downgrade(): 61 | # ### commands auto generated by Alembic - please adjust! ### 62 | op.drop_table('users') 63 | op.drop_index(op.f('ix_tokens_refresh_token'), table_name='tokens') 64 | op.drop_table('tokens') 65 | op.drop_table('request_response_log') 66 | # ### end Alembic commands ### 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "Dependency_injector_based_fastapi-boilerplate" 3 | version = "0.1.0" 4 | description = "FastAPI Boilerplate" 5 | authors = ["rumbarum "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.11" 9 | alembic = "^1.8.1" 10 | SQLAlchemy = {version = "1.4.49", extras = ["mypy"]} 11 | PyJWT = "^2.4.0" 12 | uvicorn = "^0.22.0" 13 | fastapi = "^0.98.0" 14 | celery = "^5.2.7" 15 | gunicorn = "^20.1.0" 16 | fastapi-event = "^0.1.3" 17 | pythondi = "^1.2.4" 18 | click = "^8.1.3" 19 | greenlet = "2.0.2" 20 | redis = "^4.3.4" 21 | arrow = "^1.2.3" 22 | orjson = "^3.9.1" 23 | aiohttp = "^3.8.4" 24 | asyncpg = "^0.27.0" 25 | python-dotenv = "^1.0.0" 26 | types-redis = "^4.5.5.2" 27 | dependency-injector = "^4.41.0" 28 | nest-asyncio = "^1.5.6" 29 | sqlalchemy-stubs = "^0.4" 30 | sqlalchemy-utils = "^0.41.1" 31 | bcrypt = "^4.0.1" 32 | 33 | [tool.poetry.group.dev] 34 | 35 | [tool.poetry.group.dev.dependencies] 36 | pytest = "^7.2.0" 37 | pytest-cov = "^4.0.0" 38 | black = "^22.12.0" 39 | isort = "^5.11.4" 40 | mypy = "^0.991" 41 | behave = "^1.2.6" 42 | pytest-asyncio = "^0.21.0" 43 | httpx = "^0.24.1" 44 | pytest-mock = "^3.11.1" 45 | pytest-env = "^0.8.2" 46 | autoflake = "^2.2.0" 47 | 48 | [tool.black] 49 | line-length = 88 50 | 51 | [tool.isort] 52 | profile = "black" 53 | include_trailing_comma = true 54 | multi_line_output = 3 55 | line_length = 88 56 | force_grid_wrap = 0 57 | 58 | [tool.mypy] 59 | ignore_missing_imports = "True" 60 | mypy_path = "src" 61 | #check_untyped_defs = "True" 62 | #disallow_untyped_defs = "True" 63 | plugins = ["sqlalchemy.ext.mypy.plugin","sqlmypy","pydantic.mypy"] 64 | 65 | [tool.commitizen] 66 | name = "cz_conventional_commits" 67 | tag_format = "$version" 68 | version_scheme = "semver" 69 | version_provider = "poetry" 70 | update_changelog_on_bump = true 71 | major_version_zero = true 72 | use_shortcuts = true 73 | 74 | [tool.pytest.ini_options] 75 | addopts = "--tb=short" 76 | pythonpath = "src" 77 | asyncio_mode = "auto" 78 | env = {SQLALCHEMY_SILENCE_UBER_WARNING=1} 79 | 80 | [build-system] 81 | requires = ["poetry-core>=1.0.0"] 82 | build-backend = "poetry.core.masonry.api" 83 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.4 ; python_version >= "3.11" and python_version < "4.0" 2 | aiomysql==0.2.0 ; python_version >= "3.11" and python_version < "4.0" 3 | aiosignal==1.3.1 ; python_version >= "3.11" and python_version < "4.0" 4 | alembic==1.11.1 ; python_version >= "3.11" and python_version < "4.0" 5 | amqp==5.1.1 ; python_version >= "3.11" and python_version < "4.0" 6 | anyio==3.7.1 ; python_version >= "3.11" and python_version < "4.0" 7 | arrow==1.2.3 ; python_version >= "3.11" and python_version < "4.0" 8 | async-timeout==4.0.2 ; python_version >= "3.11" and python_version < "4.0" 9 | asyncpg==0.27.0 ; python_version >= "3.11" and python_version < "4.0" 10 | attrs==23.1.0 ; python_version >= "3.11" and python_version < "4.0" 11 | autoflake==2.2.0 ; python_version >= "3.11" and python_version < "4.0" 12 | behave==1.2.6 ; python_version >= "3.11" and python_version < "4.0" 13 | billiard==4.1.0 ; python_version >= "3.11" and python_version < "4.0" 14 | black==22.12.0 ; python_version >= "3.11" and python_version < "4.0" 15 | celery==5.3.1 ; python_version >= "3.11" and python_version < "4.0" 16 | certifi==2023.5.7 ; python_version >= "3.11" and python_version < "4.0" 17 | cffi==1.15.1 ; python_version >= "3.11" and python_version < "4.0" 18 | charset-normalizer==3.1.0 ; python_version >= "3.11" and python_version < "4.0" 19 | click-didyoumean==0.3.0 ; python_version >= "3.11" and python_version < "4.0" 20 | click-plugins==1.1.1 ; python_version >= "3.11" and python_version < "4.0" 21 | click-repl==0.3.0 ; python_version >= "3.11" and python_version < "4.0" 22 | click==8.1.3 ; python_version >= "3.11" and python_version < "4.0" 23 | colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" and (platform_system == "Windows" or sys_platform == "win32") 24 | coverage[toml]==7.2.7 ; python_version >= "3.11" and python_version < "4.0" 25 | cryptography==41.0.1 ; python_version >= "3.11" and python_version < "4.0" 26 | dependency-injector==4.41.0 ; python_version >= "3.11" and python_version < "4.0" 27 | fastapi-event==0.1.3 ; python_version >= "3.11" and python_version < "4.0" 28 | fastapi==0.98.0 ; python_version >= "3.11" and python_version < "4.0" 29 | frozenlist==1.3.3 ; python_version >= "3.11" and python_version < "4.0" 30 | greenlet==2.0.2 ; python_version >= "3.11" and python_version < "4.0" 31 | gunicorn==20.1.0 ; python_version >= "3.11" and python_version < "4.0" 32 | h11==0.14.0 ; python_version >= "3.11" and python_version < "4.0" 33 | httpcore==0.17.3 ; python_version >= "3.11" and python_version < "4.0" 34 | httpx==0.24.1 ; python_version >= "3.11" and python_version < "4.0" 35 | idna==3.4 ; python_version >= "3.11" and python_version < "4.0" 36 | iniconfig==2.0.0 ; python_version >= "3.11" and python_version < "4.0" 37 | isort==5.12.0 ; python_version >= "3.11" and python_version < "4.0" 38 | kombu==5.3.1 ; python_version >= "3.11" and python_version < "4.0" 39 | mako==1.2.4 ; python_version >= "3.11" and python_version < "4.0" 40 | markupsafe==2.1.3 ; python_version >= "3.11" and python_version < "4.0" 41 | multidict==6.0.4 ; python_version >= "3.11" and python_version < "4.0" 42 | mypy-extensions==1.0.0 ; python_version >= "3.11" and python_version < "4.0" 43 | mypy==0.991 ; python_version >= "3.11" and python_version < "4.0" 44 | nest-asyncio==1.5.6 ; python_version >= "3.11" and python_version < "4.0" 45 | numpy==1.25.0 ; python_version >= "3.11" and python_version < "4.0" 46 | orjson==3.9.1 ; python_version >= "3.11" and python_version < "4.0" 47 | packaging==23.1 ; python_version >= "3.11" and python_version < "4.0" 48 | pandas==2.0.3 ; python_version >= "3.11" and python_version < "4.0" 49 | parse-type==0.6.2 ; python_version >= "3.11" and python_version < "4.0" 50 | parse==1.19.1 ; python_version >= "3.11" and python_version < "4.0" 51 | pathspec==0.11.1 ; python_version >= "3.11" and python_version < "4.0" 52 | platformdirs==3.8.0 ; python_version >= "3.11" and python_version < "4.0" 53 | pluggy==1.2.0 ; python_version >= "3.11" and python_version < "4.0" 54 | prompt-toolkit==3.0.39 ; python_version >= "3.11" and python_version < "4.0" 55 | pycparser==2.21 ; python_version >= "3.11" and python_version < "4.0" 56 | pydantic==1.10.11 ; python_version >= "3.11" and python_version < "4.0" 57 | pyflakes==3.0.1 ; python_version >= "3.11" and python_version < "4.0" 58 | pyjwt==2.7.0 ; python_version >= "3.11" and python_version < "4.0" 59 | pymysql==1.1.0 ; python_version >= "3.11" and python_version < "4.0" 60 | pytest-asyncio==0.21.0 ; python_version >= "3.11" and python_version < "4.0" 61 | pytest-cov==4.1.0 ; python_version >= "3.11" and python_version < "4.0" 62 | pytest-env==0.8.2 ; python_version >= "3.11" and python_version < "4.0" 63 | pytest-mock==3.11.1 ; python_version >= "3.11" and python_version < "4.0" 64 | pytest==7.4.0 ; python_version >= "3.11" and python_version < "4.0" 65 | python-dateutil==2.8.2 ; python_version >= "3.11" and python_version < "4.0" 66 | python-dotenv==1.0.0 ; python_version >= "3.11" and python_version < "4.0" 67 | pythondi==1.2.4 ; python_version >= "3.11" and python_version < "4.0" 68 | pytz==2023.3 ; python_version >= "3.11" and python_version < "4.0" 69 | redis==4.6.0 ; python_version >= "3.11" and python_version < "4.0" 70 | setuptools==68.0.0 ; python_version >= "3.11" and python_version < "4.0" 71 | six==1.16.0 ; python_version >= "3.11" and python_version < "4.0" 72 | sniffio==1.3.0 ; python_version >= "3.11" and python_version < "4.0" 73 | sqlalchemy-stubs==0.4 ; python_version >= "3.11" and python_version < "4.0" 74 | sqlalchemy-utils==0.41.1 ; python_version >= "3.11" and python_version < "4.0" 75 | sqlalchemy2-stubs==0.0.2a34 ; python_version >= "3.11" and python_version < "4.0" 76 | sqlalchemy==1.4.49 ; python_version >= "3.11" and python_version < "4.0" 77 | sqlalchemy[asyncio,mypy]==1.4.49 ; python_version >= "3.11" and python_version < "4.0" 78 | starlette==0.27.0 ; python_version >= "3.11" and python_version < "4.0" 79 | types-pyopenssl==23.2.0.1 ; python_version >= "3.11" and python_version < "4.0" 80 | types-redis==4.6.0.2 ; python_version >= "3.11" and python_version < "4.0" 81 | typing-extensions==4.7.1 ; python_version >= "3.11" and python_version < "4.0" 82 | tzdata==2023.3 ; python_version >= "3.11" and python_version < "4.0" 83 | uvicorn==0.22.0 ; python_version >= "3.11" and python_version < "4.0" 84 | vine==5.0.0 ; python_version >= "3.11" and python_version < "4.0" 85 | wcwidth==0.2.6 ; python_version >= "3.11" and python_version < "4.0" 86 | yarl==1.9.2 ; python_version >= "3.11" and python_version < "4.0" 87 | -------------------------------------------------------------------------------- /requirements.prod.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.4 ; python_version >= "3.11" and python_version < "4.0" 2 | aiomysql==0.2.0 ; python_version >= "3.11" and python_version < "4.0" 3 | aiosignal==1.3.1 ; python_version >= "3.11" and python_version < "4.0" 4 | alembic==1.11.1 ; python_version >= "3.11" and python_version < "4.0" 5 | amqp==5.1.1 ; python_version >= "3.11" and python_version < "4.0" 6 | anyio==3.7.1 ; python_version >= "3.11" and python_version < "4.0" 7 | arrow==1.2.3 ; python_version >= "3.11" and python_version < "4.0" 8 | async-timeout==4.0.2 ; python_version >= "3.11" and python_version < "4.0" 9 | asyncpg==0.27.0 ; python_version >= "3.11" and python_version < "4.0" 10 | attrs==23.1.0 ; python_version >= "3.11" and python_version < "4.0" 11 | billiard==4.1.0 ; python_version >= "3.11" and python_version < "4.0" 12 | celery==5.3.1 ; python_version >= "3.11" and python_version < "4.0" 13 | cffi==1.15.1 ; python_version >= "3.11" and python_version < "4.0" 14 | charset-normalizer==3.1.0 ; python_version >= "3.11" and python_version < "4.0" 15 | click-didyoumean==0.3.0 ; python_version >= "3.11" and python_version < "4.0" 16 | click-plugins==1.1.1 ; python_version >= "3.11" and python_version < "4.0" 17 | click-repl==0.3.0 ; python_version >= "3.11" and python_version < "4.0" 18 | click==8.1.3 ; python_version >= "3.11" and python_version < "4.0" 19 | colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" and platform_system == "Windows" 20 | cryptography==41.0.1 ; python_version >= "3.11" and python_version < "4.0" 21 | dependency-injector==4.41.0 ; python_version >= "3.11" and python_version < "4.0" 22 | fastapi-event==0.1.3 ; python_version >= "3.11" and python_version < "4.0" 23 | fastapi==0.98.0 ; python_version >= "3.11" and python_version < "4.0" 24 | frozenlist==1.3.3 ; python_version >= "3.11" and python_version < "4.0" 25 | greenlet==2.0.2 ; python_version >= "3.11" and python_version < "4.0" 26 | gunicorn==20.1.0 ; python_version >= "3.11" and python_version < "4.0" 27 | h11==0.14.0 ; python_version >= "3.11" and python_version < "4.0" 28 | idna==3.4 ; python_version >= "3.11" and python_version < "4.0" 29 | kombu==5.3.1 ; python_version >= "3.11" and python_version < "4.0" 30 | mako==1.2.4 ; python_version >= "3.11" and python_version < "4.0" 31 | markupsafe==2.1.3 ; python_version >= "3.11" and python_version < "4.0" 32 | multidict==6.0.4 ; python_version >= "3.11" and python_version < "4.0" 33 | mypy-extensions==1.0.0 ; python_version >= "3.11" and python_version < "4.0" 34 | mypy==0.991 ; python_version >= "3.11" and python_version < "4.0" 35 | nest-asyncio==1.5.6 ; python_version >= "3.11" and python_version < "4.0" 36 | numpy==1.25.0 ; python_version >= "3.11" and python_version < "4.0" 37 | orjson==3.9.1 ; python_version >= "3.11" and python_version < "4.0" 38 | pandas==2.0.3 ; python_version >= "3.11" and python_version < "4.0" 39 | prompt-toolkit==3.0.39 ; python_version >= "3.11" and python_version < "4.0" 40 | pycparser==2.21 ; python_version >= "3.11" and python_version < "4.0" 41 | pydantic==1.10.11 ; python_version >= "3.11" and python_version < "4.0" 42 | pyjwt==2.7.0 ; python_version >= "3.11" and python_version < "4.0" 43 | pymysql==1.1.0 ; python_version >= "3.11" and python_version < "4.0" 44 | python-dateutil==2.8.2 ; python_version >= "3.11" and python_version < "4.0" 45 | python-dotenv==1.0.0 ; python_version >= "3.11" and python_version < "4.0" 46 | pythondi==1.2.4 ; python_version >= "3.11" and python_version < "4.0" 47 | pytz==2023.3 ; python_version >= "3.11" and python_version < "4.0" 48 | redis==4.6.0 ; python_version >= "3.11" and python_version < "4.0" 49 | setuptools==68.0.0 ; python_version >= "3.11" and python_version < "4.0" 50 | six==1.16.0 ; python_version >= "3.11" and python_version < "4.0" 51 | sniffio==1.3.0 ; python_version >= "3.11" and python_version < "4.0" 52 | sqlalchemy-stubs==0.4 ; python_version >= "3.11" and python_version < "4.0" 53 | sqlalchemy-utils==0.41.1 ; python_version >= "3.11" and python_version < "4.0" 54 | sqlalchemy2-stubs==0.0.2a34 ; python_version >= "3.11" and python_version < "4.0" 55 | sqlalchemy==1.4.49 ; python_version >= "3.11" and python_version < "4.0" 56 | sqlalchemy[asyncio,mypy]==1.4.49 ; python_version >= "3.11" and python_version < "4.0" 57 | starlette==0.27.0 ; python_version >= "3.11" and python_version < "4.0" 58 | types-pyopenssl==23.2.0.1 ; python_version >= "3.11" and python_version < "4.0" 59 | types-redis==4.6.0.2 ; python_version >= "3.11" and python_version < "4.0" 60 | typing-extensions==4.7.1 ; python_version >= "3.11" and python_version < "4.0" 61 | tzdata==2023.3 ; python_version >= "3.11" and python_version < "4.0" 62 | uvicorn==0.22.0 ; python_version >= "3.11" and python_version < "4.0" 63 | vine==5.0.0 ; python_version >= "3.11" and python_version < "4.0" 64 | wcwidth==0.2.6 ; python_version >= "3.11" and python_version < "4.0" 65 | yarl==1.9.2 ; python_version >= "3.11" and python_version < "4.0" 66 | -------------------------------------------------------------------------------- /src/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/__init__.py -------------------------------------------------------------------------------- /src/application/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from application.core.fastapi.custom_json_response import CustomORJSONResponse 4 | from application.core.fastapi.pydantic_models import ResponseBaseModel 5 | from application.domain.auth.views import auth_router 6 | from application.domain.home.views import home_router 7 | from application.domain.user.views import user_router as user_v1_router 8 | 9 | 10 | class ErrorResponse(ResponseBaseModel): 11 | code: str 12 | message: str 13 | data: dict | list | None 14 | 15 | 16 | router = APIRouter( 17 | default_response_class=CustomORJSONResponse, 18 | responses={ 19 | 400: {"model": ErrorResponse}, 20 | 401: {"model": ErrorResponse}, 21 | 403: {"model": ErrorResponse}, 22 | 404: {"model": ErrorResponse}, 23 | 500: {"model": ErrorResponse}, 24 | }, 25 | ) 26 | 27 | router.include_router(user_v1_router, prefix="/api/v1/users", tags=["User"]) 28 | router.include_router(auth_router, prefix="/auth", tags=["Auth"]) 29 | router.include_router(home_router, tags=["Home"]) 30 | -------------------------------------------------------------------------------- /src/application/celery_task/__init__.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | from application.core.config.config_container import config 4 | 5 | celery_app = Celery( 6 | "worker", 7 | backend=config.CELERY_BACKEND_URL(), 8 | broker=config.CELERY_BROKER_URL(), 9 | ) 10 | 11 | celery_app.conf.task_routes = {"worker.celery_worker.test_celery": "test-queue"} 12 | celery_app.conf.update(task_track_started=True) 13 | -------------------------------------------------------------------------------- /src/application/celery_task/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/celery_task/tasks/__init__.py -------------------------------------------------------------------------------- /src/application/container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | from fastapi.middleware import Middleware 3 | from fastapi.middleware.cors import CORSMiddleware 4 | from redis.asyncio.client import Redis 5 | from sqlalchemy.ext.asyncio import ( 6 | AsyncSession, 7 | async_scoped_session, 8 | create_async_engine, 9 | ) 10 | from sqlalchemy.orm import sessionmaker 11 | 12 | from application.core.config.config_container import config_container 13 | from application.core.db.session_maker import RoutingSession, get_session_context 14 | from application.core.external_service.auth_client import AuthClient 15 | from application.core.external_service.http_client import Aiohttp 16 | from application.core.helpers.cache import CacheManager, CustomKeyMaker, RedisBackend 17 | from application.core.helpers.logging import init_logger 18 | from application.core.middlewares import ( 19 | AuthenticationMiddleware, 20 | ExternalAuthBackend, 21 | on_auth_error, 22 | ) 23 | from application.core.middlewares.sqlalchemy import SQLAlchemyMiddleware 24 | from application.domain.auth.container import AuthContainer 25 | from application.domain.log.container import LogContainer 26 | from application.domain.user.container import UserContainer 27 | 28 | 29 | class AppContainer(containers.DeclarativeContainer): 30 | # config loaded from pydantic model 31 | config = config_container.config 32 | 33 | # dependency injector coverage 34 | wiring_config = containers.WiringConfiguration( 35 | packages=["application"], 36 | modules=[__name__], 37 | ) 38 | logging = providers.Resource(init_logger, env=config.ENV) 39 | 40 | # async client 41 | async_http_client = providers.Callable( 42 | Aiohttp.get_aiohttp_client, 43 | client_timeout=config.CLIENT_TIME_OUT, 44 | limit_per_host=config.SIZE_POOL_AIOHTTP, 45 | ) 46 | 47 | auth_client = providers.Singleton( 48 | AuthClient, 49 | auth_base_url=config.AUTH_BASE_URL, 50 | client_id=config.AUTH_CLIENT_ID, 51 | client_secret=config.AUTH_CLIENT_SECRET, 52 | refresh_token_key=config.AUTH_REFRESH_TOKEN_KEY, 53 | scope=config.AUTH_SCOPE, 54 | session=async_http_client, 55 | ssl=True if config.ENV == "production" else False, 56 | ) 57 | 58 | # middleware 59 | cors_middleware = providers.Factory( 60 | Middleware, 61 | CORSMiddleware, 62 | allow_origins=config.ALLOW_ORIGINS, 63 | allow_credentials=config.ALLOW_CREDENTIALS, 64 | allow_methods=config.ALLOW_METHODS, 65 | allow_headers=config.ALLOW_HEADERS, 66 | ) 67 | auth_backend = providers.Singleton( 68 | # auth_client is injected on AstAuthBackend declaration 69 | ExternalAuthBackend 70 | ) 71 | auth_middleware = providers.Singleton( 72 | Middleware, 73 | AuthenticationMiddleware, 74 | backend=auth_backend, 75 | on_error=on_auth_error, 76 | ) 77 | sqlalchemy_middleware = providers.Factory(Middleware, SQLAlchemyMiddleware) 78 | middleware_list = providers.List( 79 | cors_middleware, 80 | auth_middleware, 81 | sqlalchemy_middleware, 82 | ) 83 | 84 | # redis 85 | redis_backend = providers.Factory(RedisBackend) 86 | redis_key_maker = providers.Factory(CustomKeyMaker) 87 | redis = providers.Singleton( 88 | Redis.from_url, 89 | # this value should be called when declared. If not, repr(config.value) will be injected. 90 | url=f"redis://{config.REDIS_HOST()}:{config.REDIS_PORT()}", 91 | ) 92 | cache_manager = providers.Singleton( 93 | CacheManager, backend=redis_backend, key_maker=redis_key_maker 94 | ) 95 | 96 | # session 97 | writer_engine = providers.Factory( 98 | create_async_engine, config.WRITER_DB_URL, pool_recycle=3600 99 | ) 100 | reader_engine = providers.Factory( 101 | create_async_engine, config.READER_DB_URL, pool_recycle=3600 102 | ) 103 | 104 | async_session_factory = providers.Factory( 105 | sessionmaker, 106 | class_=AsyncSession, 107 | sync_session_class=RoutingSession, 108 | ) 109 | session = providers.ThreadSafeSingleton( 110 | async_scoped_session, 111 | session_factory=async_session_factory, 112 | scopefunc=get_session_context, 113 | ) 114 | 115 | # domain/.../config 116 | user_container = providers.Container(UserContainer) 117 | auth_container = providers.Container(AuthContainer) 118 | log_container = providers.Container(LogContainer) 119 | -------------------------------------------------------------------------------- /src/application/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/core/__init__.py -------------------------------------------------------------------------------- /src/application/core/authority/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/core/authority/__init__.py -------------------------------------------------------------------------------- /src/application/core/authority/permissions.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from enum import StrEnum 3 | from typing import Type 4 | 5 | from starlette.requests import Request 6 | 7 | from application.core.exceptions import CustomException 8 | from application.core.exceptions.middleware import ( 9 | NoAuthenticationException, 10 | NoAuthorityException, 11 | ) 12 | from application.core.middlewares import AuthUser 13 | 14 | 15 | class Authority(StrEnum): 16 | level: int 17 | description: str 18 | 19 | MASTER = ("MASTER", 0, "master account") 20 | ADMIN = ("ADMIN", 1, "admin account") 21 | USER = ("USER", 2, "user account") 22 | GUEST = ("GUEST", 3, "guest account") 23 | 24 | def __new__(cls, value, level, description=""): 25 | obj = str.__new__(cls, value) 26 | obj._value_ = value 27 | obj.level = level # type: ignore 28 | obj.description = description # type: ignore 29 | return obj 30 | 31 | @classmethod 32 | def is_authority_higher_or_equal_than_prior(cls, auth1: str, auth2: str) -> bool: 33 | """lowest is higher priority""" 34 | return cls[auth1].level >= cls[auth2].level 35 | 36 | @classmethod 37 | def is_authority_higher_than_prior(cls, auth1: str, auth2: str) -> bool: 38 | """lowest is higher priority""" 39 | return cls[auth1].level > cls[auth2].level 40 | 41 | 42 | class BasePermission(ABC): 43 | exception: Type[CustomException] 44 | 45 | @abstractmethod 46 | async def has_permission(self, request: Request) -> bool: 47 | pass 48 | 49 | 50 | class IsAuthenticated(BasePermission): 51 | exception = NoAuthenticationException 52 | 53 | async def has_permission(self, request: Request) -> bool: 54 | user: AuthUser = request.user 55 | if user.auth_error is not None: 56 | raise user.auth_error 57 | return user.is_authenticated 58 | 59 | 60 | class IsMaster(BasePermission): 61 | allowed_authority = Authority.MASTER.name 62 | exception = NoAuthorityException 63 | 64 | async def has_permission(self, request: Request) -> bool: 65 | user: AuthUser = request.user 66 | if user.auth_error is not None: 67 | raise user.auth_error 68 | if (auth := user.user_authority) is not None and auth == self.allowed_authority: 69 | return True 70 | return False 71 | 72 | 73 | class IsAdmin(IsMaster): 74 | allowed_authority = Authority.ADMIN.name 75 | 76 | 77 | class IsUser(IsMaster): 78 | allowed_authority = Authority.USER.name 79 | 80 | 81 | class IsGuest(IsMaster): 82 | allowed_authority = Authority.GUEST.name 83 | 84 | 85 | class IsHigherOrEqualMaster(BasePermission): 86 | allowed_authority = Authority.MASTER.name 87 | exception = NoAuthorityException 88 | 89 | async def has_permission(self, request: Request) -> bool: 90 | user: AuthUser = request.user 91 | if user.auth_error is not None: 92 | raise user.auth_error 93 | if ( 94 | auth := user.user_authority 95 | ) is not None and Authority.is_authority_higher_or_equal_than_prior( 96 | self.allowed_authority, auth 97 | ): 98 | return True 99 | return False 100 | 101 | 102 | class IsHigherOrEqualAdmin(IsHigherOrEqualMaster): 103 | allowed_authority = Authority.ADMIN.name 104 | 105 | 106 | class IsHigherOrEqualUser(IsHigherOrEqualMaster): 107 | allowed_authority = Authority.USER.name 108 | 109 | 110 | class IsHigherOrEqualGuest(IsHigherOrEqualMaster): 111 | allowed_authority = Authority.GUEST.name 112 | 113 | 114 | class AllowAll(BasePermission): 115 | async def has_permission(self, request: Request) -> bool: 116 | return True 117 | -------------------------------------------------------------------------------- /src/application/core/base_class/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/core/base_class/__init__.py -------------------------------------------------------------------------------- /src/application/core/base_class/repository.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generic, Optional, Type, TypeVar 2 | 3 | from dependency_injector.wiring import Provide 4 | from sqlalchemy import and_, delete, or_, select, update 5 | from sqlalchemy.ext.asyncio import async_scoped_session 6 | 7 | from application.core.enums.repository import SynchronizeSessionEnum 8 | 9 | session: async_scoped_session = Provide["session"] 10 | 11 | 12 | ModelType = TypeVar("ModelType") 13 | 14 | 15 | class BaseRepository: 16 | pass 17 | 18 | 19 | class BaseAlchemyRepository(BaseRepository, Generic[ModelType]): 20 | model: Type[ModelType] 21 | 22 | def __init__(self, model: Type[ModelType]): 23 | self.model = model 24 | 25 | async def get_by_id(self, id: int) -> Optional[ModelType]: 26 | if hasattr(self.model, "id"): 27 | query = select(self.model.id == id) # type: ignore[attr-defined] 28 | result = await session.execute(query) 29 | return result.scalars().first() 30 | return None 31 | 32 | async def find_by_or_condition( 33 | self, 34 | where_condition: dict[str, str | int], 35 | is_first: bool = False, 36 | ) -> Any | list[ModelType] | None: 37 | cond_dict = {} 38 | for k, v in where_condition.items(): 39 | if not hasattr(self.model, k): 40 | raise ValueError(f"{self.model} has no {k}") 41 | cond_dict[getattr(self.model, k)] = v 42 | query = select(self.model).where(or_(**cond_dict)) # type: ignore[arg-type] 43 | result = await session.execute(query) 44 | if is_first: 45 | return result.scalars().first() 46 | return result.scalars().all() 47 | 48 | async def find_by_and_condition( 49 | self, 50 | where_condition: dict[str, str | int], 51 | is_first: bool = False, 52 | ) -> Any | list[ModelType] | None: 53 | cond_dict = {} 54 | for k, v in where_condition.items(): 55 | if not hasattr(self.model, k): 56 | raise ValueError(f"{self.model} has no {k}") 57 | cond_dict[getattr(self.model, k)] = v 58 | query = select(self.model).where(and_(**cond_dict)) # type: ignore[arg-type] 59 | result = await session.execute(query) 60 | if is_first: 61 | return result.scalars().first() 62 | return result.scalars().all() 63 | 64 | async def update_by_id( 65 | self, 66 | id: int, 67 | params: dict, 68 | synchronize_session: SynchronizeSessionEnum = SynchronizeSessionEnum.FALSE, 69 | ) -> None: 70 | if hasattr(self.model, "id"): 71 | query = ( 72 | update(self.model) # type: ignore[arg-type] 73 | .where(self.model.id == id) # type: ignore[attr-defined] 74 | .values(**params) 75 | .execution_options(synchronize_session=synchronize_session) 76 | ) 77 | await session.execute(query) 78 | else: 79 | raise ValueError(f"{self.model} HAS NO ID") 80 | 81 | @staticmethod 82 | async def delete(model: ModelType) -> None: 83 | await session.delete(model) # type: ignore[arg-type] 84 | 85 | async def delete_by_id( 86 | self, 87 | id: int, 88 | synchronize_session: SynchronizeSessionEnum = SynchronizeSessionEnum.FALSE, 89 | ) -> None: 90 | if hasattr(self.model, "id"): 91 | query = ( 92 | delete(self.model) # type: ignore[arg-type] 93 | .where(self.model.id == id) # type: ignore[attr-defined] 94 | .execution_options(synchronize_session=synchronize_session) 95 | ) 96 | await session.execute(query) 97 | else: 98 | raise ValueError(f"{self.model} HAS NO ID") 99 | 100 | @staticmethod 101 | def save(model: ModelType) -> None: 102 | session.add(model) # type: ignore[func-returns-value] 103 | -------------------------------------------------------------------------------- /src/application/core/base_class/service.py: -------------------------------------------------------------------------------- 1 | from .repository import BaseRepository 2 | 3 | 4 | class BaseService: 5 | repository: BaseRepository 6 | 7 | def __init__(self, repository: BaseRepository): 8 | self.repository = repository 9 | -------------------------------------------------------------------------------- /src/application/core/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/core/config/__init__.py -------------------------------------------------------------------------------- /src/application/core/config/config_container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from application.core.config.settings_model import Settings 4 | 5 | 6 | class ConfigContainer(containers.DeclarativeContainer): 7 | # config loading from pydantic 8 | config = providers.Configuration(pydantic_settings=[Settings()]) 9 | 10 | # di 동작 범위 선언 11 | wiring_config = containers.WiringConfiguration( 12 | modules=[ 13 | __name__, 14 | ], 15 | ) 16 | 17 | 18 | config_container = ConfigContainer() 19 | # This is used for when config required before config init. 20 | # e.g. main.py, migrations 21 | config = config_container.config 22 | -------------------------------------------------------------------------------- /src/application/core/config/settings_model.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, Optional 3 | 4 | from pydantic import BaseConfig, BaseSettings, Field 5 | 6 | 7 | class Settings(BaseSettings): 8 | # Basic Settings 9 | TITLE: str 10 | DESCRIPTION: str 11 | VERSION: str 12 | ENV: str 13 | DEBUG: bool 14 | APP_HOST: str 15 | APP_PORT: int 16 | APP_DOMAIN: str 17 | WRITER_DB_URL: str 18 | READER_DB_URL: str 19 | JWT_SECRET_KEY: str 20 | JWT_ALGORITHM: str = "SHA256" 21 | SENTRY_SDN: Optional[str] = None 22 | CELERY_BROKER_URL: str 23 | CELERY_BACKEND_URL: str 24 | REDIS_HOST: str 25 | REDIS_PORT: int 26 | JWT_EXPIRE_SECONDS: int = 3600 27 | ACCESS_TOKEN_EXPIRE_SECONDS: int = 60 28 | REFRESH_TOKEN_EXPIRE_SECONDS: int = 86400 29 | 30 | # Aio HTTP config 31 | CLIENT_TIME_OUT: int 32 | SIZE_POOL_AIOHTTP: int 33 | 34 | # External API Settings 35 | AUTH_BASE_URL: str 36 | AUTH_CLIENT_ID: str 37 | AUTH_CLIENT_SECRET: str 38 | AUTH_REFRESH_TOKEN_KEY: str 39 | AUTH_SCOPE: list[str] = Field(..., env="AUTH_SCOPE") 40 | 41 | # CORS Settings 42 | ALLOW_ORIGINS: list[str] = Field(..., env="ALLOW_ORIGINS") 43 | ALLOW_CREDENTIALS: bool 44 | ALLOW_METHODS: list[str] = Field(..., env="ALLOW_METHODS") 45 | ALLOW_HEADERS: list[str] = Field(..., env="ALLOW_HEADERS") 46 | 47 | class Config(BaseConfig): 48 | env_file = os.getenv("ENV_FILE", ".env.local") 49 | env_file_encoding = "utf-8" 50 | 51 | comma_separated_key = [ 52 | "AUTH_SCOPE", 53 | "ALLOW_ORIGINS", 54 | "ALLOW_METHODS", 55 | "ALLOW_HEADERS", 56 | ] 57 | 58 | @classmethod 59 | def parse_env_var(cls, field_name: str, raw_val: str) -> Any: 60 | """comma separated string to list""" 61 | if field_name in cls.comma_separated_key: 62 | return [scope for scope in raw_val.split(",")] 63 | return cls.json_loads(raw_val) 64 | -------------------------------------------------------------------------------- /src/application/core/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .session_maker import Base 2 | from .standalone_session_maker import standalone_session 3 | from .transactional import Transactional 4 | 5 | __all__ = [ 6 | "Base", 7 | "Transactional", 8 | "standalone_session", 9 | ] 10 | -------------------------------------------------------------------------------- /src/application/core/db/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .timestamp_mixin import * 2 | -------------------------------------------------------------------------------- /src/application/core/db/mixins/timestamp_mixin.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, DateTime, func 2 | from sqlalchemy.ext.declarative import declared_attr 3 | 4 | 5 | class TimestampMixin: 6 | @declared_attr 7 | def created_at(cls): 8 | return Column(DateTime, default=func.now(), nullable=False) 9 | 10 | @declared_attr 11 | def updated_at(cls): 12 | return Column( 13 | DateTime, 14 | default=func.now(), 15 | onupdate=func.now(), 16 | nullable=False, 17 | ) 18 | -------------------------------------------------------------------------------- /src/application/core/db/session_maker.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar, Token 2 | 3 | from dependency_injector.wiring import Provide, inject 4 | from sqlalchemy.ext.declarative import declarative_base 5 | from sqlalchemy.orm import Session 6 | from sqlalchemy.sql.expression import Delete, Insert, Update 7 | 8 | session_context: ContextVar[str] = ContextVar("session_context") 9 | 10 | 11 | def get_session_context() -> str: 12 | return session_context.get() 13 | 14 | 15 | def set_session_context(session_id: str) -> Token: 16 | return session_context.set(session_id) 17 | 18 | 19 | def reset_session_context(context: Token) -> None: 20 | session_context.reset(context) 21 | 22 | 23 | class RoutingSession(Session): 24 | @inject 25 | def get_bind( 26 | self, 27 | mapper=None, 28 | clause=None, 29 | writer_engine=Provide["writer_engine"], 30 | reader_engine=Provide["reader_engine"], 31 | **kw, 32 | ): 33 | if self._flushing or isinstance(clause, (Update, Delete, Insert)): # type: ignore[attr-defined] 34 | return writer_engine.sync_engine 35 | else: 36 | return reader_engine.sync_engine 37 | 38 | 39 | Base = declarative_base() 40 | -------------------------------------------------------------------------------- /src/application/core/db/standalone_session_maker.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from dependency_injector.wiring import Provide 4 | from sqlalchemy.ext.asyncio import async_scoped_session 5 | 6 | from .session_maker import reset_session_context, set_session_context 7 | 8 | session: async_scoped_session = Provide["session"] 9 | 10 | 11 | def standalone_session(func): 12 | async def _standalone_session(*args, **kwargs): 13 | session_id = str(uuid4()) 14 | context = set_session_context(session_id=session_id) 15 | 16 | try: 17 | result = await func(*args, **kwargs) 18 | await session.commit() 19 | except Exception as e: 20 | await session.rollback() 21 | raise e 22 | finally: 23 | await session.remove() 24 | reset_session_context(context=context) 25 | return result 26 | 27 | return _standalone_session 28 | -------------------------------------------------------------------------------- /src/application/core/db/transactional.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from dependency_injector.wiring import Provide 4 | from sqlalchemy.ext.asyncio import async_scoped_session 5 | 6 | session: async_scoped_session = Provide["session"] 7 | 8 | 9 | class Transactional: 10 | def __call__(self, func): 11 | @wraps(func) 12 | async def _transactional(*args, **kwargs): 13 | try: 14 | result = await func(*args, **kwargs) 15 | await session.commit() 16 | except Exception as e: 17 | await session.rollback() 18 | raise e 19 | 20 | return result 21 | 22 | return _transactional 23 | -------------------------------------------------------------------------------- /src/application/core/dependencies/__init__.py: -------------------------------------------------------------------------------- 1 | from .permission import PermissionDependency 2 | 3 | __all__ = [ 4 | "PermissionDependency", 5 | ] 6 | -------------------------------------------------------------------------------- /src/application/core/dependencies/permission.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | from fastapi import Request 4 | from fastapi.openapi.models import APIKey, APIKeyIn 5 | from fastapi.security.base import SecurityBase 6 | 7 | from application.core.authority.permissions import BasePermission 8 | 9 | 10 | class PermissionDependency(SecurityBase): 11 | def __init__(self, permissions: List[Type[BasePermission]]): 12 | self.permissions = permissions 13 | self.model: APIKey = APIKey(**{"in": APIKeyIn.header}, name="Authorization") 14 | self.scheme_name = self.__class__.__name__ 15 | 16 | async def __call__(self, request: Request): 17 | for permission in self.permissions: 18 | cls = permission() 19 | if not await cls.has_permission(request=request): 20 | raise cls.exception 21 | -------------------------------------------------------------------------------- /src/application/core/enums/__init__.py: -------------------------------------------------------------------------------- 1 | from .response_code import ResponseCode 2 | from .service_status import ServiceStatus 3 | 4 | __all__ = [ 5 | "ResponseCode", 6 | "", 7 | ] 8 | -------------------------------------------------------------------------------- /src/application/core/enums/repository.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class SynchronizeSessionEnum(Enum): 5 | FETCH = "fetch" 6 | EVALUATE = "evaluate" 7 | FALSE = False 8 | -------------------------------------------------------------------------------- /src/application/core/enums/response_code.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class ResponseCode(IntEnum): 5 | """ 6 | ## sample 7 | ResponseCode.OK ==> 2000 8 | ResponseCode.OK.message ==> "Request Success" 9 | ResponseCode.OK.description ==> "" 10 | 11 | Response code 12 | status codes and reason phrases 13 | 14 | response format 15 | { 16 | "code" : 2000, 17 | "message" : "Request Success", 18 | "data": {...} 19 | } 20 | """ 21 | 22 | message: str 23 | description: str 24 | 25 | def __new__(cls, value, message, description=""): 26 | obj = int.__new__(cls, value) 27 | obj._value_ = value 28 | obj.message = message # type: ignore 29 | obj.description = description # type: ignore 30 | return obj 31 | 32 | # informational 33 | OK = (200, "Request succeed") 34 | CREATED = (201, "Created", "Document created, URL follows") 35 | ACCEPTED = (202, "Accepted") 36 | 37 | # auth related 38 | 39 | AUTH_SERVER_NOT_RESPONDING = (2000, "Auth server not responding") 40 | NO_AUTHENTICATION = (2001, "No Authentication") 41 | INVALID_ACCESS_TOKEN = (2002, "Invalid access token") 42 | TOKEN_ISSUE_FAIL = (2003, "Token issue fail") 43 | 44 | NO_AUTHORITY = (2101, "No Authority") 45 | 46 | # request related 47 | INVALID_REQUEST_PARAM = (3000, "Invalid request param") 48 | NOT_NULLABLE_REQUEST_PARAM = (3001, "Not nullable param get null value") 49 | RUNTIME_ERROR = (3002, "Runtime error") 50 | # http related 51 | BASIC_HTTP_ERROR = (3003, "Check HTTP status code") 52 | UNDEFINED_ERROR = (3004, "Undefined error") 53 | 54 | # AUTH Server Response Code 55 | INVALID_PARAM = (4001, "Invalid param", "No 'token' key") 56 | WRONG_TOKEN_TYPE = (4002, "Wrong token type") 57 | TOKEN_INVALID = (4003, "Wrong token") 58 | TOKEN_NOT_EXIST = (4004, "Token is not exist") 59 | TOKEN_EXPIRED = (4005, "Token is expired") 60 | TOKEN_REVOKED = (4006, "Token is revoked") 61 | 62 | EXTERNAL_SERVICE_CLIENT_ERROR = ( 63 | 5001, 64 | "External service request wrongly", 65 | ) 66 | EXTERNAL_SERVICE_SERVER_ERROR = (5002, "External service error") 67 | -------------------------------------------------------------------------------- /src/application/core/enums/service_status.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class ServiceStatus(IntEnum): 5 | ENABLED = 0 # ON 6 | DISABLED = 1 # OFF 7 | DELETED = 2 # DELETED 8 | -------------------------------------------------------------------------------- /src/application/core/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | AuthException, 3 | CustomException, 4 | ExternalServiceException, 5 | TokenException, 6 | ) 7 | from .http import ( 8 | BadRequestException, 9 | DuplicateValueException, 10 | ForbiddenException, 11 | NotFoundException, 12 | UnauthorizedException, 13 | UnprocessableEntity, 14 | ) 15 | 16 | __all__ = [ 17 | "CustomException", 18 | "BadRequestException", 19 | "NotFoundException", 20 | "ForbiddenException", 21 | "UnprocessableEntity", 22 | "DuplicateValueException", 23 | "UnauthorizedException", 24 | "ExternalServiceException", 25 | "TokenException", 26 | "AuthException", 27 | ] 28 | -------------------------------------------------------------------------------- /src/application/core/exceptions/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from http import HTTPStatus 3 | 4 | from application.core.enums import ResponseCode 5 | 6 | 7 | class CustomException(ABC, Exception): 8 | """AbstractException""" 9 | 10 | http_code: int 11 | error_code: int 12 | message: str 13 | 14 | def __init__(self, message=None): 15 | if message: 16 | self.message = message 17 | 18 | 19 | # Root classification 20 | class ExternalServiceException(CustomException): 21 | http_code: int = HTTPStatus.BAD_REQUEST 22 | error_code: int = ResponseCode.EXTERNAL_SERVICE_CLIENT_ERROR 23 | message: str = ResponseCode.EXTERNAL_SERVICE_SERVER_ERROR.message 24 | 25 | 26 | class AuthException(CustomException): 27 | http_code: int = HTTPStatus.UNAUTHORIZED 28 | error_code: int = ResponseCode.NO_AUTHORITY 29 | message: str = ResponseCode.NO_AUTHORITY.message 30 | 31 | 32 | class TokenException(CustomException): 33 | http_code: int = HTTPStatus.BAD_REQUEST 34 | error_code: int = ResponseCode.TOKEN_INVALID 35 | message: str = ResponseCode.TOKEN_INVALID.message 36 | 37 | 38 | class HttpException(CustomException): 39 | http_code: int = HTTPStatus.BAD_REQUEST 40 | error_code: int = ResponseCode.BASIC_HTTP_ERROR 41 | message: str = ResponseCode.BASIC_HTTP_ERROR.message 42 | -------------------------------------------------------------------------------- /src/application/core/exceptions/external_service.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from application.core.enums import ResponseCode 4 | from application.core.exceptions import ExternalServiceException 5 | 6 | 7 | class ExternalServiceClientException(ExternalServiceException): 8 | http_code = HTTPStatus.BAD_REQUEST 9 | error_code = ResponseCode.EXTERNAL_SERVICE_CLIENT_ERROR 10 | message = ResponseCode.EXTERNAL_SERVICE_CLIENT_ERROR.message 11 | 12 | 13 | class ExternalServiceServerException(ExternalServiceException): 14 | http_code = HTTPStatus.BAD_REQUEST 15 | error_code = ResponseCode.EXTERNAL_SERVICE_SERVER_ERROR 16 | message = ResponseCode.EXTERNAL_SERVICE_SERVER_ERROR.message 17 | -------------------------------------------------------------------------------- /src/application/core/exceptions/http.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from .base import HttpException 4 | 5 | 6 | class BadRequestException(HttpException): 7 | http_code = HTTPStatus.BAD_REQUEST 8 | 9 | 10 | class NotFoundException(HttpException): 11 | http_code = HTTPStatus.NOT_FOUND 12 | 13 | 14 | class ForbiddenException(HttpException): 15 | http_code = HTTPStatus.FORBIDDEN 16 | 17 | 18 | class UnauthorizedException(HttpException): 19 | http_code = HTTPStatus.UNAUTHORIZED 20 | 21 | 22 | class UnprocessableEntity(HttpException): 23 | http_code = HTTPStatus.UNPROCESSABLE_ENTITY 24 | 25 | 26 | class DuplicateValueException(HttpException): 27 | http_code = HTTPStatus.UNPROCESSABLE_ENTITY 28 | -------------------------------------------------------------------------------- /src/application/core/exceptions/middleware.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from application.core.enums import ResponseCode 4 | from application.core.exceptions import AuthException 5 | 6 | 7 | class NoAuthenticationException(AuthException): 8 | http_code = HTTPStatus.UNAUTHORIZED 9 | error_code = ResponseCode.NO_AUTHENTICATION 10 | message = ResponseCode.NO_AUTHENTICATION.message 11 | 12 | 13 | class NoAuthorityException(AuthException): 14 | http_code = HTTPStatus.UNAUTHORIZED 15 | error_code = ResponseCode.NO_AUTHORITY 16 | message = ResponseCode.NO_AUTHORITY.message 17 | -------------------------------------------------------------------------------- /src/application/core/exceptions/token.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from application.core.enums import ResponseCode 4 | from application.core.exceptions import TokenException 5 | 6 | 7 | class TokenDecodeException(TokenException): 8 | http_code = HTTPStatus.BAD_REQUEST 9 | error_code = ResponseCode.TOKEN_INVALID 10 | message = ResponseCode.TOKEN_INVALID.message 11 | 12 | 13 | class TokenExpireException(TokenException): 14 | http_code = HTTPStatus.BAD_REQUEST 15 | error_code = ResponseCode.TOKEN_EXPIRED 16 | message = ResponseCode.TOKEN_EXPIRED.message 17 | 18 | 19 | class TokenInvalidException(TokenException): 20 | http_code = HTTPStatus.BAD_REQUEST 21 | error_code = ResponseCode.TOKEN_INVALID 22 | message = ResponseCode.TOKEN_INVALID.message 23 | -------------------------------------------------------------------------------- /src/application/core/external_service/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth_client import AuthClient 2 | 3 | __all__ = [ 4 | "AuthClient", 5 | ] 6 | -------------------------------------------------------------------------------- /src/application/core/external_service/auth_client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from typing import Literal 4 | 5 | from aiohttp import ClientSession 6 | 7 | from application.core.enums import ResponseCode 8 | from application.core.exceptions.token import TokenDecodeException, TokenExpireException 9 | from application.core.external_service.http_client import BaseHttpClient 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def encode_base64(string) -> str: 15 | encoded_bytes = base64.b64encode(string.encode("utf-8")) 16 | encoded_string = encoded_bytes.decode("utf-8") 17 | return encoded_string 18 | 19 | 20 | class AuthClient(BaseHttpClient): 21 | 22 | json_content_type = {"Content-Type": "application/json"} 23 | form_url_content_type = {"Content-Type": "application/x-www-form-urlencoded"} 24 | 25 | def __init__( 26 | self, 27 | auth_base_url: str, 28 | client_id: str, 29 | client_secret: str, 30 | refresh_token_key: str, 31 | session: ClientSession, 32 | ssl: bool = True, 33 | scope: list[str] | None = None, 34 | ) -> None: 35 | self._client_id = client_id 36 | self._client_secret = client_secret 37 | self._refresh_token_key = refresh_token_key 38 | self.session = session 39 | self.ssl = ssl 40 | self._token_issue_url = auth_base_url + "/oauth2/token" 41 | self._token_verify_url = auth_base_url + "/oauth2/verify" 42 | self._token_refresh_url = auth_base_url + "/oauth2/token" 43 | self._token_revoke_url = auth_base_url + "/oauth2/revoke" 44 | if scope is not None: 45 | self.scope = ",".join(scope) 46 | else: 47 | self.scope = "" 48 | 49 | def get_basic_header(self) -> dict: 50 | return { 51 | "Content-Type": "application/x-www-form-urlencoded", 52 | "Authorization": "Basic " 53 | + encode_base64(self._client_id + ":" + self._client_secret), 54 | } 55 | 56 | async def get_server_access_token(self) -> str: 57 | q_params = {"grant_type": "client_credentials", "scope": self.scope} 58 | resp = await self._post( 59 | self._token_issue_url, 60 | q_params=q_params, 61 | headers={**self.get_basic_header(), **self.form_url_content_type}, 62 | ) 63 | resp_json = await resp.json() 64 | return resp_json.get("access_token", None) 65 | 66 | async def get_bearer_header(self) -> dict: 67 | atk = await self.get_server_access_token() 68 | return { 69 | "Authorization": "Bearer " + atk, 70 | "Content-Type": "application/json", 71 | } 72 | 73 | def get_header_cookie(self, key: str, value: str) -> dict: 74 | return {"Cookie": f"{key}={value}"} 75 | 76 | async def is_token_valid( 77 | self, 78 | token: str, 79 | token_type: Literal["access_token", "refresh_token"] = "access_token", 80 | ) -> bool: 81 | resp = await self._post( 82 | url=self._token_verify_url, 83 | dict_data={"token": token, "tokenType": token_type}, 84 | headers={**self.get_basic_header(), **self.json_content_type}, 85 | ) 86 | resp_json = await resp.json() 87 | resp_code = resp_json.get("code") 88 | if resp_code == ResponseCode.OK: 89 | return True 90 | if resp_code == ResponseCode.TOKEN_INVALID: 91 | raise TokenDecodeException() 92 | if resp_code == ResponseCode.TOKEN_EXPIRED: 93 | raise TokenExpireException() 94 | return False 95 | 96 | async def refresh_token(self, refresh_token: str) -> dict: 97 | payload = { 98 | "grant_type": "refresh_token", 99 | "refresh_token_key": self._refresh_token_key, 100 | } 101 | cookie = self.get_header_cookie(self._refresh_token_key, refresh_token) 102 | resp = await self._post( 103 | self._token_refresh_url, 104 | data=payload, 105 | headers={ 106 | **self.get_basic_header(), 107 | **cookie, 108 | **self.form_url_content_type, 109 | }, 110 | ) 111 | resp_json = await resp.json() 112 | return resp_json 113 | 114 | async def revoke_token( 115 | self, token: str, token_type: Literal["access_token", "refresh_token"] 116 | ) -> None: 117 | payload = {"token": token, "token_type_hint": token_type} 118 | resp = await self._post( 119 | self._token_revoke_url, 120 | data=payload, 121 | headers={**self.get_basic_header(), **self.form_url_content_type}, 122 | ) 123 | -------------------------------------------------------------------------------- /src/application/core/external_service/http_client.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from socket import AF_INET 3 | from typing import Optional 4 | 5 | import aiohttp 6 | from aiohttp import ClientResponse, ClientSession 7 | 8 | from application.core.exceptions.external_service import ( 9 | ExternalServiceClientException, 10 | ExternalServiceServerException, 11 | ) 12 | 13 | logger = getLogger(__name__) 14 | 15 | 16 | SIZE_POOL_AIOHTTP = 100 17 | CLIENT_TIME_OUT = 2 18 | 19 | 20 | def json_encoder_extend(obj): 21 | return str(obj) 22 | 23 | 24 | class Aiohttp: 25 | aiohttp_client: Optional[aiohttp.ClientSession] = None 26 | 27 | @classmethod 28 | def get_aiohttp_client( 29 | cls, 30 | client_timeout: int = CLIENT_TIME_OUT, 31 | limit_per_host=SIZE_POOL_AIOHTTP, 32 | ) -> aiohttp.ClientSession: 33 | if cls.aiohttp_client is None: 34 | timeout = aiohttp.ClientTimeout(total=client_timeout) 35 | connector = aiohttp.TCPConnector( 36 | family=AF_INET, limit_per_host=limit_per_host 37 | ) 38 | cls.aiohttp_client = aiohttp.ClientSession( 39 | timeout=timeout, connector=connector 40 | ) 41 | return cls.aiohttp_client 42 | 43 | @classmethod 44 | async def close_aiohttp_client(cls) -> None: 45 | if cls.aiohttp_client: 46 | await cls.aiohttp_client.close() 47 | cls.aiohttp_client = None 48 | 49 | @staticmethod 50 | async def on_startup() -> None: 51 | Aiohttp.get_aiohttp_client() 52 | 53 | @staticmethod 54 | async def on_shutdown() -> None: 55 | await Aiohttp.close_aiohttp_client() 56 | 57 | 58 | class BaseHttpClient: 59 | session: ClientSession 60 | 61 | async def _post( 62 | self, 63 | url: str, 64 | data: dict | None = None, 65 | dict_data: dict | None = None, 66 | q_params: dict | None = None, 67 | headers: dict | None = None, 68 | ssl: bool = True, 69 | ) -> ClientResponse: 70 | resp = await self.session.post( 71 | url, 72 | data=data, 73 | json=dict_data, 74 | params=q_params, 75 | headers=headers, 76 | ssl=ssl, 77 | ) 78 | if resp.status == 200: 79 | return resp 80 | elif 400 <= resp.status < 500: 81 | text = await resp.text() 82 | logger.error(f"ExternalServiceClientException: {text}") 83 | raise ExternalServiceClientException(f"{text}") 84 | elif resp.status >= 500: 85 | text = await resp.text() 86 | logger.error(f"ExternalServiceServerException: {text}") 87 | raise ExternalServiceServerException(f"{text}") 88 | return resp 89 | 90 | async def _get( 91 | self, 92 | url: str, 93 | q_params: dict | None = None, 94 | headers: dict | None = None, 95 | ssl: bool = True, 96 | ) -> ClientResponse: 97 | resp = await self.session.post(url, params=q_params, headers=headers, ssl=ssl) 98 | if resp.status == 200: 99 | return resp 100 | elif 400 <= resp.status < 500: 101 | text = await resp.text() 102 | logger.error(f"ExternalServiceClientException: {text}") 103 | raise ExternalServiceClientException(f"{text}") 104 | elif resp.status >= 500: 105 | text = await resp.text() 106 | logger.error(f"ExternalServiceServerException: {text}") 107 | raise ExternalServiceServerException(f"{text}") 108 | return resp 109 | -------------------------------------------------------------------------------- /src/application/core/fastapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/core/fastapi/__init__.py -------------------------------------------------------------------------------- /src/application/core/fastapi/custom_json_response.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import orjson 4 | from fastapi.responses import JSONResponse 5 | 6 | 7 | def json_encoder_extend(obj): 8 | return str(obj) 9 | 10 | 11 | class CustomORJSONResponse(JSONResponse): 12 | media_type = "application/json" 13 | 14 | def render(self, content: Any) -> bytes: 15 | assert orjson is not None, "orjson must be installed" 16 | return orjson.dumps( 17 | content, default=json_encoder_extend, option=orjson.OPT_INDENT_2 18 | ) 19 | -------------------------------------------------------------------------------- /src/application/core/fastapi/log_route.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from dependency_injector.wiring import Provide, inject 4 | from fastapi import BackgroundTasks, Request, Response 5 | from fastapi.routing import APIRoute 6 | 7 | 8 | class LogRoute(APIRoute): 9 | @inject 10 | def get_route_handler( 11 | self, log_handler=Provide["log_container.db_log_handler"] 12 | ) -> Callable: 13 | original_route_handler = super().get_route_handler() 14 | 15 | async def custom_route_handler(request: Request) -> Response: 16 | 17 | response: Response = await original_route_handler(request) 18 | 19 | log_data = { 20 | "user_id": request.user.user_id, 21 | "ip": request.client.host if request.client else None, 22 | "port": request.client.port if request.client else None, 23 | "method": request.method, 24 | "path": request.url.path, 25 | "agent": dict(request.headers.items())["user-agent"], 26 | "response_status": response.status_code, 27 | } 28 | 29 | pre_background = response.background 30 | response.background = BackgroundTasks() 31 | if pre_background: 32 | response.background = BackgroundTasks([pre_background]) 33 | response.background.add_task(func=log_handler, data=log_data) 34 | return response 35 | 36 | return custom_route_handler 37 | -------------------------------------------------------------------------------- /src/application/core/fastapi/pydantic_models.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from fastapi import Form 4 | from fastapi.exceptions import RequestValidationError 5 | from pydantic import BaseConfig, BaseModel, ValidationError 6 | 7 | from application.core.utils.camelcase import snake2camel 8 | 9 | 10 | class ResponseBaseModel(BaseModel): 11 | class Config(BaseConfig): 12 | alias_generator = snake2camel 13 | allow_population_by_field_name = True 14 | orm_mode = True 15 | 16 | 17 | class BodyBaseModel(BaseModel): 18 | class Config(BaseConfig): 19 | alias_generator = snake2camel 20 | 21 | 22 | class FormBaseModel(BaseModel): 23 | def __init_subclass__(cls, *args, **kwargs): 24 | field_default = Form(...) 25 | new_params = [] 26 | for field in cls.__fields__.values(): 27 | default = Form(field.default) if not field.required else field_default 28 | annotation = inspect.Parameter.empty 29 | 30 | new_params.append( 31 | inspect.Parameter( 32 | field.alias, 33 | inspect.Parameter.POSITIONAL_ONLY, 34 | default=default, 35 | annotation=annotation, 36 | ) 37 | ) 38 | 39 | async def _as_form(**data): 40 | try: 41 | return cls(**data) 42 | except ValidationError as e: 43 | raise RequestValidationError(e.raw_errors) 44 | 45 | sig = inspect.signature(_as_form) 46 | sig = sig.replace(parameters=new_params) 47 | _as_form.__signature__ = sig # type: ignore 48 | setattr(cls, "as_form", _as_form) 49 | 50 | @staticmethod 51 | def as_form(parameters: list) -> "FormBaseModel": 52 | raise NotImplementedError 53 | 54 | class Config(BaseConfig): 55 | alias_generator = snake2camel 56 | -------------------------------------------------------------------------------- /src/application/core/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/core/helpers/__init__.py -------------------------------------------------------------------------------- /src/application/core/helpers/cache/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache_manager import CacheManager, cached 2 | from .cache_tag import CacheTag 3 | from .custom_key_maker import CustomKeyMaker 4 | from .redis_backend import RedisBackend 5 | 6 | __all__ = ["CacheManager", "RedisBackend", "CustomKeyMaker", "CacheTag", "cached"] 7 | -------------------------------------------------------------------------------- /src/application/core/helpers/cache/base/__init__.py: -------------------------------------------------------------------------------- 1 | from .backend import BaseBackend 2 | from .key_maker import BaseKeyMaker 3 | 4 | __all__ = [ 5 | "BaseKeyMaker", 6 | "BaseBackend", 7 | ] 8 | -------------------------------------------------------------------------------- /src/application/core/helpers/cache/base/backend.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | 5 | class BaseBackend(ABC): 6 | @abstractmethod 7 | async def get(self, key: str) -> Any: 8 | ... 9 | 10 | @abstractmethod 11 | async def set(self, response: Any, key: str, ttl: int = 60) -> None: 12 | ... 13 | 14 | @abstractmethod 15 | async def delete_startswith(self, value: str) -> None: 16 | ... 17 | -------------------------------------------------------------------------------- /src/application/core/helpers/cache/base/key_maker.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Callable 3 | 4 | 5 | class BaseKeyMaker(ABC): 6 | @abstractmethod 7 | async def make(self, function: Callable, prefix: str) -> str: 8 | ... 9 | -------------------------------------------------------------------------------- /src/application/core/helpers/cache/cache_manager.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Optional 3 | 4 | from dependency_injector.wiring import Provide, inject 5 | 6 | from .base import BaseBackend, BaseKeyMaker 7 | from .cache_tag import CacheTag 8 | 9 | 10 | class CacheManager: 11 | def __init__( 12 | self, 13 | backend: BaseBackend, 14 | key_maker: BaseKeyMaker, 15 | ) -> None: 16 | self.backend = backend 17 | self.key_maker = key_maker 18 | 19 | async def remove_by_tag(self, tag: CacheTag) -> None: 20 | if self.backend is not None: 21 | await self.backend.delete_startswith(value=tag.value) 22 | 23 | async def remove_by_prefix(self, prefix: str) -> None: 24 | if self.backend is not None: 25 | await self.backend.delete_startswith(value=prefix) 26 | 27 | 28 | def cached( 29 | tag: CacheTag = CacheTag.DEFAULT, prefix: Optional[str] = None, ttl: int = 60 30 | ): 31 | def _cached(function): 32 | @wraps(function) 33 | @inject 34 | async def __cached( 35 | *args, 36 | cache_manager: CacheManager = Provide["cache_manager"], 37 | **kwargs, 38 | ): 39 | key = await cache_manager.key_maker.make( 40 | function=function, 41 | prefix=prefix if prefix is not None else tag.value, 42 | ) 43 | cached_response = await cache_manager.backend.get(key=key) 44 | if cached_response: 45 | return cached_response 46 | 47 | response = await function(*args, **kwargs) 48 | await cache_manager.backend.set(response=response, key=key, ttl=ttl) 49 | return response 50 | 51 | return __cached 52 | 53 | return _cached 54 | -------------------------------------------------------------------------------- /src/application/core/helpers/cache/cache_tag.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CacheTag(Enum): 5 | DEFAULT = "default" 6 | GET_USER_LIST = "get_user_list" 7 | -------------------------------------------------------------------------------- /src/application/core/helpers/cache/custom_key_maker.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Callable 3 | 4 | from application.core.helpers.cache.base import BaseKeyMaker 5 | 6 | 7 | class CustomKeyMaker(BaseKeyMaker): 8 | async def make(self, function: Callable, prefix: str) -> str: 9 | if (module := inspect.getmodule(function)) is not None: 10 | path = f"{prefix}::{module.__name__}.{function.__name__}" 11 | else: 12 | path = "function.__name__" 13 | args = "" 14 | 15 | for arg in inspect.signature(function).parameters.values(): 16 | args += arg.name 17 | 18 | if args: 19 | return f"{path}.{args}" 20 | 21 | return path 22 | -------------------------------------------------------------------------------- /src/application/core/helpers/cache/redis_backend.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from typing import Any 3 | 4 | import orjson 5 | from dependency_injector.providers import Singleton 6 | from dependency_injector.wiring import Provide 7 | from redis.asyncio.client import Redis 8 | 9 | from application.core.helpers.cache.base import BaseBackend 10 | 11 | 12 | class RedisBackend(BaseBackend): 13 | 14 | redis_provider: Singleton[Redis] = Provide["redis.provider"] 15 | 16 | async def get(self, key: str) -> Any: 17 | redis = self.redis_provider() 18 | result = await redis.get(key) 19 | if not result: 20 | return 21 | 22 | try: 23 | return orjson.loads(result.decode("utf8")) 24 | except UnicodeDecodeError: 25 | return pickle.loads(result) 26 | 27 | async def set(self, response: Any, key: str, ttl: int = 60) -> None: 28 | if isinstance(response, dict): 29 | response = orjson.dumps(response) 30 | elif isinstance(response, object): 31 | response = pickle.dumps(response) 32 | 33 | redis = self.redis_provider() 34 | await redis.set(name=key, value=response, ex=ttl) 35 | 36 | async def delete_startswith(self, value: str) -> None: 37 | redis = self.redis_provider() 38 | async for key in redis.scan_iter(f"{value}::*"): 39 | await redis.delete(key) 40 | -------------------------------------------------------------------------------- /src/application/core/helpers/logging.py: -------------------------------------------------------------------------------- 1 | from logging import config as cfg 2 | 3 | 4 | def init_logger(env: str): 5 | default_logging = { 6 | # this is required 7 | "version": 1, 8 | # Disable other logger, Default True 9 | "disable_existing_loggers": False, 10 | # filter setting 11 | "filters": {}, 12 | # formatter setting 13 | "formatters": { 14 | "basic": { 15 | "format": "%(asctime)s %(levelname)s: %(message)s", 16 | }, 17 | }, 18 | # handler setting 19 | "handlers": { 20 | "console": { 21 | "level": "DEBUG", 22 | "class": "logging.StreamHandler", 23 | "formatter": "basic", 24 | }, 25 | }, 26 | # make logger 27 | "loggers": {}, 28 | # root logger setting 29 | "root": { 30 | "handlers": ["console"], 31 | # depends on env 32 | "level": "DEBUG" if env.lower() != "production" else "INFO", 33 | }, 34 | } 35 | # config set 36 | cfg.dictConfig(default_logging) 37 | -------------------------------------------------------------------------------- /src/application/core/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .authentication_external import ( 2 | AuthenticationMiddleware, 3 | AuthUser, 4 | CustomAuthCredentials, 5 | ExternalAuthBackend, 6 | on_auth_error, 7 | ) 8 | 9 | __all__ = [ 10 | "AuthenticationMiddleware", 11 | "ExternalAuthBackend", 12 | "on_auth_error", 13 | "CustomAuthCredentials", 14 | "AuthUser", 15 | ] 16 | -------------------------------------------------------------------------------- /src/application/core/middlewares/authentication_external.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import logging 4 | from typing import Optional, Tuple 5 | 6 | from dependency_injector.providers import Singleton 7 | from dependency_injector.wiring import Provide 8 | from fastapi import Request 9 | from fastapi.responses import JSONResponse 10 | from starlette.authentication import AuthCredentials, AuthenticationBackend, BaseUser 11 | from starlette.middleware.authentication import ( 12 | AuthenticationMiddleware as BaseAuthenticationMiddleware, 13 | ) 14 | from starlette.requests import HTTPConnection 15 | 16 | from application.core.exceptions import ExternalServiceException, TokenException 17 | from application.core.exceptions.token import TokenDecodeException 18 | from application.core.external_service import AuthClient 19 | 20 | from ..exceptions.middleware import NoAuthenticationException 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def on_auth_error(request: Request, exc: Exception): 26 | """ 27 | Dummy function. 28 | If there are not requiring authenticated request e.g. `/healthcheck` and auth middleware raise Exception when there is no auth info, 29 | no chance to handle request. So if auth middleware raise error, it is set on `request.user.auth_error` 30 | and when permission checks, raise Exception 31 | """ 32 | status_code, error_code, message = 401, None, str(exc) 33 | return JSONResponse( 34 | status_code=status_code, 35 | content={"error_code": error_code, "message": message}, 36 | ) 37 | 38 | 39 | def decode_base64(encoded_string) -> dict: 40 | # Add padding if necessary 41 | padding_length = len(encoded_string) % 4 42 | if padding_length > 0: 43 | encoded_string += "=" * (4 - padding_length) 44 | 45 | # Decode the Base64 string 46 | decoded_bytes = base64.b64decode(encoded_string) 47 | decoded_string = decoded_bytes.decode("utf-8") 48 | return json.loads(decoded_string) 49 | 50 | 51 | class CustomAuthCredentials(AuthCredentials): 52 | def __init__( 53 | self, 54 | scopes: Optional[list[str]] = None, 55 | ): 56 | super().__init__(scopes) 57 | 58 | 59 | class AuthUser(BaseUser): 60 | def __init__( 61 | self, 62 | user_id: int | None = None, 63 | group_id: int | None = None, 64 | user_authority: str | None = None, 65 | ): 66 | self.user_id = user_id 67 | self.group_id = group_id 68 | self.user_authority = user_authority 69 | self.auth_error: Exception | None = None 70 | 71 | def __repr__(self): 72 | return f"AuthUser(user_id={self.user_id}, group_id={self.group_id})" 73 | 74 | @property 75 | def is_authenticated(self) -> bool: 76 | return self.user_id is not None 77 | 78 | @property 79 | def display_name(self) -> str: 80 | return f"USER_ID: {self.user_id}" 81 | 82 | @property 83 | def identity(self) -> str: 84 | return self.__repr__() 85 | 86 | 87 | class ExternalAuthBackend(AuthenticationBackend): 88 | 89 | auth_client_provider: Singleton[AuthClient] = Provide["auth_client.provider"] 90 | 91 | async def authenticate( 92 | self, conn: HTTPConnection 93 | ) -> Tuple[AuthCredentials, BaseUser] | None: 94 | auth_client = self.auth_client_provider() 95 | current_user = AuthUser() 96 | auth_credentials = CustomAuthCredentials() 97 | authorization: str | None = conn.headers.get("Authorization") 98 | if authorization is None: 99 | current_user.auth_error = NoAuthenticationException() 100 | return auth_credentials, current_user 101 | try: 102 | scheme, access_token = authorization.split(" ") 103 | if ( 104 | (scheme.lower() != "bearer") 105 | or (access_token is None) 106 | or (not await auth_client.is_token_valid(access_token)) 107 | ): 108 | current_user.auth_error = TokenDecodeException() 109 | return auth_credentials, current_user 110 | 111 | creds = access_token.split(".") 112 | user_info = decode_base64(creds[1]) 113 | 114 | except ValueError as e: 115 | logger.info(f"TokenValueException: {e}") 116 | current_user.auth_error = e 117 | return auth_credentials, current_user 118 | except ExternalServiceException as e: 119 | logger.info(f"ExternalServiceException: {e}") 120 | current_user.auth_error = e 121 | return auth_credentials, current_user 122 | except TokenException as e: 123 | logger.info(f"TokenException: {e}") 124 | current_user.auth_error = e 125 | return auth_credentials, current_user 126 | 127 | auth_credentials.scopes = ( 128 | scope if (scope := user_info.get("scope")) is not None else [] 129 | ) 130 | 131 | current_user.user_id = user_info.get("user_id") 132 | current_user.group_id = user_info.get("group_id") 133 | current_user.user_authority = user_info.get("user_authority") 134 | 135 | return auth_credentials, current_user 136 | 137 | 138 | class AuthenticationMiddleware(BaseAuthenticationMiddleware): 139 | pass 140 | -------------------------------------------------------------------------------- /src/application/core/middlewares/authentication_internal.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Tuple 3 | 4 | import jwt 5 | from dependency_injector.wiring import Provide 6 | from starlette.authentication import AuthenticationBackend, BaseUser 7 | from starlette.requests import HTTPConnection 8 | 9 | from application.core.exceptions import TokenException 10 | from application.domain.user.service import UserService 11 | 12 | from ..exceptions.middleware import NoAuthenticationException 13 | from .authentication_external import AuthCredentials, AuthUser 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class InternalAuthBackend(AuthenticationBackend): 19 | async def authenticate( 20 | self, 21 | conn: HTTPConnection, 22 | user_service: UserService = Provide["user_container.user_service"], 23 | ) -> Tuple[AuthCredentials, BaseUser] | None: 24 | 25 | current_user = AuthUser() 26 | auth_credential = AuthCredentials() 27 | authorization: str | None = conn.headers.get("Authorization") 28 | if not authorization: 29 | current_user.auth_error = NoAuthenticationException() 30 | return auth_credential, current_user 31 | 32 | try: 33 | scheme, credentials = authorization.split(" ") 34 | if scheme.lower() != "bearer": 35 | current_user.auth_error = TokenException("NO_BEARER") 36 | return auth_credential, current_user 37 | except ValueError as e: 38 | current_user.auth_error = TokenException(e) 39 | return auth_credential, current_user 40 | 41 | if not credentials: 42 | current_user.auth_error = TokenException("NO_TOKEN_VALUE") 43 | return auth_credential, current_user 44 | 45 | try: 46 | payload = jwt.decode( 47 | jwt=credentials, 48 | key=Provide["config.JWT_SECRET_KEY"], 49 | algorithms=Provide["config.JWT_ALGORITHM"], 50 | ) 51 | user_id = payload.get("user_id") 52 | token_scope = scope if (scope := payload.get("scope")) is not None else [] 53 | except jwt.exceptions.PyJWTError as e: 54 | current_user.auth_error = e 55 | return auth_credential, current_user 56 | 57 | current_user.user_id = user_id 58 | current_user.user_authority = await user_service.get_authority(user_id=user_id) 59 | auth_credential.scopes = token_scope 60 | return auth_credential, current_user 61 | -------------------------------------------------------------------------------- /src/application/core/middlewares/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from dependency_injector.wiring import Provide 4 | from sqlalchemy.ext.asyncio import async_scoped_session 5 | from starlette.types import ASGIApp, Receive, Scope, Send 6 | 7 | from application.core.db.session_maker import reset_session_context, set_session_context 8 | 9 | session: async_scoped_session = Provide["session"] 10 | 11 | 12 | class SQLAlchemyMiddleware: 13 | def __init__(self, app: ASGIApp) -> None: 14 | self.app = app 15 | 16 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 17 | session_id = str(uuid4()) 18 | context = set_session_context(session_id=session_id) 19 | 20 | try: 21 | await self.app(scope, receive, send) 22 | except Exception as e: 23 | raise e 24 | finally: 25 | await session.remove() 26 | reset_session_context(context=context) 27 | -------------------------------------------------------------------------------- /src/application/core/repository/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/core/repository/__init__.py -------------------------------------------------------------------------------- /src/application/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .token_helper import TokenHelper 2 | 3 | __all__ = [ 4 | "TokenHelper", 5 | ] 6 | -------------------------------------------------------------------------------- /src/application/core/utils/camelcase.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import reduce 3 | 4 | 5 | def snake2pascal(snake: str) -> str: 6 | """ 7 | Converts a snake_case string to PascalCase. 8 | """ 9 | camel = snake.title() 10 | camel = re.sub("([0-9A-Za-z])_(?=[0-9A-Z])", lambda m: m.group(1), camel) 11 | return camel 12 | 13 | 14 | def snake2camel(snake: str) -> str: 15 | """ 16 | Converts a snake_case string to camelCase. 17 | """ 18 | pascal = snake.title() 19 | pascal = re.sub("([0-9A-Za-z])_(?=[0-9A-Z])", lambda m: m.group(1), pascal) 20 | pascal = re.sub("(^_*[A-Z])", lambda m: m.group(1).lower(), pascal) 21 | return pascal 22 | 23 | 24 | def camel2snake(camel: str) -> str: 25 | """ 26 | Converts a camelCase string to snake_case. 27 | """ 28 | snake = re.sub(r"([a-zA-Z])([0-9])", lambda m: f"{m.group(1)}_{m.group(2)}", camel) 29 | snake = re.sub(r"([a-z0-9])([A-Z])", lambda m: f"{m.group(1)}_{m.group(2)}", snake) 30 | return snake.lower() 31 | 32 | 33 | def change_case(string, type_="to_camel"): 34 | if type_ == "to_camel": 35 | temp = string.split("_") 36 | res = temp[0] + "".join(ele.title() for ele in temp[1:]) 37 | elif type_ == "to_snake": 38 | res = reduce(lambda x, y: x + ("_" if y.isupper() else "") + y, string).lower() 39 | return res 40 | -------------------------------------------------------------------------------- /src/application/core/utils/token_helper.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import jwt 4 | from dependency_injector.wiring import Provide, inject 5 | 6 | from application.core.exceptions.token import TokenDecodeException, TokenExpireException 7 | 8 | 9 | class TokenHelper: 10 | @staticmethod 11 | @inject 12 | def encode( 13 | payload: dict, 14 | expire_period: int, 15 | jwt_secret_key: str = Provide["config.JWT_SECRET_KEY"], 16 | jwt_algorithm: str = Provide["config.JWT_ALGORITHM"], 17 | ) -> str: 18 | token = jwt.encode( # type: ignore[attr-defined] 19 | payload={ 20 | **payload, 21 | "exp": datetime.utcnow() + timedelta(seconds=expire_period), 22 | }, 23 | key=jwt_secret_key, 24 | algorithm=jwt_algorithm, 25 | ) 26 | return token 27 | 28 | @staticmethod 29 | @inject 30 | def decode( 31 | token: str, 32 | jwt_secret_key=Provide["config.JWT_SECRET_KEY"], 33 | jwt_algorithm=Provide["config.JWT_ALGORITHM"], 34 | ) -> dict: 35 | try: 36 | return jwt.decode( 37 | token, 38 | jwt_secret_key, 39 | jwt_algorithm, 40 | ) 41 | except jwt.exceptions.DecodeError: 42 | raise TokenDecodeException 43 | except jwt.exceptions.ExpiredSignatureError: 44 | raise TokenExpireException 45 | 46 | @staticmethod 47 | @inject 48 | def decode_expired_token( 49 | token: str, 50 | jwt_secret_key=Provide["config.JWT_SECRET_KEY"], 51 | jwt_algorithm=Provide["config.JWT_ALGORITHM"], 52 | ) -> dict: 53 | try: 54 | return jwt.decode( 55 | token, 56 | jwt_secret_key, 57 | jwt_algorithm, 58 | options={"verify_exp": False}, 59 | ) 60 | except jwt.exceptions.DecodeError: 61 | raise TokenDecodeException 62 | -------------------------------------------------------------------------------- /src/application/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/domain/__init__.py -------------------------------------------------------------------------------- /src/application/domain/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/domain/auth/__init__.py -------------------------------------------------------------------------------- /src/application/domain/auth/container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from .models import Token 4 | from .repository import TokenAlchemyRepository 5 | from .service import TokenService 6 | 7 | 8 | class AuthContainer(containers.DeclarativeContainer): 9 | token_repository = providers.Factory(TokenAlchemyRepository, model=Token) 10 | token_service = providers.Factory(TokenService, repository=token_repository) 11 | -------------------------------------------------------------------------------- /src/application/domain/auth/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Literal, Union 3 | 4 | from dependency_injector.wiring import Provide 5 | from pydantic import Field 6 | from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, String 7 | 8 | from application.core.db import Base 9 | from application.core.fastapi.pydantic_models import BodyBaseModel, ResponseBaseModel 10 | 11 | 12 | class Token(Base): 13 | __tablename__ = "tokens" 14 | 15 | id = Column(BigInteger, primary_key=True, autoincrement=True) 16 | user_id = Column(Integer, nullable=False) 17 | refresh_token = Column(String, nullable=False, index=True) 18 | refresh_expires_at = Column( 19 | DateTime(timezone=True), nullable=False, default=datetime.utcnow() 20 | ) 21 | is_valid = Column(Boolean, nullable=False, default=False) 22 | 23 | def __init__( 24 | self, 25 | user_id, 26 | refresh_token, 27 | refresh_token_expires_in: int = Provide["config.REFRESH_TOKEN_EXPIRE_SECONDS"], 28 | ): 29 | self.user_id = user_id 30 | self.refresh_token = refresh_token 31 | self.refresh_expires_at = datetime.utcnow() + timedelta( 32 | seconds=refresh_token_expires_in 33 | ) 34 | self.is_valid = True 35 | 36 | 37 | class RefreshTokenRequest(BodyBaseModel): 38 | refresh_token: str = Field(..., description="Refresh token") 39 | 40 | 41 | class RevokeTokenRequest(BodyBaseModel): 42 | token: str = Field(..., description="Token") 43 | token_type: Literal["access_token", "refresh_token"] = Field( 44 | ..., description="Token" 45 | ) 46 | 47 | 48 | class RefreshTokenResponse(ResponseBaseModel): 49 | token: str = Field(...) 50 | refresh_token: str 51 | 52 | 53 | class AccessTokenResponse(ResponseBaseModel): 54 | access_token: str = Field(...) 55 | 56 | 57 | class RevokeTokenResponse(ResponseBaseModel): 58 | code: str 59 | message: str 60 | data: Union[dict | list | None] 61 | -------------------------------------------------------------------------------- /src/application/domain/auth/repository.py: -------------------------------------------------------------------------------- 1 | from dependency_injector.wiring import Provide 2 | from sqlalchemy import and_, select, update 3 | from sqlalchemy.ext.asyncio import async_scoped_session 4 | 5 | from application.core.base_class.repository import BaseAlchemyRepository 6 | from application.core.db import standalone_session 7 | 8 | from .models import Token 9 | 10 | session: async_scoped_session = Provide["session"] 11 | 12 | from typing import Type 13 | 14 | 15 | class TokenAlchemyRepository(BaseAlchemyRepository[Token]): 16 | model: Type[Token] 17 | 18 | def __init__(self, model): 19 | super().__init__(model) 20 | 21 | @standalone_session 22 | async def get_token_instance(self, token: str) -> Token | None: 23 | query = select(self.model).filter(self.model.refresh_token == token) 24 | result = await session.execute(query) 25 | return result.scalars().first() 26 | 27 | @standalone_session 28 | async def make_all_token_invalid(self, user_id): 29 | stmt = ( 30 | update(self.model) 31 | .where(and_(self.model.user_id == user_id, self.model.is_valid == True)) 32 | .values(is_valid=False) 33 | ) 34 | await session.execute(stmt) 35 | -------------------------------------------------------------------------------- /src/application/domain/auth/service.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from dependency_injector.wiring import Provide, inject 4 | 5 | from application.core.base_class.service import BaseService 6 | from application.core.exceptions.token import TokenDecodeException, TokenExpireException 7 | from application.core.utils.token_helper import TokenHelper 8 | 9 | from .models import Token 10 | from .repository import TokenAlchemyRepository 11 | 12 | 13 | class TokenService(BaseService): 14 | """ 15 | Token Related Service 16 | """ 17 | 18 | repository: TokenAlchemyRepository 19 | 20 | def __init__(self, repository): 21 | super().__init__(repository) 22 | 23 | @inject 24 | async def issue_token( 25 | self, 26 | user_id: int, 27 | access_token_exp_in: int = Provide["config.ACCESS_TOKEN_EXPIRE_SECONDS"], 28 | refresh_token_exp_in: int = Provide["config.REFRESH_TOKEN_EXPIRE_SECONDS"], 29 | ) -> tuple: 30 | access_token = TokenHelper.encode( 31 | payload={"user_id": user_id}, expire_period=access_token_exp_in 32 | ) 33 | refresh_token = TokenHelper.encode( 34 | payload={"sub": "refresh"}, expire_period=refresh_token_exp_in 35 | ) 36 | token_model = Token(user_id=user_id, refresh_token=refresh_token) 37 | self.repository.save(token_model) 38 | return access_token, refresh_token 39 | 40 | @inject 41 | async def refresh_access_token( 42 | self, 43 | refresh_token: str, 44 | refresh_expire_in: int = Provide["config.REFRESH_TOKEN_EXPIRE_SECONDS"], 45 | ): 46 | decoded_refresh_token = TokenHelper.decode(token=refresh_token) 47 | if decoded_refresh_token.get("sub") != "refresh": 48 | raise TokenDecodeException 49 | 50 | token_instance = await self.repository.get_token_instance(refresh_token) 51 | if ( 52 | token_instance is not None 53 | and (exp := token_instance.refresh_expires_at) is not None 54 | and exp > datetime.utcnow() 55 | ): 56 | new_access_token = TokenHelper.encode( 57 | payload={"user_id": token_instance.user_id}, 58 | expire_period=refresh_expire_in, 59 | ) 60 | return new_access_token 61 | raise TokenExpireException 62 | 63 | async def revoke_refresh_token(self, user_id: int) -> None: 64 | await self.repository.make_all_token_invalid(user_id) 65 | -------------------------------------------------------------------------------- /src/application/domain/auth/views.py: -------------------------------------------------------------------------------- 1 | from dependency_injector.wiring import Provide, inject 2 | from fastapi import APIRouter, Depends 3 | 4 | from .models import AccessTokenResponse, RefreshTokenRequest 5 | from .service import TokenService 6 | 7 | auth_router = APIRouter() 8 | 9 | 10 | @auth_router.post( 11 | "/refresh", 12 | response_model=AccessTokenResponse, 13 | ) 14 | @inject 15 | async def refresh_token( 16 | request: RefreshTokenRequest, 17 | token_service: TokenService = Depends(Provide["token_container.token_service"]), 18 | ): 19 | new_access_token = await token_service.refresh_access_token( 20 | refresh_token=request.refresh_token 21 | ) 22 | return {"access_token": new_access_token} 23 | -------------------------------------------------------------------------------- /src/application/domain/domain_structure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/domain/domain_structure/__init__.py -------------------------------------------------------------------------------- /src/application/domain/domain_structure/container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from .models import YourModel 4 | from .repository import YourAlchemyRepository 5 | from .service import YourService 6 | 7 | 8 | # register below to core.domain.config.AppContainer 9 | class YourDomainContainer(containers.DeclarativeContainer): 10 | 11 | your_repository = providers.Factory(YourAlchemyRepository, YourModel) 12 | your_service = providers.Factory(YourService, your_repository) 13 | -------------------------------------------------------------------------------- /src/application/domain/domain_structure/enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class YourEnum(enum.StrEnum): 5 | 6 | YOUR_VALUE = "your_value" 7 | YOUR_OTHER_VALUE = "your_other_value" 8 | -------------------------------------------------------------------------------- /src/application/domain/domain_structure/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, Column 2 | 3 | from application.core.db import Base 4 | from application.core.fastapi.pydantic_models import ResponseBaseModel 5 | 6 | 7 | class YourModel(Base): 8 | __tablename__ = "your_table" 9 | id = Column(BigInteger, primary_key=True, autoincrement=True) 10 | 11 | 12 | class YourResponseModel(ResponseBaseModel): 13 | ... 14 | -------------------------------------------------------------------------------- /src/application/domain/domain_structure/repository.py: -------------------------------------------------------------------------------- 1 | from dependency_injector.wiring import Provide 2 | from sqlalchemy.ext.asyncio import async_scoped_session 3 | 4 | from application.core.base_class.repository import BaseAlchemyRepository 5 | 6 | from .models import YourModel 7 | 8 | session: async_scoped_session = Provide["session"] 9 | 10 | 11 | class YourAlchemyRepository(BaseAlchemyRepository): 12 | model: type[YourModel] 13 | 14 | def __init__(self, model): 15 | super().__init__(model) 16 | -------------------------------------------------------------------------------- /src/application/domain/domain_structure/service.py: -------------------------------------------------------------------------------- 1 | from application.core.base_class.service import BaseService 2 | 3 | from .repository import YourAlchemyRepository 4 | 5 | 6 | class YourService(BaseService): 7 | repository: YourAlchemyRepository 8 | 9 | def __init__(self, repository): 10 | super().__init__(repository) 11 | -------------------------------------------------------------------------------- /src/application/domain/domain_structure/views.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from dependency_injector.wiring import Provide, inject 4 | from fastapi import APIRouter, Depends, Request 5 | 6 | from application.core.dependencies.permission import PermissionDependency 7 | from application.core.enums import ResponseCode 8 | from application.core.fastapi.custom_json_response import CustomORJSONResponse 9 | from application.core.fastapi.log_route import LogRoute 10 | 11 | from .models import YourResponseModel 12 | from .service import YourService 13 | 14 | # register below to domain.api.py 15 | your_router = APIRouter(route_class=LogRoute) 16 | 17 | 18 | @your_router.get( 19 | "/your_path", 20 | dependencies=[Depends(PermissionDependency([]))], 21 | response_model=YourResponseModel, 22 | ) 23 | @inject 24 | async def your_endpoint( 25 | request: Request, 26 | your_service: YourService = Depends(Provide["your_domain_container.your_service"]), 27 | ): 28 | """ 29 | your_endpoint 30 | """ 31 | # your code here 32 | return CustomORJSONResponse( 33 | status_code=HTTPStatus.OK, 34 | content={"code": ResponseCode.OK, "message": "success"}, 35 | ) 36 | -------------------------------------------------------------------------------- /src/application/domain/home/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/domain/home/__init__.py -------------------------------------------------------------------------------- /src/application/domain/home/views.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Response 2 | 3 | from application.core.authority.permissions import AllowAll 4 | from application.core.dependencies import PermissionDependency 5 | from application.core.fastapi.log_route import LogRoute 6 | 7 | home_router = APIRouter(route_class=LogRoute) 8 | 9 | 10 | @home_router.get("/health", dependencies=[Depends(PermissionDependency([AllowAll]))]) 11 | async def home(): 12 | return Response(status_code=200) 13 | -------------------------------------------------------------------------------- /src/application/domain/log/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/domain/log/__init__.py -------------------------------------------------------------------------------- /src/application/domain/log/container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from .models import RequestResponseLog 4 | from .repository import RequestResponseLogAlchemyRepository 5 | from .service import DatabaseLoghandler 6 | 7 | 8 | class LogContainer(containers.DeclarativeContainer): 9 | token_repository = providers.Factory( 10 | RequestResponseLogAlchemyRepository, model=RequestResponseLog 11 | ) 12 | data_base_log_handler = providers.Factory( 13 | DatabaseLoghandler, repository=token_repository 14 | ) 15 | -------------------------------------------------------------------------------- /src/application/domain/log/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import sqlalchemy as sa 4 | from sqlalchemy import BigInteger, Column 5 | from sqlalchemy_utils import UUIDType 6 | 7 | from application.core.db import Base 8 | from application.core.db.mixins import TimestampMixin 9 | 10 | 11 | class RequestResponseLog(Base, TimestampMixin): 12 | __tablename__ = "request_response_log" 13 | id = Column(BigInteger, primary_key=True, autoincrement=True) 14 | user_id = Column(sa.INTEGER) 15 | ip = Column(sa.VARCHAR, nullable=False) 16 | port = Column(sa.INTEGER, nullable=False) 17 | agent = Column(sa.VARCHAR, nullable=False) 18 | method = Column(sa.VARCHAR(20), nullable=False) 19 | path = Column(sa.VARCHAR(20), nullable=False) 20 | response_status = Column(sa.SMALLINT, nullable=False) 21 | request_id = Column(UUIDType(binary=False), nullable=False, default=uuid.uuid4) 22 | -------------------------------------------------------------------------------- /src/application/domain/log/repository.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from dependency_injector.wiring import Provide 4 | from sqlalchemy.ext.asyncio import async_scoped_session 5 | 6 | from application.core.base_class.repository import BaseAlchemyRepository 7 | 8 | from .models import RequestResponseLog 9 | 10 | session: async_scoped_session = Provide["session"] 11 | 12 | 13 | class RequestResponseLogAlchemyRepository(BaseAlchemyRepository[RequestResponseLog]): 14 | model: Type[RequestResponseLog] 15 | 16 | def __init__(self, model): 17 | super().__init__(model) 18 | -------------------------------------------------------------------------------- /src/application/domain/log/service.py: -------------------------------------------------------------------------------- 1 | from dependency_injector.wiring import Provide 2 | from sqlalchemy.ext.asyncio import async_scoped_session 3 | 4 | from application.core.base_class.service import BaseService 5 | from application.core.db import standalone_session 6 | 7 | from .models import RequestResponseLog 8 | from .repository import RequestResponseLogAlchemyRepository 9 | 10 | session: async_scoped_session = Provide["session"] 11 | 12 | 13 | class BaseLogHandler: 14 | async def __call__(self, *args, **kwargs): 15 | raise NotImplementedError 16 | 17 | 18 | class DatabaseLoghandler(BaseLogHandler, BaseService): 19 | repository: RequestResponseLogAlchemyRepository 20 | 21 | def __init__(self, repository): 22 | super().__init__() 23 | self.repository = repository 24 | 25 | @standalone_session 26 | async def __call__(self, data: dict): 27 | request_response_log = RequestResponseLog(**data) 28 | self.repository.save(request_response_log) 29 | -------------------------------------------------------------------------------- /src/application/domain/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/src/application/domain/user/__init__.py -------------------------------------------------------------------------------- /src/application/domain/user/container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from .models import User 4 | from .repository import UserAlchemyRepository 5 | from .service import UserService 6 | 7 | 8 | class UserContainer(containers.DeclarativeContainer): 9 | user_repository = providers.Factory(UserAlchemyRepository, model=User) 10 | user_service = providers.Factory(UserService, repository=user_repository) 11 | -------------------------------------------------------------------------------- /src/application/domain/user/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class UserEnum(Enum): 5 | pass 6 | -------------------------------------------------------------------------------- /src/application/domain/user/exceptions.py: -------------------------------------------------------------------------------- 1 | from application.core.exceptions import CustomException 2 | 3 | 4 | class UserDomainException(CustomException): 5 | pass 6 | 7 | 8 | class PasswordDoesNotMatchException(UserDomainException): 9 | http_code = 401 10 | error_code = 401 11 | message = "password does not match" 12 | 13 | 14 | class DuplicateEmailOrNicknameException(UserDomainException): 15 | http_code = 400 16 | error_code = 400 17 | message = "duplicate email or nickname" 18 | 19 | 20 | class UserNotFoundException(UserDomainException): 21 | http_code = 404 22 | error_code = 404 23 | message = "user not found" 24 | 25 | 26 | class NoEmailOrWrongPassword(UserDomainException): 27 | http_code = 400 28 | error_code = 400 29 | message = "no email or wrong password" 30 | -------------------------------------------------------------------------------- /src/application/domain/user/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | from sqlalchemy import BigInteger, Boolean, Column, Enum, Unicode 3 | 4 | from application.core.authority.permissions import Authority 5 | from application.core.db import Base 6 | from application.core.db.mixins import TimestampMixin 7 | from application.core.fastapi.pydantic_models import ResponseBaseModel 8 | 9 | 10 | class User(Base, TimestampMixin): 11 | __tablename__ = "users" 12 | 13 | id = Column(BigInteger, primary_key=True, autoincrement=True) 14 | password = Column(Unicode(255), nullable=False) 15 | email = Column(Unicode(255), nullable=False, unique=True) 16 | nickname = Column(Unicode(255), nullable=False, unique=True) 17 | authority = Column(Enum(Authority), nullable=False, default=Authority.USER) 18 | is_admin = Column(Boolean, default=False) 19 | 20 | 21 | class LoginResponse(BaseModel): 22 | access_token: str = Field(..., description="Access Token") 23 | refresh_token: str = Field(..., description="Refresh token") 24 | 25 | 26 | class LoginRequest(BaseModel): 27 | email: str = Field(..., description="Email") 28 | password: str = Field(..., description="Password") 29 | 30 | 31 | class GetUserListResponseSchema(BaseModel): 32 | id: int = Field(..., description="ID") 33 | email: str = Field(..., description="Email") 34 | nickname: str = Field(..., description="Nickname") 35 | 36 | class Config: 37 | orm_mode = True 38 | 39 | 40 | class CreateUserRequestSchema(BaseModel): 41 | email: str = Field(..., description="Email") 42 | password1: str = Field(..., description="Password1") 43 | password2: str = Field(..., description="Password2") 44 | nickname: str = Field(..., description="Nickname") 45 | 46 | 47 | class CreateUserResponseSchema(BaseModel): 48 | email: str = Field(..., description="Email") 49 | nickname: str = Field(..., description="Nickname") 50 | 51 | class Config: 52 | orm_mode = True 53 | 54 | 55 | class LoginResponseSchema(BaseModel): 56 | access_token: str = Field(..., description="Access Token") 57 | refresh_token: str = Field(..., description="Refresh token") 58 | 59 | 60 | class ErrorResponse(ResponseBaseModel): 61 | code: str 62 | message: str 63 | data: dict | list | None 64 | -------------------------------------------------------------------------------- /src/application/domain/user/repository.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | from dependency_injector.wiring import Provide 4 | from sqlalchemy import or_, select 5 | from sqlalchemy.ext.asyncio import async_scoped_session 6 | 7 | from application.core.base_class.repository import BaseAlchemyRepository 8 | from application.core.db import standalone_session 9 | 10 | from .models import User 11 | 12 | session: async_scoped_session = Provide["session"] 13 | 14 | 15 | class UserAlchemyRepository(BaseAlchemyRepository[User]): 16 | model: Type[User] 17 | 18 | def __init__(self, model): 19 | super().__init__(model) 20 | 21 | @standalone_session 22 | async def get_user_list( 23 | self, limit: int = 12, prev: int | None = None 24 | ) -> List[User]: 25 | query = select(self.model) # type: ignore[arg-type] 26 | 27 | if prev is not None: 28 | if self.model.id is not None: 29 | query = query.where(self.model.id < prev) 30 | 31 | if limit > 12: 32 | limit = 12 33 | 34 | query = query.limit(limit) 35 | result = await session.execute(query) 36 | return result.scalars().all() 37 | 38 | @standalone_session 39 | async def get_user_by_email(self, email: str) -> User | None: 40 | query = select(self.model).filter(self.model.email == email) # type: ignore[arg-type] 41 | result = await session.execute(query) 42 | return result.scalars().first() 43 | 44 | @standalone_session 45 | async def get_user_by_email_or_nickname( 46 | self, email: str, nickname: str 47 | ) -> User | None: 48 | query = select(self.model).where(or_(self.model.email == email, self.model.nickname == nickname)) # type: ignore[arg-type] 49 | result = await session.execute(query) 50 | return result.scalars().first() 51 | 52 | @standalone_session 53 | async def save_user(self, email: str, hashed_password, nickname: str) -> None: 54 | user = self.model(email=email, password=hashed_password, nickname=nickname) 55 | session.add(user) 56 | -------------------------------------------------------------------------------- /src/application/domain/user/service.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import bcrypt 4 | from dependency_injector.wiring import Provide, inject 5 | from sqlalchemy.ext.asyncio import async_scoped_session 6 | 7 | from application.core.base_class.service import BaseService 8 | from application.core.db import Transactional 9 | from application.domain.auth.service import TokenService 10 | 11 | from .exceptions import ( 12 | DuplicateEmailOrNicknameException, 13 | NoEmailOrWrongPassword, 14 | PasswordDoesNotMatchException, 15 | UserNotFoundException, 16 | ) 17 | from .models import LoginResponseSchema, User 18 | from .repository import UserAlchemyRepository 19 | 20 | session: async_scoped_session = Provide["session"] 21 | 22 | 23 | def hash_password(password: str) -> str: 24 | byte_password = password.encode("utf-8") 25 | salt = bcrypt.gensalt() 26 | hashed_password = bcrypt.hashpw(byte_password, salt) 27 | return hashed_password.decode("utf-8") 28 | 29 | 30 | def check_password(hashed_password: str, user_password: str) -> bool: 31 | byte_password = user_password.encode("utf-8") 32 | return bcrypt.checkpw(byte_password, hashed_password.encode("utf-8")) 33 | 34 | 35 | class UserService(BaseService): 36 | repository: UserAlchemyRepository 37 | 38 | def __init__(self, repository): 39 | super().__init__(repository) 40 | 41 | async def get_user_list( 42 | self, 43 | limit: int = 12, 44 | prev: Optional[int] = None, 45 | ) -> List[User]: 46 | return self.repository.get_user_list(limit=limit, prev=prev) 47 | 48 | @Transactional() 49 | async def create_user( 50 | self, email: str, password1: str, password2: str, nickname: str 51 | ) -> None: 52 | if password1 != password2: 53 | raise PasswordDoesNotMatchException 54 | hashed_password = hash_password(password1) 55 | 56 | exist_user = await self.repository.get_user_by_email_or_nickname( 57 | email=email, nickname=nickname 58 | ) 59 | if exist_user: 60 | raise DuplicateEmailOrNicknameException 61 | 62 | await self.repository.save_user( 63 | email=email, hashed_password=hashed_password, nickname=nickname 64 | ) 65 | 66 | async def is_admin(self, user_id: int) -> bool: 67 | user = await self.repository.get_by_id(id=user_id) 68 | if not user: 69 | return False 70 | if user.is_admin is False: 71 | return False 72 | return True 73 | 74 | async def get_authority(self, user_id: int) -> None | str: 75 | user = await self.repository.get_by_id(id=user_id) 76 | if not user: 77 | return None 78 | return user.authority 79 | 80 | @inject 81 | async def login( 82 | self, 83 | email: str, 84 | password: str, 85 | token_service: TokenService = Provide["token_container.token_service"], 86 | ) -> LoginResponseSchema: 87 | user = await self.repository.get_user_by_email(email=email) 88 | if not user: 89 | raise UserNotFoundException 90 | if not check_password(user.password, password): 91 | raise NoEmailOrWrongPassword 92 | 93 | access_token, refresh_token = await token_service.issue_token(user.id) 94 | response = LoginResponseSchema( 95 | access_token=access_token, refresh_token=refresh_token 96 | ) 97 | return response 98 | -------------------------------------------------------------------------------- /src/application/domain/user/views.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from dependency_injector.wiring import Provide, inject 4 | from fastapi import APIRouter, Depends, Query 5 | 6 | from application.core.dependencies import PermissionDependency 7 | from application.core.fastapi.log_route import LogRoute 8 | from application.core.helpers.cache.cache_manager import cached 9 | 10 | from .models import ( 11 | CreateUserRequestSchema, 12 | CreateUserResponseSchema, 13 | ErrorResponse, 14 | GetUserListResponseSchema, 15 | LoginRequest, 16 | LoginResponse, 17 | ) 18 | from .service import UserService 19 | 20 | user_router = APIRouter(route_class=LogRoute) 21 | 22 | 23 | @user_router.get( 24 | "", 25 | response_model=List[GetUserListResponseSchema], 26 | response_model_exclude={"id"}, 27 | responses={"400": {"model": ErrorResponse}}, 28 | dependencies=[Depends(PermissionDependency([]))], 29 | ) 30 | @cached(prefix="get_user_list", ttl=60) 31 | @inject 32 | async def get_user_list( 33 | limit: int = Query(10, description="Limit"), 34 | prev: int = Query(None, description="Prev ID"), 35 | user_service: UserService = Depends(Provide["user_container.user_service"]), 36 | ): 37 | return await user_service.get_user_list(limit=limit, prev=prev) 38 | 39 | 40 | @user_router.post( 41 | "", 42 | response_model=CreateUserResponseSchema, 43 | responses={"400": {"model": ErrorResponse}}, 44 | ) 45 | @inject 46 | async def create_user( 47 | request: CreateUserRequestSchema, 48 | user_service: UserService = Depends(Provide["user_container.user_service"]), 49 | ): 50 | await user_service.create_user(**request.dict()) 51 | return {"email": request.email, "nickname": request.nickname} 52 | 53 | 54 | @user_router.post( 55 | "/login", 56 | response_model=LoginResponse, 57 | responses={"404": {"model": ErrorResponse}}, 58 | ) 59 | @inject 60 | async def login( 61 | request: LoginRequest, 62 | user_service: UserService = Depends(Provide["user_container.user_service"]), 63 | ): 64 | token = await user_service.login(email=request.email, password=request.password) 65 | return {"token": token.access_token, "refresh_token": token.refresh_token} 66 | -------------------------------------------------------------------------------- /src/application/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from http import HTTPStatus 3 | 4 | import nest_asyncio 5 | from dependency_injector.wiring import Provide, inject 6 | from fastapi import FastAPI, Request 7 | 8 | from application.api import router 9 | from application.container import AppContainer 10 | from application.core.enums import ResponseCode 11 | from application.core.exceptions import CustomException 12 | from application.core.external_service.http_client import Aiohttp 13 | from application.core.fastapi.custom_json_response import CustomORJSONResponse 14 | from application.domain.log.service import DatabaseLoghandler 15 | 16 | nest_asyncio.apply() 17 | 18 | 19 | def init_routers(app_: FastAPI) -> None: 20 | app_.include_router(router) 21 | 22 | 23 | @inject 24 | def init_listeners( 25 | app_: FastAPI, 26 | log_handler: DatabaseLoghandler = Provide["log_container.db_log_handler"], 27 | ) -> None: 28 | """ 29 | Order of presence is not affecting handler. 30 | Only specified Exception will be handled. 31 | """ 32 | 33 | @app_.exception_handler(Exception) 34 | async def root_exception_handler(request: Request, exc: CustomException): 35 | """ 36 | Unmanaged exception will be caught here. 37 | """ 38 | try: 39 | # fill your logging 40 | log_data = { 41 | "user_id": request.user.user_id, 42 | "ip": request.client.host if request.client else None, 43 | "port": request.client.port if request.client else None, 44 | "method": request.method, 45 | "path": request.url.path, 46 | "agent": dict(request.headers.items())["user-agent"], 47 | "response_status": HTTPStatus.INTERNAL_SERVER_ERROR, 48 | } 49 | await log_handler(log_data) 50 | return CustomORJSONResponse( 51 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR, 52 | content={"code": ResponseCode.UNDEFINED_ERROR, "message": exc}, 53 | ) 54 | except Exception as e: 55 | raise e 56 | 57 | @app_.exception_handler(CustomException) 58 | async def custom_exception_handler(request: Request, exc: CustomException): 59 | return CustomORJSONResponse( 60 | status_code=exc.http_code, 61 | content={"code": exc.error_code, "message": exc.message}, 62 | ) 63 | 64 | 65 | async def create_app() -> FastAPI: 66 | """ 67 | This func can be run synchronously, 68 | but if you declare some awaitable Resource on Container then switch to asynchronously func. 69 | This func support both of them. 70 | """ 71 | container = AppContainer() 72 | container.logging.init() 73 | middlewares = container.middleware_list() 74 | config = container.config 75 | 76 | app_ = FastAPI( 77 | title=config.TITLE(), 78 | description=config.DESCRIPTION(), 79 | version=config.VERSION(), 80 | docs_url=None if config.ENV() == "production" else "/docs", 81 | redoc_url=None if config.ENV() == "production" else "/redoc", 82 | middleware=middlewares, 83 | on_startup=[], 84 | on_shutdown=[Aiohttp.on_shutdown], 85 | ) 86 | 87 | init_routers(app_=app_) 88 | init_listeners(app_=app_) 89 | 90 | # This setting attribute is for test suit, making config accessible. 91 | # In normal case, use wiring.Provide["container_attr"] 92 | if config.ENV() != "production": 93 | app_.container = container # type: ignore[attr-defined] 94 | return app_ 95 | 96 | 97 | app = asyncio.run(create_app()) 98 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from unittest import mock 3 | 4 | import nest_asyncio 5 | import pytest 6 | import pytest_asyncio 7 | from httpx import AsyncClient 8 | 9 | from application.core.db.session_maker import Base 10 | from application.core.external_service import AuthClient 11 | 12 | nest_asyncio.apply() 13 | 14 | # This setting env var should be written earlier than import domain for load different config(.env.test) for test! 15 | 16 | # create Test DB, 17 | # required 18 | # make test-db 19 | import os 20 | 21 | os.environ["ENV_FILE"] = ".env.test" 22 | 23 | from application.server import app 24 | 25 | engine = app.container.writer_engine() 26 | 27 | 28 | async def async_create_all(): 29 | async with engine.begin() as conn: 30 | # Use run_sync() to run metadata.create_all() asynchronously 31 | await conn.run_sync(Base.metadata.create_all) 32 | 33 | 34 | asyncio.run(async_create_all()) 35 | 36 | 37 | @pytest.fixture(scope="session") 38 | def event_loop(request): 39 | loop = asyncio.get_event_loop_policy().new_event_loop() 40 | yield loop 41 | loop.close() 42 | 43 | 44 | @pytest_asyncio.fixture(scope="session") 45 | async def async_client(): 46 | async with AsyncClient(app=app, base_url="http://test") as client: 47 | yield client 48 | 49 | 50 | @pytest.fixture(scope="session", autouse=True) 51 | async def mock_auth_client(async_client): 52 | mock_refresh_token = { 53 | "access_token": "your-available-jwt-token-value", 54 | "scope": "write,read", 55 | "token_type": "Bearer", 56 | "expires_in": 599, 57 | } 58 | auth_client_mock = mock.Mock(spec=AuthClient) 59 | auth_client_mock.is_token_valid.return_value = True 60 | auth_client_mock.refresh_token.return_value = mock_refresh_token 61 | auth_client_mock.revoke_token.return_value = None 62 | 63 | with app.container.auth_client.override(auth_client_mock): 64 | yield 65 | 66 | 67 | @pytest.fixture(scope="function") 68 | def auth_header(): 69 | return {"Authorization": "your-available-jwt-token-value"} 70 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/tests/core/__init__.py -------------------------------------------------------------------------------- /tests/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/tests/domain/__init__.py -------------------------------------------------------------------------------- /tests/domain/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/tests/domain/auth/__init__.py -------------------------------------------------------------------------------- /tests/domain/auth/test_service.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pytest_asyncio 3 | from application.domain.user.models import User 4 | 5 | from application.core.db import standalone_session 6 | 7 | from application.server import app 8 | 9 | from sqlalchemy import select 10 | 11 | from application.domain.auth.service import TokenService 12 | from application.domain.auth.models import Token 13 | 14 | root_container = app.container 15 | 16 | user_info = { 17 | "email": "this@mail.com", 18 | "password1": "password1", 19 | "password2": "password1", 20 | "nickname": "name_nik", 21 | } 22 | 23 | @pytest_asyncio.fixture(scope="module", autouse=True) 24 | async def setup(): 25 | @standalone_session 26 | async def create_user(): 27 | await root_container.user_container.user_service().create_user(**user_info) 28 | return 29 | 30 | await create_user() 31 | 32 | yield 33 | 34 | @standalone_session 35 | async def delete_user(): 36 | query = select(User).filter(User.email == user_info["email"]) 37 | result = await root_container.session().execute(query) 38 | user = result.scalars().one() 39 | await root_container.session().delete(user) 40 | 41 | await delete_user() 42 | 43 | 44 | token_service: TokenService = root_container.auth_container.token_service() 45 | 46 | @pytest_asyncio.fixture(scope="function") 47 | async def cleanup_token(): 48 | yield 49 | 50 | @standalone_session 51 | async def delete_token(): 52 | query = select(Token) 53 | session = root_container.session() 54 | result = await session.execute(query) 55 | token_list = result.scalars().all() 56 | for token in token_list: 57 | session().delete(token) 58 | 59 | await delete_token() 60 | 61 | 62 | @pytest.mark.usefixtures("cleanup_token") 63 | @pytest.mark.asyncio 64 | @standalone_session 65 | async def test_issue_token(): 66 | user_id = 99999 67 | atk, rtk = await token_service.issue_token( 68 | user_id=user_id 69 | ) 70 | query = select(Token).filter(Token.user_id == user_id).order_by(-Token.id) 71 | result = await root_container.session().execute(query) 72 | token = result.scalars().first() 73 | 74 | assert token.refresh_token == rtk 75 | assert token.user_id == user_id 76 | 77 | 78 | @pytest.mark.usefixtures("cleanup_token") 79 | @pytest.mark.asyncio 80 | async def test_refresh_access_token(): 81 | user_id = 109999 82 | atk, rtk = await token_service.issue_token( 83 | user_id=user_id 84 | ) 85 | refreshed_access_token = await token_service.refresh_access_token( 86 | refresh_token=rtk 87 | ) 88 | assert rtk != refreshed_access_token 89 | 90 | 91 | @pytest.mark.usefixtures("cleanup_token") 92 | @pytest.mark.asyncio 93 | @standalone_session 94 | async def test_revoke_refresh_token(): 95 | user_id = 88888 96 | atk, rtk = await token_service.issue_token( 97 | user_id=user_id 98 | ) 99 | await token_service.revoke_refresh_token(user_id=user_id) 100 | 101 | query = select(Token).filter(Token.user_id == user_id) 102 | result = await root_container.session().execute(query) 103 | token = result.scalars().one() 104 | assert token.is_valid == False 105 | 106 | assert token.refresh_token == rtk 107 | assert token.user_id == user_id 108 | -------------------------------------------------------------------------------- /tests/domain/auth/test_view.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/tests/domain/auth/test_view.py -------------------------------------------------------------------------------- /tests/domain/log/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/tests/domain/log/__init__.py -------------------------------------------------------------------------------- /tests/domain/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/tests/domain/user/__init__.py -------------------------------------------------------------------------------- /tests/domain/user/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/tests/domain/user/services/__init__.py -------------------------------------------------------------------------------- /tests/domain/user/services/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.asyncio 5 | async def test_get_user_list(): 6 | ... 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_create_user(): 11 | ... 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_is_admin(): 16 | ... 17 | -------------------------------------------------------------------------------- /tests/domain/user/test_service.py: -------------------------------------------------------------------------------- 1 | import pytest_asyncio 2 | from application.domain.user.models import User 3 | 4 | from application.core import standalone_session 5 | 6 | from application.server import app 7 | 8 | from sqlalchemy import select 9 | 10 | root_container = app.container 11 | 12 | user_info = { 13 | "email": "this@mail.com", 14 | "password1": "password1", 15 | "password2": "password1", 16 | "nickname": "name_nik", 17 | } 18 | 19 | 20 | @pytest_asyncio.fixture(scope="module", autouse=True) 21 | async def setup(): 22 | @standalone_session 23 | async def create_user(): 24 | await root_container.user_container.user_service().create_user(**user_info) 25 | return 26 | 27 | await create_user() 28 | 29 | yield 30 | 31 | @standalone_session 32 | async def delete_user(): 33 | query = select(User).filter(User.email == user_info["email"]) 34 | result = await root_container.session().execute(query) 35 | user = result.scalars().one() 36 | await root_container.session().delete(user) 37 | 38 | await delete_user() 39 | 40 | 41 | @standalone_session 42 | async def test_(): 43 | q = select(User) 44 | result = await root_container.session().execute(q) 45 | assert result.scalars().first().email == user_info["email"] 46 | -------------------------------------------------------------------------------- /tests/domain/user/test_view.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumbarum/fastapi-boilerplate-on-di/8c7bca62f64b055491c71d3daad116df7c749755/tests/domain/user/test_view.py --------------------------------------------------------------------------------