├── .dockerignore ├── .env.example ├── .github ├── assets │ └── logo.png ├── dependabot.yml └── workflows │ ├── conduit.yml │ ├── deploy.yml │ ├── styles.yml │ └── tests.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.rst ├── alembic.ini ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── dependencies │ │ ├── __init__.py │ │ ├── articles.py │ │ ├── authentication.py │ │ ├── comments.py │ │ ├── database.py │ │ └── profiles.py │ ├── errors │ │ ├── __init__.py │ │ ├── http_error.py │ │ └── validation_error.py │ └── routes │ │ ├── __init__.py │ │ ├── api.py │ │ ├── articles │ │ ├── __init__.py │ │ ├── api.py │ │ ├── articles_common.py │ │ └── articles_resource.py │ │ ├── authentication.py │ │ ├── comments.py │ │ ├── profiles.py │ │ ├── tags.py │ │ └── users.py ├── core │ ├── __init__.py │ ├── config.py │ ├── events.py │ ├── logging.py │ └── settings │ │ ├── __init__.py │ │ ├── app.py │ │ ├── base.py │ │ ├── development.py │ │ ├── production.py │ │ └── test.py ├── db │ ├── __init__.py │ ├── errors.py │ ├── events.py │ ├── migrations │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ └── fdf8821871d7_main_tables.py │ ├── queries │ │ ├── __init__.py │ │ ├── queries.py │ │ ├── queries.pyi │ │ ├── sql │ │ │ ├── articles.sql │ │ │ ├── comments.sql │ │ │ ├── profiles.sql │ │ │ ├── tags.sql │ │ │ └── users.sql │ │ └── tables.py │ └── repositories │ │ ├── __init__.py │ │ ├── articles.py │ │ ├── base.py │ │ ├── comments.py │ │ ├── profiles.py │ │ ├── tags.py │ │ └── users.py ├── main.py ├── models │ ├── __init__.py │ ├── common.py │ ├── domain │ │ ├── __init__.py │ │ ├── articles.py │ │ ├── comments.py │ │ ├── profiles.py │ │ ├── rwmodel.py │ │ └── users.py │ └── schemas │ │ ├── __init__.py │ │ ├── articles.py │ │ ├── comments.py │ │ ├── jwt.py │ │ ├── profiles.py │ │ ├── rwschema.py │ │ ├── tags.py │ │ └── users.py ├── resources │ ├── __init__.py │ └── strings.py └── services │ ├── __init__.py │ ├── articles.py │ ├── authentication.py │ ├── comments.py │ ├── jwt.py │ └── security.py ├── docker-compose.yml ├── poetry.lock ├── postman ├── Conduit.postman_collection.json └── run-api-tests.sh ├── pyproject.toml ├── scripts ├── format ├── lint ├── test └── test-cov-html ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── fake_asyncpg_pool.py ├── test_api ├── __init__.py ├── test_errors │ ├── __init__.py │ ├── test_422_error.py │ └── test_error.py └── test_routes │ ├── __init__.py │ ├── test_articles.py │ ├── test_authentication.py │ ├── test_comments.py │ ├── test_login.py │ ├── test_profiles.py │ ├── test_registration.py │ ├── test_tags.py │ └── test_users.py ├── test_db ├── __init__.py └── test_queries │ ├── __init__.py │ └── test_tables.py ├── test_schemas ├── __init__.py └── test_rw_model.py └── test_services ├── __init__.py └── test_jwt.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | .Python 6 | .env* 7 | pip-log.txt 8 | pip-delete-this-directory.txt 9 | .tox 10 | .coverage 11 | .coverage.* 12 | .cache 13 | nosetests.xml 14 | coverage.xml 15 | *,cover 16 | *.log 17 | .git* 18 | tests 19 | scripts 20 | postman 21 | ./postgres-data 22 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SECRET_KEY=secret 2 | DEBUG=True 3 | DATABASE_URL=postgresql://postgres:postgres@localhost/postgres 4 | -------------------------------------------------------------------------------- /.github/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/.github/assets/logo.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: pip 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | time: "12:00" 9 | pull-request-branch-name: 10 | separator: "-" 11 | open-pull-requests-limit: 10 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: monthly 17 | time: "12:00" 18 | pull-request-branch-name: 19 | separator: "-" 20 | open-pull-requests-limit: 10 21 | -------------------------------------------------------------------------------- /.github/workflows/conduit.yml: -------------------------------------------------------------------------------- 1 | name: API spec 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | pull_request: 9 | branches: 10 | - "*" 11 | 12 | jobs: 13 | api-spec: 14 | name: API spec tests 15 | 16 | runs-on: ubuntu-18.04 17 | 18 | strategy: 19 | matrix: 20 | python-version: [3.9] 21 | 22 | services: 23 | postgres: 24 | image: postgres:11.5-alpine 25 | env: 26 | POSTGRES_USER: postgres 27 | POSTGRES_PASSWORD: postgres 28 | POSTGRES_DB: postgres 29 | ports: 30 | - 5432:5432 31 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 32 | 33 | steps: 34 | - uses: actions/checkout@v3 35 | - name: Set up Python 36 | uses: actions/setup-python@v4.2.0 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | 40 | - name: Install Poetry 41 | uses: snok/install-poetry@v1 42 | with: 43 | version: "1.1.12" 44 | virtualenvs-in-project: true 45 | 46 | - name: Set up cache 47 | uses: actions/cache@v3 48 | id: cache 49 | with: 50 | path: .venv 51 | key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }} 52 | 53 | - name: Ensure cache is healthy 54 | if: steps.cache.outputs.cache-hit == 'true' 55 | run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv 56 | 57 | - name: Install dependencies 58 | run: poetry install --no-interaction 59 | 60 | - name: Run newman and test service 61 | env: 62 | SECRET_KEY: secret_key 63 | DATABASE_URL: postgresql://postgres:postgres@localhost/postgres 64 | run: | 65 | poetry run alembic upgrade head 66 | poetry run uvicorn app.main:app & 67 | APIURL=http://localhost:8000/api ./postman/run-api-tests.sh 68 | poetry run alembic downgrade base 69 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | IMAGE_NAME: nsidnev/fastapi-realworld-example-app 10 | DOCKER_USER: ${{ secrets.DOCKER_USER }} 11 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 12 | 13 | jobs: 14 | build: 15 | name: Build Container 16 | 17 | runs-on: ubuntu-18.04 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Build image and publish to registry 23 | run: | 24 | docker build -t $IMAGE_NAME:latest . 25 | echo $DOCKER_PASSWORD | docker login -u $DOCKER_USER --password-stdin 26 | docker push $IMAGE_NAME:latest 27 | -------------------------------------------------------------------------------- /.github/workflows/styles.yml: -------------------------------------------------------------------------------- 1 | name: Styles 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | pull_request: 9 | branches: 10 | - "*" 11 | 12 | jobs: 13 | lint: 14 | name: Lint code 15 | 16 | runs-on: ubuntu-18.04 17 | 18 | strategy: 19 | matrix: 20 | python-version: [3.9] 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Python 25 | uses: actions/setup-python@v4.2.0 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install Poetry 30 | uses: snok/install-poetry@v1 31 | with: 32 | version: "1.1.12" 33 | virtualenvs-in-project: true 34 | 35 | - name: Set up cache 36 | uses: actions/cache@v3 37 | id: cache 38 | with: 39 | path: .venv 40 | key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }} 41 | 42 | - name: Ensure cache is healthy 43 | if: steps.cache.outputs.cache-hit == 'true' 44 | run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv 45 | 46 | - name: Install dependencies 47 | run: poetry install --no-interaction 48 | 49 | - name: Run linters 50 | run: poetry run ./scripts/lint 51 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | pull_request: 9 | branches: 10 | - "*" 11 | 12 | jobs: 13 | lint: 14 | name: Run tests 15 | 16 | runs-on: ubuntu-18.04 17 | 18 | strategy: 19 | matrix: 20 | python-version: [3.9] 21 | 22 | services: 23 | postgres: 24 | image: postgres:11.5-alpine 25 | env: 26 | POSTGRES_USER: postgres 27 | POSTGRES_PASSWORD: postgres 28 | POSTGRES_DB: postgres 29 | ports: 30 | - 5432:5432 31 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 32 | 33 | steps: 34 | - uses: actions/checkout@v3 35 | 36 | - name: Set up Python 37 | uses: actions/setup-python@v4.2.0 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | 41 | - name: Install Poetry 42 | uses: snok/install-poetry@v1 43 | with: 44 | version: "1.1.12" 45 | virtualenvs-in-project: true 46 | 47 | - name: Set up cache 48 | uses: actions/cache@v3 49 | id: cache 50 | with: 51 | path: .venv 52 | key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }} 53 | 54 | - name: Ensure cache is healthy 55 | if: steps.cache.outputs.cache-hit == 'true' 56 | run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv 57 | 58 | - name: Install dependencies 59 | run: poetry install --no-interaction 60 | 61 | - name: Run tests 62 | env: 63 | SECRET_KEY: secret_key 64 | DATABASE_URL: postgresql://postgres:postgres@localhost/postgres 65 | run: | 66 | poetry run alembic upgrade head 67 | poetry run ./scripts/test 68 | 69 | - name: Upload coverage to Codecov 70 | uses: codecov/codecov-action@v3.1.0 71 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | .idea/ 107 | .vscode/ 108 | 109 | # Project 110 | postgres-data 111 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.10-slim 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | EXPOSE 8000 6 | WORKDIR /app 7 | 8 | 9 | RUN apt-get update && \ 10 | apt-get install -y --no-install-recommends netcat && \ 11 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 12 | 13 | COPY poetry.lock pyproject.toml ./ 14 | RUN pip install poetry==1.1 && \ 15 | poetry config virtualenvs.in-project true && \ 16 | poetry install --no-dev 17 | 18 | COPY . ./ 19 | 20 | CMD poetry run alembic upgrade head && \ 21 | poetry run uvicorn --host=0.0.0.0 app.main:app 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nik Sidnev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: ./.github/assets/logo.png 2 | 3 | | 4 | 5 | .. image:: https://github.com/nsidnev/fastapi-realworld-example-app/workflows/API%20spec/badge.svg 6 | :target: https://github.com/nsidnev/fastapi-realworld-example-app 7 | 8 | .. image:: https://github.com/nsidnev/fastapi-realworld-example-app/workflows/Tests/badge.svg 9 | :target: https://github.com/nsidnev/fastapi-realworld-example-app 10 | 11 | .. image:: https://github.com/nsidnev/fastapi-realworld-example-app/workflows/Styles/badge.svg 12 | :target: https://github.com/nsidnev/fastapi-realworld-example-app 13 | 14 | .. image:: https://codecov.io/gh/nsidnev/fastapi-realworld-example-app/branch/master/graph/badge.svg 15 | :target: https://codecov.io/gh/nsidnev/fastapi-realworld-example-app 16 | 17 | .. image:: https://img.shields.io/github/license/Naereen/StrapDown.js.svg 18 | :target: https://github.com/nsidnev/fastapi-realworld-example-app/blob/master/LICENSE 19 | 20 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 21 | :target: https://github.com/ambv/black 22 | 23 | .. image:: https://img.shields.io/badge/style-wemake-000000.svg 24 | :target: https://github.com/wemake-services/wemake-python-styleguide 25 | 26 | ---------- 27 | 28 | **NOTE**: This repository is not actively maintained because this example is quite complete and does its primary goal - passing Conduit testsuite. 29 | 30 | More modern and relevant examples can be found in other repositories with ``fastapi`` tag on GitHub. 31 | 32 | Quickstart 33 | ---------- 34 | 35 | First, run ``PostgreSQL``, set environment variables and create database. For example using ``docker``: :: 36 | 37 | export POSTGRES_DB=rwdb POSTGRES_PORT=5432 POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres 38 | docker run --name pgdb --rm -e POSTGRES_USER="$POSTGRES_USER" -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" -e POSTGRES_DB="$POSTGRES_DB" postgres 39 | export POSTGRES_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' pgdb) 40 | createdb --host=$POSTGRES_HOST --port=$POSTGRES_PORT --username=$POSTGRES_USER $POSTGRES_DB 41 | 42 | Then run the following commands to bootstrap your environment with ``poetry``: :: 43 | 44 | git clone https://github.com/nsidnev/fastapi-realworld-example-app 45 | cd fastapi-realworld-example-app 46 | poetry install 47 | poetry shell 48 | 49 | Then create ``.env`` file (or rename and modify ``.env.example``) in project root and set environment variables for application: :: 50 | 51 | touch .env 52 | echo APP_ENV=dev >> .env 53 | echo DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB >> .env 54 | echo SECRET_KEY=$(openssl rand -hex 32) >> .env 55 | 56 | To run the web application in debug use:: 57 | 58 | alembic upgrade head 59 | uvicorn app.main:app --reload 60 | 61 | If you run into the following error in your docker container: 62 | 63 | sqlalchemy.exc.OperationalError: (psycopg2.OperationalError) could not connect to server: No such file or directory 64 | Is the server running locally and accepting 65 | connections on Unix domain socket "/tmp/.s.PGSQL.5432"? 66 | 67 | Ensure the DATABASE_URL variable is set correctly in the `.env` file. 68 | It is most likely caused by POSTGRES_HOST not pointing to its localhost. 69 | 70 | DATABASE_URL=postgresql://postgres:postgres@0.0.0.0:5432/rwdb 71 | 72 | 73 | 74 | Run tests 75 | --------- 76 | 77 | Tests for this project are defined in the ``tests/`` folder. 78 | 79 | Set up environment variable ``DATABASE_URL`` or set up ``database_url`` in ``app/core/settings/test.py`` 80 | 81 | This project uses `pytest 82 | `_ to define tests because it allows you to use the ``assert`` keyword with good formatting for failed assertations. 83 | 84 | 85 | To run all the tests of a project, simply run the ``pytest`` command: :: 86 | 87 | $ pytest 88 | ================================================= test session starts ================================================== 89 | platform linux -- Python 3.8.3, pytest-5.4.2, py-1.8.1, pluggy-0.13.1 90 | rootdir: /home/some-user/user-projects/fastapi-realworld-example-app, inifile: setup.cfg, testpaths: tests 91 | plugins: env-0.6.2, cov-2.9.0, asyncio-0.12.0 92 | collected 90 items 93 | 94 | tests/test_api/test_errors/test_422_error.py . [ 1%] 95 | tests/test_api/test_errors/test_error.py . [ 2%] 96 | tests/test_api/test_routes/test_articles.py ................................. [ 38%] 97 | tests/test_api/test_routes/test_authentication.py .. [ 41%] 98 | tests/test_api/test_routes/test_comments.py .... [ 45%] 99 | tests/test_api/test_routes/test_login.py ... [ 48%] 100 | tests/test_api/test_routes/test_profiles.py ............ [ 62%] 101 | tests/test_api/test_routes/test_registration.py ... [ 65%] 102 | tests/test_api/test_routes/test_tags.py .. [ 67%] 103 | tests/test_api/test_routes/test_users.py .................... [ 90%] 104 | tests/test_db/test_queries/test_tables.py ... [ 93%] 105 | tests/test_schemas/test_rw_model.py . [ 94%] 106 | tests/test_services/test_jwt.py ..... [100%] 107 | 108 | ============================================ 90 passed in 70.50s (0:01:10) ============================================= 109 | $ 110 | 111 | If you want to run a specific test, you can do this with `this 112 | `_ pytest feature: :: 113 | 114 | $ pytest tests/test_api/test_routes/test_users.py::test_user_can_not_take_already_used_credentials 115 | 116 | Deployment with Docker 117 | ---------------------- 118 | 119 | You must have ``docker`` and ``docker-compose`` tools installed to work with material in this section. 120 | First, create ``.env`` file like in `Quickstart` section or modify ``.env.example``. 121 | ``POSTGRES_HOST`` must be specified as `db` or modified in ``docker-compose.yml`` also. 122 | Then just run:: 123 | 124 | docker-compose up -d db 125 | docker-compose up -d app 126 | 127 | Application will be available on ``localhost`` in your browser. 128 | 129 | Web routes 130 | ---------- 131 | 132 | All routes are available on ``/docs`` or ``/redoc`` paths with Swagger or ReDoc. 133 | 134 | 135 | Project structure 136 | ----------------- 137 | 138 | Files related to application are in the ``app`` or ``tests`` directories. 139 | Application parts are: 140 | 141 | :: 142 | 143 | app 144 | ├── api - web related stuff. 145 | │   ├── dependencies - dependencies for routes definition. 146 | │   ├── errors - definition of error handlers. 147 | │   └── routes - web routes. 148 | ├── core - application configuration, startup events, logging. 149 | ├── db - db related stuff. 150 | │   ├── migrations - manually written alembic migrations. 151 | │   └── repositories - all crud stuff. 152 | ├── models - pydantic models for this application. 153 | │   ├── domain - main models that are used almost everywhere. 154 | │   └── schemas - schemas for using in web routes. 155 | ├── resources - strings that are used in web responses. 156 | ├── services - logic that is not just crud related. 157 | └── main.py - FastAPI application creation and configuration. 158 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | script_location = ./app/db/migrations 3 | 4 | [loggers] 5 | keys = root,sqlalchemy,alembic 6 | 7 | [handlers] 8 | keys = console 9 | 10 | [formatters] 11 | keys = generic 12 | 13 | [logger_root] 14 | level = WARN 15 | handlers = console 16 | qualname = 17 | 18 | [logger_sqlalchemy] 19 | level = WARN 20 | handlers = 21 | qualname = sqlalchemy.engine 22 | 23 | [logger_alembic] 24 | level = INFO 25 | handlers = 26 | qualname = alembic 27 | 28 | [handler_console] 29 | class = StreamHandler 30 | args = (sys.stderr,) 31 | level = NOTSET 32 | formatter = generic 33 | 34 | [formatter_generic] 35 | format = %(levelname)-5.5s [%(name)s] %(message)s 36 | datefmt = %H:%M:%S 37 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/__init__.py -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/api/__init__.py -------------------------------------------------------------------------------- /app/api/dependencies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/api/dependencies/__init__.py -------------------------------------------------------------------------------- /app/api/dependencies/articles.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import Depends, HTTPException, Path, Query 4 | from starlette import status 5 | 6 | from app.api.dependencies.authentication import get_current_user_authorizer 7 | from app.api.dependencies.database import get_repository 8 | from app.db.errors import EntityDoesNotExist 9 | from app.db.repositories.articles import ArticlesRepository 10 | from app.models.domain.articles import Article 11 | from app.models.domain.users import User 12 | from app.models.schemas.articles import ( 13 | DEFAULT_ARTICLES_LIMIT, 14 | DEFAULT_ARTICLES_OFFSET, 15 | ArticlesFilters, 16 | ) 17 | from app.resources import strings 18 | from app.services.articles import check_user_can_modify_article 19 | 20 | 21 | def get_articles_filters( 22 | tag: Optional[str] = None, 23 | author: Optional[str] = None, 24 | favorited: Optional[str] = None, 25 | limit: int = Query(DEFAULT_ARTICLES_LIMIT, ge=1), 26 | offset: int = Query(DEFAULT_ARTICLES_OFFSET, ge=0), 27 | ) -> ArticlesFilters: 28 | return ArticlesFilters( 29 | tag=tag, 30 | author=author, 31 | favorited=favorited, 32 | limit=limit, 33 | offset=offset, 34 | ) 35 | 36 | 37 | async def get_article_by_slug_from_path( 38 | slug: str = Path(..., min_length=1), 39 | user: Optional[User] = Depends(get_current_user_authorizer(required=False)), 40 | articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), 41 | ) -> Article: 42 | try: 43 | return await articles_repo.get_article_by_slug(slug=slug, requested_user=user) 44 | except EntityDoesNotExist: 45 | raise HTTPException( 46 | status_code=status.HTTP_404_NOT_FOUND, 47 | detail=strings.ARTICLE_DOES_NOT_EXIST_ERROR, 48 | ) 49 | 50 | 51 | def check_article_modification_permissions( 52 | current_article: Article = Depends(get_article_by_slug_from_path), 53 | user: User = Depends(get_current_user_authorizer()), 54 | ) -> None: 55 | if not check_user_can_modify_article(current_article, user): 56 | raise HTTPException( 57 | status_code=status.HTTP_403_FORBIDDEN, 58 | detail=strings.USER_IS_NOT_AUTHOR_OF_ARTICLE, 59 | ) 60 | -------------------------------------------------------------------------------- /app/api/dependencies/authentication.py: -------------------------------------------------------------------------------- 1 | # noqa:WPS201 2 | from typing import Callable, Optional 3 | 4 | from fastapi import Depends, HTTPException, Security 5 | from fastapi.security import APIKeyHeader 6 | from starlette import requests, status 7 | from starlette.exceptions import HTTPException as StarletteHTTPException 8 | 9 | from app.api.dependencies.database import get_repository 10 | from app.core.config import get_app_settings 11 | from app.core.settings.app import AppSettings 12 | from app.db.errors import EntityDoesNotExist 13 | from app.db.repositories.users import UsersRepository 14 | from app.models.domain.users import User 15 | from app.resources import strings 16 | from app.services import jwt 17 | 18 | HEADER_KEY = "Authorization" 19 | 20 | 21 | class RWAPIKeyHeader(APIKeyHeader): 22 | async def __call__( # noqa: WPS610 23 | self, 24 | request: requests.Request, 25 | ) -> Optional[str]: 26 | try: 27 | return await super().__call__(request) 28 | except StarletteHTTPException as original_auth_exc: 29 | raise HTTPException( 30 | status_code=original_auth_exc.status_code, 31 | detail=strings.AUTHENTICATION_REQUIRED, 32 | ) 33 | 34 | 35 | def get_current_user_authorizer(*, required: bool = True) -> Callable: # type: ignore 36 | return _get_current_user if required else _get_current_user_optional 37 | 38 | 39 | def _get_authorization_header_retriever( 40 | *, 41 | required: bool = True, 42 | ) -> Callable: # type: ignore 43 | return _get_authorization_header if required else _get_authorization_header_optional 44 | 45 | 46 | def _get_authorization_header( 47 | api_key: str = Security(RWAPIKeyHeader(name=HEADER_KEY)), 48 | settings: AppSettings = Depends(get_app_settings), 49 | ) -> str: 50 | try: 51 | token_prefix, token = api_key.split(" ") 52 | except ValueError: 53 | raise HTTPException( 54 | status_code=status.HTTP_403_FORBIDDEN, 55 | detail=strings.WRONG_TOKEN_PREFIX, 56 | ) 57 | if token_prefix != settings.jwt_token_prefix: 58 | raise HTTPException( 59 | status_code=status.HTTP_403_FORBIDDEN, 60 | detail=strings.WRONG_TOKEN_PREFIX, 61 | ) 62 | 63 | return token 64 | 65 | 66 | def _get_authorization_header_optional( 67 | authorization: Optional[str] = Security( 68 | RWAPIKeyHeader(name=HEADER_KEY, auto_error=False), 69 | ), 70 | settings: AppSettings = Depends(get_app_settings), 71 | ) -> str: 72 | if authorization: 73 | return _get_authorization_header(authorization, settings) 74 | 75 | return "" 76 | 77 | 78 | async def _get_current_user( 79 | users_repo: UsersRepository = Depends(get_repository(UsersRepository)), 80 | token: str = Depends(_get_authorization_header_retriever()), 81 | settings: AppSettings = Depends(get_app_settings), 82 | ) -> User: 83 | try: 84 | username = jwt.get_username_from_token( 85 | token, 86 | str(settings.secret_key.get_secret_value()), 87 | ) 88 | except ValueError: 89 | raise HTTPException( 90 | status_code=status.HTTP_403_FORBIDDEN, 91 | detail=strings.MALFORMED_PAYLOAD, 92 | ) 93 | 94 | try: 95 | return await users_repo.get_user_by_username(username=username) 96 | except EntityDoesNotExist: 97 | raise HTTPException( 98 | status_code=status.HTTP_403_FORBIDDEN, 99 | detail=strings.MALFORMED_PAYLOAD, 100 | ) 101 | 102 | 103 | async def _get_current_user_optional( 104 | repo: UsersRepository = Depends(get_repository(UsersRepository)), 105 | token: str = Depends(_get_authorization_header_retriever(required=False)), 106 | settings: AppSettings = Depends(get_app_settings), 107 | ) -> Optional[User]: 108 | if token: 109 | return await _get_current_user(repo, token, settings) 110 | 111 | return None 112 | -------------------------------------------------------------------------------- /app/api/dependencies/comments.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import Depends, HTTPException, Path 4 | from starlette import status 5 | 6 | from app.api.dependencies import articles, authentication, database 7 | from app.db.errors import EntityDoesNotExist 8 | from app.db.repositories.comments import CommentsRepository 9 | from app.models.domain.articles import Article 10 | from app.models.domain.comments import Comment 11 | from app.models.domain.users import User 12 | from app.resources import strings 13 | from app.services.comments import check_user_can_modify_comment 14 | 15 | 16 | async def get_comment_by_id_from_path( 17 | comment_id: int = Path(..., ge=1), 18 | article: Article = Depends(articles.get_article_by_slug_from_path), 19 | user: Optional[User] = Depends( 20 | authentication.get_current_user_authorizer(required=False), 21 | ), 22 | comments_repo: CommentsRepository = Depends( 23 | database.get_repository(CommentsRepository), 24 | ), 25 | ) -> Comment: 26 | try: 27 | return await comments_repo.get_comment_by_id( 28 | comment_id=comment_id, 29 | article=article, 30 | user=user, 31 | ) 32 | except EntityDoesNotExist: 33 | raise HTTPException( 34 | status_code=status.HTTP_404_NOT_FOUND, 35 | detail=strings.COMMENT_DOES_NOT_EXIST, 36 | ) 37 | 38 | 39 | def check_comment_modification_permissions( 40 | comment: Comment = Depends(get_comment_by_id_from_path), 41 | user: User = Depends(authentication.get_current_user_authorizer()), 42 | ) -> None: 43 | if not check_user_can_modify_comment(comment, user): 44 | raise HTTPException( 45 | status_code=status.HTTP_403_FORBIDDEN, 46 | detail=strings.USER_IS_NOT_AUTHOR_OF_ARTICLE, 47 | ) 48 | -------------------------------------------------------------------------------- /app/api/dependencies/database.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator, Callable, Type 2 | 3 | from asyncpg.connection import Connection 4 | from asyncpg.pool import Pool 5 | from fastapi import Depends 6 | from starlette.requests import Request 7 | 8 | from app.db.repositories.base import BaseRepository 9 | 10 | 11 | def _get_db_pool(request: Request) -> Pool: 12 | return request.app.state.pool 13 | 14 | 15 | async def _get_connection_from_pool( 16 | pool: Pool = Depends(_get_db_pool), 17 | ) -> AsyncGenerator[Connection, None]: 18 | async with pool.acquire() as conn: 19 | yield conn 20 | 21 | 22 | def get_repository( 23 | repo_type: Type[BaseRepository], 24 | ) -> Callable[[Connection], BaseRepository]: 25 | def _get_repo( 26 | conn: Connection = Depends(_get_connection_from_pool), 27 | ) -> BaseRepository: 28 | return repo_type(conn) 29 | 30 | return _get_repo 31 | -------------------------------------------------------------------------------- /app/api/dependencies/profiles.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import Depends, HTTPException, Path 4 | from starlette.status import HTTP_404_NOT_FOUND 5 | 6 | from app.api.dependencies.authentication import get_current_user_authorizer 7 | from app.api.dependencies.database import get_repository 8 | from app.db.errors import EntityDoesNotExist 9 | from app.db.repositories.profiles import ProfilesRepository 10 | from app.models.domain.profiles import Profile 11 | from app.models.domain.users import User 12 | from app.resources import strings 13 | 14 | 15 | async def get_profile_by_username_from_path( 16 | username: str = Path(..., min_length=1), 17 | user: Optional[User] = Depends(get_current_user_authorizer(required=False)), 18 | profiles_repo: ProfilesRepository = Depends(get_repository(ProfilesRepository)), 19 | ) -> Profile: 20 | try: 21 | return await profiles_repo.get_profile_by_username( 22 | username=username, 23 | requested_user=user, 24 | ) 25 | except EntityDoesNotExist: 26 | raise HTTPException( 27 | status_code=HTTP_404_NOT_FOUND, 28 | detail=strings.USER_DOES_NOT_EXIST_ERROR, 29 | ) 30 | -------------------------------------------------------------------------------- /app/api/errors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/api/errors/__init__.py -------------------------------------------------------------------------------- /app/api/errors/http_error.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from starlette.requests import Request 3 | from starlette.responses import JSONResponse 4 | 5 | 6 | async def http_error_handler(_: Request, exc: HTTPException) -> JSONResponse: 7 | return JSONResponse({"errors": [exc.detail]}, status_code=exc.status_code) 8 | -------------------------------------------------------------------------------- /app/api/errors/validation_error.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from fastapi.exceptions import RequestValidationError 4 | from fastapi.openapi.constants import REF_PREFIX 5 | from fastapi.openapi.utils import validation_error_response_definition 6 | from pydantic import ValidationError 7 | from starlette.requests import Request 8 | from starlette.responses import JSONResponse 9 | from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY 10 | 11 | 12 | async def http422_error_handler( 13 | _: Request, 14 | exc: Union[RequestValidationError, ValidationError], 15 | ) -> JSONResponse: 16 | return JSONResponse( 17 | {"errors": exc.errors()}, 18 | status_code=HTTP_422_UNPROCESSABLE_ENTITY, 19 | ) 20 | 21 | 22 | validation_error_response_definition["properties"] = { 23 | "errors": { 24 | "title": "Errors", 25 | "type": "array", 26 | "items": {"$ref": "{0}ValidationError".format(REF_PREFIX)}, 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /app/api/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/api/routes/__init__.py -------------------------------------------------------------------------------- /app/api/routes/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.routes import authentication, comments, profiles, tags, users 4 | from app.api.routes.articles import api as articles 5 | 6 | router = APIRouter() 7 | router.include_router(authentication.router, tags=["authentication"], prefix="/users") 8 | router.include_router(users.router, tags=["users"], prefix="/user") 9 | router.include_router(profiles.router, tags=["profiles"], prefix="/profiles") 10 | router.include_router(articles.router, tags=["articles"]) 11 | router.include_router( 12 | comments.router, 13 | tags=["comments"], 14 | prefix="/articles/{slug}/comments", 15 | ) 16 | router.include_router(tags.router, tags=["tags"], prefix="/tags") 17 | -------------------------------------------------------------------------------- /app/api/routes/articles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/api/routes/articles/__init__.py -------------------------------------------------------------------------------- /app/api/routes/articles/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.routes.articles import articles_common, articles_resource 4 | 5 | router = APIRouter() 6 | 7 | router.include_router(articles_common.router, prefix="/articles") 8 | router.include_router(articles_resource.router, prefix="/articles") 9 | -------------------------------------------------------------------------------- /app/api/routes/articles/articles_common.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, Query 2 | from starlette import status 3 | 4 | from app.api.dependencies.articles import get_article_by_slug_from_path 5 | from app.api.dependencies.authentication import get_current_user_authorizer 6 | from app.api.dependencies.database import get_repository 7 | from app.db.repositories.articles import ArticlesRepository 8 | from app.models.domain.articles import Article 9 | from app.models.domain.users import User 10 | from app.models.schemas.articles import ( 11 | DEFAULT_ARTICLES_LIMIT, 12 | DEFAULT_ARTICLES_OFFSET, 13 | ArticleForResponse, 14 | ArticleInResponse, 15 | ListOfArticlesInResponse, 16 | ) 17 | from app.resources import strings 18 | 19 | router = APIRouter() 20 | 21 | 22 | @router.get( 23 | "/feed", 24 | response_model=ListOfArticlesInResponse, 25 | name="articles:get-user-feed-articles", 26 | ) 27 | async def get_articles_for_user_feed( 28 | limit: int = Query(DEFAULT_ARTICLES_LIMIT, ge=1), 29 | offset: int = Query(DEFAULT_ARTICLES_OFFSET, ge=0), 30 | user: User = Depends(get_current_user_authorizer()), 31 | articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), 32 | ) -> ListOfArticlesInResponse: 33 | articles = await articles_repo.get_articles_for_user_feed( 34 | user=user, 35 | limit=limit, 36 | offset=offset, 37 | ) 38 | articles_for_response = [ 39 | ArticleForResponse(**article.dict()) for article in articles 40 | ] 41 | return ListOfArticlesInResponse( 42 | articles=articles_for_response, 43 | articles_count=len(articles), 44 | ) 45 | 46 | 47 | @router.post( 48 | "/{slug}/favorite", 49 | response_model=ArticleInResponse, 50 | name="articles:mark-article-favorite", 51 | ) 52 | async def mark_article_as_favorite( 53 | article: Article = Depends(get_article_by_slug_from_path), 54 | user: User = Depends(get_current_user_authorizer()), 55 | articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), 56 | ) -> ArticleInResponse: 57 | if not article.favorited: 58 | await articles_repo.add_article_into_favorites(article=article, user=user) 59 | 60 | return ArticleInResponse( 61 | article=ArticleForResponse.from_orm( 62 | article.copy( 63 | update={ 64 | "favorited": True, 65 | "favorites_count": article.favorites_count + 1, 66 | }, 67 | ), 68 | ), 69 | ) 70 | 71 | raise HTTPException( 72 | status_code=status.HTTP_400_BAD_REQUEST, 73 | detail=strings.ARTICLE_IS_ALREADY_FAVORITED, 74 | ) 75 | 76 | 77 | @router.delete( 78 | "/{slug}/favorite", 79 | response_model=ArticleInResponse, 80 | name="articles:unmark-article-favorite", 81 | ) 82 | async def remove_article_from_favorites( 83 | article: Article = Depends(get_article_by_slug_from_path), 84 | user: User = Depends(get_current_user_authorizer()), 85 | articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), 86 | ) -> ArticleInResponse: 87 | if article.favorited: 88 | await articles_repo.remove_article_from_favorites(article=article, user=user) 89 | 90 | return ArticleInResponse( 91 | article=ArticleForResponse.from_orm( 92 | article.copy( 93 | update={ 94 | "favorited": False, 95 | "favorites_count": article.favorites_count - 1, 96 | }, 97 | ), 98 | ), 99 | ) 100 | 101 | raise HTTPException( 102 | status_code=status.HTTP_400_BAD_REQUEST, 103 | detail=strings.ARTICLE_IS_NOT_FAVORITED, 104 | ) 105 | -------------------------------------------------------------------------------- /app/api/routes/articles/articles_resource.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import APIRouter, Body, Depends, HTTPException, Response 4 | from starlette import status 5 | 6 | from app.api.dependencies.articles import ( 7 | check_article_modification_permissions, 8 | get_article_by_slug_from_path, 9 | get_articles_filters, 10 | ) 11 | from app.api.dependencies.authentication import get_current_user_authorizer 12 | from app.api.dependencies.database import get_repository 13 | from app.db.repositories.articles import ArticlesRepository 14 | from app.models.domain.articles import Article 15 | from app.models.domain.users import User 16 | from app.models.schemas.articles import ( 17 | ArticleForResponse, 18 | ArticleInCreate, 19 | ArticleInResponse, 20 | ArticleInUpdate, 21 | ArticlesFilters, 22 | ListOfArticlesInResponse, 23 | ) 24 | from app.resources import strings 25 | from app.services.articles import check_article_exists, get_slug_for_article 26 | 27 | router = APIRouter() 28 | 29 | 30 | @router.get("", response_model=ListOfArticlesInResponse, name="articles:list-articles") 31 | async def list_articles( 32 | articles_filters: ArticlesFilters = Depends(get_articles_filters), 33 | user: Optional[User] = Depends(get_current_user_authorizer(required=False)), 34 | articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), 35 | ) -> ListOfArticlesInResponse: 36 | articles = await articles_repo.filter_articles( 37 | tag=articles_filters.tag, 38 | author=articles_filters.author, 39 | favorited=articles_filters.favorited, 40 | limit=articles_filters.limit, 41 | offset=articles_filters.offset, 42 | requested_user=user, 43 | ) 44 | articles_for_response = [ 45 | ArticleForResponse.from_orm(article) for article in articles 46 | ] 47 | return ListOfArticlesInResponse( 48 | articles=articles_for_response, 49 | articles_count=len(articles), 50 | ) 51 | 52 | 53 | @router.post( 54 | "", 55 | status_code=status.HTTP_201_CREATED, 56 | response_model=ArticleInResponse, 57 | name="articles:create-article", 58 | ) 59 | async def create_new_article( 60 | article_create: ArticleInCreate = Body(..., embed=True, alias="article"), 61 | user: User = Depends(get_current_user_authorizer()), 62 | articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), 63 | ) -> ArticleInResponse: 64 | slug = get_slug_for_article(article_create.title) 65 | if await check_article_exists(articles_repo, slug): 66 | raise HTTPException( 67 | status_code=status.HTTP_400_BAD_REQUEST, 68 | detail=strings.ARTICLE_ALREADY_EXISTS, 69 | ) 70 | 71 | article = await articles_repo.create_article( 72 | slug=slug, 73 | title=article_create.title, 74 | description=article_create.description, 75 | body=article_create.body, 76 | author=user, 77 | tags=article_create.tags, 78 | ) 79 | return ArticleInResponse(article=ArticleForResponse.from_orm(article)) 80 | 81 | 82 | @router.get("/{slug}", response_model=ArticleInResponse, name="articles:get-article") 83 | async def retrieve_article_by_slug( 84 | article: Article = Depends(get_article_by_slug_from_path), 85 | ) -> ArticleInResponse: 86 | return ArticleInResponse(article=ArticleForResponse.from_orm(article)) 87 | 88 | 89 | @router.put( 90 | "/{slug}", 91 | response_model=ArticleInResponse, 92 | name="articles:update-article", 93 | dependencies=[Depends(check_article_modification_permissions)], 94 | ) 95 | async def update_article_by_slug( 96 | article_update: ArticleInUpdate = Body(..., embed=True, alias="article"), 97 | current_article: Article = Depends(get_article_by_slug_from_path), 98 | articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), 99 | ) -> ArticleInResponse: 100 | slug = get_slug_for_article(article_update.title) if article_update.title else None 101 | article = await articles_repo.update_article( 102 | article=current_article, 103 | slug=slug, 104 | **article_update.dict(), 105 | ) 106 | return ArticleInResponse(article=ArticleForResponse.from_orm(article)) 107 | 108 | 109 | @router.delete( 110 | "/{slug}", 111 | status_code=status.HTTP_204_NO_CONTENT, 112 | name="articles:delete-article", 113 | dependencies=[Depends(check_article_modification_permissions)], 114 | response_class=Response, 115 | ) 116 | async def delete_article_by_slug( 117 | article: Article = Depends(get_article_by_slug_from_path), 118 | articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), 119 | ) -> None: 120 | await articles_repo.delete_article(article=article) 121 | -------------------------------------------------------------------------------- /app/api/routes/authentication.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Body, Depends, HTTPException 2 | from starlette.status import HTTP_201_CREATED, HTTP_400_BAD_REQUEST 3 | 4 | from app.api.dependencies.database import get_repository 5 | from app.core.config import get_app_settings 6 | from app.core.settings.app import AppSettings 7 | from app.db.errors import EntityDoesNotExist 8 | from app.db.repositories.users import UsersRepository 9 | from app.models.schemas.users import ( 10 | UserInCreate, 11 | UserInLogin, 12 | UserInResponse, 13 | UserWithToken, 14 | ) 15 | from app.resources import strings 16 | from app.services import jwt 17 | from app.services.authentication import check_email_is_taken, check_username_is_taken 18 | 19 | router = APIRouter() 20 | 21 | 22 | @router.post("/login", response_model=UserInResponse, name="auth:login") 23 | async def login( 24 | user_login: UserInLogin = Body(..., embed=True, alias="user"), 25 | users_repo: UsersRepository = Depends(get_repository(UsersRepository)), 26 | settings: AppSettings = Depends(get_app_settings), 27 | ) -> UserInResponse: 28 | wrong_login_error = HTTPException( 29 | status_code=HTTP_400_BAD_REQUEST, 30 | detail=strings.INCORRECT_LOGIN_INPUT, 31 | ) 32 | 33 | try: 34 | user = await users_repo.get_user_by_email(email=user_login.email) 35 | except EntityDoesNotExist as existence_error: 36 | raise wrong_login_error from existence_error 37 | 38 | if not user.check_password(user_login.password): 39 | raise wrong_login_error 40 | 41 | token = jwt.create_access_token_for_user( 42 | user, 43 | str(settings.secret_key.get_secret_value()), 44 | ) 45 | return UserInResponse( 46 | user=UserWithToken( 47 | username=user.username, 48 | email=user.email, 49 | bio=user.bio, 50 | image=user.image, 51 | token=token, 52 | ), 53 | ) 54 | 55 | 56 | @router.post( 57 | "", 58 | status_code=HTTP_201_CREATED, 59 | response_model=UserInResponse, 60 | name="auth:register", 61 | ) 62 | async def register( 63 | user_create: UserInCreate = Body(..., embed=True, alias="user"), 64 | users_repo: UsersRepository = Depends(get_repository(UsersRepository)), 65 | settings: AppSettings = Depends(get_app_settings), 66 | ) -> UserInResponse: 67 | if await check_username_is_taken(users_repo, user_create.username): 68 | raise HTTPException( 69 | status_code=HTTP_400_BAD_REQUEST, 70 | detail=strings.USERNAME_TAKEN, 71 | ) 72 | 73 | if await check_email_is_taken(users_repo, user_create.email): 74 | raise HTTPException( 75 | status_code=HTTP_400_BAD_REQUEST, 76 | detail=strings.EMAIL_TAKEN, 77 | ) 78 | 79 | user = await users_repo.create_user(**user_create.dict()) 80 | 81 | token = jwt.create_access_token_for_user( 82 | user, 83 | str(settings.secret_key.get_secret_value()), 84 | ) 85 | return UserInResponse( 86 | user=UserWithToken( 87 | username=user.username, 88 | email=user.email, 89 | bio=user.bio, 90 | image=user.image, 91 | token=token, 92 | ), 93 | ) 94 | -------------------------------------------------------------------------------- /app/api/routes/comments.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import APIRouter, Body, Depends, Response 4 | from starlette import status 5 | 6 | from app.api.dependencies.articles import get_article_by_slug_from_path 7 | from app.api.dependencies.authentication import get_current_user_authorizer 8 | from app.api.dependencies.comments import ( 9 | check_comment_modification_permissions, 10 | get_comment_by_id_from_path, 11 | ) 12 | from app.api.dependencies.database import get_repository 13 | from app.db.repositories.comments import CommentsRepository 14 | from app.models.domain.articles import Article 15 | from app.models.domain.comments import Comment 16 | from app.models.domain.users import User 17 | from app.models.schemas.comments import ( 18 | CommentInCreate, 19 | CommentInResponse, 20 | ListOfCommentsInResponse, 21 | ) 22 | 23 | router = APIRouter() 24 | 25 | 26 | @router.get( 27 | "", 28 | response_model=ListOfCommentsInResponse, 29 | name="comments:get-comments-for-article", 30 | ) 31 | async def list_comments_for_article( 32 | article: Article = Depends(get_article_by_slug_from_path), 33 | user: Optional[User] = Depends(get_current_user_authorizer(required=False)), 34 | comments_repo: CommentsRepository = Depends(get_repository(CommentsRepository)), 35 | ) -> ListOfCommentsInResponse: 36 | comments = await comments_repo.get_comments_for_article(article=article, user=user) 37 | return ListOfCommentsInResponse(comments=comments) 38 | 39 | 40 | @router.post( 41 | "", 42 | status_code=status.HTTP_201_CREATED, 43 | response_model=CommentInResponse, 44 | name="comments:create-comment-for-article", 45 | ) 46 | async def create_comment_for_article( 47 | comment_create: CommentInCreate = Body(..., embed=True, alias="comment"), 48 | article: Article = Depends(get_article_by_slug_from_path), 49 | user: User = Depends(get_current_user_authorizer()), 50 | comments_repo: CommentsRepository = Depends(get_repository(CommentsRepository)), 51 | ) -> CommentInResponse: 52 | comment = await comments_repo.create_comment_for_article( 53 | body=comment_create.body, 54 | article=article, 55 | user=user, 56 | ) 57 | return CommentInResponse(comment=comment) 58 | 59 | 60 | @router.delete( 61 | "/{comment_id}", 62 | status_code=status.HTTP_204_NO_CONTENT, 63 | name="comments:delete-comment-from-article", 64 | dependencies=[Depends(check_comment_modification_permissions)], 65 | response_class=Response, 66 | ) 67 | async def delete_comment_from_article( 68 | comment: Comment = Depends(get_comment_by_id_from_path), 69 | comments_repo: CommentsRepository = Depends(get_repository(CommentsRepository)), 70 | ) -> None: 71 | await comments_repo.delete_comment(comment=comment) 72 | -------------------------------------------------------------------------------- /app/api/routes/profiles.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException 2 | from starlette.status import HTTP_400_BAD_REQUEST 3 | 4 | from app.api.dependencies.authentication import get_current_user_authorizer 5 | from app.api.dependencies.database import get_repository 6 | from app.api.dependencies.profiles import get_profile_by_username_from_path 7 | from app.db.repositories.profiles import ProfilesRepository 8 | from app.models.domain.profiles import Profile 9 | from app.models.domain.users import User 10 | from app.models.schemas.profiles import ProfileInResponse 11 | from app.resources import strings 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.get( 17 | "/{username}", 18 | response_model=ProfileInResponse, 19 | name="profiles:get-profile", 20 | ) 21 | async def retrieve_profile_by_username( 22 | profile: Profile = Depends(get_profile_by_username_from_path), 23 | ) -> ProfileInResponse: 24 | return ProfileInResponse(profile=profile) 25 | 26 | 27 | @router.post( 28 | "/{username}/follow", 29 | response_model=ProfileInResponse, 30 | name="profiles:follow-user", 31 | ) 32 | async def follow_for_user( 33 | profile: Profile = Depends(get_profile_by_username_from_path), 34 | user: User = Depends(get_current_user_authorizer()), 35 | profiles_repo: ProfilesRepository = Depends(get_repository(ProfilesRepository)), 36 | ) -> ProfileInResponse: 37 | if user.username == profile.username: 38 | raise HTTPException( 39 | status_code=HTTP_400_BAD_REQUEST, 40 | detail=strings.UNABLE_TO_FOLLOW_YOURSELF, 41 | ) 42 | 43 | if profile.following: 44 | raise HTTPException( 45 | status_code=HTTP_400_BAD_REQUEST, 46 | detail=strings.USER_IS_ALREADY_FOLLOWED, 47 | ) 48 | 49 | await profiles_repo.add_user_into_followers( 50 | target_user=profile, 51 | requested_user=user, 52 | ) 53 | 54 | return ProfileInResponse(profile=profile.copy(update={"following": True})) 55 | 56 | 57 | @router.delete( 58 | "/{username}/follow", 59 | response_model=ProfileInResponse, 60 | name="profiles:unsubscribe-from-user", 61 | ) 62 | async def unsubscribe_from_user( 63 | profile: Profile = Depends(get_profile_by_username_from_path), 64 | user: User = Depends(get_current_user_authorizer()), 65 | profiles_repo: ProfilesRepository = Depends(get_repository(ProfilesRepository)), 66 | ) -> ProfileInResponse: 67 | if user.username == profile.username: 68 | raise HTTPException( 69 | status_code=HTTP_400_BAD_REQUEST, 70 | detail=strings.UNABLE_TO_UNSUBSCRIBE_FROM_YOURSELF, 71 | ) 72 | 73 | if not profile.following: 74 | raise HTTPException( 75 | status_code=HTTP_400_BAD_REQUEST, 76 | detail=strings.USER_IS_NOT_FOLLOWED, 77 | ) 78 | 79 | await profiles_repo.remove_user_from_followers( 80 | target_user=profile, 81 | requested_user=user, 82 | ) 83 | 84 | return ProfileInResponse(profile=profile.copy(update={"following": False})) 85 | -------------------------------------------------------------------------------- /app/api/routes/tags.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from app.api.dependencies.database import get_repository 4 | from app.db.repositories.tags import TagsRepository 5 | from app.models.schemas.tags import TagsInList 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("", response_model=TagsInList, name="tags:get-all") 11 | async def get_all_tags( 12 | tags_repo: TagsRepository = Depends(get_repository(TagsRepository)), 13 | ) -> TagsInList: 14 | tags = await tags_repo.get_all_tags() 15 | return TagsInList(tags=tags) 16 | -------------------------------------------------------------------------------- /app/api/routes/users.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Body, Depends, HTTPException 2 | from starlette.status import HTTP_400_BAD_REQUEST 3 | 4 | from app.api.dependencies.authentication import get_current_user_authorizer 5 | from app.api.dependencies.database import get_repository 6 | from app.core.config import get_app_settings 7 | from app.core.settings.app import AppSettings 8 | from app.db.repositories.users import UsersRepository 9 | from app.models.domain.users import User 10 | from app.models.schemas.users import UserInResponse, UserInUpdate, UserWithToken 11 | from app.resources import strings 12 | from app.services import jwt 13 | from app.services.authentication import check_email_is_taken, check_username_is_taken 14 | 15 | router = APIRouter() 16 | 17 | 18 | @router.get("", response_model=UserInResponse, name="users:get-current-user") 19 | async def retrieve_current_user( 20 | user: User = Depends(get_current_user_authorizer()), 21 | settings: AppSettings = Depends(get_app_settings), 22 | ) -> UserInResponse: 23 | token = jwt.create_access_token_for_user( 24 | user, 25 | str(settings.secret_key.get_secret_value()), 26 | ) 27 | return UserInResponse( 28 | user=UserWithToken( 29 | username=user.username, 30 | email=user.email, 31 | bio=user.bio, 32 | image=user.image, 33 | token=token, 34 | ), 35 | ) 36 | 37 | 38 | @router.put("", response_model=UserInResponse, name="users:update-current-user") 39 | async def update_current_user( 40 | user_update: UserInUpdate = Body(..., embed=True, alias="user"), 41 | current_user: User = Depends(get_current_user_authorizer()), 42 | users_repo: UsersRepository = Depends(get_repository(UsersRepository)), 43 | settings: AppSettings = Depends(get_app_settings), 44 | ) -> UserInResponse: 45 | if user_update.username and user_update.username != current_user.username: 46 | if await check_username_is_taken(users_repo, user_update.username): 47 | raise HTTPException( 48 | status_code=HTTP_400_BAD_REQUEST, 49 | detail=strings.USERNAME_TAKEN, 50 | ) 51 | 52 | if user_update.email and user_update.email != current_user.email: 53 | if await check_email_is_taken(users_repo, user_update.email): 54 | raise HTTPException( 55 | status_code=HTTP_400_BAD_REQUEST, 56 | detail=strings.EMAIL_TAKEN, 57 | ) 58 | 59 | user = await users_repo.update_user(user=current_user, **user_update.dict()) 60 | 61 | token = jwt.create_access_token_for_user( 62 | user, 63 | str(settings.secret_key.get_secret_value()), 64 | ) 65 | return UserInResponse( 66 | user=UserWithToken( 67 | username=user.username, 68 | email=user.email, 69 | bio=user.bio, 70 | image=user.image, 71 | token=token, 72 | ), 73 | ) 74 | -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/core/__init__.py -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Dict, Type 3 | 4 | from app.core.settings.app import AppSettings 5 | from app.core.settings.base import AppEnvTypes, BaseAppSettings 6 | from app.core.settings.development import DevAppSettings 7 | from app.core.settings.production import ProdAppSettings 8 | from app.core.settings.test import TestAppSettings 9 | 10 | environments: Dict[AppEnvTypes, Type[AppSettings]] = { 11 | AppEnvTypes.dev: DevAppSettings, 12 | AppEnvTypes.prod: ProdAppSettings, 13 | AppEnvTypes.test: TestAppSettings, 14 | } 15 | 16 | 17 | @lru_cache 18 | def get_app_settings() -> AppSettings: 19 | app_env = BaseAppSettings().app_env 20 | config = environments[app_env] 21 | return config() 22 | -------------------------------------------------------------------------------- /app/core/events.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from fastapi import FastAPI 4 | from loguru import logger 5 | 6 | from app.core.settings.app import AppSettings 7 | from app.db.events import close_db_connection, connect_to_db 8 | 9 | 10 | def create_start_app_handler( 11 | app: FastAPI, 12 | settings: AppSettings, 13 | ) -> Callable: # type: ignore 14 | async def start_app() -> None: 15 | await connect_to_db(app, settings) 16 | 17 | return start_app 18 | 19 | 20 | def create_stop_app_handler(app: FastAPI) -> Callable: # type: ignore 21 | @logger.catch 22 | async def stop_app() -> None: 23 | await close_db_connection(app) 24 | 25 | return stop_app 26 | -------------------------------------------------------------------------------- /app/core/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from types import FrameType 3 | from typing import cast 4 | 5 | from loguru import logger 6 | 7 | 8 | class InterceptHandler(logging.Handler): 9 | def emit(self, record: logging.LogRecord) -> None: # pragma: no cover 10 | # Get corresponding Loguru level if it exists 11 | try: 12 | level = logger.level(record.levelname).name 13 | except ValueError: 14 | level = str(record.levelno) 15 | 16 | # Find caller from where originated the logged message 17 | frame, depth = logging.currentframe(), 2 18 | while frame.f_code.co_filename == logging.__file__: # noqa: WPS609 19 | frame = cast(FrameType, frame.f_back) 20 | depth += 1 21 | 22 | logger.opt(depth=depth, exception=record.exc_info).log( 23 | level, 24 | record.getMessage(), 25 | ) 26 | -------------------------------------------------------------------------------- /app/core/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/core/settings/__init__.py -------------------------------------------------------------------------------- /app/core/settings/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from typing import Any, Dict, List, Tuple 4 | 5 | from loguru import logger 6 | from pydantic import PostgresDsn, SecretStr 7 | 8 | from app.core.logging import InterceptHandler 9 | from app.core.settings.base import BaseAppSettings 10 | 11 | 12 | class AppSettings(BaseAppSettings): 13 | debug: bool = False 14 | docs_url: str = "/docs" 15 | openapi_prefix: str = "" 16 | openapi_url: str = "/openapi.json" 17 | redoc_url: str = "/redoc" 18 | title: str = "FastAPI example application" 19 | version: str = "0.0.0" 20 | 21 | database_url: PostgresDsn 22 | max_connection_count: int = 10 23 | min_connection_count: int = 10 24 | 25 | secret_key: SecretStr 26 | 27 | api_prefix: str = "/api" 28 | 29 | jwt_token_prefix: str = "Token" 30 | 31 | allowed_hosts: List[str] = ["*"] 32 | 33 | logging_level: int = logging.INFO 34 | loggers: Tuple[str, str] = ("uvicorn.asgi", "uvicorn.access") 35 | 36 | class Config: 37 | validate_assignment = True 38 | 39 | @property 40 | def fastapi_kwargs(self) -> Dict[str, Any]: 41 | return { 42 | "debug": self.debug, 43 | "docs_url": self.docs_url, 44 | "openapi_prefix": self.openapi_prefix, 45 | "openapi_url": self.openapi_url, 46 | "redoc_url": self.redoc_url, 47 | "title": self.title, 48 | "version": self.version, 49 | } 50 | 51 | def configure_logging(self) -> None: 52 | logging.getLogger().handlers = [InterceptHandler()] 53 | for logger_name in self.loggers: 54 | logging_logger = logging.getLogger(logger_name) 55 | logging_logger.handlers = [InterceptHandler(level=self.logging_level)] 56 | 57 | logger.configure(handlers=[{"sink": sys.stderr, "level": self.logging_level}]) 58 | -------------------------------------------------------------------------------- /app/core/settings/base.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pydantic import BaseSettings 4 | 5 | 6 | class AppEnvTypes(Enum): 7 | prod: str = "prod" 8 | dev: str = "dev" 9 | test: str = "test" 10 | 11 | 12 | class BaseAppSettings(BaseSettings): 13 | app_env: AppEnvTypes = AppEnvTypes.prod 14 | 15 | class Config: 16 | env_file = ".env" 17 | -------------------------------------------------------------------------------- /app/core/settings/development.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.core.settings.app import AppSettings 4 | 5 | 6 | class DevAppSettings(AppSettings): 7 | debug: bool = True 8 | 9 | title: str = "Dev FastAPI example application" 10 | 11 | logging_level: int = logging.DEBUG 12 | 13 | class Config(AppSettings.Config): 14 | env_file = ".env" 15 | -------------------------------------------------------------------------------- /app/core/settings/production.py: -------------------------------------------------------------------------------- 1 | from app.core.settings.app import AppSettings 2 | 3 | 4 | class ProdAppSettings(AppSettings): 5 | class Config(AppSettings.Config): 6 | env_file = "prod.env" 7 | -------------------------------------------------------------------------------- /app/core/settings/test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pydantic import PostgresDsn, SecretStr 4 | 5 | from app.core.settings.app import AppSettings 6 | 7 | 8 | class TestAppSettings(AppSettings): 9 | debug: bool = True 10 | 11 | title: str = "Test FastAPI example application" 12 | 13 | secret_key: SecretStr = SecretStr("test_secret") 14 | 15 | database_url: PostgresDsn 16 | max_connection_count: int = 5 17 | min_connection_count: int = 5 18 | 19 | logging_level: int = logging.DEBUG 20 | -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/db/__init__.py -------------------------------------------------------------------------------- /app/db/errors.py: -------------------------------------------------------------------------------- 1 | class EntityDoesNotExist(Exception): 2 | """Raised when entity was not found in database.""" 3 | -------------------------------------------------------------------------------- /app/db/events.py: -------------------------------------------------------------------------------- 1 | import asyncpg 2 | from fastapi import FastAPI 3 | from loguru import logger 4 | 5 | from app.core.settings.app import AppSettings 6 | 7 | 8 | async def connect_to_db(app: FastAPI, settings: AppSettings) -> None: 9 | logger.info("Connecting to PostgreSQL") 10 | 11 | app.state.pool = await asyncpg.create_pool( 12 | str(settings.database_url), 13 | min_size=settings.min_connection_count, 14 | max_size=settings.max_connection_count, 15 | ) 16 | 17 | logger.info("Connection established") 18 | 19 | 20 | async def close_db_connection(app: FastAPI) -> None: 21 | logger.info("Closing connection to database") 22 | 23 | await app.state.pool.close() 24 | 25 | logger.info("Connection closed") 26 | -------------------------------------------------------------------------------- /app/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | from logging.config import fileConfig 4 | 5 | from alembic import context 6 | from sqlalchemy import engine_from_config, pool 7 | 8 | sys.path.append(str(pathlib.Path(__file__).resolve().parents[3])) 9 | 10 | from app.core.config import get_app_settings # isort:skip 11 | 12 | SETTINGS = get_app_settings() 13 | DATABASE_URL = SETTINGS.database_url 14 | 15 | config = context.config 16 | 17 | fileConfig(config.config_file_name) # type: ignore 18 | 19 | target_metadata = None 20 | 21 | config.set_main_option("sqlalchemy.url", str(DATABASE_URL)) 22 | 23 | 24 | def run_migrations_online() -> None: 25 | connectable = engine_from_config( 26 | config.get_section(config.config_ini_section), 27 | prefix="sqlalchemy.", 28 | poolclass=pool.NullPool, 29 | ) 30 | 31 | with connectable.connect() as connection: 32 | context.configure(connection=connection, target_metadata=target_metadata) 33 | 34 | with context.begin_transaction(): 35 | context.run_migrations() 36 | 37 | 38 | run_migrations_online() 39 | -------------------------------------------------------------------------------- /app/db/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | revision = ${repr(up_revision)} 13 | down_revision = ${repr(down_revision)} 14 | branch_labels = ${repr(branch_labels)} 15 | depends_on = ${repr(depends_on)} 16 | 17 | 18 | def upgrade() -> None: 19 | ${upgrades if upgrades else "pass"} 20 | 21 | 22 | def downgrade() -> None: 23 | ${downgrades if downgrades else "pass"} 24 | -------------------------------------------------------------------------------- /app/db/migrations/versions/fdf8821871d7_main_tables.py: -------------------------------------------------------------------------------- 1 | """main tables 2 | 3 | Revision ID: fdf8821871d7 4 | Revises: 5 | Create Date: 2019-09-22 01:36:44.791880 6 | 7 | """ 8 | from typing import Tuple 9 | 10 | import sqlalchemy as sa 11 | from alembic import op 12 | from sqlalchemy import func 13 | 14 | revision = "fdf8821871d7" 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def create_updated_at_trigger() -> None: 21 | op.execute( 22 | """ 23 | CREATE FUNCTION update_updated_at_column() 24 | RETURNS TRIGGER AS 25 | $$ 26 | BEGIN 27 | NEW.updated_at = now(); 28 | RETURN NEW; 29 | END; 30 | $$ language 'plpgsql'; 31 | """ 32 | ) 33 | 34 | 35 | def timestamps() -> Tuple[sa.Column, sa.Column]: 36 | return ( 37 | sa.Column( 38 | "created_at", 39 | sa.TIMESTAMP(timezone=True), 40 | nullable=False, 41 | server_default=func.now(), 42 | ), 43 | sa.Column( 44 | "updated_at", 45 | sa.TIMESTAMP(timezone=True), 46 | nullable=False, 47 | server_default=func.now(), 48 | onupdate=func.current_timestamp(), 49 | ), 50 | ) 51 | 52 | 53 | def create_users_table() -> None: 54 | op.create_table( 55 | "users", 56 | sa.Column("id", sa.Integer, primary_key=True), 57 | sa.Column("username", sa.Text, unique=True, nullable=False, index=True), 58 | sa.Column("email", sa.Text, unique=True, nullable=False, index=True), 59 | sa.Column("salt", sa.Text, nullable=False), 60 | sa.Column("hashed_password", sa.Text), 61 | sa.Column("bio", sa.Text, nullable=False, server_default=""), 62 | sa.Column("image", sa.Text), 63 | *timestamps(), 64 | ) 65 | op.execute( 66 | """ 67 | CREATE TRIGGER update_user_modtime 68 | BEFORE UPDATE 69 | ON users 70 | FOR EACH ROW 71 | EXECUTE PROCEDURE update_updated_at_column(); 72 | """ 73 | ) 74 | 75 | 76 | def create_followers_to_followings_table() -> None: 77 | op.create_table( 78 | "followers_to_followings", 79 | sa.Column( 80 | "follower_id", 81 | sa.Integer, 82 | sa.ForeignKey("users.id", ondelete="CASCADE"), 83 | nullable=False, 84 | ), 85 | sa.Column( 86 | "following_id", 87 | sa.Integer, 88 | sa.ForeignKey("users.id", ondelete="CASCADE"), 89 | nullable=False, 90 | ), 91 | ) 92 | op.create_primary_key( 93 | "pk_followers_to_followings", 94 | "followers_to_followings", 95 | ["follower_id", "following_id"], 96 | ) 97 | 98 | 99 | def create_articles_table() -> None: 100 | op.create_table( 101 | "articles", 102 | sa.Column("id", sa.Integer, primary_key=True), 103 | sa.Column("slug", sa.Text, unique=True, nullable=False, index=True), 104 | sa.Column("title", sa.Text, nullable=False), 105 | sa.Column("description", sa.Text, nullable=False), 106 | sa.Column("body", sa.Text, nullable=False), 107 | sa.Column( 108 | "author_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL") 109 | ), 110 | *timestamps(), 111 | ) 112 | op.execute( 113 | """ 114 | CREATE TRIGGER update_article_modtime 115 | BEFORE UPDATE 116 | ON articles 117 | FOR EACH ROW 118 | EXECUTE PROCEDURE update_updated_at_column(); 119 | """ 120 | ) 121 | 122 | 123 | def create_tags_table() -> None: 124 | op.create_table("tags", sa.Column("tag", sa.Text, primary_key=True)) 125 | 126 | 127 | def create_articles_to_tags_table() -> None: 128 | op.create_table( 129 | "articles_to_tags", 130 | sa.Column( 131 | "article_id", 132 | sa.Integer, 133 | sa.ForeignKey("articles.id", ondelete="CASCADE"), 134 | nullable=False, 135 | ), 136 | sa.Column( 137 | "tag", 138 | sa.Text, 139 | sa.ForeignKey("tags.tag", ondelete="CASCADE"), 140 | nullable=False, 141 | ), 142 | ) 143 | op.create_primary_key( 144 | "pk_articles_to_tags", "articles_to_tags", ["article_id", "tag"] 145 | ) 146 | 147 | 148 | def create_favorites_table() -> None: 149 | op.create_table( 150 | "favorites", 151 | sa.Column( 152 | "user_id", 153 | sa.Integer, 154 | sa.ForeignKey("users.id", ondelete="CASCADE"), 155 | nullable=False, 156 | ), 157 | sa.Column( 158 | "article_id", 159 | sa.Integer, 160 | sa.ForeignKey("articles.id", ondelete="CASCADE"), 161 | nullable=False, 162 | ), 163 | ) 164 | op.create_primary_key("pk_favorites", "favorites", ["user_id", "article_id"]) 165 | 166 | 167 | def create_commentaries_table() -> None: 168 | op.create_table( 169 | "commentaries", 170 | sa.Column("id", sa.Integer, primary_key=True), 171 | sa.Column("body", sa.Text, nullable=False), 172 | sa.Column( 173 | "author_id", 174 | sa.Integer, 175 | sa.ForeignKey("users.id", ondelete="CASCADE"), 176 | nullable=False, 177 | ), 178 | sa.Column( 179 | "article_id", 180 | sa.Integer, 181 | sa.ForeignKey("articles.id", ondelete="CASCADE"), 182 | nullable=False, 183 | ), 184 | *timestamps(), 185 | ) 186 | op.execute( 187 | """ 188 | CREATE TRIGGER update_comment_modtime 189 | BEFORE UPDATE 190 | ON commentaries 191 | FOR EACH ROW 192 | EXECUTE PROCEDURE update_updated_at_column(); 193 | """ 194 | ) 195 | 196 | 197 | def upgrade() -> None: 198 | create_updated_at_trigger() 199 | create_users_table() 200 | create_followers_to_followings_table() 201 | create_articles_table() 202 | create_tags_table() 203 | create_articles_to_tags_table() 204 | create_favorites_table() 205 | create_commentaries_table() 206 | 207 | 208 | def downgrade() -> None: 209 | op.drop_table("commentaries") 210 | op.drop_table("favorites") 211 | op.drop_table("articles_to_tags") 212 | op.drop_table("tags") 213 | op.drop_table("articles") 214 | op.drop_table("followers_to_followings") 215 | op.drop_table("users") 216 | op.execute("DROP FUNCTION update_updated_at_column") 217 | -------------------------------------------------------------------------------- /app/db/queries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/db/queries/__init__.py -------------------------------------------------------------------------------- /app/db/queries/queries.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import aiosql 4 | 5 | queries = aiosql.from_path(pathlib.Path(__file__).parent / "sql", "asyncpg") 6 | -------------------------------------------------------------------------------- /app/db/queries/queries.pyi: -------------------------------------------------------------------------------- 1 | """Typings for queries generated by aiosql""" 2 | 3 | from typing import Dict, Optional, Sequence 4 | 5 | from asyncpg import Connection, Record 6 | 7 | class TagsQueriesMixin: 8 | async def get_all_tags(self, conn: Connection) -> Record: ... 9 | async def create_new_tags( 10 | self, conn: Connection, tags: Sequence[Dict[str, str]] 11 | ) -> None: ... 12 | 13 | class UsersQueriesMixin: 14 | async def get_user_by_email(self, conn: Connection, *, email: str) -> Record: ... 15 | async def get_user_by_username( 16 | self, conn: Connection, *, username: str 17 | ) -> Record: ... 18 | async def create_new_user( 19 | self, 20 | conn: Connection, 21 | *, 22 | username: str, 23 | email: str, 24 | salt: str, 25 | hashed_password: str 26 | ) -> Record: ... 27 | async def update_user_by_username( 28 | self, 29 | conn: Connection, 30 | *, 31 | username: str, 32 | new_username: str, 33 | new_email: str, 34 | new_salt: str, 35 | new_password: str, 36 | new_bio: Optional[str], 37 | new_image: Optional[str] 38 | ) -> Record: ... 39 | 40 | class ProfilesQueriesMixin: 41 | async def is_user_following_for_another( 42 | self, conn: Connection, *, follower_username: str, following_username: str 43 | ) -> Record: ... 44 | async def subscribe_user_to_another( 45 | self, conn: Connection, *, follower_username: str, following_username: str 46 | ) -> None: ... 47 | async def unsubscribe_user_from_another( 48 | self, conn: Connection, *, follower_username: str, following_username: str 49 | ) -> None: ... 50 | 51 | class CommentsQueriesMixin: 52 | async def get_comments_for_article_by_slug( 53 | self, conn: Connection, *, slug: str 54 | ) -> Record: ... 55 | async def get_comment_by_id_and_slug( 56 | self, conn: Connection, *, comment_id: int, article_slug: str 57 | ) -> Record: ... 58 | async def create_new_comment( 59 | self, conn: Connection, *, body: str, article_slug: str, author_username: str 60 | ) -> Record: ... 61 | async def delete_comment_by_id( 62 | self, conn: Connection, *, comment_id: int, author_username: str 63 | ) -> None: ... 64 | 65 | class ArticlesQueriesMixin: 66 | async def add_article_to_favorites( 67 | self, conn: Connection, *, username: str, slug: str 68 | ) -> None: ... 69 | async def remove_article_from_favorites( 70 | self, conn: Connection, *, username: str, slug: str 71 | ) -> None: ... 72 | async def is_article_in_favorites( 73 | self, conn: Connection, *, username: str, slug: str 74 | ) -> Record: ... 75 | async def get_favorites_count_for_article( 76 | self, conn: Connection, *, slug: str 77 | ) -> Record: ... 78 | async def get_tags_for_article_by_slug( 79 | self, conn: Connection, *, slug: str 80 | ) -> Record: ... 81 | async def get_article_by_slug(self, conn: Connection, *, slug: str) -> Record: ... 82 | async def create_new_article( 83 | self, 84 | conn: Connection, 85 | *, 86 | slug: str, 87 | title: str, 88 | description: str, 89 | body: str, 90 | author_username: str 91 | ) -> Record: ... 92 | async def add_tags_to_article( 93 | self, conn: Connection, tags_slugs: Sequence[Dict[str, str]] 94 | ) -> None: ... 95 | async def update_article( 96 | self, 97 | conn: Connection, 98 | *, 99 | slug: str, 100 | author_username: str, 101 | new_slug: str, 102 | new_title: str, 103 | new_body: str, 104 | new_description: str 105 | ) -> Record: ... 106 | async def delete_article( 107 | self, conn: Connection, *, slug: str, author_username: str 108 | ) -> None: ... 109 | async def get_articles_for_feed( 110 | self, conn: Connection, *, follower_username: str, limit: int, offset: int 111 | ) -> Record: ... 112 | 113 | class Queries( 114 | TagsQueriesMixin, 115 | UsersQueriesMixin, 116 | ProfilesQueriesMixin, 117 | CommentsQueriesMixin, 118 | ArticlesQueriesMixin, 119 | ): ... 120 | 121 | queries: Queries 122 | -------------------------------------------------------------------------------- /app/db/queries/sql/articles.sql: -------------------------------------------------------------------------------- 1 | -- name: add-article-to-favorites! 2 | INSERT INTO favorites (user_id, article_id) 3 | VALUES ((SELECT id FROM users WHERE username = :username), 4 | (SELECT id FROM articles WHERE slug = :slug)) 5 | ON CONFLICT DO NOTHING; 6 | 7 | 8 | -- name: remove-article-from-favorites! 9 | DELETE 10 | FROM favorites 11 | WHERE user_id = (SELECT id FROM users WHERE username = :username) 12 | AND article_id = (SELECT id FROM articles WHERE slug = :slug); 13 | 14 | 15 | -- name: is-article-in-favorites^ 16 | SELECT CASE WHEN count(user_id) > 0 THEN TRUE ELSE FALSE END AS favorited 17 | FROM favorites 18 | WHERE user_id = (SELECT id FROM users WHERE username = :username) 19 | AND article_id = (SELECT id FROM articles WHERE slug = :slug); 20 | 21 | 22 | -- name: get-favorites-count-for-article^ 23 | SELECT count(*) as favorites_count 24 | FROM favorites 25 | WHERE article_id = (SELECT id FROM articles WHERE slug = :slug); 26 | 27 | 28 | -- name: get-tags-for-article-by-slug 29 | SELECT t.tag 30 | FROM tags t 31 | INNER JOIN articles_to_tags att ON 32 | t.tag = att.tag 33 | AND 34 | att.article_id = (SELECT id FROM articles WHERE slug = :slug); 35 | 36 | 37 | -- name: get-article-by-slug^ 38 | SELECT id, 39 | slug, 40 | title, 41 | description, 42 | body, 43 | created_at, 44 | updated_at, 45 | (SELECT username FROM users WHERE id = author_id) AS author_username 46 | FROM articles 47 | WHERE slug = :slug 48 | LIMIT 1; 49 | 50 | 51 | -- name: create-new-article None: 9 | super().__init__("${0}".format(count)) 10 | 11 | 12 | class TypedTable(Table): 13 | __table__ = "" 14 | 15 | def __init__( 16 | self, 17 | name: Optional[str] = None, 18 | schema: Optional[str] = None, 19 | alias: Optional[str] = None, 20 | query_cls: Optional[Query] = None, 21 | ) -> None: 22 | if name is None: 23 | if self.__table__: 24 | name = self.__table__ 25 | else: 26 | name = self.__class__.__name__ 27 | 28 | super().__init__(name, schema, alias, query_cls) 29 | 30 | 31 | class Users(TypedTable): 32 | __table__ = "users" 33 | 34 | id: int 35 | username: str 36 | 37 | 38 | class Articles(TypedTable): 39 | __table__ = "articles" 40 | 41 | id: int 42 | slug: str 43 | title: str 44 | description: str 45 | body: str 46 | author_id: int 47 | created_at: datetime 48 | updated_at: datetime 49 | 50 | 51 | class Tags(TypedTable): 52 | __table__ = "tags" 53 | 54 | tag: str 55 | 56 | 57 | class ArticlesToTags(TypedTable): 58 | __table__ = "articles_to_tags" 59 | 60 | article_id: int 61 | tag: str 62 | 63 | 64 | class Favorites(TypedTable): 65 | __table__ = "favorites" 66 | 67 | article_id: int 68 | user_id: int 69 | 70 | 71 | users = Users() 72 | articles = Articles() 73 | tags = Tags() 74 | articles_to_tags = ArticlesToTags() 75 | favorites = Favorites() 76 | -------------------------------------------------------------------------------- /app/db/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/db/repositories/__init__.py -------------------------------------------------------------------------------- /app/db/repositories/articles.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Sequence, Union 2 | 3 | from asyncpg import Connection, Record 4 | from pypika import Query 5 | 6 | from app.db.errors import EntityDoesNotExist 7 | from app.db.queries.queries import queries 8 | from app.db.queries.tables import ( 9 | Parameter, 10 | articles, 11 | articles_to_tags, 12 | favorites, 13 | tags as tags_table, 14 | users, 15 | ) 16 | from app.db.repositories.base import BaseRepository 17 | from app.db.repositories.profiles import ProfilesRepository 18 | from app.db.repositories.tags import TagsRepository 19 | from app.models.domain.articles import Article 20 | from app.models.domain.users import User 21 | 22 | AUTHOR_USERNAME_ALIAS = "author_username" 23 | SLUG_ALIAS = "slug" 24 | 25 | CAMEL_OR_SNAKE_CASE_TO_WORDS = r"^[a-z\d_\-]+|[A-Z\d_\-][^A-Z\d_\-]*" 26 | 27 | 28 | class ArticlesRepository(BaseRepository): # noqa: WPS214 29 | def __init__(self, conn: Connection) -> None: 30 | super().__init__(conn) 31 | self._profiles_repo = ProfilesRepository(conn) 32 | self._tags_repo = TagsRepository(conn) 33 | 34 | async def create_article( # noqa: WPS211 35 | self, 36 | *, 37 | slug: str, 38 | title: str, 39 | description: str, 40 | body: str, 41 | author: User, 42 | tags: Optional[Sequence[str]] = None, 43 | ) -> Article: 44 | async with self.connection.transaction(): 45 | article_row = await queries.create_new_article( 46 | self.connection, 47 | slug=slug, 48 | title=title, 49 | description=description, 50 | body=body, 51 | author_username=author.username, 52 | ) 53 | 54 | if tags: 55 | await self._tags_repo.create_tags_that_dont_exist(tags=tags) 56 | await self._link_article_with_tags(slug=slug, tags=tags) 57 | 58 | return await self._get_article_from_db_record( 59 | article_row=article_row, 60 | slug=slug, 61 | author_username=article_row[AUTHOR_USERNAME_ALIAS], 62 | requested_user=author, 63 | ) 64 | 65 | async def update_article( # noqa: WPS211 66 | self, 67 | *, 68 | article: Article, 69 | slug: Optional[str] = None, 70 | title: Optional[str] = None, 71 | body: Optional[str] = None, 72 | description: Optional[str] = None, 73 | ) -> Article: 74 | updated_article = article.copy(deep=True) 75 | updated_article.slug = slug or updated_article.slug 76 | updated_article.title = title or article.title 77 | updated_article.body = body or article.body 78 | updated_article.description = description or article.description 79 | 80 | async with self.connection.transaction(): 81 | updated_article.updated_at = await queries.update_article( 82 | self.connection, 83 | slug=article.slug, 84 | author_username=article.author.username, 85 | new_slug=updated_article.slug, 86 | new_title=updated_article.title, 87 | new_body=updated_article.body, 88 | new_description=updated_article.description, 89 | ) 90 | 91 | return updated_article 92 | 93 | async def delete_article(self, *, article: Article) -> None: 94 | async with self.connection.transaction(): 95 | await queries.delete_article( 96 | self.connection, 97 | slug=article.slug, 98 | author_username=article.author.username, 99 | ) 100 | 101 | async def filter_articles( # noqa: WPS211 102 | self, 103 | *, 104 | tag: Optional[str] = None, 105 | author: Optional[str] = None, 106 | favorited: Optional[str] = None, 107 | limit: int = 20, 108 | offset: int = 0, 109 | requested_user: Optional[User] = None, 110 | ) -> List[Article]: 111 | query_params: List[Union[str, int]] = [] 112 | query_params_count = 0 113 | 114 | # fmt: off 115 | query = Query.from_( 116 | articles, 117 | ).select( 118 | articles.id, 119 | articles.slug, 120 | articles.title, 121 | articles.description, 122 | articles.body, 123 | articles.created_at, 124 | articles.updated_at, 125 | Query.from_( 126 | users, 127 | ).where( 128 | users.id == articles.author_id, 129 | ).select( 130 | users.username, 131 | ).as_( 132 | AUTHOR_USERNAME_ALIAS, 133 | ), 134 | ) 135 | # fmt: on 136 | 137 | if tag: 138 | query_params.append(tag) 139 | query_params_count += 1 140 | 141 | # fmt: off 142 | query = query.join( 143 | articles_to_tags, 144 | ).on( 145 | (articles.id == articles_to_tags.article_id) & ( 146 | articles_to_tags.tag == Query.from_( 147 | tags_table, 148 | ).where( 149 | tags_table.tag == Parameter(query_params_count), 150 | ).select( 151 | tags_table.tag, 152 | ) 153 | ), 154 | ) 155 | # fmt: on 156 | 157 | if author: 158 | query_params.append(author) 159 | query_params_count += 1 160 | 161 | # fmt: off 162 | query = query.join( 163 | users, 164 | ).on( 165 | (articles.author_id == users.id) & ( 166 | users.id == Query.from_( 167 | users, 168 | ).where( 169 | users.username == Parameter(query_params_count), 170 | ).select( 171 | users.id, 172 | ) 173 | ), 174 | ) 175 | # fmt: on 176 | 177 | if favorited: 178 | query_params.append(favorited) 179 | query_params_count += 1 180 | 181 | # fmt: off 182 | query = query.join( 183 | favorites, 184 | ).on( 185 | (articles.id == favorites.article_id) & ( 186 | favorites.user_id == Query.from_( 187 | users, 188 | ).where( 189 | users.username == Parameter(query_params_count), 190 | ).select( 191 | users.id, 192 | ) 193 | ), 194 | ) 195 | # fmt: on 196 | 197 | query = query.limit(Parameter(query_params_count + 1)).offset( 198 | Parameter(query_params_count + 2), 199 | ) 200 | query_params.extend([limit, offset]) 201 | 202 | articles_rows = await self.connection.fetch(query.get_sql(), *query_params) 203 | 204 | return [ 205 | await self._get_article_from_db_record( 206 | article_row=article_row, 207 | slug=article_row[SLUG_ALIAS], 208 | author_username=article_row[AUTHOR_USERNAME_ALIAS], 209 | requested_user=requested_user, 210 | ) 211 | for article_row in articles_rows 212 | ] 213 | 214 | async def get_articles_for_user_feed( 215 | self, 216 | *, 217 | user: User, 218 | limit: int = 20, 219 | offset: int = 0, 220 | ) -> List[Article]: 221 | articles_rows = await queries.get_articles_for_feed( 222 | self.connection, 223 | follower_username=user.username, 224 | limit=limit, 225 | offset=offset, 226 | ) 227 | return [ 228 | await self._get_article_from_db_record( 229 | article_row=article_row, 230 | slug=article_row[SLUG_ALIAS], 231 | author_username=article_row[AUTHOR_USERNAME_ALIAS], 232 | requested_user=user, 233 | ) 234 | for article_row in articles_rows 235 | ] 236 | 237 | async def get_article_by_slug( 238 | self, 239 | *, 240 | slug: str, 241 | requested_user: Optional[User] = None, 242 | ) -> Article: 243 | article_row = await queries.get_article_by_slug(self.connection, slug=slug) 244 | if article_row: 245 | return await self._get_article_from_db_record( 246 | article_row=article_row, 247 | slug=article_row[SLUG_ALIAS], 248 | author_username=article_row[AUTHOR_USERNAME_ALIAS], 249 | requested_user=requested_user, 250 | ) 251 | 252 | raise EntityDoesNotExist("article with slug {0} does not exist".format(slug)) 253 | 254 | async def get_tags_for_article_by_slug(self, *, slug: str) -> List[str]: 255 | tag_rows = await queries.get_tags_for_article_by_slug( 256 | self.connection, 257 | slug=slug, 258 | ) 259 | return [row["tag"] for row in tag_rows] 260 | 261 | async def get_favorites_count_for_article_by_slug(self, *, slug: str) -> int: 262 | return ( 263 | await queries.get_favorites_count_for_article(self.connection, slug=slug) 264 | )["favorites_count"] 265 | 266 | async def is_article_favorited_by_user(self, *, slug: str, user: User) -> bool: 267 | return ( 268 | await queries.is_article_in_favorites( 269 | self.connection, 270 | username=user.username, 271 | slug=slug, 272 | ) 273 | )["favorited"] 274 | 275 | async def add_article_into_favorites(self, *, article: Article, user: User) -> None: 276 | await queries.add_article_to_favorites( 277 | self.connection, 278 | username=user.username, 279 | slug=article.slug, 280 | ) 281 | 282 | async def remove_article_from_favorites( 283 | self, 284 | *, 285 | article: Article, 286 | user: User, 287 | ) -> None: 288 | await queries.remove_article_from_favorites( 289 | self.connection, 290 | username=user.username, 291 | slug=article.slug, 292 | ) 293 | 294 | async def _get_article_from_db_record( 295 | self, 296 | *, 297 | article_row: Record, 298 | slug: str, 299 | author_username: str, 300 | requested_user: Optional[User], 301 | ) -> Article: 302 | return Article( 303 | id_=article_row["id"], 304 | slug=slug, 305 | title=article_row["title"], 306 | description=article_row["description"], 307 | body=article_row["body"], 308 | author=await self._profiles_repo.get_profile_by_username( 309 | username=author_username, 310 | requested_user=requested_user, 311 | ), 312 | tags=await self.get_tags_for_article_by_slug(slug=slug), 313 | favorites_count=await self.get_favorites_count_for_article_by_slug( 314 | slug=slug, 315 | ), 316 | favorited=await self.is_article_favorited_by_user( 317 | slug=slug, 318 | user=requested_user, 319 | ) 320 | if requested_user 321 | else False, 322 | created_at=article_row["created_at"], 323 | updated_at=article_row["updated_at"], 324 | ) 325 | 326 | async def _link_article_with_tags(self, *, slug: str, tags: Sequence[str]) -> None: 327 | await queries.add_tags_to_article( 328 | self.connection, 329 | [{SLUG_ALIAS: slug, "tag": tag} for tag in tags], 330 | ) 331 | -------------------------------------------------------------------------------- /app/db/repositories/base.py: -------------------------------------------------------------------------------- 1 | from asyncpg.connection import Connection 2 | 3 | 4 | class BaseRepository: 5 | def __init__(self, conn: Connection) -> None: 6 | self._conn = conn 7 | 8 | @property 9 | def connection(self) -> Connection: 10 | return self._conn 11 | -------------------------------------------------------------------------------- /app/db/repositories/comments.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from asyncpg import Connection, Record 4 | 5 | from app.db.errors import EntityDoesNotExist 6 | from app.db.queries.queries import queries 7 | from app.db.repositories.base import BaseRepository 8 | from app.db.repositories.profiles import ProfilesRepository 9 | from app.models.domain.articles import Article 10 | from app.models.domain.comments import Comment 11 | from app.models.domain.users import User 12 | 13 | 14 | class CommentsRepository(BaseRepository): 15 | def __init__(self, conn: Connection) -> None: 16 | super().__init__(conn) 17 | self._profiles_repo = ProfilesRepository(conn) 18 | 19 | async def get_comment_by_id( 20 | self, 21 | *, 22 | comment_id: int, 23 | article: Article, 24 | user: Optional[User] = None, 25 | ) -> Comment: 26 | comment_row = await queries.get_comment_by_id_and_slug( 27 | self.connection, 28 | comment_id=comment_id, 29 | article_slug=article.slug, 30 | ) 31 | if comment_row: 32 | return await self._get_comment_from_db_record( 33 | comment_row=comment_row, 34 | author_username=comment_row["author_username"], 35 | requested_user=user, 36 | ) 37 | 38 | raise EntityDoesNotExist( 39 | "comment with id {0} does not exist".format(comment_id), 40 | ) 41 | 42 | async def get_comments_for_article( 43 | self, 44 | *, 45 | article: Article, 46 | user: Optional[User] = None, 47 | ) -> List[Comment]: 48 | comments_rows = await queries.get_comments_for_article_by_slug( 49 | self.connection, 50 | slug=article.slug, 51 | ) 52 | return [ 53 | await self._get_comment_from_db_record( 54 | comment_row=comment_row, 55 | author_username=comment_row["author_username"], 56 | requested_user=user, 57 | ) 58 | for comment_row in comments_rows 59 | ] 60 | 61 | async def create_comment_for_article( 62 | self, 63 | *, 64 | body: str, 65 | article: Article, 66 | user: User, 67 | ) -> Comment: 68 | comment_row = await queries.create_new_comment( 69 | self.connection, 70 | body=body, 71 | article_slug=article.slug, 72 | author_username=user.username, 73 | ) 74 | return await self._get_comment_from_db_record( 75 | comment_row=comment_row, 76 | author_username=comment_row["author_username"], 77 | requested_user=user, 78 | ) 79 | 80 | async def delete_comment(self, *, comment: Comment) -> None: 81 | await queries.delete_comment_by_id( 82 | self.connection, 83 | comment_id=comment.id_, 84 | author_username=comment.author.username, 85 | ) 86 | 87 | async def _get_comment_from_db_record( 88 | self, 89 | *, 90 | comment_row: Record, 91 | author_username: str, 92 | requested_user: Optional[User], 93 | ) -> Comment: 94 | return Comment( 95 | id_=comment_row["id"], 96 | body=comment_row["body"], 97 | author=await self._profiles_repo.get_profile_by_username( 98 | username=author_username, 99 | requested_user=requested_user, 100 | ), 101 | created_at=comment_row["created_at"], 102 | updated_at=comment_row["updated_at"], 103 | ) 104 | -------------------------------------------------------------------------------- /app/db/repositories/profiles.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from asyncpg import Connection 4 | 5 | from app.db.queries.queries import queries 6 | from app.db.repositories.base import BaseRepository 7 | from app.db.repositories.users import UsersRepository 8 | from app.models.domain.profiles import Profile 9 | from app.models.domain.users import User 10 | 11 | UserLike = Union[User, Profile] 12 | 13 | 14 | class ProfilesRepository(BaseRepository): 15 | def __init__(self, conn: Connection): 16 | super().__init__(conn) 17 | self._users_repo = UsersRepository(conn) 18 | 19 | async def get_profile_by_username( 20 | self, 21 | *, 22 | username: str, 23 | requested_user: Optional[UserLike], 24 | ) -> Profile: 25 | user = await self._users_repo.get_user_by_username(username=username) 26 | 27 | profile = Profile(username=user.username, bio=user.bio, image=user.image) 28 | if requested_user: 29 | profile.following = await self.is_user_following_for_another_user( 30 | target_user=user, 31 | requested_user=requested_user, 32 | ) 33 | 34 | return profile 35 | 36 | async def is_user_following_for_another_user( 37 | self, 38 | *, 39 | target_user: UserLike, 40 | requested_user: UserLike, 41 | ) -> bool: 42 | return ( 43 | await queries.is_user_following_for_another( 44 | self.connection, 45 | follower_username=requested_user.username, 46 | following_username=target_user.username, 47 | ) 48 | )["is_following"] 49 | 50 | async def add_user_into_followers( 51 | self, 52 | *, 53 | target_user: UserLike, 54 | requested_user: UserLike, 55 | ) -> None: 56 | async with self.connection.transaction(): 57 | await queries.subscribe_user_to_another( 58 | self.connection, 59 | follower_username=requested_user.username, 60 | following_username=target_user.username, 61 | ) 62 | 63 | async def remove_user_from_followers( 64 | self, 65 | *, 66 | target_user: UserLike, 67 | requested_user: UserLike, 68 | ) -> None: 69 | async with self.connection.transaction(): 70 | await queries.unsubscribe_user_from_another( 71 | self.connection, 72 | follower_username=requested_user.username, 73 | following_username=target_user.username, 74 | ) 75 | -------------------------------------------------------------------------------- /app/db/repositories/tags.py: -------------------------------------------------------------------------------- 1 | from typing import List, Sequence 2 | 3 | from app.db.queries.queries import queries 4 | from app.db.repositories.base import BaseRepository 5 | 6 | 7 | class TagsRepository(BaseRepository): 8 | async def get_all_tags(self) -> List[str]: 9 | tags_row = await queries.get_all_tags(self.connection) 10 | return [tag[0] for tag in tags_row] 11 | 12 | async def create_tags_that_dont_exist(self, *, tags: Sequence[str]) -> None: 13 | await queries.create_new_tags(self.connection, [{"tag": tag} for tag in tags]) 14 | -------------------------------------------------------------------------------- /app/db/repositories/users.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from app.db.errors import EntityDoesNotExist 4 | from app.db.queries.queries import queries 5 | from app.db.repositories.base import BaseRepository 6 | from app.models.domain.users import User, UserInDB 7 | 8 | 9 | class UsersRepository(BaseRepository): 10 | async def get_user_by_email(self, *, email: str) -> UserInDB: 11 | user_row = await queries.get_user_by_email(self.connection, email=email) 12 | if user_row: 13 | return UserInDB(**user_row) 14 | 15 | raise EntityDoesNotExist("user with email {0} does not exist".format(email)) 16 | 17 | async def get_user_by_username(self, *, username: str) -> UserInDB: 18 | user_row = await queries.get_user_by_username( 19 | self.connection, 20 | username=username, 21 | ) 22 | if user_row: 23 | return UserInDB(**user_row) 24 | 25 | raise EntityDoesNotExist( 26 | "user with username {0} does not exist".format(username), 27 | ) 28 | 29 | async def create_user( 30 | self, 31 | *, 32 | username: str, 33 | email: str, 34 | password: str, 35 | ) -> UserInDB: 36 | user = UserInDB(username=username, email=email) 37 | user.change_password(password) 38 | 39 | async with self.connection.transaction(): 40 | user_row = await queries.create_new_user( 41 | self.connection, 42 | username=user.username, 43 | email=user.email, 44 | salt=user.salt, 45 | hashed_password=user.hashed_password, 46 | ) 47 | 48 | return user.copy(update=dict(user_row)) 49 | 50 | async def update_user( # noqa: WPS211 51 | self, 52 | *, 53 | user: User, 54 | username: Optional[str] = None, 55 | email: Optional[str] = None, 56 | password: Optional[str] = None, 57 | bio: Optional[str] = None, 58 | image: Optional[str] = None, 59 | ) -> UserInDB: 60 | user_in_db = await self.get_user_by_username(username=user.username) 61 | 62 | user_in_db.username = username or user_in_db.username 63 | user_in_db.email = email or user_in_db.email 64 | user_in_db.bio = bio or user_in_db.bio 65 | user_in_db.image = image or user_in_db.image 66 | if password: 67 | user_in_db.change_password(password) 68 | 69 | async with self.connection.transaction(): 70 | user_in_db.updated_at = await queries.update_user_by_username( 71 | self.connection, 72 | username=user.username, 73 | new_username=user_in_db.username, 74 | new_email=user_in_db.email, 75 | new_salt=user_in_db.salt, 76 | new_password=user_in_db.hashed_password, 77 | new_bio=user_in_db.bio, 78 | new_image=user_in_db.image, 79 | ) 80 | 81 | return user_in_db 82 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.exceptions import RequestValidationError 3 | from starlette.exceptions import HTTPException 4 | from starlette.middleware.cors import CORSMiddleware 5 | 6 | from app.api.errors.http_error import http_error_handler 7 | from app.api.errors.validation_error import http422_error_handler 8 | from app.api.routes.api import router as api_router 9 | from app.core.config import get_app_settings 10 | from app.core.events import create_start_app_handler, create_stop_app_handler 11 | 12 | 13 | def get_application() -> FastAPI: 14 | settings = get_app_settings() 15 | 16 | settings.configure_logging() 17 | 18 | application = FastAPI(**settings.fastapi_kwargs) 19 | 20 | application.add_middleware( 21 | CORSMiddleware, 22 | allow_origins=settings.allowed_hosts, 23 | allow_credentials=True, 24 | allow_methods=["*"], 25 | allow_headers=["*"], 26 | ) 27 | 28 | application.add_event_handler( 29 | "startup", 30 | create_start_app_handler(application, settings), 31 | ) 32 | application.add_event_handler( 33 | "shutdown", 34 | create_stop_app_handler(application), 35 | ) 36 | 37 | application.add_exception_handler(HTTPException, http_error_handler) 38 | application.add_exception_handler(RequestValidationError, http422_error_handler) 39 | 40 | application.include_router(api_router, prefix=settings.api_prefix) 41 | 42 | return application 43 | 44 | 45 | app = get_application() 46 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/models/__init__.py -------------------------------------------------------------------------------- /app/models/common.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from pydantic import BaseModel, Field, validator 4 | 5 | 6 | class DateTimeModelMixin(BaseModel): 7 | created_at: datetime.datetime = None # type: ignore 8 | updated_at: datetime.datetime = None # type: ignore 9 | 10 | @validator("created_at", "updated_at", pre=True) 11 | def default_datetime( 12 | cls, # noqa: N805 13 | value: datetime.datetime, # noqa: WPS110 14 | ) -> datetime.datetime: 15 | return value or datetime.datetime.now() 16 | 17 | 18 | class IDModelMixin(BaseModel): 19 | id_: int = Field(0, alias="id") 20 | -------------------------------------------------------------------------------- /app/models/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/models/domain/__init__.py -------------------------------------------------------------------------------- /app/models/domain/articles.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from app.models.common import DateTimeModelMixin, IDModelMixin 4 | from app.models.domain.profiles import Profile 5 | from app.models.domain.rwmodel import RWModel 6 | 7 | 8 | class Article(IDModelMixin, DateTimeModelMixin, RWModel): 9 | slug: str 10 | title: str 11 | description: str 12 | body: str 13 | tags: List[str] 14 | author: Profile 15 | favorited: bool 16 | favorites_count: int 17 | -------------------------------------------------------------------------------- /app/models/domain/comments.py: -------------------------------------------------------------------------------- 1 | from app.models.common import DateTimeModelMixin, IDModelMixin 2 | from app.models.domain.profiles import Profile 3 | from app.models.domain.rwmodel import RWModel 4 | 5 | 6 | class Comment(IDModelMixin, DateTimeModelMixin, RWModel): 7 | body: str 8 | author: Profile 9 | -------------------------------------------------------------------------------- /app/models/domain/profiles.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from app.models.domain.rwmodel import RWModel 4 | 5 | 6 | class Profile(RWModel): 7 | username: str 8 | bio: str = "" 9 | image: Optional[str] = None 10 | following: bool = False 11 | -------------------------------------------------------------------------------- /app/models/domain/rwmodel.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from pydantic import BaseConfig, BaseModel 4 | 5 | 6 | def convert_datetime_to_realworld(dt: datetime.datetime) -> str: 7 | return dt.replace(tzinfo=datetime.timezone.utc).isoformat().replace("+00:00", "Z") 8 | 9 | 10 | def convert_field_to_camel_case(string: str) -> str: 11 | return "".join( 12 | word if index == 0 else word.capitalize() 13 | for index, word in enumerate(string.split("_")) 14 | ) 15 | 16 | 17 | class RWModel(BaseModel): 18 | class Config(BaseConfig): 19 | allow_population_by_field_name = True 20 | json_encoders = {datetime.datetime: convert_datetime_to_realworld} 21 | alias_generator = convert_field_to_camel_case 22 | -------------------------------------------------------------------------------- /app/models/domain/users.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from app.models.common import DateTimeModelMixin, IDModelMixin 4 | from app.models.domain.rwmodel import RWModel 5 | from app.services import security 6 | 7 | 8 | class User(RWModel): 9 | username: str 10 | email: str 11 | bio: str = "" 12 | image: Optional[str] = None 13 | 14 | 15 | class UserInDB(IDModelMixin, DateTimeModelMixin, User): 16 | salt: str = "" 17 | hashed_password: str = "" 18 | 19 | def check_password(self, password: str) -> bool: 20 | return security.verify_password(self.salt + password, self.hashed_password) 21 | 22 | def change_password(self, password: str) -> None: 23 | self.salt = security.generate_salt() 24 | self.hashed_password = security.get_password_hash(self.salt + password) 25 | -------------------------------------------------------------------------------- /app/models/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/models/schemas/__init__.py -------------------------------------------------------------------------------- /app/models/schemas/articles.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from app.models.domain.articles import Article 6 | from app.models.schemas.rwschema import RWSchema 7 | 8 | DEFAULT_ARTICLES_LIMIT = 20 9 | DEFAULT_ARTICLES_OFFSET = 0 10 | 11 | 12 | class ArticleForResponse(RWSchema, Article): 13 | tags: List[str] = Field(..., alias="tagList") 14 | 15 | 16 | class ArticleInResponse(RWSchema): 17 | article: ArticleForResponse 18 | 19 | 20 | class ArticleInCreate(RWSchema): 21 | title: str 22 | description: str 23 | body: str 24 | tags: List[str] = Field([], alias="tagList") 25 | 26 | 27 | class ArticleInUpdate(RWSchema): 28 | title: Optional[str] = None 29 | description: Optional[str] = None 30 | body: Optional[str] = None 31 | 32 | 33 | class ListOfArticlesInResponse(RWSchema): 34 | articles: List[ArticleForResponse] 35 | articles_count: int 36 | 37 | 38 | class ArticlesFilters(BaseModel): 39 | tag: Optional[str] = None 40 | author: Optional[str] = None 41 | favorited: Optional[str] = None 42 | limit: int = Field(DEFAULT_ARTICLES_LIMIT, ge=1) 43 | offset: int = Field(DEFAULT_ARTICLES_OFFSET, ge=0) 44 | -------------------------------------------------------------------------------- /app/models/schemas/comments.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from app.models.domain.comments import Comment 4 | from app.models.schemas.rwschema import RWSchema 5 | 6 | 7 | class ListOfCommentsInResponse(RWSchema): 8 | comments: List[Comment] 9 | 10 | 11 | class CommentInResponse(RWSchema): 12 | comment: Comment 13 | 14 | 15 | class CommentInCreate(RWSchema): 16 | body: str 17 | -------------------------------------------------------------------------------- /app/models/schemas/jwt.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class JWTMeta(BaseModel): 7 | exp: datetime 8 | sub: str 9 | 10 | 11 | class JWTUser(BaseModel): 12 | username: str 13 | -------------------------------------------------------------------------------- /app/models/schemas/profiles.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from app.models.domain.profiles import Profile 4 | 5 | 6 | class ProfileInResponse(BaseModel): 7 | profile: Profile 8 | -------------------------------------------------------------------------------- /app/models/schemas/rwschema.py: -------------------------------------------------------------------------------- 1 | from app.models.domain.rwmodel import RWModel 2 | 3 | 4 | class RWSchema(RWModel): 5 | class Config(RWModel.Config): 6 | orm_mode = True 7 | -------------------------------------------------------------------------------- /app/models/schemas/tags.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class TagsInList(BaseModel): 7 | tags: List[str] 8 | -------------------------------------------------------------------------------- /app/models/schemas/users.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, EmailStr, HttpUrl 4 | 5 | from app.models.domain.users import User 6 | from app.models.schemas.rwschema import RWSchema 7 | 8 | 9 | class UserInLogin(RWSchema): 10 | email: EmailStr 11 | password: str 12 | 13 | 14 | class UserInCreate(UserInLogin): 15 | username: str 16 | 17 | 18 | class UserInUpdate(BaseModel): 19 | username: Optional[str] = None 20 | email: Optional[EmailStr] = None 21 | password: Optional[str] = None 22 | bio: Optional[str] = None 23 | image: Optional[HttpUrl] = None 24 | 25 | 26 | class UserWithToken(User): 27 | token: str 28 | 29 | 30 | class UserInResponse(RWSchema): 31 | user: UserWithToken 32 | -------------------------------------------------------------------------------- /app/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/resources/__init__.py -------------------------------------------------------------------------------- /app/resources/strings.py: -------------------------------------------------------------------------------- 1 | # API messages 2 | 3 | USER_DOES_NOT_EXIST_ERROR = "user does not exist" 4 | ARTICLE_DOES_NOT_EXIST_ERROR = "article does not exist" 5 | ARTICLE_ALREADY_EXISTS = "article already exists" 6 | USER_IS_NOT_AUTHOR_OF_ARTICLE = "you are not an author of this article" 7 | 8 | INCORRECT_LOGIN_INPUT = "incorrect email or password" 9 | USERNAME_TAKEN = "user with this username already exists" 10 | EMAIL_TAKEN = "user with this email already exists" 11 | 12 | UNABLE_TO_FOLLOW_YOURSELF = "user can not follow him self" 13 | UNABLE_TO_UNSUBSCRIBE_FROM_YOURSELF = "user can not unsubscribe from him self" 14 | USER_IS_NOT_FOLLOWED = "you don't follow this user" 15 | USER_IS_ALREADY_FOLLOWED = "you follow this user already" 16 | 17 | WRONG_TOKEN_PREFIX = "unsupported authorization type" # noqa: S105 18 | MALFORMED_PAYLOAD = "could not validate credentials" 19 | 20 | ARTICLE_IS_ALREADY_FAVORITED = "you are already marked this articles as favorite" 21 | ARTICLE_IS_NOT_FAVORITED = "article is not favorited" 22 | 23 | COMMENT_DOES_NOT_EXIST = "comment does not exist" 24 | 25 | AUTHENTICATION_REQUIRED = "authentication required" 26 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/app/services/__init__.py -------------------------------------------------------------------------------- /app/services/articles.py: -------------------------------------------------------------------------------- 1 | from slugify import slugify 2 | 3 | from app.db.errors import EntityDoesNotExist 4 | from app.db.repositories.articles import ArticlesRepository 5 | from app.models.domain.articles import Article 6 | from app.models.domain.users import User 7 | 8 | 9 | async def check_article_exists(articles_repo: ArticlesRepository, slug: str) -> bool: 10 | try: 11 | await articles_repo.get_article_by_slug(slug=slug) 12 | except EntityDoesNotExist: 13 | return False 14 | 15 | return True 16 | 17 | 18 | def get_slug_for_article(title: str) -> str: 19 | return slugify(title) 20 | 21 | 22 | def check_user_can_modify_article(article: Article, user: User) -> bool: 23 | return article.author.username == user.username 24 | -------------------------------------------------------------------------------- /app/services/authentication.py: -------------------------------------------------------------------------------- 1 | from app.db.errors import EntityDoesNotExist 2 | from app.db.repositories.users import UsersRepository 3 | 4 | 5 | async def check_username_is_taken(repo: UsersRepository, username: str) -> bool: 6 | try: 7 | await repo.get_user_by_username(username=username) 8 | except EntityDoesNotExist: 9 | return False 10 | 11 | return True 12 | 13 | 14 | async def check_email_is_taken(repo: UsersRepository, email: str) -> bool: 15 | try: 16 | await repo.get_user_by_email(email=email) 17 | except EntityDoesNotExist: 18 | return False 19 | 20 | return True 21 | -------------------------------------------------------------------------------- /app/services/comments.py: -------------------------------------------------------------------------------- 1 | from app.models.domain.comments import Comment 2 | from app.models.domain.users import User 3 | 4 | 5 | def check_user_can_modify_comment(comment: Comment, user: User) -> bool: 6 | return comment.author.username == user.username 7 | -------------------------------------------------------------------------------- /app/services/jwt.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Dict 3 | 4 | import jwt 5 | from pydantic import ValidationError 6 | 7 | from app.models.domain.users import User 8 | from app.models.schemas.jwt import JWTMeta, JWTUser 9 | 10 | JWT_SUBJECT = "access" 11 | ALGORITHM = "HS256" 12 | ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # one week 13 | 14 | 15 | def create_jwt_token( 16 | *, 17 | jwt_content: Dict[str, str], 18 | secret_key: str, 19 | expires_delta: timedelta, 20 | ) -> str: 21 | to_encode = jwt_content.copy() 22 | expire = datetime.utcnow() + expires_delta 23 | to_encode.update(JWTMeta(exp=expire, sub=JWT_SUBJECT).dict()) 24 | return jwt.encode(to_encode, secret_key, algorithm=ALGORITHM) 25 | 26 | 27 | def create_access_token_for_user(user: User, secret_key: str) -> str: 28 | return create_jwt_token( 29 | jwt_content=JWTUser(username=user.username).dict(), 30 | secret_key=secret_key, 31 | expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), 32 | ) 33 | 34 | 35 | def get_username_from_token(token: str, secret_key: str) -> str: 36 | try: 37 | return JWTUser(**jwt.decode(token, secret_key, algorithms=[ALGORITHM])).username 38 | except jwt.PyJWTError as decode_error: 39 | raise ValueError("unable to decode JWT token") from decode_error 40 | except ValidationError as validation_error: 41 | raise ValueError("malformed payload in token") from validation_error 42 | -------------------------------------------------------------------------------- /app/services/security.py: -------------------------------------------------------------------------------- 1 | import bcrypt 2 | from passlib.context import CryptContext 3 | 4 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 5 | 6 | 7 | def generate_salt() -> str: 8 | return bcrypt.gensalt().decode() 9 | 10 | 11 | def verify_password(plain_password: str, hashed_password: str) -> bool: 12 | return pwd_context.verify(plain_password, hashed_password) 13 | 14 | 15 | def get_password_hash(password: str) -> str: 16 | return pwd_context.hash(password) 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | build: . 6 | restart: on-failure 7 | ports: 8 | - "8000:8000" 9 | environment: 10 | DATABASE_URL: "postgresql://postgres:postgres@db/postgres" 11 | env_file: 12 | - .env 13 | depends_on: 14 | - db 15 | db: 16 | image: postgres:11.5-alpine 17 | ports: 18 | - "5432:5432" 19 | env_file: 20 | - .env 21 | volumes: 22 | - ./postgres-data:/var/lib/postgresql/data:cached 23 | -------------------------------------------------------------------------------- /postman/run-api-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | 4 | SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 5 | 6 | APIURL=${APIURL:-https://conduit.productionready.io/api} 7 | USERNAME=${USERNAME:-u`date +%s`} 8 | EMAIL=${EMAIL:-$USERNAME@mail.com} 9 | PASSWORD=${PASSWORD:-password} 10 | 11 | npx newman run $SCRIPTDIR/Conduit.postman_collection.json \ 12 | --delay-request 500 \ 13 | --global-var "APIURL=$APIURL" \ 14 | --global-var "USERNAME=$USERNAME" \ 15 | --global-var "EMAIL=$EMAIL" \ 16 | --global-var "PASSWORD=$PASSWORD" -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi-realworld-example-app" 3 | version = "0.0.0" 4 | description = "Backend logic implementation for https://github.com/gothinkster/realworld with awesome FastAPI" 5 | authors = ["Nik Sidnev "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | uvicorn = "^0.18.2" 11 | fastapi = "^0.79.1" 12 | pydantic = { version = "^1.9", extras = ["email", "dotenv"] } 13 | passlib = { version = "^1.7", extras = ["bcrypt"] } 14 | pyjwt = "^2.4" 15 | databases = "^0.6.1" 16 | asyncpg = "^0.26.0" 17 | psycopg2-binary = "^2.9.3" 18 | aiosql = "^6.2" 19 | pypika = "^0.48.9" 20 | alembic = "^1.8" 21 | python-slugify = "^6.1" 22 | Unidecode = "^1.3" 23 | loguru = "^0.6.0" 24 | 25 | [tool.poetry.dev-dependencies] 26 | black = "^22.6.0" 27 | isort = "^5.10" 28 | autoflake = "^1.4" 29 | wemake-python-styleguide = "^0.16.1" 30 | mypy = "^0.971" 31 | flake8-fixme = "^1.1" 32 | pytest = "^7.1" 33 | pytest-cov = "^3.0" 34 | pytest-asyncio = "^0.19.0" 35 | pytest-env = "^0.6.2" 36 | pytest-xdist = "^2.4.0" 37 | httpx = "^0.23.0" 38 | asgi-lifespan = "^1.0.1" 39 | 40 | [tool.isort] 41 | profile = "black" 42 | src_paths = ["app", "tests"] 43 | combine_as_imports = true 44 | 45 | [tool.pytest.ini_options] 46 | testpaths = "tests" 47 | filterwarnings = "error" 48 | addopts = ''' 49 | --strict-markers 50 | --tb=short 51 | --cov=app 52 | --cov=tests 53 | --cov-branch 54 | --cov-report=term-missing 55 | --cov-report=html 56 | --cov-report=xml 57 | --no-cov-on-fail 58 | --cov-fail-under=100 59 | --numprocesses=auto 60 | --asyncio-mode=auto 61 | ''' 62 | env = [ 63 | "SECRET_KEY=secret", 64 | "MAX_CONNECTIONS_COUNT=1", 65 | "MIN_CONNECTIONS_COUNT=1" 66 | ] 67 | 68 | [build-system] 69 | requires = ["poetry>=1.0"] 70 | build-backend = "poetry.masonry.api" 71 | -------------------------------------------------------------------------------- /scripts/format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | isort --force-single-line-imports app tests 6 | autoflake --recursive --remove-all-unused-imports --remove-unused-variables --in-place app tests 7 | black app tests 8 | isort app tests 9 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | 7 | flake8 app --exclude=app/db/migrations 8 | mypy app 9 | 10 | black --check app --diff 11 | isort --check-only app 12 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | pytest --cov=app --cov=tests --cov-report=term-missing --cov-config=setup.cfg ${@} 7 | -------------------------------------------------------------------------------- /scripts/test-cov-html: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | bash scripts/test --cov-report=html ${@} 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:report] 2 | precision = 2 3 | exclude_lines = 4 | pragma: no cover 5 | raise NotImplementedError 6 | raise NotImplemented 7 | 8 | [coverage:run] 9 | source = app 10 | branch = True 11 | 12 | [mypy] 13 | plugins = pydantic.mypy 14 | 15 | strict_optional = True 16 | warn_redundant_casts = True 17 | warn_unused_ignores = True 18 | disallow_any_generics = True 19 | check_untyped_defs = True 20 | 21 | disallow_untyped_defs = True 22 | 23 | [pydantic-mypy] 24 | init_forbid_extra = True 25 | init_typed = True 26 | warn_required_dynamic_aliases = True 27 | warn_untyped_fields = True 28 | 29 | [mypy-sqlalchemy.*] 30 | ignore_missing_imports = True 31 | 32 | [mypy-alembic.*] 33 | ignore_missing_imports = True 34 | 35 | [mypy-loguru.*] 36 | ignore_missing_imports = True 37 | 38 | [mypy-asyncpg.*] 39 | ignore_missing_imports = True 40 | 41 | [mypy-bcrypt.*] 42 | ignore_missing_imports = True 43 | 44 | [mypy-passlib.*] 45 | ignore_missing_imports = True 46 | 47 | [mypy-slugify.*] 48 | ignore_missing_imports = True 49 | 50 | [mypy-pypika.*] 51 | ignore_missing_imports = True 52 | 53 | [flake8] 54 | format = wemake 55 | max-line-length = 88 56 | per-file-ignores = 57 | # ignore error on builtin names for TypedTable classes, since just mapper for SQL table 58 | app/db/queries/tables.py: WPS125, 59 | 60 | # ignore black disabling in some places for queries building using pypika 61 | app/db/repositories/*.py: E800, 62 | 63 | app/api/dependencies/authentication.py: WPS201, 64 | ignore = 65 | # common errors: 66 | # FastAPI architecture requires a lot of functions calls as default arguments, so ignore it here. 67 | B008, 68 | # docs are missing in this project. 69 | D, RST 70 | 71 | # WPS: 3xx 72 | # IMO, but the obligation to specify the base class is redundant. 73 | WPS306, 74 | 75 | # WPS: 4xx 76 | # FastAPI architecture requires a lot of complex calls as default arguments, so ignore it here. 77 | WPS404, 78 | # again, FastAPI DI architecture involves a lot of nested functions as DI providers. 79 | WPS430, 80 | # used for pypika operations 81 | WPS465, 82 | 83 | # WPS: 6xx 84 | # pydantic defines models in dataclasses model style, but not supported by WPS. 85 | WPS601, 86 | no-accept-encodings = True 87 | nested-classes-whitelist=Config 88 | inline-quotes = double 89 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | import pytest 4 | from asgi_lifespan import LifespanManager 5 | from asyncpg.pool import Pool 6 | from fastapi import FastAPI 7 | from httpx import AsyncClient 8 | 9 | from app.db.repositories.articles import ArticlesRepository 10 | from app.db.repositories.users import UsersRepository 11 | from app.models.domain.articles import Article 12 | from app.models.domain.users import UserInDB 13 | from app.services import jwt 14 | from tests.fake_asyncpg_pool import FakeAsyncPGPool 15 | 16 | environ["APP_ENV"] = "test" 17 | 18 | 19 | @pytest.fixture 20 | def app() -> FastAPI: 21 | from app.main import get_application # local import for testing purpose 22 | 23 | return get_application() 24 | 25 | 26 | @pytest.fixture 27 | async def initialized_app(app: FastAPI) -> FastAPI: 28 | async with LifespanManager(app): 29 | app.state.pool = await FakeAsyncPGPool.create_pool(app.state.pool) 30 | yield app 31 | 32 | 33 | @pytest.fixture 34 | def pool(initialized_app: FastAPI) -> Pool: 35 | return initialized_app.state.pool 36 | 37 | 38 | @pytest.fixture 39 | async def client(initialized_app: FastAPI) -> AsyncClient: 40 | async with AsyncClient( 41 | app=initialized_app, 42 | base_url="http://testserver", 43 | headers={"Content-Type": "application/json"}, 44 | ) as client: 45 | yield client 46 | 47 | 48 | @pytest.fixture 49 | def authorization_prefix() -> str: 50 | from app.core.config import get_app_settings 51 | 52 | settings = get_app_settings() 53 | jwt_token_prefix = settings.jwt_token_prefix 54 | 55 | return jwt_token_prefix 56 | 57 | 58 | @pytest.fixture 59 | async def test_user(pool: Pool) -> UserInDB: 60 | async with pool.acquire() as conn: 61 | return await UsersRepository(conn).create_user( 62 | email="test@test.com", password="password", username="username" 63 | ) 64 | 65 | 66 | @pytest.fixture 67 | async def test_article(test_user: UserInDB, pool: Pool) -> Article: 68 | async with pool.acquire() as connection: 69 | articles_repo = ArticlesRepository(connection) 70 | return await articles_repo.create_article( 71 | slug="test-slug", 72 | title="Test Slug", 73 | description="Slug for tests", 74 | body="Test " * 100, 75 | author=test_user, 76 | tags=["tests", "testing", "pytest"], 77 | ) 78 | 79 | 80 | @pytest.fixture 81 | def token(test_user: UserInDB) -> str: 82 | return jwt.create_access_token_for_user(test_user, environ["SECRET_KEY"]) 83 | 84 | 85 | @pytest.fixture 86 | def authorized_client( 87 | client: AsyncClient, token: str, authorization_prefix: str 88 | ) -> AsyncClient: 89 | client.headers = { 90 | "Authorization": f"{authorization_prefix} {token}", 91 | **client.headers, 92 | } 93 | return client 94 | -------------------------------------------------------------------------------- /tests/fake_asyncpg_pool.py: -------------------------------------------------------------------------------- 1 | from types import TracebackType 2 | from typing import Optional, Type 3 | 4 | from asyncpg import Connection 5 | from asyncpg.pool import Pool 6 | 7 | 8 | class FakeAsyncPGPool: 9 | def __init__(self, pool: Pool) -> None: 10 | self._pool = pool 11 | self._conn = None 12 | self._tx = None 13 | 14 | @classmethod 15 | async def create_pool(cls, pool: Pool) -> "FakeAsyncPGPool": 16 | pool = cls(pool) 17 | conn = await pool._pool.acquire() 18 | tx = conn.transaction() 19 | await tx.start() 20 | pool._conn = conn 21 | pool._tx = tx 22 | return pool 23 | 24 | async def close(self) -> None: 25 | await self._tx.rollback() 26 | await self._pool.release(self._conn) 27 | await self._pool.close() 28 | 29 | def acquire(self, *, timeout: Optional[float] = None) -> "FakePoolAcquireContent": 30 | return FakePoolAcquireContent(self) 31 | 32 | 33 | class FakePoolAcquireContent: 34 | def __init__(self, pool: FakeAsyncPGPool) -> None: 35 | self._pool = pool 36 | 37 | async def __aenter__(self) -> Connection: 38 | return self._pool._conn 39 | 40 | async def __aexit__( 41 | self, 42 | exc_type: Optional[Type[Exception]], 43 | exc: Optional[Exception], 44 | tb: Optional[TracebackType], 45 | ) -> None: 46 | pass 47 | -------------------------------------------------------------------------------- /tests/test_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/tests/test_api/__init__.py -------------------------------------------------------------------------------- /tests/test_api/test_errors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/tests/test_api/test_errors/__init__.py -------------------------------------------------------------------------------- /tests/test_api/test_errors/test_422_error.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from httpx import AsyncClient 4 | from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY 5 | 6 | pytestmark = pytest.mark.asyncio 7 | 8 | 9 | async def test_frw_validation_error_format(app: FastAPI): 10 | @app.get("/wrong_path/{param}") 11 | def route_for_test(param: int) -> None: # pragma: no cover 12 | pass 13 | 14 | async with AsyncClient(base_url="http://testserver", app=app) as client: 15 | response = await client.get("/wrong_path/asd") 16 | 17 | assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY 18 | 19 | error_data = response.json() 20 | assert "errors" in error_data 21 | -------------------------------------------------------------------------------- /tests/test_api/test_errors/test_error.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from httpx import AsyncClient 4 | from starlette.status import HTTP_404_NOT_FOUND 5 | 6 | pytestmark = pytest.mark.asyncio 7 | 8 | 9 | async def test_frw_validation_error_format(app: FastAPI): 10 | async with AsyncClient(base_url="http://testserver", app=app) as client: 11 | response = await client.get("/wrong_path/asd") 12 | 13 | assert response.status_code == HTTP_404_NOT_FOUND 14 | 15 | error_data = response.json() 16 | assert "errors" in error_data 17 | -------------------------------------------------------------------------------- /tests/test_api/test_routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/tests/test_api/test_routes/__init__.py -------------------------------------------------------------------------------- /tests/test_api/test_routes/test_articles.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from asyncpg.pool import Pool 3 | from fastapi import FastAPI 4 | from httpx import AsyncClient 5 | from starlette import status 6 | 7 | from app.db.errors import EntityDoesNotExist 8 | from app.db.repositories.articles import ArticlesRepository 9 | from app.db.repositories.profiles import ProfilesRepository 10 | from app.db.repositories.users import UsersRepository 11 | from app.models.domain.articles import Article 12 | from app.models.domain.users import UserInDB 13 | from app.models.schemas.articles import ArticleInResponse, ListOfArticlesInResponse 14 | 15 | pytestmark = pytest.mark.asyncio 16 | 17 | 18 | async def test_user_can_not_create_article_with_duplicated_slug( 19 | app: FastAPI, authorized_client: AsyncClient, test_article: Article 20 | ) -> None: 21 | article_data = { 22 | "title": "Test Slug", 23 | "body": "does not matter", 24 | "description": "¯\\_(ツ)_/¯", 25 | } 26 | response = await authorized_client.post( 27 | app.url_path_for("articles:create-article"), json={"article": article_data} 28 | ) 29 | assert response.status_code == status.HTTP_400_BAD_REQUEST 30 | 31 | 32 | async def test_user_can_create_article( 33 | app: FastAPI, authorized_client: AsyncClient, test_user: UserInDB 34 | ) -> None: 35 | article_data = { 36 | "title": "Test Slug", 37 | "body": "does not matter", 38 | "description": "¯\\_(ツ)_/¯", 39 | } 40 | response = await authorized_client.post( 41 | app.url_path_for("articles:create-article"), json={"article": article_data} 42 | ) 43 | article = ArticleInResponse(**response.json()) 44 | assert article.article.title == article_data["title"] 45 | assert article.article.author.username == test_user.username 46 | 47 | 48 | async def test_not_existing_tags_will_be_created_without_duplication( 49 | app: FastAPI, authorized_client: AsyncClient, test_user: UserInDB 50 | ) -> None: 51 | article_data = { 52 | "title": "Test Slug", 53 | "body": "does not matter", 54 | "description": "¯\\_(ツ)_/¯", 55 | "tagList": ["tag1", "tag2", "tag3", "tag3"], 56 | } 57 | response = await authorized_client.post( 58 | app.url_path_for("articles:create-article"), json={"article": article_data} 59 | ) 60 | article = ArticleInResponse(**response.json()) 61 | assert set(article.article.tags) == {"tag1", "tag2", "tag3"} 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "api_method, route_name", 66 | (("GET", "articles:get-article"), ("PUT", "articles:update-article")), 67 | ) 68 | async def test_user_can_not_retrieve_not_existing_article( 69 | app: FastAPI, 70 | authorized_client: AsyncClient, 71 | test_article: Article, 72 | api_method: str, 73 | route_name: str, 74 | ) -> None: 75 | response = await authorized_client.request( 76 | api_method, app.url_path_for(route_name, slug="wrong-slug") 77 | ) 78 | assert response.status_code == status.HTTP_404_NOT_FOUND 79 | 80 | 81 | async def test_user_can_retrieve_article_if_exists( 82 | app: FastAPI, authorized_client: AsyncClient, test_article: Article 83 | ) -> None: 84 | response = await authorized_client.get( 85 | app.url_path_for("articles:get-article", slug=test_article.slug) 86 | ) 87 | article = ArticleInResponse(**response.json()) 88 | assert article.article == test_article 89 | 90 | 91 | @pytest.mark.parametrize( 92 | "update_field, update_value, extra_updates", 93 | ( 94 | ("title", "New Title", {"slug": "new-title"}), 95 | ("description", "new description", {}), 96 | ("body", "new body", {}), 97 | ), 98 | ) 99 | async def test_user_can_update_article( 100 | app: FastAPI, 101 | authorized_client: AsyncClient, 102 | test_article: Article, 103 | update_field: str, 104 | update_value: str, 105 | extra_updates: dict, 106 | ) -> None: 107 | response = await authorized_client.put( 108 | app.url_path_for("articles:update-article", slug=test_article.slug), 109 | json={"article": {update_field: update_value}}, 110 | ) 111 | 112 | assert response.status_code == status.HTTP_200_OK 113 | 114 | article = ArticleInResponse(**response.json()).article 115 | article_as_dict = article.dict() 116 | assert article_as_dict[update_field] == update_value 117 | 118 | for extra_field, extra_value in extra_updates.items(): 119 | assert article_as_dict[extra_field] == extra_value 120 | 121 | exclude_fields = {update_field, *extra_updates.keys(), "updated_at"} 122 | assert article.dict(exclude=exclude_fields) == test_article.dict( 123 | exclude=exclude_fields 124 | ) 125 | 126 | 127 | @pytest.mark.parametrize( 128 | "api_method, route_name", 129 | (("PUT", "articles:update-article"), ("DELETE", "articles:delete-article")), 130 | ) 131 | async def test_user_can_not_modify_article_that_is_not_authored_by_him( 132 | app: FastAPI, 133 | authorized_client: AsyncClient, 134 | pool: Pool, 135 | api_method: str, 136 | route_name: str, 137 | ) -> None: 138 | async with pool.acquire() as connection: 139 | users_repo = UsersRepository(connection) 140 | user = await users_repo.create_user( 141 | username="test_author", email="author@email.com", password="password" 142 | ) 143 | articles_repo = ArticlesRepository(connection) 144 | await articles_repo.create_article( 145 | slug="test-slug", 146 | title="Test Slug", 147 | description="Slug for tests", 148 | body="Test " * 100, 149 | author=user, 150 | tags=["tests", "testing", "pytest"], 151 | ) 152 | 153 | response = await authorized_client.request( 154 | api_method, 155 | app.url_path_for(route_name, slug="test-slug"), 156 | json={"article": {"title": "Updated Title"}}, 157 | ) 158 | assert response.status_code == status.HTTP_403_FORBIDDEN 159 | 160 | 161 | async def test_user_can_delete_his_article( 162 | app: FastAPI, 163 | authorized_client: AsyncClient, 164 | test_article: Article, 165 | pool: Pool, 166 | ) -> None: 167 | await authorized_client.delete( 168 | app.url_path_for("articles:delete-article", slug=test_article.slug) 169 | ) 170 | 171 | async with pool.acquire() as connection: 172 | articles_repo = ArticlesRepository(connection) 173 | with pytest.raises(EntityDoesNotExist): 174 | await articles_repo.get_article_by_slug(slug=test_article.slug) 175 | 176 | 177 | @pytest.mark.parametrize( 178 | "api_method, route_name, favorite_state", 179 | ( 180 | ("POST", "articles:mark-article-favorite", True), 181 | ("DELETE", "articles:unmark-article-favorite", False), 182 | ), 183 | ) 184 | async def test_user_can_change_favorite_state( 185 | app: FastAPI, 186 | authorized_client: AsyncClient, 187 | test_article: Article, 188 | test_user: UserInDB, 189 | pool: Pool, 190 | api_method: str, 191 | route_name: str, 192 | favorite_state: bool, 193 | ) -> None: 194 | if not favorite_state: 195 | async with pool.acquire() as connection: 196 | articles_repo = ArticlesRepository(connection) 197 | await articles_repo.add_article_into_favorites( 198 | article=test_article, user=test_user 199 | ) 200 | 201 | await authorized_client.request( 202 | api_method, app.url_path_for(route_name, slug=test_article.slug) 203 | ) 204 | 205 | response = await authorized_client.get( 206 | app.url_path_for("articles:get-article", slug=test_article.slug) 207 | ) 208 | 209 | article = ArticleInResponse(**response.json()) 210 | 211 | assert article.article.favorited == favorite_state 212 | assert article.article.favorites_count == int(favorite_state) 213 | 214 | 215 | @pytest.mark.parametrize( 216 | "api_method, route_name, favorite_state", 217 | ( 218 | ("POST", "articles:mark-article-favorite", True), 219 | ("DELETE", "articles:unmark-article-favorite", False), 220 | ), 221 | ) 222 | async def test_user_can_not_change_article_state_twice( 223 | app: FastAPI, 224 | authorized_client: AsyncClient, 225 | test_article: Article, 226 | test_user: UserInDB, 227 | pool: Pool, 228 | api_method: str, 229 | route_name: str, 230 | favorite_state: bool, 231 | ) -> None: 232 | if favorite_state: 233 | async with pool.acquire() as connection: 234 | articles_repo = ArticlesRepository(connection) 235 | await articles_repo.add_article_into_favorites( 236 | article=test_article, user=test_user 237 | ) 238 | 239 | response = await authorized_client.request( 240 | api_method, app.url_path_for(route_name, slug=test_article.slug) 241 | ) 242 | 243 | assert response.status_code == status.HTTP_400_BAD_REQUEST 244 | 245 | 246 | async def test_empty_feed_if_user_has_not_followings( 247 | app: FastAPI, 248 | authorized_client: AsyncClient, 249 | test_article: Article, 250 | test_user: UserInDB, 251 | pool: Pool, 252 | ) -> None: 253 | async with pool.acquire() as connection: 254 | users_repo = UsersRepository(connection) 255 | articles_repo = ArticlesRepository(connection) 256 | 257 | for i in range(5): 258 | user = await users_repo.create_user( 259 | username=f"user-{i}", email=f"user-{i}@email.com", password="password" 260 | ) 261 | for j in range(5): 262 | await articles_repo.create_article( 263 | slug=f"slug-{i}-{j}", 264 | title="tmp", 265 | description="tmp", 266 | body="tmp", 267 | author=user, 268 | tags=[f"tag-{i}-{j}"], 269 | ) 270 | 271 | response = await authorized_client.get( 272 | app.url_path_for("articles:get-user-feed-articles") 273 | ) 274 | 275 | articles = ListOfArticlesInResponse(**response.json()) 276 | assert articles.articles == [] 277 | 278 | 279 | async def test_user_will_receive_only_following_articles( 280 | app: FastAPI, 281 | authorized_client: AsyncClient, 282 | test_article: Article, 283 | test_user: UserInDB, 284 | pool: Pool, 285 | ) -> None: 286 | following_author_username = "user-2" 287 | async with pool.acquire() as connection: 288 | users_repo = UsersRepository(connection) 289 | profiles_repo = ProfilesRepository(connection) 290 | articles_repo = ArticlesRepository(connection) 291 | 292 | for i in range(5): 293 | user = await users_repo.create_user( 294 | username=f"user-{i}", email=f"user-{i}@email.com", password="password" 295 | ) 296 | if i == 2: 297 | await profiles_repo.add_user_into_followers( 298 | target_user=user, requested_user=test_user 299 | ) 300 | 301 | for j in range(5): 302 | await articles_repo.create_article( 303 | slug=f"slug-{i}-{j}", 304 | title="tmp", 305 | description="tmp", 306 | body="tmp", 307 | author=user, 308 | tags=[f"tag-{i}-{j}"], 309 | ) 310 | 311 | response = await authorized_client.get( 312 | app.url_path_for("articles:get-user-feed-articles") 313 | ) 314 | 315 | articles_from_response = ListOfArticlesInResponse(**response.json()) 316 | assert len(articles_from_response.articles) == 5 317 | 318 | all_from_following = ( 319 | article.author.username == following_author_username 320 | for article in articles_from_response.articles 321 | ) 322 | assert all(all_from_following) 323 | 324 | 325 | async def test_user_receiving_feed_with_limit_and_offset( 326 | app: FastAPI, 327 | authorized_client: AsyncClient, 328 | test_article: Article, 329 | test_user: UserInDB, 330 | pool: Pool, 331 | ) -> None: 332 | async with pool.acquire() as connection: 333 | users_repo = UsersRepository(connection) 334 | profiles_repo = ProfilesRepository(connection) 335 | articles_repo = ArticlesRepository(connection) 336 | 337 | for i in range(5): 338 | user = await users_repo.create_user( 339 | username=f"user-{i}", email=f"user-{i}@email.com", password="password" 340 | ) 341 | if i == 2: 342 | await profiles_repo.add_user_into_followers( 343 | target_user=user, requested_user=test_user 344 | ) 345 | 346 | for j in range(5): 347 | await articles_repo.create_article( 348 | slug=f"slug-{i}-{j}", 349 | title="tmp", 350 | description="tmp", 351 | body="tmp", 352 | author=user, 353 | tags=[f"tag-{i}-{j}"], 354 | ) 355 | 356 | full_response = await authorized_client.get( 357 | app.url_path_for("articles:get-user-feed-articles") 358 | ) 359 | full_articles = ListOfArticlesInResponse(**full_response.json()) 360 | 361 | response = await authorized_client.get( 362 | app.url_path_for("articles:get-user-feed-articles"), 363 | params={"limit": 2, "offset": 3}, 364 | ) 365 | 366 | articles_from_response = ListOfArticlesInResponse(**response.json()) 367 | assert full_articles.articles[3:] == articles_from_response.articles 368 | 369 | 370 | async def test_article_will_contain_only_attached_tags( 371 | app: FastAPI, authorized_client: AsyncClient, test_user: UserInDB, pool: Pool 372 | ) -> None: 373 | attached_tags = ["tag1", "tag3"] 374 | 375 | async with pool.acquire() as connection: 376 | articles_repo = ArticlesRepository(connection) 377 | 378 | await articles_repo.create_article( 379 | slug=f"test-slug", 380 | title="tmp", 381 | description="tmp", 382 | body="tmp", 383 | author=test_user, 384 | tags=attached_tags, 385 | ) 386 | 387 | for i in range(5): 388 | await articles_repo.create_article( 389 | slug=f"slug-{i}", 390 | title="tmp", 391 | description="tmp", 392 | body="tmp", 393 | author=test_user, 394 | tags=[f"tag-{i}"], 395 | ) 396 | 397 | response = await authorized_client.get( 398 | app.url_path_for("articles:get-article", slug="test-slug") 399 | ) 400 | article = ArticleInResponse(**response.json()) 401 | assert len(article.article.tags) == len(attached_tags) 402 | assert set(article.article.tags) == set(attached_tags) 403 | 404 | 405 | @pytest.mark.parametrize( 406 | "tag, result", (("", 7), ("tag1", 1), ("tag2", 2), ("wrong", 0)) 407 | ) 408 | async def test_filtering_by_tags( 409 | app: FastAPI, 410 | authorized_client: AsyncClient, 411 | test_user: UserInDB, 412 | pool: Pool, 413 | tag: str, 414 | result: int, 415 | ) -> None: 416 | async with pool.acquire() as connection: 417 | articles_repo = ArticlesRepository(connection) 418 | 419 | await articles_repo.create_article( 420 | slug=f"slug-1", 421 | title="tmp", 422 | description="tmp", 423 | body="tmp", 424 | author=test_user, 425 | tags=["tag1", "tag2"], 426 | ) 427 | await articles_repo.create_article( 428 | slug=f"slug-2", 429 | title="tmp", 430 | description="tmp", 431 | body="tmp", 432 | author=test_user, 433 | tags=["tag2"], 434 | ) 435 | 436 | for i in range(5, 10): 437 | await articles_repo.create_article( 438 | slug=f"slug-{i}", 439 | title="tmp", 440 | description="tmp", 441 | body="tmp", 442 | author=test_user, 443 | tags=[f"tag-{i}"], 444 | ) 445 | 446 | response = await authorized_client.get( 447 | app.url_path_for("articles:list-articles"), params={"tag": tag} 448 | ) 449 | articles = ListOfArticlesInResponse(**response.json()) 450 | assert articles.articles_count == result 451 | 452 | 453 | @pytest.mark.parametrize( 454 | "author, result", (("", 8), ("author1", 1), ("author2", 2), ("wrong", 0)) 455 | ) 456 | async def test_filtering_by_authors( 457 | app: FastAPI, 458 | authorized_client: AsyncClient, 459 | test_user: UserInDB, 460 | pool: Pool, 461 | author: str, 462 | result: int, 463 | ) -> None: 464 | async with pool.acquire() as connection: 465 | users_repo = UsersRepository(connection) 466 | articles_repo = ArticlesRepository(connection) 467 | 468 | author1 = await users_repo.create_user( 469 | username="author1", email="author1@email.com", password="password" 470 | ) 471 | author2 = await users_repo.create_user( 472 | username="author2", email="author2@email.com", password="password" 473 | ) 474 | 475 | await articles_repo.create_article( 476 | slug=f"slug-1", title="tmp", description="tmp", body="tmp", author=author1 477 | ) 478 | await articles_repo.create_article( 479 | slug=f"slug-2-1", title="tmp", description="tmp", body="tmp", author=author2 480 | ) 481 | await articles_repo.create_article( 482 | slug=f"slug-2-2", title="tmp", description="tmp", body="tmp", author=author2 483 | ) 484 | 485 | for i in range(5, 10): 486 | await articles_repo.create_article( 487 | slug=f"slug-{i}", 488 | title="tmp", 489 | description="tmp", 490 | body="tmp", 491 | author=test_user, 492 | ) 493 | 494 | response = await authorized_client.get( 495 | app.url_path_for("articles:list-articles"), params={"author": author} 496 | ) 497 | articles = ListOfArticlesInResponse(**response.json()) 498 | assert articles.articles_count == result 499 | 500 | 501 | @pytest.mark.parametrize( 502 | "favorited, result", (("", 7), ("fan1", 1), ("fan2", 2), ("wrong", 0)) 503 | ) 504 | async def test_filtering_by_favorited( 505 | app: FastAPI, 506 | authorized_client: AsyncClient, 507 | test_user: UserInDB, 508 | pool: Pool, 509 | favorited: str, 510 | result: int, 511 | ) -> None: 512 | async with pool.acquire() as connection: 513 | users_repo = UsersRepository(connection) 514 | articles_repo = ArticlesRepository(connection) 515 | 516 | fan1 = await users_repo.create_user( 517 | username="fan1", email="fan1@email.com", password="password" 518 | ) 519 | fan2 = await users_repo.create_user( 520 | username="fan2", email="fan2@email.com", password="password" 521 | ) 522 | 523 | article1 = await articles_repo.create_article( 524 | slug=f"slug-1", title="tmp", description="tmp", body="tmp", author=test_user 525 | ) 526 | article2 = await articles_repo.create_article( 527 | slug=f"slug-2", title="tmp", description="tmp", body="tmp", author=test_user 528 | ) 529 | 530 | await articles_repo.add_article_into_favorites(article=article1, user=fan1) 531 | await articles_repo.add_article_into_favorites(article=article1, user=fan2) 532 | await articles_repo.add_article_into_favorites(article=article2, user=fan2) 533 | 534 | for i in range(5, 10): 535 | await articles_repo.create_article( 536 | slug=f"slug-{i}", 537 | title="tmp", 538 | description="tmp", 539 | body="tmp", 540 | author=test_user, 541 | ) 542 | 543 | response = await authorized_client.get( 544 | app.url_path_for("articles:list-articles"), params={"favorited": favorited} 545 | ) 546 | articles = ListOfArticlesInResponse(**response.json()) 547 | assert articles.articles_count == result 548 | 549 | 550 | async def test_filtering_with_limit_and_offset( 551 | app: FastAPI, authorized_client: AsyncClient, test_user: UserInDB, pool: Pool 552 | ) -> None: 553 | async with pool.acquire() as connection: 554 | articles_repo = ArticlesRepository(connection) 555 | 556 | for i in range(5, 10): 557 | await articles_repo.create_article( 558 | slug=f"slug-{i}", 559 | title="tmp", 560 | description="tmp", 561 | body="tmp", 562 | author=test_user, 563 | ) 564 | 565 | full_response = await authorized_client.get( 566 | app.url_path_for("articles:list-articles") 567 | ) 568 | full_articles = ListOfArticlesInResponse(**full_response.json()) 569 | 570 | response = await authorized_client.get( 571 | app.url_path_for("articles:list-articles"), params={"limit": 2, "offset": 3} 572 | ) 573 | 574 | articles_from_response = ListOfArticlesInResponse(**response.json()) 575 | assert full_articles.articles[3:] == articles_from_response.articles 576 | -------------------------------------------------------------------------------- /tests/test_api/test_routes/test_authentication.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from httpx import AsyncClient 4 | from starlette.status import HTTP_403_FORBIDDEN 5 | 6 | from app.models.domain.users import User 7 | from app.services.jwt import create_access_token_for_user 8 | 9 | pytestmark = pytest.mark.asyncio 10 | 11 | 12 | async def test_unable_to_login_with_wrong_jwt_prefix( 13 | app: FastAPI, client: AsyncClient, token: str 14 | ) -> None: 15 | response = await client.get( 16 | app.url_path_for("users:get-current-user"), 17 | headers={"Authorization": f"WrongPrefix {token}"}, 18 | ) 19 | assert response.status_code == HTTP_403_FORBIDDEN 20 | 21 | 22 | async def test_unable_to_login_when_user_does_not_exist_any_more( 23 | app: FastAPI, client: AsyncClient, authorization_prefix: str 24 | ) -> None: 25 | token = create_access_token_for_user( 26 | User(username="user", email="email@email.com"), "secret" 27 | ) 28 | response = await client.get( 29 | app.url_path_for("users:get-current-user"), 30 | headers={"Authorization": f"{authorization_prefix} {token}"}, 31 | ) 32 | assert response.status_code == HTTP_403_FORBIDDEN 33 | -------------------------------------------------------------------------------- /tests/test_api/test_routes/test_comments.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from asyncpg.pool import Pool 3 | from fastapi import FastAPI 4 | from httpx import AsyncClient 5 | from starlette import status 6 | 7 | from app.db.repositories.comments import CommentsRepository 8 | from app.db.repositories.users import UsersRepository 9 | from app.models.domain.articles import Article 10 | from app.models.schemas.comments import CommentInResponse, ListOfCommentsInResponse 11 | 12 | pytestmark = pytest.mark.asyncio 13 | 14 | 15 | async def test_user_can_add_comment_for_article( 16 | app: FastAPI, authorized_client: AsyncClient, test_article: Article 17 | ) -> None: 18 | created_comment_response = await authorized_client.post( 19 | app.url_path_for("comments:create-comment-for-article", slug=test_article.slug), 20 | json={"comment": {"body": "comment"}}, 21 | ) 22 | 23 | created_comment = CommentInResponse(**created_comment_response.json()) 24 | 25 | comments_for_article_response = await authorized_client.get( 26 | app.url_path_for("comments:get-comments-for-article", slug=test_article.slug) 27 | ) 28 | 29 | comments = ListOfCommentsInResponse(**comments_for_article_response.json()) 30 | 31 | assert created_comment.comment == comments.comments[0] 32 | 33 | 34 | async def test_user_can_delete_own_comment( 35 | app: FastAPI, authorized_client: AsyncClient, test_article: Article 36 | ) -> None: 37 | created_comment_response = await authorized_client.post( 38 | app.url_path_for("comments:create-comment-for-article", slug=test_article.slug), 39 | json={"comment": {"body": "comment"}}, 40 | ) 41 | 42 | created_comment = CommentInResponse(**created_comment_response.json()) 43 | 44 | await authorized_client.delete( 45 | app.url_path_for( 46 | "comments:delete-comment-from-article", 47 | slug=test_article.slug, 48 | comment_id=str(created_comment.comment.id_), 49 | ) 50 | ) 51 | 52 | comments_for_article_response = await authorized_client.get( 53 | app.url_path_for("comments:get-comments-for-article", slug=test_article.slug) 54 | ) 55 | 56 | comments = ListOfCommentsInResponse(**comments_for_article_response.json()) 57 | 58 | assert len(comments.comments) == 0 59 | 60 | 61 | async def test_user_can_not_delete_not_authored_comment( 62 | app: FastAPI, authorized_client: AsyncClient, test_article: Article, pool: Pool 63 | ) -> None: 64 | async with pool.acquire() as connection: 65 | users_repo = UsersRepository(connection) 66 | user = await users_repo.create_user( 67 | username="test_author", email="author@email.com", password="password" 68 | ) 69 | comments_repo = CommentsRepository(connection) 70 | comment = await comments_repo.create_comment_for_article( 71 | body="tmp", article=test_article, user=user 72 | ) 73 | 74 | forbidden_response = await authorized_client.delete( 75 | app.url_path_for( 76 | "comments:delete-comment-from-article", 77 | slug=test_article.slug, 78 | comment_id=str(comment.id_), 79 | ) 80 | ) 81 | 82 | assert forbidden_response.status_code == status.HTTP_403_FORBIDDEN 83 | 84 | 85 | async def test_user_will_receive_error_for_not_existing_comment( 86 | app: FastAPI, authorized_client: AsyncClient, test_article: Article 87 | ) -> None: 88 | not_found_response = await authorized_client.delete( 89 | app.url_path_for( 90 | "comments:delete-comment-from-article", 91 | slug=test_article.slug, 92 | comment_id="1", 93 | ) 94 | ) 95 | 96 | assert not_found_response.status_code == status.HTTP_404_NOT_FOUND 97 | -------------------------------------------------------------------------------- /tests/test_api/test_routes/test_login.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from httpx import AsyncClient 4 | from starlette.status import HTTP_200_OK, HTTP_400_BAD_REQUEST 5 | 6 | from app.models.domain.users import UserInDB 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | async def test_user_successful_login( 12 | app: FastAPI, client: AsyncClient, test_user: UserInDB 13 | ) -> None: 14 | login_json = {"user": {"email": "test@test.com", "password": "password"}} 15 | 16 | response = await client.post(app.url_path_for("auth:login"), json=login_json) 17 | assert response.status_code == HTTP_200_OK 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "credentials_part, credentials_value", 22 | (("email", "wrong@test.com"), ("password", "wrong")), 23 | ) 24 | async def test_user_login_when_credential_part_does_not_match( 25 | app: FastAPI, 26 | client: AsyncClient, 27 | test_user: UserInDB, 28 | credentials_part: str, 29 | credentials_value: str, 30 | ) -> None: 31 | login_json = {"user": {"email": "test@test.com", "password": "password"}} 32 | login_json["user"][credentials_part] = credentials_value 33 | response = await client.post(app.url_path_for("auth:login"), json=login_json) 34 | assert response.status_code == HTTP_400_BAD_REQUEST 35 | -------------------------------------------------------------------------------- /tests/test_api/test_routes/test_profiles.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from asyncpg.pool import Pool 3 | from fastapi import FastAPI 4 | from httpx import AsyncClient 5 | from starlette import status 6 | 7 | from app.db.repositories.profiles import ProfilesRepository 8 | from app.db.repositories.users import UsersRepository 9 | from app.models.domain.users import UserInDB 10 | from app.models.schemas.profiles import ProfileInResponse 11 | 12 | pytestmark = pytest.mark.asyncio 13 | 14 | 15 | async def test_unregistered_user_will_receive_profile_without_following( 16 | app: FastAPI, client: AsyncClient, test_user: UserInDB 17 | ) -> None: 18 | response = await client.get( 19 | app.url_path_for("profiles:get-profile", username=test_user.username) 20 | ) 21 | profile = ProfileInResponse(**response.json()) 22 | assert profile.profile.username == test_user.username 23 | assert not profile.profile.following 24 | 25 | 26 | async def test_user_that_does_not_follows_another_will_receive_profile_without_follow( 27 | app: FastAPI, authorized_client: AsyncClient, pool: Pool 28 | ) -> None: 29 | async with pool.acquire() as conn: 30 | users_repo = UsersRepository(conn) 31 | user = await users_repo.create_user( 32 | username="user_for_following", 33 | email="test-for-following@email.com", 34 | password="password", 35 | ) 36 | 37 | response = await authorized_client.get( 38 | app.url_path_for("profiles:get-profile", username=user.username) 39 | ) 40 | profile = ProfileInResponse(**response.json()) 41 | assert profile.profile.username == user.username 42 | assert not profile.profile.following 43 | 44 | 45 | async def test_user_that_follows_another_will_receive_profile_with_follow( 46 | app: FastAPI, authorized_client: AsyncClient, pool: Pool, test_user: UserInDB 47 | ) -> None: 48 | async with pool.acquire() as conn: 49 | users_repo = UsersRepository(conn) 50 | user = await users_repo.create_user( 51 | username="user_for_following", 52 | email="test-for-following@email.com", 53 | password="password", 54 | ) 55 | 56 | profiles_repo = ProfilesRepository(conn) 57 | await profiles_repo.add_user_into_followers( 58 | target_user=user, requested_user=test_user 59 | ) 60 | 61 | response = await authorized_client.get( 62 | app.url_path_for("profiles:get-profile", username=user.username) 63 | ) 64 | profile = ProfileInResponse(**response.json()) 65 | assert profile.profile.username == user.username 66 | assert profile.profile.following 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "api_method, route_name", 71 | ( 72 | ("GET", "profiles:get-profile"), 73 | ("POST", "profiles:follow-user"), 74 | ("DELETE", "profiles:unsubscribe-from-user"), 75 | ), 76 | ) 77 | async def test_user_can_not_retrieve_not_existing_profile( 78 | app: FastAPI, authorized_client: AsyncClient, api_method: str, route_name: str 79 | ) -> None: 80 | response = await authorized_client.request( 81 | api_method, app.url_path_for(route_name, username="not_existing_user") 82 | ) 83 | assert response.status_code == status.HTTP_404_NOT_FOUND 84 | 85 | 86 | @pytest.mark.parametrize( 87 | "api_method, route_name, following", 88 | ( 89 | ("POST", "profiles:follow-user", True), 90 | ("DELETE", "profiles:unsubscribe-from-user", False), 91 | ), 92 | ) 93 | async def test_user_can_change_following_for_another_user( 94 | app: FastAPI, 95 | authorized_client: AsyncClient, 96 | pool: Pool, 97 | test_user: UserInDB, 98 | api_method: str, 99 | route_name: str, 100 | following: bool, 101 | ) -> None: 102 | async with pool.acquire() as conn: 103 | users_repo = UsersRepository(conn) 104 | user = await users_repo.create_user( 105 | username="user_for_following", 106 | email="test-for-following@email.com", 107 | password="password", 108 | ) 109 | 110 | if not following: 111 | profiles_repo = ProfilesRepository(conn) 112 | await profiles_repo.add_user_into_followers( 113 | target_user=user, requested_user=test_user 114 | ) 115 | 116 | change_following_response = await authorized_client.request( 117 | api_method, app.url_path_for(route_name, username=user.username) 118 | ) 119 | assert change_following_response.status_code == status.HTTP_200_OK 120 | 121 | response = await authorized_client.get( 122 | app.url_path_for("profiles:get-profile", username=user.username) 123 | ) 124 | profile = ProfileInResponse(**response.json()) 125 | assert profile.profile.username == user.username 126 | assert profile.profile.following == following 127 | 128 | 129 | @pytest.mark.parametrize( 130 | "api_method, route_name, following", 131 | ( 132 | ("POST", "profiles:follow-user", True), 133 | ("DELETE", "profiles:unsubscribe-from-user", False), 134 | ), 135 | ) 136 | async def test_user_can_not_change_following_state_to_the_same_twice( 137 | app: FastAPI, 138 | authorized_client: AsyncClient, 139 | pool: Pool, 140 | test_user: UserInDB, 141 | api_method: str, 142 | route_name: str, 143 | following: bool, 144 | ) -> None: 145 | async with pool.acquire() as conn: 146 | users_repo = UsersRepository(conn) 147 | user = await users_repo.create_user( 148 | username="user_for_following", 149 | email="test-for-following@email.com", 150 | password="password", 151 | ) 152 | 153 | if following: 154 | profiles_repo = ProfilesRepository(conn) 155 | await profiles_repo.add_user_into_followers( 156 | target_user=user, requested_user=test_user 157 | ) 158 | 159 | response = await authorized_client.request( 160 | api_method, app.url_path_for(route_name, username=user.username) 161 | ) 162 | 163 | assert response.status_code == status.HTTP_400_BAD_REQUEST 164 | 165 | 166 | @pytest.mark.parametrize( 167 | "api_method, route_name", 168 | (("POST", "profiles:follow-user"), ("DELETE", "profiles:unsubscribe-from-user")), 169 | ) 170 | async def test_user_can_not_change_following_state_for_him_self( 171 | app: FastAPI, 172 | authorized_client: AsyncClient, 173 | test_user: UserInDB, 174 | api_method: str, 175 | route_name: str, 176 | ) -> None: 177 | response = await authorized_client.request( 178 | api_method, app.url_path_for(route_name, username=test_user.username) 179 | ) 180 | 181 | assert response.status_code == status.HTTP_400_BAD_REQUEST 182 | -------------------------------------------------------------------------------- /tests/test_api/test_routes/test_registration.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from asyncpg.pool import Pool 3 | from fastapi import FastAPI 4 | from httpx import AsyncClient 5 | from starlette.status import HTTP_201_CREATED, HTTP_400_BAD_REQUEST 6 | 7 | from app.db.repositories.users import UsersRepository 8 | from app.models.domain.users import UserInDB 9 | 10 | pytestmark = pytest.mark.asyncio 11 | 12 | 13 | async def test_user_success_registration( 14 | app: FastAPI, client: AsyncClient, pool: Pool 15 | ) -> None: 16 | email, username, password = "test@test.com", "username", "password" 17 | registration_json = { 18 | "user": {"email": email, "username": username, "password": password} 19 | } 20 | response = await client.post( 21 | app.url_path_for("auth:register"), json=registration_json 22 | ) 23 | assert response.status_code == HTTP_201_CREATED 24 | 25 | async with pool.acquire() as conn: 26 | repo = UsersRepository(conn) 27 | user = await repo.get_user_by_email(email=email) 28 | assert user.email == email 29 | assert user.username == username 30 | assert user.check_password(password) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "credentials_part, credentials_value", 35 | (("username", "free_username"), ("email", "free-email@tset.com")), 36 | ) 37 | async def test_failed_user_registration_when_some_credentials_are_taken( 38 | app: FastAPI, 39 | client: AsyncClient, 40 | test_user: UserInDB, 41 | credentials_part: str, 42 | credentials_value: str, 43 | ) -> None: 44 | registration_json = { 45 | "user": { 46 | "email": "test@test.com", 47 | "username": "username", 48 | "password": "password", 49 | } 50 | } 51 | registration_json["user"][credentials_part] = credentials_value 52 | 53 | response = await client.post( 54 | app.url_path_for("auth:register"), json=registration_json 55 | ) 56 | assert response.status_code == HTTP_400_BAD_REQUEST 57 | -------------------------------------------------------------------------------- /tests/test_api/test_routes/test_tags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from asyncpg.pool import Pool 3 | from fastapi import FastAPI 4 | from httpx import AsyncClient 5 | 6 | from app.db.repositories.tags import TagsRepository 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | async def test_empty_list_when_no_tags_exist(app: FastAPI, client: AsyncClient) -> None: 12 | response = await client.get(app.url_path_for("tags:get-all")) 13 | assert response.json() == {"tags": []} 14 | 15 | 16 | async def test_list_of_tags_when_tags_exist( 17 | app: FastAPI, client: AsyncClient, pool: Pool 18 | ) -> None: 19 | tags = ["tag1", "tag2", "tag3", "tag4", "tag1"] 20 | 21 | async with pool.acquire() as conn: 22 | tags_repo = TagsRepository(conn) 23 | await tags_repo.create_tags_that_dont_exist(tags=tags) 24 | 25 | response = await client.get(app.url_path_for("tags:get-all")) 26 | tags_from_response = response.json()["tags"] 27 | assert len(tags_from_response) == len(set(tags)) 28 | assert all((tag in tags for tag in tags_from_response)) 29 | -------------------------------------------------------------------------------- /tests/test_api/test_routes/test_users.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from asyncpg.pool import Pool 3 | from fastapi import FastAPI 4 | from httpx import AsyncClient 5 | from starlette import status 6 | 7 | from app.db.repositories.users import UsersRepository 8 | from app.models.domain.users import UserInDB 9 | from app.models.schemas.users import UserInResponse 10 | 11 | pytestmark = pytest.mark.asyncio 12 | 13 | 14 | @pytest.fixture(params=("", "value", "Token value", "JWT value", "Bearer value")) 15 | def wrong_authorization_header(request) -> str: 16 | return request.param 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "api_method, route_name", 21 | (("GET", "users:get-current-user"), ("PUT", "users:update-current-user")), 22 | ) 23 | async def test_user_can_not_access_own_profile_if_not_logged_in( 24 | app: FastAPI, 25 | client: AsyncClient, 26 | test_user: UserInDB, 27 | api_method: str, 28 | route_name: str, 29 | ) -> None: 30 | response = await client.request(api_method, app.url_path_for(route_name)) 31 | assert response.status_code == status.HTTP_403_FORBIDDEN 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "api_method, route_name", 36 | (("GET", "users:get-current-user"), ("PUT", "users:update-current-user")), 37 | ) 38 | async def test_user_can_not_retrieve_own_profile_if_wrong_token( 39 | app: FastAPI, 40 | client: AsyncClient, 41 | test_user: UserInDB, 42 | api_method: str, 43 | route_name: str, 44 | wrong_authorization_header: str, 45 | ) -> None: 46 | response = await client.request( 47 | api_method, 48 | app.url_path_for(route_name), 49 | headers={"Authorization": wrong_authorization_header}, 50 | ) 51 | assert response.status_code == status.HTTP_403_FORBIDDEN 52 | 53 | 54 | async def test_user_can_retrieve_own_profile( 55 | app: FastAPI, authorized_client: AsyncClient, test_user: UserInDB, token: str 56 | ) -> None: 57 | response = await authorized_client.get(app.url_path_for("users:get-current-user")) 58 | assert response.status_code == status.HTTP_200_OK 59 | 60 | user_profile = UserInResponse(**response.json()) 61 | assert user_profile.user.email == test_user.email 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "update_field, update_value", 66 | ( 67 | ("username", "new_username"), 68 | ("email", "new_email@email.com"), 69 | ("bio", "new bio"), 70 | ("image", "http://testhost.com/imageurl"), 71 | ), 72 | ) 73 | async def test_user_can_update_own_profile( 74 | app: FastAPI, 75 | authorized_client: AsyncClient, 76 | test_user: UserInDB, 77 | token: str, 78 | update_value: str, 79 | update_field: str, 80 | ) -> None: 81 | response = await authorized_client.put( 82 | app.url_path_for("users:update-current-user"), 83 | json={"user": {update_field: update_value}}, 84 | ) 85 | assert response.status_code == status.HTTP_200_OK 86 | 87 | user_profile = UserInResponse(**response.json()).dict() 88 | assert user_profile["user"][update_field] == update_value 89 | 90 | 91 | async def test_user_can_change_password( 92 | app: FastAPI, 93 | authorized_client: AsyncClient, 94 | test_user: UserInDB, 95 | token: str, 96 | pool: Pool, 97 | ) -> None: 98 | response = await authorized_client.put( 99 | app.url_path_for("users:update-current-user"), 100 | json={"user": {"password": "new_password"}}, 101 | ) 102 | assert response.status_code == status.HTTP_200_OK 103 | user_profile = UserInResponse(**response.json()) 104 | 105 | async with pool.acquire() as connection: 106 | users_repo = UsersRepository(connection) 107 | user = await users_repo.get_user_by_username( 108 | username=user_profile.user.username 109 | ) 110 | 111 | assert user.check_password("new_password") 112 | 113 | 114 | @pytest.mark.parametrize( 115 | "credentials_part, credentials_value", 116 | (("username", "taken_username"), ("email", "taken@email.com")), 117 | ) 118 | async def test_user_can_not_take_already_used_credentials( 119 | app: FastAPI, 120 | authorized_client: AsyncClient, 121 | pool: Pool, 122 | token: str, 123 | credentials_part: str, 124 | credentials_value: str, 125 | ) -> None: 126 | user_dict = { 127 | "username": "not_taken_username", 128 | "password": "password", 129 | "email": "free_email@email.com", 130 | } 131 | user_dict.update({credentials_part: credentials_value}) 132 | async with pool.acquire() as conn: 133 | users_repo = UsersRepository(conn) 134 | await users_repo.create_user(**user_dict) 135 | 136 | response = await authorized_client.put( 137 | app.url_path_for("users:update-current-user"), 138 | json={"user": {credentials_part: credentials_value}}, 139 | ) 140 | assert response.status_code == status.HTTP_400_BAD_REQUEST 141 | -------------------------------------------------------------------------------- /tests/test_db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/tests/test_db/__init__.py -------------------------------------------------------------------------------- /tests/test_db/test_queries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/tests/test_db/test_queries/__init__.py -------------------------------------------------------------------------------- /tests/test_db/test_queries/test_tables.py: -------------------------------------------------------------------------------- 1 | from app.db.queries.tables import TypedTable 2 | 3 | 4 | def test_typed_table_uses_explicit_name() -> None: 5 | assert TypedTable("table_name").get_sql() == "table_name" 6 | 7 | 8 | def test_typed_table_use_class_attribute_as_table_name() -> None: 9 | class NewTable(TypedTable): 10 | __table__ = "new_table" 11 | 12 | assert NewTable().get_table_name() == "new_table" 13 | 14 | 15 | def test_typed_table_use_class_name_as_table_name() -> None: 16 | class NewTable(TypedTable): 17 | ... 18 | 19 | assert NewTable().get_table_name() == "NewTable" 20 | -------------------------------------------------------------------------------- /tests/test_schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/tests/test_schemas/__init__.py -------------------------------------------------------------------------------- /tests/test_schemas/test_rw_model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from app.models.domain.rwmodel import convert_datetime_to_realworld 4 | 5 | 6 | def test_api_datetime_is_in_realworld_format() -> None: 7 | dt = datetime.fromisoformat("2019-10-27T02:21:42.844640") 8 | assert convert_datetime_to_realworld(dt) == "2019-10-27T02:21:42.844640Z" 9 | -------------------------------------------------------------------------------- /tests/test_services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsidnev/fastapi-realworld-example-app/029eb7781c60d5f563ee8990a0cbfb79b244538c/tests/test_services/__init__.py -------------------------------------------------------------------------------- /tests/test_services/test_jwt.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import jwt 4 | import pytest 5 | 6 | from app.models.domain.users import UserInDB 7 | from app.services.jwt import ( 8 | ALGORITHM, 9 | create_access_token_for_user, 10 | create_jwt_token, 11 | get_username_from_token, 12 | ) 13 | 14 | 15 | def test_creating_jwt_token() -> None: 16 | token = create_jwt_token( 17 | jwt_content={"content": "payload"}, 18 | secret_key="secret", 19 | expires_delta=timedelta(minutes=1), 20 | ) 21 | parsed_payload = jwt.decode(token, "secret", algorithms=[ALGORITHM]) 22 | 23 | assert parsed_payload["content"] == "payload" 24 | 25 | 26 | def test_creating_token_for_user(test_user: UserInDB) -> None: 27 | token = create_access_token_for_user(user=test_user, secret_key="secret") 28 | parsed_payload = jwt.decode(token, "secret", algorithms=[ALGORITHM]) 29 | 30 | assert parsed_payload["username"] == test_user.username 31 | 32 | 33 | def test_retrieving_token_from_user(test_user: UserInDB) -> None: 34 | token = create_access_token_for_user(user=test_user, secret_key="secret") 35 | username = get_username_from_token(token, "secret") 36 | assert username == test_user.username 37 | 38 | 39 | def test_error_when_wrong_token() -> None: 40 | with pytest.raises(ValueError): 41 | get_username_from_token("asdf", "asdf") 42 | 43 | 44 | def test_error_when_wrong_token_shape() -> None: 45 | token = create_jwt_token( 46 | jwt_content={"content": "payload"}, 47 | secret_key="secret", 48 | expires_delta=timedelta(minutes=1), 49 | ) 50 | with pytest.raises(ValueError): 51 | get_username_from_token(token, "secret") 52 | --------------------------------------------------------------------------------