├── tests ├── __init__.py ├── e2e │ └── __init__.py ├── mocks │ ├── __init__.py │ └── access_service │ │ ├── __init__.py │ │ ├── id_provider.py │ │ ├── token_sender.py │ │ ├── event_emitter.py │ │ ├── uow.py │ │ └── user_gateway.py ├── unit │ ├── __init__.py │ ├── notes │ │ ├── __init__.py │ │ └── domain │ │ │ ├── __init__.py │ │ │ └── entities │ │ │ ├── __init__.py │ │ │ └── test_user.py │ └── access_service │ │ ├── __init__.py │ │ ├── domain │ │ ├── __init__.py │ │ ├── entities │ │ │ ├── __init__.py │ │ │ ├── test_timed_token.py │ │ │ └── test_user.py │ │ └── services │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ └── test_token_access_service.py │ │ ├── application │ │ ├── __init__.py │ │ └── interactors │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── test_get_identity.py │ │ │ ├── test_create_identity.py │ │ │ ├── test_verify_email.py │ │ │ ├── test_delete_identity.py │ │ │ └── test_authorize.py │ │ └── conftest.py └── integration │ └── __init__.py ├── src └── zametka │ ├── __init__.py │ ├── main │ ├── __init__.py │ ├── web.py │ └── cli.py │ ├── notes │ ├── __init__.py │ ├── domain │ │ ├── __init__.py │ │ ├── common │ │ │ ├── __init__.py │ │ │ └── value_objects │ │ │ │ ├── __init__.py │ │ │ │ └── base.py │ │ ├── entities │ │ │ ├── __init__.py │ │ │ ├── user.py │ │ │ └── note.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── note.py │ │ │ └── user.py │ │ └── value_objects │ │ │ ├── __init__.py │ │ │ ├── note │ │ │ ├── __init__.py │ │ │ ├── note_id.py │ │ │ ├── note_created_at.py │ │ │ ├── note_text.py │ │ │ └── note_title.py │ │ │ └── user │ │ │ ├── __init__.py │ │ │ ├── user_joined_at.py │ │ │ ├── user_id.py │ │ │ ├── user_last_name.py │ │ │ └── user_first_name.py │ ├── main │ │ ├── __init__.py │ │ └── ioc.py │ ├── application │ │ ├── __init__.py │ │ ├── common │ │ │ ├── __init__.py │ │ │ ├── interactor.py │ │ │ ├── id_provider.py │ │ │ ├── uow.py │ │ │ └── repository.py │ │ ├── note │ │ │ ├── __init__.py │ │ │ └── dto.py │ │ └── user │ │ │ ├── __init__.py │ │ │ ├── dto.py │ │ │ ├── get_user.py │ │ │ └── create_user.py │ ├── infrastructure │ │ ├── __init__.py │ │ ├── db │ │ │ ├── __init__.py │ │ │ ├── alembic │ │ │ │ ├── __init__.py │ │ │ │ ├── migrations │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── versions │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ └── 5b9db61f86b5_init.py │ │ │ │ │ ├── README │ │ │ │ │ ├── script.py.mako │ │ │ │ │ └── env.py │ │ │ │ ├── config.py │ │ │ │ └── alembic.ini │ │ │ ├── models │ │ │ │ ├── base.py │ │ │ │ ├── __init__.py │ │ │ │ ├── user.py │ │ │ │ └── note.py │ │ │ ├── uow.py │ │ │ ├── provider.py │ │ │ └── main.py │ │ ├── repositories │ │ │ ├── __init__.py │ │ │ ├── converters │ │ │ │ ├── __init__.py │ │ │ │ ├── user.py │ │ │ │ └── note.py │ │ │ └── user.py │ │ ├── id_provider.py │ │ ├── access_api_client.py │ │ └── config_loader.py │ └── presentation │ │ ├── web_api │ │ ├── __init__.py │ │ ├── schemas │ │ │ ├── __init__.py │ │ │ ├── note.py │ │ │ └── user.py │ │ ├── endpoints │ │ │ ├── __init__.py │ │ │ ├── user.py │ │ │ └── note.py │ │ ├── exception_handlers │ │ │ ├── __init__.py │ │ │ ├── note.py │ │ │ └── user.py │ │ └── dependencies │ │ │ ├── __init__.py │ │ │ ├── stub.py │ │ │ └── id_provider.py │ │ ├── interactor_factory.py │ │ └── __init__.py │ └── access_service │ ├── __init__.py │ ├── bootstrap │ ├── __init__.py │ └── conf.py │ ├── domain │ ├── __init__.py │ ├── common │ │ ├── __init__.py │ │ ├── entities │ │ │ ├── __init__.py │ │ │ └── timed_user_token.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ ├── access_service.py │ │ │ └── password_hasher.py │ │ ├── value_objects │ │ │ ├── __init__.py │ │ │ ├── timed_token_id.py │ │ │ └── base.py │ │ └── base_error.py │ ├── entities │ │ ├── __init__.py │ │ ├── config.py │ │ ├── confirmation_token.py │ │ ├── access_token.py │ │ └── user.py │ ├── exceptions │ │ ├── __init__.py │ │ ├── base.py │ │ ├── password_hasher.py │ │ ├── access_token.py │ │ ├── confirmation_token.py │ │ └── user.py │ ├── services │ │ ├── __init__.py │ │ └── token_access_service.py │ └── value_objects │ │ ├── __init__.py │ │ ├── user_hashed_password.py │ │ ├── user_id.py │ │ ├── expires_in.py │ │ ├── user_email.py │ │ └── user_raw_password.py │ ├── application │ ├── __init__.py │ ├── common │ │ ├── __init__.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── repo_error.py │ │ │ └── user.py │ │ ├── id_provider.py │ │ ├── event │ │ │ ├── __init__.py │ │ │ ├── event.py │ │ │ ├── event_handler.py │ │ │ └── event_emitter.py │ │ ├── interactor.py │ │ ├── uow.py │ │ ├── token_sender.py │ │ └── user_gateway.py │ ├── get_user.py │ ├── dto.py │ ├── delete_user.py │ ├── verify_email.py │ ├── authorize.py │ └── create_user.py │ ├── infrastructure │ ├── __init__.py │ ├── auth │ │ ├── __init__.py │ │ ├── password_hasher.py │ │ ├── id_provider.py │ │ └── access_token_processor.py │ ├── jwt │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── config.py │ │ └── jwt_processor.py │ ├── email │ │ ├── __init__.py │ │ ├── email_client.py │ │ ├── config.py │ │ ├── aio_email_client.py │ │ ├── confirmation_token_processor.py │ │ └── email_token_sender.py │ ├── event_bus │ │ ├── __init__.py │ │ ├── exchanges.py │ │ ├── events │ │ │ ├── __init__.py │ │ │ ├── user.py │ │ │ ├── integration_event.py │ │ │ └── amqp_event.py │ │ ├── event_handler.py │ │ ├── event_emitter.py │ │ └── amqp_event_sender.py │ ├── gateway │ │ ├── __init__.py │ │ ├── converters │ │ │ ├── __init__.py │ │ │ └── user.py │ │ └── user.py │ ├── persistence │ │ ├── __init__.py │ │ ├── alembic │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ │ ├── __init__.py │ │ │ │ ├── versions │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── 8ecffb117471_init.py │ │ │ │ ├── README │ │ │ │ ├── script.py.mako │ │ │ │ └── env.py │ │ │ ├── config.py │ │ │ └── alembic.ini │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ └── user_identity.py │ │ ├── uow.py │ │ ├── config.py │ │ └── provider.py │ ├── message_broker │ │ ├── __init__.py │ │ ├── config.py │ │ ├── message.py │ │ ├── uow.py │ │ └── message_broker.py │ └── error_code.py │ └── presentation │ ├── http │ ├── __init__.py │ ├── auth │ │ ├── __init__.py │ │ ├── config.py │ │ └── token_auth.py │ ├── endpoints │ │ ├── __init__.py │ │ └── user.py │ ├── schemas │ │ ├── __init__.py │ │ └── user.py │ ├── exceptions.py │ ├── http_error_code.py │ ├── config.py │ └── exception_handlers.py │ ├── __init__.py │ └── error_message.py ├── .env.notes.example ├── .dockerignore ├── .env.access_service.example ├── .env.example ├── .config ├── nginx │ └── nginx.conf └── dev.config.toml ├── Dockerfile ├── DEPLOYMENT.md ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── docker-compose.yml ├── README.md ├── pyproject.toml └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/e2e/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mocks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/main/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/notes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/main/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mocks/access_service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/access_service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/notes/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/application/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/bootstrap/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/application/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/application/note/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/application/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/access_service/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/notes/domain/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/access_service/application/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/access_service/domain/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/access_service/domain/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.notes.example: -------------------------------------------------------------------------------- 1 | NOTES_POSTGRES_DB=notes_database 2 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/jwt/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/common/value_objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/note/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/alembic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/value_objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/email/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/event_bus/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/gateway/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/access_service/application/interactors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/value_objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/alembic/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/repositories/converters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/gateway/converters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/alembic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/exception_handlers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/alembic/migrations/versions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/alembic/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/base_error.py: -------------------------------------------------------------------------------- 1 | class BaseError(Exception): ... 2 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/event_bus/exchanges.py: -------------------------------------------------------------------------------- 1 | USER_EXCHANGE = "users" 2 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/alembic/migrations/versions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/alembic/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/alembic/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .venv/ 3 | .mypy_cache/ 4 | .ruff_cache/ 5 | zametka.egg-info/ 6 | .env.notes 7 | .env.access_service 8 | .env 9 | __pycache__/ -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/jwt/exceptions.py: -------------------------------------------------------------------------------- 1 | class JWTDecodeError(Exception): ... 2 | 3 | 4 | class JWTExpiredError(JWTDecodeError): ... 5 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/models/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm.decl_api import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/dependencies/__init__.py: -------------------------------------------------------------------------------- 1 | from .id_provider import get_token_id_provider 2 | 3 | __all__ = ["get_token_id_provider"] 4 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/exceptions/base.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.common.base_error import BaseError 2 | 3 | 4 | class DomainError(BaseError): ... 5 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from .user_identity import DBUser 3 | 4 | __all__ = ["Base", "DBUser"] 5 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/exceptions/base.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class DomainError(Exception): 4 | def __init__(self, message: str | None = None): 5 | self.message = message 6 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/models/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm.decl_api import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from .note import Note 3 | from .user import User 4 | 5 | __all__ = ["Base", "Note", "User"] 6 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/alembic/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ALEMBIC_CONFIG = os.path.join( 4 | os.path.dirname(os.path.abspath(__file__)), 5 | "alembic.ini", 6 | ) 7 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/schemas/note.py: -------------------------------------------------------------------------------- 1 | 2 | from pydantic import BaseModel 3 | 4 | 5 | class NoteSchema(BaseModel): 6 | title: str 7 | text: str | None = None 8 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/exceptions/base.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.common.base_error import BaseError 2 | 3 | 4 | class ApplicationError(BaseError): ... 5 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/exceptions/password_hasher.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.exceptions.base import DomainError 2 | 3 | 4 | class PasswordMismatchError(DomainError): ... 5 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/exceptions/repo_error.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.exceptions.base import ApplicationError 2 | 3 | 4 | class RepoError(ApplicationError): ... 5 | -------------------------------------------------------------------------------- /.env.access_service.example: -------------------------------------------------------------------------------- 1 | ACCESS_POSTGRES_DB=access_database 2 | 3 | JWT_KEY=randomkeyhere 4 | 5 | MAIL_USERNAME=usernameformailhere 6 | 7 | MAIL_PASSWORD=passwordformailhere 8 | 9 | MAIL_FROM=youremailhere 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=myuser 2 | 3 | POSTGRES_PASSWORD=mypassword 4 | 5 | DB_HOST=db 6 | 7 | POSTGRES_MULTIPLE_DATABASES=access_database,notes_database 8 | 9 | CONFIG_PATH=/usr/local/etc/zametka/cfg.toml 10 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/value_objects/user_hashed_password.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.common.value_objects.base import ValueObject 2 | 3 | 4 | class UserHashedPassword(ValueObject[str]): 5 | value: str 6 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/auth/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class TokenAuthConfig: 6 | token_cookie_key: str 7 | csrf_cookie_key: str 8 | csrf_headers_key: str 9 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/value_objects/user_id.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from zametka.access_service.domain.common.value_objects.base import ValueObject 4 | 5 | 6 | class UserId(ValueObject[UUID]): 7 | value: UUID 8 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/alembic/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ALEMBIC_CONFIG = os.path.join( # noqa: PTH118 4 | os.path.dirname(os.path.abspath(__file__)), # noqa: PTH100, PTH120 5 | "alembic.ini", 6 | ) 7 | -------------------------------------------------------------------------------- /src/zametka/notes/application/user/dto.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import date 3 | 4 | 5 | @dataclass(frozen=True) 6 | class UserDTO: 7 | first_name: str 8 | last_name: str 9 | joined_at: date 10 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/exceptions/access_token.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.exceptions.base import DomainError 2 | 3 | 4 | class UnauthorizedError(DomainError): ... 5 | 6 | 7 | class AccessTokenIsExpiredError(DomainError): ... 8 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/value_objects/timed_token_id.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from zametka.access_service.domain.common.value_objects.base import ValueObject 4 | 5 | 6 | class TimedTokenId(ValueObject[UUID]): 7 | value: UUID 8 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/message_broker/__init__.py: -------------------------------------------------------------------------------- 1 | from .message import Message 2 | from .message_broker import MessageBroker, RMQMessageBroker 3 | 4 | __all__ = [ 5 | "Message", 6 | "MessageBroker", 7 | "RMQMessageBroker", 8 | ] 9 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/note/note_id.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from zametka.notes.domain.common.value_objects.base import ValueObject 4 | 5 | 6 | @dataclass(frozen=True) 7 | class NoteId(ValueObject[int]): 8 | value: int 9 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/message_broker/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class AMQPConfig: 6 | host: str = "localhost" 7 | port: int = 5672 8 | login: str = "guest" 9 | password: str = "guest" 10 | -------------------------------------------------------------------------------- /.config/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | http { 6 | resolver 127.0.0.1 ipv6=off; 7 | server{ 8 | listen 80; 9 | location /api/ { 10 | proxy_pass http://backend:8000/; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/exceptions/user.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.exceptions.base import ApplicationError 2 | 3 | 4 | class UserIsNotExistsError(ApplicationError): ... 5 | 6 | 7 | class UserEmailAlreadyExistsError(ApplicationError): ... 8 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/message_broker/message.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from uuid import UUID 3 | 4 | 5 | @dataclass(frozen=True, kw_only=True) 6 | class Message: 7 | message_id: UUID 8 | data: str = "" 9 | message_type: str = "message" 10 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/schemas/user.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class UserSchema(BaseModel): 7 | first_name: str 8 | last_name: str 9 | 10 | 11 | class IdentitySchema(BaseModel): 12 | identity_id: UUID 13 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/email/email_client.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from email.message import Message 3 | from typing import Any, Protocol 4 | 5 | 6 | class EmailClient(Protocol): 7 | @abstractmethod 8 | async def send(self, message: Message) -> Any: ... 9 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/id_provider.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from zametka.access_service.domain.entities.user import User 5 | 6 | 7 | class IdProvider(Protocol): 8 | @abstractmethod 9 | async def get_user(self) -> User: ... 10 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/entities/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import timedelta 3 | 4 | 5 | @dataclass 6 | class AccessTokenConfig: 7 | expires_after: timedelta 8 | 9 | 10 | @dataclass 11 | class UserConfirmationTokenConfig: 12 | expires_after: timedelta 13 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/event_bus/events/__init__.py: -------------------------------------------------------------------------------- 1 | from .amqp_event import AMQPEvent 2 | from .integration_event import IntegrationEvent 3 | from .user import UserDeletedAMQPEvent 4 | 5 | __all__ = [ 6 | "IntegrationEvent", 7 | "AMQPEvent", 8 | "UserDeletedAMQPEvent", 9 | ] 10 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/event/__init__.py: -------------------------------------------------------------------------------- 1 | from .event import Event, EventsT, EventT 2 | from .event_emitter import EventEmitter 3 | from .event_handler import EventHandler 4 | 5 | __all__ = [ 6 | "Event", 7 | "EventT", 8 | "EventsT", 9 | "EventEmitter", 10 | "EventHandler", 11 | ] 12 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/services/access_service.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from zametka.access_service.domain.entities.user import User 5 | 6 | 7 | class AccessService(Protocol): 8 | @abstractmethod 9 | def authorize(self, user: User) -> None: ... 10 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/note/note_created_at.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from zametka.notes.domain.common.value_objects.base import ValueObject 5 | 6 | 7 | @dataclass(frozen=True) 8 | class NoteCreatedAt(ValueObject[datetime]): 9 | value: datetime 10 | -------------------------------------------------------------------------------- /src/zametka/notes/application/common/interactor.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | 3 | InputDTO = TypeVar("InputDTO") 4 | OutputDTO = TypeVar("OutputDTO") 5 | 6 | 7 | class Interactor(Generic[InputDTO, OutputDTO]): 8 | async def __call__(self, data: InputDTO) -> OutputDTO: 9 | raise NotImplementedError 10 | -------------------------------------------------------------------------------- /src/zametka/notes/application/common/id_provider.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from zametka.notes.domain.value_objects.user.user_id import UserId 5 | 6 | 7 | class IdProvider(Protocol): 8 | @abstractmethod 9 | async def get_user_id(self) -> UserId: 10 | raise NotImplementedError 11 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/exceptions/confirmation_token.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.exceptions.base import DomainError 2 | 3 | 4 | class ConfirmationTokenAlreadyUsedError(DomainError): ... 5 | 6 | 7 | class ConfirmationTokenIsExpiredError(DomainError): ... 8 | 9 | 10 | class CorruptedConfirmationTokenError(DomainError): ... 11 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/jwt/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Literal 3 | 4 | Algorithm = Literal[ 5 | "HS256", 6 | "HS384", 7 | "HS512", 8 | "RS256", 9 | "RS384", 10 | "RS512", 11 | ] 12 | 13 | 14 | @dataclass 15 | class JWTConfig: 16 | key: str 17 | algorithm: Algorithm 18 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/event/event.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import TypeVar 3 | 4 | 5 | class Event(ABC): 6 | def __str__(self) -> str: 7 | return self.__class__.__name__ 8 | 9 | 10 | EventT = TypeVar("EventT", bound=Event) # e.g Event1 11 | EventsT = TypeVar("EventsT", bound=Event) # e.g Event1 | Event 2 12 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/interactor.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Generic, TypeVar 3 | 4 | InputDTO = TypeVar("InputDTO") 5 | OutputDTO = TypeVar("OutputDTO") 6 | 7 | 8 | class Interactor(Generic[InputDTO, OutputDTO]): 9 | @abstractmethod 10 | async def __call__(self, data: InputDTO) -> OutputDTO: ... 11 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/exceptions/user.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.exceptions.base import DomainError 2 | 3 | 4 | class UserIsNotActiveError(DomainError): ... 5 | 6 | 7 | class InvalidCredentialsError(DomainError): ... 8 | 9 | 10 | class WeakPasswordError(DomainError): ... 11 | 12 | 13 | class InvalidUserEmailError(DomainError): ... 14 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/uow.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | 5 | class UoW(Protocol): 6 | @abstractmethod 7 | async def commit(self) -> None: ... 8 | 9 | @abstractmethod 10 | async def flush(self) -> None: ... 11 | 12 | @abstractmethod 13 | async def rollback(self) -> None: ... 14 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/event/event_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC 3 | from typing import Generic 4 | 5 | from zametka.access_service.application.common.event.event import EventT 6 | 7 | 8 | class EventHandler(Generic[EventT], ABC): 9 | async def __call__(self, event: EventT) -> None: 10 | logging.info("Handling event: %s", event) 11 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/user/user_joined_at.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import date, datetime 3 | 4 | from zametka.notes.domain.common.value_objects.base import ValueObject 5 | 6 | 7 | @dataclass(frozen=True) 8 | class UserJoinedAt(ValueObject[datetime]): 9 | value: datetime 10 | 11 | def read(self) -> date: 12 | return self.value.date() 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.1-slim-buster as base 2 | 3 | WORKDIR /app 4 | 5 | ENV APP_HOME=/home/app/backend 6 | WORKDIR $APP_HOME 7 | 8 | RUN mkdir ./src 9 | 10 | RUN addgroup --system app && adduser --system --group app 11 | 12 | RUN pip install uv 13 | 14 | COPY ./pyproject.toml $APP_HOME 15 | 16 | RUN uv pip install -e . --system 17 | 18 | COPY ./src/ $APP_HOME/src/ 19 | 20 | RUN chown -R app:app $HOME 21 | 22 | USER app 23 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/email/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class SMTPConfig: 6 | user: str 7 | password: str 8 | port: int 9 | host: str 10 | use_tls: bool 11 | 12 | 13 | @dataclass 14 | class ConfirmationEmailConfig: 15 | subject: str 16 | confirmation_link: str 17 | email_from: str 18 | template_path: str 19 | template_name: str 20 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/exceptions/note.py: -------------------------------------------------------------------------------- 1 | from zametka.notes.domain.exceptions.base import DomainError 2 | 3 | 4 | class NoteDataError(DomainError): 5 | pass 6 | 7 | 8 | class NoteNotExistsError(DomainError): 9 | pass 10 | 11 | 12 | class NoteAccessDeniedError(DomainError): 13 | pass 14 | 15 | 16 | class InvalidNoteTextError(NoteDataError): 17 | pass 18 | 19 | 20 | class InvalidNoteTitleError(NoteDataError): 21 | pass 22 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/value_objects/expires_in.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | 3 | from zametka.access_service.domain.common.value_objects.base import ValueObject 4 | 5 | 6 | class ExpiresIn(ValueObject[datetime]): 7 | value: datetime 8 | 9 | @property 10 | def is_expired(self) -> bool: 11 | now = datetime.now(tz=UTC) 12 | 13 | if now > self.value: 14 | return True 15 | 16 | return False 17 | -------------------------------------------------------------------------------- /src/zametka/notes/application/common/uow.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | 5 | class UoW(Protocol): 6 | @abstractmethod 7 | async def commit(self) -> None: 8 | raise NotImplementedError 9 | 10 | @abstractmethod 11 | async def flush(self) -> None: 12 | raise NotImplementedError 13 | 14 | @abstractmethod 15 | async def rollback(self) -> None: 16 | raise NotImplementedError 17 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/exceptions/user.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.exceptions.base import DomainError 2 | 3 | 4 | class UserDataError(DomainError): 5 | pass 6 | 7 | 8 | class InvalidUserFirstNameError(UserDataError): 9 | pass 10 | 11 | 12 | class InvalidUserLastNameError(UserDataError): 13 | pass 14 | 15 | 16 | class UserIsNotExistsError(DomainError): 17 | pass 18 | 19 | 20 | class IsNotAuthorizedError(DomainError): 21 | pass 22 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/entities/confirmation_token.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.common.entities.timed_user_token import ( 2 | TimedUserToken, 3 | ) 4 | from zametka.access_service.domain.exceptions.confirmation_token import ( 5 | ConfirmationTokenIsExpiredError, 6 | ) 7 | 8 | 9 | class UserConfirmationToken(TimedUserToken): 10 | def verify(self) -> None: 11 | if self.expires_in.is_expired: 12 | raise ConfirmationTokenIsExpiredError 13 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/token_sender.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from zametka.access_service.application.dto import UserConfirmationTokenDTO 5 | from zametka.access_service.domain.entities.user import User 6 | 7 | 8 | class TokenSender(Protocol): 9 | @abstractmethod 10 | async def send( 11 | self, 12 | confirmation_token: UserConfirmationTokenDTO, 13 | user: User, 14 | ) -> None: ... 15 | -------------------------------------------------------------------------------- /tests/mocks/access_service/id_provider.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.id_provider import IdProvider 2 | from zametka.access_service.domain.entities.user import User 3 | 4 | 5 | class FakeIdProvider(IdProvider): 6 | def __init__(self, user: User): 7 | self.requested = False 8 | self.user = user 9 | 10 | async def get_user(self) -> User: 11 | self.user.ensure_is_active() 12 | self.requested = True 13 | return self.user 14 | -------------------------------------------------------------------------------- /tests/mocks/access_service/token_sender.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.token_sender import TokenSender 2 | from zametka.access_service.application.dto import UserConfirmationTokenDTO 3 | from zametka.access_service.domain.entities.user import User 4 | 5 | 6 | class FakeTokenSender(TokenSender): 7 | def __init__(self): 8 | self.token_sent_cnt = 0 9 | 10 | async def send( 11 | self, 12 | confirmation_token: UserConfirmationTokenDTO, 13 | user: User, 14 | ) -> None: 15 | self.token_sent_cnt += 1 16 | -------------------------------------------------------------------------------- /.config/dev.config.toml: -------------------------------------------------------------------------------- 1 | [web] 2 | cors-allow = ['http://localhost:5173'] 3 | 4 | [email] 5 | activation-mail-subject = 'ЗАВЕРШИТЕ РЕГИСТРАЦИЮ В ZAMETKA' 6 | activation-email-template-path = 'zametka.access_service.presentation.http' 7 | activation-email-template-name = 'confirmation-mail.html' 8 | 9 | [smtp] 10 | use-tls = true 11 | host = 'smtp.yandex.ru' 12 | port = 465 13 | 14 | [security] 15 | algorithm = 'HS256' 16 | access-token-expires-minutes = 5 17 | confirmation-token-expires-minutes = 5 18 | refresh-token-expires-days = 15 19 | 20 | [auth] 21 | auth-token-key = 'Token' -------------------------------------------------------------------------------- /src/zametka/access_service/domain/entities/access_token.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from zametka.access_service.domain.common.entities.timed_user_token import ( 4 | TimedUserToken, 5 | ) 6 | from zametka.access_service.domain.exceptions.access_token import ( 7 | AccessTokenIsExpiredError, 8 | ) 9 | 10 | 11 | @dataclass(frozen=True) 12 | class AccessToken(TimedUserToken): 13 | revoked: bool = False 14 | 15 | def verify(self) -> None: 16 | if self.expires_in.is_expired or self.revoked: 17 | raise AccessTokenIsExpiredError 18 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/email/aio_email_client.py: -------------------------------------------------------------------------------- 1 | from email.message import Message 2 | from typing import Any 3 | 4 | from aiosmtplib import SMTP 5 | 6 | from zametka.access_service.infrastructure.email.email_client import ( 7 | EmailClient, 8 | ) 9 | 10 | 11 | class AioSMTPEmailClient(EmailClient): 12 | def __init__(self, aio_smtp_client: SMTP) -> None: 13 | self.client = aio_smtp_client 14 | 15 | async def send(self, message: Message) -> Any: 16 | async with self.client: 17 | await self.client.send_message(message) 18 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/event/event_emitter.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Generic 3 | 4 | from zametka.access_service.application.common.event.event import EventsT 5 | from zametka.access_service.application.common.event.event_handler import ( 6 | EventHandler, 7 | ) 8 | 9 | 10 | class EventEmitter(Generic[EventsT], ABC): 11 | @abstractmethod 12 | def on(self, event_type: type[EventsT], handler: EventHandler[EventsT]) -> None: ... 13 | 14 | @abstractmethod 15 | async def emit(self, event: EventsT) -> None: ... 16 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/event_bus/events/user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from zametka.access_service.application.dto import UserDeletedEvent 4 | from zametka.access_service.infrastructure.event_bus.exchanges import ( 5 | USER_EXCHANGE, 6 | ) 7 | 8 | from .amqp_event import AMQPEvent, amqp_event 9 | from .integration_event import integration_event 10 | 11 | 12 | @dataclass(frozen=True) 13 | @amqp_event(exchange=USER_EXCHANGE) 14 | @integration_event("UserDeletedEvent") 15 | class UserDeletedAMQPEvent(AMQPEvent[UserDeletedEvent]): 16 | pass 17 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/exceptions.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from zametka.access_service.domain.common.base_error import BaseError 4 | 5 | 6 | @dataclass(frozen=True) 7 | class HTTPError(BaseError): 8 | http_code: int 9 | 10 | 11 | @dataclass(frozen=True) 12 | class CSRFError(HTTPError): 13 | http_code: int = 401 14 | 15 | 16 | class CSRFMismatchError(CSRFError): ... 17 | 18 | 19 | class CSRFCorruptedError(CSRFError): ... 20 | 21 | 22 | class CSRFMissingError(CSRFError): ... 23 | 24 | 25 | class CSRFExpiredError(CSRFError): ... 26 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/message_broker/uow.py: -------------------------------------------------------------------------------- 1 | import aio_pika 2 | 3 | from zametka.access_service.application.common.uow import UoW 4 | 5 | 6 | class RabbitMQUoW(UoW): 7 | def __init__(self, rq_transaction: aio_pika.abc.AbstractTransaction) -> None: 8 | self._rq_transaction = rq_transaction 9 | 10 | async def commit(self) -> None: 11 | await self._rq_transaction.commit() 12 | 13 | async def rollback(self) -> None: 14 | await self._rq_transaction.rollback() 15 | 16 | async def flush(self) -> None: 17 | raise NotImplementedError 18 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/uow.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from zametka.notes.application.common.uow import UoW 4 | 5 | 6 | class SAUnitOfWork(UoW): 7 | """Sqlalchemy unit of work""" 8 | 9 | session: AsyncSession 10 | 11 | def __init__(self, session: AsyncSession): 12 | self.session = session 13 | 14 | async def commit(self) -> None: 15 | await self.session.commit() 16 | 17 | async def rollback(self) -> None: 18 | await self.session.rollback() 19 | 20 | async def flush(self) -> None: 21 | await self.session.flush() 22 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/user/user_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | from uuid import UUID 6 | 7 | from zametka.notes.domain.common.value_objects.base import ValueObject 8 | 9 | 10 | @dataclass(frozen=True) 11 | class UserId(ValueObject[UUID]): 12 | value: UUID 13 | 14 | def __eq__(self, other: UserId | Any) -> bool: 15 | if not isinstance(other, UserId): 16 | raise ValueError(f"Expected UserIdentityId, got {type(other)}") 17 | return str(self.to_raw()) == str(other.to_raw()) 18 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/note/note_text.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from zametka.notes.domain.common.value_objects.base import ValueObject 4 | from zametka.notes.domain.exceptions.note import InvalidNoteTextError 5 | 6 | 7 | @dataclass(frozen=True) 8 | class NoteText(ValueObject[str]): 9 | value: str 10 | 11 | MAX_LENGTH = 60000 12 | 13 | def _validate(self) -> None: 14 | if len(self.value) > self.MAX_LENGTH: 15 | raise InvalidNoteTextError("Текст заметки слишком длинный!") 16 | if not self.value: 17 | raise InvalidNoteTextError("Текст заметки пуст!") 18 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/uow.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from zametka.access_service.application.common.uow import UoW 4 | 5 | 6 | class SAUnitOfWork(UoW): 7 | """Sqlalchemy unit of work""" 8 | 9 | session: AsyncSession 10 | 11 | def __init__(self, session: AsyncSession): 12 | self.session = session 13 | 14 | async def commit(self) -> None: 15 | await self.session.commit() 16 | 17 | async def rollback(self) -> None: 18 | await self.session.rollback() 19 | 20 | async def flush(self) -> None: 21 | await self.session.flush() 22 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/common/value_objects/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from dataclasses import dataclass 3 | from typing import Any, Generic, TypeVar 4 | 5 | V = TypeVar("V", bound=Any) 6 | 7 | 8 | @dataclass(frozen=True) 9 | class BaseValueObject(ABC): 10 | def __post_init__(self) -> None: 11 | self._validate() 12 | 13 | def _validate(self) -> None: 14 | """This method checks that a value is valid to create this value object""" 15 | 16 | 17 | @dataclass(frozen=True) 18 | class ValueObject(BaseValueObject, ABC, Generic[V]): 19 | value: V 20 | 21 | def to_raw(self) -> V: 22 | return self.value 23 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | 3 | from pydantic import BaseModel, model_validator 4 | 5 | 6 | class CreateIdentitySchema(BaseModel): 7 | email: str 8 | password: str 9 | password2: str 10 | 11 | @model_validator(mode="after") 12 | def check_passwords_match(self) -> Self: 13 | pw1, pw2 = self.password, self.password2 14 | 15 | if pw1 is not None and pw2 is not None and pw1 != pw2: 16 | raise ValueError("Пароли не совпадают") 17 | 18 | return self 19 | 20 | 21 | class AuthorizeSchema(BaseModel): 22 | email: str 23 | password: str 24 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/get_user.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.id_provider import IdProvider 2 | from zametka.access_service.application.common.interactor import Interactor 3 | from zametka.access_service.application.dto import UserDTO 4 | 5 | 6 | class GetUser(Interactor[None, UserDTO]): 7 | def __init__( 8 | self, 9 | id_provider: IdProvider, 10 | ): 11 | self.id_provider = id_provider 12 | 13 | async def __call__(self, data=None) -> UserDTO: 14 | user = await self.id_provider.get_user() 15 | return UserDTO( 16 | user_id=user.user_id.to_raw(), 17 | ) 18 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/alembic/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/dto.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | from uuid import UUID 4 | 5 | from zametka.access_service.application.common.event.event import Event 6 | 7 | 8 | @dataclass(frozen=True) 9 | class UserDTO: 10 | user_id: UUID 11 | 12 | 13 | @dataclass(frozen=True) 14 | class AccessTokenDTO: 15 | uid: UUID 16 | expires_in: datetime 17 | token_id: UUID 18 | 19 | 20 | @dataclass(frozen=True) 21 | class UserConfirmationTokenDTO: 22 | uid: UUID 23 | expires_in: datetime 24 | token_id: UUID 25 | 26 | 27 | @dataclass(frozen=True) 28 | class UserDeletedEvent(Event): 29 | user_id: UUID 30 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/models/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from uuid import UUID 3 | 4 | from sqlalchemy import DateTime, String, Uuid 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | from zametka.notes.infrastructure.db.models.base import Base 8 | 9 | 10 | class User(Base): 11 | """App user""" 12 | 13 | __tablename__ = "users" 14 | 15 | identity_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True) 16 | first_name: Mapped[str] = mapped_column(String(40), nullable=False) 17 | last_name: Mapped[str] = mapped_column(String(60), nullable=False) 18 | joined_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) 19 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/models/user_identity.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy import Boolean, String, Uuid 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | from zametka.access_service.infrastructure.persistence.models.base import Base 7 | 8 | 9 | class DBUser(Base): 10 | __tablename__ = "users" 11 | 12 | user_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True) 13 | email: Mapped[str] = mapped_column(String(60), nullable=False, unique=True) 14 | hashed_password: Mapped[str] = mapped_column(String(300), nullable=False) 15 | is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) 16 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/value_objects/user_email.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from email_validator import EmailNotValidError, validate_email 4 | 5 | from zametka.access_service.domain.common.value_objects.base import ValueObject 6 | from zametka.access_service.domain.exceptions.user import InvalidUserEmailError 7 | 8 | 9 | class UserEmail(ValueObject[str]): 10 | value: str 11 | 12 | MAX_LENGTH = 100 13 | MIN_LENGTH = 6 14 | 15 | def _validate(self) -> None: 16 | try: 17 | validate_email(self.value, check_deliverability=False) 18 | except EmailNotValidError as exc: 19 | raise InvalidUserEmailError from exc 20 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/alembic/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/provider.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from zametka.notes.infrastructure.db.uow import SAUnitOfWork 4 | from zametka.notes.infrastructure.repositories.note import NoteRepositoryImpl 5 | from zametka.notes.infrastructure.repositories.user import UserRepositoryImpl 6 | 7 | 8 | def get_uow(session: AsyncSession) -> SAUnitOfWork: 9 | return SAUnitOfWork(session=session) 10 | 11 | 12 | def get_note_repository(session: AsyncSession) -> NoteRepositoryImpl: 13 | return NoteRepositoryImpl(session=session) 14 | 15 | 16 | def get_user_repository(session: AsyncSession) -> UserRepositoryImpl: 17 | return UserRepositoryImpl(session=session) 18 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/services/password_hasher.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from zametka.access_service.domain.value_objects.user_hashed_password import ( 5 | UserHashedPassword, 6 | ) 7 | from zametka.access_service.domain.value_objects.user_raw_password import ( 8 | UserRawPassword, 9 | ) 10 | 11 | 12 | class PasswordHasher(Protocol): 13 | @abstractmethod 14 | def hash_password(self, password: UserRawPassword) -> UserHashedPassword: ... 15 | 16 | @abstractmethod 17 | def verify_password( 18 | self, 19 | raw_password: UserRawPassword, 20 | hashed_password: UserHashedPassword, 21 | ) -> None: ... 22 | -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Configuration and Deployment 2 | 3 | ## Configuring secrets 4 | 5 | #### For the application to run, you need to set up some secrets, for this, rename the 6 | 1. .env.example -> .env 7 | 2. .env.access_service.example -> .env.access_service 8 | 3. .env.notes.example -> .env.notes 9 | 10 | #### Next, fill in your secrets instead of template secrets in these files. 11 | 12 | ## Preparing the environment 13 | 14 | #### In order to run the project, you need to install the 15 | 1. docker engine 16 | 2. docker compose 17 | 18 | ## Launching an application 19 | 20 | ### All you need is 21 | 22 | ```shell 23 | docker compose up --build 24 | ``` 25 | 26 | Or 27 | 28 | ```shell 29 | docker-compose up --build 30 | ``` -------------------------------------------------------------------------------- /tests/unit/access_service/domain/entities/test_timed_token.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from zametka.access_service.domain.exceptions.confirmation_token import ( 3 | ConfirmationTokenIsExpiredError, 4 | ) 5 | 6 | 7 | @pytest.mark.access 8 | @pytest.mark.domain 9 | @pytest.mark.parametrize( 10 | ["fixture_name", "exc_class"], 11 | [ 12 | ("confirmation_token", None), 13 | ("expired_confirmation_token", ConfirmationTokenIsExpiredError), 14 | ], 15 | ) 16 | def test_verify_token(fixture_name, exc_class, request): 17 | token = request.getfixturevalue(fixture_name) 18 | if not exc_class: 19 | token.verify() 20 | else: 21 | with pytest.raises(exc_class): 22 | token.verify() 23 | -------------------------------------------------------------------------------- /tests/mocks/access_service/event_emitter.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.event import ( 2 | EventEmitter, 3 | EventHandler, 4 | EventsT, 5 | ) 6 | 7 | 8 | class FakeEventEmitter(EventEmitter[EventsT]): 9 | def __init__(self) -> None: 10 | self._calls = {} 11 | 12 | def on(self, event_type: type[EventsT], handler: EventHandler[EventsT]) -> None: 13 | raise NotImplementedError 14 | 15 | def calls(self, event_type: type[EventsT]): 16 | return event_type in self._calls 17 | 18 | async def emit(self, event: EventsT) -> None: 19 | if not self._calls.get(type(event)): 20 | self._calls[type(event)] = 1 21 | else: 22 | self._calls[type(event)] += 1 23 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import FastAPI 4 | 5 | from zametka.access_service.presentation.http.exception_handlers import ( 6 | app_exception_handler, 7 | ) 8 | 9 | from ..domain.common.base_error import BaseError 10 | from .http.endpoints import user 11 | 12 | 13 | def include_routers(app: FastAPI) -> None: 14 | app.include_router(user.router) 15 | logging.info("Routers was included.") 16 | 17 | 18 | def include_exception_handlers(app: FastAPI) -> None: 19 | app.add_exception_handler(BaseError, app_exception_handler) 20 | logging.info("Exception handlers was included.") 21 | 22 | 23 | __all__ = [ 24 | "include_exception_handlers", 25 | "include_routers", 26 | ] 27 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/value_objects/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from dataclasses import dataclass 3 | from typing import Any, Generic, TypeVar 4 | 5 | V = TypeVar("V", bound=Any) 6 | 7 | 8 | @dataclass(frozen=True) 9 | class BaseValueObject(ABC): 10 | def __post_init__(self) -> None: 11 | self._validate() 12 | 13 | def _validate(self) -> None: ... 14 | 15 | 16 | @dataclass(frozen=True) 17 | class ValueObject(BaseValueObject, ABC, Generic[V]): 18 | value: V 19 | 20 | def to_raw(self) -> V: 21 | return self.value 22 | 23 | def __eq__(self, other: object) -> bool: 24 | if not isinstance(other, self.__class__): 25 | return self.value == other 26 | return self.value == other.value 27 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/id_provider.py: -------------------------------------------------------------------------------- 1 | from zametka.notes.application.common.id_provider import IdProvider 2 | from zametka.notes.domain.value_objects.user.user_id import UserId 3 | from zametka.notes.infrastructure.access_api_client import AccessAPIClient 4 | 5 | 6 | class RawIdProvider(IdProvider): 7 | def __init__(self, user_id: UserId) -> None: 8 | self._user_id = user_id 9 | 10 | async def get_user_id(self) -> UserId: 11 | return self._user_id 12 | 13 | 14 | class TokenIdProvider(IdProvider): 15 | def __init__(self, api_client: AccessAPIClient): 16 | self._api_client = api_client 17 | 18 | async def get_user_id(self) -> UserId: 19 | user_id = await self._api_client.get_identity() 20 | 21 | return user_id 22 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/repositories/converters/user.py: -------------------------------------------------------------------------------- 1 | from zametka.notes.application.user.dto import UserDTO 2 | from zametka.notes.domain.entities.user import User as UserEntity 3 | from zametka.notes.infrastructure.db.models.user import User 4 | 5 | 6 | def user_db_model_to_user_dto(user: User) -> UserDTO: 7 | return UserDTO( 8 | first_name=user.first_name, 9 | last_name=user.last_name, 10 | joined_at=user.joined_at, 11 | ) 12 | 13 | 14 | def user_entity_to_db_model(user: UserEntity) -> User: 15 | db_user = User( 16 | first_name=user.first_name.to_raw(), 17 | last_name=user.last_name.to_raw(), 18 | joined_at=user.joined_at.to_raw(), 19 | identity_id=user.identity_id.to_raw(), 20 | ) 21 | 22 | return db_user 23 | -------------------------------------------------------------------------------- /tests/mocks/access_service/uow.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.uow import UoW 2 | 3 | 4 | class FakeUoW(UoW): 5 | def __init__(self): 6 | self.committed = False 7 | self.rolled_back = False 8 | self.flushed = False 9 | 10 | async def commit(self) -> None: 11 | if self.rolled_back: 12 | raise ValueError("Cannot commit after rolling back.") 13 | self.committed = True 14 | 15 | async def rollback(self) -> None: 16 | if self.committed: 17 | raise ValueError("Cannot rollback after committing.") 18 | self.rolled_back = True 19 | 20 | async def flush(self) -> None: 21 | if self.flushed: 22 | raise ValueError("Cannot flush after flushing.") 23 | self.flushed = True 24 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/note/note_title.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from zametka.notes.domain.common.value_objects.base import ValueObject 4 | from zametka.notes.domain.exceptions.note import InvalidNoteTitleError 5 | 6 | 7 | @dataclass(frozen=True) 8 | class NoteTitle(ValueObject[str]): 9 | value: str 10 | 11 | MAX_LENGTH = 50 12 | 13 | def _validate(self) -> None: 14 | if len(self.value) > self.MAX_LENGTH: 15 | raise InvalidNoteTitleError("Название заметки слишком длинное!") 16 | if not any(not x.isspace() for x in self.value): 17 | raise InvalidNoteTitleError( 18 | "Название заметки не может состоять только из пробелов!", 19 | ) 20 | if not self.value: 21 | raise InvalidNoteTitleError("Название заметки пусто!") 22 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/http_error_code.py: -------------------------------------------------------------------------------- 1 | from types import MappingProxyType 2 | from typing import Final 3 | 4 | from zametka.access_service.infrastructure.error_code import ErrorCode 5 | 6 | HTTP_ERROR_CODE: Final[MappingProxyType[ErrorCode, int]] = MappingProxyType( 7 | { 8 | ErrorCode.WEAK_PASSWORD: 400, 9 | ErrorCode.INVALID_EMAIL: 400, 10 | ErrorCode.INVALID_CREDENTIALS: 403, 11 | ErrorCode.USER_NOT_EXISTS: 404, 12 | ErrorCode.USER_NOT_ACTIVE: 403, 13 | ErrorCode.ACCESS_TOKEN_EXPIRED: 401, 14 | ErrorCode.UNAUTHORIZED: 401, 15 | ErrorCode.CONFIRMATION_TOKEN_EXPIRED: 408, 16 | ErrorCode.CONFIRMATION_TOKEN_ALREADY_USED: 409, 17 | ErrorCode.CORRUPTED_CONFIRMATION_TOKEN: 400, 18 | ErrorCode.USER_EMAIL_ALREADY_EXISTS: 409, 19 | }, 20 | ) 21 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/models/note.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from uuid import UUID 3 | 4 | from sqlalchemy import DateTime, ForeignKey, Integer, String, Uuid 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | from zametka.notes.infrastructure.db.models.base import Base 8 | 9 | 10 | class Note(Base): 11 | """The user notes""" 12 | 13 | __tablename__ = "notes" 14 | 15 | note_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 16 | title: Mapped[str] = mapped_column(String(50), nullable=False) 17 | text: Mapped[str | None] = mapped_column(String(60000), nullable=True) 18 | created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) 19 | 20 | author_id: Mapped[UUID] = mapped_column( 21 | Uuid, ForeignKey("users.identity_id"), nullable=False, 22 | ) 23 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/common/user_gateway.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from zametka.access_service.application.dto import UserDTO 5 | from zametka.access_service.domain.entities.user import User 6 | from zametka.access_service.domain.value_objects.user_email import UserEmail 7 | from zametka.access_service.domain.value_objects.user_id import UserId 8 | 9 | 10 | class UserReader(Protocol): 11 | @abstractmethod 12 | async def with_id(self, user_id: UserId) -> User | None: ... 13 | 14 | @abstractmethod 15 | async def with_email(self, email: UserEmail) -> User | None: ... 16 | 17 | 18 | class UserSaver(Protocol): 19 | @abstractmethod 20 | async def save(self, user: User) -> UserDTO: ... 21 | 22 | @abstractmethod 23 | async def delete(self, user_id: UserId) -> None: ... 24 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | 4 | 5 | @dataclass 6 | class BaseDBConfig: 7 | """Base database config""" 8 | 9 | host: str 10 | db_name: str 11 | user: str 12 | password: str 13 | 14 | def get_connection_url(self) -> str: 15 | return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}/{self.db_name}" 16 | 17 | 18 | @dataclass 19 | class DBConfig(BaseDBConfig): 20 | """App database config""" 21 | 22 | 23 | @dataclass 24 | class AlembicDBConfig(BaseDBConfig): 25 | """Alembic database config""" 26 | 27 | 28 | def load_alembic_config() -> AlembicDBConfig: 29 | return AlembicDBConfig( 30 | db_name=os.environ["ACCESS_POSTGRES_DB"], 31 | host=os.environ["DB_HOST"], 32 | password=os.environ["POSTGRES_PASSWORD"], 33 | user=os.environ["POSTGRES_USER"], 34 | ) 35 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/event_bus/event_handler.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.event import ( 2 | EventHandler, 3 | ) 4 | from zametka.access_service.application.dto import UserDeletedEvent 5 | from zametka.access_service.infrastructure.event_bus.amqp_event_sender import ( 6 | AMQPEventSender, 7 | ) 8 | from zametka.access_service.infrastructure.event_bus.events import ( 9 | AMQPEvent, 10 | UserDeletedAMQPEvent, 11 | ) 12 | 13 | 14 | class UserDeletedEventHandler(EventHandler[UserDeletedEvent]): 15 | def __init__(self, event_sender: AMQPEventSender) -> None: 16 | self.event_sender = event_sender 17 | 18 | async def __call__(self, event: UserDeletedEvent) -> None: 19 | await super().__call__(event) 20 | 21 | amqp_event: AMQPEvent[UserDeletedEvent] = UserDeletedAMQPEvent( 22 | original_event=event, 23 | ) 24 | 25 | await self.event_sender.send(amqp_event) 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: 'https://github.com/pre-commit/pre-commit-hooks' 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - id: detect-private-key 7 | - id: trailing-whitespace 8 | - id: check-added-large-files 9 | args: ['--maxkb=100'] 10 | #- repo: https://github.com/astral-sh/ruff-pre-commit 11 | # rev: v0.4.2 12 | # hooks: 13 | # - id: ruff 14 | # - id: ruff-format 15 | - repo: local 16 | hooks: 17 | - id: pytest-check 18 | types: [python] 19 | name: pytest-check 20 | entry: pytest tests 21 | language: system 22 | pass_filenames: false 23 | always_run: true 24 | #- repo: local 25 | # hooks: 26 | # - id: mypy-check 27 | # types: [python] 28 | # name: mypy-check 29 | # entry: mypy src/zametka/access_service 30 | # language: system 31 | # pass_filenames: false 32 | # always_run: true -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this project 2 | 3 | ## Preparing the working environment 4 | 5 | ### 1. Install the uv 6 | 7 | #### This project is designed to work with the python package manager called [uv](https://github.com/astral-sh/uv), let's [install it](https://github.com/astral-sh/uv?tab=readme-ov-file#getting-started) 8 | 9 | ### 2. Making venv 10 | 11 | ```shell 12 | uv venv 13 | ``` 14 | 15 | ### 3. Activating venv 16 | 17 | ```shell 18 | # On macOS and Linux. 19 | source .venv/bin/activate 20 | 21 | # On Windows. 22 | .venv\Scripts\activate 23 | ``` 24 | 25 | ### 4. Installing project in development mode 26 | 27 | ```shell 28 | uv pip install -e .[dev] 29 | ``` 30 | 31 | ## Running tests 32 | 33 | ```shell 34 | pytest tests 35 | ``` 36 | 37 | ## Running linters 38 | 39 | ### Typecheck: 40 | ```shell 41 | mypy src/zametka/access_service 42 | ``` 43 | 44 | ### Lint 45 | ```shell 46 | ruff check 47 | ``` 48 | 49 | ### Format 50 | ```shell 51 | ruff format 52 | ``` -------------------------------------------------------------------------------- /src/zametka/access_service/domain/common/entities/timed_user_token.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from dataclasses import dataclass 5 | 6 | from zametka.access_service.domain.common.value_objects.timed_token_id import ( 7 | TimedTokenId, 8 | ) 9 | from zametka.access_service.domain.value_objects.expires_in import ExpiresIn 10 | from zametka.access_service.domain.value_objects.user_id import UserId 11 | 12 | 13 | @dataclass(frozen=True) 14 | class TimedTokenMetadata: 15 | uid: UserId 16 | expires_in: ExpiresIn 17 | 18 | 19 | @dataclass(frozen=True) 20 | class TimedUserToken(ABC): 21 | metadata: TimedTokenMetadata 22 | token_id: TimedTokenId 23 | 24 | @property 25 | def expires_in(self) -> ExpiresIn: 26 | return self.metadata.expires_in 27 | 28 | @property 29 | def uid(self) -> UserId: 30 | return self.metadata.uid 31 | 32 | @abstractmethod 33 | def verify(self) -> None: ... 34 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/event_bus/events/integration_event.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass, field 3 | from datetime import datetime 4 | from typing import Any, ClassVar, Generic, TypeVar 5 | from uuid import UUID, uuid4 6 | 7 | from zametka.access_service.application.common.event import Event, EventT 8 | 9 | 10 | @dataclass(frozen=True, kw_only=True) 11 | class IntegrationEvent(Event, Generic[EventT]): 12 | event_id: UUID = field(default_factory=uuid4) 13 | event_timestamp: datetime = field(default_factory=datetime.utcnow) 14 | event_type: ClassVar[str] 15 | original_event: EventT 16 | 17 | 18 | EventType = TypeVar("EventType", bound=type[IntegrationEvent[Any]]) 19 | 20 | 21 | def integration_event( 22 | event_type: str, 23 | ) -> Callable[[EventType], EventType]: 24 | def _integration_event(cls: EventType) -> EventType: 25 | cls.event_type = event_type 26 | return cls 27 | 28 | return _integration_event 29 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/exception_handlers/note.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request 2 | from fastapi.responses import JSONResponse 3 | 4 | from zametka.notes.domain.exceptions.note import ( 5 | NoteAccessDeniedError, 6 | NoteDataError, 7 | NoteNotExistsError, 8 | ) 9 | 10 | 11 | async def note_access_denied_exception_handler( 12 | _request: Request, _exc: NoteAccessDeniedError, 13 | ) -> JSONResponse: 14 | return JSONResponse( 15 | status_code=403, 16 | content={"message": "Доступ закрыт."}, 17 | ) 18 | 19 | 20 | async def note_not_exists_exception_handler( 21 | _request: Request, _exc: NoteNotExistsError, 22 | ) -> JSONResponse: 23 | return JSONResponse( 24 | status_code=404, 25 | content={"message": "Такой записи не существует."}, 26 | ) 27 | 28 | 29 | async def note_data_exception_handler( 30 | _request: Request, exc: NoteDataError, 31 | ) -> JSONResponse: 32 | return JSONResponse(status_code=422, content={"detail": exc.message}) 33 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/event_bus/event_emitter.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.event import ( 2 | EventEmitter, 3 | EventHandler, 4 | EventsT, 5 | ) 6 | 7 | 8 | class EventEmitterImpl(EventEmitter[EventsT]): 9 | _events: dict[type[EventsT], list[EventHandler[EventsT]]] 10 | 11 | def __init__(self) -> None: 12 | self._events = {} 13 | 14 | def on(self, event_type: type[EventsT], handler: EventHandler[EventsT]) -> None: 15 | existing_handlers = self._events.get(event_type) 16 | 17 | if not existing_handlers: 18 | self._events[event_type] = [handler] 19 | else: 20 | self._events[event_type] = [handler, *existing_handlers] 21 | 22 | async def emit(self, event: EventsT) -> None: 23 | handlers: list[EventHandler[EventsT]] | None = self._events.get(type(event)) 24 | 25 | if not handlers: 26 | return 27 | 28 | for handler in handlers: 29 | await handler(event) 30 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | from datetime import timedelta 4 | 5 | 6 | @dataclass 7 | class CORSConfig: 8 | frontend_url: str 9 | 10 | 11 | @dataclass 12 | class AuthJWTConfig: 13 | authjwt_secret_key: str 14 | 15 | authjwt_token_location: set[str] 16 | 17 | authjwt_access_token_expires: timedelta 18 | authjwt_cookie_expires: int 19 | 20 | authjwt_cookie_secure: bool = False 21 | 22 | authjwt_cookie_csrf_protect: bool = True 23 | 24 | authjwt_cookie_samesite: str = "lax" 25 | 26 | 27 | def load_authjwt_config() -> AuthJWTConfig: 28 | return AuthJWTConfig( 29 | authjwt_secret_key=os.environ["AUTHJWT_SECRET_KEY"], 30 | authjwt_token_location={"cookies"}, 31 | authjwt_access_token_expires=timedelta( 32 | minutes=int(os.environ["AUTHJWT_TOKEN_EXPIRES_MINUTES"]), 33 | ), 34 | authjwt_cookie_expires=int(os.environ["AUTHJWT_COOKIE_EXPIRES_SECONDS"]), 35 | ) 36 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/services/token_access_service.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.domain.common.services.access_service import AccessService 2 | from zametka.access_service.domain.entities.access_token import AccessToken 3 | from zametka.access_service.domain.entities.user import User 4 | from zametka.access_service.domain.exceptions.access_token import ( 5 | AccessTokenIsExpiredError, 6 | UnauthorizedError, 7 | ) 8 | from zametka.access_service.domain.exceptions.user import UserIsNotActiveError 9 | 10 | 11 | class TokenAccessService(AccessService): 12 | def __init__(self, token: AccessToken) -> None: 13 | self.token = token 14 | 15 | def authorize(self, user: User) -> None: 16 | try: 17 | user.ensure_is_active() 18 | self.token.verify() 19 | except (AccessTokenIsExpiredError, UserIsNotActiveError) as exc: 20 | raise exc from UnauthorizedError 21 | 22 | if not self.token.uid == user.user_id: 23 | raise UnauthorizedError 24 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/user/user_last_name.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dataclasses import dataclass 3 | 4 | from zametka.notes.domain.common.value_objects.base import ValueObject 5 | from zametka.notes.domain.exceptions.user import InvalidUserLastNameError 6 | 7 | 8 | @dataclass(frozen=True) 9 | class UserLastName(ValueObject[str]): 10 | value: str 11 | 12 | MAX_LENGTH = 60 13 | MIN_LENGTH = 2 14 | 15 | def _validate(self) -> None: 16 | if len(self.value) > self.MAX_LENGTH: 17 | raise InvalidUserLastNameError("Фамилия пользователя слишком длинная!") 18 | if len(self.value) < self.MIN_LENGTH: 19 | raise InvalidUserLastNameError("Фамилия пользователя слишком короткая!") 20 | if not self.value: 21 | raise InvalidUserLastNameError("Поле не может быть пустым!") 22 | if bool(re.search(r"\d", self.value)): 23 | raise InvalidUserLastNameError( 24 | "Фамилия пользователя не может содержать цифр!", 25 | ) 26 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/event_bus/events/amqp_event.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass 3 | from typing import Any, ClassVar, Generic, TypeVar 4 | 5 | from zametka.access_service.application.common.event import EventT 6 | from zametka.access_service.infrastructure.event_bus.events.integration_event import ( 7 | IntegrationEvent, 8 | ) 9 | 10 | 11 | @dataclass(frozen=True, kw_only=True) 12 | class AMQPEvent(IntegrationEvent[EventT], Generic[EventT]): 13 | exchange_name: ClassVar[str] 14 | routing_key: ClassVar[str] 15 | 16 | 17 | EventType = TypeVar("EventType", bound=type[AMQPEvent[Any]]) 18 | 19 | 20 | def amqp_event( 21 | exchange: str, 22 | routing_key: str | None = None, 23 | ) -> Callable[[EventType], EventType]: 24 | def _amqp_event(cls: EventType) -> EventType: 25 | cls.exchange_name = exchange 26 | cls.routing_key = routing_key if routing_key is not None else cls.event_type 27 | return cls 28 | 29 | return _amqp_event 30 | -------------------------------------------------------------------------------- /src/zametka/main/web.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from dishka.integrations.fastapi import setup_dishka 4 | from fastapi import FastAPI 5 | from fastapi.middleware.cors import CORSMiddleware 6 | 7 | from zametka.access_service import presentation as access_presentation 8 | from zametka.access_service.bootstrap import di as access_di 9 | 10 | logging.basicConfig( 11 | level=logging.INFO, 12 | format="%(asctime)s [%(levelname)s] %(message)s", 13 | handlers=[logging.StreamHandler()], 14 | ) 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | app = FastAPI() 19 | 20 | logging.info("App was created.") 21 | 22 | origins = ["*"] 23 | 24 | app.add_middleware( 25 | CORSMiddleware, 26 | allow_origins=origins, 27 | allow_credentials=True, 28 | allow_methods=["*"], 29 | allow_headers=["*"], 30 | ) 31 | 32 | logging.info("Initialized app middlewares.") 33 | 34 | access_presentation.include_exception_handlers(app) 35 | access_presentation.include_routers(app) 36 | 37 | setup_dishka(access_di.setup_http_di(), app) 38 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/value_objects/user/user_first_name.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dataclasses import dataclass 3 | 4 | from zametka.notes.domain.common.value_objects.base import ValueObject 5 | from zametka.notes.domain.exceptions.user import InvalidUserFirstNameError 6 | 7 | 8 | @dataclass(frozen=True) 9 | class UserFirstName(ValueObject[str]): 10 | value: str 11 | 12 | MAX_LENGTH = 40 13 | MIN_LENGTH = 2 14 | ALLOW_DIGITS = False 15 | 16 | def _validate(self) -> None: 17 | if len(self.value) > self.MAX_LENGTH: 18 | raise InvalidUserFirstNameError("Имя пользователя слишком длинное!") 19 | if len(self.value) < self.MIN_LENGTH: 20 | raise InvalidUserFirstNameError("Имя пользователя слишком короткое!") 21 | if not self.value: 22 | raise InvalidUserFirstNameError("Поле не может быть пустым!") 23 | if bool(re.search(r"\d", self.value)) and not self.ALLOW_DIGITS: 24 | raise InvalidUserFirstNameError("Имя пользователя не может содержать цифр!") 25 | -------------------------------------------------------------------------------- /tests/unit/access_service/application/interactors/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from zametka.access_service.domain.entities.user import User 3 | 4 | from tests.mocks.access_service.event_emitter import FakeEventEmitter 5 | from tests.mocks.access_service.id_provider import FakeIdProvider 6 | from tests.mocks.access_service.token_sender import FakeTokenSender 7 | from tests.mocks.access_service.uow import FakeUoW 8 | from tests.mocks.access_service.user_gateway import FakeUserGateway 9 | 10 | 11 | @pytest.fixture 12 | def user_gateway(user: User) -> FakeUserGateway: 13 | return FakeUserGateway(user) 14 | 15 | 16 | @pytest.fixture 17 | def id_provider( 18 | user: User, 19 | ) -> FakeIdProvider: 20 | return FakeIdProvider(user) 21 | 22 | 23 | @pytest.fixture 24 | def uow() -> FakeUoW: 25 | return FakeUoW() 26 | 27 | 28 | @pytest.fixture 29 | def token_sender() -> FakeTokenSender: 30 | return FakeTokenSender() 31 | 32 | 33 | @pytest.fixture 34 | def event_emitter() -> FakeEventEmitter: 35 | return FakeEventEmitter() 36 | -------------------------------------------------------------------------------- /src/zametka/notes/application/note/dto.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(frozen=True) 5 | class NoteDTO: 6 | title: str 7 | text: str | None 8 | 9 | 10 | @dataclass(frozen=True, kw_only=True) 11 | class DBNoteDTO(NoteDTO): 12 | note_id: int 13 | 14 | 15 | @dataclass(frozen=True, kw_only=True) 16 | class ListNoteDTO: 17 | title: str 18 | note_id: int 19 | 20 | 21 | @dataclass(frozen=True) 22 | class CreateNoteInputDTO: 23 | title: str 24 | text: str | None = None 25 | 26 | 27 | @dataclass(frozen=True) 28 | class UpdateNoteInputDTO: 29 | note_id: int 30 | title: str 31 | text: str | None = None 32 | 33 | 34 | @dataclass(frozen=True) 35 | class ReadNoteInputDTO: 36 | note_id: int 37 | 38 | 39 | @dataclass(frozen=True) 40 | class ListNotesInputDTO: 41 | limit: int 42 | offset: int 43 | search: str | None = None 44 | 45 | 46 | @dataclass(frozen=True) 47 | class ListNotesDTO: 48 | notes: list[ListNoteDTO] 49 | has_next: bool 50 | 51 | 52 | @dataclass(frozen=True) 53 | class DeleteNoteInputDTO: 54 | note_id: int 55 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/event_bus/amqp_event_sender.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | from adaptix import Retort 5 | 6 | from zametka.access_service.infrastructure.event_bus.events import AMQPEvent 7 | from zametka.access_service.infrastructure.message_broker import ( 8 | Message, 9 | MessageBroker, 10 | ) 11 | 12 | 13 | class AMQPEventSender: 14 | def __init__(self, retort: Retort, message_broker: MessageBroker) -> None: 15 | self._retort = retort 16 | self._message_broker = message_broker 17 | 18 | async def send(self, event: AMQPEvent[Any]) -> None: 19 | original_event = event.original_event 20 | message_data = self._retort.dump(original_event) 21 | 22 | broker_message = Message( 23 | message_id=event.event_id, 24 | data=message_data, 25 | ) 26 | 27 | await self._message_broker.publish_message( 28 | broker_message, 29 | event.routing_key, 30 | event.exchange_name, 31 | ) 32 | 33 | logging.info("Event %s was published.", event) 34 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import AsyncGenerator 3 | 4 | from sqlalchemy.ext.asyncio import ( 5 | AsyncEngine, 6 | AsyncSession, 7 | async_sessionmaker, 8 | create_async_engine, 9 | ) 10 | 11 | from zametka.notes.infrastructure.config_loader import DB 12 | 13 | 14 | async def get_engine(settings: DB) -> AsyncGenerator[AsyncEngine, None]: 15 | """Get async SA engine""" 16 | 17 | engine = create_async_engine( 18 | settings.get_connection_url(), 19 | future=True, 20 | ) 21 | 22 | logging.info("Engine was created.") 23 | 24 | yield engine 25 | 26 | await engine.dispose() 27 | 28 | logging.info("Engine was disposed.") 29 | 30 | 31 | async def get_async_sessionmaker( 32 | engine: AsyncEngine, 33 | ) -> async_sessionmaker[AsyncSession]: 34 | """Get async SA sessionmaker""" 35 | 36 | session_factory = async_sessionmaker( 37 | engine, expire_on_commit=False, class_=AsyncSession, 38 | ) 39 | 40 | logging.info("Session provider was initialized") 41 | 42 | return session_factory 43 | -------------------------------------------------------------------------------- /src/zametka/notes/application/user/get_user.py: -------------------------------------------------------------------------------- 1 | from zametka.notes.application.common.id_provider import IdProvider 2 | from zametka.notes.application.common.interactor import Interactor 3 | from zametka.notes.application.common.repository import UserRepository 4 | from zametka.notes.application.user.dto import UserDTO 5 | from zametka.notes.domain.exceptions.user import UserIsNotExistsError 6 | 7 | 8 | class GetUser(Interactor[None, UserDTO]): 9 | def __init__( 10 | self, 11 | user_repository: UserRepository, 12 | id_provider: IdProvider, 13 | ): 14 | self.user_repository = user_repository 15 | self.id_provider = id_provider 16 | 17 | async def __call__(self, data=None) -> UserDTO: # type:ignore 18 | user_id = await self.id_provider.get_user_id() 19 | user = await self.user_repository.get(user_id=user_id) 20 | 21 | if not user: 22 | raise UserIsNotExistsError() 23 | 24 | return UserDTO( 25 | first_name=user.first_name, 26 | last_name=user.last_name, 27 | joined_at=user.joined_at, 28 | ) 29 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/alembic/migrations/versions/8ecffb117471_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 8ecffb117471 4 | Revises: 5 | Create Date: 2024-01-02 18:06:08.334998 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "8ecffb117471" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "users", 23 | sa.Column("user_id", sa.Uuid(), nullable=False), 24 | sa.Column("email", sa.String(length=60), nullable=False), 25 | sa.Column("hashed_password", sa.String(length=300), nullable=False), 26 | sa.Column("is_active", sa.Boolean(), nullable=False), 27 | sa.PrimaryKeyConstraint("user_id"), 28 | sa.UniqueConstraint("email"), 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade() -> None: 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_table("users") 36 | # ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/auth/password_hasher.py: -------------------------------------------------------------------------------- 1 | import argon2 2 | 3 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 4 | from zametka.access_service.domain.exceptions.password_hasher import ( 5 | PasswordMismatchError, 6 | ) 7 | from zametka.access_service.domain.value_objects.user_hashed_password import ( 8 | UserHashedPassword, 9 | ) 10 | from zametka.access_service.domain.value_objects.user_raw_password import ( 11 | UserRawPassword, 12 | ) 13 | 14 | 15 | class ArgonPasswordHasher(PasswordHasher): 16 | def __init__(self, password_hasher: argon2.PasswordHasher) -> None: 17 | self.ph = password_hasher 18 | 19 | def hash_password(self, password: UserRawPassword) -> UserHashedPassword: 20 | return UserHashedPassword(self.ph.hash(password.value)) 21 | 22 | def verify_password( 23 | self, 24 | raw_password: UserRawPassword, 25 | hashed_password: UserHashedPassword, 26 | ) -> None: 27 | try: 28 | self.ph.verify(hashed_password.value, raw_password.value) 29 | except argon2.exceptions.VerifyMismatchError as exc: 30 | raise PasswordMismatchError from exc 31 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/entities/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | from zametka.notes.domain.value_objects.user.user_first_name import ( 7 | UserFirstName, 8 | ) 9 | from zametka.notes.domain.value_objects.user.user_id import UserId 10 | from zametka.notes.domain.value_objects.user.user_joined_at import UserJoinedAt 11 | from zametka.notes.domain.value_objects.user.user_last_name import UserLastName 12 | 13 | 14 | class User: 15 | __slots__ = ( 16 | "first_name", 17 | "last_name", 18 | "joined_at", 19 | "user_id", 20 | ) 21 | 22 | def __init__( 23 | self, 24 | user_id: UserId, 25 | first_name: UserFirstName, 26 | last_name: UserLastName, 27 | ) -> None: 28 | self.user_id = user_id 29 | self.first_name = first_name 30 | self.last_name = last_name 31 | self.joined_at = UserJoinedAt(datetime.now()) 32 | 33 | def __eq__(self, other: User | Any) -> bool: 34 | if isinstance(other, User) and other.user_id == self.user_id: 35 | return True 36 | return False 37 | 38 | def __str__(self): 39 | return f"User <{self.user_id}>" 40 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/jwt/jwt_processor.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Any, Protocol, TypeAlias 3 | 4 | import jwt 5 | 6 | from zametka.access_service.infrastructure.jwt.config import JWTConfig 7 | from zametka.access_service.infrastructure.jwt.exceptions import ( 8 | JWTDecodeError, 9 | JWTExpiredError, 10 | ) 11 | 12 | JWTPayload: TypeAlias = dict[str, Any] 13 | JWTToken: TypeAlias = str 14 | 15 | 16 | class JWTProcessor(Protocol): 17 | @abstractmethod 18 | def encode(self, payload: JWTPayload) -> JWTToken: ... 19 | 20 | @abstractmethod 21 | def decode(self, token: JWTToken) -> JWTPayload: ... 22 | 23 | 24 | class PyJWTProcessor(JWTProcessor): 25 | def __init__(self, config: JWTConfig) -> None: 26 | self.key = config.key 27 | self.algorithm = config.algorithm 28 | 29 | def encode(self, payload: JWTPayload) -> JWTToken: 30 | return jwt.encode(payload, self.key, self.algorithm) 31 | 32 | def decode(self, token: JWTToken) -> JWTPayload: 33 | try: 34 | return jwt.decode(token, self.key, algorithms=[self.algorithm]) 35 | except jwt.ExpiredSignatureError as exc: 36 | raise JWTExpiredError from exc 37 | except jwt.DecodeError as exc: 38 | raise JWTDecodeError from exc 39 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/exception_handlers/user.py: -------------------------------------------------------------------------------- 1 | from asyncpg import UniqueViolationError 2 | from fastapi import Request, responses 3 | 4 | from zametka.notes.domain.exceptions.user import ( 5 | IsNotAuthorizedError, 6 | UserDataError, 7 | UserIsNotExistsError, 8 | ) 9 | 10 | 11 | async def unique_exception_handler( 12 | _request: Request, _exc: UniqueViolationError, 13 | ) -> responses.JSONResponse: 14 | return responses.JSONResponse( 15 | status_code=422, 16 | content={"message": "Такая запись уже существует!"}, 17 | ) 18 | 19 | 20 | async def user_data_exception_handler( 21 | _request: Request, exc: UserDataError, 22 | ) -> responses.JSONResponse: 23 | return responses.JSONResponse(status_code=422, content={"detail": exc.message}) 24 | 25 | 26 | async def user_is_not_exists_exception_handler( 27 | _request: Request, _exc: UserIsNotExistsError, 28 | ) -> responses.JSONResponse: 29 | return responses.JSONResponse( 30 | status_code=401, content={"detail": "Пользователя не существует"}, 31 | ) 32 | 33 | 34 | async def is_not_authorized_exception_handler( 35 | _request: Request, _exc: IsNotAuthorizedError, 36 | ) -> responses.JSONResponse: 37 | return responses.JSONResponse( 38 | status_code=401, content={"detail": "Вы не авторизованы"}, 39 | ) 40 | -------------------------------------------------------------------------------- /tests/unit/access_service/application/interactors/test_get_identity.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from zametka.access_service.application.dto import UserDTO 3 | from zametka.access_service.application.get_user import ( 4 | GetUser, 5 | ) 6 | from zametka.access_service.domain.exceptions.user import UserIsNotActiveError 7 | 8 | from tests.mocks.access_service.id_provider import FakeIdProvider 9 | from tests.mocks.access_service.user_gateway import ( 10 | FakeUserGateway, 11 | ) 12 | 13 | 14 | @pytest.mark.access 15 | @pytest.mark.application 16 | @pytest.mark.parametrize( 17 | ["user_is_active", "exc_class"], 18 | [ 19 | (True, None), 20 | (False, UserIsNotActiveError), 21 | ], 22 | ) 23 | async def test_get_identity( 24 | user_gateway: FakeUserGateway, 25 | id_provider: FakeIdProvider, 26 | user_is_active: bool, 27 | exc_class, 28 | ) -> None: 29 | user_gateway.user.is_active = user_is_active 30 | 31 | interactor = GetUser( 32 | id_provider=id_provider, 33 | ) 34 | 35 | coro = interactor() 36 | 37 | if not exc_class: 38 | result = await coro 39 | assert result is not None 40 | assert isinstance(result, UserDTO) is True 41 | assert result.user_id == id_provider.user.user_id 42 | assert id_provider.requested is True 43 | else: 44 | with pytest.raises(exc_class): 45 | await coro 46 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/error_code.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from zametka.access_service.application.common.exceptions.user import ( 4 | UserEmailAlreadyExistsError, 5 | UserIsNotExistsError, 6 | ) 7 | from zametka.access_service.domain.exceptions.access_token import ( 8 | AccessTokenIsExpiredError, 9 | UnauthorizedError, 10 | ) 11 | from zametka.access_service.domain.exceptions.confirmation_token import ( 12 | ConfirmationTokenAlreadyUsedError, 13 | ConfirmationTokenIsExpiredError, 14 | CorruptedConfirmationTokenError, 15 | ) 16 | from zametka.access_service.domain.exceptions.user import ( 17 | InvalidCredentialsError, 18 | InvalidUserEmailError, 19 | UserIsNotActiveError, 20 | WeakPasswordError, 21 | ) 22 | 23 | 24 | class ErrorCode(Enum): 25 | WEAK_PASSWORD = WeakPasswordError 26 | INVALID_EMAIL = InvalidUserEmailError 27 | INVALID_CREDENTIALS = InvalidCredentialsError 28 | USER_NOT_EXISTS = UserIsNotExistsError 29 | USER_NOT_ACTIVE = UserIsNotActiveError 30 | ACCESS_TOKEN_EXPIRED = AccessTokenIsExpiredError 31 | UNAUTHORIZED = UnauthorizedError 32 | CONFIRMATION_TOKEN_EXPIRED = ConfirmationTokenIsExpiredError 33 | CONFIRMATION_TOKEN_ALREADY_USED = ConfirmationTokenAlreadyUsedError 34 | CORRUPTED_CONFIRMATION_TOKEN = CorruptedConfirmationTokenError 35 | USER_EMAIL_ALREADY_EXISTS = UserEmailAlreadyExistsError 36 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import AsyncGenerator, AsyncIterable 3 | 4 | from sqlalchemy.ext.asyncio import ( 5 | AsyncEngine, 6 | AsyncSession, 7 | async_sessionmaker, 8 | create_async_engine, 9 | ) 10 | 11 | from zametka.access_service.infrastructure.persistence.config import DBConfig 12 | 13 | 14 | async def get_engine(settings: DBConfig) -> AsyncGenerator[AsyncEngine, None]: 15 | """Get async SA engine""" 16 | 17 | engine = create_async_engine( 18 | settings.get_connection_url(), 19 | future=True, 20 | ) 21 | 22 | logging.info("Engine was created.") 23 | 24 | yield engine 25 | 26 | await engine.dispose() 27 | 28 | logging.info("Engine was disposed.") 29 | 30 | 31 | async def get_async_sessionmaker( 32 | engine: AsyncEngine, 33 | ) -> async_sessionmaker[AsyncSession]: 34 | """Get async SA sessionmaker""" 35 | 36 | session_factory = async_sessionmaker( 37 | engine, 38 | expire_on_commit=False, 39 | class_=AsyncSession, 40 | ) 41 | 42 | logging.info("Session provider was initialized") 43 | 44 | return session_factory 45 | 46 | 47 | async def get_async_session( 48 | session_factory: async_sessionmaker[AsyncSession], 49 | ) -> AsyncIterable[AsyncSession]: 50 | async with session_factory() as session: 51 | yield session 52 | -------------------------------------------------------------------------------- /tests/mocks/access_service/user_gateway.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.user_gateway import ( 2 | UserReader, 3 | UserSaver, 4 | ) 5 | from zametka.access_service.application.dto import UserDTO 6 | from zametka.access_service.domain.entities.user import User 7 | from zametka.access_service.domain.value_objects.user_email import UserEmail 8 | from zametka.access_service.domain.value_objects.user_id import UserId 9 | 10 | 11 | class FakeUserGateway(UserReader, UserSaver): 12 | def __init__(self, user: User): 13 | self.user = user 14 | self.saved = False 15 | self.deleted = False 16 | 17 | async def save(self, user: User) -> UserDTO: 18 | self.user.is_active = user.is_active 19 | self.user.email = user.email 20 | self.user.hashed_password = user.hashed_password 21 | 22 | self.saved = True 23 | 24 | return UserDTO( 25 | user_id=self.user.user_id.to_raw(), 26 | ) 27 | 28 | async def with_id(self, user_id: UserId) -> User | None: 29 | if self.user.user_id != user_id: 30 | return None 31 | 32 | return self.user 33 | 34 | async def with_email(self, email: UserEmail) -> User | None: 35 | if self.user.email != email: 36 | return None 37 | 38 | return self.user 39 | 40 | async def delete(self, user_id: UserId) -> None: 41 | self.deleted = True 42 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/interactor_factory.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from collections.abc import Awaitable, Callable 3 | from typing import AsyncContextManager, TypeAlias, TypeVar 4 | 5 | from zametka.notes.application.common.id_provider import IdProvider 6 | from zametka.notes.application.note.note_interactor import NoteInteractor 7 | from zametka.notes.application.user.create_user import CreateUser 8 | from zametka.notes.application.user.get_user import GetUser 9 | 10 | # G means generic 11 | GInputDTO = TypeVar("GInputDTO") 12 | GOutputDTO = TypeVar("GOutputDTO") 13 | 14 | InteractorCallable: TypeAlias = Callable[[GInputDTO], Awaitable[GOutputDTO]] 15 | InteractorPicker: TypeAlias = Callable[ 16 | [NoteInteractor], InteractorCallable[GInputDTO, GOutputDTO], 17 | ] 18 | 19 | 20 | class InteractorFactory(ABC): 21 | @abstractmethod 22 | def pick_note_interactor( 23 | self, 24 | id_provider: IdProvider, 25 | picker: InteractorPicker[GInputDTO, GOutputDTO], 26 | ) -> AsyncContextManager[InteractorCallable[GInputDTO, GOutputDTO]]: 27 | raise NotImplementedError 28 | 29 | @abstractmethod 30 | def create_user(self, id_provider: IdProvider) -> AsyncContextManager[CreateUser]: 31 | raise NotImplementedError 32 | 33 | @abstractmethod 34 | def get_user(self, id_provider: IdProvider) -> AsyncContextManager[GetUser]: 35 | raise NotImplementedError 36 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/error_message.py: -------------------------------------------------------------------------------- 1 | from types import MappingProxyType 2 | from typing import Final 3 | 4 | from zametka.access_service.infrastructure.error_code import ErrorCode 5 | 6 | 7 | class ErrorMessage: 8 | def __init__(self) -> None: 9 | self._msg: Final[MappingProxyType[ErrorCode, str]] = MappingProxyType( 10 | { 11 | ErrorCode.WEAK_PASSWORD: "Слабый пароль!", 12 | ErrorCode.INVALID_EMAIL: "Неккоректный адрес e-mail!", 13 | ErrorCode.INVALID_CREDENTIALS: "Неправильно введены данные для входа!", 14 | ErrorCode.USER_NOT_EXISTS: "Пользователя не существует.", 15 | ErrorCode.USER_NOT_ACTIVE: "Пользователь не активен. Сначала вы " 16 | "должны верифицировать свою почту.", 17 | ErrorCode.ACCESS_TOKEN_EXPIRED: "Токен истёк.", 18 | ErrorCode.UNAUTHORIZED: "Вы не авторизованы.", 19 | ErrorCode.CONFIRMATION_TOKEN_EXPIRED: "Токен истёк.", 20 | ErrorCode.CONFIRMATION_TOKEN_ALREADY_USED: "Токен уже использован.", 21 | ErrorCode.CORRUPTED_CONFIRMATION_TOKEN: "Токен повреждён.", 22 | ErrorCode.USER_EMAIL_ALREADY_EXISTS: "Такой пользователь уже " 23 | "существует", 24 | }, 25 | ) 26 | 27 | def get_error_message(self, error_code: ErrorCode) -> str: 28 | return self._msg[error_code] 29 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/value_objects/user_raw_password.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from zametka.access_service.domain.common.value_objects.base import ValueObject 4 | from zametka.access_service.domain.exceptions.user import WeakPasswordError 5 | 6 | 7 | def has_special_symbols(string: str) -> bool: 8 | regex = re.compile("[@_!#$%^&*()<>?/}{~:]") 9 | 10 | if re.search(regex, string) is None: 11 | return False 12 | 13 | return True 14 | 15 | 16 | class UserRawPassword(ValueObject[str]): 17 | value: str 18 | 19 | def _validate(self) -> None: 20 | """Validate password""" 21 | 22 | error_messages = { 23 | "Пароль должен содержать заглавную букву.": lambda s: any( 24 | x.isupper() for x in s 25 | ), 26 | "Пароль не должен состоять только из заглавных букв.": lambda s: any( 27 | x.islower() for x in s 28 | ), 29 | "Пароль должен содержать число.": lambda s: any(x.isdigit() for x in s), 30 | "Пароль не должен содержать пробелы.": lambda s: not any( 31 | x.isspace() for x in s 32 | ), 33 | "Пароль должен содержать в себе специальный \ 34 | символ (@, #, $, %)": has_special_symbols, 35 | } 36 | 37 | for message, password_validator in error_messages.items(): 38 | if not password_validator(self.value): 39 | raise WeakPasswordError(message) 40 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/exception_handlers.py: -------------------------------------------------------------------------------- 1 | from dishka import AsyncContainer 2 | from fastapi import Request 3 | from fastapi.responses import JSONResponse 4 | 5 | from zametka.access_service.domain.common.base_error import BaseError 6 | from zametka.access_service.infrastructure.error_code import ErrorCode 7 | from zametka.access_service.presentation.error_message import ErrorMessage 8 | from zametka.access_service.presentation.http.http_error_code import ( 9 | HTTP_ERROR_CODE, 10 | ) 11 | 12 | 13 | def get_http_error_response( 14 | err: BaseError, 15 | error_message: ErrorMessage, 16 | ) -> JSONResponse: 17 | err_type = type(err) 18 | err_code = ErrorCode(err_type) 19 | err_message = error_message.get_error_message(err_code) 20 | err_http_code = HTTP_ERROR_CODE[ErrorCode(err_type)] 21 | 22 | return JSONResponse( 23 | status_code=err_http_code, 24 | content={ 25 | "error_code": err_code.name, 26 | "message": err_message, 27 | }, 28 | ) 29 | 30 | 31 | async def app_exception_handler(request: Request, exc: Exception) -> JSONResponse: 32 | if not isinstance(exc, BaseError): 33 | # TODO: handle unknown exc 34 | return JSONResponse(status_code=500, content={}) 35 | 36 | di_container: AsyncContainer = request.state.dishka_container 37 | error_message = await di_container.get(ErrorMessage) 38 | 39 | return get_http_error_response(exc, error_message) 40 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/repositories/user.py: -------------------------------------------------------------------------------- 1 | 2 | from sqlalchemy import select 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from zametka.notes.application.common.repository import ( 6 | UserRepository, 7 | ) 8 | from zametka.notes.application.user.dto import UserDTO 9 | from zametka.notes.domain.entities.user import User as UserEntity 10 | from zametka.notes.domain.value_objects.user.user_id import UserId 11 | from zametka.notes.infrastructure.db.models.user import User 12 | from zametka.notes.infrastructure.repositories.converters.user import ( 13 | user_db_model_to_user_dto, 14 | user_entity_to_db_model, 15 | ) 16 | 17 | 18 | class UserRepositoryImpl(UserRepository): 19 | session: AsyncSession 20 | 21 | def __init__(self, session: AsyncSession): 22 | self.session = session 23 | 24 | async def create( 25 | self, 26 | user: UserEntity, 27 | ) -> UserDTO: 28 | db_user = user_entity_to_db_model(user) 29 | 30 | self.session.add(db_user) 31 | 32 | await self.session.flush(objects=[db_user]) 33 | 34 | return user_db_model_to_user_dto(db_user) 35 | 36 | async def get(self, user_id: UserId) -> UserDTO | None: 37 | q = select(User).where(User.identity_id == user_id.to_raw()) 38 | 39 | res = await self.session.execute(q) 40 | user: User | None = res.scalar() 41 | 42 | if not user: 43 | return None 44 | 45 | return user_db_model_to_user_dto(user) 46 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/dependencies/stub.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any 3 | 4 | 5 | class Stub: 6 | """ 7 | This class is used to prevent fastapi from digging into 8 | real dependencies attributes detecting them as request data 9 | 10 | So instead of 11 | `interactor: Annotated[Interactor, Depends()]` 12 | Write 13 | `interactor: Annotated[Interactor, Depends(Stub(Interactor))]` 14 | 15 | And then you can declare how to create it: 16 | `app.dependency_overridess[Interactor] = some_real_factory` 17 | 18 | """ 19 | 20 | def __init__( 21 | self, dependency: Callable[[Any], Any], **kwargs: dict[str, Any], 22 | ) -> None: 23 | self._dependency = dependency 24 | self._kwargs = kwargs 25 | 26 | def __call__(self) -> None: 27 | raise NotImplementedError 28 | 29 | def __eq__(self, other: "Stub | Any") -> bool: 30 | if isinstance(other, Stub): 31 | return ( 32 | self._dependency == other._dependency and self._kwargs == other._kwargs 33 | ) 34 | 35 | if not self._kwargs: 36 | return self._dependency == other # type:ignore 37 | return False 38 | 39 | def __hash__(self) -> int: 40 | if not self._kwargs: 41 | return hash(self._dependency) 42 | serial = ( 43 | self._dependency, 44 | *self._kwargs.items(), 45 | ) 46 | return hash(serial) 47 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/gateway/converters/user.py: -------------------------------------------------------------------------------- 1 | from adaptix import P 2 | from adaptix.conversion import coercer, get_converter, link 3 | 4 | from zametka.access_service.application.dto import UserDTO 5 | from zametka.access_service.domain.entities.user import ( 6 | User, 7 | ) 8 | from zametka.access_service.domain.value_objects.user_email import UserEmail 9 | from zametka.access_service.domain.value_objects.user_hashed_password import ( 10 | UserHashedPassword, 11 | ) 12 | from zametka.access_service.domain.value_objects.user_id import UserId 13 | from zametka.access_service.infrastructure.persistence.models.user_identity import ( 14 | DBUser, 15 | ) 16 | 17 | convert_db_user_to_entity = get_converter( 18 | DBUser, 19 | User, 20 | recipe=[ 21 | link(P[DBUser].user_id, P[User].user_id, coercer=lambda x: UserId(x)), 22 | link(P[DBUser].email, P[User].email, coercer=lambda x: UserEmail(x)), 23 | link( 24 | P[DBUser].hashed_password, 25 | P[User].hashed_password, 26 | coercer=lambda x: UserHashedPassword(x), 27 | ), 28 | ], 29 | ) 30 | 31 | convert_db_user_to_dto = get_converter(DBUser, UserDTO) 32 | 33 | convert_user_entity_to_db_user = get_converter( 34 | User, 35 | DBUser, 36 | recipe=[ 37 | coercer( 38 | P[User][".*"] & ~P[User].is_active, 39 | P[DBUser][".*"] & ~P[DBUser].is_active, 40 | lambda x: x.to_raw(), 41 | ), 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /tests/unit/access_service/domain/services/conftest.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | from zametka.access_service.domain.common.entities.timed_user_token import ( 5 | TimedTokenMetadata, 6 | ) 7 | from zametka.access_service.domain.common.value_objects.timed_token_id import ( 8 | TimedTokenId, 9 | ) 10 | from zametka.access_service.domain.entities.access_token import AccessToken 11 | from zametka.access_service.domain.entities.user import User 12 | from zametka.access_service.domain.services.token_access_service import ( 13 | TokenAccessService, 14 | ) 15 | from zametka.access_service.domain.value_objects.expires_in import ExpiresIn 16 | 17 | 18 | @pytest.fixture 19 | def access_token( 20 | user: User, 21 | token_expires_in: ExpiresIn, 22 | ) -> AccessToken: 23 | metadata = TimedTokenMetadata( 24 | uid=user.user_id, 25 | expires_in=token_expires_in, 26 | ) 27 | token_id = TimedTokenId(uuid4()) 28 | return AccessToken(metadata, token_id) 29 | 30 | 31 | @pytest.fixture 32 | def expired_access_token( 33 | user: User, 34 | token_expired_in: ExpiresIn, 35 | ) -> AccessToken: 36 | metadata = TimedTokenMetadata( 37 | uid=user.user_id, 38 | expires_in=token_expired_in, 39 | ) 40 | 41 | token_id = TimedTokenId(uuid4()) 42 | token = AccessToken(metadata, token_id=token_id) 43 | 44 | return token 45 | 46 | 47 | @pytest.fixture 48 | def token_access_service(access_token: AccessToken) -> TokenAccessService: 49 | return TokenAccessService(access_token) 50 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/endpoints/user.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends 4 | 5 | from zametka.notes.application.common.id_provider import IdProvider 6 | from zametka.notes.application.user.create_user import CreateUserInputDTO 7 | from zametka.notes.application.user.dto import UserDTO 8 | from zametka.notes.presentation.interactor_factory import InteractorFactory 9 | from zametka.notes.presentation.web_api.dependencies.id_provider import ( 10 | get_raw_id_provider, 11 | ) 12 | from zametka.notes.presentation.web_api.schemas.user import UserSchema 13 | 14 | router = APIRouter( 15 | prefix="/users", 16 | tags=["users"], 17 | responses={404: {"description": "Not found"}}, 18 | ) 19 | 20 | 21 | @router.post("/") 22 | async def create_user( 23 | data: UserSchema, 24 | id_provider: Annotated[IdProvider, Depends(get_raw_id_provider)], 25 | ioc: InteractorFactory = Depends(), 26 | ) -> UserDTO: 27 | async with ioc.create_user(id_provider) as interactor: 28 | response = await interactor( 29 | CreateUserInputDTO( 30 | first_name=data.first_name, 31 | last_name=data.last_name, 32 | ), 33 | ) 34 | 35 | return response 36 | 37 | 38 | @router.get("/me") 39 | async def get_user( 40 | id_provider: IdProvider = Depends(), 41 | ioc: InteractorFactory = Depends(), 42 | ) -> UserDTO: 43 | async with ioc.get_user(id_provider) as interactor: 44 | response = await interactor() 45 | 46 | return response 47 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/access_api_client.py: -------------------------------------------------------------------------------- 1 | 2 | import aiohttp 3 | 4 | from zametka.notes.domain.exceptions.user import IsNotAuthorizedError 5 | from zametka.notes.domain.value_objects.user.user_id import UserId 6 | 7 | 8 | class AccessAPIClient: 9 | def __init__( 10 | self, 11 | access_token: str, 12 | session: aiohttp.ClientSession, 13 | csrf_token: str | None = None, 14 | ) -> None: 15 | self.access_token = access_token 16 | self.csrf_token = csrf_token 17 | self.session = session 18 | 19 | def get_access_cookies(self) -> dict[str, str | None]: 20 | return { 21 | "access_token_cookie": self.access_token, 22 | "csrf_access_token": self.csrf_token, 23 | } 24 | 25 | async def get_identity(self) -> UserId: 26 | async with self.session.get( 27 | "http://access_service/me/", cookies=self.get_access_cookies(), 28 | ) as response: 29 | json = await response.json() 30 | 31 | if response.status == 200: 32 | return UserId(json.with_id("identity_id")) 33 | else: 34 | raise IsNotAuthorizedError() 35 | 36 | async def ensure_can_edit(self, headers: dict[str, str | None]) -> None: 37 | async with self.session.get( 38 | "http://access_service/ensure-can-edit/", 39 | headers=headers, 40 | cookies=self.get_access_cookies(), 41 | ) as response: 42 | if response.status != 200: 43 | raise IsNotAuthorizedError() 44 | -------------------------------------------------------------------------------- /src/zametka/notes/application/user/create_user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from zametka.notes.application.common.id_provider import IdProvider 4 | from zametka.notes.application.common.interactor import Interactor 5 | from zametka.notes.application.common.repository import UserRepository 6 | from zametka.notes.application.common.uow import UoW 7 | from zametka.notes.application.user.dto import UserDTO 8 | from zametka.notes.domain.entities.user import User 9 | from zametka.notes.domain.value_objects.user.user_first_name import ( 10 | UserFirstName, 11 | ) 12 | from zametka.notes.domain.value_objects.user.user_last_name import UserLastName 13 | 14 | 15 | @dataclass(frozen=True) 16 | class CreateUserInputDTO: 17 | first_name: str 18 | last_name: str 19 | 20 | 21 | class CreateUser(Interactor[CreateUserInputDTO, UserDTO]): 22 | def __init__( 23 | self, 24 | user_repository: UserRepository, 25 | id_provider: IdProvider, 26 | uow: UoW, 27 | ): 28 | self.uow = uow 29 | self.id_provider = id_provider 30 | self.user_repository = user_repository 31 | 32 | async def __call__(self, data: CreateUserInputDTO) -> UserDTO: 33 | first_name = UserFirstName(data.first_name) 34 | last_name = UserLastName(data.last_name) 35 | user_id = await self.id_provider.get_user_id() 36 | 37 | user = User(first_name=first_name, last_name=last_name, user_id=user_id) 38 | 39 | user_dto = await self.user_repository.create(user) 40 | 41 | await self.uow.commit() 42 | 43 | return user_dto 44 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/auth/id_provider.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.exceptions.user import ( 2 | UserIsNotExistsError, 3 | ) 4 | from zametka.access_service.application.common.id_provider import ( 5 | IdProvider, 6 | ) 7 | from zametka.access_service.application.common.user_gateway import UserReader 8 | from zametka.access_service.domain.common.services.access_service import AccessService 9 | from zametka.access_service.domain.entities.access_token import AccessToken 10 | from zametka.access_service.domain.entities.user import User 11 | from zametka.access_service.domain.exceptions.access_token import ( 12 | UnauthorizedError, 13 | ) 14 | from zametka.access_service.domain.value_objects.user_id import UserId 15 | 16 | 17 | class TokenIdProvider(IdProvider): 18 | def __init__( 19 | self, 20 | token: AccessToken, 21 | access_service: AccessService, 22 | user_gateway: UserReader, 23 | ): 24 | self._token = token 25 | self._user_id: UserId | None = None 26 | self._user_gateway = user_gateway 27 | self._access_service = access_service 28 | 29 | def _get_id(self) -> UserId: 30 | if self._user_id: 31 | return self._user_id 32 | 33 | user_id = self._token.uid 34 | self._user_id = user_id 35 | 36 | return user_id 37 | 38 | async def get_user(self) -> User: 39 | user_id = self._get_id() 40 | user = await self._user_gateway.with_id(user_id) 41 | 42 | if not user: 43 | raise UnauthorizedError from UserIsNotExistsError 44 | 45 | self._access_service.authorize(user) 46 | 47 | return user 48 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/config_loader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class BaseDB: 8 | """Base database config""" 9 | 10 | host: str 11 | db_name: str 12 | user: str 13 | password: str 14 | 15 | def get_connection_url(self) -> str: 16 | return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}/{self.db_name}" 17 | 18 | 19 | @dataclass 20 | class DB(BaseDB): 21 | """App database config""" 22 | 23 | 24 | @dataclass 25 | class AlembicDB(BaseDB): 26 | """Alembic database config""" 27 | 28 | 29 | @dataclass 30 | class CORSSettings: 31 | """CORS Allowed Domains Settings""" 32 | 33 | frontend_url: str 34 | 35 | 36 | @dataclass 37 | class Settings: 38 | """App settings""" 39 | 40 | db: DB 41 | cors: CORSSettings 42 | 43 | 44 | def load_settings() -> Settings: 45 | """Get app settings""" 46 | 47 | db = DB( 48 | db_name=os.environ["NOTES_POSTGRES_DB"], 49 | host=os.environ["DB_HOST"], 50 | password=os.environ["POSTGRES_PASSWORD"], 51 | user=os.environ["POSTGRES_USER"], 52 | ) 53 | 54 | cors = CORSSettings(frontend_url=os.environ["FRONTEND"]) 55 | 56 | logging.info("Notes config was loaded") 57 | 58 | return Settings( 59 | db=db, 60 | cors=cors, 61 | ) 62 | 63 | 64 | def load_alembic_settings() -> AlembicDB: 65 | """Get alembic settings""" 66 | 67 | return AlembicDB( 68 | db_name=os.environ["NOTES_POSTGRES_DB"], 69 | host=os.environ["DB_HOST"], 70 | password=os.environ["POSTGRES_PASSWORD"], 71 | user=os.environ["POSTGRES_USER"], 72 | ) 73 | -------------------------------------------------------------------------------- /tests/unit/access_service/domain/services/test_token_access_service.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | from zametka.access_service.domain.entities.access_token import AccessToken 5 | from zametka.access_service.domain.entities.user import User 6 | from zametka.access_service.domain.exceptions.access_token import ( 7 | AccessTokenIsExpiredError, 8 | UnauthorizedError, 9 | ) 10 | from zametka.access_service.domain.exceptions.user import UserIsNotActiveError 11 | from zametka.access_service.domain.services.token_access_service import ( 12 | TokenAccessService, 13 | ) 14 | 15 | 16 | @pytest.mark.access 17 | @pytest.mark.domain 18 | def test_authorize(token_access_service: TokenAccessService, user: User): 19 | user.is_active = True 20 | token_access_service.authorize(user) 21 | 22 | 23 | @pytest.mark.access 24 | @pytest.mark.domain 25 | def test_authorize_not_active(token_access_service: TokenAccessService, user: User): 26 | with pytest.raises(UserIsNotActiveError): 27 | token_access_service.authorize(user) 28 | 29 | 30 | @pytest.mark.access 31 | @pytest.mark.domain 32 | def test_authorize_expired_token(expired_access_token: AccessToken, user: User): 33 | token_access_service = TokenAccessService(expired_access_token) 34 | user.is_active = True 35 | 36 | with pytest.raises(AccessTokenIsExpiredError): 37 | token_access_service.authorize(user) 38 | 39 | 40 | @pytest.mark.access 41 | @pytest.mark.domain 42 | def test_authorize_bad_user(token_access_service: TokenAccessService, user: User): 43 | user.user_id = uuid4() 44 | user.is_active = True 45 | 46 | with pytest.raises(UnauthorizedError): 47 | token_access_service.authorize(user) 48 | -------------------------------------------------------------------------------- /src/zametka/main/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import alembic.config 4 | 5 | from zametka.access_service.infrastructure.persistence.alembic.config import ( 6 | ALEMBIC_CONFIG as ACCESS_SERVICE_ALEMBIC, 7 | ) 8 | from zametka.notes.infrastructure.db.alembic.config import ( 9 | ALEMBIC_CONFIG as NOTES_ALEMBIC, 10 | ) 11 | 12 | 13 | def notes_alembic_handler(args: list[str]) -> None: 14 | alembic.config.main( 15 | argv=["-c", NOTES_ALEMBIC, *args], 16 | ) 17 | 18 | 19 | def access_service_alembic_handler(args: list[str]) -> None: 20 | alembic.config.main( 21 | argv=["-c", ACCESS_SERVICE_ALEMBIC, *args], 22 | ) 23 | 24 | 25 | def all_alembic_handler(args: list[str]) -> None: 26 | notes_alembic_handler(args) 27 | access_service_alembic_handler(args) 28 | 29 | 30 | def main() -> None: 31 | print(">> zametka CLI <<") 32 | 33 | argv = sys.argv[1:] 34 | 35 | if not argv: 36 | print(">> Hi, my friend.") 37 | return 38 | 39 | try: 40 | module = argv[0] 41 | option = argv[1] 42 | args = argv[2:] 43 | except IndexError: 44 | print(">> Invalid option!") 45 | return 46 | 47 | modules = { 48 | "notes": { 49 | "alembic": notes_alembic_handler, 50 | }, 51 | "access_service": { 52 | "alembic": access_service_alembic_handler, 53 | }, 54 | "all": { 55 | "alembic": all_alembic_handler, 56 | }, 57 | } 58 | 59 | if module not in modules: 60 | print(">> No such module.") 61 | return 62 | 63 | if option not in modules[module]: 64 | print(">> No such option.") 65 | return 66 | 67 | modules[module][option](args) 68 | -------------------------------------------------------------------------------- /src/zametka/notes/application/common/repository.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from zametka.notes.application.note.dto import DBNoteDTO, ListNotesDTO 5 | from zametka.notes.application.user.dto import UserDTO 6 | from zametka.notes.domain.entities.note import DBNote, Note 7 | from zametka.notes.domain.entities.user import User 8 | from zametka.notes.domain.value_objects.note.note_id import NoteId 9 | from zametka.notes.domain.value_objects.user.user_id import UserId 10 | 11 | 12 | class NoteRepository(Protocol): 13 | """Note repository interface""" 14 | 15 | @abstractmethod 16 | async def create(self, note: Note) -> DBNoteDTO: 17 | """Create""" 18 | 19 | @abstractmethod 20 | async def get(self, note_id: NoteId) -> DBNote | None: 21 | """Get by id""" 22 | 23 | @abstractmethod 24 | async def update( 25 | self, note_id: NoteId, updated_note: DBNote, 26 | ) -> DBNoteDTO | None: 27 | """Update""" 28 | 29 | @abstractmethod 30 | async def list(self, limit: int, offset: int, author_id: UserId) -> ListNotesDTO: 31 | """List""" 32 | 33 | @abstractmethod 34 | async def search( 35 | self, query: str, limit: int, offset: int, author_id: UserId, 36 | ) -> ListNotesDTO: 37 | """FTS""" 38 | 39 | @abstractmethod 40 | async def delete(self, note_id: NoteId) -> None: 41 | """Delete""" 42 | 43 | 44 | class UserRepository(Protocol): 45 | """User repository interface""" 46 | 47 | @abstractmethod 48 | async def create(self, user: User) -> UserDTO: 49 | """Create""" 50 | 51 | @abstractmethod 52 | async def get(self, user_id: UserId) -> UserDTO | None: 53 | """Get by id""" 54 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/delete_user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from zametka.access_service.application.common.event import EventEmitter 4 | from zametka.access_service.application.common.id_provider import IdProvider 5 | from zametka.access_service.application.common.interactor import Interactor 6 | from zametka.access_service.application.common.user_gateway import ( 7 | UserSaver, 8 | ) 9 | from zametka.access_service.application.dto import UserDeletedEvent 10 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 11 | from zametka.access_service.domain.value_objects.user_raw_password import ( 12 | UserRawPassword, 13 | ) 14 | 15 | 16 | @dataclass(frozen=True) 17 | class DeleteUserInputDTO: 18 | password: str 19 | 20 | 21 | class DeleteUser(Interactor[DeleteUserInputDTO, None]): 22 | def __init__( 23 | self, 24 | user_gateway: UserSaver, 25 | id_provider: IdProvider, 26 | event_emitter: EventEmitter[UserDeletedEvent], 27 | password_hasher: PasswordHasher, 28 | ): 29 | self.user_gateway = user_gateway 30 | self.id_provider = id_provider 31 | self.event_emitter = event_emitter 32 | self.ph = password_hasher 33 | 34 | async def __call__(self, data: DeleteUserInputDTO) -> None: 35 | user = await self.id_provider.get_user() 36 | raw_password = UserRawPassword(data.password) 37 | 38 | user.authenticate(raw_password, self.ph) 39 | await self.user_gateway.delete(user.user_id) 40 | 41 | event = UserDeletedEvent( 42 | user_id=user.user_id.to_raw(), 43 | ) 44 | await self.event_emitter.emit(event) 45 | -------------------------------------------------------------------------------- /tests/unit/notes/domain/entities/test_user.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from uuid import uuid4 3 | 4 | import pytest 5 | from zametka.notes.domain.entities.user import User 6 | from zametka.notes.domain.exceptions.user import ( 7 | InvalidUserFirstNameError, 8 | InvalidUserLastNameError, 9 | ) 10 | from zametka.notes.domain.value_objects.user.user_first_name import ( 11 | UserFirstName, 12 | ) 13 | from zametka.notes.domain.value_objects.user.user_id import UserId 14 | from zametka.notes.domain.value_objects.user.user_joined_at import UserJoinedAt 15 | from zametka.notes.domain.value_objects.user.user_last_name import UserLastName 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "first_name", 20 | [ 21 | "Ilya" * UserFirstName.MAX_LENGTH, 22 | "q" * (UserFirstName.MIN_LENGTH - 1), 23 | "", 24 | " ", 25 | "Ilya1", 26 | ], 27 | ) 28 | def test_create_user_bad_first_name(first_name): 29 | with pytest.raises(InvalidUserFirstNameError): 30 | UserFirstName(first_name) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "last_name", 35 | [ 36 | "Lyubavski" * UserLastName.MAX_LENGTH, 37 | "q" * (UserLastName.MIN_LENGTH - 1), 38 | "", 39 | " ", 40 | "Lyubavski1", 41 | ], 42 | ) 43 | def test_create_user_bad_last_name(last_name): 44 | with pytest.raises(InvalidUserLastNameError): 45 | UserLastName(last_name) 46 | 47 | 48 | def test_read_joined_at(): 49 | joined_at = UserJoinedAt(datetime.datetime.now(tz=datetime.UTC)) 50 | assert isinstance(joined_at.read(), datetime.date) 51 | 52 | 53 | def test_create_user(): 54 | user = User( 55 | first_name=UserFirstName("Ilya"), 56 | last_name=UserLastName("Lyubavski"), 57 | user_id=UserId(uuid4()), 58 | ) 59 | 60 | assert user.joined_at 61 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/dependencies/id_provider.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from aiohttp import ClientSession 4 | from fastapi import Cookie, Depends, Request 5 | 6 | from zametka.notes.domain.exceptions.user import IsNotAuthorizedError 7 | from zametka.notes.domain.value_objects.user.user_id import UserId 8 | from zametka.notes.infrastructure.access_api_client import AccessAPIClient 9 | from zametka.notes.infrastructure.id_provider import ( 10 | RawIdProvider, 11 | TokenIdProvider, 12 | ) 13 | from zametka.notes.presentation.web_api.dependencies.stub import Stub 14 | from zametka.notes.presentation.web_api.schemas.user import IdentitySchema 15 | 16 | 17 | async def get_token_id_provider( 18 | request: Request, 19 | aiohttp_session: Annotated[ClientSession, Depends(Stub(ClientSession))], 20 | csrf_access_token: Annotated[str | None, Cookie()] = None, 21 | access_token_cookie: Annotated[str | None, Cookie()] = None, 22 | ) -> TokenIdProvider: 23 | csrf_methods = {"POST", "PUT", "PATCH", "DELETE"} 24 | 25 | if not access_token_cookie: 26 | raise IsNotAuthorizedError() 27 | 28 | api_client = AccessAPIClient(access_token_cookie, session=aiohttp_session) 29 | 30 | if request.method in csrf_methods: 31 | if not csrf_access_token: 32 | raise IsNotAuthorizedError() 33 | 34 | api_client = AccessAPIClient( 35 | access_token_cookie, aiohttp_session, csrf_access_token, 36 | ) 37 | await api_client.ensure_can_edit( 38 | headers={"X-CSRF-Token": request.headers.get("X-CSRF-Token", "")}, 39 | ) 40 | 41 | id_provider = TokenIdProvider(api_client) 42 | 43 | return id_provider 44 | 45 | 46 | async def get_raw_id_provider(identity_data: IdentitySchema) -> RawIdProvider: 47 | return RawIdProvider(user_id=UserId(identity_data.identity_id)) 48 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/auth/access_token_processor.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | from uuid import UUID 3 | 4 | from zametka.access_service.application.dto import AccessTokenDTO 5 | from zametka.access_service.domain.exceptions.access_token import ( 6 | AccessTokenIsExpiredError, 7 | UnauthorizedError, 8 | ) 9 | from zametka.access_service.infrastructure.jwt.exceptions import ( 10 | JWTDecodeError, 11 | JWTExpiredError, 12 | ) 13 | from zametka.access_service.infrastructure.jwt.jwt_processor import ( 14 | JWTProcessor, 15 | JWTToken, 16 | ) 17 | 18 | 19 | class AccessTokenProcessor: 20 | def __init__(self, jwt_processor: JWTProcessor): 21 | self.jwt_processor = jwt_processor 22 | 23 | def encode(self, token: AccessTokenDTO) -> JWTToken: 24 | jwt_token_payload = { 25 | "sub": { 26 | "uid": str(token.uid), 27 | "token_id": str(token.token_id), 28 | }, 29 | "exp": token.expires_in, 30 | } 31 | jwt_token = self.jwt_processor.encode(jwt_token_payload) 32 | 33 | return jwt_token 34 | 35 | def decode(self, token: JWTToken) -> AccessTokenDTO: 36 | try: 37 | payload = self.jwt_processor.decode(token) 38 | sub = payload["sub"] 39 | 40 | uid = UUID(sub["uid"]) 41 | token_id = UUID(sub["token_id"]) 42 | expires_in = datetime.fromtimestamp(float(payload["exp"]), UTC) 43 | access_token = AccessTokenDTO( 44 | uid=uid, 45 | expires_in=expires_in, 46 | token_id=token_id, 47 | ) 48 | except JWTExpiredError as exc: 49 | raise AccessTokenIsExpiredError from exc 50 | except (JWTDecodeError, ValueError, TypeError, KeyError) as exc: 51 | raise UnauthorizedError from exc 52 | else: 53 | return access_token 54 | -------------------------------------------------------------------------------- /tests/unit/access_service/application/interactors/test_create_identity.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from zametka.access_service.application.create_user import ( 3 | CreateUser, 4 | CreateUserInputDTO, 5 | ) 6 | from zametka.access_service.application.dto import UserDTO 7 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 8 | from zametka.access_service.domain.entities.config import ( 9 | UserConfirmationTokenConfig, 10 | ) 11 | from zametka.access_service.domain.value_objects.user_email import UserEmail 12 | from zametka.access_service.domain.value_objects.user_raw_password import ( 13 | UserRawPassword, 14 | ) 15 | 16 | from tests.mocks.access_service.token_sender import FakeTokenSender 17 | from tests.mocks.access_service.uow import FakeUoW 18 | from tests.mocks.access_service.user_gateway import ( 19 | FakeUserGateway, 20 | ) 21 | 22 | 23 | @pytest.mark.access 24 | @pytest.mark.application 25 | async def test_create_identity( 26 | user_gateway: FakeUserGateway, 27 | uow: FakeUoW, 28 | token_sender: FakeTokenSender, 29 | password_hasher: PasswordHasher, 30 | confirmation_token_config: UserConfirmationTokenConfig, 31 | user_password: UserRawPassword, 32 | user_email: UserEmail, 33 | ) -> None: 34 | interactor = CreateUser( 35 | user_gateway=user_gateway, 36 | uow=uow, 37 | token_sender=token_sender, 38 | config=confirmation_token_config, 39 | password_hasher=password_hasher, 40 | ) 41 | 42 | dto = CreateUserInputDTO( 43 | email=user_email.to_raw(), 44 | password=user_password.to_raw(), 45 | ) 46 | 47 | result = await interactor(dto) 48 | 49 | assert result is not None 50 | assert isinstance(result, UserDTO) is True 51 | 52 | assert uow.committed is True 53 | 54 | assert user_gateway.saved is True 55 | assert result.user_id == user_gateway.user.user_id 56 | 57 | assert token_sender.token_sent_cnt == 1 58 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/verify_email.py: -------------------------------------------------------------------------------- 1 | from zametka.access_service.application.common.exceptions.user import ( 2 | UserIsNotExistsError, 3 | ) 4 | from zametka.access_service.application.common.interactor import Interactor 5 | from zametka.access_service.application.common.uow import UoW 6 | from zametka.access_service.application.common.user_gateway import ( 7 | UserReader, 8 | UserSaver, 9 | ) 10 | from zametka.access_service.application.dto import UserConfirmationTokenDTO 11 | from zametka.access_service.domain.common.entities.timed_user_token import ( 12 | TimedTokenMetadata, 13 | ) 14 | from zametka.access_service.domain.common.value_objects.timed_token_id import ( 15 | TimedTokenId, 16 | ) 17 | from zametka.access_service.domain.entities.confirmation_token import ( 18 | UserConfirmationToken, 19 | ) 20 | from zametka.access_service.domain.entities.user import User 21 | from zametka.access_service.domain.value_objects.expires_in import ExpiresIn 22 | from zametka.access_service.domain.value_objects.user_id import UserId 23 | 24 | 25 | class VerifyEmail(Interactor[UserConfirmationTokenDTO, None]): 26 | def __init__( 27 | self, 28 | user_reader: UserReader, 29 | user_saver: UserSaver, 30 | uow: UoW, 31 | ): 32 | self.uow = uow 33 | self.user_reader = user_reader 34 | self.user_saver = user_saver 35 | 36 | async def __call__(self, data: UserConfirmationTokenDTO) -> None: 37 | metadata = TimedTokenMetadata( 38 | uid=UserId(data.uid), 39 | expires_in=ExpiresIn(data.expires_in), 40 | ) 41 | token = UserConfirmationToken(metadata, TimedTokenId(data.token_id)) 42 | 43 | user: User | None = await self.user_reader.with_id(token.uid) 44 | 45 | if not user: 46 | raise UserIsNotExistsError 47 | 48 | user.activate(token) 49 | 50 | await self.user_saver.save(user) 51 | await self.uow.commit() 52 | -------------------------------------------------------------------------------- /tests/unit/access_service/application/interactors/test_verify_email.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from zametka.access_service.application.common.exceptions.user import ( 3 | UserIsNotExistsError, 4 | ) 5 | from zametka.access_service.application.dto import UserConfirmationTokenDTO 6 | from zametka.access_service.application.verify_email import VerifyEmail 7 | from zametka.access_service.domain.entities.confirmation_token import ( 8 | UserConfirmationToken, 9 | ) 10 | from zametka.access_service.domain.exceptions.confirmation_token import ( 11 | ConfirmationTokenIsExpiredError, 12 | ) 13 | 14 | from tests.mocks.access_service.uow import FakeUoW 15 | from tests.mocks.access_service.user_gateway import ( 16 | FakeUserGateway, 17 | ) 18 | 19 | 20 | @pytest.mark.access 21 | @pytest.mark.application 22 | @pytest.mark.parametrize( 23 | ["token_fixture_name", "exc_class"], 24 | [ 25 | ("confirmation_token", None), 26 | ("fake_confirmation_token", UserIsNotExistsError), 27 | ("expired_confirmation_token", ConfirmationTokenIsExpiredError), 28 | ], 29 | ) 30 | async def test_verify_email( 31 | user_gateway: FakeUserGateway, 32 | uow: FakeUoW, 33 | token_fixture_name: str, 34 | exc_class, 35 | request, 36 | ) -> None: 37 | interactor = VerifyEmail( 38 | uow=uow, 39 | user_reader=user_gateway, 40 | user_saver=user_gateway, 41 | ) 42 | 43 | token: UserConfirmationToken = request.getfixturevalue(token_fixture_name) 44 | 45 | dto = UserConfirmationTokenDTO( 46 | uid=token.uid.to_raw(), 47 | expires_in=token.expires_in.to_raw(), 48 | token_id=token.token_id.to_raw(), 49 | ) 50 | 51 | coro = interactor(dto) 52 | 53 | if not exc_class: 54 | result = await coro 55 | 56 | assert result is None 57 | assert uow.committed is True 58 | assert user_gateway.user.is_active is True 59 | else: 60 | with pytest.raises(exc_class): 61 | await coro 62 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/email/confirmation_token_processor.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | from uuid import UUID 3 | 4 | from zametka.access_service.application.dto import UserConfirmationTokenDTO 5 | from zametka.access_service.domain.exceptions.confirmation_token import ( 6 | ConfirmationTokenIsExpiredError, 7 | CorruptedConfirmationTokenError, 8 | ) 9 | from zametka.access_service.infrastructure.jwt.exceptions import ( 10 | JWTDecodeError, 11 | JWTExpiredError, 12 | ) 13 | from zametka.access_service.infrastructure.jwt.jwt_processor import ( 14 | JWTProcessor, 15 | JWTToken, 16 | ) 17 | 18 | 19 | class ConfirmationTokenProcessor: 20 | def __init__(self, jwt_processor: JWTProcessor): 21 | self.jwt_processor = jwt_processor 22 | 23 | def encode(self, token: UserConfirmationTokenDTO) -> JWTToken: 24 | jwt_token_payload = { 25 | "sub": { 26 | "uid": str(token.uid), 27 | "token_id": str(token.token_id), 28 | }, 29 | "exp": token.expires_in, 30 | } 31 | jwt_token = self.jwt_processor.encode(jwt_token_payload) 32 | 33 | return jwt_token 34 | 35 | def decode(self, token: JWTToken) -> UserConfirmationTokenDTO: 36 | try: 37 | payload = self.jwt_processor.decode(token) 38 | sub = payload["sub"] 39 | 40 | uid = UUID(sub["uid"]) 41 | token_id = UUID(sub["token_id"]) 42 | expires_in = datetime.fromtimestamp(float(payload["exp"]), UTC) 43 | 44 | confirmation_token = UserConfirmationTokenDTO( 45 | uid=uid, 46 | expires_in=expires_in, 47 | token_id=token_id, 48 | ) 49 | except JWTExpiredError as exc: 50 | raise ConfirmationTokenIsExpiredError from exc 51 | except (JWTDecodeError, ValueError, TypeError, KeyError) as exc: 52 | raise CorruptedConfirmationTokenError from exc 53 | else: 54 | return confirmation_token 55 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from asyncpg import UniqueViolationError 4 | from fastapi import FastAPI 5 | 6 | from zametka.notes.domain.exceptions.note import ( 7 | NoteAccessDeniedError, 8 | NoteDataError, 9 | NoteNotExistsError, 10 | ) 11 | from zametka.notes.domain.exceptions.user import ( 12 | IsNotAuthorizedError, 13 | UserDataError, 14 | UserIsNotExistsError, 15 | ) 16 | from zametka.notes.presentation.web_api.endpoints import note, user 17 | from zametka.notes.presentation.web_api.exception_handlers.note import ( 18 | note_access_denied_exception_handler, 19 | note_data_exception_handler, 20 | note_not_exists_exception_handler, 21 | ) 22 | from zametka.notes.presentation.web_api.exception_handlers.user import ( 23 | is_not_authorized_exception_handler, 24 | unique_exception_handler, 25 | user_data_exception_handler, 26 | user_is_not_exists_exception_handler, 27 | ) 28 | 29 | 30 | def include_routers(app: FastAPI) -> None: 31 | """Include endpoints APIRouters to the bootstrap app""" 32 | 33 | logging.info("Routers was included.") 34 | 35 | app.include_router(note.router) 36 | app.include_router(user.router) 37 | 38 | 39 | def include_exception_handlers(app: FastAPI) -> None: 40 | """Include exceptions handlers to the bootstrap app""" 41 | 42 | logging.info("Exception handlers was included.") 43 | 44 | app.add_exception_handler( 45 | NoteAccessDeniedError, note_access_denied_exception_handler, 46 | ) 47 | app.add_exception_handler(NoteNotExistsError, note_not_exists_exception_handler) 48 | app.add_exception_handler(NoteDataError, note_data_exception_handler) 49 | app.add_exception_handler(UserDataError, user_data_exception_handler) 50 | app.add_exception_handler( 51 | UserIsNotExistsError, user_is_not_exists_exception_handler, 52 | ) 53 | app.add_exception_handler(IsNotAuthorizedError, is_not_authorized_exception_handler) 54 | app.add_exception_handler(UniqueViolationError, unique_exception_handler) 55 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/message_broker/message_broker.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Protocol 4 | 5 | import aio_pika 6 | from aio_pika.abc import AbstractChannel 7 | 8 | from .message import Message 9 | 10 | 11 | class MessageBroker(Protocol): 12 | async def publish_message( 13 | self, 14 | message: Message, 15 | routing_key: str, 16 | exchange_name: str, 17 | ) -> None: 18 | raise NotImplementedError 19 | 20 | async def declare_exchange(self, exchange_name: str) -> None: 21 | raise NotImplementedError 22 | 23 | 24 | class RMQMessageBroker(MessageBroker): 25 | def __init__(self, channel: AbstractChannel) -> None: 26 | self._channel = channel 27 | 28 | async def publish_message( 29 | self, 30 | message: Message, 31 | routing_key: str, 32 | exchange_name: str, 33 | ) -> None: 34 | body = { 35 | "message_type": message.message_type, 36 | "data": message.data, 37 | } 38 | 39 | rq_message = aio_pika.Message( 40 | body=json.dumps(body).encode(), 41 | message_id=str(message.message_id), 42 | content_type="application/json", 43 | delivery_mode=aio_pika.DeliveryMode.PERSISTENT, 44 | headers={}, 45 | ) 46 | 47 | await self._publish_message(rq_message, routing_key, exchange_name) 48 | 49 | async def declare_exchange(self, exchange_name: str) -> None: 50 | await self._channel.declare_exchange(exchange_name, aio_pika.ExchangeType.TOPIC) 51 | 52 | async def _publish_message( 53 | self, 54 | rq_message: aio_pika.Message, 55 | routing_key: str, 56 | exchange_name: str, 57 | ) -> None: 58 | exchange = await self._get_exchange(exchange_name) 59 | await exchange.publish(rq_message, routing_key=routing_key) 60 | 61 | logging.info("Message sent", extra={"rq_message": rq_message}) 62 | 63 | async def _get_exchange(self, exchange_name: str) -> aio_pika.abc.AbstractExchange: 64 | return await self._channel.get_exchange(exchange_name, ensure=False) 65 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | image: nginx:1.25.5-alpine 4 | ports: 5 | - 80:80 6 | depends_on: 7 | - backend 8 | volumes: 9 | - ./.config/nginx/nginx.conf:/etc/nginx/nginx.conf 10 | 11 | backend: 12 | container_name: backend 13 | restart: on-failure 14 | build: . 15 | command: fastapi run src/zametka/main/web.py --root-path=/api/ 16 | env_file: 17 | - /usr/local/etc/zametka/.env.access_service 18 | - /usr/local/etc/zametka/.env 19 | - /usr/local/etc/zametka/.env.notes 20 | volumes: 21 | - ./src/zametka/access_service/infrastructure/persistence/alembic/migrations/versions:/home/app/backend/src/zametka/access_service/infrastructure/persistence/alembic/migrations/versions 22 | - ./src/zametka/notes/infrastructure/persistence/alembic/migrations/versions:/home/app/backend/src/zametka/notes/infrastructure/persistence/alembic/migrations/versions 23 | - ./.config/dev.config.toml:/usr/local/etc/zametka/cfg.toml 24 | depends_on: 25 | - migration 26 | db: 27 | container_name: persistence 28 | image: zametkaru/postgres-multi-db 29 | volumes: 30 | - pg_data:/var/lib/postgresql/data/ 31 | env_file: 32 | - /usr/local/etc/zametka/.env 33 | healthcheck: 34 | test: [ "CMD-SHELL", "pg_isready -d access_database -U $${POSTGRES_USER}", 35 | "CMD-SHELL", "pg_isready -d notes_database -U $${POSTGRES_USER}" ] 36 | interval: 2s 37 | timeout: 60s 38 | retries: 10 39 | start_period: 3s 40 | 41 | migration: 42 | container_name: migration 43 | build: . 44 | restart: on-failure 45 | env_file: 46 | - /usr/local/etc/zametka/.env.access_service 47 | - /usr/local/etc/zametka/.env 48 | - /usr/local/etc/zametka/.env.notes 49 | depends_on: 50 | db: 51 | condition: service_healthy 52 | command: [ "zametka", "all", "alembic", "upgrade", "head" ] 53 | 54 | volumes: 55 | pg_data: 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### **API Приложения zametka** 2 | 3 | #### Особенности 4 | - Чистая архитектура (выделен домен, прикладной слой, инфраструктурный слой и презентационный слой, выстроен модульный монолит) 5 | - Некоторые паттерны из DDD, например Value Objects или Domain Services 6 | - Реализован синхронный (сетевой) вариант общения между контекстами с применением паттерна EventEmitter из EDP 7 | - Хорошее качество кода (mypy --strict проходит без ошибок) 8 | - Все завернуто в докер 9 | - Своя аутентификация на базе JWT, выделен отдельный контекст авторизации и доступа 10 | - Продуманная структура базы данных, используется PostgreSQL 11 | - Система миграций 12 | - Оптимизация, эндпоинты в среднем отвечают за меньше чем **100мс** 13 | - Код соответствует PEP8 14 | - Код придерживается принципов SOLID 15 | 16 | #### Основные сущности в контексте заметок 17 | 18 | Note (заметкa): 19 | 20 | Поля: 21 | - Название (строка <= 50 символов) 22 | - Текст (строка <= 60000 символов) 23 | - Дата создания (datetime) 24 | - Айди автора 25 | 26 | Операции: 27 | - Создание 28 | - Чтение 29 | - Обновление 30 | - Удаление 31 | - Получение заметок пользователя (поддерживаются параметры limit & offset) 32 | - Полнотекстовый поиск по заметкам пользователя с помошью триграмм (pg_trgm) 33 | 34 | User (пользователь): 35 | 36 | Поля: 37 | - Имя (строка >= 2 & <= 40 символов) 38 | - Фамилия (строка >= 2 & <= 60 символов) 39 | - Дата регистрации (datetime) 40 | - Айди авторизационных данных (из контекста авторизации и доступа) 41 | 42 | Операции: 43 | - Регистрация 44 | - Подтверждение почты 45 | - Вход в аккаунт 46 | - Чтение 47 | - Выход из аккаунта 48 | - Удаление аккаунта 49 | 50 | #### Основные сущности в контексте авторизации и доступа 51 | 52 | UserIdentity (авторизационные данные пользователя): 53 | 54 | Поля: 55 | - Почта (строка <= 100 символов) 56 | - Захэшированный пароль 57 | - Активный? (false) 58 | 59 | Операции: 60 | - Регистрация 61 | - Подтверждение почты 62 | - Вход в аккаунт 63 | - Чтение 64 | 65 | --------------------- 66 | 67 | ### Стeк технологий backend 68 | 69 | - FastAPI 70 | - SQLAlchemy (asyncpg) 71 | - Pydantic 72 | - JWT Auth 73 | - Docker 74 | - PostgreSQL 75 | - aiohttp 76 | - Alembic 77 | 78 | ### Лицензия 79 | 80 | Проект распространяется под лицензией GPLv3 -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/repositories/converters/note.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | 3 | from sqlalchemy import Row 4 | 5 | from zametka.notes.application.note.dto import DBNoteDTO, ListNoteDTO 6 | from zametka.notes.domain.entities.note import DBNote 7 | from zametka.notes.domain.entities.note import Note as NoteEntity 8 | from zametka.notes.domain.value_objects.note.note_created_at import ( 9 | NoteCreatedAt, 10 | ) 11 | from zametka.notes.domain.value_objects.note.note_id import NoteId 12 | from zametka.notes.domain.value_objects.note.note_text import NoteText 13 | from zametka.notes.domain.value_objects.note.note_title import NoteTitle 14 | from zametka.notes.domain.value_objects.user.user_id import UserId 15 | from zametka.notes.infrastructure.db.models.note import Note 16 | 17 | 18 | def note_db_data_to_db_note_dto( 19 | note: tuple[int, str, str | None], 20 | ) -> DBNoteDTO: 21 | return DBNoteDTO( 22 | note_id=note[0], 23 | title=note[1], 24 | text=note[2], 25 | ) 26 | 27 | 28 | def note_db_model_to_db_note_dto(note: Note) -> DBNoteDTO: 29 | return DBNoteDTO( 30 | title=note.title, 31 | text=note.text, 32 | note_id=note.note_id, 33 | ) 34 | 35 | 36 | def note_db_model_to_db_note_entity(note: Note) -> DBNote: 37 | return DBNote( 38 | note_id=NoteId(note.note_id), 39 | title=NoteTitle(note.title), 40 | text=NoteText(note.text) if note.text else None, 41 | author_id=UserId(note.author_id), 42 | created_at=NoteCreatedAt(note.created_at), 43 | ) 44 | 45 | 46 | def note_db_model_to_list_note_dto(note: Row[tuple[str, int]]) -> ListNoteDTO: 47 | return ListNoteDTO( 48 | title=note[0], 49 | note_id=note[1], 50 | ) 51 | 52 | 53 | def notes_to_dto(notes: Sequence[Row[tuple[str, int]]]) -> list[ListNoteDTO]: 54 | return [note_db_model_to_list_note_dto(note) for note in notes] 55 | 56 | 57 | def note_entity_to_db_model(note: NoteEntity) -> Note: 58 | db_note = Note( 59 | title=note.title.to_raw(), 60 | text=note.text.to_raw() if note.text else None, 61 | created_at=note.created_at.to_raw(), 62 | author_id=note.author_id.to_raw(), 63 | ) 64 | 65 | return db_note 66 | -------------------------------------------------------------------------------- /tests/unit/access_service/application/interactors/test_delete_identity.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from zametka.access_service.application.delete_user import ( 3 | DeleteUser, 4 | DeleteUserInputDTO, 5 | ) 6 | from zametka.access_service.application.dto import UserDeletedEvent 7 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 8 | from zametka.access_service.domain.exceptions.user import ( 9 | InvalidCredentialsError, 10 | UserIsNotActiveError, 11 | ) 12 | from zametka.access_service.domain.value_objects.user_raw_password import ( 13 | UserRawPassword, 14 | ) 15 | 16 | from tests.mocks.access_service.event_emitter import FakeEventEmitter 17 | from tests.mocks.access_service.id_provider import FakeIdProvider 18 | from tests.mocks.access_service.user_gateway import ( 19 | FakeUserGateway, 20 | ) 21 | 22 | 23 | @pytest.mark.access 24 | @pytest.mark.application 25 | @pytest.mark.parametrize( 26 | ["user_is_active", "password_startswith", "exc_class"], 27 | [ 28 | (True, "", None), 29 | (False, "", UserIsNotActiveError), 30 | (True, "blabla", InvalidCredentialsError), 31 | ], 32 | ) 33 | async def test_delete_identity( 34 | user_gateway: FakeUserGateway, 35 | id_provider: FakeIdProvider, 36 | event_emitter: FakeEventEmitter, 37 | password_hasher: PasswordHasher, 38 | user_password: UserRawPassword, 39 | user_is_active: bool, 40 | password_startswith: str, 41 | exc_class, 42 | ) -> None: 43 | user_gateway.user.is_active = user_is_active 44 | 45 | interactor = DeleteUser( 46 | id_provider=id_provider, 47 | event_emitter=event_emitter, 48 | user_gateway=user_gateway, 49 | password_hasher=password_hasher, 50 | ) 51 | 52 | coro = interactor( 53 | DeleteUserInputDTO( 54 | password=password_startswith + user_password.to_raw(), 55 | ), 56 | ) 57 | 58 | if exc_class: 59 | with pytest.raises(exc_class): 60 | await coro 61 | else: 62 | result = await coro 63 | 64 | assert result is None 65 | assert id_provider.requested is True 66 | assert user_gateway.deleted is True 67 | assert event_emitter.calls(UserDeletedEvent) 68 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/email/email_token_sender.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from email.mime.multipart import MIMEMultipart 3 | from email.mime.text import MIMEText 4 | 5 | from jinja2 import Environment 6 | 7 | from zametka.access_service.application.common.token_sender import TokenSender 8 | from zametka.access_service.application.dto import UserConfirmationTokenDTO 9 | from zametka.access_service.domain.entities.user import User 10 | from zametka.access_service.infrastructure.email.config import ( 11 | ConfirmationEmailConfig, 12 | ) 13 | from zametka.access_service.infrastructure.email.email_client import ( 14 | EmailClient, 15 | ) 16 | from zametka.access_service.infrastructure.jwt.confirmation_token_processor import ( 17 | ConfirmationTokenProcessor, 18 | ) 19 | 20 | 21 | class EmailTokenSender(TokenSender): 22 | def __init__( 23 | self, 24 | client: EmailClient, 25 | jinja: Environment, 26 | config: ConfirmationEmailConfig, 27 | token_processor: ConfirmationTokenProcessor, 28 | ) -> None: 29 | self.client = client 30 | self.jinja = jinja 31 | self.config = config 32 | self.token_processor = token_processor 33 | 34 | def _render_html(self, token: UserConfirmationTokenDTO) -> str: 35 | template = self.jinja.get_template(self.config.template_name) 36 | jwt_token = self.token_processor.encode(token) 37 | link = self.config.confirmation_link.format_map( 38 | { 39 | "token": jwt_token, 40 | }, 41 | ) 42 | 43 | rendered: str = template.render( 44 | token_link=link, 45 | ) 46 | 47 | return rendered 48 | 49 | async def send(self, token: UserConfirmationTokenDTO, user: User) -> None: 50 | html = self._render_html(token) 51 | message = MIMEMultipart("alternative") 52 | 53 | message["From"] = self.config.email_from 54 | message["To"] = user.email.to_raw() 55 | message["Subject"] = self.config.subject 56 | 57 | html_text = MIMEText(html, "html") 58 | message.attach(html_text) 59 | 60 | await self.client.send(message) 61 | 62 | logging.info("Email sent to uid=%s", str(user.user_id.to_raw())) 63 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/alembic/migrations/versions/5b9db61f86b5_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 5b9db61f86b5 4 | Revises: 5 | Create Date: 2024-01-03 13:04:46.906573 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | from zametka.notes.infrastructure.db.models import Note 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "5b9db61f86b5" 16 | down_revision = None 17 | branch_labels = None 18 | depends_on = None 19 | 20 | INDEX_NAME = "notes_title_trgm_idx" 21 | 22 | 23 | def upgrade() -> None: 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.create_table( 26 | "users", 27 | sa.Column("identity_id", sa.Uuid(), nullable=False), 28 | sa.Column("first_name", sa.String(length=40), nullable=False), 29 | sa.Column("last_name", sa.String(length=60), nullable=False), 30 | sa.Column("joined_at", sa.DateTime(), nullable=False), 31 | sa.PrimaryKeyConstraint("identity_id"), 32 | ) 33 | op.create_table( 34 | "notes", 35 | sa.Column("note_id", sa.Integer(), autoincrement=True, nullable=False), 36 | sa.Column("title", sa.String(length=50), nullable=False), 37 | sa.Column("text", sa.String(length=60000), nullable=True), 38 | sa.Column("created_at", sa.DateTime(), nullable=False), 39 | sa.Column("author_id", sa.Uuid(), nullable=False), 40 | sa.ForeignKeyConstraint( 41 | ["author_id"], 42 | ["users.identity_id"], 43 | ), 44 | sa.PrimaryKeyConstraint("note_id"), 45 | ) 46 | # ### end Alembic commands ### 47 | 48 | conn = op.get_bind() 49 | 50 | conn.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) 51 | 52 | index = sa.Index( 53 | INDEX_NAME, 54 | sa.func.coalesce(Note.title, "").label("columns"), 55 | postgresql_using="gin", 56 | postgresql_ops={ 57 | "columns": "gin_trgm_ops", 58 | }, 59 | ) 60 | 61 | op.create_index( 62 | INDEX_NAME, 63 | "notes", 64 | index.expressions, # type:ignore 65 | postgresql_using="gin", 66 | postgresql_ops={"columns": "gin_trgm_ops"}, 67 | ) 68 | 69 | 70 | def downgrade() -> None: 71 | # ### commands auto generated by Alembic - please adjust! ### 72 | op.drop_table("notes") 73 | op.drop_table("users") 74 | # ### end Alembic commands ### 75 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/endpoints/user.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import DishkaRoute 3 | from fastapi import APIRouter 4 | from fastapi.responses import JSONResponse, Response 5 | 6 | from zametka.access_service.application.authorize import Authorize, AuthorizeInputDTO 7 | from zametka.access_service.application.create_user import ( 8 | CreateUser, 9 | CreateUserInputDTO, 10 | ) 11 | from zametka.access_service.application.dto import UserDTO 12 | from zametka.access_service.application.get_user import GetUser 13 | from zametka.access_service.application.verify_email import VerifyEmail 14 | from zametka.access_service.infrastructure.email.confirmation_token_processor import ( 15 | ConfirmationTokenProcessor, 16 | ) 17 | from zametka.access_service.infrastructure.jwt.jwt_processor import JWTToken 18 | from zametka.access_service.presentation.http.auth.token_auth import TokenAuth 19 | from zametka.access_service.presentation.http.schemas.user import ( 20 | AuthorizeSchema, 21 | CreateIdentitySchema, 22 | ) 23 | 24 | router = APIRouter( 25 | prefix="/auth", 26 | tags=["Auth"], 27 | responses={404: {"description": "Не найдено"}}, 28 | route_class=DishkaRoute, 29 | ) 30 | 31 | 32 | @router.post("/") 33 | async def create_identity( 34 | data: CreateIdentitySchema, 35 | action: FromDishka[CreateUser], 36 | ) -> UserDTO: 37 | response = await action( 38 | CreateUserInputDTO( 39 | email=data.email, 40 | password=data.password, 41 | ), 42 | ) 43 | 44 | return response 45 | 46 | 47 | @router.post("/authorize") 48 | async def authorize( 49 | data: AuthorizeSchema, 50 | action: FromDishka[Authorize], 51 | token_auth: FromDishka[TokenAuth], 52 | ) -> Response: 53 | access_token = await action( 54 | AuthorizeInputDTO( 55 | email=data.email, 56 | password=data.password, 57 | ), 58 | ) 59 | 60 | http_response = JSONResponse(status_code=201, content={}) 61 | return token_auth.set_session(access_token, http_response) 62 | 63 | 64 | @router.get("/me") 65 | async def get_identity(action: FromDishka[GetUser]) -> UserDTO: 66 | response = await action() 67 | return response 68 | 69 | 70 | @router.get("/verify/{token}") 71 | async def verify_email( 72 | token: JWTToken, 73 | action: FromDishka[VerifyEmail], 74 | token_processor: FromDishka[ConfirmationTokenProcessor], 75 | ) -> None: 76 | decoded_token = token_processor.decode(token) 77 | await action(decoded_token) 78 | -------------------------------------------------------------------------------- /tests/unit/access_service/application/interactors/test_authorize.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from zametka.access_service.application.authorize import ( 3 | Authorize, 4 | AuthorizeInputDTO, 5 | ) 6 | from zametka.access_service.application.common.exceptions.user import ( 7 | UserIsNotExistsError, 8 | ) 9 | from zametka.access_service.application.dto import AccessTokenDTO 10 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 11 | from zametka.access_service.domain.entities.config import AccessTokenConfig 12 | from zametka.access_service.domain.exceptions.user import ( 13 | InvalidCredentialsError, 14 | UserIsNotActiveError, 15 | ) 16 | from zametka.access_service.domain.value_objects.user_email import UserEmail 17 | from zametka.access_service.domain.value_objects.user_raw_password import ( 18 | UserRawPassword, 19 | ) 20 | 21 | from tests.mocks.access_service.user_gateway import ( 22 | FakeUserGateway, 23 | ) 24 | 25 | 26 | @pytest.mark.access 27 | @pytest.mark.application 28 | @pytest.mark.parametrize( 29 | ["user_is_active", "user_is_exists", "password_startswith", "exc_class"], 30 | [ 31 | (True, True, "", None), 32 | (False, True, "", UserIsNotActiveError), 33 | (True, False, "", UserIsNotExistsError), 34 | (True, True, "blabla", InvalidCredentialsError), 35 | ], 36 | ) 37 | async def test_authorize( 38 | user_gateway: FakeUserGateway, 39 | access_token_config: AccessTokenConfig, 40 | user_password: UserRawPassword, 41 | user_email: UserEmail, 42 | password_hasher: PasswordHasher, 43 | user_is_active: bool, 44 | user_is_exists: bool, 45 | password_startswith: str, 46 | exc_class, 47 | ) -> None: 48 | user_gateway.user.is_active = user_is_active 49 | 50 | if not user_is_exists: 51 | 52 | async def fake_get(*_): 53 | return None 54 | 55 | user_gateway.with_email = fake_get 56 | 57 | interactor = Authorize( 58 | user_gateway, 59 | access_token_config, 60 | password_hasher, 61 | ) 62 | 63 | dto = AuthorizeInputDTO( 64 | email=user_email.to_raw(), 65 | password=password_startswith + user_password.to_raw(), 66 | ) 67 | 68 | coro = interactor(dto) 69 | 70 | if exc_class: 71 | with pytest.raises(exc_class): 72 | await coro 73 | else: 74 | result = await coro 75 | 76 | assert result is not None 77 | assert isinstance(result, AccessTokenDTO) is True 78 | 79 | assert result.uid == user_gateway.user.user_id.to_raw() 80 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/authorize.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import UTC, datetime 3 | from uuid import uuid4 4 | 5 | from zametka.access_service.application.common.exceptions.user import ( 6 | UserIsNotExistsError, 7 | ) 8 | from zametka.access_service.application.common.interactor import Interactor 9 | from zametka.access_service.application.common.user_gateway import UserReader 10 | from zametka.access_service.application.dto import AccessTokenDTO 11 | from zametka.access_service.domain.common.entities.timed_user_token import ( 12 | TimedTokenMetadata, 13 | ) 14 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 15 | from zametka.access_service.domain.common.value_objects.timed_token_id import ( 16 | TimedTokenId, 17 | ) 18 | from zametka.access_service.domain.entities.access_token import AccessToken 19 | from zametka.access_service.domain.entities.config import AccessTokenConfig 20 | from zametka.access_service.domain.entities.user import User 21 | from zametka.access_service.domain.value_objects.expires_in import ExpiresIn 22 | from zametka.access_service.domain.value_objects.user_email import UserEmail 23 | from zametka.access_service.domain.value_objects.user_raw_password import ( 24 | UserRawPassword, 25 | ) 26 | 27 | 28 | @dataclass(frozen=True) 29 | class AuthorizeInputDTO: 30 | email: str 31 | password: str 32 | 33 | 34 | class Authorize(Interactor[AuthorizeInputDTO, AccessTokenDTO]): 35 | def __init__( 36 | self, 37 | user_gateway: UserReader, 38 | config: AccessTokenConfig, 39 | password_hasher: PasswordHasher, 40 | ): 41 | self.user_gateway = user_gateway 42 | self.config = config 43 | self.ph = password_hasher 44 | 45 | async def __call__(self, data: AuthorizeInputDTO) -> AccessTokenDTO: 46 | user: User | None = await self.user_gateway.with_email(UserEmail(data.email)) 47 | 48 | if not user: 49 | raise UserIsNotExistsError() 50 | 51 | user.authenticate(UserRawPassword(data.password), self.ph) 52 | user.ensure_is_active() 53 | 54 | now = datetime.now(tz=UTC) 55 | expires_in = ExpiresIn(now + self.config.expires_after) 56 | metadata = TimedTokenMetadata(uid=user.user_id, expires_in=expires_in) 57 | 58 | token_id = TimedTokenId(uuid4()) 59 | token = AccessToken(metadata, token_id=token_id) 60 | 61 | return AccessTokenDTO( 62 | uid=token.uid.to_raw(), 63 | expires_in=token.expires_in.to_raw(), 64 | token_id=token_id.to_raw(), 65 | ) 66 | -------------------------------------------------------------------------------- /src/zametka/notes/domain/entities/note.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | from zametka.notes.domain.value_objects.note.note_created_at import ( 7 | NoteCreatedAt, 8 | ) 9 | from zametka.notes.domain.value_objects.note.note_id import NoteId 10 | from zametka.notes.domain.value_objects.note.note_text import NoteText 11 | from zametka.notes.domain.value_objects.note.note_title import NoteTitle 12 | from zametka.notes.domain.value_objects.user.user_id import UserId 13 | 14 | 15 | class Note: 16 | __slots__ = ( 17 | "title", 18 | "author_id", 19 | "text", 20 | "created_at", 21 | ) 22 | 23 | def __init__( 24 | self, 25 | title: NoteTitle, 26 | author_id: UserId, 27 | text: NoteText | None = None, 28 | created_at: NoteCreatedAt | None = None, 29 | ) -> None: 30 | self.title = title 31 | self.author_id = author_id 32 | self.text = text 33 | self.created_at = created_at 34 | 35 | if not self.created_at: 36 | self.created_at = NoteCreatedAt(datetime.now()) 37 | 38 | def merge(self, other: Note) -> Note: 39 | return Note( 40 | title=other.title, 41 | text=other.text or self.text, 42 | author_id=self.author_id, 43 | created_at=self.created_at, 44 | ) 45 | 46 | def has_access(self, user_id: UserId) -> bool: 47 | return self.author_id == user_id 48 | 49 | def __str__(self) -> str: 50 | return f"Note: {self.title}" 51 | 52 | 53 | class DBNote(Note): 54 | __slots__ = ("note_id",) 55 | 56 | def __init__( 57 | self, 58 | title: NoteTitle, 59 | author_id: UserId, 60 | note_id: NoteId, 61 | text: NoteText | None = None, 62 | created_at: NoteCreatedAt | None = None, 63 | ) -> None: 64 | super().__init__( 65 | title=title, 66 | author_id=author_id, 67 | text=text, 68 | created_at=created_at, 69 | ) 70 | self.note_id = note_id 71 | 72 | def merge(self, other: Note) -> DBNote: 73 | merged = super().merge(other) 74 | return DBNote( 75 | title=merged.title, 76 | author_id=merged.author_id, 77 | note_id=self.note_id, 78 | text=merged.text, 79 | created_at=merged.created_at, 80 | ) 81 | 82 | def __eq__(self, other: DBNote | Any) -> bool: 83 | if isinstance(other, DBNote) and other.note_id == self.note_id: 84 | return True 85 | return False 86 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/alembic/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy import pool 6 | from sqlalchemy.engine import Connection 7 | from sqlalchemy.ext.asyncio import async_engine_from_config 8 | 9 | from zametka.access_service.infrastructure.persistence.config import ( 10 | load_alembic_config, 11 | ) 12 | from zametka.access_service.infrastructure.persistence.models import Base 13 | 14 | config = context.config 15 | 16 | if config.config_file_name is not None: 17 | fileConfig(config.config_file_name) 18 | 19 | target_metadata = Base.metadata 20 | 21 | 22 | def get_url() -> str: 23 | settings = load_alembic_config() 24 | 25 | return settings.get_connection_url() 26 | 27 | 28 | def run_migrations_offline() -> None: 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | url = get_url() 41 | context.configure( 42 | url=url, 43 | target_metadata=target_metadata, 44 | literal_binds=True, 45 | dialect_opts={"paramstyle": "named"}, 46 | ) 47 | 48 | with context.begin_transaction(): 49 | context.run_migrations() 50 | 51 | 52 | def do_run_migrations(connection: Connection) -> None: 53 | context.configure(connection=connection, target_metadata=target_metadata) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | async def run_async_migrations() -> None: 60 | """ 61 | In this scenario we need to create an Engine 62 | and associate a connection with the context. 63 | """ 64 | 65 | configuration = config.get_section(config.config_ini_section) 66 | configuration["sqlalchemy.url"] = get_url() 67 | 68 | connectable = async_engine_from_config( 69 | configuration, 70 | prefix="sqlalchemy.", 71 | poolclass=pool.NullPool, 72 | ) 73 | 74 | async with connectable.connect() as connection: 75 | await connection.run_sync(do_run_migrations) 76 | 77 | await connectable.dispose() 78 | 79 | 80 | def run_migrations_online() -> None: 81 | """Run migrations in 'online' mode.""" 82 | 83 | asyncio.run(run_async_migrations()) 84 | 85 | 86 | if context.is_offline_mode(): 87 | run_migrations_offline() 88 | else: 89 | run_migrations_online() 90 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/gateway/user.py: -------------------------------------------------------------------------------- 1 | from typing import NoReturn 2 | 3 | from sqlalchemy import delete, select 4 | from sqlalchemy.exc import DBAPIError, IntegrityError 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from zametka.access_service.application.common.exceptions.repo_error import RepoError 8 | from zametka.access_service.application.common.exceptions.user import ( 9 | UserEmailAlreadyExistsError, 10 | ) 11 | from zametka.access_service.application.common.user_gateway import ( 12 | UserReader, 13 | UserSaver, 14 | ) 15 | from zametka.access_service.application.dto import UserDTO 16 | from zametka.access_service.domain.entities.user import ( 17 | User, 18 | ) 19 | from zametka.access_service.domain.value_objects.user_email import UserEmail 20 | from zametka.access_service.domain.value_objects.user_id import UserId 21 | from zametka.access_service.infrastructure.gateway.converters.user import ( 22 | convert_db_user_to_dto, 23 | convert_db_user_to_entity, 24 | convert_user_entity_to_db_user, 25 | ) 26 | from zametka.access_service.infrastructure.persistence.models.user_identity import ( 27 | DBUser, 28 | ) 29 | 30 | 31 | class UserGatewayImpl(UserSaver, UserReader): 32 | session: AsyncSession 33 | 34 | def __init__(self, session: AsyncSession): 35 | self.session = session 36 | 37 | async def save( 38 | self, 39 | user: User, 40 | ) -> UserDTO: 41 | db_user = convert_user_entity_to_db_user(user) 42 | 43 | try: 44 | await self.session.merge(db_user) 45 | except IntegrityError as err: 46 | self._process_error(err) 47 | 48 | return convert_db_user_to_dto(db_user) 49 | 50 | async def with_id(self, user_id: UserId) -> User | None: 51 | q = select(DBUser).where(DBUser.user_id == user_id.to_raw()) 52 | 53 | res = await self.session.execute(q) 54 | user: DBUser | None = res.scalar() 55 | 56 | if not user: 57 | return None 58 | 59 | return convert_db_user_to_entity(user) 60 | 61 | async def with_email(self, email: UserEmail) -> User | None: 62 | q = select(DBUser).where(DBUser.email == email.to_raw()) 63 | 64 | res = await self.session.execute(q) 65 | 66 | user: DBUser | None = res.scalar() 67 | 68 | if not user: 69 | return None 70 | 71 | return convert_db_user_to_entity(user) 72 | 73 | async def delete(self, user_id: UserId) -> None: 74 | q = delete(DBUser).where(DBUser.user_id == user_id.to_raw()) 75 | 76 | await self.session.execute(q) 77 | 78 | @staticmethod 79 | def _process_error(error: DBAPIError) -> NoReturn: 80 | match error.__cause__.__cause__.constraint_name: 81 | case "uq_users_email": 82 | raise UserEmailAlreadyExistsError from error 83 | case _: 84 | raise RepoError from error 85 | -------------------------------------------------------------------------------- /src/zametka/notes/main/ioc.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncIterator 2 | from contextlib import asynccontextmanager 3 | 4 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 5 | 6 | from zametka.notes.application.common.id_provider import IdProvider 7 | from zametka.notes.application.note.note_interactor import NoteInteractor 8 | from zametka.notes.application.user.create_user import CreateUser 9 | from zametka.notes.application.user.get_user import GetUser 10 | from zametka.notes.domain.services.note_service import NoteService 11 | from zametka.notes.domain.services.user_service import UserService 12 | from zametka.notes.infrastructure.db.provider import ( 13 | get_note_repository, 14 | get_uow, 15 | get_user_repository, 16 | ) 17 | from zametka.notes.presentation.interactor_factory import ( 18 | GInputDTO, 19 | GOutputDTO, 20 | InteractorCallable, 21 | InteractorFactory, 22 | InteractorPicker, 23 | ) 24 | 25 | 26 | class IoC(InteractorFactory): 27 | def __init__( 28 | self, 29 | session_factory: async_sessionmaker[AsyncSession], 30 | ): 31 | self._session_factory = session_factory 32 | self._note_service = NoteService() 33 | self._user_service = UserService() 34 | 35 | def _construct_note_interactor( 36 | self, session: AsyncSession, id_provider: IdProvider, 37 | ) -> NoteInteractor: 38 | note_repository = get_note_repository(session) 39 | uow = get_uow(session) 40 | 41 | note_service = self._note_service 42 | 43 | return NoteInteractor( 44 | note_repository=note_repository, 45 | uow=uow, 46 | note_service=note_service, 47 | id_provider=id_provider, 48 | ) 49 | 50 | @asynccontextmanager 51 | async def pick_note_interactor( 52 | self, 53 | id_provider: IdProvider, 54 | picker: InteractorPicker[GInputDTO, GOutputDTO], 55 | ) -> AsyncIterator[InteractorCallable[GInputDTO, GOutputDTO]]: 56 | async with self._session_factory() as session: 57 | interactor = self._construct_note_interactor(session, id_provider) 58 | yield picker(interactor) 59 | 60 | @asynccontextmanager 61 | async def create_user(self, id_provider: IdProvider) -> AsyncIterator[CreateUser]: 62 | async with self._session_factory() as session: 63 | interactor = CreateUser( 64 | user_repository=get_user_repository(session), 65 | uow=get_uow(session), 66 | id_provider=id_provider, 67 | user_service=self._user_service, 68 | ) 69 | 70 | yield interactor 71 | 72 | @asynccontextmanager 73 | async def get_user(self, id_provider: IdProvider) -> AsyncIterator[GetUser]: 74 | async with self._session_factory() as session: 75 | interactor = GetUser( 76 | user_repository=get_user_repository(session), 77 | id_provider=id_provider, 78 | ) 79 | 80 | yield interactor 81 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | 'setuptools==68.1.2', 4 | ] 5 | build-backend = 'setuptools.build_meta' 6 | 7 | [project] 8 | name = 'zametka' 9 | version = '0.0.1' 10 | description = 'zametka API' 11 | readme = 'README.md' 12 | requires-python = '>=3.11.1' 13 | dependencies = [ 14 | 'alembic==1.11.1', 15 | 'asyncpg==0.29.0', 16 | 'fastapi==0.111.0', 17 | 'SQLAlchemy==2.0.22', 18 | 'uvicorn==0.29.0', 19 | 'email-validator==2.1.1', 20 | 'argon2-cffi', 21 | 'aiosmtplib==2.0.2', 22 | 'Jinja2~=3.1.2', 23 | 'pydantic~=2.7.1', 24 | 'starlette~=0.37.2', 25 | 'aiohttp==3.9.1', 26 | 'Brotli==1.1.0', 27 | 'adaptix==3.0.0b5', 28 | 'aio-pika==9.3.1', 29 | 'dishka==1.1.1', 30 | 'PyJWT==2.8.0', 31 | ] 32 | 33 | [project.optional-dependencies] 34 | dev = [ 35 | 'pytest==8.2.0', 36 | 'pytest-asyncio==0.23.6', 37 | 'mypy==1.3.0', 38 | 'ruff==0.4.2', 39 | 'pre-commit==3.7.0', 40 | ] 41 | test = [ 42 | 'pytest==8.2.0', 43 | 'pytest-asyncio==0.23.6', 44 | ] 45 | lint = [ 46 | 'mypy==1.3.0', 47 | 'ruff==0.4.2' 48 | ] 49 | 50 | 51 | [tool.pytest.ini_options] 52 | testpaths = ["tests"] 53 | asyncio_mode = "auto" 54 | markers = [ 55 | 'access: tests related to access context.', 56 | 'notes: tests related to notes context.', 57 | 'domain: domain tests', 58 | 'application: application tests', 59 | ] 60 | filterwarnings = "ignore::DeprecationWarning" 61 | 62 | [project.scripts] 63 | zametka = "zametka.main.cli:main" 64 | 65 | [tool.setuptools] 66 | package-dir = {"" = "src"} 67 | 68 | [tool.mypy] 69 | strict = true 70 | warn_unreachable = true 71 | show_column_numbers = true 72 | show_error_context = true 73 | check_untyped_defs = true 74 | ignore_missing_imports = false 75 | 76 | [tool.ruff] 77 | line-length = 88 78 | exclude = [ 79 | "notes" 80 | ] 81 | 82 | [tool.ruff.lint] 83 | select = ['ALL'] 84 | 85 | ignore = [ 86 | # Rules that should be turned on in the near future 87 | 'N818', 88 | 'B904', 89 | 'FIX002', 90 | 'RUF012', 91 | 92 | # Rules emitting false alerts 93 | 'N804', 94 | 'B008', 95 | 'BLE001', 96 | 'RUF009', 97 | 'RUF001', 98 | 99 | # Rules that are not applicable in the project for now 100 | 'D', 101 | 'TID252', 102 | 'D104', 103 | 'ANN', 104 | 'SLF001', 105 | 'ARG', 106 | 'D100', 107 | 'PLR0913', 108 | 'TCH002', 109 | 'EXE002', 110 | 111 | # Strange and obscure rules that will never be turned on 112 | 'ANN101', 113 | 'FA100', 114 | 'TRY003', 115 | 'TRY201', 116 | 'EM', 117 | 'PERF203', 118 | 'TCH001', 119 | 'TD002', 120 | 'PTH201', 121 | 'RSE102', 122 | 'RET504', 123 | 'FBT001', 124 | 'TD003', 125 | 'B024', 126 | 'ISC001', 127 | 'B027', 128 | ] 129 | 130 | [tool.ruff.lint.per-file-ignores] 131 | "__init__.py" = ['F401'] 132 | 133 | "test_*" = ['S101', 'PLR2004', 'PT023', 'PT001', 'PT006'] 134 | "conftest.py" = ['PT023', 'PT001', 'PT006'] 135 | "cli.py" = ["T201"] 136 | 137 | [[project.authors]] 138 | name = 'lubaskinc0de' 139 | email = 'lubaskincorporation@gmail.com' 140 | -------------------------------------------------------------------------------- /src/zametka/access_service/domain/entities/user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Union 3 | 4 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 5 | from zametka.access_service.domain.entities.confirmation_token import ( 6 | UserConfirmationToken, 7 | ) 8 | from zametka.access_service.domain.exceptions.confirmation_token import ( 9 | ConfirmationTokenAlreadyUsedError, 10 | CorruptedConfirmationTokenError, 11 | ) 12 | from zametka.access_service.domain.exceptions.password_hasher import ( 13 | PasswordMismatchError, 14 | ) 15 | from zametka.access_service.domain.exceptions.user import ( 16 | InvalidCredentialsError, 17 | UserIsNotActiveError, 18 | ) 19 | from zametka.access_service.domain.value_objects.user_email import UserEmail 20 | from zametka.access_service.domain.value_objects.user_hashed_password import ( 21 | UserHashedPassword, 22 | ) 23 | from zametka.access_service.domain.value_objects.user_id import UserId 24 | from zametka.access_service.domain.value_objects.user_raw_password import ( 25 | UserRawPassword, 26 | ) 27 | 28 | 29 | @dataclass 30 | class User: 31 | user_id: UserId 32 | email: UserEmail 33 | hashed_password: UserHashedPassword 34 | is_active: bool = False 35 | 36 | @classmethod 37 | def create_with_raw_password( 38 | cls, 39 | user_id: UserId, 40 | email: UserEmail, 41 | raw_password: UserRawPassword, 42 | password_hasher: PasswordHasher, 43 | ) -> "User": 44 | hashed_password = password_hasher.hash_password(raw_password) 45 | return cls(user_id, email, hashed_password) 46 | 47 | def ensure_is_active(self) -> None: 48 | if not self.is_active: 49 | raise UserIsNotActiveError 50 | 51 | def authenticate( 52 | self, 53 | raw_password: UserRawPassword, 54 | password_hasher: PasswordHasher, 55 | ) -> None: 56 | try: 57 | password_hasher.verify_password(raw_password, self.hashed_password) 58 | except PasswordMismatchError as exc: 59 | raise InvalidCredentialsError from exc 60 | 61 | def _activate(self) -> None: 62 | self.is_active = True 63 | 64 | def activate(self, token: UserConfirmationToken) -> None: 65 | token.verify() 66 | 67 | if self.is_active: 68 | raise ConfirmationTokenAlreadyUsedError 69 | if token.uid != self.user_id: 70 | raise CorruptedConfirmationTokenError 71 | 72 | self._activate() 73 | 74 | def __hash__(self) -> int: 75 | return hash(self.user_id) 76 | 77 | def __eq__(self, other: Union[object, "User"]) -> bool: 78 | if not isinstance(other, User): 79 | return False 80 | 81 | return self.user_id == other.user_id 82 | 83 | def __repr__(self) -> str: 84 | return ( 85 | f"{self.__class__.__qualname__} object(id={self.user_id}, is_active" 86 | f"={bool(self)})" 87 | ) 88 | 89 | def __str__(self) -> str: 90 | return f"{self.__class__.__qualname__} <{self.user_id}>" 91 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/alembic/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = %(here)s/migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 10 | 11 | # sys.path path, will be prepended to sys.path if present. 12 | # defaults to the current working directory. 13 | prepend_sys_path = . 14 | 15 | # timezone to use when rendering the date within the migration file 16 | # as well as the filename. 17 | # If specified, requires the python-dateutil library that can be 18 | # installed by adding `alembic[tz]` to the pip requirements 19 | # string value is passed to dateutil.tz.gettz() 20 | # leave blank for localtime 21 | # timezone = 22 | 23 | # max length of characters to apply to the 24 | # "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to migrations/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from note.py.mako 59 | # output_encoding = utf-8 60 | 61 | # Logging configuration 62 | [loggers] 63 | keys = root,sqlalchemy,alembic 64 | 65 | [handlers] 66 | keys = console 67 | 68 | [formatters] 69 | keys = generic 70 | 71 | [logger_root] 72 | level = WARN 73 | handlers = console 74 | qualname = 75 | 76 | [logger_sqlalchemy] 77 | level = WARN 78 | handlers = 79 | qualname = sqlalchemy.engine 80 | 81 | [logger_alembic] 82 | level = INFO 83 | handlers = 84 | qualname = alembic 85 | 86 | [handler_console] 87 | class = StreamHandler 88 | args = (sys.stderr,) 89 | level = NOTSET 90 | formatter = generic 91 | 92 | [formatter_generic] 93 | format = %(levelname)-5.5s [%(name)s] %(message)s 94 | datefmt = %H:%M:%S 95 | -------------------------------------------------------------------------------- /src/zametka/access_service/infrastructure/persistence/alembic/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = %(here)s/migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 10 | 11 | # sys.path path, will be prepended to sys.path if present. 12 | # defaults to the current working directory. 13 | prepend_sys_path = . 14 | 15 | # timezone to use when rendering the date within the migration file 16 | # as well as the filename. 17 | # If specified, requires the python-dateutil library that can be 18 | # installed by adding `alembic[tz]` to the pip requirements 19 | # string value is passed to dateutil.tz.gettz() 20 | # leave blank for localtime 21 | # timezone = 22 | 23 | # max length of characters to apply to the 24 | # "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to migrations/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from note.py.mako 59 | # output_encoding = utf-8 60 | 61 | # Logging configuration 62 | [loggers] 63 | keys = root,sqlalchemy,alembic 64 | 65 | [handlers] 66 | keys = console 67 | 68 | [formatters] 69 | keys = generic 70 | 71 | [logger_root] 72 | level = WARN 73 | handlers = console 74 | qualname = 75 | 76 | [logger_sqlalchemy] 77 | level = WARN 78 | handlers = 79 | qualname = sqlalchemy.engine 80 | 81 | [logger_alembic] 82 | level = INFO 83 | handlers = 84 | qualname = alembic 85 | 86 | [handler_console] 87 | class = StreamHandler 88 | args = (sys.stderr,) 89 | level = NOTSET 90 | formatter = generic 91 | 92 | [formatter_generic] 93 | format = %(levelname)-5.5s [%(name)s] %(message)s 94 | datefmt = %H:%M:%S 95 | -------------------------------------------------------------------------------- /src/zametka/notes/infrastructure/db/alembic/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy import pool 6 | from sqlalchemy.engine import Connection 7 | from sqlalchemy.ext.asyncio import async_engine_from_config 8 | 9 | from zametka.notes.infrastructure.config_loader import load_alembic_settings 10 | from zametka.notes.infrastructure.db.models import Base 11 | 12 | # this is the Alembic Config object, which provides 13 | # access to the values within the .ini file in use. 14 | config = context.config 15 | 16 | # Interpret the config file for Python logging. 17 | # This line sets up loggers basically. 18 | if config.config_file_name is not None: 19 | fileConfig(config.config_file_name) 20 | 21 | # add your model's MetaData object here 22 | # for 'autogenerate' support 23 | # from myapp import mymodel 24 | # target_metadata = mymodel.Base.metadata 25 | 26 | target_metadata = Base.metadata 27 | 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def get_url() -> str: 36 | settings = load_alembic_settings() 37 | 38 | return settings.get_connection_url() 39 | 40 | 41 | def run_migrations_offline() -> None: 42 | """Run migrations in 'offline' mode. 43 | 44 | This configures the context with just a URL 45 | and not an Engine, though an Engine is acceptable 46 | here as well. By skipping the Engine creation 47 | we don't even need a DBAPI to be available. 48 | 49 | Calls to context.execute() here emit the given string to the 50 | script output. 51 | 52 | """ 53 | url = get_url() 54 | context.configure( 55 | url=url, 56 | target_metadata=target_metadata, 57 | literal_binds=True, 58 | dialect_opts={"paramstyle": "named"}, 59 | ) 60 | 61 | with context.begin_transaction(): 62 | context.run_migrations() 63 | 64 | 65 | def do_run_migrations(connection: Connection) -> None: 66 | context.configure(connection=connection, target_metadata=target_metadata) 67 | 68 | with context.begin_transaction(): 69 | context.run_migrations() 70 | 71 | 72 | async def run_async_migrations() -> None: 73 | """ 74 | In this scenario we need to create an Engine 75 | and associate a connection with the context. 76 | """ 77 | 78 | configuration = config.get_section(config.config_ini_section) 79 | configuration["sqlalchemy.url"] = get_url() # type:ignore 80 | 81 | connectable = async_engine_from_config( 82 | configuration, # type:ignore 83 | prefix="sqlalchemy.", 84 | poolclass=pool.NullPool, 85 | ) 86 | 87 | async with connectable.connect() as connection: 88 | await connection.run_sync(do_run_migrations) 89 | 90 | await connectable.dispose() 91 | 92 | 93 | def run_migrations_online() -> None: 94 | """Run migrations in 'online' mode.""" 95 | 96 | asyncio.run(run_async_migrations()) 97 | 98 | 99 | if context.is_offline_mode(): 100 | run_migrations_offline() 101 | else: 102 | run_migrations_online() 103 | -------------------------------------------------------------------------------- /tests/unit/access_service/domain/entities/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 3 | from zametka.access_service.domain.entities.confirmation_token import ( 4 | UserConfirmationToken, 5 | ) 6 | from zametka.access_service.domain.entities.user import User 7 | from zametka.access_service.domain.exceptions.confirmation_token import ( 8 | ConfirmationTokenAlreadyUsedError, 9 | ConfirmationTokenIsExpiredError, 10 | CorruptedConfirmationTokenError, 11 | ) 12 | from zametka.access_service.domain.exceptions.user import ( 13 | InvalidUserEmailError, 14 | UserIsNotActiveError, 15 | WeakPasswordError, 16 | ) 17 | from zametka.access_service.domain.value_objects.user_email import UserEmail 18 | from zametka.access_service.domain.value_objects.user_raw_password import ( 19 | UserRawPassword, 20 | ) 21 | 22 | 23 | @pytest.mark.access 24 | @pytest.mark.domain 25 | def test_create_user( 26 | user: User, 27 | password_hasher: PasswordHasher, 28 | user_password: UserRawPassword, 29 | ): 30 | user.authenticate(user_password, password_hasher) 31 | 32 | with pytest.raises(UserIsNotActiveError): 33 | user.ensure_is_active() 34 | 35 | assert user.hashed_password.to_raw() != user_password.to_raw() 36 | 37 | 38 | @pytest.mark.access 39 | @pytest.mark.domain 40 | def test_activate_user(user: User, confirmation_token: UserConfirmationToken): 41 | user.activate(confirmation_token) 42 | user.ensure_is_active() 43 | 44 | 45 | @pytest.mark.access 46 | @pytest.mark.domain 47 | @pytest.mark.parametrize( 48 | ["exc_class", "fixture_name"], 49 | [ 50 | (CorruptedConfirmationTokenError, "fake_confirmation_token"), 51 | (ConfirmationTokenIsExpiredError, "expired_confirmation_token"), 52 | ], 53 | ) 54 | def test_activate_user_bad_token(exc_class, fixture_name, user: User, request): 55 | token = request.getfixturevalue(fixture_name) 56 | with pytest.raises(exc_class): 57 | user.activate(token) 58 | 59 | 60 | @pytest.mark.access 61 | @pytest.mark.domain 62 | def test_activate_user_twice(user: User, confirmation_token: UserConfirmationToken): 63 | user.activate(confirmation_token) 64 | 65 | with pytest.raises(ConfirmationTokenAlreadyUsedError): 66 | user.activate(confirmation_token) 67 | 68 | 69 | @pytest.mark.access 70 | @pytest.mark.domain 71 | @pytest.mark.parametrize( 72 | "pwd", 73 | [ 74 | "qwerty", 75 | "qwertyA", 76 | "qwertyA1", 77 | ], 78 | ) 79 | def test_create_user_bad_password(pwd): 80 | with pytest.raises(WeakPasswordError): 81 | UserRawPassword(pwd) 82 | 83 | 84 | @pytest.mark.access 85 | @pytest.mark.domain 86 | @pytest.mark.parametrize( 87 | "email", 88 | [ 89 | "abc", 90 | "a" * 120, 91 | "myawesomeemail@gmail", 92 | "............@gmail.com", 93 | "myemailgmail.com", 94 | "my email@gmail.com", 95 | " ", 96 | 12345, 97 | ], 98 | ) 99 | def test_create_user_bad_email(email): 100 | with pytest.raises(InvalidUserEmailError): 101 | UserEmail(str(email)) 102 | -------------------------------------------------------------------------------- /src/zametka/access_service/application/create_user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from datetime import UTC, datetime 4 | from uuid import uuid4 5 | 6 | from zametka.access_service.application.common.interactor import Interactor 7 | from zametka.access_service.application.common.token_sender import TokenSender 8 | from zametka.access_service.application.common.uow import UoW 9 | from zametka.access_service.application.common.user_gateway import UserSaver 10 | from zametka.access_service.application.dto import ( 11 | UserConfirmationTokenDTO, 12 | UserDTO, 13 | ) 14 | from zametka.access_service.domain.common.entities.timed_user_token import ( 15 | TimedTokenMetadata, 16 | ) 17 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 18 | from zametka.access_service.domain.common.value_objects.timed_token_id import ( 19 | TimedTokenId, 20 | ) 21 | from zametka.access_service.domain.entities.config import ( 22 | UserConfirmationTokenConfig, 23 | ) 24 | from zametka.access_service.domain.entities.confirmation_token import ( 25 | UserConfirmationToken, 26 | ) 27 | from zametka.access_service.domain.entities.user import User 28 | from zametka.access_service.domain.value_objects.expires_in import ExpiresIn 29 | from zametka.access_service.domain.value_objects.user_email import UserEmail 30 | from zametka.access_service.domain.value_objects.user_id import UserId 31 | from zametka.access_service.domain.value_objects.user_raw_password import ( 32 | UserRawPassword, 33 | ) 34 | 35 | 36 | @dataclass(frozen=True) 37 | class CreateUserInputDTO: 38 | email: str 39 | password: str 40 | 41 | 42 | class CreateUser(Interactor[CreateUserInputDTO, UserDTO]): 43 | def __init__( 44 | self, 45 | user_gateway: UserSaver, 46 | token_sender: TokenSender, 47 | uow: UoW, 48 | config: UserConfirmationTokenConfig, 49 | password_hasher: PasswordHasher, 50 | ): 51 | self.uow = uow 52 | self.token_sender = token_sender 53 | self.user_gateway = user_gateway 54 | self.config = config 55 | self.ph = password_hasher 56 | 57 | async def __call__(self, data: CreateUserInputDTO) -> UserDTO: 58 | email = UserEmail(data.email) 59 | raw_password = UserRawPassword(data.password) 60 | user_id = UserId(value=uuid4()) 61 | 62 | user = User.create_with_raw_password( 63 | user_id, 64 | email, 65 | raw_password, 66 | self.ph, 67 | ) 68 | 69 | user_dto = await self.user_gateway.save(user) 70 | await self.uow.commit() 71 | 72 | now = datetime.now(tz=UTC) 73 | expires_in = ExpiresIn(now + self.config.expires_after) 74 | metadata = TimedTokenMetadata(uid=user.user_id, expires_in=expires_in) 75 | 76 | token_id = TimedTokenId(uuid4()) 77 | token = UserConfirmationToken(metadata, token_id) 78 | token_dto = UserConfirmationTokenDTO( 79 | uid=token.uid.to_raw(), 80 | expires_in=token.expires_in.to_raw(), 81 | token_id=token_id.to_raw(), 82 | ) 83 | 84 | await self.token_sender.send(token_dto, user) 85 | 86 | logging.info("Uid=%s created.", str(user_id.to_raw())) 87 | 88 | return user_dto 89 | -------------------------------------------------------------------------------- /src/zametka/notes/presentation/web_api/endpoints/note.py: -------------------------------------------------------------------------------- 1 | 2 | from fastapi import APIRouter, Depends 3 | 4 | from zametka.notes.application.common.id_provider import IdProvider 5 | from zametka.notes.application.note.dto import ( 6 | CreateNoteInputDTO, 7 | DBNoteDTO, 8 | DeleteNoteInputDTO, 9 | ListNotesDTO, 10 | ListNotesInputDTO, 11 | ReadNoteInputDTO, 12 | UpdateNoteInputDTO, 13 | ) 14 | from zametka.notes.presentation.interactor_factory import InteractorFactory 15 | from zametka.notes.presentation.web_api.schemas.note import NoteSchema 16 | 17 | router = APIRouter( 18 | prefix="/notes", 19 | tags=["notes"], 20 | responses={404: {"description": "Not found"}}, 21 | ) 22 | 23 | 24 | @router.post("/") 25 | async def create( 26 | note: NoteSchema, 27 | ioc: InteractorFactory = Depends(), 28 | id_provider: IdProvider = Depends(), 29 | ) -> DBNoteDTO: 30 | async with ioc.pick_note_interactor(id_provider, lambda i: i.create) as interactor: 31 | response = await interactor( 32 | CreateNoteInputDTO( 33 | text=note.text, 34 | title=note.title, 35 | ), 36 | ) 37 | 38 | return response 39 | 40 | 41 | @router.get("/{note_id}") 42 | async def read( 43 | note_id: int, 44 | ioc: InteractorFactory = Depends(), 45 | id_provider: IdProvider = Depends(), 46 | ) -> DBNoteDTO: 47 | async with ioc.pick_note_interactor(id_provider, lambda i: i.read) as interactor: 48 | response = await interactor( 49 | ReadNoteInputDTO( 50 | note_id=note_id, 51 | ), 52 | ) 53 | 54 | return response 55 | 56 | 57 | @router.put("/{note_id}") 58 | async def update( 59 | new_note: NoteSchema, 60 | note_id: int, 61 | ioc: InteractorFactory = Depends(), 62 | id_provider: IdProvider = Depends(), 63 | ) -> DBNoteDTO: 64 | async with ioc.pick_note_interactor(id_provider, lambda i: i.update) as interactor: 65 | response = await interactor( 66 | UpdateNoteInputDTO( 67 | note_id=note_id, 68 | title=new_note.title, 69 | text=new_note.text, 70 | ), 71 | ) 72 | 73 | return response 74 | 75 | 76 | @router.get("/") 77 | async def list_notes( 78 | limit: int, 79 | offset: int, 80 | search: str | None = None, 81 | ioc: InteractorFactory = Depends(), 82 | id_provider: IdProvider = Depends(), 83 | ) -> ListNotesDTO: 84 | if search and len(search) > 50: 85 | raise ValueError("Слишком длинный поисковый запрос!") 86 | 87 | async with ioc.pick_note_interactor(id_provider, lambda i: i.list) as interactor: 88 | response = await interactor( 89 | ListNotesInputDTO( 90 | limit=limit, 91 | offset=offset, 92 | search=search, 93 | ), 94 | ) 95 | 96 | return response 97 | 98 | 99 | @router.delete("/{note_id}") 100 | async def delete( 101 | note_id: int, 102 | ioc: InteractorFactory = Depends(), 103 | id_provider: IdProvider = Depends(), 104 | ) -> None: 105 | async with ioc.pick_note_interactor(id_provider, lambda i: i.delete) as interactor: 106 | await interactor( 107 | DeleteNoteInputDTO( 108 | note_id=note_id, 109 | ), 110 | ) 111 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | .ruff_cache/ 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Secrets 124 | .env.access_service 125 | .env.notes 126 | .env 127 | 128 | # Environments 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | .idea/ 166 | -------------------------------------------------------------------------------- /src/zametka/access_service/presentation/http/auth/token_auth.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from fastapi import Request, Response 4 | from starlette.datastructures import Headers 5 | 6 | from zametka.access_service.application.dto import AccessTokenDTO 7 | from zametka.access_service.domain.common.entities.timed_user_token import ( 8 | TimedTokenMetadata, 9 | ) 10 | from zametka.access_service.domain.common.value_objects.timed_token_id import ( 11 | TimedTokenId, 12 | ) 13 | from zametka.access_service.domain.entities.access_token import AccessToken 14 | from zametka.access_service.domain.exceptions.access_token import ( 15 | UnauthorizedError, 16 | ) 17 | from zametka.access_service.domain.value_objects.expires_in import ExpiresIn 18 | from zametka.access_service.domain.value_objects.user_id import UserId 19 | from zametka.access_service.infrastructure.auth.access_token_processor import ( 20 | AccessTokenProcessor, 21 | ) 22 | from zametka.access_service.infrastructure.jwt.exceptions import JWTDecodeError 23 | from zametka.access_service.infrastructure.jwt.jwt_processor import JWTProcessor 24 | from zametka.access_service.presentation.http.auth.config import TokenAuthConfig 25 | from zametka.access_service.presentation.http.exceptions import ( 26 | CSRFCorruptedError, 27 | CSRFExpiredError, 28 | CSRFMismatchError, 29 | CSRFMissingError, 30 | ) 31 | 32 | 33 | class TokenAuth: 34 | def __init__( 35 | self, 36 | req: Request, 37 | token_processor: AccessTokenProcessor, 38 | csrf_processor: JWTProcessor, 39 | config: TokenAuthConfig, 40 | ): 41 | self.req = req 42 | self.token_processor = token_processor 43 | self.config = config 44 | self.csrf_processor = csrf_processor 45 | 46 | def _get_csrf_session(self, cookies: dict[str, str], headers: Headers) -> UUID: 47 | csrf_key = self.config.csrf_cookie_key 48 | csrf_cookie = cookies.get(csrf_key) 49 | csrf_header = headers.get(self.config.csrf_headers_key) 50 | 51 | if not csrf_cookie or not csrf_header: 52 | raise CSRFMissingError from UnauthorizedError 53 | 54 | if not csrf_cookie == csrf_header: # double submit (see https://clck.ru/3AqsjZ) 55 | raise CSRFMismatchError from UnauthorizedError 56 | 57 | try: 58 | csrf_session_id = UUID(self.csrf_processor.decode(csrf_cookie)["sub"]) 59 | except (KeyError, ValueError, JWTDecodeError) as exc: 60 | raise CSRFCorruptedError from exc 61 | 62 | return csrf_session_id 63 | 64 | def get_access_token(self) -> AccessToken: 65 | unsafe_http_methods = {"POST", "PUT", "DELETE"} 66 | 67 | cookies = self.req.cookies 68 | headers = self.req.headers 69 | token_key = self.config.token_cookie_key 70 | cookies_token = cookies.get(token_key) 71 | is_unsafe_request = self.req.method in unsafe_http_methods 72 | 73 | if not cookies_token: 74 | raise UnauthorizedError 75 | 76 | csrf_session_id = None 77 | if is_unsafe_request: 78 | csrf_session_id = self._get_csrf_session(cookies, headers) 79 | 80 | token = self.token_processor.decode(cookies_token) 81 | metadata = TimedTokenMetadata( 82 | uid=UserId(token.uid), 83 | expires_in=ExpiresIn(token.expires_in), 84 | ) 85 | 86 | access_token = AccessToken(metadata, token_id=TimedTokenId(token.token_id)) 87 | 88 | if csrf_session_id and csrf_session_id != access_token.token_id: 89 | raise CSRFExpiredError 90 | 91 | return access_token 92 | 93 | def set_session(self, token: AccessTokenDTO, response: Response) -> Response: 94 | jwt_token = self.token_processor.encode(token) 95 | csrf_token = self.csrf_processor.encode({"sub": token.uid}) 96 | 97 | response.set_cookie(self.config.token_cookie_key, jwt_token, httponly=True) 98 | response.set_cookie(self.config.csrf_cookie_key, csrf_token, httponly=False) 99 | 100 | return response 101 | -------------------------------------------------------------------------------- /src/zametka/access_service/bootstrap/conf.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import tomllib 4 | from dataclasses import dataclass 5 | from datetime import timedelta 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | from zametka.access_service.domain.entities.config import ( 10 | AccessTokenConfig, 11 | UserConfirmationTokenConfig, 12 | ) 13 | from zametka.access_service.infrastructure.email.config import ( 14 | ConfirmationEmailConfig, 15 | SMTPConfig, 16 | ) 17 | from zametka.access_service.infrastructure.jwt.config import JWTConfig 18 | from zametka.access_service.infrastructure.message_broker.config import ( 19 | AMQPConfig, 20 | ) 21 | from zametka.access_service.infrastructure.persistence.config import DBConfig 22 | from zametka.access_service.presentation.http.auth.config import TokenAuthConfig 23 | 24 | 25 | def load_config_by_path(config_path: Path) -> dict[str, Any]: 26 | with config_path.open("rb") as cfg: 27 | return tomllib.load(cfg) 28 | 29 | 30 | @dataclass 31 | class AllConfig: 32 | db: DBConfig 33 | amqp: AMQPConfig 34 | smtp: SMTPConfig 35 | email: ConfirmationEmailConfig 36 | jwt: JWTConfig 37 | token_auth: TokenAuthConfig 38 | access_token: AccessTokenConfig 39 | confirmation_token: UserConfirmationTokenConfig 40 | 41 | 42 | def load_all_config() -> AllConfig: 43 | db = DBConfig( 44 | db_name=os.environ["ACCESS_POSTGRES_DB"], 45 | host=os.environ["DB_HOST"], 46 | password=os.environ["POSTGRES_PASSWORD"], 47 | user=os.environ["POSTGRES_USER"], 48 | ) 49 | 50 | amqp = AMQPConfig( 51 | host=os.environ.get("AMQP_HOST", "localhost"), 52 | port=int(os.environ.get("AMQP_PORT", 5672)), 53 | login=os.environ.get("AMQP_LOGIN", "guest"), 54 | password=os.environ.get("AMQP_PASSWORD", "guest"), 55 | ) 56 | 57 | cfg_path = os.environ["CONFIG_PATH"] 58 | cfg = load_config_by_path(Path(cfg_path)) 59 | 60 | try: 61 | email_subject = cfg["email"]["activation-mail-subject"] 62 | email_url = cfg["email"]["activation-url-template"] 63 | email_template_path = cfg["email"]["activation-email-template-path"] 64 | email_template_name = cfg["email"]["activation-email-template-name"] 65 | 66 | smtp_use_tls = cfg["smtp"]["use-tls"] 67 | smtp_host = cfg["smtp"]["host"] 68 | smtp_port = cfg["smtp"]["port"] 69 | 70 | jwt_algorithm = cfg["security"]["algorithm"] 71 | jwt_token_key = cfg["auth"]["auth-token-key"] 72 | 73 | access_token_expires_after = cfg["security"]["access-token-expires-minutes"] 74 | confirmation_token_expires_after = cfg["security"][ 75 | "confirmation-token-expires-minutes" 76 | ] 77 | except KeyError: 78 | logging.fatal("On startup: Error reading config %s", cfg_path) 79 | raise 80 | 81 | email = ConfirmationEmailConfig( 82 | email_from=os.environ["MAIL_FROM"], 83 | subject=email_subject, 84 | confirmation_link=email_url, 85 | template_path=email_template_path, 86 | template_name=email_template_name, 87 | ) 88 | 89 | smtp = SMTPConfig( 90 | password=os.environ["MAIL_PASSWORD"], 91 | user=os.environ["MAIL_USERNAME"], 92 | host=smtp_host, 93 | port=smtp_port, 94 | use_tls=smtp_use_tls, 95 | ) 96 | 97 | jwt = JWTConfig(algorithm=jwt_algorithm, key=os.environ["JWT_KEY"]) 98 | 99 | token_auth = TokenAuthConfig(token_key=jwt_token_key) 100 | 101 | access_token = AccessTokenConfig( 102 | expires_after=timedelta(minutes=access_token_expires_after), 103 | ) 104 | 105 | confirmation_token = UserConfirmationTokenConfig( 106 | expires_after=timedelta(minutes=confirmation_token_expires_after), 107 | ) 108 | 109 | logging.info("Config loaded.") 110 | 111 | return AllConfig( 112 | db=db, 113 | amqp=amqp, 114 | smtp=smtp, 115 | email=email, 116 | jwt=jwt, 117 | token_auth=token_auth, 118 | access_token=access_token, 119 | confirmation_token=confirmation_token, 120 | ) 121 | -------------------------------------------------------------------------------- /tests/unit/access_service/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime, timedelta 2 | from uuid import uuid4 3 | 4 | import argon2 5 | import pytest 6 | from zametka.access_service.domain.common.entities.timed_user_token import ( 7 | TimedTokenMetadata, 8 | ) 9 | from zametka.access_service.domain.common.services.password_hasher import PasswordHasher 10 | from zametka.access_service.domain.common.value_objects.timed_token_id import ( 11 | TimedTokenId, 12 | ) 13 | from zametka.access_service.domain.entities.config import ( 14 | AccessTokenConfig, 15 | UserConfirmationTokenConfig, 16 | ) 17 | from zametka.access_service.domain.entities.confirmation_token import ( 18 | UserConfirmationToken, 19 | ) 20 | from zametka.access_service.domain.entities.user import User 21 | from zametka.access_service.domain.value_objects.expires_in import ExpiresIn 22 | from zametka.access_service.domain.value_objects.user_email import UserEmail 23 | from zametka.access_service.domain.value_objects.user_id import UserId 24 | from zametka.access_service.domain.value_objects.user_raw_password import ( 25 | UserRawPassword, 26 | ) 27 | from zametka.access_service.infrastructure.auth.password_hasher import ( 28 | ArgonPasswordHasher, 29 | ) 30 | 31 | EXPIRES_AFTER_MINUTES = 5 32 | 33 | 34 | @pytest.fixture 35 | def access_token_config() -> AccessTokenConfig: 36 | return AccessTokenConfig(expires_after=timedelta(days=30)) 37 | 38 | 39 | @pytest.fixture 40 | def confirmation_token_config() -> UserConfirmationTokenConfig: 41 | return UserConfirmationTokenConfig( 42 | expires_after=timedelta(minutes=15), 43 | ) 44 | 45 | 46 | @pytest.fixture 47 | def user_email() -> UserEmail: 48 | return UserEmail("lubaskincorporation@gmail.com") 49 | 50 | 51 | @pytest.fixture 52 | def user_password() -> UserRawPassword: 53 | return UserRawPassword("someSuper123#Password") 54 | 55 | 56 | @pytest.fixture 57 | def user_id() -> UserId: 58 | return UserId(uuid4()) 59 | 60 | 61 | @pytest.fixture 62 | def user_fake_id() -> UserId: 63 | return UserId(uuid4()) 64 | 65 | 66 | @pytest.fixture 67 | def password_hasher() -> PasswordHasher: 68 | return ArgonPasswordHasher(argon2.PasswordHasher()) 69 | 70 | 71 | @pytest.fixture 72 | def user( 73 | password_hasher: PasswordHasher, 74 | user_email: UserEmail, 75 | user_password: UserRawPassword, 76 | user_id: UserId, 77 | ) -> User: 78 | return User.create_with_raw_password( 79 | email=user_email, 80 | raw_password=user_password, 81 | user_id=user_id, 82 | password_hasher=password_hasher, 83 | ) 84 | 85 | 86 | @pytest.fixture 87 | def token_expires_in() -> ExpiresIn: 88 | expires_in = ExpiresIn( 89 | datetime.now(tz=UTC) + timedelta(minutes=EXPIRES_AFTER_MINUTES), 90 | ) 91 | return expires_in 92 | 93 | 94 | @pytest.fixture 95 | def token_expired_in(token_expires_in: ExpiresIn) -> ExpiresIn: 96 | return ExpiresIn( 97 | token_expires_in.to_raw() - timedelta(minutes=EXPIRES_AFTER_MINUTES + 1), 98 | ) 99 | 100 | 101 | @pytest.fixture 102 | def confirmation_token( 103 | user: User, 104 | token_expires_in: ExpiresIn, 105 | ) -> UserConfirmationToken: 106 | metadata = TimedTokenMetadata( 107 | uid=user.user_id, 108 | expires_in=token_expires_in, 109 | ) 110 | token_id = TimedTokenId(uuid4()) 111 | token = UserConfirmationToken(metadata, token_id) 112 | return token 113 | 114 | 115 | @pytest.fixture 116 | def fake_confirmation_token( 117 | token_expires_in: ExpiresIn, 118 | user_fake_id: UserId, 119 | ) -> UserConfirmationToken: 120 | metadata = TimedTokenMetadata( 121 | uid=user_fake_id, 122 | expires_in=token_expires_in, 123 | ) 124 | token_id = TimedTokenId(uuid4()) 125 | token = UserConfirmationToken(metadata, token_id) 126 | return token 127 | 128 | 129 | @pytest.fixture 130 | def expired_confirmation_token( 131 | user: User, 132 | confirmation_token_config: UserConfirmationTokenConfig, 133 | ): 134 | metadata = TimedTokenMetadata( 135 | uid=user.user_id, 136 | expires_in=ExpiresIn(datetime.now(tz=UTC) - timedelta(days=1)), 137 | ) 138 | token_id = TimedTokenId(uuid4()) 139 | token = UserConfirmationToken(metadata, token_id) 140 | return token 141 | --------------------------------------------------------------------------------