├── .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 |
--------------------------------------------------------------------------------