Skip to content

Commit

Permalink
Реализовать эндпойнт регистрации (#88)
Browse files Browse the repository at this point in the history
После того как была разработана архитектура эндпойнтов авторизации (#77), необходимо приступить к реализации этих эндпойнтов. Т.к. процессы аутентификации и авторизации требуют наличия зарегистрированного в системе Аккаунта, то в первую очередь нужно реализовать эндпойнт регистрации (`src/auth/routes.py :: create_account`).

Планируемая архитектура бд:

**Account**

- id (primary key)
- login (unique)
- email (unique)
- created_at
- updated_at

**PasswordHash**

- account_id (foreign key, primary key)
- value

**Profile**

- account_id (foreign key, primary key)
- avatar (nullable, unique)
- description (nullable)
- name

В рамках этой задачи необходимо написать реализацию для эндпойнта регистрации.

---

В процессе реализации были приняты следующие решения:

- **Использование foreign key в качестве primary key для таблиц, имеющих связь один к одному.**

  Например, Профиль пользователя всегда связан с одним и только одним Аккаунтом, таким образом в качестве primary key для таблицы Профиля будет использоваться foreign key на айди записи в таблице Аккаунта. Это позволит нам обеспечить соблюдение некоторых инвариантов доменной модели на уровне бд.

- **Отказ от использования `default` и `onupdate` в колонках даты и времени.**

  Это решение было принято в качестве следствия из разработанного подхода работы с бд, а именно: "бд **должна** автоматизировать проверку как можно большего количества **ограничений**, но при этом бд **не должна** автоматизировать какое-либо **изменение** данных. Следование этому принципу позволит нам, во-первых, сохранить бизнес логику изменения данных на уровне кода приложения, т.е. сохранить её в явном виде, а не спрятать в бд в виде триггеров и процедур. А во-вторых, обеспечить поддержание целостности данных при попытке внесения изменений в обход кода нашего приложения (например, при проведении миграций).

- **Расположение логики обработчиков ошибок в `src/app.py`.**

  Это решение является временным компромиссом и, возможно, будет изменено во время будущего рефакторинга. Мы приняли это решение в связи с тем, что fastapi позволяет привязывать обработчики ошибок только к экземпляру приложения, но не к отдельным роутам.

- **Отказ от инкапсуляции логики создания зависимых моделей в методах основной модели.**

  Например, при создании Аккаунта всегда должен создаваться соответствующий аккаунту Профиль, но на данных момент эндпойнт создания Аккаунта не содержит в себе вызов эндпойнта создания Профиля. Это связано с тем, что после реализации MVP, мы планируем провести рефакторинг кода, связанного с внесением изменений в бд и созданием объектов модели, с помощью внедрения DDD репозиториев, фабрик и агрегатов. Наша "упрощенная" текущая реализация скорее всего позволит перейти на новый подход более безболезненно (по крайней мере сейчас так кажется).

- **Сохранение таймзоны в бд и использование timezone-aware объектов даты и времени.**

  Это позволит нам всегда точно знать, что в нашей бд все записи даты и времени добавленные/измененные нашим приложением были сделаны с учетом таймзоны.

- **Использование `.flush()` вместо `.commit()` в методах изменяющих данные в бд.**

  Все методы вносящие изменения в данные бд будут использовать `.flush()`, чтобы в нашем коде, в рамках сессии мы могли видеть данные в изменённом виде, но при этом `.commit()` будет вызываться только в тирдауне `get_db`, позволяя нам фиксировать изменения атомарно в рамках всего эндпойнта, а не в рамках вызовов отдельных методов. У это подхода тоже есть серьезный недостаток - в связи с особенностями работы `Dependency` в fastapi, применение изменений в бд будет происходить уже после того как ответ 200 будет отправлен (см [комментарий](zhanymkanov/fastapi-best-practices#1 (comment))). Но т.к. для решения этой проблемы необходимо проводить отдельный ресёрч, было принято решение исправить это в рамках [другой](#142) задачи.
  • Loading branch information
Alex Goryev authored and birthdaysgift committed Nov 15, 2023
1 parent 8dcfb08 commit 25060fe
Show file tree
Hide file tree
Showing 26 changed files with 660 additions and 11 deletions.
8 changes: 7 additions & 1 deletion .mypy.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
[mypy]

files = src
files = src,tests

exclude =
(?x)(
^tests/test.*$ # skip tests
| ^tests/conftest.*$ # skip fixtures
)

plugins = pydantic.mypy

Expand Down
13 changes: 13 additions & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
# A list of rule codes or prefixes that are unsupported by Ruff,
# but should be preserved when (e.g.) validating # noqa directives.
external = [
# darglint
"DAR101",
"DAR201",

# flake8-rst-docstrings
"RST214",
"RST215"
]

ignore = [
"D203", # ignores D203 (one-blank-line-before-class) since we use D211 (no-blank-line-before-class)
"D213", # ignores D213 (multi-line-summary-second-line) since we use D212 (multi-line-summary-first-line)
"S101", # ignores S101 since we don't use python optimizations with "-O"
"RSE102", # ignores RSE102 (unnecessary-paren-on-raise-exception) since we think that empty parens is more clear
]

line-length = 120
Expand Down
2 changes: 2 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ script_location = ./migrations

# absolute paths to the packages with models definitions
models_packages =
src.account.models,
src.profile.models,

# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""add account, profile and password_hash tables
Revision ID: d7454b0101c0
Revises: 8f2f6221d1de
Create Date: 2023-11-05 13:32:37.606097+00:00
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "d7454b0101c0"
down_revision = "8f2f6221d1de"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"account",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("email", sa.String(), nullable=False),
sa.Column("login", sa.String(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
sa.UniqueConstraint("login"),
)
op.create_table(
"password_hash",
sa.Column("account_id", sa.Integer(), autoincrement=False, nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("value", sa.LargeBinary(), nullable=False),
sa.ForeignKeyConstraint(
["account_id"],
["account.id"],
),
sa.PrimaryKeyConstraint("account_id"),
)
op.create_table(
"profile",
sa.Column("account_id", sa.Integer(), autoincrement=False, nullable=False),
sa.Column("avatar", sa.String(), nullable=True),
sa.Column("description", sa.String(), nullable=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["account_id"],
["account.id"],
),
sa.PrimaryKeyConstraint("account_id"),
sa.UniqueConstraint("avatar"),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("profile")
op.drop_table("password_hash")
op.drop_table("account")
# ### end Alembic commands ###
82 changes: 81 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ optional = true

alembic = {extras = ["tz"], version = "1.10.4"}
asyncpg = "0.27.0" # asynchronous postgresql driver used by sqlalchemy
bcrypt = "4.0.1" # modern password hashing library
fastapi = {extras = ["all"], version = "0.95.0" } # we need "all" at least for uvicorn
minio = "7.1.14"
overrides = "7.3.1"
Expand Down Expand Up @@ -72,6 +73,8 @@ optional = true

[tool.poetry.group.test.dependencies]

dirty-equals = "0.6.0"
freezegun = "1.2.2"
pytest = "7.3.0"
pytest-cov = "4.0.0"
pytest-env = "0.8.1" # needed to set up default $WLSS_ENV for tests execution
Expand Down
14 changes: 14 additions & 0 deletions src/account/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Exceptions related to account functionality."""

from __future__ import annotations

from src.shared.exceptions import BadRequestException


class DuplicateAccountException(BadRequestException):
"""Exception raised when such account already exists."""

action = "create account"

description = "Account already exists."
details: str = "There is another account with same value for one of the unique fields."
84 changes: 84 additions & 0 deletions src/account/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Database models for account."""

from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING

import bcrypt
from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, select, String
from sqlalchemy.orm import Mapped, mapped_column

from src.account.exceptions import DuplicateAccountException
from src.shared.database import Base
from src.shared.datetime import utcnow


if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession

from src.auth.fields import Password
from src.auth.schemas import NewAccount


class Account(Base): # pylint: disable=too-few-public-methods
"""Account database model."""

__tablename__ = "account"

id: Mapped[int] = mapped_column(Integer, primary_key=True) # noqa: A003

created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
email: Mapped[str] = mapped_column(String, nullable=False, unique=True)
login: Mapped[str] = mapped_column(String, nullable=False, unique=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False, onupdate=utcnow,
)

@staticmethod
async def create(session: AsyncSession, account_data: NewAccount) -> Account:
"""Create new account object."""
query = select(Account).where(Account.email == account_data.email)
account_with_same_email = (await session.execute(query)).first()
if account_with_same_email is not None:
raise DuplicateAccountException()

query = select(Account).where(Account.login == account_data.login)
account_with_same_login = (await session.execute(query)).first()
if account_with_same_login is not None:
raise DuplicateAccountException()

account = Account(**account_data.dict(exclude={"password"}))
session.add(account)
await session.flush()
return account


class PasswordHash(Base): # pylint: disable=too-few-public-methods
"""Password hash database model."""

__tablename__ = "password_hash"

account_id: Mapped[int] = mapped_column(ForeignKey("account.id"), autoincrement=False, primary_key=True)

updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False, onupdate=utcnow,
)
value: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)

@staticmethod
async def create(session: AsyncSession, password: Password, account_id: int) -> PasswordHash:
"""Create a password hash object."""
hash_value = PasswordHash._generate_hash(password)
password_hash = PasswordHash(value=hash_value, account_id=account_id)
session.add(password_hash)
await session.flush()
return password_hash

@staticmethod
def _generate_hash(password: Password) -> bytes:
"""Generate hash for password."""
salt = bcrypt.gensalt()
raw_password = password.get_secret_value()
byte_password = raw_password.encode("utf-8")
return bcrypt.hashpw(byte_password, salt)
5 changes: 5 additions & 0 deletions src/account/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ class Account(Schema):
email: Email
login: Login

class Config:
"""Pydantic's special class to configure pydantic models."""

orm_mode = True


class AccountId(Schema):
"""Account Id."""
Expand Down
21 changes: 21 additions & 0 deletions src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@

from __future__ import annotations

from typing import TYPE_CHECKING

from fastapi import FastAPI
from fastapi.responses import JSONResponse

import src.routes
from src.shared.exceptions import BadRequestException


if TYPE_CHECKING:
from fastapi import Request


app = FastAPI(
Expand All @@ -23,3 +31,16 @@
)

app.include_router(src.routes.router)


@app.exception_handler(BadRequestException)
async def handle_bad_request(_: Request, exception: BadRequestException) -> JSONResponse:
"""Handle BadRequestException."""
return JSONResponse(
status_code=exception.status_code,
content={
"action": exception.action,
"description": exception.description,
"details": exception.details,
},
)
Loading

0 comments on commit 25060fe

Please sign in to comment.