├── .env ├── .env.test ├── .flake8 ├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── aiosqlite ├── alembic.ini ├── alembic ├── README ├── env.py └── script.py.mako ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── src ├── __init__.py ├── app.py ├── config.py ├── core │ ├── __init__.py │ ├── constants.py │ ├── database │ │ ├── __init__.py │ │ ├── base.py │ │ ├── config.py │ │ └── connection.py │ ├── exceptions.py │ └── utils.py ├── main.py ├── security │ ├── __init__.py │ ├── config.py │ ├── constants.py │ ├── exceptions.py │ ├── models.py │ └── utils.py └── users │ ├── __init__.py │ ├── config.py │ ├── constants.py │ ├── dependencies.py │ ├── exceptions.py │ ├── models.py │ ├── router.py │ ├── schemas.py │ ├── service.py │ └── utils.py └── tests ├── __init__.py ├── config.py ├── conftest.py ├── core ├── __init__.py └── unit │ ├── __init__.py │ └── test_utils.py ├── homepage ├── __init__.py └── e2e │ ├── __init__.py │ └── test_homepage.py ├── security ├── __init__.py └── unit │ ├── __init__.py │ └── test_utils.py ├── users ├── __init__.py ├── e2e │ ├── __init__.py │ ├── test_dislike_user.py │ ├── test_get_all_users.py │ ├── test_get_my_account.py │ ├── test_get_my_statistics.py │ ├── test_like_user.py │ ├── test_login.py │ ├── test_logout.py │ └── test_register.py ├── integration │ ├── __init__.py │ └── test_dependencies.py └── unit │ ├── __init__.py │ ├── test_service.py │ └── test_utils.py └── utils.py /.env: -------------------------------------------------------------------------------- 1 | # Uvicorn environments: 2 | HOST="0.0.0.0" 3 | PORT=8000 4 | LOG_LEVEL="debug" 5 | RELOAD=true 6 | 7 | # CORS environments: 8 | ALLOW_ORIGINS=["*"] 9 | ALLOW_HEADERS=["*"] 10 | ALLOW_CREDENTIALS=true 11 | ALLOW_METHODS=["*"] 12 | 13 | # JWT environments: 14 | JWT_TOKEN_SECRET_KEY="someRandomSecretKey" # openssl rand -hex 32 15 | JWT_TOKEN_ALGORITHM="HS256" 16 | JWT_TOKEN_EXPIRE_DAYS=7 17 | 18 | # Database environments: 19 | DATABASE_DIALECT="sqlite" 20 | DATABASE_DRIVER="aiosqlite" 21 | DATABASE_HOST="" 22 | DATABASE_PORT=5432 23 | DATABASE_USER="" 24 | DATABASE_PASSWORD="" 25 | DATABASE_NAME="database.db" 26 | DATABASE_ECHO=true 27 | DATABASE_POOL_RECYCLE=3600 28 | DATABASE_POOL_PRE_PING=true 29 | DATABASE_AUTO_FLUSH=false 30 | DATABASE_EXPIRE_ON_COMMIT=false 31 | 32 | # Cookies environments: 33 | COOKIES_KEY=Access-Token 34 | COOKIES_LIFESPAN_DAYS=7 35 | SECURE_COOKIES=false 36 | HTTP_ONLY=false 37 | SAME_SITE=lax 38 | 39 | # Passlib environments: 40 | PASSLIB_SCHEME="sha256_crypt" 41 | PASSLIB_DEPRECATED="auto" 42 | 43 | # Links environments: 44 | HTTP_PROTOCOL="http" 45 | DOMAIN="0.0.0.0:8000" 46 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # Uvicorn environments: 2 | HOST="127.0.0.1" 3 | PORT=8000 4 | LOG_LEVEL="debug" 5 | RELOAD=true 6 | 7 | # CORS environments: 8 | ALLOW_ORIGINS=["*"] 9 | ALLOW_HEADERS=["*"] 10 | ALLOW_CREDENTIALS=true 11 | ALLOW_METHODS=["*"] 12 | 13 | # JWT environments: 14 | JWT_TOKEN_SECRET_KEY="someRandomSecretKey" # openssl rand -hex 32 15 | JWT_TOKEN_ALGORITHM="HS256" 16 | JWT_TOKEN_EXPIRE_DAYS=7 17 | 18 | # Database environments: 19 | DATABASE_DIALECT="sqlite" 20 | DATABASE_DRIVER="aiosqlite" 21 | DATABASE_HOST="" 22 | DATABASE_PORT=5432 23 | DATABASE_USER="" 24 | DATABASE_PASSWORD="" 25 | DATABASE_NAME="test_database.db" 26 | DATABASE_ECHO=true 27 | DATABASE_POOL_RECYCLE=3600 28 | DATABASE_POOL_PRE_PING=true 29 | DATABASE_AUTO_FLUSH=false 30 | DATABASE_EXPIRE_ON_COMMIT=false 31 | 32 | # Cookies environments: 33 | COOKIES_KEY=Access-Token 34 | COOKIES_LIFESPAN_DAYS=7 35 | SECURE_COOKIES=false 36 | HTTP_ONLY=false 37 | SAME_SITE=lax 38 | 39 | # Passlib environments: 40 | PASSLIB_SCHEME="sha256_crypt" 41 | PASSLIB_DEPRECATED="auto" 42 | 43 | # Links environments: 44 | HTTP_PROTOCOL="http" 45 | DOMAIN="0.0.0.0:8000" 46 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ################### PROGRAM ################################ 3 | 4 | # Specify the number of subprocesses that Flake8 will use to run checks in parallel. 5 | jobs = auto 6 | 7 | 8 | ################### OUTPUT ################################# 9 | 10 | ########## Verbosity ########## 11 | 12 | # Increase the verbosity of Flake8’s output. 13 | verbose = 0 14 | # Decrease the verbosity of Flake8’s output. 15 | quiet = 0 16 | 17 | 18 | ########## Formatting ########## 19 | 20 | # Select the formatter used to display errors to the user. 21 | format = default 22 | 23 | # Print the total number of errors. 24 | count = True 25 | # Print the source code generating the error/warning in question. 26 | show-source = True 27 | # Count the number of occurrences of each error/warning code and print a report. 28 | statistics = True 29 | 30 | 31 | ########## Targets ########## 32 | 33 | # Redirect all output to the specified file. 34 | output-file = .flake8.log 35 | # Also print output to stdout if output-file has been configured. 36 | tee = True 37 | 38 | 39 | ################### FILE PATTERNS ########################## 40 | 41 | # Provide a comma-separated list of glob patterns to exclude from checks. 42 | exclude = 43 | # git folder 44 | .git, 45 | # python cache 46 | __pycache__, 47 | # pytest cache 48 | .pytest_cache, 49 | # mypy cache 50 | .mypy_cache, 51 | # venv 52 | .venv, venv, 53 | # Docker volumes 54 | database_data, database_backups, 55 | # Alembic 56 | alembic/ 57 | 58 | 59 | # Provide a comma-separate list of glob patterns to include for checks. 60 | filename = 61 | *.py 62 | 63 | 64 | ################### LINTING ################################ 65 | 66 | ########## Environment ########## 67 | 68 | # Provide a custom list of builtin functions, objects, names, etc. 69 | builtins = 70 | 71 | 72 | ########## Options ########## 73 | 74 | # Report all errors, even if it is on the same line as a `# NOQA` comment. 75 | disable-noqa = False 76 | 77 | # Set the maximum length that any line (with some exceptions) may be. 78 | max-line-length = 120 79 | # Set the maximum allowed McCabe complexity value for a block of code. 80 | max-complexity = 14 81 | # Toggle whether pycodestyle should enforce matching the indentation of the opening bracket’s line. 82 | # incluences E131 and E133 83 | hang-closing = True 84 | 85 | 86 | ########## Rules ########## 87 | 88 | # ERROR CODES 89 | # 90 | # E/W - PEP8 errors/warnings (pycodestyle) 91 | # F - linting errors (pyflakes) 92 | # C - McCabe complexity error (mccabe) 93 | # 94 | # E133 - closing bracket is missing indentation (conflicts with black) 95 | # E203 - whitespace before ‘:’ (conflicts with black) 96 | # W503 - line break before binary operator 97 | # F401 - module imported but unused 98 | # F403 - ‘from module import *’ used; unable to detect undefined names 99 | # 100 | 101 | # Specify a list of codes to ignore. 102 | ignore = 103 | E133, 104 | E203, 105 | W503 106 | 107 | # Specify the list of error codes you wish Flake8 to report. 108 | # flake8-quotes package adds flake8 warnings with the prefix Q0. 109 | select = 110 | Q0, 111 | C90, 112 | E, 113 | W, 114 | F, 115 | C 116 | # Specify a list of mappings of files and the codes that should be ignored for the entirety of the 117 | # file. 118 | per-file-ignores = 119 | __init__.py:F401,F403 120 | 121 | # Enable off-by-default extensions. 122 | enable-extensions = 123 | 124 | 125 | ########## Docstring ########## 126 | 127 | # Enable PyFlakes syntax checking of doctests in docstrings. 128 | doctests = True 129 | 130 | # Specify which files are checked by PyFlakes for doctest syntax. 131 | include-in-doctest = 132 | # Specify which files are not to be checked by PyFlakes for doctest syntax. 133 | exclude-in-doctest = 134 | 135 | # Specify which type of quotes to use: 136 | inline-quotes = single 137 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main-workflow 2 | run-name: ${{ github.actor }} is testing changes 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | flake8: 7 | runs-on: ubuntu-latest 8 | name: Flake8 9 | steps: 10 | - name: Check out source repository 11 | uses: actions/checkout@v4 12 | - name: Set up Python environment 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.12' 16 | - name: Install requirements 17 | run: pip install -r requirements.txt 18 | - name: Run flake8 19 | run: flake8 ./ -v 20 | mypy: 21 | runs-on: ubuntu-latest 22 | name: Mypy 23 | steps: 24 | - name: Check out source repository 25 | uses: actions/checkout@v4 26 | - name: Set up Python environment 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: '3.12' 30 | - name: Install requirements 31 | run: pip install -r requirements.txt 32 | - name: Run mypy 33 | run: mypy ./ 34 | pytest: 35 | runs-on: ubuntu-latest 36 | name: PyTest 37 | steps: 38 | - name: Check out source repository 39 | uses: actions/checkout@v4 40 | - name: Set up Python environment 41 | uses: actions/setup-python@v4 42 | with: 43 | python-version: '3.12' 44 | - name: Install requirements 45 | run: pip install -r requirements.txt 46 | - name: Run pytest 47 | run: pytest -v 48 | -------------------------------------------------------------------------------- /.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 | *.db 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .venv 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | # pytype static type analyzer 149 | .pytype/ 150 | 151 | # Cython debug symbols 152 | cython_debug/ 153 | 154 | # PyCharm 155 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 156 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 157 | # and can be added to the global gitignore or merged into this file. For a more nuclear 158 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 159 | .idea/ 160 | 161 | # Project's data: 162 | /database_backups/ 163 | /database_data/ 164 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DDD and FastAPI 2 | 3 | This project is a tutorial on how to implement Domain Driven Design using FastAPI, 4 | and was also created to demonstrate the advantages of this approach. 5 | 6 | All commands should be executed in project's root directory: 7 | 8 | ## Getting started 9 | 10 | To run application via source files, use next commands: 11 | 12 | ```bash 13 | python -m venv venv 14 | 15 | source .venv/bin/activate 16 | 17 | pip install -r requirements.txt 18 | 19 | uvicorn src.app:app --env-file .env --host --port --reload 20 | ``` 21 | 22 | ### Run via IDE: 23 | 24 | Run ```src/main.py``` file, using project's root directory as Working Directory and 25 | provide path to .env.local file as the environments file. 26 | 27 | ## Linters 28 | 29 | ```bash 30 | flake8 ./ -v 31 | ``` 32 | 33 | ## Type Checkers 34 | 35 | ```bash 36 | mypy ./ 37 | ``` 38 | 39 | ## Alembic 40 | 41 | To create new migration use next command: 42 | ```bash 43 | alembic revision -m "" --autogenerate 44 | ``` 45 | 46 | To migrate use next command: 47 | ```bash 48 | alembic upgrade head 49 | ``` 50 | 51 | To downgrade database use next command: 52 | ```bash 53 | alembic downgrade # -1, -2 or base to downgrade to start point 54 | ``` 55 | 56 | ## Tests 57 | 58 | To run tests use next command in project's root directory: 59 | ```bash 60 | pytest -v 61 | ``` 62 | 63 | To check tests coverage use next commands in project's root directory and 64 | open ```htmlcov/index.html``` file in browser: 65 | ```bash 66 | coverage run -m pytest -v 67 | coverage html 68 | ``` 69 | -------------------------------------------------------------------------------- /aiosqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/aiosqlite -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 20 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to ZoneInfo() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | sqlalchemy.url = driver://user:pass@localhost/dbname 64 | 65 | 66 | [post_write_hooks] 67 | # post_write_hooks defines scripts or Python functions that are run 68 | # on newly generated revision scripts. See the documentation for further 69 | # detail and examples 70 | 71 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 72 | # hooks = black 73 | # black.type = console_scripts 74 | # black.entrypoint = black 75 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 76 | 77 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 78 | # hooks = ruff 79 | # ruff.type = exec 80 | # ruff.executable = %(here)s/.venv/bin/ruff 81 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 82 | 83 | # Logging configuration 84 | [loggers] 85 | keys = root,sqlalchemy,alembic 86 | 87 | [handlers] 88 | keys = console 89 | 90 | [formatters] 91 | keys = generic 92 | 93 | [logger_root] 94 | level = WARN 95 | handlers = console 96 | qualname = 97 | 98 | [logger_sqlalchemy] 99 | level = WARN 100 | handlers = 101 | qualname = sqlalchemy.engine 102 | 103 | [logger_alembic] 104 | level = INFO 105 | handlers = 106 | qualname = alembic 107 | 108 | [handler_console] 109 | class = StreamHandler 110 | args = (sys.stderr,) 111 | level = NOTSET 112 | formatter = generic 113 | 114 | [formatter_generic] 115 | format = %(levelname)-5.5s [%(name)s] %(message)s 116 | datefmt = %H:%M:%S 117 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | from src.core.database.base import Base 9 | 10 | import os 11 | from pathlib import Path 12 | from dotenv import dotenv_values 13 | from typing import Dict 14 | 15 | 16 | # this is the Alembic Config object, which provides 17 | # access to the values within the .ini file in use. 18 | config = context.config 19 | 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | if config.config_file_name is not None: 23 | fileConfig(config.config_file_name) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | target_metadata = Base.metadata 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | # Reading environments from file: 35 | DOTENV: Path = Path(f'{os.getcwd()}/.env') 36 | 37 | # Creating database url and changes async driver to sync, if needed: 38 | DATABASE_CONFIG: Dict[str, str] = dotenv_values(DOTENV) 39 | DATABASE_DIALECT: str = DATABASE_CONFIG['DATABASE_DIALECT'] 40 | DATABASE_DRIVER: str = DATABASE_CONFIG['DATABASE_DRIVER'] 41 | if DATABASE_DRIVER.startswith('a'): # asyncpg or other async driver 42 | DATABASE_DRIVER_AND_DIALECT = DATABASE_DIALECT 43 | else: 44 | DATABASE_DRIVER_AND_DIALECT = DATABASE_DIALECT + '+' + DATABASE_DRIVER 45 | 46 | DATABASE_URL: str 47 | if DATABASE_CONFIG['DATABASE_DIALECT'] == 'sqlite': 48 | DATABASE_URL = '{}:///{}'.format( 49 | DATABASE_DRIVER_AND_DIALECT, 50 | DATABASE_CONFIG['DATABASE_DRIVER'], 51 | DATABASE_CONFIG['DATABASE_NAME'], 52 | ) 53 | else: 54 | DATABASE_URL = '{}://{}:{}@{}:{}/{}'.format( 55 | DATABASE_DRIVER_AND_DIALECT, 56 | DATABASE_CONFIG['DATABASE_USER'], 57 | DATABASE_CONFIG['DATABASE_PASSWORD'], 58 | DATABASE_CONFIG['DATABASE_HOST'], 59 | DATABASE_CONFIG['DATABASE_PORT'], 60 | DATABASE_CONFIG['DATABASE_NAME'] 61 | ) 62 | 63 | # Setting database url for alembic correct work: 64 | config.set_main_option("sqlalchemy.url", DATABASE_URL) 65 | config.compare_type = True 66 | config.compare_server_default = True 67 | 68 | 69 | def run_migrations_offline() -> None: 70 | """Run migrations in 'offline' mode. 71 | 72 | This configures the context with just a URL 73 | and not an Engine, though an Engine is acceptable 74 | here as well. By skipping the Engine creation 75 | we don't even need a DBAPI to be available. 76 | 77 | Calls to context.execute() here emit the given string to the 78 | script output. 79 | 80 | """ 81 | url = config.get_main_option("sqlalchemy.url") 82 | context.configure( 83 | url=url, 84 | target_metadata=target_metadata, 85 | literal_binds=True, 86 | dialect_opts={"paramstyle": "named"}, 87 | ) 88 | 89 | with context.begin_transaction(): 90 | context.run_migrations() 91 | 92 | 93 | def run_migrations_online() -> None: 94 | """Run migrations in 'online' mode. 95 | 96 | In this scenario we need to create an Engine 97 | and associate a connection with the context. 98 | 99 | """ 100 | connectable = engine_from_config( 101 | config.get_section(config.config_ini_section, {}), 102 | prefix="sqlalchemy.", 103 | poolclass=pool.NullPool, 104 | ) 105 | 106 | with connectable.connect() as connection: 107 | context.configure( 108 | connection=connection, target_metadata=target_metadata 109 | ) 110 | 111 | with context.begin_transaction(): 112 | context.run_migrations() 113 | 114 | 115 | if context.is_offline_mode(): 116 | run_migrations_offline() 117 | else: 118 | run_migrations_online() 119 | -------------------------------------------------------------------------------- /alembic/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 typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = 'DDD and FastAPI' 3 | 4 | [tool.mypy] 5 | plugins = ['pydantic.mypy'] 6 | disallow_any_generics = false 7 | check_untyped_defs = true 8 | warn_return_any = false 9 | ignore_missing_imports = false 10 | disable_error_code = ['empty-body', 'method-assign'] 11 | exclude = ['alembic/'] 12 | 13 | # Avoiding "skipping analyzing 'celery' error": 14 | [[tool.mypy.overrides]] 15 | module = "celery.*" 16 | ignore_missing_imports = true 17 | 18 | # Avoiding incorrect 'override' error: 19 | [[tool.mypy.overrides]] 20 | module = [ 21 | "src.users.interfaces.repositories", 22 | "src.users.adapters.repositories", 23 | "src.users.service_layer.handlers.*", 24 | "tests.users.fake_objects", 25 | ] 26 | disable_error_code = ["override"] 27 | 28 | # Avoiding incorrect 'misc' error during dependency injection to event-handlers and command-handlers: 29 | [[tool.mypy.overrides]] 30 | module = "src.core.bootstrap" 31 | disable_error_code = ["misc"] 32 | 33 | [tool.pytest.ini_options] 34 | testpaths = [ 35 | 'tests', 36 | ] 37 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | env_files = 3 | .env.test 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiosqlite==0.20.0 2 | alembic==1.13.2 3 | annotated-types==0.7.0 4 | anyio==4.4.0 5 | certifi==2024.7.4 6 | click==8.1.7 7 | coverage==7.5.4 8 | dnspython==2.6.1 9 | ecdsa==0.19.0 10 | email_validator==2.2.0 11 | fastapi==0.111.0 12 | fastapi-cli==0.0.4 13 | flake8==7.1.0 14 | flake8-quotes==3.4.0 15 | greenlet==3.0.3 16 | h11==0.14.0 17 | httpcore==1.0.5 18 | httptools==0.6.1 19 | httpx==0.27.0 20 | idna==3.7 21 | iniconfig==2.0.0 22 | itsdangerous==2.2.0 23 | Jinja2==3.1.4 24 | jose==1.0.0 25 | Mako==1.3.5 26 | markdown-it-py==3.0.0 27 | MarkupSafe==2.1.5 28 | mccabe==0.7.0 29 | mdurl==0.1.2 30 | mypy==1.10.1 31 | mypy-extensions==1.0.0 32 | orjson==3.10.6 33 | packaging==24.1 34 | passlib==1.7.4 35 | pluggy==1.5.0 36 | pyasn1==0.6.0 37 | pycodestyle==2.12.0 38 | pydantic==2.8.2 39 | pydantic-extra-types==2.9.0 40 | pydantic-settings==2.3.4 41 | pydantic_core==2.20.1 42 | pyflakes==3.2.0 43 | Pygments==2.18.0 44 | pytest==8.2.2 45 | pytest-dotenv==0.5.2 46 | python-dotenv==1.0.1 47 | python-jose==3.3.0 48 | python-multipart==0.0.9 49 | PyYAML==6.0.1 50 | rich==13.7.1 51 | rsa==4.9 52 | setuptools==70.2.0 53 | shellingham==1.5.4 54 | six==1.16.0 55 | sniffio==1.3.1 56 | SQLAlchemy==2.0.31 57 | starlette==0.37.2 58 | typer==0.12.3 59 | types-passlib==1.7.7.20240327 60 | types-pyasn1==0.6.0.20240402 61 | types-python-jose==3.3.4.20240106 62 | typing_extensions==4.12.2 63 | ujson==5.10.0 64 | uvicorn==0.30.1 65 | uvloop==0.19.0 66 | watchfiles==0.22.0 67 | websockets==12.0 68 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | # Import all models for correct Alembic work: 2 | from src.users.models import * 3 | -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | from typing import AsyncGenerator 3 | 4 | from fastapi import FastAPI 5 | from fastapi.responses import RedirectResponse 6 | from fastapi.middleware.cors import CORSMiddleware 7 | from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine 8 | from starlette import status 9 | 10 | from src.config import cors_config, URLPathsConfig, URLNamesConfig 11 | from src.core.database.connection import DATABASE_URL 12 | from src.core.database.base import Base 13 | from src.users.router import router as users_router 14 | 15 | 16 | @asynccontextmanager 17 | async def lifespan(_app: FastAPI) -> AsyncGenerator: 18 | """ 19 | Runs events before application startup and after application shutdown. 20 | """ 21 | 22 | # Startup events: 23 | engine: AsyncEngine = create_async_engine(DATABASE_URL) 24 | async with engine.begin() as conn: 25 | await conn.run_sync(Base.metadata.create_all) 26 | 27 | yield 28 | 29 | # Shutdown events: 30 | 31 | 32 | app = FastAPI(lifespan=lifespan) 33 | 34 | # Middlewares: 35 | app.add_middleware( 36 | CORSMiddleware, 37 | allow_origins=cors_config.ALLOW_ORIGINS, 38 | allow_credentials=cors_config.ALLOW_CREDENTIALS, 39 | allow_methods=cors_config.ALLOW_METHODS, 40 | allow_headers=cors_config.ALLOW_HEADERS, 41 | ) 42 | 43 | # Routers: 44 | app.include_router(users_router) 45 | 46 | 47 | @app.get( 48 | path=URLPathsConfig.HOMEPAGE, 49 | response_class=RedirectResponse, 50 | name=URLNamesConfig.HOMEPAGE, 51 | status_code=status.HTTP_303_SEE_OTHER 52 | ) 53 | async def homepage(): 54 | return RedirectResponse( 55 | status_code=status.HTTP_303_SEE_OTHER, 56 | url=URLPathsConfig.DOCS 57 | ) 58 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | 4 | from pydantic_settings import BaseSettings 5 | from typing import List, Tuple 6 | 7 | 8 | @dataclass(frozen=True) 9 | class URLPathsConfig: 10 | HOMEPAGE: str = '/' 11 | STATIC: str = '/static' 12 | DOCS: str = '/docs' 13 | 14 | 15 | @dataclass(frozen=True) 16 | class URLNamesConfig: 17 | HOMEPAGE: str = 'homepage' 18 | STATIC: str = 'static' 19 | 20 | 21 | @dataclass(frozen=True) 22 | class RouterConfig: 23 | PREFIX: str 24 | TAGS: Tuple[str] 25 | 26 | @classmethod 27 | def tags_list(cls) -> List[str | Enum]: 28 | return [tag for tag in cls.TAGS] 29 | 30 | 31 | class CORSConfig(BaseSettings): 32 | ALLOW_ORIGINS: List[str] 33 | ALLOW_HEADERS: List[str] 34 | ALLOW_CREDENTIALS: bool 35 | ALLOW_METHODS: List[str] 36 | 37 | 38 | class UvicornConfig(BaseSettings): 39 | HOST: str = '0.0.0.0' 40 | PORT: int = 8000 41 | LOG_LEVEL: str = 'info' 42 | RELOAD: bool = True 43 | 44 | 45 | class LinksConfig(BaseSettings): 46 | HTTP_PROTOCOL: str 47 | DOMAIN: str 48 | 49 | 50 | cors_config: CORSConfig = CORSConfig() 51 | uvicorn_config: UvicornConfig = UvicornConfig() 52 | links_config: LinksConfig = LinksConfig() 53 | -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/src/core/__init__.py -------------------------------------------------------------------------------- /src/core/constants.py: -------------------------------------------------------------------------------- 1 | class ErrorDetails: 2 | """ 3 | Base error messages for custom exceptions. 4 | """ 5 | 6 | SERVER_ERROR: str = 'Server error' 7 | PERMISSION_DENIED: str = 'Permission denied' 8 | BAD_REQUEST: str = 'Bad Request' 9 | -------------------------------------------------------------------------------- /src/core/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/src/core/database/__init__.py -------------------------------------------------------------------------------- /src/core/database/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /src/core/database/config.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings 2 | 3 | 4 | class DatabaseConfig(BaseSettings): 5 | DATABASE_DIALECT: str 6 | DATABASE_DRIVER: str 7 | DATABASE_HOST: str 8 | DATABASE_PORT: int 9 | DATABASE_USER: str 10 | DATABASE_PASSWORD: str 11 | DATABASE_NAME: str 12 | DATABASE_ECHO: bool 13 | DATABASE_POOL_RECYCLE: int 14 | DATABASE_POOL_PRE_PING: bool 15 | DATABASE_AUTO_FLUSH: bool 16 | DATABASE_EXPIRE_ON_COMMIT: bool 17 | 18 | 19 | database_config: DatabaseConfig = DatabaseConfig() 20 | -------------------------------------------------------------------------------- /src/core/database/connection.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine, async_sessionmaker 2 | 3 | from src.core.database.config import database_config 4 | 5 | 6 | """ 7 | Due to the fact, that sqlite is serverless and has no host, port and so on, there is a need to create 8 | database url in different patterns. 9 | """ 10 | DATABASE_URL: str 11 | if database_config.DATABASE_DIALECT == 'sqlite': 12 | DATABASE_URL = '{}+{}:///{}'.format( 13 | database_config.DATABASE_DIALECT, 14 | database_config.DATABASE_DRIVER, 15 | database_config.DATABASE_NAME, 16 | ) 17 | else: 18 | DATABASE_URL = '{}+{}://{}:{}@{}:{}/{}'.format( 19 | database_config.DATABASE_DIALECT, 20 | database_config.DATABASE_DRIVER, 21 | database_config.DATABASE_USER, 22 | database_config.DATABASE_PASSWORD, 23 | database_config.DATABASE_HOST, 24 | database_config.DATABASE_PORT, 25 | database_config.DATABASE_NAME 26 | ) 27 | 28 | engine: AsyncEngine = create_async_engine( 29 | url=DATABASE_URL, 30 | pool_pre_ping=database_config.DATABASE_POOL_PRE_PING, 31 | pool_recycle=database_config.DATABASE_POOL_RECYCLE, 32 | echo=database_config.DATABASE_ECHO, 33 | ) 34 | 35 | session_factory: async_sessionmaker = async_sessionmaker( 36 | bind=engine, 37 | autoflush=database_config.DATABASE_AUTO_FLUSH, 38 | expire_on_commit=database_config.DATABASE_EXPIRE_ON_COMMIT 39 | ) 40 | -------------------------------------------------------------------------------- /src/core/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from fastapi import HTTPException, status 3 | 4 | from src.core.constants import ErrorDetails 5 | 6 | 7 | class DetailedHTTPException(HTTPException): 8 | STATUS_CODE: int = status.HTTP_500_INTERNAL_SERVER_ERROR 9 | DETAIL: str = ErrorDetails.SERVER_ERROR 10 | 11 | def __init__(self, **kwargs: dict[str, Any]) -> None: 12 | super().__init__(status_code=self.STATUS_CODE, detail=self.DETAIL, **kwargs) 13 | 14 | 15 | class PermissionDeniedError(DetailedHTTPException): 16 | STATUS_CODE = status.HTTP_403_FORBIDDEN 17 | DETAIL = ErrorDetails.PERMISSION_DENIED 18 | 19 | 20 | class NotFoundError(DetailedHTTPException): 21 | STATUS_CODE = status.HTTP_404_NOT_FOUND 22 | 23 | 24 | class AlreadyExistsError(DetailedHTTPException): 25 | STATUS_CODE = status.HTTP_409_CONFLICT 26 | 27 | 28 | class BadRequestError(DetailedHTTPException): 29 | STATUS_CODE = status.HTTP_400_BAD_REQUEST 30 | DETAIL = ErrorDetails.BAD_REQUEST 31 | 32 | 33 | class PreconditionFailedError(DetailedHTTPException): 34 | STATUS_CODE = status.HTTP_412_PRECONDITION_FAILED 35 | 36 | 37 | class ValidationError(DetailedHTTPException): 38 | STATUS_CODE = status.HTTP_422_UNPROCESSABLE_ENTITY 39 | -------------------------------------------------------------------------------- /src/core/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | 5 | def get_substring_before_chars(string: str, chars: str) -> str: 6 | """ 7 | Iterates through provided string and trys to return first match before provided chars, using RegEx. 8 | If no provided chars in string, returns the full string back. 9 | """ 10 | 11 | result: Optional[re.Match] = re.match( 12 | pattern=rf'([^{chars}]*){chars}', # all chars before provided chars 13 | string=string 14 | ) 15 | 16 | if result: 17 | return result.group(1) # 1 not to include provided chars to the result 18 | 19 | return string 20 | 21 | 22 | def get_substring_after_chars(string: str, chars: str) -> str: 23 | """ 24 | Iterates through provided string and trys to return first match after provided chars, using RegEx. 25 | If no provided chars in string, returns the full string back. 26 | """ 27 | 28 | result: Optional[re.Match] = re.search( 29 | pattern=rf'(?<={chars})[^.]*', # all chars after provided chars 30 | string=string 31 | ) 32 | 33 | if result: 34 | return result.group() 35 | 36 | return string 37 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from src.config import uvicorn_config 4 | 5 | 6 | if __name__ == '__main__': 7 | uvicorn.run( 8 | 'src.app:app', 9 | host=uvicorn_config.HOST, 10 | port=uvicorn_config.PORT, 11 | log_level=uvicorn_config.LOG_LEVEL, 12 | reload=uvicorn_config.RELOAD 13 | ) 14 | -------------------------------------------------------------------------------- /src/security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/src/security/__init__.py -------------------------------------------------------------------------------- /src/security/config.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings 2 | 3 | 4 | class JWTConfig(BaseSettings): 5 | JWT_TOKEN_SECRET_KEY: str 6 | JWT_TOKEN_ALGORITHM: str 7 | JWT_TOKEN_EXPIRE_DAYS: int 8 | 9 | 10 | jwt_config: JWTConfig = JWTConfig() 11 | -------------------------------------------------------------------------------- /src/security/constants.py: -------------------------------------------------------------------------------- 1 | from src.core.constants import ErrorDetails as BaseErrorDetails 2 | 3 | 4 | class ErrorDetails(BaseErrorDetails): 5 | """ 6 | Security error messages for custom exceptions. 7 | """ 8 | 9 | INVALID_TOKEN: str = 'Token has expired or is invalid' 10 | -------------------------------------------------------------------------------- /src/security/exceptions.py: -------------------------------------------------------------------------------- 1 | from src.security.constants import ErrorDetails 2 | from src.core.exceptions import PreconditionFailedError 3 | 4 | 5 | class InvalidTokenError(PreconditionFailedError): 6 | DETAIL = ErrorDetails.INVALID_TOKEN 7 | -------------------------------------------------------------------------------- /src/security/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from datetime import datetime, timedelta, timezone 3 | 4 | from src.security.config import jwt_config 5 | 6 | 7 | class JWTDataModel(BaseModel): 8 | user_id: int 9 | 10 | # Name should be only "exp" due to JWT docs. In other case will raise datetime encode error: 11 | exp: datetime = datetime.now(tz=timezone.utc) + timedelta(days=jwt_config.JWT_TOKEN_EXPIRE_DAYS) 12 | -------------------------------------------------------------------------------- /src/security/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from jose import jwt, JWTError, ExpiredSignatureError 3 | 4 | from src.security.config import jwt_config 5 | from src.security.models import JWTDataModel 6 | from src.security.exceptions import InvalidTokenError 7 | 8 | 9 | async def create_jwt_token(jwt_data: JWTDataModel) -> str: 10 | jwt_token: str = jwt.encode( 11 | claims=jwt_data.model_dump(), 12 | key=jwt_config.JWT_TOKEN_SECRET_KEY, 13 | algorithm=jwt_config.JWT_TOKEN_ALGORITHM 14 | ) 15 | return jwt_token 16 | 17 | 18 | async def parse_jwt_token(token: str) -> JWTDataModel: 19 | """ 20 | Decodes a JWT token, checks, if token is valid and hadn't expired and returns a JWTData object, 21 | which represents token data. 22 | """ 23 | 24 | try: 25 | payload = jwt.decode(token, jwt_config.JWT_TOKEN_SECRET_KEY, algorithms=[jwt_config.JWT_TOKEN_ALGORITHM]) 26 | payload['exp'] = datetime.fromtimestamp(payload['exp'], tz=timezone.utc) # converting to datetime format 27 | except (JWTError, ExpiredSignatureError): 28 | raise InvalidTokenError 29 | 30 | jwt_data: JWTDataModel = JWTDataModel(**payload) 31 | if jwt_data.exp < datetime.now(tz=timezone.utc): 32 | raise InvalidTokenError 33 | 34 | return jwt_data 35 | -------------------------------------------------------------------------------- /src/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/src/users/__init__.py -------------------------------------------------------------------------------- /src/users/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Tuple, Literal 3 | from pydantic_settings import BaseSettings 4 | 5 | from src.config import RouterConfig as BaseRouterConfig 6 | 7 | 8 | @dataclass(frozen=True) 9 | class URLPathsConfig: 10 | REGISTER: str = '/register' 11 | LOGIN: str = '/login' 12 | LOGOUT: str = '/logout' 13 | ME: str = '/me' 14 | VERIFY_EMAIL: str = '/verify-email/{token}' 15 | ALL: str = '' 16 | MY_STATS: str = '/get-my-statistics' 17 | LIKE_USER: str = '/{user_id}/like' 18 | DISLIKE_USER: str = '/{user_id}/dislike' 19 | 20 | 21 | @dataclass(frozen=True) 22 | class URLNamesConfig: 23 | REGISTER: str = 'register' 24 | LOGIN: str = 'login' 25 | LOGOUT: str = 'logout' 26 | ME: str = 'get my account' 27 | VERIFY_EMAIL: str = 'verify email' 28 | ALL: str = 'get all users' 29 | MY_STATS: str = 'get my statistics' 30 | LIKE_USER: str = 'like user' 31 | DISLIKE_USER: str = 'dislike user' 32 | 33 | 34 | @dataclass(frozen=True) 35 | class UserValidationConfig: 36 | PASSWORD_MIN_LENGTH: int = 8 37 | PASSWORD_MAX_LENGTH: int = 30 38 | USERNAME_MIN_LENGTH: int = 5 39 | USERNAME_MAX_LENGTH: int = 60 40 | 41 | 42 | @dataclass(frozen=True) 43 | class RouterConfig(BaseRouterConfig): 44 | PREFIX: str = '/users' 45 | TAGS: Tuple[str] = ('Users', ) 46 | 47 | 48 | class CookiesConfig(BaseSettings): 49 | COOKIES_KEY: str 50 | COOKIES_LIFESPAN_DAYS: int 51 | SECURE_COOKIES: bool 52 | HTTP_ONLY: bool 53 | SAME_SITE: Literal['strict', 'lax', 'none'] 54 | 55 | 56 | class PasslibConfig(BaseSettings): 57 | PASSLIB_SCHEME: str 58 | PASSLIB_DEPRECATED: str 59 | 60 | 61 | cookies_config: CookiesConfig = CookiesConfig() 62 | passlib_config: PasslibConfig = PasslibConfig() 63 | -------------------------------------------------------------------------------- /src/users/constants.py: -------------------------------------------------------------------------------- 1 | from src.core.constants import ErrorDetails as BaseErrorDetails 2 | from src.users.config import UserValidationConfig 3 | 4 | 5 | class ErrorDetails(BaseErrorDetails): 6 | """ 7 | Authorization and authentication error messages for custom exceptions. 8 | """ 9 | 10 | USER_NOT_AUTHENTICATED: str = 'User not authenticated' 11 | PASSWORD_VALIDATION_ERROR: str = (f'Password must be between {UserValidationConfig.PASSWORD_MIN_LENGTH} and ' 12 | f'{UserValidationConfig.PASSWORD_MAX_LENGTH} characters inclusive') 13 | USERNAME_VALIDATION_ERROR: str = (f'Username must be between {UserValidationConfig.USERNAME_MIN_LENGTH} and ' 14 | f'{UserValidationConfig.USERNAME_MAX_LENGTH} characters inclusive') 15 | INVALID_PASSWORD: str = 'Provided password is invalid' 16 | INVALID_USER: str = 'Current user is invalid' 17 | USER_ALREADY_EXISTS: str = 'User with provided credentials already exists' 18 | USER_NOT_FOUND: str = 'User with provided credentials not found' 19 | USER_ATTRIBUTE_REQUIRED: str = 'user id, email or username is required' 20 | USER_CAN_NOT_VOTE_FOR_HIMSELF: str = 'User can not vote for himself' 21 | USER_STATISTICS_NOT_FOUND: str = 'User statistics not found' 22 | USER_ALREADY_VOTED: str = 'Current user already voted for provided user' 23 | -------------------------------------------------------------------------------- /src/users/dependencies.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | from typing import List 3 | 4 | from src.users.exceptions import ( 5 | UserNotFoundError, 6 | InvalidPasswordError, 7 | UserAlreadyExistsError, 8 | UserAlreadyVotedError, 9 | UserCanNotVoteForHimSelf 10 | ) 11 | from src.users.models import UserModel, UserStatisticsModel 12 | from src.security.models import JWTDataModel 13 | from src.users.schemas import LoginUserScheme, RegisterUserScheme 14 | from src.users.utils import oauth2_scheme, verify_password, hash_password 15 | from src.security.utils import parse_jwt_token 16 | from src.users.service import UsersService 17 | 18 | 19 | async def register_user(user_data: RegisterUserScheme) -> UserModel: 20 | users_service: UsersService = UsersService() 21 | if await users_service.check_user_existence(email=user_data.email, username=user_data.username): 22 | raise UserAlreadyExistsError 23 | 24 | user: UserModel = UserModel(**user_data.model_dump()) 25 | user.password = await hash_password(user.password) 26 | return await users_service.register_user(user=user) 27 | 28 | 29 | async def verify_user_credentials(user_data: LoginUserScheme) -> UserModel: 30 | users_service: UsersService = UsersService() 31 | user: UserModel 32 | if await users_service.check_user_existence(email=user_data.username): 33 | user = await users_service.get_user_by_email(email=user_data.username) 34 | elif await users_service.check_user_existence(username=user_data.username): 35 | user = await users_service.get_user_by_username(username=user_data.username) 36 | else: 37 | raise UserNotFoundError 38 | 39 | if not await verify_password(plain_password=user_data.password, hashed_password=user.password): 40 | raise InvalidPasswordError 41 | 42 | return user 43 | 44 | 45 | async def authenticate_user(token: str = Depends(oauth2_scheme)) -> UserModel: 46 | """ 47 | Authenticates user according to provided JWT token, if token is valid and hadn't expired. 48 | """ 49 | 50 | jwt_data: JWTDataModel = await parse_jwt_token(token=token) 51 | users_service: UsersService = UsersService() 52 | user: UserModel = await users_service.get_user_by_id(id=jwt_data.user_id) 53 | return user 54 | 55 | 56 | async def get_my_account(user: UserModel = Depends(authenticate_user)) -> UserModel: 57 | return user 58 | 59 | 60 | async def get_my_statistics(user: UserModel = Depends(authenticate_user)) -> UserStatisticsModel: 61 | users_service: UsersService = UsersService() 62 | user_statistics: UserStatisticsModel = await users_service.get_user_statistics_by_user_id(user_id=user.id) 63 | return user_statistics 64 | 65 | 66 | async def like_user(user_id: int, user: UserModel = Depends(authenticate_user)) -> UserStatisticsModel: 67 | if user.id == user_id: 68 | raise UserCanNotVoteForHimSelf 69 | 70 | users_service: UsersService = UsersService() 71 | if await users_service.check_if_user_already_voted(voting_user_id=user.id, voted_for_user_id=user_id): 72 | raise UserAlreadyVotedError 73 | 74 | user_statistics: UserStatisticsModel = await users_service.like_user( 75 | voting_user_id=user.id, 76 | voted_for_user_id=user_id 77 | ) 78 | 79 | return user_statistics 80 | 81 | 82 | async def dislike_user(user_id: int, user: UserModel = Depends(authenticate_user)) -> UserStatisticsModel: 83 | if user.id == user_id: 84 | raise UserCanNotVoteForHimSelf 85 | 86 | users_service: UsersService = UsersService() 87 | if await users_service.check_if_user_already_voted(voting_user_id=user.id, voted_for_user_id=user_id): 88 | raise UserAlreadyVotedError 89 | 90 | user_statistics: UserStatisticsModel = await users_service.dislike_user( 91 | voting_user_id=user.id, 92 | voted_for_user_id=user_id 93 | ) 94 | 95 | return user_statistics 96 | 97 | 98 | async def get_all_users() -> List[UserModel]: 99 | users_service: UsersService = UsersService() 100 | users: List[UserModel] = await users_service.get_all_users() 101 | return users 102 | -------------------------------------------------------------------------------- /src/users/exceptions.py: -------------------------------------------------------------------------------- 1 | from fastapi import status 2 | 3 | from src.users.constants import ErrorDetails 4 | from src.core.exceptions import ( 5 | DetailedHTTPException, 6 | PreconditionFailedError, 7 | AlreadyExistsError, 8 | NotFoundError, 9 | ValidationError, 10 | BadRequestError 11 | ) 12 | 13 | 14 | class NotAuthenticatedError(DetailedHTTPException): 15 | STATUS_CODE = status.HTTP_401_UNAUTHORIZED 16 | DETAIL = ErrorDetails.USER_NOT_AUTHENTICATED 17 | 18 | 19 | class InvalidPasswordError(PreconditionFailedError): 20 | DETAIL = ErrorDetails.INVALID_PASSWORD 21 | 22 | 23 | class InvalidUserError(PreconditionFailedError): 24 | DETAIL = ErrorDetails.INVALID_USER 25 | 26 | 27 | class UserAlreadyExistsError(AlreadyExistsError): 28 | DETAIL = ErrorDetails.USER_ALREADY_EXISTS 29 | 30 | 31 | class UserNotFoundError(NotFoundError): 32 | DETAIL = ErrorDetails.USER_NOT_FOUND 33 | 34 | 35 | class UsernameValidationError(ValidationError): 36 | DETAIL = ErrorDetails.USERNAME_VALIDATION_ERROR 37 | 38 | 39 | class PasswordValidationError(ValidationError): 40 | DETAIL = ErrorDetails.PASSWORD_VALIDATION_ERROR 41 | 42 | 43 | class UserCanNotVoteForHimSelf(BadRequestError): 44 | DETAIL = ErrorDetails.USER_CAN_NOT_VOTE_FOR_HIMSELF 45 | 46 | 47 | class UserStatisticsNotFoundError(NotFoundError): 48 | DETAIL = ErrorDetails.USER_STATISTICS_NOT_FOUND 49 | 50 | 51 | class UserAlreadyVotedError(BadRequestError): 52 | DETAIL = ErrorDetails.USER_ALREADY_VOTED 53 | -------------------------------------------------------------------------------- /src/users/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import mapped_column, Mapped 2 | from sqlalchemy import String, Integer, ForeignKey 3 | 4 | from src.core.database.base import Base 5 | 6 | 7 | class UserModel(Base): 8 | __tablename__ = 'users' 9 | 10 | id: Mapped[int] = mapped_column(primary_key=True) 11 | email: Mapped[str] = mapped_column(String, unique=True) 12 | password: Mapped[str] = mapped_column(String) 13 | username: Mapped[str] = mapped_column(String, unique=True) 14 | 15 | 16 | class UserStatisticsModel(Base): 17 | __tablename__ = 'users_statistics' 18 | 19 | id: Mapped[int] = mapped_column(primary_key=True) 20 | user_id: Mapped[int] = mapped_column( 21 | Integer, 22 | ForeignKey('users.id', onupdate='CASCADE', ondelete='CASCADE'), 23 | nullable=False 24 | ) 25 | likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 26 | dislikes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 27 | 28 | 29 | class UserVoteModel(Base): 30 | __tablename__ = 'users_votes' 31 | 32 | id: Mapped[int] = mapped_column(primary_key=True) 33 | voted_for_user_id: Mapped[int] = mapped_column( 34 | Integer, 35 | ForeignKey('users.id', onupdate='CASCADE', ondelete='CASCADE'), 36 | nullable=False 37 | ) 38 | voting_user_id: Mapped[int] = mapped_column( 39 | Integer, 40 | ForeignKey('users.id', onupdate='CASCADE', ondelete='CASCADE'), 41 | nullable=False 42 | ) 43 | -------------------------------------------------------------------------------- /src/users/router.py: -------------------------------------------------------------------------------- 1 | from typing import MutableSequence 2 | from datetime import datetime, timezone, timedelta 3 | from fastapi import APIRouter, Depends, status 4 | from fastapi.responses import Response, JSONResponse 5 | 6 | from src.users.models import UserModel, UserStatisticsModel 7 | from src.users.config import RouterConfig, URLPathsConfig, URLNamesConfig, cookies_config 8 | from src.security.models import JWTDataModel 9 | from src.security.utils import create_jwt_token 10 | from src.users.dependencies import ( 11 | verify_user_credentials, 12 | register_user, 13 | get_my_account as get_my_account_dependency, 14 | get_all_users as get_all_users_dependency, 15 | get_my_statistics as get_my_statistics_dependency, 16 | like_user as like_user_dependency, 17 | dislike_user as dislike_user_dependency 18 | ) 19 | 20 | 21 | router = APIRouter( 22 | prefix=RouterConfig.PREFIX, 23 | tags=RouterConfig.tags_list(), 24 | ) 25 | 26 | 27 | @router.post( 28 | path=URLPathsConfig.REGISTER, 29 | response_class=JSONResponse, 30 | name=URLNamesConfig.REGISTER, 31 | status_code=status.HTTP_201_CREATED, 32 | # response_model=UserModel 33 | ) 34 | async def register(user: UserModel = Depends(register_user)): 35 | return user 36 | 37 | 38 | @router.post( 39 | path=URLPathsConfig.LOGIN, 40 | response_class=Response, 41 | name=URLNamesConfig.LOGIN, 42 | status_code=status.HTTP_204_NO_CONTENT 43 | ) 44 | async def login(user: UserModel = Depends(verify_user_credentials)): 45 | jwt_data: JWTDataModel = JWTDataModel(user_id=user.id) 46 | token: str = await create_jwt_token(jwt_data=jwt_data) 47 | response: Response = Response() 48 | response.set_cookie( 49 | key=cookies_config.COOKIES_KEY, 50 | value=token, 51 | secure=cookies_config.SECURE_COOKIES, 52 | httponly=cookies_config.HTTP_ONLY, 53 | expires=datetime.now(tz=timezone.utc) + timedelta(days=cookies_config.COOKIES_LIFESPAN_DAYS), 54 | samesite=cookies_config.SAME_SITE 55 | ) 56 | return response 57 | 58 | 59 | @router.get( 60 | path=URLPathsConfig.LOGOUT, 61 | response_class=Response, 62 | name=URLNamesConfig.LOGOUT, 63 | status_code=status.HTTP_204_NO_CONTENT 64 | ) 65 | async def logout(): 66 | response: Response = Response() 67 | response.delete_cookie( 68 | key=cookies_config.COOKIES_KEY, 69 | secure=cookies_config.SECURE_COOKIES, 70 | httponly=cookies_config.HTTP_ONLY, 71 | samesite=cookies_config.SAME_SITE 72 | ) 73 | return response 74 | 75 | 76 | @router.get( 77 | path=URLPathsConfig.ME, 78 | response_class=JSONResponse, 79 | # response_model=UserModel, 80 | name=URLNamesConfig.ME, 81 | status_code=status.HTTP_200_OK 82 | ) 83 | async def get_my_account(user: UserModel = Depends(get_my_account_dependency)): 84 | return user 85 | 86 | 87 | @router.get( 88 | path=URLPathsConfig.ALL, 89 | response_class=JSONResponse, 90 | # response_model=MutableSequence[UserModel], 91 | name=URLNamesConfig.ALL, 92 | status_code=status.HTTP_200_OK 93 | ) 94 | async def get_all_users(users: MutableSequence[UserModel] = Depends(get_all_users_dependency)): 95 | return users 96 | 97 | 98 | @router.get( 99 | path=URLPathsConfig.MY_STATS, 100 | response_class=JSONResponse, 101 | # response_model=UserStatisticsModel, 102 | name=URLNamesConfig.MY_STATS, 103 | status_code=status.HTTP_200_OK 104 | ) 105 | async def get_my_statistics(statistics: UserStatisticsModel = Depends(get_my_statistics_dependency)): 106 | return statistics 107 | 108 | 109 | @router.patch( 110 | path=URLPathsConfig.LIKE_USER, 111 | response_class=JSONResponse, 112 | # response_model=UserStatisticsModel, 113 | name=URLNamesConfig.LIKE_USER, 114 | status_code=status.HTTP_200_OK 115 | ) 116 | async def like_user(statistics: UserStatisticsModel = Depends(like_user_dependency)): 117 | return statistics 118 | 119 | 120 | @router.patch( 121 | path=URLPathsConfig.DISLIKE_USER, 122 | response_class=JSONResponse, 123 | # response_model=UserStatisticsModel, 124 | name=URLNamesConfig.DISLIKE_USER, 125 | status_code=status.HTTP_200_OK 126 | ) 127 | async def dislike_user(statistics: UserStatisticsModel = Depends(dislike_user_dependency)): 128 | return statistics 129 | -------------------------------------------------------------------------------- /src/users/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr, field_validator 2 | 3 | from src.users.config import UserValidationConfig 4 | from src.users.exceptions import PasswordValidationError, UsernameValidationError 5 | 6 | 7 | class LoginUserScheme(BaseModel): 8 | username: str 9 | password: str 10 | 11 | @field_validator('username', mode='before') 12 | @classmethod 13 | def validate_username(cls, value: str) -> str: 14 | if not UserValidationConfig.USERNAME_MIN_LENGTH <= len(value) <= UserValidationConfig.USERNAME_MAX_LENGTH: 15 | raise UsernameValidationError 16 | 17 | return value 18 | 19 | @field_validator('password', mode='before') 20 | @classmethod 21 | def validate_password(cls, value: str) -> str: 22 | if not UserValidationConfig.PASSWORD_MIN_LENGTH <= len(value) <= UserValidationConfig.PASSWORD_MAX_LENGTH: 23 | raise PasswordValidationError 24 | 25 | return value 26 | 27 | 28 | class RegisterUserScheme(LoginUserScheme): 29 | email: EmailStr 30 | -------------------------------------------------------------------------------- /src/users/service.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Sequence 2 | from sqlalchemy.ext.asyncio import async_sessionmaker 3 | from sqlalchemy import select, update, insert 4 | 5 | from src.users.constants import ErrorDetails 6 | from src.users.exceptions import UserNotFoundError, UserStatisticsNotFoundError 7 | from src.users.models import UserModel, UserStatisticsModel, UserVoteModel 8 | from src.core.database.connection import session_factory as default_session_factory 9 | 10 | 11 | class UsersService: 12 | 13 | def __init__(self, session_factory: async_sessionmaker = default_session_factory) -> None: 14 | self._session_factory: async_sessionmaker = session_factory 15 | 16 | async def register_user(self, user: UserModel) -> UserModel: 17 | async with self._session_factory() as session: 18 | session.add(user) 19 | await session.flush() 20 | session.add(UserStatisticsModel(user_id=user.id)) 21 | await session.commit() 22 | return user 23 | 24 | async def check_user_existence( 25 | self, 26 | id: Optional[int] = None, 27 | email: Optional[str] = None, 28 | username: Optional[str] = None 29 | ) -> bool: 30 | 31 | if not (id or email or username): 32 | raise ValueError(ErrorDetails.USER_ATTRIBUTE_REQUIRED) 33 | 34 | async with self._session_factory() as session: 35 | user: Optional[UserModel] # declaring here for mypy passing 36 | if id: 37 | user = (await session.scalars(select(UserModel).filter_by(id=id))).one_or_none() 38 | if user: 39 | return True 40 | 41 | if email: 42 | user = (await session.scalars(select(UserModel).filter_by(email=email))).one_or_none() 43 | if user: 44 | return True 45 | 46 | if username: 47 | user = (await session.scalars(select(UserModel).filter_by(username=username))).one_or_none() 48 | if user: 49 | return True 50 | 51 | return False 52 | 53 | async def get_user_by_email(self, email: str) -> UserModel: 54 | async with self._session_factory() as session: 55 | user: Optional[UserModel] = (await session.scalars(select(UserModel).filter_by(email=email))).one_or_none() 56 | if not user: 57 | raise UserNotFoundError 58 | 59 | return user 60 | 61 | async def get_user_by_username(self, username: str) -> UserModel: 62 | async with self._session_factory() as session: 63 | user: Optional[UserModel] = ( 64 | await session.scalars( 65 | select( 66 | UserModel 67 | ).filter_by( 68 | username=username 69 | ) 70 | ) 71 | ).one_or_none() 72 | if not user: 73 | raise UserNotFoundError 74 | 75 | return user 76 | 77 | async def get_user_by_id(self, id: int) -> UserModel: 78 | async with self._session_factory() as session: 79 | user: Optional[UserModel] = (await session.scalars(select(UserModel).filter_by(id=id))).one_or_none() 80 | if not user: 81 | raise UserNotFoundError 82 | 83 | return user 84 | 85 | async def get_all_users(self) -> List[UserModel]: 86 | async with self._session_factory() as session: 87 | users: Sequence[UserModel] = (await session.scalars(select(UserModel))).all() 88 | assert isinstance(users, list) 89 | return users 90 | 91 | async def get_user_statistics_by_user_id(self, user_id: int) -> UserStatisticsModel: 92 | async with self._session_factory() as session: 93 | user_statistics: Optional[UserStatisticsModel] = ( 94 | await session.scalars( 95 | select( 96 | UserStatisticsModel 97 | ).filter_by( 98 | user_id=user_id 99 | ) 100 | ) 101 | ).one_or_none() 102 | if not user_statistics: 103 | raise UserStatisticsNotFoundError 104 | 105 | return user_statistics 106 | 107 | async def like_user(self, voting_user_id: int, voted_for_user_id: int) -> UserStatisticsModel: 108 | async with self._session_factory() as session: 109 | user_statistics: Optional[UserStatisticsModel] = ( 110 | await session.scalars( 111 | select( 112 | UserStatisticsModel 113 | ).filter_by( 114 | user_id=voted_for_user_id 115 | ) 116 | ) 117 | ).one_or_none() 118 | if not user_statistics: 119 | raise UserStatisticsNotFoundError 120 | 121 | await session.execute( 122 | update( 123 | UserStatisticsModel 124 | ).filter_by( 125 | id=user_statistics.id 126 | ).values( 127 | likes=user_statistics.likes + 1 128 | ) 129 | ) 130 | 131 | await session.execute( 132 | insert( 133 | UserVoteModel 134 | ).values( 135 | voting_user_id=voting_user_id, 136 | voted_for_user_id=voted_for_user_id 137 | ) 138 | ) 139 | 140 | await session.commit() 141 | return user_statistics 142 | 143 | async def dislike_user(self, voting_user_id: int, voted_for_user_id: int) -> UserStatisticsModel: 144 | async with self._session_factory() as session: 145 | user_statistics: Optional[UserStatisticsModel] = ( 146 | await session.scalars( 147 | select( 148 | UserStatisticsModel 149 | ).filter_by( 150 | user_id=voted_for_user_id 151 | ) 152 | ) 153 | ).one_or_none() 154 | if not user_statistics: 155 | raise UserStatisticsNotFoundError 156 | 157 | await session.execute( 158 | update( 159 | UserStatisticsModel 160 | ).filter_by( 161 | id=user_statistics.id 162 | ).values( 163 | dislikes=user_statistics.dislikes + 1 164 | ) 165 | ) 166 | 167 | await session.execute( 168 | insert( 169 | UserVoteModel 170 | ).values( 171 | voting_user_id=voting_user_id, 172 | voted_for_user_id=voted_for_user_id 173 | ) 174 | ) 175 | 176 | await session.commit() 177 | return user_statistics 178 | 179 | async def check_if_user_already_voted(self, voting_user_id: int, voted_for_user_id: int) -> bool: 180 | async with self._session_factory() as session: 181 | user_vote: Optional[UserVoteModel] = ( 182 | await session.scalars( 183 | select( 184 | UserVoteModel 185 | ).filter_by( 186 | voted_for_user_id=voted_for_user_id, 187 | voting_user_id=voting_user_id 188 | ) 189 | ) 190 | ).one_or_none() 191 | if user_vote: 192 | return True 193 | 194 | return False 195 | -------------------------------------------------------------------------------- /src/users/utils.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request 2 | from fastapi.security import OAuth2 3 | from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel 4 | from passlib.context import CryptContext 5 | from typing import Optional, Dict 6 | 7 | from src.users.config import URLPathsConfig, cookies_config, passlib_config, RouterConfig 8 | from src.users.exceptions import NotAuthenticatedError 9 | 10 | 11 | class OAuth2Cookie(OAuth2): 12 | """ 13 | Class uses OAuth2 to retrieve token for user authentication from cookies. 14 | """ 15 | 16 | def __init__( 17 | self, 18 | token_url: str, 19 | scheme_name: Optional[str] = None, 20 | scopes: Optional[Dict[str, str]] = None, 21 | description: Optional[str] = None, 22 | auto_error: bool = True, 23 | ): 24 | if not scopes: 25 | scopes = {} 26 | flows = OAuthFlowsModel(password={'tokenUrl': token_url, 'scopes': scopes}) 27 | super().__init__( 28 | flows=flows, 29 | scheme_name=scheme_name, 30 | description=description, 31 | auto_error=auto_error, 32 | ) 33 | 34 | async def __call__(self, request: Request) -> Optional[str]: 35 | """ 36 | Retrieves token for user authentication from cookies, if exists. 37 | """ 38 | 39 | token: Optional[str] = request.cookies.get(cookies_config.COOKIES_KEY) 40 | if not token: 41 | if self.auto_error: 42 | raise NotAuthenticatedError 43 | else: 44 | return None 45 | return token 46 | 47 | 48 | pwd_context: CryptContext = CryptContext( 49 | schemes=[passlib_config.PASSLIB_SCHEME], 50 | deprecated=passlib_config.PASSLIB_DEPRECATED 51 | ) 52 | 53 | oauth2_scheme: OAuth2Cookie = OAuth2Cookie(token_url=RouterConfig.PREFIX + URLPathsConfig.LOGIN) 54 | 55 | 56 | async def verify_password(plain_password: str, hashed_password: str) -> bool: 57 | return pwd_context.verify(secret=plain_password, hash=hashed_password) 58 | 59 | 60 | async def hash_password(password: str) -> str: 61 | return pwd_context.hash(secret=password) 62 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/__init__.py -------------------------------------------------------------------------------- /tests/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, asdict 2 | from typing import Dict, Any 3 | 4 | 5 | @dataclass 6 | class BaseTestConfig: 7 | 8 | def to_dict(self, to_lower: bool = False) -> Dict[str, Any]: 9 | base_dict: Dict[str, Any] = asdict(self) 10 | if to_lower: 11 | return {k.lower(): v for k, v in base_dict.items()} 12 | 13 | return base_dict 14 | 15 | 16 | @dataclass 17 | class FakeUserConfig(BaseTestConfig): 18 | EMAIL: str = 'test@yandex.ru' 19 | PASSWORD: str = 'test_password' 20 | USERNAME: str = 'test_username' 21 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | from httpx import AsyncClient, Cookies, Response 4 | from sqlalchemy import insert, CursorResult, RowMapping 5 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine, AsyncConnection 6 | from sqlalchemy.exc import IntegrityError 7 | from typing import AsyncGenerator, Optional 8 | 9 | from src.app import app 10 | from src.users.config import RouterConfig, URLPathsConfig, cookies_config 11 | from src.users.models import UserModel, UserStatisticsModel 12 | from src.core.database.connection import DATABASE_URL 13 | from src.core.database.base import Base 14 | from src.users.utils import hash_password 15 | from tests.config import FakeUserConfig 16 | from tests.utils import get_base_url, drop_test_db 17 | 18 | 19 | @pytest.fixture(scope='session') 20 | def anyio_backend() -> str: 21 | """ 22 | Launch tests only on "asyncio" backend, without "trio" backend. 23 | """ 24 | 25 | return 'asyncio' 26 | 27 | 28 | @pytest.fixture 29 | async def async_connection() -> AsyncGenerator[AsyncConnection, None]: 30 | engine: AsyncEngine = create_async_engine(DATABASE_URL) 31 | async with engine.begin() as conn: 32 | yield conn 33 | 34 | 35 | @pytest.fixture 36 | async def create_test_db(async_connection: AsyncConnection) -> AsyncGenerator[None, None]: 37 | await async_connection.run_sync(Base.metadata.create_all) 38 | yield 39 | drop_test_db() 40 | 41 | 42 | @pytest.fixture 43 | async def async_client(create_test_db: None) -> AsyncGenerator[AsyncClient, None]: 44 | """ 45 | Creates test app client for end-to-end tests to make requests to endpoints with. 46 | """ 47 | 48 | async with AsyncClient(app=app, base_url=get_base_url()) as async_client: 49 | yield async_client 50 | 51 | 52 | @pytest.fixture 53 | async def create_test_user(create_test_db: None) -> None: 54 | """ 55 | Creates test user in test database, if user with provided credentials does not exist. 56 | """ 57 | 58 | engine: AsyncEngine = create_async_engine(DATABASE_URL) 59 | test_user_config: FakeUserConfig = FakeUserConfig() 60 | test_user_config.PASSWORD = await hash_password(test_user_config.PASSWORD) 61 | async with engine.begin() as conn: 62 | try: 63 | cursor: CursorResult = await conn.execute( 64 | insert(UserModel).values(**test_user_config.to_dict(to_lower=True)).returning(UserModel) 65 | ) 66 | user_data: Optional[RowMapping] = cursor.mappings().fetchone() 67 | assert user_data is not None 68 | user: UserModel = UserModel(**user_data) 69 | await conn.execute(insert(UserStatisticsModel).values(user_id=user.id)) 70 | await conn.commit() 71 | except IntegrityError: 72 | await conn.rollback() 73 | 74 | 75 | @pytest.fixture 76 | async def access_token(async_client: AsyncClient, create_test_user: None) -> str: 77 | """ 78 | Gets access token for test user, for usage during end-to-end tests to make request, 79 | which requires authenticated user. 80 | """ 81 | 82 | response: Response = await async_client.post( 83 | url=RouterConfig.PREFIX + URLPathsConfig.LOGIN, 84 | json=FakeUserConfig().to_dict(to_lower=True) 85 | ) 86 | access_token: str = response.cookies[cookies_config.COOKIES_KEY] 87 | return access_token 88 | 89 | 90 | @pytest.fixture 91 | async def cookies(access_token: str) -> Cookies: 92 | """ 93 | Creates cookies object for AsyncClient, for usage during end-to-end tests to make request, 94 | which requires authenticated user. 95 | """ 96 | 97 | cookies: Cookies = Cookies() 98 | domain: str = os.environ.get('HOST', '0.0.0.0') 99 | cookies.set(name=cookies_config.COOKIES_KEY, value=access_token, domain=domain) 100 | return cookies 101 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/core/__init__.py -------------------------------------------------------------------------------- /tests/core/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/core/unit/__init__.py -------------------------------------------------------------------------------- /tests/core/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | from src.core.utils import ( 2 | get_substring_before_chars, 3 | get_substring_after_chars 4 | ) 5 | 6 | 7 | def test_get_substring_before_chars_common_case() -> None: 8 | expected_result: str = 'someString' 9 | chars: str = '_' 10 | test_string: str = f'{expected_result}{chars}postfix' 11 | assert get_substring_before_chars(string=test_string, chars=chars) == expected_result 12 | 13 | 14 | def test_get_substring_before_chars_without_selected_chars_in_string() -> None: 15 | chars: str = '_' 16 | test_string: str = 'Some text without selected symbol' 17 | assert get_substring_before_chars(string=test_string, chars=chars) == test_string 18 | 19 | 20 | def test_get_substring_after_chars_common_case() -> None: 21 | expected_result: str = 'someString' 22 | chars: str = '_' 23 | test_string: str = f'prefix{chars}{expected_result}' 24 | assert get_substring_after_chars(string=test_string, chars=chars) == expected_result 25 | 26 | 27 | def test_get_substring_after_chars_without_selected_chars_in_string() -> None: 28 | chars: str = '_' 29 | test_string: str = 'Some text without selected symbol' 30 | assert get_substring_after_chars(string=test_string, chars=chars) == test_string 31 | -------------------------------------------------------------------------------- /tests/homepage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/homepage/__init__.py -------------------------------------------------------------------------------- /tests/homepage/e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/homepage/e2e/__init__.py -------------------------------------------------------------------------------- /tests/homepage/e2e/test_homepage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import Response, AsyncClient 4 | 5 | from src.config import URLPathsConfig 6 | 7 | 8 | @pytest.mark.anyio 9 | async def test_homepage(async_client: AsyncClient) -> None: 10 | response: Response = await async_client.get(url=URLPathsConfig.HOMEPAGE) 11 | assert response.status_code == status.HTTP_303_SEE_OTHER 12 | assert response.has_redirect_location 13 | -------------------------------------------------------------------------------- /tests/security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/security/__init__.py -------------------------------------------------------------------------------- /tests/security/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/security/unit/__init__.py -------------------------------------------------------------------------------- /tests/security/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime, timezone 3 | 4 | from src.security.exceptions import InvalidTokenError 5 | from src.security.models import JWTDataModel 6 | from src.security.utils import parse_jwt_token, create_jwt_token 7 | 8 | 9 | @pytest.mark.anyio 10 | async def test_parse_jwt_token_fail_expired_token() -> None: 11 | jwt_data: JWTDataModel = JWTDataModel(user_id=1, exp=datetime.now(timezone.utc)) 12 | token: str = await create_jwt_token(jwt_data=jwt_data) 13 | with pytest.raises(InvalidTokenError): 14 | await parse_jwt_token(token=token) 15 | 16 | 17 | @pytest.mark.anyio 18 | async def test_parse_jwt_token_fail_incorrect_token() -> None: 19 | with pytest.raises(InvalidTokenError): 20 | await parse_jwt_token(token='someIncorrectToken') 21 | 22 | 23 | @pytest.mark.anyio 24 | async def test_parse_jwt_token_success() -> None: 25 | jwt_data: JWTDataModel = JWTDataModel(user_id=1) 26 | token: str = await create_jwt_token(jwt_data=jwt_data) 27 | assert await parse_jwt_token(token=token) 28 | -------------------------------------------------------------------------------- /tests/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/users/__init__.py -------------------------------------------------------------------------------- /tests/users/e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/users/e2e/__init__.py -------------------------------------------------------------------------------- /tests/users/e2e/test_dislike_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import Response, AsyncClient, Cookies 4 | from typing import Dict, Any, Optional 5 | from sqlalchemy import insert, CursorResult, RowMapping 6 | from sqlalchemy.ext.asyncio import AsyncConnection 7 | 8 | from src.users.models import UserModel, UserStatisticsModel 9 | from src.core.utils import get_substring_before_chars, get_substring_after_chars 10 | from src.users.config import RouterConfig, URLPathsConfig, cookies_config 11 | from src.users.constants import ErrorDetails 12 | from tests.utils import get_error_message_from_response 13 | 14 | 15 | @pytest.mark.anyio 16 | async def test_dislike_user_success( 17 | async_client: AsyncClient, 18 | create_test_user: None, 19 | cookies: Cookies, 20 | async_connection: AsyncConnection 21 | ) -> None: 22 | 23 | cursor: CursorResult = await async_connection.execute( 24 | insert( 25 | UserModel 26 | ).values( 27 | email='second_user_email', 28 | password='', 29 | username='second_user_username', 30 | ).returning( 31 | UserModel 32 | ) 33 | ) 34 | user_data: Optional[RowMapping] = cursor.mappings().fetchone() 35 | assert user_data is not None 36 | user: UserModel = UserModel(**user_data) 37 | await async_connection.execute(insert(UserStatisticsModel).values(user_id=user.id)) 38 | await async_connection.commit() 39 | 40 | dislike_user_url_prefix: str = get_substring_before_chars( 41 | chars='{', 42 | string=URLPathsConfig.DISLIKE_USER 43 | ) 44 | 45 | dislike_user_url_postfix: str = get_substring_after_chars( 46 | chars='}', 47 | string=URLPathsConfig.DISLIKE_USER 48 | ) 49 | 50 | response: Response = await async_client.patch( 51 | url=RouterConfig.PREFIX + dislike_user_url_prefix + '2' + dislike_user_url_postfix, 52 | cookies=cookies 53 | ) 54 | 55 | assert response.status_code == status.HTTP_200_OK 56 | 57 | response_content: Dict[str, Any] = response.json() 58 | assert response_content['likes'] == 0 59 | assert response_content['dislikes'] == 1 60 | 61 | 62 | @pytest.mark.anyio 63 | async def test_dislike_user_fail_user_not_authorized(async_client: AsyncClient, create_test_user: None) -> None: 64 | # Deleting cookies from async client, because if used as a "session" fixture: 65 | async_client.cookies.delete(cookies_config.COOKIES_KEY) 66 | 67 | dislike_user_url_prefix: str = get_substring_before_chars( 68 | chars='{', 69 | string=URLPathsConfig.DISLIKE_USER 70 | ) 71 | 72 | dislike_user_url_postfix: str = get_substring_after_chars( 73 | chars='}', 74 | string=URLPathsConfig.DISLIKE_USER 75 | ) 76 | 77 | response: Response = await async_client.patch( 78 | url=RouterConfig.PREFIX + dislike_user_url_prefix + '2' + dislike_user_url_postfix, 79 | ) 80 | 81 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 82 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_NOT_AUTHENTICATED 83 | 84 | 85 | @pytest.mark.anyio 86 | async def test_dislike_user_fail_voted_for_user_does_not_exists( 87 | async_client: AsyncClient, 88 | create_test_user: None, 89 | cookies: Cookies 90 | ) -> None: 91 | 92 | dislike_user_url_prefix: str = get_substring_before_chars( 93 | chars='{', 94 | string=URLPathsConfig.DISLIKE_USER 95 | ) 96 | 97 | dislike_user_url_postfix: str = get_substring_after_chars( 98 | chars='}', 99 | string=URLPathsConfig.DISLIKE_USER 100 | ) 101 | 102 | response: Response = await async_client.patch( 103 | url=RouterConfig.PREFIX + dislike_user_url_prefix + '2' + dislike_user_url_postfix, 104 | cookies=cookies 105 | ) 106 | 107 | assert response.status_code == status.HTTP_404_NOT_FOUND 108 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_STATISTICS_NOT_FOUND 109 | 110 | 111 | @pytest.mark.anyio 112 | async def test_dislike_user_fail_user_can_not_vote_for_himself( 113 | async_client: AsyncClient, 114 | create_test_user: None, 115 | cookies: Cookies 116 | ) -> None: 117 | 118 | dislike_user_url_prefix: str = get_substring_before_chars( 119 | chars='{', 120 | string=URLPathsConfig.DISLIKE_USER 121 | ) 122 | 123 | dislike_user_url_postfix: str = get_substring_after_chars( 124 | chars='}', 125 | string=URLPathsConfig.DISLIKE_USER 126 | ) 127 | 128 | response: Response = await async_client.patch( 129 | url=RouterConfig.PREFIX + dislike_user_url_prefix + '1' + dislike_user_url_postfix, 130 | cookies=cookies 131 | ) 132 | 133 | assert response.status_code == status.HTTP_400_BAD_REQUEST 134 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_CAN_NOT_VOTE_FOR_HIMSELF 135 | 136 | 137 | @pytest.mark.anyio 138 | async def test_dislike_user_fail_can_not_vote_more_than_one_time( 139 | async_client: AsyncClient, 140 | create_test_user: None, 141 | cookies: Cookies, 142 | async_connection: AsyncConnection 143 | ) -> None: 144 | cursor: CursorResult = await async_connection.execute( 145 | insert( 146 | UserModel 147 | ).values( 148 | email='second_user_email', 149 | password='', 150 | username='second_user_username', 151 | ).returning( 152 | UserModel 153 | ) 154 | ) 155 | user_data: Optional[RowMapping] = cursor.mappings().fetchone() 156 | assert user_data is not None 157 | user: UserModel = UserModel(**user_data) 158 | await async_connection.execute(insert(UserStatisticsModel).values(user_id=user.id)) 159 | await async_connection.commit() 160 | 161 | dislike_user_url_prefix: str = get_substring_before_chars( 162 | chars='{', 163 | string=URLPathsConfig.DISLIKE_USER 164 | ) 165 | 166 | dislike_user_url_postfix: str = get_substring_after_chars( 167 | chars='}', 168 | string=URLPathsConfig.DISLIKE_USER 169 | ) 170 | 171 | response: Response = await async_client.patch( 172 | url=RouterConfig.PREFIX + dislike_user_url_prefix + '2' + dislike_user_url_postfix, 173 | cookies=cookies 174 | ) 175 | 176 | assert response.status_code == status.HTTP_200_OK 177 | 178 | response_content: Dict[str, Any] = response.json() 179 | assert response_content['likes'] == 0 180 | assert response_content['dislikes'] == 1 181 | 182 | response = await async_client.patch( 183 | url=RouterConfig.PREFIX + dislike_user_url_prefix + '2' + dislike_user_url_postfix, 184 | cookies=cookies 185 | ) 186 | 187 | assert response.status_code == status.HTTP_400_BAD_REQUEST 188 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_ALREADY_VOTED 189 | -------------------------------------------------------------------------------- /tests/users/e2e/test_get_all_users.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import Response, AsyncClient 4 | from typing import Dict, Any, List 5 | 6 | from src.users.config import RouterConfig, URLPathsConfig 7 | from tests.config import FakeUserConfig 8 | 9 | 10 | @pytest.mark.anyio 11 | async def test_get_all_users_with_existing_user( 12 | async_client: AsyncClient, 13 | create_test_user: None, 14 | ) -> None: 15 | 16 | response: Response = await async_client.get(url=RouterConfig.PREFIX + URLPathsConfig.ALL) 17 | assert response.status_code == status.HTTP_200_OK 18 | 19 | response_content: List[Dict[str, Any]] = response.json() 20 | assert len(response_content) == 1 21 | user: Dict[str, Any] = response_content[0] 22 | assert user['id'] == 1 23 | assert user['email'] == FakeUserConfig.EMAIL 24 | assert user['username'] == FakeUserConfig.USERNAME 25 | 26 | 27 | @pytest.mark.anyio 28 | async def test_get_all_users_without_existing_users(async_client: AsyncClient, create_test_db: None) -> None: 29 | response: Response = await async_client.get(url=RouterConfig.PREFIX + URLPathsConfig.ALL) 30 | assert response.status_code == status.HTTP_200_OK 31 | 32 | response_content: List[Dict[str, Any]] = response.json() 33 | assert len(response_content) == 0 34 | -------------------------------------------------------------------------------- /tests/users/e2e/test_get_my_account.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import Response, AsyncClient, Cookies 4 | from typing import Dict, Any 5 | 6 | from src.users.config import RouterConfig, URLPathsConfig, cookies_config 7 | from src.users.constants import ErrorDetails 8 | from tests.config import FakeUserConfig 9 | from tests.utils import get_error_message_from_response 10 | 11 | 12 | @pytest.mark.anyio 13 | async def test_get_my_account_success( 14 | async_client: AsyncClient, 15 | create_test_user: None, 16 | cookies: Cookies 17 | ) -> None: 18 | 19 | response: Response = await async_client.get( 20 | url=RouterConfig.PREFIX + URLPathsConfig.ME, 21 | cookies=cookies 22 | ) 23 | 24 | assert response.status_code == status.HTTP_200_OK 25 | 26 | response_content: Dict[str, Any] = response.json() 27 | assert response_content['email'] == FakeUserConfig.EMAIL 28 | assert response_content['username'] == FakeUserConfig.USERNAME 29 | 30 | 31 | @pytest.mark.anyio 32 | async def test_get_my_account_fail(async_client: AsyncClient, create_test_user: None) -> None: 33 | # Deleting cookies from async client, because if used as a "session" fixture: 34 | async_client.cookies.delete(cookies_config.COOKIES_KEY) 35 | 36 | response: Response = await async_client.get(url=RouterConfig.PREFIX + URLPathsConfig.ME) 37 | 38 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 39 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_NOT_AUTHENTICATED 40 | -------------------------------------------------------------------------------- /tests/users/e2e/test_get_my_statistics.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import Response, AsyncClient, Cookies 4 | from typing import Dict, Any 5 | 6 | from src.users.config import RouterConfig, URLPathsConfig, cookies_config 7 | from src.users.constants import ErrorDetails 8 | from tests.utils import get_error_message_from_response 9 | 10 | 11 | @pytest.mark.anyio 12 | async def test_get_my_statistics_success( 13 | async_client: AsyncClient, 14 | create_test_user: None, 15 | cookies: Cookies 16 | ) -> None: 17 | 18 | response: Response = await async_client.get( 19 | url=RouterConfig.PREFIX + URLPathsConfig.MY_STATS, 20 | cookies=cookies 21 | ) 22 | 23 | assert response.status_code == status.HTTP_200_OK 24 | 25 | response_content: Dict[str, Any] = response.json() 26 | assert response_content['likes'] == 0 27 | assert response_content['dislikes'] == 0 28 | 29 | 30 | @pytest.mark.anyio 31 | async def test_get_my_statistics_fail(async_client: AsyncClient, create_test_user: None) -> None: 32 | # Deleting cookies from async client, because if used as a "session" fixture: 33 | async_client.cookies.delete(cookies_config.COOKIES_KEY) 34 | 35 | response: Response = await async_client.get(url=RouterConfig.PREFIX + URLPathsConfig.MY_STATS) 36 | 37 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 38 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_NOT_AUTHENTICATED 39 | -------------------------------------------------------------------------------- /tests/users/e2e/test_like_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import Response, AsyncClient, Cookies 4 | from typing import Dict, Any, Optional 5 | from sqlalchemy import insert, CursorResult, RowMapping 6 | from sqlalchemy.ext.asyncio import AsyncConnection 7 | 8 | from src.users.models import UserModel, UserStatisticsModel 9 | from src.core.utils import get_substring_before_chars, get_substring_after_chars 10 | from src.users.config import RouterConfig, URLPathsConfig, cookies_config 11 | from src.users.constants import ErrorDetails 12 | from tests.utils import get_error_message_from_response 13 | 14 | 15 | @pytest.mark.anyio 16 | async def test_like_user_success( 17 | async_client: AsyncClient, 18 | create_test_user: None, 19 | cookies: Cookies, 20 | async_connection: AsyncConnection 21 | ) -> None: 22 | 23 | cursor: CursorResult = await async_connection.execute( 24 | insert( 25 | UserModel 26 | ).values( 27 | email='second_user_email', 28 | password='', 29 | username='second_user_username', 30 | ).returning( 31 | UserModel 32 | ) 33 | ) 34 | user_data: Optional[RowMapping] = cursor.mappings().fetchone() 35 | assert user_data is not None 36 | user: UserModel = UserModel(**user_data) 37 | await async_connection.execute(insert(UserStatisticsModel).values(user_id=user.id)) 38 | await async_connection.commit() 39 | 40 | like_user_url_prefix: str = get_substring_before_chars( 41 | chars='{', 42 | string=URLPathsConfig.LIKE_USER 43 | ) 44 | 45 | like_user_url_postfix: str = get_substring_after_chars( 46 | chars='}', 47 | string=URLPathsConfig.LIKE_USER 48 | ) 49 | 50 | response: Response = await async_client.patch( 51 | url=RouterConfig.PREFIX + like_user_url_prefix + '2' + like_user_url_postfix, 52 | cookies=cookies 53 | ) 54 | 55 | assert response.status_code == status.HTTP_200_OK 56 | 57 | response_content: Dict[str, Any] = response.json() 58 | assert response_content['likes'] == 1 59 | assert response_content['dislikes'] == 0 60 | 61 | 62 | @pytest.mark.anyio 63 | async def test_like_user_fail_user_not_authorized(async_client: AsyncClient, create_test_user: None) -> None: 64 | # Deleting cookies from async client, because if used as a "session" fixture: 65 | async_client.cookies.delete(cookies_config.COOKIES_KEY) 66 | 67 | like_user_url_prefix: str = get_substring_before_chars( 68 | chars='{', 69 | string=URLPathsConfig.LIKE_USER 70 | ) 71 | 72 | like_user_url_postfix: str = get_substring_after_chars( 73 | chars='}', 74 | string=URLPathsConfig.LIKE_USER 75 | ) 76 | 77 | response: Response = await async_client.patch( 78 | url=RouterConfig.PREFIX + like_user_url_prefix + '2' + like_user_url_postfix, 79 | ) 80 | 81 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 82 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_NOT_AUTHENTICATED 83 | 84 | 85 | @pytest.mark.anyio 86 | async def test_like_user_fail_voted_for_user_does_not_exists( 87 | async_client: AsyncClient, 88 | create_test_user: None, 89 | cookies: Cookies 90 | ) -> None: 91 | 92 | like_user_url_prefix: str = get_substring_before_chars( 93 | chars='{', 94 | string=URLPathsConfig.LIKE_USER 95 | ) 96 | 97 | like_user_url_postfix: str = get_substring_after_chars( 98 | chars='}', 99 | string=URLPathsConfig.LIKE_USER 100 | ) 101 | 102 | response: Response = await async_client.patch( 103 | url=RouterConfig.PREFIX + like_user_url_prefix + '2' + like_user_url_postfix, 104 | cookies=cookies 105 | ) 106 | 107 | assert response.status_code == status.HTTP_404_NOT_FOUND 108 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_STATISTICS_NOT_FOUND 109 | 110 | 111 | @pytest.mark.anyio 112 | async def test_like_user_fail_user_can_not_vote_for_himself( 113 | async_client: AsyncClient, 114 | create_test_user: None, 115 | cookies: Cookies 116 | ) -> None: 117 | 118 | like_user_url_prefix: str = get_substring_before_chars( 119 | chars='{', 120 | string=URLPathsConfig.LIKE_USER 121 | ) 122 | 123 | like_user_url_postfix: str = get_substring_after_chars( 124 | chars='}', 125 | string=URLPathsConfig.LIKE_USER 126 | ) 127 | 128 | response: Response = await async_client.patch( 129 | url=RouterConfig.PREFIX + like_user_url_prefix + '1' + like_user_url_postfix, 130 | cookies=cookies 131 | ) 132 | 133 | assert response.status_code == status.HTTP_400_BAD_REQUEST 134 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_CAN_NOT_VOTE_FOR_HIMSELF 135 | 136 | 137 | @pytest.mark.anyio 138 | async def test_like_user_fail_can_not_vote_more_than_one_time( 139 | async_client: AsyncClient, 140 | create_test_user: None, 141 | cookies: Cookies, 142 | async_connection: AsyncConnection 143 | ) -> None: 144 | 145 | cursor: CursorResult = await async_connection.execute( 146 | insert( 147 | UserModel 148 | ).values( 149 | email='second_user_email', 150 | password='', 151 | username='second_user_username', 152 | ).returning( 153 | UserModel 154 | ) 155 | ) 156 | user_data: Optional[RowMapping] = cursor.mappings().fetchone() 157 | assert user_data is not None 158 | user: UserModel = UserModel(**user_data) 159 | await async_connection.execute(insert(UserStatisticsModel).values(user_id=user.id)) 160 | await async_connection.commit() 161 | 162 | like_user_url_prefix: str = get_substring_before_chars( 163 | chars='{', 164 | string=URLPathsConfig.LIKE_USER 165 | ) 166 | 167 | like_user_url_postfix: str = get_substring_after_chars( 168 | chars='}', 169 | string=URLPathsConfig.LIKE_USER 170 | ) 171 | 172 | response: Response = await async_client.patch( 173 | url=RouterConfig.PREFIX + like_user_url_prefix + '2' + like_user_url_postfix, 174 | cookies=cookies 175 | ) 176 | 177 | assert response.status_code == status.HTTP_200_OK 178 | 179 | response_content: Dict[str, Any] = response.json() 180 | assert response_content['likes'] == 1 181 | assert response_content['dislikes'] == 0 182 | 183 | response = await async_client.patch( 184 | url=RouterConfig.PREFIX + like_user_url_prefix + '2' + like_user_url_postfix, 185 | cookies=cookies 186 | ) 187 | 188 | assert response.status_code == status.HTTP_400_BAD_REQUEST 189 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_ALREADY_VOTED 190 | -------------------------------------------------------------------------------- /tests/users/e2e/test_login.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import Response, AsyncClient 4 | 5 | from src.users.config import RouterConfig, URLPathsConfig, cookies_config 6 | from src.users.constants import ErrorDetails 7 | from tests.config import FakeUserConfig 8 | from tests.utils import get_error_message_from_response 9 | 10 | 11 | @pytest.mark.anyio 12 | async def test_login_by_email_success(async_client: AsyncClient, create_test_user: None) -> None: 13 | response: Response = await async_client.post( 14 | url=RouterConfig.PREFIX + URLPathsConfig.LOGIN, 15 | json={ 16 | 'username': FakeUserConfig.EMAIL, 17 | 'password': FakeUserConfig.PASSWORD 18 | } 19 | ) 20 | 21 | assert response.status_code == status.HTTP_200_OK 22 | assert response.cookies.get(cookies_config.COOKIES_KEY) 23 | 24 | 25 | @pytest.mark.anyio 26 | async def test_login_by_username_success(async_client: AsyncClient, create_test_user: None) -> None: 27 | response: Response = await async_client.post( 28 | url=RouterConfig.PREFIX + URLPathsConfig.LOGIN, 29 | json={ 30 | 'username': FakeUserConfig.USERNAME, 31 | 'password': FakeUserConfig.PASSWORD 32 | } 33 | ) 34 | 35 | assert response.status_code == status.HTTP_200_OK 36 | assert response.cookies.get(cookies_config.COOKIES_KEY) 37 | 38 | 39 | @pytest.mark.anyio 40 | async def test_login_fail_user_not_found(async_client: AsyncClient, create_test_db: None) -> None: 41 | response: Response = await async_client.post( 42 | url=RouterConfig.PREFIX + URLPathsConfig.LOGIN, 43 | json=FakeUserConfig().to_dict(to_lower=True) 44 | ) 45 | 46 | assert response.status_code == status.HTTP_404_NOT_FOUND 47 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_NOT_FOUND 48 | 49 | 50 | @pytest.mark.anyio 51 | async def test_login_fail_incorrect_password(async_client: AsyncClient, create_test_user: None) -> None: 52 | test_user_config: FakeUserConfig = FakeUserConfig() 53 | test_user_config.PASSWORD = 'incorrectPassword' 54 | response: Response = await async_client.post( 55 | url=RouterConfig.PREFIX + URLPathsConfig.LOGIN, 56 | json=test_user_config.to_dict(to_lower=True) 57 | ) 58 | 59 | assert response.status_code == status.HTTP_412_PRECONDITION_FAILED 60 | assert get_error_message_from_response(response=response) == ErrorDetails.INVALID_PASSWORD 61 | -------------------------------------------------------------------------------- /tests/users/e2e/test_logout.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import Response, AsyncClient, Cookies 4 | 5 | from src.users.config import RouterConfig, URLPathsConfig, cookies_config 6 | 7 | 8 | @pytest.mark.anyio 9 | async def test_logout(async_client: AsyncClient, cookies: Cookies) -> None: 10 | response: Response = await async_client.get( 11 | url=RouterConfig.PREFIX + URLPathsConfig.LOGOUT, 12 | cookies=cookies 13 | ) 14 | 15 | assert response.status_code == status.HTTP_200_OK 16 | assert not response.cookies.get(cookies_config.COOKIES_KEY) 17 | -------------------------------------------------------------------------------- /tests/users/e2e/test_register.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import Response, AsyncClient 4 | 5 | from src.users.config import RouterConfig, URLPathsConfig, UserValidationConfig 6 | from src.users.constants import ErrorDetails 7 | from src.users.models import UserModel 8 | from tests.utils import get_error_message_from_response, generate_random_string 9 | from tests.config import FakeUserConfig 10 | 11 | 12 | @pytest.mark.anyio 13 | async def test_register_fail_incorrect_email_pattern(async_client: AsyncClient) -> None: 14 | test_user_config: FakeUserConfig = FakeUserConfig() 15 | test_user_config.EMAIL = '' 16 | response: Response = await async_client.post( 17 | url=RouterConfig.PREFIX + URLPathsConfig.REGISTER, 18 | json=test_user_config.to_dict(to_lower=True) 19 | ) 20 | 21 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 22 | 23 | expected_error_message: str = ('value is not a valid email address: ' 24 | 'An email address must have an @-sign.') 25 | assert get_error_message_from_response(response=response) == expected_error_message 26 | 27 | 28 | @pytest.mark.anyio 29 | async def test_register_fail_too_short_username(async_client: AsyncClient) -> None: 30 | test_user_config: FakeUserConfig = FakeUserConfig() 31 | test_user_config.USERNAME = generate_random_string(length=UserValidationConfig.USERNAME_MIN_LENGTH - 1) 32 | response: Response = await async_client.post( 33 | url=RouterConfig.PREFIX + URLPathsConfig.REGISTER, 34 | json=test_user_config.to_dict(to_lower=True) 35 | ) 36 | 37 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 38 | assert get_error_message_from_response(response=response) == ErrorDetails.USERNAME_VALIDATION_ERROR 39 | 40 | 41 | @pytest.mark.anyio 42 | async def test_register_fail_too_long_username(async_client: AsyncClient) -> None: 43 | test_user_config: FakeUserConfig = FakeUserConfig() 44 | test_user_config.USERNAME = generate_random_string(length=UserValidationConfig.USERNAME_MAX_LENGTH + 1) 45 | response: Response = await async_client.post( 46 | url=RouterConfig.PREFIX + URLPathsConfig.REGISTER, 47 | json=test_user_config.to_dict(to_lower=True) 48 | ) 49 | 50 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 51 | assert get_error_message_from_response(response=response) == ErrorDetails.USERNAME_VALIDATION_ERROR 52 | 53 | 54 | @pytest.mark.anyio 55 | async def test_register_fail_too_short_password(async_client: AsyncClient) -> None: 56 | test_user_config: FakeUserConfig = FakeUserConfig() 57 | test_user_config.PASSWORD = generate_random_string(length=UserValidationConfig.PASSWORD_MIN_LENGTH - 1) 58 | response: Response = await async_client.post( 59 | url=RouterConfig.PREFIX + URLPathsConfig.REGISTER, 60 | json=test_user_config.to_dict(to_lower=True) 61 | ) 62 | 63 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 64 | assert get_error_message_from_response(response=response) == ErrorDetails.PASSWORD_VALIDATION_ERROR 65 | 66 | 67 | @pytest.mark.anyio 68 | async def test_register_fail_too_long_password(async_client: AsyncClient) -> None: 69 | test_user_config: FakeUserConfig = FakeUserConfig() 70 | test_user_config.PASSWORD = generate_random_string(length=UserValidationConfig.PASSWORD_MAX_LENGTH + 1) 71 | response: Response = await async_client.post( 72 | url=RouterConfig.PREFIX + URLPathsConfig.REGISTER, 73 | json=test_user_config.to_dict(to_lower=True) 74 | ) 75 | 76 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 77 | assert get_error_message_from_response(response=response) == ErrorDetails.PASSWORD_VALIDATION_ERROR 78 | 79 | 80 | @pytest.mark.anyio 81 | async def test_register_success(async_client: AsyncClient) -> None: 82 | response: Response = await async_client.post( 83 | url=RouterConfig.PREFIX + URLPathsConfig.REGISTER, 84 | json=FakeUserConfig().to_dict(to_lower=True) 85 | ) 86 | 87 | assert response.status_code == status.HTTP_201_CREATED 88 | user: UserModel = UserModel(**response.json()) 89 | assert user.email == FakeUserConfig.EMAIL 90 | assert user.username == FakeUserConfig.USERNAME 91 | 92 | 93 | @pytest.mark.anyio 94 | async def test_register_fail_email_already_taken( 95 | async_client: AsyncClient, 96 | create_test_user: None 97 | ) -> None: 98 | 99 | test_user_config: FakeUserConfig = FakeUserConfig() 100 | test_user_config.USERNAME = 'some_new_username' 101 | response: Response = await async_client.post( 102 | url=RouterConfig.PREFIX + URLPathsConfig.REGISTER, 103 | json=test_user_config.to_dict(to_lower=True) 104 | ) 105 | 106 | assert response.status_code == status.HTTP_409_CONFLICT 107 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_ALREADY_EXISTS 108 | 109 | 110 | @pytest.mark.anyio 111 | async def test_register_fail_username_already_taken( 112 | async_client: AsyncClient, 113 | create_test_user: None 114 | ) -> None: 115 | 116 | test_user_config: FakeUserConfig = FakeUserConfig() 117 | test_user_config.EMAIL = 'some_new_email@mail.ru' 118 | response: Response = await async_client.post( 119 | url=RouterConfig.PREFIX + URLPathsConfig.REGISTER, 120 | json=test_user_config.to_dict(to_lower=True) 121 | ) 122 | 123 | assert response.status_code == status.HTTP_409_CONFLICT 124 | assert get_error_message_from_response(response=response) == ErrorDetails.USER_ALREADY_EXISTS 125 | -------------------------------------------------------------------------------- /tests/users/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/users/integration/__init__.py -------------------------------------------------------------------------------- /tests/users/integration/test_dependencies.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime, timezone 3 | from typing import List, Optional 4 | 5 | from sqlalchemy import insert, RowMapping, CursorResult 6 | from sqlalchemy.ext.asyncio import AsyncConnection 7 | 8 | from src.users.exceptions import ( 9 | UserAlreadyExistsError, 10 | InvalidPasswordError, 11 | UserNotFoundError, 12 | UserCanNotVoteForHimSelf, 13 | UserAlreadyVotedError, 14 | UserStatisticsNotFoundError, 15 | ) 16 | from src.security.exceptions import InvalidTokenError 17 | from src.users.models import UserModel, UserStatisticsModel 18 | from src.security.models import JWTDataModel 19 | from src.users.schemas import RegisterUserScheme, LoginUserScheme 20 | from src.security.utils import create_jwt_token 21 | from tests.config import FakeUserConfig 22 | from src.users.dependencies import ( 23 | register_user, 24 | authenticate_user, 25 | verify_user_credentials, 26 | get_all_users, 27 | dislike_user, 28 | like_user, 29 | get_my_statistics 30 | ) 31 | 32 | 33 | @pytest.mark.anyio 34 | async def test_register_user_success(create_test_db: None) -> None: 35 | user_data: RegisterUserScheme = RegisterUserScheme(**FakeUserConfig().to_dict(to_lower=True)) 36 | user: UserModel = await register_user(user_data=user_data) 37 | 38 | assert user.id == 1 39 | assert user.username == FakeUserConfig.USERNAME 40 | assert user.email == FakeUserConfig.EMAIL 41 | 42 | 43 | @pytest.mark.anyio 44 | async def test_register_user_fail(create_test_user: None) -> None: 45 | user_data: RegisterUserScheme = RegisterUserScheme(**FakeUserConfig().to_dict(to_lower=True)) 46 | with pytest.raises(UserAlreadyExistsError): 47 | await register_user(user_data=user_data) 48 | 49 | 50 | @pytest.mark.anyio 51 | async def test_verify_user_credentials_by_username_success(create_test_user: None) -> None: 52 | user_data: LoginUserScheme = LoginUserScheme(username=FakeUserConfig.USERNAME, password=FakeUserConfig.PASSWORD) 53 | user: UserModel = await verify_user_credentials(user_data=user_data) 54 | 55 | assert user.id == 1 56 | assert user.username == FakeUserConfig.USERNAME 57 | assert user.email == FakeUserConfig.EMAIL 58 | 59 | 60 | @pytest.mark.anyio 61 | async def test_verify_user_credentials_by_email_success(create_test_user: None) -> None: 62 | user_data: LoginUserScheme = LoginUserScheme(username=FakeUserConfig.EMAIL, password=FakeUserConfig.PASSWORD) 63 | user: UserModel = await verify_user_credentials(user_data=user_data) 64 | 65 | assert user.id == 1 66 | assert user.username == FakeUserConfig.USERNAME 67 | assert user.email == FakeUserConfig.EMAIL 68 | 69 | 70 | @pytest.mark.anyio 71 | async def test_verify_user_credentials_fail_user_does_not_exist(create_test_db: None) -> None: 72 | user_data: LoginUserScheme = LoginUserScheme(**FakeUserConfig().to_dict(to_lower=True)) 73 | with pytest.raises(UserNotFoundError): 74 | await verify_user_credentials(user_data=user_data) 75 | 76 | 77 | @pytest.mark.anyio 78 | async def test_verify_user_credentials_fail_incorrect_password(create_test_user: None) -> None: 79 | user_data: LoginUserScheme = LoginUserScheme(**FakeUserConfig().to_dict(to_lower=True)) 80 | user_data.password = 'some_incorrect_password' 81 | with pytest.raises(InvalidPasswordError): 82 | await verify_user_credentials(user_data=user_data) 83 | 84 | 85 | @pytest.mark.anyio 86 | async def test_authenticate_user_success(create_test_db: None, access_token: str) -> None: 87 | user: UserModel = await authenticate_user(token=access_token) 88 | assert user.email == FakeUserConfig.EMAIL 89 | assert user.username == FakeUserConfig.USERNAME 90 | 91 | 92 | @pytest.mark.anyio 93 | async def test_authenticate_user_fail_invalid_token(create_test_db: None) -> None: 94 | with pytest.raises(InvalidTokenError): 95 | await authenticate_user(token='someInvalidToken') 96 | 97 | 98 | @pytest.mark.anyio 99 | async def test_authenticate_user_fail_token_expired(create_test_db: None) -> None: 100 | jwt_data: JWTDataModel = JWTDataModel(user_id=1, exp=datetime.now(timezone.utc)) 101 | token: str = await create_jwt_token(jwt_data=jwt_data) 102 | with pytest.raises(InvalidTokenError): 103 | await authenticate_user(token=token) 104 | 105 | 106 | @pytest.mark.anyio 107 | async def test_authenticate_user_fail_user_does_not_exist(create_test_db: None) -> None: 108 | jwt_data: JWTDataModel = JWTDataModel(user_id=1) 109 | token: str = await create_jwt_token(jwt_data=jwt_data) 110 | with pytest.raises(UserNotFoundError): 111 | await authenticate_user(token=token) 112 | 113 | 114 | @pytest.mark.anyio 115 | async def test_get_all_users_with_existing_user(create_test_user: None) -> None: 116 | users: List[UserModel] = await get_all_users() 117 | assert len(users) == 1 118 | user: UserModel = users[0] 119 | assert user.id == 1 120 | assert user.username == FakeUserConfig.USERNAME 121 | assert user.email == FakeUserConfig.EMAIL 122 | 123 | 124 | @pytest.mark.anyio 125 | async def test_get_all_users_without_existing_users(create_test_db: None) -> None: 126 | users: List[UserModel] = await get_all_users() 127 | assert len(users) == 0 128 | 129 | 130 | @pytest.mark.anyio 131 | async def test_get_my_statistics_success(create_test_user: None) -> None: 132 | statistics: UserStatisticsModel = await get_my_statistics( 133 | user=UserModel( 134 | id=1, 135 | **FakeUserConfig().to_dict(to_lower=True) 136 | ) 137 | ) 138 | assert statistics.likes == 0 139 | assert statistics.dislikes == 0 140 | 141 | 142 | @pytest.mark.anyio 143 | async def test_get_my_statistics_fail_user_does_not_exist(create_test_db: None) -> None: 144 | with pytest.raises(UserStatisticsNotFoundError): 145 | await get_my_statistics( 146 | user=UserModel( 147 | id=1, 148 | **FakeUserConfig().to_dict(to_lower=True) 149 | ) 150 | ) 151 | 152 | 153 | @pytest.mark.anyio 154 | async def test_like_user_success(create_test_user: None, async_connection: AsyncConnection) -> None: 155 | cursor: CursorResult = await async_connection.execute( 156 | insert( 157 | UserModel 158 | ).values( 159 | email='second_user_email', 160 | password='', 161 | username='second_user_username', 162 | ).returning( 163 | UserModel 164 | ) 165 | ) 166 | user_data: Optional[RowMapping] = cursor.mappings().fetchone() 167 | assert user_data is not None 168 | user: UserModel = UserModel(**user_data) 169 | await async_connection.execute(insert(UserStatisticsModel).values(user_id=user.id)) 170 | await async_connection.commit() 171 | 172 | statistics: UserStatisticsModel = await like_user( 173 | user_id=user.id, 174 | user=UserModel( 175 | id=1, 176 | **FakeUserConfig().to_dict(to_lower=True) 177 | ) 178 | ) 179 | assert statistics.likes == 1 180 | assert statistics.dislikes == 0 181 | 182 | 183 | @pytest.mark.anyio 184 | async def test_like_user_fail_can_not_vote_more_than_one_time( 185 | create_test_user: None, 186 | async_connection: AsyncConnection 187 | ) -> None: 188 | 189 | cursor: CursorResult = await async_connection.execute( 190 | insert( 191 | UserModel 192 | ).values( 193 | email='second_user_email', 194 | password='', 195 | username='second_user_username', 196 | ).returning( 197 | UserModel 198 | ) 199 | ) 200 | user_data: Optional[RowMapping] = cursor.mappings().fetchone() 201 | assert user_data is not None 202 | user: UserModel = UserModel(**user_data) 203 | await async_connection.execute(insert(UserStatisticsModel).values(user_id=user.id)) 204 | await async_connection.commit() 205 | 206 | await like_user( 207 | user_id=user.id, 208 | user=UserModel( 209 | id=1, 210 | **FakeUserConfig().to_dict(to_lower=True) 211 | ) 212 | ) 213 | 214 | with pytest.raises(UserAlreadyVotedError): 215 | await like_user( 216 | user_id=user.id, 217 | user=UserModel( 218 | id=1, 219 | **FakeUserConfig().to_dict(to_lower=True) 220 | ) 221 | ) 222 | 223 | 224 | @pytest.mark.anyio 225 | async def test_like_user_fail_can_not_for_himself(create_test_user: None) -> None: 226 | with pytest.raises(UserCanNotVoteForHimSelf): 227 | await like_user( 228 | user_id=1, 229 | user=UserModel( 230 | id=1, 231 | **FakeUserConfig().to_dict(to_lower=True) 232 | ) 233 | ) 234 | 235 | 236 | @pytest.mark.anyio 237 | async def test_dislike_user_success(create_test_user: None, async_connection: AsyncConnection) -> None: 238 | cursor: CursorResult = await async_connection.execute( 239 | insert( 240 | UserModel 241 | ).values( 242 | email='second_user_email', 243 | password='', 244 | username='second_user_username', 245 | ).returning( 246 | UserModel 247 | ) 248 | ) 249 | user_data: Optional[RowMapping] = cursor.mappings().fetchone() 250 | assert user_data is not None 251 | user: UserModel = UserModel(**user_data) 252 | await async_connection.execute(insert(UserStatisticsModel).values(user_id=user.id)) 253 | await async_connection.commit() 254 | 255 | statistics: UserStatisticsModel = await dislike_user( 256 | user_id=user.id, 257 | user=UserModel( 258 | id=1, 259 | **FakeUserConfig().to_dict(to_lower=True) 260 | ) 261 | ) 262 | assert statistics.likes == 0 263 | assert statistics.dislikes == 1 264 | 265 | 266 | @pytest.mark.anyio 267 | async def test_dislike_user_fail_can_not_vote_more_than_one_time( 268 | create_test_user: None, 269 | async_connection: AsyncConnection 270 | ) -> None: 271 | 272 | cursor: CursorResult = await async_connection.execute( 273 | insert( 274 | UserModel 275 | ).values( 276 | email='second_user_email', 277 | password='', 278 | username='second_user_username', 279 | ).returning( 280 | UserModel 281 | ) 282 | ) 283 | user_data: Optional[RowMapping] = cursor.mappings().fetchone() 284 | assert user_data is not None 285 | user: UserModel = UserModel(**user_data) 286 | await async_connection.execute(insert(UserStatisticsModel).values(user_id=user.id)) 287 | await async_connection.commit() 288 | 289 | await dislike_user( 290 | user_id=user.id, 291 | user=UserModel( 292 | id=1, 293 | **FakeUserConfig().to_dict(to_lower=True) 294 | ) 295 | ) 296 | 297 | with pytest.raises(UserAlreadyVotedError): 298 | await dislike_user( 299 | user_id=user.id, 300 | user=UserModel( 301 | id=1, 302 | **FakeUserConfig().to_dict(to_lower=True) 303 | ) 304 | ) 305 | 306 | 307 | @pytest.mark.anyio 308 | async def test_dislike_user_fail_can_not_for_himself(create_test_user: None) -> None: 309 | with pytest.raises(UserCanNotVoteForHimSelf): 310 | await dislike_user( 311 | user_id=1, 312 | user=UserModel( 313 | id=1, 314 | **FakeUserConfig().to_dict(to_lower=True) 315 | ) 316 | ) 317 | -------------------------------------------------------------------------------- /tests/users/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DKhorkov/DDD-and-FastAPI/3d0d6f60f4f3465f52dae024446ba1b30a9ab362/tests/users/unit/__init__.py -------------------------------------------------------------------------------- /tests/users/unit/test_service.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typing import Optional, List 3 | from sqlalchemy import select, insert, CursorResult, Row 4 | from sqlalchemy.exc import IntegrityError 5 | from sqlalchemy.ext.asyncio import AsyncConnection 6 | 7 | from src.users.constants import ErrorDetails 8 | from src.users.exceptions import UserNotFoundError, UserStatisticsNotFoundError 9 | from src.users.service import UsersService 10 | from src.users.models import UserModel, UserStatisticsModel, UserVoteModel 11 | from tests.config import FakeUserConfig 12 | 13 | 14 | @pytest.mark.anyio 15 | async def test_users_service_get_user_by_id_success(create_test_user: None) -> None: 16 | user: Optional[UserModel] = await UsersService().get_user_by_id(id=1) 17 | 18 | assert user is not None 19 | assert user.id == 1 20 | assert user.email == FakeUserConfig.EMAIL 21 | assert user.username == FakeUserConfig.USERNAME 22 | 23 | 24 | @pytest.mark.anyio 25 | async def test_users_service_get_user_by_id_fail(create_test_db: None) -> None: 26 | with pytest.raises(UserNotFoundError): 27 | await UsersService().get_user_by_id(id=1) 28 | 29 | 30 | @pytest.mark.anyio 31 | async def test_users_service_get_user_by_email_success(create_test_user: None) -> None: 32 | user: Optional[UserModel] = await UsersService().get_user_by_email( 33 | email=FakeUserConfig.EMAIL 34 | ) 35 | 36 | assert user is not None 37 | assert user.id == 1 38 | assert user.email == FakeUserConfig.EMAIL 39 | assert user.username == FakeUserConfig.USERNAME 40 | 41 | 42 | @pytest.mark.anyio 43 | async def test_users_service_get_user_by_email_fail(create_test_db: None) -> None: 44 | with pytest.raises(UserNotFoundError): 45 | await UsersService().get_user_by_email(email=FakeUserConfig.EMAIL) 46 | 47 | 48 | @pytest.mark.anyio 49 | async def test_users_service_get_user_by_username_success(create_test_user: None) -> None: 50 | user: Optional[UserModel] = await UsersService().get_user_by_username( 51 | username=FakeUserConfig.USERNAME 52 | ) 53 | 54 | assert user is not None 55 | assert user.id == 1 56 | assert user.email == FakeUserConfig.EMAIL 57 | assert user.username == FakeUserConfig.USERNAME 58 | 59 | 60 | @pytest.mark.anyio 61 | async def test_users_service_get_user_by_username_fail(create_test_db: None) -> None: 62 | with pytest.raises(UserNotFoundError): 63 | await UsersService().get_user_by_username(username=FakeUserConfig.USERNAME) 64 | 65 | 66 | @pytest.mark.anyio 67 | async def test_users_service_get_all_users_with_existing_users(create_test_user: None) -> None: 68 | users_list: List[UserModel] = await UsersService().get_all_users() 69 | assert len(users_list) == 1 70 | 71 | user: UserModel = users_list[0] 72 | assert user.id == 1 73 | assert user.email == FakeUserConfig.EMAIL 74 | assert user.username == FakeUserConfig.USERNAME 75 | 76 | 77 | @pytest.mark.anyio 78 | async def test_users_service_get_all_users_without_existing_users(create_test_db: None) -> None: 79 | users_list: List[UserModel] = await UsersService().get_all_users() 80 | assert len(users_list) == 0 81 | 82 | 83 | @pytest.mark.anyio 84 | async def test_users_service_register_user_success( 85 | create_test_db: None, 86 | async_connection: AsyncConnection 87 | ) -> None: 88 | 89 | cursor: CursorResult = await async_connection.execute(select(UserModel).filter_by(email=FakeUserConfig.EMAIL)) 90 | result: Optional[Row] = cursor.first() 91 | assert not result 92 | 93 | user: UserModel = UserModel(**FakeUserConfig().to_dict(to_lower=True)) 94 | user = await UsersService().register_user(user=user) 95 | assert user.id == 1 96 | assert user.email == FakeUserConfig.EMAIL 97 | assert user.username == FakeUserConfig.USERNAME 98 | 99 | cursor = await async_connection.execute(select(UserModel).filter_by(email=FakeUserConfig.EMAIL)) 100 | result = cursor.first() 101 | assert result 102 | 103 | cursor = await async_connection.execute(select(UserStatisticsModel).filter_by(user_id=1)) 104 | result = cursor.first() 105 | assert result 106 | 107 | 108 | @pytest.mark.anyio 109 | async def test_users_service_register_user_fail_username_already_exists( 110 | create_test_user: None, 111 | async_connection: AsyncConnection 112 | ) -> None: 113 | 114 | cursor: CursorResult = await async_connection.execute(select(UserModel).filter_by(username=FakeUserConfig.USERNAME)) 115 | result: Optional[Row] = cursor.first() 116 | assert result 117 | 118 | user: UserModel = UserModel( 119 | username=FakeUserConfig.USERNAME, 120 | email='someTestEmail@gmail.com', 121 | password=FakeUserConfig.PASSWORD 122 | ) 123 | 124 | with pytest.raises(IntegrityError): 125 | await UsersService().register_user(user=user) 126 | 127 | 128 | @pytest.mark.anyio 129 | async def test_users_service_register_user_fail_email_already_exists( 130 | create_test_user: None, 131 | async_connection: AsyncConnection 132 | ) -> None: 133 | 134 | cursor: CursorResult = await async_connection.execute(select(UserModel).filter_by(email=FakeUserConfig.EMAIL)) 135 | result: Optional[Row] = cursor.first() 136 | assert result 137 | 138 | user: UserModel = UserModel( 139 | email=FakeUserConfig.EMAIL, 140 | username='someUsername', 141 | password=FakeUserConfig.PASSWORD 142 | ) 143 | 144 | with pytest.raises(IntegrityError): 145 | await UsersService().register_user(user=user) 146 | 147 | 148 | @pytest.mark.anyio 149 | async def test_users_service_check_user_existence_success_by_id(create_test_user: None) -> None: 150 | assert await UsersService().check_user_existence(id=1) 151 | 152 | 153 | @pytest.mark.anyio 154 | async def test_users_service_check_user_existence_success_by_email(create_test_user: None) -> None: 155 | assert await UsersService().check_user_existence(email=FakeUserConfig.EMAIL) 156 | 157 | 158 | @pytest.mark.anyio 159 | async def test_users_service_check_user_existence_success_by_username(create_test_user: None) -> None: 160 | assert await UsersService().check_user_existence(username=FakeUserConfig.USERNAME) 161 | 162 | 163 | @pytest.mark.anyio 164 | async def test_users_service_check_user_existence_fail_user_does_not_exist(create_test_db: None) -> None: 165 | assert not await UsersService().check_user_existence(username=FakeUserConfig.USERNAME) 166 | 167 | 168 | @pytest.mark.anyio 169 | async def test_users_service_check_user_existence_fail_no_attributes_provided(create_test_db: None) -> None: 170 | with pytest.raises(ValueError) as exc_info: 171 | await UsersService().check_user_existence() 172 | 173 | assert str(exc_info.value) == ErrorDetails.USER_ATTRIBUTE_REQUIRED 174 | 175 | 176 | @pytest.mark.anyio 177 | async def test_get_user_statistics_by_user_id_success(create_test_user: None) -> None: 178 | user_statistics: UserStatisticsModel = await UsersService().get_user_statistics_by_user_id(user_id=1) 179 | assert user_statistics.likes == 0 180 | assert user_statistics.dislikes == 0 181 | 182 | 183 | @pytest.mark.anyio 184 | async def test_get_user_statistics_by_user_id_fail_user_statistics_not_found(create_test_db: None) -> None: 185 | with pytest.raises(UserStatisticsNotFoundError): 186 | await UsersService().get_user_statistics_by_user_id(user_id=1) 187 | 188 | 189 | @pytest.mark.anyio 190 | async def test_like_user_success(create_test_user: None) -> None: 191 | user_statistics: UserStatisticsModel = await UsersService().like_user(voting_user_id=1, voted_for_user_id=1) 192 | assert user_statistics.likes == 1 193 | assert user_statistics.dislikes == 0 194 | 195 | 196 | @pytest.mark.anyio 197 | async def test_like_user_fail_user_statistics_not_found(create_test_db: None) -> None: 198 | with pytest.raises(UserStatisticsNotFoundError): 199 | await UsersService().like_user(voting_user_id=1, voted_for_user_id=1) 200 | 201 | 202 | @pytest.mark.anyio 203 | async def test_dislike_user_success(create_test_user: None) -> None: 204 | user_statistics: UserStatisticsModel = await UsersService().dislike_user(voting_user_id=1, voted_for_user_id=1) 205 | assert user_statistics.likes == 0 206 | assert user_statistics.dislikes == 1 207 | 208 | 209 | @pytest.mark.anyio 210 | async def test_dislike_user_fail_user_statistics_not_found(create_test_db: None) -> None: 211 | with pytest.raises(UserStatisticsNotFoundError): 212 | await UsersService().dislike_user(voting_user_id=1, voted_for_user_id=1) 213 | 214 | 215 | @pytest.mark.anyio 216 | async def test_check_if_user_already_voted_success(create_test_user: None, async_connection: AsyncConnection) -> None: 217 | await async_connection.execute( 218 | insert( 219 | UserVoteModel 220 | ).values( 221 | voted_for_user_id=1, 222 | voting_user_id=1 223 | ) 224 | ) 225 | await async_connection.commit() 226 | 227 | assert await UsersService().check_if_user_already_voted(voting_user_id=1, voted_for_user_id=1) 228 | 229 | 230 | @pytest.mark.anyio 231 | async def test_check_if_user_already_voted_fail(create_test_db: None) -> None: 232 | assert not await UsersService().check_if_user_already_voted(voting_user_id=1, voted_for_user_id=1) 233 | -------------------------------------------------------------------------------- /tests/users/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.requests import Request 3 | from typing import Dict 4 | 5 | from src.users.config import URLPathsConfig 6 | from src.users.exceptions import NotAuthenticatedError 7 | from src.users.utils import hash_password, verify_password, OAuth2Cookie 8 | from tests.config import FakeUserConfig 9 | from tests.utils import build_request 10 | 11 | 12 | @pytest.mark.anyio 13 | async def test_verify_password_success() -> None: 14 | hashed_password: str = await hash_password(FakeUserConfig.PASSWORD) 15 | assert await verify_password(plain_password=FakeUserConfig.PASSWORD, hashed_password=hashed_password) 16 | 17 | 18 | @pytest.mark.anyio 19 | async def test_verify_password_fail() -> None: 20 | hashed_password: str = await hash_password('some other password') 21 | assert not await verify_password(plain_password=FakeUserConfig.PASSWORD, hashed_password=hashed_password) 22 | 23 | 24 | @pytest.mark.anyio 25 | async def test_oauth2cookie_success() -> None: 26 | oauth2cookie: OAuth2Cookie = OAuth2Cookie(token_url=URLPathsConfig.LOGIN) 27 | headers: Dict[str, str] = { 28 | 'cookie': 29 | 'Access-Token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MTQ4NTgwOTd9.' 30 | 'v4yKYGx0O5SxdL3hy37KM2f50W8TZNFeNDtk3zkTibs' 31 | } 32 | 33 | request: Request = build_request(headers=headers) 34 | assert await oauth2cookie(request) 35 | 36 | 37 | @pytest.mark.anyio 38 | async def test_oauth2cookie_fail_without_auto_error() -> None: 39 | oauth2cookie: OAuth2Cookie = OAuth2Cookie(token_url=URLPathsConfig.LOGIN, auto_error=False) 40 | request: Request = build_request() 41 | assert not await oauth2cookie(request) 42 | 43 | 44 | @pytest.mark.anyio 45 | async def test_oauth2cookie_fail_with_auto_error() -> None: 46 | with pytest.raises(NotAuthenticatedError): 47 | oauth2cookie: OAuth2Cookie = OAuth2Cookie(token_url=URLPathsConfig.LOGIN) 48 | request: Request = build_request() 49 | await oauth2cookie(request) 50 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from random import choice 3 | from string import ascii_uppercase 4 | from typing import Dict, Any, Optional 5 | from httpx import Response 6 | from starlette.requests import Request 7 | from starlette.datastructures import Headers 8 | 9 | from src.core.database.config import database_config 10 | 11 | 12 | def get_base_url() -> str: 13 | host: str = os.environ.get('HOST', '0.0.0.0') 14 | port: str = os.environ.get('PORT', '8000') 15 | return f'http://{host}:{port}' 16 | 17 | 18 | def drop_test_db() -> None: 19 | if os.path.exists(database_config.DATABASE_NAME): 20 | os.remove(database_config.DATABASE_NAME) 21 | 22 | 23 | def get_error_message_from_response(response: Response) -> str: 24 | response_content: Dict[str, Any] = response.json() 25 | try: 26 | return response_content['detail'][0]['msg'] 27 | except TypeError: 28 | return response_content['detail'] 29 | 30 | 31 | def generate_random_string(length: int) -> str: 32 | return ''.join(choice(ascii_uppercase) for _ in range(length)) 33 | 34 | 35 | def build_request( 36 | method: str = 'GET', 37 | server: str = 'www.example.com', 38 | path: str = '/', 39 | headers: Optional[Dict[str, str]] = None, 40 | body: Optional[str] = None, 41 | ) -> Request: 42 | 43 | """ 44 | Builds a mock request object for testing. 45 | 46 | https://stackoverflow.com/questions/62231022/how-to-programmatically-instantiate-starlettes-request-with-a-body 47 | """ 48 | 49 | if headers is None: 50 | headers = {} 51 | 52 | request = Request( 53 | { 54 | 'type': 'http', 55 | 'path': path, 56 | 'headers': Headers(headers).raw, 57 | 'http_version': '1.1', 58 | 'method': method, 59 | 'scheme': 'https', 60 | 'client': ('127.0.0.1', 8080), 61 | 'server': (server, 443), 62 | } 63 | ) 64 | 65 | if body: 66 | async def request_body(): 67 | return body 68 | 69 | request.body = request_body 70 | return request 71 | --------------------------------------------------------------------------------