├── tests ├── __init__.py ├── api │ ├── __init__.py │ └── routes │ │ ├── __init__.py │ │ ├── test_health_check.py │ │ ├── test_tags.py │ │ ├── test_login.py │ │ ├── test_registaration.py │ │ ├── test_user.py │ │ ├── test_article.py │ │ └── test_profile.py ├── core │ ├── __init__.py │ └── utils │ │ ├── __init__.py │ │ └── test_errors.py ├── utils.py └── conftest.py ├── conduit ├── __init__.py ├── api │ ├── __init__.py │ ├── routes │ │ ├── __init__.py │ │ ├── health_check.py │ │ ├── tag.py │ │ ├── users.py │ │ ├── authentication.py │ │ ├── comment.py │ │ ├── profile.py │ │ └── article.py │ ├── schemas │ │ ├── __init__.py │ │ ├── requests │ │ │ ├── __init__.py │ │ │ ├── comment.py │ │ │ ├── article.py │ │ │ └── user.py │ │ └── responses │ │ │ ├── __init__.py │ │ │ ├── tag.py │ │ │ ├── profile.py │ │ │ ├── comment.py │ │ │ ├── article.py │ │ │ └── user.py │ ├── router.py │ └── middlewares.py ├── core │ ├── __init__.py │ ├── settings │ │ ├── __init__.py │ │ ├── production.py │ │ ├── development.py │ │ ├── test.py │ │ ├── app.py │ │ └── base.py │ ├── utils │ │ ├── __init__.py │ │ ├── date.py │ │ ├── errors.py │ │ └── slug.py │ ├── config.py │ ├── security.py │ ├── dependencies.py │ ├── logging.py │ ├── container.py │ └── exceptions.py ├── domain │ ├── __init__.py │ ├── dtos │ │ ├── __init__.py │ │ ├── auth_token.py │ │ ├── tag.py │ │ ├── profile.py │ │ ├── comment.py │ │ ├── user.py │ │ └── article.py │ ├── services │ │ ├── __init__.py │ │ ├── tag.py │ │ ├── auth_token.py │ │ ├── auth.py │ │ ├── comment.py │ │ ├── profile.py │ │ ├── user.py │ │ └── article.py │ ├── repositories │ │ ├── __init__.py │ │ ├── tag.py │ │ ├── article_tag.py │ │ ├── favorite.py │ │ ├── follower.py │ │ ├── comment.py │ │ ├── user.py │ │ └── article.py │ └── mapper.py ├── services │ ├── __init__.py │ ├── password.py │ ├── tag.py │ ├── auth_token.py │ ├── auth.py │ ├── user.py │ ├── comment.py │ ├── profile.py │ └── article.py ├── infrastructure │ ├── __init__.py │ ├── alembic │ │ ├── __init__.py │ │ ├── versions │ │ │ ├── __init__.py │ │ │ └── 666cc53a93be_add_tables.py │ │ ├── script.py.mako │ │ └── env.py │ ├── mappers │ │ ├── __init__.py │ │ ├── tag.py │ │ ├── comment.py │ │ ├── user.py │ │ └── article.py │ ├── repositories │ │ ├── __init__.py │ │ ├── tag.py │ │ ├── favorite.py │ │ ├── follower.py │ │ ├── article_tag.py │ │ ├── comment.py │ │ ├── user.py │ │ └── article.py │ └── models.py └── app.py ├── .python-version ├── .env.example ├── .github ├── assets │ └── logo.png └── workflows │ └── style.yaml ├── requirements-ci.txt ├── version.py ├── .gitignore ├── .dockerignore ├── Dockerfile ├── requirements.txt ├── postman └── run-api-tests.sh ├── pyproject.toml ├── docker-compose.yaml ├── .pre-commit-config.yaml ├── Makefile ├── setup.cfg ├── README.md └── alembic.ini /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12.0 2 | -------------------------------------------------------------------------------- /conduit/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/routes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/api/routes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/api/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/core/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/domain/dtos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SECRET_KEY=changeme 2 | -------------------------------------------------------------------------------- /conduit/domain/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/api/schemas/requests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/api/schemas/responses/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/domain/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/infrastructure/alembic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/infrastructure/mappers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/infrastructure/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conduit/infrastructure/alembic/versions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borys25ol/fastapi-realworld-backend/HEAD/.github/assets/logo.png -------------------------------------------------------------------------------- /requirements-ci.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | black==24.3.0 4 | flake8==7.0.0 5 | isort==5.13.2 6 | mypy==1.9.0 7 | pre-commit==3.7.0 8 | -------------------------------------------------------------------------------- /conduit/domain/dtos/auth_token.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(frozen=True) 5 | class TokenPayloadDTO: 6 | user_id: int 7 | username: str 8 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | __status__ = True 2 | __version__ = "0.1.0" 3 | __message__ = "Conduit Realworld API" 4 | 5 | response = {"success": __status__, "version": __version__, "message": __message__} 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ve 2 | .idea 3 | .DS_Store 4 | __pycache__ 5 | *.pyc 6 | .env 7 | .env.dev 8 | .env.test 9 | .env.local 10 | .vscode 11 | .coverage 12 | google_credentials.json 13 | ./test.py 14 | -------------------------------------------------------------------------------- /conduit/core/utils/date.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | def convert_datetime_to_realworld(dt: datetime.datetime) -> str: 5 | return dt.replace(tzinfo=datetime.UTC).isoformat().replace("+00:00", "Z") 6 | -------------------------------------------------------------------------------- /conduit/domain/dtos/tag.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from dataclasses import dataclass 3 | 4 | 5 | @dataclass(frozen=True) 6 | class TagDTO: 7 | id: int 8 | tag: str 9 | created_at: datetime.datetime 10 | -------------------------------------------------------------------------------- /conduit/api/routes/health_check.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from version import response 4 | 5 | router = APIRouter() 6 | 7 | 8 | @router.get("") 9 | async def health_check() -> dict: 10 | return response 11 | -------------------------------------------------------------------------------- /conduit/domain/dtos/profile.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class ProfileDTO: 6 | user_id: int 7 | username: str 8 | bio: str = "" 9 | image: str | None = None 10 | following: bool = False 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .ve 2 | .idea 3 | .DS_Store 4 | .git 5 | __pycache__ 6 | *.pyc 7 | *.pyo 8 | logs 9 | .coverage 10 | .env.example 11 | .pre-commit-config.yaml 12 | .python-version 13 | docker-compose.yaml 14 | Dockerfile 15 | pyproject.toml 16 | setup.cfg 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt version.py ./ 6 | 7 | RUN pip install -U pip 8 | RUN pip install -r requirements.txt 9 | 10 | ENV PYTHONPATH "${PYTHONPATH}:/conduit" 11 | 12 | COPY . ./ 13 | 14 | EXPOSE 8080 15 | -------------------------------------------------------------------------------- /conduit/domain/services/tag.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | from conduit.domain.dtos.tag import TagDTO 5 | 6 | 7 | class ITagService(abc.ABC): 8 | 9 | @abc.abstractmethod 10 | async def get_all_tags(self, session: Any) -> list[TagDTO]: ... 11 | -------------------------------------------------------------------------------- /conduit/core/settings/production.py: -------------------------------------------------------------------------------- 1 | from conduit.core.settings.app import AppSettings 2 | 3 | 4 | class ProdAppSettings(AppSettings): 5 | """ 6 | Production application settings. 7 | """ 8 | 9 | class Config(AppSettings.Config): 10 | env_file = ".env" 11 | -------------------------------------------------------------------------------- /conduit/domain/repositories/tag.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from conduit.domain.dtos.tag import TagDTO 6 | 7 | 8 | class ITagRepository(abc.ABC): 9 | 10 | @abc.abstractmethod 11 | async def list(self, session: AsyncSession) -> list[TagDTO]: ... 12 | -------------------------------------------------------------------------------- /conduit/api/schemas/responses/tag.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from conduit.domain.dtos.tag import TagDTO 4 | 5 | 6 | class TagsResponse(BaseModel): 7 | tags: list[str] 8 | 9 | @classmethod 10 | def from_dtos(cls, dtos: list[TagDTO]) -> "TagsResponse": 11 | return TagsResponse(tags=[dto.tag for dto in dtos]) 12 | -------------------------------------------------------------------------------- /tests/api/routes/test_health_check.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | 4 | 5 | @pytest.mark.anyio 6 | async def test_successful_health_check(test_client: AsyncClient) -> None: 7 | response = await test_client.get("/health-check") 8 | assert response.status_code == 200 9 | 10 | response = response.json() 11 | assert response["message"] == "Conduit Realworld API" 12 | -------------------------------------------------------------------------------- /conduit/domain/services/auth_token.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from conduit.domain.dtos.auth_token import TokenPayloadDTO 4 | from conduit.domain.dtos.user import UserDTO 5 | 6 | 7 | class IAuthTokenService(abc.ABC): 8 | 9 | @abc.abstractmethod 10 | def generate_jwt_token(self, user: UserDTO) -> str: ... 11 | 12 | @abc.abstractmethod 13 | def parse_jwt_token(self, token: str) -> TokenPayloadDTO: ... 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.13.3 2 | asyncpg==0.30.0 3 | fastapi==0.115.4 4 | greenlet==3.1.1 5 | httpx==0.27.2 6 | passlib[bcrypt]==1.7.4 7 | pydantic-settings==2.6.1 8 | pydantic[email]==2.9.2 9 | pyjwt==2.9.0 10 | pytest==8.3.3 11 | pytest-asyncio==0.24.0 12 | pytest-cov==6.0.0 13 | python-slugify==8.0.4 14 | SQLAlchemy==2.0.36 15 | SQLAlchemy-Utils==0.41.2 16 | starlette==0.41.2 17 | structlog==24.4.0 18 | uvicorn==0.32.0 19 | -------------------------------------------------------------------------------- /conduit/api/schemas/requests/comment.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from conduit.domain.dtos.comment import CreateCommentDTO 4 | 5 | 6 | class CreateCommentData(BaseModel): 7 | body: str = Field(..., min_length=1) 8 | 9 | 10 | class CreateCommentRequest(BaseModel): 11 | comment: CreateCommentData 12 | 13 | def to_dto(self) -> CreateCommentDTO: 14 | return CreateCommentDTO(body=self.comment.body) 15 | -------------------------------------------------------------------------------- /conduit/domain/mapper.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Generic, TypeVar 3 | 4 | M_ = TypeVar("M_") 5 | D_ = TypeVar("D_") 6 | 7 | 8 | class IModelMapper(ABC, Generic[M_, D_]): 9 | """Interface for model mapping.""" 10 | 11 | @staticmethod 12 | @abstractmethod 13 | def to_dto(model: M_) -> D_: ... 14 | 15 | @staticmethod 16 | @abstractmethod 17 | def from_dto(dto: D_) -> M_: ... 18 | -------------------------------------------------------------------------------- /conduit/domain/repositories/article_tag.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | from conduit.domain.dtos.tag import TagDTO 5 | 6 | 7 | class IArticleTagRepository(abc.ABC): 8 | """Article Tag repository interface.""" 9 | 10 | @abc.abstractmethod 11 | async def add_many( 12 | self, session: Any, article_id: int, tags: list[str] 13 | ) -> list[TagDTO]: ... 14 | 15 | @abc.abstractmethod 16 | async def list(self, session: Any, article_id: int) -> list[TagDTO]: ... 17 | -------------------------------------------------------------------------------- /conduit/api/routes/tag.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from conduit.api.schemas.responses.tag import TagsResponse 4 | from conduit.core.dependencies import DBSession, ITagService 5 | 6 | router = APIRouter() 7 | 8 | 9 | @router.get("", response_model=TagsResponse) 10 | async def get_all_tags(session: DBSession, tag_service: ITagService) -> TagsResponse: 11 | """ 12 | Return available all tags. 13 | """ 14 | tags = await tag_service.get_all_tags(session=session) 15 | return TagsResponse.from_dtos(dtos=tags) 16 | -------------------------------------------------------------------------------- /conduit/services/password.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 4 | 5 | 6 | def get_password_hash(password: str) -> str: 7 | """ 8 | Convert user password to hash string. 9 | """ 10 | return pwd_context.hash(secret=password) 11 | 12 | 13 | def verify_password(plain_password: str, hashed_password: str) -> bool: 14 | """ 15 | Check if the user password from request is valid. 16 | """ 17 | return pwd_context.verify(secret=plain_password, hash=hashed_password) 18 | -------------------------------------------------------------------------------- /postman/run-api-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | 4 | SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 5 | 6 | APIURL=${APIURL:-https://api.realworld.io/api} 7 | USERNAME=${USERNAME:-u`date +%s`} 8 | EMAIL=${EMAIL:-$USERNAME@mail.com} 9 | PASSWORD=${PASSWORD:-password} 10 | 11 | npx newman run $SCRIPTDIR/Conduit.postman_collection.json \ 12 | --delay-request 500 \ 13 | --global-var "APIURL=$APIURL" \ 14 | --global-var "USERNAME=$USERNAME" \ 15 | --global-var "EMAIL=$EMAIL" \ 16 | --global-var "PASSWORD=$PASSWORD" \ 17 | "$@" 18 | -------------------------------------------------------------------------------- /conduit/services/tag.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from conduit.domain.dtos.tag import TagDTO 4 | from conduit.domain.repositories.tag import ITagRepository 5 | from conduit.domain.services.tag import ITagService 6 | 7 | 8 | class TagService(ITagService): 9 | """Service to handle article tags logic.""" 10 | 11 | def __init__(self, tag_repo: ITagRepository): 12 | self._tag_repo = tag_repo 13 | 14 | async def get_all_tags(self, session: AsyncSession) -> list[TagDTO]: 15 | return await self._tag_repo.list(session=session) 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py312'] 4 | skip-magic-trailing-comma = true 5 | include = '\.pyi?$' 6 | exclude = ''' 7 | /( 8 | \.eggs 9 | | \.git 10 | | \.hg 11 | | \.mypy_cache 12 | | \.tox 13 | | \.ve 14 | | _build 15 | | build 16 | | dist 17 | )/ 18 | ''' 19 | 20 | [tool.isort] 21 | line_length = 88 22 | multi_line_output = 3 23 | include_trailing_comma = true 24 | force_grid_wrap = 0 25 | ensure_newline_before_comments = true 26 | use_parentheses = true 27 | skip_gitignore = true 28 | skip_glob = ['.ve', '.git'] 29 | -------------------------------------------------------------------------------- /conduit/domain/services/auth.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | from conduit.domain.dtos.user import ( 5 | CreatedUserDTO, 6 | CreateUserDTO, 7 | LoggedInUserDTO, 8 | LoginUserDTO, 9 | ) 10 | 11 | 12 | class IUserAuthService(abc.ABC): 13 | 14 | @abc.abstractmethod 15 | async def sign_up_user( 16 | self, session: Any, user_to_create: CreateUserDTO 17 | ) -> CreatedUserDTO: ... 18 | 19 | @abc.abstractmethod 20 | async def sign_in_user( 21 | self, session: Any, user_to_login: LoginUserDTO 22 | ) -> LoggedInUserDTO: ... 23 | -------------------------------------------------------------------------------- /conduit/infrastructure/mappers/tag.py: -------------------------------------------------------------------------------- 1 | from conduit.domain.dtos.tag import TagDTO 2 | from conduit.domain.mapper import IModelMapper 3 | from conduit.infrastructure.models import Tag 4 | 5 | 6 | class TagModelMapper(IModelMapper[Tag, TagDTO]): 7 | 8 | @staticmethod 9 | def to_dto(model: Tag) -> TagDTO: 10 | dto = TagDTO(id=model.id, tag=model.tag, created_at=model.created_at) 11 | return dto 12 | 13 | @staticmethod 14 | def from_dto(dto: TagDTO) -> Tag: 15 | model = Tag(tag=dto.tag) 16 | if hasattr(dto, "id"): 17 | model.id = dto.id 18 | return model 19 | -------------------------------------------------------------------------------- /conduit/core/settings/development.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pydantic import computed_field 4 | 5 | from conduit.core.settings.app import AppSettings 6 | 7 | 8 | class DevAppSettings(AppSettings): 9 | """ 10 | Development application settings. 11 | """ 12 | 13 | debug: bool = True 14 | 15 | title: str = "[DEV] Conduit API" 16 | 17 | logging_level: int = logging.DEBUG 18 | 19 | class Config(AppSettings.Config): 20 | env_file = ".env.dev" 21 | 22 | @computed_field # type: ignore 23 | @property 24 | def sqlalchemy_engine_props(self) -> dict: 25 | return dict(url=self.sql_db_uri, echo=True) 26 | -------------------------------------------------------------------------------- /conduit/domain/repositories/favorite.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | 5 | class IFavoriteRepository(abc.ABC): 6 | """Favorite articles repository interface.""" 7 | 8 | @abc.abstractmethod 9 | async def exists(self, session: Any, author_id: int, article_id: int) -> bool: ... 10 | 11 | @abc.abstractmethod 12 | async def count(self, session: Any, article_id: int) -> int: ... 13 | 14 | @abc.abstractmethod 15 | async def create(self, session: Any, article_id: int, user_id: int) -> None: ... 16 | 17 | @abc.abstractmethod 18 | async def delete(self, session: Any, article_id: int, user_id: int) -> None: ... 19 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | api: 5 | build: . 6 | container_name: conduit-api 7 | command: uvicorn conduit.app:app --host 0.0.0.0 --port 8080 8 | environment: 9 | POSTGRES_HOST: postgres 10 | env_file: 11 | - .env 12 | ports: 13 | - "8080:8080" 14 | volumes: 15 | - .:/conduit 16 | 17 | postgres: 18 | image: postgres:16 19 | container_name: conduit-postgres 20 | env_file: 21 | - .env 22 | ports: 23 | - ${POSTGRES_PORT}:5432 24 | healthcheck: 25 | test: ["CMD-SHELL", "pg_isready -d $POSTGRES_DB -U $POSTGRES_USER"] 26 | interval: 10s 27 | timeout: 10s 28 | retries: 5 29 | -------------------------------------------------------------------------------- /conduit/api/schemas/responses/profile.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from conduit.domain.dtos.profile import ProfileDTO 4 | 5 | 6 | class ProfileData(BaseModel): 7 | username: str 8 | bio: str | None 9 | image: str | None 10 | following: bool 11 | 12 | 13 | class ProfileResponse(BaseModel): 14 | profile: ProfileData 15 | 16 | @classmethod 17 | def from_dto(cls, dto: ProfileDTO) -> "ProfileResponse": 18 | return ProfileResponse( 19 | profile=ProfileData( 20 | username=dto.username, 21 | bio=dto.bio, 22 | image=dto.image, 23 | following=dto.following, 24 | ) 25 | ) 26 | -------------------------------------------------------------------------------- /.github/workflows/style.yaml: -------------------------------------------------------------------------------- 1 | name: Run code style check 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | branches: 10 | - dev 11 | 12 | workflow_dispatch: 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Initialize python 3.12 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: '3.12' 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements-ci.txt 30 | 31 | - name: Style checking 32 | run: make lint 33 | -------------------------------------------------------------------------------- /conduit/infrastructure/repositories/tag.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | from sqlalchemy.ext.asyncio import AsyncSession 3 | 4 | from conduit.domain.dtos.tag import TagDTO 5 | from conduit.domain.mapper import IModelMapper 6 | from conduit.domain.repositories.tag import ITagRepository 7 | from conduit.infrastructure.models import Tag 8 | 9 | 10 | class TagRepository(ITagRepository): 11 | """Repository for Tag model.""" 12 | 13 | def __init__(self, tag_mapper: IModelMapper[Tag, TagDTO]): 14 | self._tag_mapper = tag_mapper 15 | 16 | async def list(self, session: AsyncSession) -> list[TagDTO]: 17 | query = select(Tag) 18 | tags = await session.scalars(query) 19 | return [self._tag_mapper.to_dto(tag) for tag in tags] 20 | -------------------------------------------------------------------------------- /conduit/domain/repositories/follower.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | 5 | class IFollowerRepository(abc.ABC): 6 | """Follower repository interface.""" 7 | 8 | @abc.abstractmethod 9 | async def exists( 10 | self, session: Any, follower_id: int, following_id: int 11 | ) -> bool: ... 12 | 13 | @abc.abstractmethod 14 | async def list( 15 | self, session: Any, follower_id: int, following_ids: list[int] 16 | ) -> list[int]: ... 17 | 18 | @abc.abstractmethod 19 | async def create( 20 | self, session: Any, follower_id: int, following_id: int 21 | ) -> None: ... 22 | 23 | @abc.abstractmethod 24 | async def delete( 25 | self, session: Any, follower_id: int, following_id: int 26 | ) -> None: ... 27 | -------------------------------------------------------------------------------- /conduit/infrastructure/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /tests/api/routes/test_tags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | 4 | from conduit.domain.dtos.article import ArticleDTO 5 | 6 | 7 | @pytest.mark.anyio 8 | async def test_empty_list_when_no_tags_exist(test_client: AsyncClient) -> None: 9 | response = await test_client.get(url="/tags") 10 | assert response.json() == {"tags": []} 11 | 12 | 13 | @pytest.mark.anyio 14 | async def test_list_of_tags_when_article_with_tags_exists( 15 | authorized_test_client: AsyncClient, test_article: ArticleDTO 16 | ) -> None: 17 | response = await authorized_test_client.get(url="/tags") 18 | response_tags = response.json()["tags"] 19 | assert len(response_tags) == len(set(test_article.tags)) 20 | assert all(tag in test_article.tags for tag in response_tags) 21 | -------------------------------------------------------------------------------- /conduit/domain/dtos/comment.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from dataclasses import dataclass 3 | 4 | from conduit.domain.dtos.profile import ProfileDTO 5 | 6 | 7 | @dataclass(frozen=True) 8 | class CommentRecordDTO: 9 | id: int 10 | body: str 11 | author_id: int 12 | article_id: int 13 | created_at: datetime.datetime 14 | updated_at: datetime.datetime 15 | 16 | 17 | @dataclass(frozen=True) 18 | class CommentDTO: 19 | id: int 20 | body: str 21 | author: ProfileDTO 22 | created_at: datetime.datetime 23 | updated_at: datetime.datetime 24 | 25 | 26 | @dataclass(frozen=True) 27 | class CommentsListDTO: 28 | comments: list[CommentDTO] 29 | comments_count: int 30 | 31 | 32 | @dataclass(frozen=True) 33 | class CreateCommentDTO: 34 | body: str 35 | -------------------------------------------------------------------------------- /conduit/core/settings/test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pydantic import computed_field 4 | from sqlalchemy import NullPool 5 | 6 | from conduit.core.settings.app import AppSettings 7 | 8 | 9 | class TestAppSettings(AppSettings): 10 | """ 11 | Test application settings. 12 | """ 13 | 14 | debug: bool = True 15 | 16 | title: str = "[TEST] Conduit API" 17 | 18 | logging_level: int = logging.DEBUG 19 | 20 | class Config(AppSettings.Config): 21 | env_file = ".env.test" 22 | 23 | @computed_field # type: ignore 24 | @property 25 | def sqlalchemy_engine_props(self) -> dict: 26 | return dict( 27 | url=self.sql_db_uri, 28 | echo=False, 29 | poolclass=NullPool, 30 | isolation_level="AUTOCOMMIT", 31 | ) 32 | -------------------------------------------------------------------------------- /conduit/domain/services/comment.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | from conduit.domain.dtos.comment import CommentDTO, CommentsListDTO, CreateCommentDTO 5 | from conduit.domain.dtos.user import UserDTO 6 | 7 | 8 | class ICommentService(abc.ABC): 9 | 10 | @abc.abstractmethod 11 | async def create_article_comment( 12 | self, 13 | session: Any, 14 | slug: str, 15 | comment_to_create: CreateCommentDTO, 16 | current_user: UserDTO, 17 | ) -> CommentDTO: ... 18 | 19 | @abc.abstractmethod 20 | async def get_article_comments( 21 | self, session: Any, slug: str, current_user: UserDTO | None 22 | ) -> CommentsListDTO: ... 23 | 24 | @abc.abstractmethod 25 | async def delete_article_comment( 26 | self, session: Any, slug: str, comment_id: int, current_user: UserDTO 27 | ) -> None: ... 28 | -------------------------------------------------------------------------------- /conduit/core/utils/errors.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from collections.abc import Awaitable, Sequence 3 | from typing import Any 4 | 5 | 6 | async def get_or_raise(awaitable: Awaitable, exception: Exception) -> Any: 7 | """ 8 | Await the awaitable and raise the given exception if the result is None. 9 | """ 10 | result = await awaitable 11 | if not result: 12 | raise exception 13 | return result 14 | 15 | 16 | def format_errors(errors: Sequence[Any]) -> dict[str, list[str]]: 17 | """ 18 | Format errors from pydantic validation errors. 19 | """ 20 | result: defaultdict[str, list[str]] = defaultdict(list) 21 | for error in errors: 22 | field = error["loc"][-1] 23 | message = error.get("ctx", {}).get("reason") or error["msg"] 24 | result[field].append(message.lower()) 25 | return dict(result) 26 | -------------------------------------------------------------------------------- /conduit/api/router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from conduit.api.routes import ( 4 | article, 5 | authentication, 6 | comment, 7 | health_check, 8 | profile, 9 | tag, 10 | users, 11 | ) 12 | 13 | router = APIRouter() 14 | 15 | router.include_router( 16 | router=health_check.router, tags=["Health Check"], prefix="/health-check" 17 | ) 18 | router.include_router( 19 | router=authentication.router, tags=["Authentication"], prefix="/users" 20 | ) 21 | router.include_router(router=users.router, tags=["User"], prefix="/user") 22 | router.include_router(router=profile.router, tags=["Profiles"], prefix="/profiles") 23 | router.include_router(router=tag.router, tags=["Tags"], prefix="/tags") 24 | router.include_router(router=article.router, tags=["Articles"], prefix="/articles") 25 | router.include_router(router=comment.router, tags=["Comments"], prefix="/articles") 26 | -------------------------------------------------------------------------------- /conduit/core/config.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from conduit.core.settings.app import AppSettings 4 | from conduit.core.settings.base import AppEnvTypes, BaseAppSettings 5 | from conduit.core.settings.development import DevAppSettings 6 | from conduit.core.settings.production import ProdAppSettings 7 | from conduit.core.settings.test import TestAppSettings 8 | 9 | AppEnvType = DevAppSettings | TestAppSettings | ProdAppSettings 10 | 11 | environments: dict[str, type[AppEnvType]] = { # type: ignore 12 | AppEnvTypes.development: DevAppSettings, 13 | AppEnvTypes.testing: TestAppSettings, 14 | AppEnvTypes.production: ProdAppSettings, 15 | } 16 | 17 | 18 | @lru_cache 19 | def get_app_settings() -> AppSettings: 20 | """ 21 | Return application config. 22 | """ 23 | app_env = BaseAppSettings().app_env 24 | config = environments[app_env] 25 | return config() # type: ignore 26 | -------------------------------------------------------------------------------- /tests/api/routes/test_login.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | 4 | from conduit.domain.dtos.user import UserDTO 5 | 6 | 7 | @pytest.mark.anyio 8 | async def test_user_successful_login( 9 | test_client: AsyncClient, test_user: UserDTO 10 | ) -> None: 11 | payload = {"user": {"email": "test@gmail.com", "password": "password"}} 12 | response = await test_client.post("/users/login", json=payload) 13 | assert response.status_code == 200 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "credentials,", 18 | ( 19 | {"email": "invalid@gmail.com", "password": "password"}, 20 | {"email": "test@gmail.com", "password": "invalid"}, 21 | ), 22 | ) 23 | @pytest.mark.anyio 24 | async def test_user_login_with_invalid_credentials_part( 25 | test_client: AsyncClient, credentials: dict 26 | ) -> None: 27 | payload = {"user": credentials} 28 | response = await test_client.post("/users/login", json=payload) 29 | assert response.status_code == 400 30 | -------------------------------------------------------------------------------- /conduit/domain/repositories/comment.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | from conduit.domain.dtos.comment import CommentRecordDTO, CreateCommentDTO 5 | 6 | 7 | class ICommentRepository(abc.ABC): 8 | """Comment repository interface.""" 9 | 10 | @abc.abstractmethod 11 | async def add( 12 | self, 13 | session: Any, 14 | author_id: int, 15 | article_id: int, 16 | create_item: CreateCommentDTO, 17 | ) -> CommentRecordDTO: ... 18 | 19 | @abc.abstractmethod 20 | async def get_or_none( 21 | self, session: Any, comment_id: int 22 | ) -> CommentRecordDTO | None: ... 23 | 24 | @abc.abstractmethod 25 | async def get(self, session: Any, comment_id: int) -> CommentRecordDTO: ... 26 | 27 | @abc.abstractmethod 28 | async def list(self, session: Any, article_id: int) -> list[CommentRecordDTO]: ... 29 | 30 | @abc.abstractmethod 31 | async def delete(self, session: Any, comment_id: int) -> None: ... 32 | 33 | @abc.abstractmethod 34 | async def count(self, session: Any, article_id: int) -> int: ... 35 | -------------------------------------------------------------------------------- /conduit/infrastructure/mappers/comment.py: -------------------------------------------------------------------------------- 1 | from conduit.domain.dtos.comment import CommentRecordDTO 2 | from conduit.domain.mapper import IModelMapper 3 | from conduit.infrastructure.models import Comment 4 | 5 | 6 | class CommentModelMapper(IModelMapper[Comment, CommentRecordDTO]): 7 | 8 | @staticmethod 9 | def to_dto(model: Comment) -> CommentRecordDTO: 10 | dto = CommentRecordDTO( 11 | id=model.id, 12 | body=model.body, 13 | author_id=model.author_id, 14 | article_id=model.article_id, 15 | created_at=model.created_at, 16 | updated_at=model.updated_at, 17 | ) 18 | return dto 19 | 20 | @staticmethod 21 | def from_dto(dto: CommentRecordDTO) -> Comment: 22 | model = Comment( 23 | body=dto.body, 24 | author_id=dto.author_id, 25 | article_id=dto.article_id, 26 | created_at=dto.created_at, 27 | updated_at=dto.updated_at, 28 | ) 29 | if hasattr(dto, "id"): 30 | model.id = dto.id 31 | return model 32 | -------------------------------------------------------------------------------- /conduit/infrastructure/mappers/user.py: -------------------------------------------------------------------------------- 1 | from conduit.domain.dtos.user import UserDTO 2 | from conduit.domain.mapper import IModelMapper 3 | from conduit.infrastructure.models import User 4 | 5 | 6 | class UserModelMapper(IModelMapper[User, UserDTO]): 7 | 8 | @staticmethod 9 | def to_dto(model: User) -> UserDTO: 10 | dto = UserDTO( 11 | username=model.username, 12 | email=model.email, 13 | password_hash=model.password_hash, 14 | bio=model.bio, 15 | image_url=model.image_url, 16 | created_at=model.created_at, 17 | ) 18 | dto.id = model.id 19 | return dto 20 | 21 | @staticmethod 22 | def from_dto(dto: UserDTO) -> User: 23 | model = User( 24 | username=dto.username, 25 | email=dto.email, 26 | bio=dto.bio, 27 | password_hash=dto.password_hash, 28 | image_url=dto.image_url, 29 | created_at=dto.created_at, 30 | ) 31 | if hasattr(dto, "id"): 32 | model.id = dto.id 33 | return model 34 | -------------------------------------------------------------------------------- /conduit/domain/services/profile.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | from conduit.domain.dtos.profile import ProfileDTO 5 | from conduit.domain.dtos.user import UserDTO 6 | 7 | 8 | class IProfileService(abc.ABC): 9 | 10 | @abc.abstractmethod 11 | async def get_profile_by_username( 12 | self, session: Any, username: str, current_user: UserDTO | None = None 13 | ) -> ProfileDTO: ... 14 | 15 | @abc.abstractmethod 16 | async def get_profile_by_user_id( 17 | self, session: Any, user_id: int, current_user: UserDTO | None = None 18 | ) -> ProfileDTO: ... 19 | 20 | @abc.abstractmethod 21 | async def get_profiles_by_user_ids( 22 | self, session: Any, user_ids: list[int], current_user: UserDTO | None 23 | ) -> list[ProfileDTO]: ... 24 | 25 | @abc.abstractmethod 26 | async def follow_user( 27 | self, session: Any, username: str, current_user: UserDTO 28 | ) -> None: ... 29 | 30 | @abc.abstractmethod 31 | async def unfollow_user( 32 | self, session: Any, username: str, current_user: UserDTO 33 | ) -> None: ... 34 | -------------------------------------------------------------------------------- /conduit/core/utils/slug.py: -------------------------------------------------------------------------------- 1 | from secrets import token_urlsafe 2 | 3 | from slugify import slugify 4 | 5 | 6 | def make_slug_from_title(title: str) -> str: 7 | """ 8 | Create a unique slug from the title. 9 | 10 | Example: 11 | make_slug_from_title("Hello World") 12 | "hello-world-123456" 13 | """ 14 | slug = slugify(text=title, max_length=32, lowercase=True) 15 | unique_code = token_urlsafe(6) 16 | return f"{slug}-{unique_code.lower()}" 17 | 18 | 19 | def make_slug_from_title_and_code(title: str, code: str) -> str: 20 | """ 21 | Create a unique slug from the title and code. 22 | 23 | Example: 24 | make_slug_from_title_and_code("Hello World", "123456") 25 | "hello-world-123456" 26 | """ 27 | slug = slugify(text=title, max_length=32, lowercase=True) 28 | return f"{slug}-{code}" 29 | 30 | 31 | def get_slug_unique_part(slug: str) -> str: 32 | """ 33 | Get unique part of the slug. 34 | 35 | Example: 36 | get_slug_unique_part("hello-world-123456") 37 | "123456" 38 | """ 39 | return slug.split("-")[-1] 40 | -------------------------------------------------------------------------------- /conduit/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from starlette.middleware.cors import CORSMiddleware 3 | 4 | from conduit.api.middlewares import RateLimitingMiddleware 5 | from conduit.api.router import router as api_router 6 | from conduit.core.config import get_app_settings 7 | from conduit.core.exceptions import add_exception_handlers 8 | from conduit.core.logging import configure_logger 9 | 10 | 11 | def create_app() -> FastAPI: 12 | """ 13 | Application factory, used to create application. 14 | """ 15 | settings = get_app_settings() 16 | 17 | application = FastAPI(**settings.fastapi_kwargs) 18 | 19 | application.add_middleware( 20 | CORSMiddleware, 21 | allow_origins=settings.allowed_hosts, 22 | allow_credentials=True, 23 | allow_methods=["*"], 24 | allow_headers=["*"], 25 | ) 26 | application.add_middleware(RateLimitingMiddleware) 27 | 28 | application.include_router(api_router, prefix="/api") 29 | 30 | add_exception_handlers(app=application) 31 | 32 | configure_logger() 33 | 34 | return application 35 | 36 | 37 | app = create_app() 38 | -------------------------------------------------------------------------------- /conduit/domain/services/user.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from collections.abc import Collection 3 | from typing import Any 4 | 5 | from conduit.domain.dtos.user import ( 6 | CreateUserDTO, 7 | UpdatedUserDTO, 8 | UpdateUserDTO, 9 | UserDTO, 10 | ) 11 | 12 | 13 | class IUserService(abc.ABC): 14 | 15 | @abc.abstractmethod 16 | async def create_user( 17 | self, session: Any, user_to_create: CreateUserDTO 18 | ) -> UserDTO: ... 19 | 20 | @abc.abstractmethod 21 | async def get_user_by_id(self, session: Any, user_id: int) -> UserDTO: ... 22 | 23 | @abc.abstractmethod 24 | async def get_user_by_email(self, session: Any, email: str) -> UserDTO: ... 25 | 26 | @abc.abstractmethod 27 | async def get_user_by_username(self, session: Any, username: str) -> UserDTO: ... 28 | 29 | @abc.abstractmethod 30 | async def get_users_by_ids( 31 | self, session: Any, user_ids: Collection[int] 32 | ) -> list[UserDTO]: ... 33 | 34 | @abc.abstractmethod 35 | async def update_user( 36 | self, session: Any, current_user: UserDTO, user_to_update: UpdateUserDTO 37 | ) -> UpdatedUserDTO: ... 38 | -------------------------------------------------------------------------------- /conduit/core/settings/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | from conduit.core.settings.base import BaseAppSettings 5 | from version import response 6 | 7 | 8 | class AppSettings(BaseAppSettings): 9 | """ 10 | Base application settings 11 | """ 12 | 13 | debug: bool = False 14 | docs_url: str = "/" 15 | openapi_prefix: str = "" 16 | openapi_url: str = "/openapi.json" 17 | redoc_url: str = "/redoc" 18 | title: str = response["message"] 19 | version: str = response["version"] 20 | 21 | secret_key: str 22 | 23 | api_prefix: str = "/api/v1" 24 | 25 | allowed_hosts: list[str] = ["*"] 26 | 27 | logging_level: int = logging.INFO 28 | 29 | class Config: 30 | validate_assignment = True 31 | 32 | @property 33 | def fastapi_kwargs(self) -> dict[str, Any]: 34 | return { 35 | "debug": self.debug, 36 | "docs_url": self.docs_url, 37 | "openapi_prefix": self.openapi_prefix, 38 | "openapi_url": self.openapi_url, 39 | "redoc_url": self.redoc_url, 40 | "title": self.title, 41 | "version": self.version, 42 | } 43 | -------------------------------------------------------------------------------- /conduit/infrastructure/mappers/article.py: -------------------------------------------------------------------------------- 1 | from conduit.domain.dtos.article import ArticleRecordDTO 2 | from conduit.domain.mapper import IModelMapper 3 | from conduit.infrastructure.models import Article 4 | 5 | 6 | class ArticleModelMapper(IModelMapper[Article, ArticleRecordDTO]): 7 | 8 | @staticmethod 9 | def to_dto(model: Article) -> ArticleRecordDTO: 10 | dto = ArticleRecordDTO( 11 | id=model.id, 12 | author_id=model.author_id, 13 | slug=model.slug, 14 | title=model.title, 15 | description=model.description, 16 | body=model.body, 17 | created_at=model.created_at, 18 | updated_at=model.updated_at, 19 | ) 20 | return dto 21 | 22 | @staticmethod 23 | def from_dto(dto: ArticleRecordDTO) -> Article: 24 | model = Article( 25 | author_id=dto.author_id, 26 | slug=dto.slug, 27 | title=dto.title, 28 | description=dto.description, 29 | body=dto.body, 30 | created_at=dto.created_at, 31 | ) 32 | if hasattr(dto, "id"): 33 | model.id = dto.id 34 | return model 35 | -------------------------------------------------------------------------------- /conduit/api/routes/users.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from conduit.api.schemas.requests.user import UserUpdateRequest 4 | from conduit.api.schemas.responses.user import CurrentUserResponse, UpdatedUserResponse 5 | from conduit.core.dependencies import CurrentUser, DBSession, IUserService, JWTToken 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("", response_model=CurrentUserResponse) 11 | async def get_current_user( 12 | token: JWTToken, current_user: CurrentUser 13 | ) -> CurrentUserResponse: 14 | """ 15 | Return current user. 16 | """ 17 | return CurrentUserResponse.from_dto(dto=current_user, token=token) 18 | 19 | 20 | @router.put("", response_model=UpdatedUserResponse) 21 | async def update_current_user( 22 | payload: UserUpdateRequest, 23 | token: JWTToken, 24 | session: DBSession, 25 | current_user: CurrentUser, 26 | user_service: IUserService, 27 | ) -> UpdatedUserResponse: 28 | """ 29 | Update current user. 30 | """ 31 | updated_user_dto = await user_service.update_user( 32 | session=session, current_user=current_user, user_to_update=payload.to_dto() 33 | ) 34 | return UpdatedUserResponse.from_dto(dto=updated_user_dto, token=token) 35 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from conduit.domain.dtos.article import ArticleRecordDTO, CreateArticleDTO 4 | from conduit.domain.dtos.user import CreateUserDTO, UserDTO 5 | from conduit.infrastructure.repositories.article import ArticleRepository 6 | from conduit.infrastructure.repositories.user import UserRepository 7 | 8 | 9 | async def create_another_test_user( 10 | session: AsyncSession, user_repository: UserRepository 11 | ) -> UserDTO: 12 | create_user_dto = CreateUserDTO( 13 | username="temp-user", email="temp-user@gmail.com", password="password" 14 | ) 15 | return await user_repository.add(session=session, create_item=create_user_dto) 16 | 17 | 18 | async def create_another_test_article( 19 | session: AsyncSession, article_repository: ArticleRepository, author_id: int 20 | ) -> ArticleRecordDTO: 21 | create_article_dto = CreateArticleDTO( 22 | title="One More Test Article", 23 | description="Test Description", 24 | body="Test Body", 25 | tags=["tag1", "tag2", "tag3"], 26 | ) 27 | return await article_repository.add( 28 | session=session, author_id=author_id, create_item=create_article_dto 29 | ) 30 | -------------------------------------------------------------------------------- /.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: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-added-large-files 8 | - id: check-toml 9 | - id: check-yaml 10 | - id: check-json 11 | - id: mixed-line-ending 12 | - id: detect-private-key 13 | - id: check-case-conflict 14 | - id: requirements-txt-fixer 15 | - id: fix-encoding-pragma 16 | args: [--remove] 17 | 18 | - repo: https://github.com/PyCQA/flake8 19 | rev: 7.0.0 20 | hooks: 21 | - id: flake8 22 | 23 | - repo: local 24 | hooks: 25 | - id: mypy 26 | name: mypy 27 | pass_filenames: false 28 | language: python 29 | entry: bash -c 'make types' 30 | 31 | - repo: https://github.com/PyCQA/isort 32 | rev: 5.13.2 33 | hooks: 34 | - id: isort 35 | 36 | - repo: https://github.com/psf/black 37 | rev: 24.3.0 38 | hooks: 39 | - id: black 40 | language_version: python 41 | 42 | - repo: https://github.com/asottile/pyupgrade 43 | rev: v3.15.1 44 | hooks: 45 | - id: pyupgrade 46 | args: [ --py312-plus ] 47 | -------------------------------------------------------------------------------- /conduit/domain/dtos/user.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from dataclasses import dataclass, field 3 | 4 | 5 | @dataclass 6 | class UserDTO: 7 | id: int = field(init=False) 8 | username: str 9 | email: str 10 | password_hash: str 11 | bio: str 12 | image_url: str 13 | created_at: datetime.datetime 14 | 15 | 16 | @dataclass(frozen=True) 17 | class CreatedUserDTO: 18 | id: int 19 | email: str 20 | username: str 21 | bio: str 22 | image: str 23 | token: str 24 | 25 | 26 | @dataclass(frozen=True) 27 | class LoggedInUserDTO: 28 | email: str 29 | username: str 30 | bio: str 31 | image: str 32 | token: str 33 | 34 | 35 | @dataclass(frozen=True) 36 | class UpdatedUserDTO: 37 | id: int 38 | email: str 39 | username: str 40 | bio: str 41 | image: str 42 | 43 | 44 | @dataclass(frozen=True) 45 | class CreateUserDTO: 46 | username: str 47 | email: str 48 | password: str 49 | 50 | 51 | @dataclass(frozen=True) 52 | class LoginUserDTO: 53 | email: str 54 | password: str 55 | 56 | 57 | @dataclass(frozen=True) 58 | class UpdateUserDTO: 59 | username: str | None = None 60 | email: str | None = None 61 | password: str | None = None 62 | bio: str | None = None 63 | image_url: str | None = None 64 | -------------------------------------------------------------------------------- /tests/core/utils/test_errors.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | from fastapi import FastAPI 5 | 6 | from conduit.core.utils.errors import get_or_raise 7 | 8 | 9 | @pytest.fixture(scope="session", autouse=True) 10 | def create_test_db() -> None: 11 | return 12 | 13 | 14 | @pytest.fixture(scope="session", autouse=True) 15 | def create_tables() -> None: 16 | return 17 | 18 | 19 | class TestException(Exception): 20 | pass 21 | 22 | 23 | async def mock_awaitable(result: Any) -> Any: 24 | return result 25 | 26 | 27 | @pytest.mark.anyio 28 | async def test_get_or_raise_returns_value(application: FastAPI) -> None: 29 | result = await get_or_raise(mock_awaitable("expected_value"), TestException()) 30 | assert result == "expected_value" 31 | 32 | 33 | @pytest.mark.anyio 34 | async def test_get_or_raise_raises_exception(application: FastAPI) -> None: 35 | with pytest.raises(TestException): 36 | await get_or_raise(mock_awaitable(None), TestException()) 37 | 38 | 39 | @pytest.mark.anyio 40 | async def test_get_or_raise_raises_custom_exception_message( 41 | application: FastAPI, 42 | ) -> None: 43 | with pytest.raises(TestException, match="Custom error message"): 44 | await get_or_raise(mock_awaitable(None), TestException("Custom error message")) 45 | -------------------------------------------------------------------------------- /conduit/core/security.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi.security import APIKeyHeader 4 | from starlette.exceptions import HTTPException 5 | from starlette.requests import Request 6 | from starlette.status import HTTP_403_FORBIDDEN 7 | 8 | 9 | class HTTPTokenHeader(APIKeyHeader): 10 | 11 | def __init__(self, raise_error: bool, *args: Any, **kwargs: Any): 12 | super().__init__(*args, **kwargs) 13 | self.raise_error = raise_error 14 | 15 | async def __call__(self, request: Request) -> str | None: 16 | api_key = request.headers.get(self.model.name) 17 | if not api_key: 18 | if not self.raise_error: 19 | return "" 20 | raise HTTPException( 21 | status_code=HTTP_403_FORBIDDEN, 22 | detail="Missing authorization credentials", 23 | ) 24 | 25 | try: 26 | token_prefix, token = api_key.split(" ") 27 | except ValueError: 28 | raise HTTPException( 29 | status_code=HTTP_403_FORBIDDEN, detail="Invalid token schema" 30 | ) 31 | 32 | if token_prefix.lower() != "token": 33 | raise HTTPException( 34 | status_code=HTTP_403_FORBIDDEN, detail="Invalid token schema" 35 | ) 36 | 37 | return token 38 | -------------------------------------------------------------------------------- /conduit/api/routes/authentication.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from conduit.api.schemas.requests.user import UserLoginRequest, UserRegistrationRequest 4 | from conduit.api.schemas.responses.user import ( 5 | UserLoginResponse, 6 | UserRegistrationResponse, 7 | ) 8 | from conduit.core.dependencies import DBSession, IUserAuthService 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.post("", response_model=UserRegistrationResponse) 14 | async def register_user( 15 | payload: UserRegistrationRequest, 16 | session: DBSession, 17 | user_auth_service: IUserAuthService, 18 | ) -> UserRegistrationResponse: 19 | """ 20 | Process user registration. 21 | """ 22 | user_dto = await user_auth_service.sign_up_user( 23 | session=session, user_to_create=payload.to_dto() 24 | ) 25 | return UserRegistrationResponse.from_dto(dto=user_dto) 26 | 27 | 28 | @router.post("/login", response_model=UserLoginResponse) 29 | async def login_user( 30 | payload: UserLoginRequest, session: DBSession, user_auth_service: IUserAuthService 31 | ) -> UserLoginResponse: 32 | """ 33 | Process user login. 34 | """ 35 | user_dto = await user_auth_service.sign_in_user( 36 | session=session, user_to_login=payload.to_dto() 37 | ) 38 | return UserLoginResponse.from_dto(dto=user_dto) 39 | -------------------------------------------------------------------------------- /conduit/core/settings/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import Extra, computed_field 2 | from pydantic_settings import BaseSettings 3 | from sqlalchemy import URL 4 | 5 | 6 | class AppEnvTypes: 7 | """ 8 | Available application environments. 9 | """ 10 | 11 | production = "prod" 12 | development = "dev" 13 | testing = "test" 14 | 15 | 16 | class BaseAppSettings(BaseSettings): 17 | """ 18 | Base application setting class. 19 | """ 20 | 21 | app_env: str = AppEnvTypes.production 22 | 23 | postgres_host: str 24 | postgres_port: int 25 | postgres_user: str 26 | postgres_password: str 27 | postgres_db: str 28 | 29 | jwt_secret_key: str 30 | jwt_token_expiration_minutes: int = 60 * 24 * 7 # one week. 31 | jwt_algorithm: str = "HS256" 32 | 33 | class Config: 34 | env_file = ".env" 35 | extra = Extra.ignore 36 | 37 | @computed_field # type: ignore 38 | @property 39 | def sql_db_uri(self) -> URL: 40 | return URL.create( 41 | drivername="postgresql+asyncpg", 42 | username=self.postgres_user, 43 | password=self.postgres_password, 44 | host=self.postgres_host, 45 | port=self.postgres_port, 46 | database=self.postgres_db, 47 | ) 48 | 49 | @computed_field # type: ignore 50 | @property 51 | def sqlalchemy_engine_props(self) -> dict: 52 | return dict(url=self.sql_db_uri) 53 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ve: 2 | python3 -m venv .ve; \ 3 | . .ve/bin/activate; \ 4 | pip install -r requirements.txt 5 | 6 | docker_build: 7 | docker-compose up -d --build 8 | 9 | docker_build_postgres: 10 | docker-compose up -d postgres --build 11 | 12 | docker_up: 13 | docker-compose up -d 14 | 15 | docker_down: 16 | docker-compose down 17 | 18 | docker_restart: 19 | docker-compose stop 20 | docker-compose up -d 21 | 22 | docker_logs: 23 | docker-compose logs --tail=100 -f 24 | 25 | runserver: 26 | uvicorn conduit.app:app --host 0.0.0.0 27 | 28 | runserver-dev: 29 | export APP_ENV=dev && uvicorn conduit.app:app --host 0.0.0.0 --reload 30 | 31 | test: 32 | export APP_ENV=test && python -m pytest -v ./tests 33 | 34 | test-cov: 35 | export APP_ENV=test && python -m pytest --cov=./conduit ./tests 36 | 37 | install_hooks: 38 | pip install -r requirements-ci.txt; \ 39 | pre-commit install 40 | 41 | run_hooks: 42 | pre-commit run --all-files 43 | 44 | style: 45 | flake8 conduit 46 | 47 | format: 48 | black conduit --check 49 | 50 | types: 51 | mypy --namespace-packages -p "conduit" --config-file setup.cfg 52 | 53 | types-tests: 54 | mypy --namespace-packages -p "tests" --config-file setup.cfg 55 | 56 | lint: 57 | flake8 conduit 58 | isort conduit --diff 59 | black conduit --check 60 | mypy --namespace-packages -p "conduit" --config-file setup.cfg 61 | 62 | migration: 63 | alembic revision --autogenerate -m "$(message)" 64 | 65 | migrate: 66 | alembic upgrade head 67 | -------------------------------------------------------------------------------- /conduit/domain/repositories/user.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from collections.abc import Collection, Mapping 3 | from typing import Any 4 | 5 | from conduit.domain.dtos.user import CreateUserDTO, UpdateUserDTO, UserDTO 6 | 7 | 8 | class IUserRepository(abc.ABC): 9 | """User repository interface.""" 10 | 11 | @abc.abstractmethod 12 | async def add(self, session: Any, create_item: CreateUserDTO) -> UserDTO: ... 13 | 14 | @abc.abstractmethod 15 | async def get_or_none(self, session: Any, user_id: int) -> UserDTO | None: ... 16 | 17 | @abc.abstractmethod 18 | async def get(self, session: Any, user_id: int) -> UserDTO: ... 19 | 20 | @abc.abstractmethod 21 | async def get_by_email_or_none( 22 | self, session: Any, email: str 23 | ) -> UserDTO | None: ... 24 | 25 | @abc.abstractmethod 26 | async def get_by_email(self, session: Any, email: str) -> UserDTO: ... 27 | 28 | @abc.abstractmethod 29 | async def list_by_users( 30 | self, session: Any, user_ids: Collection[int] 31 | ) -> list[UserDTO]: ... 32 | 33 | @abc.abstractmethod 34 | async def get_by_username_or_none( 35 | self, session: Any, username: str 36 | ) -> UserDTO | None: ... 37 | 38 | @abc.abstractmethod 39 | async def get_by_username(self, session: Any, username: str) -> UserDTO: ... 40 | 41 | @abc.abstractmethod 42 | async def update( 43 | self, session: Any, user_id: int, update_item: UpdateUserDTO 44 | ) -> UserDTO: ... 45 | -------------------------------------------------------------------------------- /conduit/infrastructure/alembic/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy import Connection, pool 6 | from sqlalchemy.ext.asyncio import create_async_engine 7 | 8 | from conduit.core.config import get_app_settings 9 | from conduit.infrastructure.models import Base 10 | 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Project settings. 15 | settings = get_app_settings() 16 | 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | if config.config_file_name is not None: 20 | fileConfig(config.config_file_name) 21 | 22 | target_metadata = Base.metadata 23 | 24 | 25 | def _run_sync_migrations(connection: Connection) -> None: 26 | context.configure(connection=connection, target_metadata=target_metadata) 27 | with context.begin_transaction(): 28 | context.run_migrations() 29 | 30 | 31 | async def run_migrations_online() -> None: 32 | """ 33 | Run alembic in 'online' mode. 34 | 35 | In this scenario we need to create an Engine 36 | and associate a connection with the context. 37 | """ 38 | connectable = create_async_engine(url=settings.sql_db_uri, poolclass=pool.NullPool) 39 | 40 | async with connectable.connect() as connection: 41 | await connection.run_sync(_run_sync_migrations) 42 | 43 | await connectable.dispose() 44 | 45 | 46 | asyncio.run(run_migrations_online()) 47 | -------------------------------------------------------------------------------- /conduit/api/schemas/requests/article.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from conduit.domain.dtos.article import CreateArticleDTO, UpdateArticleDTO 4 | 5 | 6 | class ArticlesPagination(BaseModel): 7 | limit: int = Field(ge=1) 8 | offset: int = Field(ge=0) 9 | 10 | 11 | class ArticlesFilters(BaseModel): 12 | tag: str | None = None 13 | author: str | None = None 14 | favorited: str | None = None 15 | 16 | 17 | class CreateArticleData(BaseModel): 18 | title: str = Field(..., min_length=5) 19 | description: str = Field(min_length=10) 20 | body: str = Field(min_length=10) 21 | tags: list[str] = Field(alias="tagList") 22 | 23 | 24 | class UpdateArticleData(BaseModel): 25 | title: str | None = Field(None) 26 | description: str | None = Field(None) 27 | body: str | None = Field(None) 28 | 29 | 30 | class UpdateArticleRequest(BaseModel): 31 | article: UpdateArticleData 32 | 33 | def to_dto(self) -> UpdateArticleDTO: 34 | return UpdateArticleDTO( 35 | title=self.article.title, 36 | description=self.article.description, 37 | body=self.article.body, 38 | ) 39 | 40 | 41 | class CreateArticleRequest(BaseModel): 42 | article: CreateArticleData 43 | 44 | def to_dto(self) -> CreateArticleDTO: 45 | return CreateArticleDTO( 46 | title=self.article.title, 47 | description=self.article.description, 48 | body=self.article.body, 49 | tags=self.article.tags, 50 | ) 51 | -------------------------------------------------------------------------------- /conduit/services/auth_token.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import jwt 4 | from structlog import get_logger 5 | 6 | from conduit.core.exceptions import IncorrectJWTTokenException 7 | from conduit.domain.dtos.auth_token import TokenPayloadDTO 8 | from conduit.domain.dtos.user import UserDTO 9 | from conduit.domain.services.auth_token import IAuthTokenService 10 | 11 | logger = get_logger() 12 | 13 | 14 | class AuthTokenService(IAuthTokenService): 15 | """Service to handle JWT tokens.""" 16 | 17 | def __init__( 18 | self, secret_key: str, token_expiration_minutes: int, algorithm: str 19 | ) -> None: 20 | self._secret_key = secret_key 21 | self._algorithm = algorithm 22 | self._token_expiration_minutes = token_expiration_minutes 23 | 24 | def generate_jwt_token(self, user: UserDTO) -> str: 25 | expire = datetime.now() + timedelta(minutes=self._token_expiration_minutes) 26 | payload = {"user_id": user.id, "username": user.username, "exp": expire} 27 | return jwt.encode(payload, self._secret_key, algorithm=self._algorithm) 28 | 29 | def parse_jwt_token(self, token: str) -> TokenPayloadDTO: 30 | try: 31 | payload = jwt.decode(token, self._secret_key, algorithms=[self._algorithm]) 32 | except jwt.InvalidTokenError as err: 33 | logger.error("Invalid JWT token", token=token, error=err) 34 | raise IncorrectJWTTokenException() 35 | 36 | return TokenPayloadDTO(user_id=payload["user_id"], username=payload["username"]) 37 | -------------------------------------------------------------------------------- /tests/api/routes/test_registaration.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from conduit.domain.dtos.user import UserDTO 6 | from conduit.infrastructure.repositories.user import UserRepository 7 | from conduit.services.password import verify_password 8 | 9 | 10 | @pytest.mark.anyio 11 | async def test_user_success_registration( 12 | test_client: AsyncClient, session: AsyncSession, user_repository: UserRepository 13 | ) -> None: 14 | email, username, password = ("success@gmail.com", "success", "password") 15 | payload = {"user": {"email": email, "username": username, "password": password}} 16 | response = await test_client.post("/users", json=payload) 17 | assert response.status_code == 200 18 | 19 | user = await user_repository.get_by_email(session=session, email=email) 20 | assert user.email == email 21 | assert user.username == username 22 | assert verify_password(plain_password=password, hashed_password=user.password_hash) 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "field, value", (("username", "new_username"), ("email", "new-email@gmail.com")) 27 | ) 28 | @pytest.mark.anyio 29 | async def test_user_registration_with_taken_credentials( 30 | test_client: AsyncClient, test_user: UserDTO, field: str, value: str 31 | ) -> None: 32 | payload = { 33 | "user": {"email": "test@gmail.com", "username": "test", "password": "password"} 34 | } 35 | payload["user"][field] = value 36 | response = await test_client.post("/users", json=payload) 37 | assert response.status_code == 400 38 | -------------------------------------------------------------------------------- /conduit/api/schemas/requests/user.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr, Field 2 | 3 | from conduit.domain.dtos.user import CreateUserDTO, LoginUserDTO, UpdateUserDTO 4 | 5 | 6 | class UserRegistrationData(BaseModel): 7 | email: EmailStr 8 | password: str = Field(..., min_length=8) 9 | username: str = Field(..., min_length=3) 10 | 11 | 12 | class UserLoginData(BaseModel): 13 | email: EmailStr 14 | password: str = Field(..., min_length=8) 15 | 16 | 17 | class UserUpdateData(BaseModel): 18 | email: str | None = Field(None) 19 | password: str | None = Field(None) 20 | username: str | None = Field(None) 21 | bio: str | None = Field(None) 22 | image: str | None = Field(None) 23 | 24 | 25 | class UserRegistrationRequest(BaseModel): 26 | user: UserRegistrationData 27 | 28 | def to_dto(self) -> CreateUserDTO: 29 | return CreateUserDTO( 30 | username=self.user.username, 31 | email=self.user.email, 32 | password=self.user.password, 33 | ) 34 | 35 | 36 | class UserLoginRequest(BaseModel): 37 | user: UserLoginData 38 | 39 | def to_dto(self) -> LoginUserDTO: 40 | return LoginUserDTO(email=self.user.email, password=self.user.password) 41 | 42 | 43 | class UserUpdateRequest(BaseModel): 44 | user: UserUpdateData 45 | 46 | def to_dto(self) -> UpdateUserDTO: 47 | return UpdateUserDTO( 48 | email=self.user.email, 49 | password=self.user.password, 50 | username=self.user.username, 51 | bio=self.user.bio, 52 | image_url=self.user.image, 53 | ) 54 | -------------------------------------------------------------------------------- /conduit/domain/dtos/article.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from dataclasses import dataclass, replace 3 | 4 | from conduit.domain.dtos.profile import ProfileDTO 5 | 6 | 7 | @dataclass(frozen=True) 8 | class ArticleRecordDTO: 9 | id: int 10 | author_id: int 11 | slug: str 12 | title: str 13 | description: str 14 | body: str 15 | created_at: datetime.datetime 16 | updated_at: datetime.datetime 17 | 18 | 19 | @dataclass(frozen=True) 20 | class ArticleAuthorDTO: 21 | username: str 22 | bio: str = "" 23 | image: str | None = None 24 | following: bool = False 25 | id: int | None = None 26 | 27 | 28 | @dataclass(frozen=True) 29 | class ArticleDTO: 30 | id: int 31 | author_id: int 32 | slug: str 33 | title: str 34 | description: str 35 | body: str 36 | tags: list[str] 37 | author: ArticleAuthorDTO 38 | created_at: datetime.datetime 39 | updated_at: datetime.datetime 40 | favorited: bool 41 | favorites_count: int 42 | 43 | @classmethod 44 | def with_updated_fields( 45 | cls, dto: "ArticleDTO", updated_fields: dict 46 | ) -> "ArticleDTO": 47 | return replace(dto, **updated_fields) 48 | 49 | 50 | @dataclass(frozen=True) 51 | class ArticlesFeedDTO: 52 | articles: list[ArticleDTO] 53 | articles_count: int 54 | 55 | 56 | @dataclass(frozen=True) 57 | class CreateArticleDTO: 58 | title: str 59 | description: str 60 | body: str 61 | tags: list[str] 62 | 63 | 64 | @dataclass(frozen=True) 65 | class UpdateArticleDTO: 66 | title: str | None 67 | description: str | None 68 | body: str | None 69 | -------------------------------------------------------------------------------- /conduit/api/middlewares.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, Unpack 3 | 4 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 5 | from starlette.requests import Request 6 | from starlette.responses import Response 7 | 8 | from conduit.core.exceptions import RateLimitExceededException 9 | 10 | 11 | class RateLimitingMiddleware(BaseHTTPMiddleware): 12 | """ 13 | Middleware that handle requests rate limiting. 14 | """ 15 | 16 | rate_limit_duration = timedelta(minutes=1) 17 | rate_limit_requests = 100 18 | 19 | def __init__(self, *args: Unpack[tuple[Any]], **kwargs: Any): 20 | super().__init__(*args, **kwargs) 21 | # Dictionary to store request counts for each IP. 22 | self.request_counts: dict[str, tuple[int, datetime]] = {} 23 | 24 | async def dispatch( 25 | self, request: Request, call_next: RequestResponseEndpoint 26 | ) -> Response: 27 | client_ip = request.client.host 28 | 29 | request_count, last_request = self.request_counts.get( 30 | client_ip, (0, datetime.min) 31 | ) 32 | # Calculate the time elapsed since the last request 33 | elapsed_time = datetime.now() - last_request 34 | 35 | if elapsed_time > self.rate_limit_duration: 36 | request_count = 1 37 | else: 38 | if request_count >= self.rate_limit_requests: 39 | return RateLimitExceededException.get_response() 40 | request_count += 1 41 | 42 | self.request_counts[client_ip] = (request_count, datetime.now()) 43 | 44 | response = await call_next(request) 45 | return response 46 | -------------------------------------------------------------------------------- /conduit/infrastructure/repositories/favorite.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import delete, exists, insert, select 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | from sqlalchemy.sql.functions import count 6 | 7 | from conduit.domain.repositories.favorite import IFavoriteRepository 8 | from conduit.infrastructure.models import Favorite 9 | 10 | 11 | class FavoriteRepository(IFavoriteRepository): 12 | """Repository for Follower model.""" 13 | 14 | async def exists( 15 | self, session: AsyncSession, author_id: int, article_id: int 16 | ) -> bool: 17 | query = ( 18 | exists() 19 | .where(Favorite.user_id == author_id, Favorite.article_id == article_id) 20 | .select() 21 | ) 22 | result = await session.execute(query) 23 | return result.scalar() 24 | 25 | async def count(self, session: AsyncSession, article_id: int) -> int: 26 | query = select(count()).where(Favorite.article_id == article_id) 27 | result = await session.execute(query) 28 | return result.scalar() 29 | 30 | async def create( 31 | self, session: AsyncSession, article_id: int, user_id: int 32 | ) -> None: 33 | query = insert(Favorite).values( 34 | user_id=user_id, article_id=article_id, created_at=datetime.now() 35 | ) 36 | await session.execute(query) 37 | 38 | async def delete( 39 | self, session: AsyncSession, article_id: int, user_id: int 40 | ) -> None: 41 | query = delete(Favorite).where( 42 | Favorite.user_id == user_id, Favorite.article_id == article_id 43 | ) 44 | await session.execute(query) 45 | -------------------------------------------------------------------------------- /conduit/api/schemas/responses/comment.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from pydantic import BaseModel, ConfigDict, Field 4 | 5 | from conduit.core.utils.date import convert_datetime_to_realworld 6 | from conduit.domain.dtos.comment import CommentDTO, CommentsListDTO 7 | 8 | 9 | class CommentAuthorData(BaseModel): 10 | username: str 11 | bio: str 12 | image: str | None 13 | following: bool 14 | 15 | 16 | class CommentData(BaseModel): 17 | id: int 18 | body: str 19 | author: CommentAuthorData 20 | created_at: datetime.datetime = Field(alias="createdAt") 21 | updated_at: datetime.datetime = Field(alias="updatedAt") 22 | 23 | model_config = ConfigDict( 24 | json_encoders={datetime.datetime: convert_datetime_to_realworld} 25 | ) 26 | 27 | 28 | class CommentResponse(BaseModel): 29 | comment: CommentData 30 | 31 | @classmethod 32 | def from_dto(cls, dto: CommentDTO) -> "CommentResponse": 33 | comment = CommentData( 34 | id=dto.id, 35 | body=dto.body, 36 | createdAt=dto.created_at, 37 | updatedAt=dto.updated_at, 38 | author=CommentAuthorData( 39 | username=dto.author.username, 40 | bio=dto.author.bio, 41 | image=dto.author.image, 42 | following=dto.author.following, 43 | ), 44 | ) 45 | return CommentResponse(comment=comment) 46 | 47 | 48 | class CommentsListResponse(BaseModel): 49 | comments: list[CommentData] 50 | commentsCount: int 51 | 52 | @classmethod 53 | def from_dto(cls, dto: CommentsListDTO) -> "CommentsListResponse": 54 | comments = [ 55 | CommentResponse.from_dto(dto=comment_dto).comment 56 | for comment_dto in dto.comments 57 | ] 58 | return CommentsListResponse(comments=comments, commentsCount=dto.comments_count) 59 | -------------------------------------------------------------------------------- /conduit/infrastructure/repositories/follower.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import delete, exists, insert, select 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from conduit.domain.repositories.follower import IFollowerRepository 7 | from conduit.infrastructure.models import Follower 8 | 9 | 10 | class FollowerRepository(IFollowerRepository): 11 | """Repository for Follower model.""" 12 | 13 | async def exists( 14 | self, session: AsyncSession, follower_id: int, following_id: int 15 | ) -> bool: 16 | query = ( 17 | exists() 18 | .where( 19 | Follower.follower_id == follower_id, 20 | Follower.following_id == following_id, 21 | ) 22 | .select() 23 | ) 24 | result = await session.execute(query) 25 | return result.scalar() 26 | 27 | async def list( 28 | self, session: AsyncSession, follower_id: int, following_ids: list[int] 29 | ) -> list[int]: 30 | query = select(Follower.following_id).where( 31 | Follower.following_id.in_(following_ids), 32 | Follower.follower_id == follower_id, 33 | ) 34 | result = await session.execute(query) 35 | return list(result.scalars()) 36 | 37 | async def create( 38 | self, session: AsyncSession, follower_id: int, following_id: int 39 | ) -> None: 40 | query = insert(Follower).values( 41 | follower_id=follower_id, 42 | following_id=following_id, 43 | created_at=datetime.now(), 44 | ) 45 | await session.execute(query) 46 | 47 | async def delete( 48 | self, session: AsyncSession, follower_id: int, following_id: int 49 | ) -> None: 50 | query = delete(Follower).where( 51 | Follower.follower_id == follower_id, Follower.following_id == following_id 52 | ) 53 | await session.execute(query) 54 | -------------------------------------------------------------------------------- /conduit/api/routes/comment.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Path 2 | from starlette import status 3 | 4 | from conduit.api.schemas.requests.comment import CreateCommentRequest 5 | from conduit.api.schemas.responses.comment import CommentResponse, CommentsListResponse 6 | from conduit.core.dependencies import ( 7 | CurrentOptionalUser, 8 | CurrentUser, 9 | DBSession, 10 | ICommentService, 11 | ) 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.get("/{slug}/comments", response_model=CommentsListResponse) 17 | async def get_comments( 18 | slug: str, 19 | session: DBSession, 20 | current_user: CurrentOptionalUser, 21 | comment_service: ICommentService, 22 | ) -> CommentsListResponse: 23 | """ 24 | Get comments for an article. 25 | """ 26 | comment_list_dto = await comment_service.get_article_comments( 27 | session=session, slug=slug, current_user=current_user 28 | ) 29 | return CommentsListResponse.from_dto(dto=comment_list_dto) 30 | 31 | 32 | @router.post("/{slug}/comments", response_model=CommentResponse) 33 | async def create_comment( 34 | slug: str, 35 | payload: CreateCommentRequest, 36 | session: DBSession, 37 | current_user: CurrentUser, 38 | comment_service: ICommentService, 39 | ) -> CommentResponse: 40 | """ 41 | Create a comment for an article. 42 | """ 43 | comment_dto = await comment_service.create_article_comment( 44 | session=session, 45 | slug=slug, 46 | comment_to_create=payload.to_dto(), 47 | current_user=current_user, 48 | ) 49 | return CommentResponse.from_dto(dto=comment_dto) 50 | 51 | 52 | @router.delete("/{slug}/comments/{id}", status_code=status.HTTP_204_NO_CONTENT) 53 | async def delete_comment( 54 | slug: str, 55 | session: DBSession, 56 | current_user: CurrentUser, 57 | comment_service: ICommentService, 58 | comment_id: int = Path(..., alias="id"), 59 | ) -> None: 60 | """ 61 | Delete a comment for an article. 62 | """ 63 | await comment_service.delete_article_comment( 64 | session=session, slug=slug, comment_id=comment_id, current_user=current_user 65 | ) 66 | -------------------------------------------------------------------------------- /conduit/infrastructure/repositories/article_tag.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import select 4 | from sqlalchemy.dialects.postgresql import insert 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from conduit.domain.dtos.tag import TagDTO 8 | from conduit.domain.mapper import IModelMapper 9 | from conduit.domain.repositories.article_tag import IArticleTagRepository 10 | from conduit.infrastructure.models import ArticleTag, Tag 11 | 12 | 13 | class ArticleTagRepository(IArticleTagRepository): 14 | """Repository for Article Tag model.""" 15 | 16 | def __init__(self, tag_mapper: IModelMapper[Tag, TagDTO]): 17 | self._tag_mapper = tag_mapper 18 | 19 | async def add_many( 20 | self, session: AsyncSession, article_id: int, tags: list[str] 21 | ) -> list[TagDTO]: 22 | insert_query = ( 23 | insert(Tag) 24 | .on_conflict_do_nothing() 25 | .values([dict(tag=tag, created_at=datetime.now()) for tag in tags]) 26 | ) 27 | await session.execute(insert_query) 28 | 29 | select_query = select(Tag).where(Tag.tag.in_(tags)) 30 | tags = await session.scalars(select_query) 31 | tags = [self._tag_mapper.to_dto(tag) for tag in tags] 32 | 33 | link_query = ( 34 | insert(ArticleTag) 35 | .on_conflict_do_nothing() 36 | .values( 37 | [ 38 | dict( 39 | article_id=article_id, tag_id=tag.id, created_at=datetime.now() 40 | ) 41 | for tag in tags 42 | ] 43 | ) 44 | ) 45 | await session.execute(link_query) 46 | 47 | return tags 48 | 49 | async def list(self, session: AsyncSession, article_id: int) -> list[TagDTO]: 50 | query = ( 51 | select(Tag, ArticleTag) 52 | .where( 53 | (ArticleTag.article_id == article_id) & (ArticleTag.tag_id == Tag.id) 54 | ) 55 | .order_by(Tag.created_at.desc()) 56 | ) 57 | tags = await session.scalars(query) 58 | return [self._tag_mapper.to_dto(tag) for tag in tags] 59 | -------------------------------------------------------------------------------- /conduit/api/routes/profile.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from conduit.api.schemas.responses.profile import ProfileResponse 4 | from conduit.core.dependencies import ( 5 | CurrentOptionalUser, 6 | CurrentUser, 7 | DBSession, 8 | IProfileService, 9 | ) 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.get("/{username}", response_model=ProfileResponse) 15 | async def get_user_profile( 16 | username: str, 17 | session: DBSession, 18 | current_user: CurrentOptionalUser, 19 | profile_service: IProfileService, 20 | ) -> ProfileResponse: 21 | """ 22 | Return user profile information. 23 | """ 24 | profile_dto = await profile_service.get_profile_by_username( 25 | session=session, username=username, current_user=current_user 26 | ) 27 | return ProfileResponse.from_dto(dto=profile_dto) 28 | 29 | 30 | @router.post("/{username}/follow", response_model=ProfileResponse) 31 | async def follow_username( 32 | username: str, 33 | session: DBSession, 34 | current_user: CurrentUser, 35 | profile_service: IProfileService, 36 | ) -> ProfileResponse: 37 | """ 38 | Follow profile with specific username. 39 | """ 40 | await profile_service.follow_user( 41 | session=session, username=username, current_user=current_user 42 | ) 43 | profile_dto = await profile_service.get_profile_by_username( 44 | session=session, username=username, current_user=current_user 45 | ) 46 | return ProfileResponse.from_dto(dto=profile_dto) 47 | 48 | 49 | @router.delete("/{username}/follow", response_model=ProfileResponse) 50 | async def unfollow_username( 51 | username: str, 52 | session: DBSession, 53 | current_user: CurrentUser, 54 | profile_service: IProfileService, 55 | ) -> ProfileResponse: 56 | """ 57 | Unfollow profile with specific username 58 | """ 59 | await profile_service.unfollow_user( 60 | session=session, username=username, current_user=current_user 61 | ) 62 | profile_dto = await profile_service.get_profile_by_username( 63 | session=session, username=username, current_user=current_user 64 | ) 65 | return ProfileResponse.from_dto(dto=profile_dto) 66 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 15 3 | max-adjustable-complexity = 15 4 | max_cognitive_complexity = 15 5 | max-annotations-complexity = 4 6 | max-line-length = 88 7 | max_parameters_amount = 10 8 | ignore = 9 | F401, F403, F405, E501, E741, E999, F821, F901, W291, W504 10 | S101, S104, S105, S303, S106 11 | # P103 should be disabled since it threats non-format strings with braces (like default='{}') 12 | # all DXXX errors should be disabled because fuck forcing stupid docstrings everywhere 13 | W503, P103, D, N805, B008 14 | # black handles commas 15 | C812, C813, C815, C816 16 | # black handles wihtespace around operators 17 | E203 18 | # We use `id` for Session field names 19 | A003 20 | # Allow f-strings 21 | WPS305 22 | # Some unused WPS restrictions 23 | WPS111, WPS210, WPS326, WPS226, WPS602, WPS115, WPS432 24 | WPS110, WPS412, WPS338, WPS114, WPS331, WPS440, WPS214 25 | WPS323, WPS213, WPS211, WPS407, WPS306, WPS235, WPS237 26 | CCE001, WPS221, WPS202, WPS605, WPS204, WPS100, WPS601 27 | WPS317, WPS201, WPS606, WPS231, WPS232, WPS318, WPS118 28 | WPS431, WPS433, WPS337, WPS347, WPS615, WPS215, WPS348 29 | WPS352, WPS220, WPS230, WPS441, WPS410, WPS430, WPS437 30 | WPS442, WPS608, WPS404, WPS463, WPS224, WPS504, WPS600 31 | WPS436, E704 32 | # Fix single quotes 33 | Q000 34 | # Error suffix 35 | N818 36 | RST203, RST301, RST201 37 | exclude = 38 | env 39 | .git 40 | .ve 41 | setup.py 42 | Makefile 43 | README.md 44 | requirements.txt 45 | __pycache__ 46 | .DS_Store 47 | docker-compose.yml 48 | 49 | 50 | [mypy] 51 | python_version = 3.12 52 | ignore_missing_imports = True 53 | allow_redefinition = True 54 | warn_no_return = False 55 | check_untyped_defs = False 56 | disallow_untyped_defs = True 57 | warn_unused_ignores = True 58 | follow_imports = skip 59 | strict_optional = True 60 | exclude = .ve|env|logs 61 | 62 | [mypy-dateutil.*] 63 | ignore_missing_imports = True 64 | 65 | [mypy-requests.*] 66 | ignore_missing_imports = True 67 | 68 | [tool:pytest] 69 | norecursedirs=.ve 70 | addopts = -ra -q -s -v --disable-warnings 71 | 72 | [coverage:run] 73 | omit = ./conduit/infrastructure/alembic/* 74 | -------------------------------------------------------------------------------- /conduit/api/schemas/responses/article.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from pydantic import BaseModel, ConfigDict, Field 4 | 5 | from conduit.core.utils.date import convert_datetime_to_realworld 6 | from conduit.domain.dtos.article import ArticleDTO, ArticlesFeedDTO 7 | 8 | 9 | class ArticleAuthorData(BaseModel): 10 | username: str 11 | bio: str 12 | image: str | None 13 | following: bool 14 | 15 | 16 | class ArticleData(BaseModel): 17 | slug: str 18 | title: str 19 | description: str 20 | body: str 21 | tags: list[str] = Field(alias="tagList") 22 | created_at: datetime.datetime = Field(alias="createdAt") 23 | updated_at: datetime.datetime = Field(alias="updatedAt") 24 | favorited: bool = False 25 | favorites_count: int = Field(default=0, alias="favoritesCount") 26 | author: ArticleAuthorData 27 | 28 | model_config = ConfigDict( 29 | json_encoders={datetime.datetime: convert_datetime_to_realworld} 30 | ) 31 | 32 | 33 | class ArticleResponse(BaseModel): 34 | article: ArticleData 35 | 36 | @classmethod 37 | def from_dto(cls, dto: ArticleDTO) -> "ArticleResponse": 38 | article = ArticleData( 39 | slug=dto.slug, 40 | title=dto.title, 41 | description=dto.description, 42 | body=dto.body, 43 | tagList=dto.tags, 44 | createdAt=dto.created_at, 45 | updatedAt=dto.updated_at, 46 | favorited=dto.favorited, 47 | favoritesCount=dto.favorites_count, 48 | author=ArticleAuthorData( 49 | username=dto.author.username, 50 | bio=dto.author.bio, 51 | image=dto.author.image, 52 | following=dto.author.following, 53 | ), 54 | ) 55 | return ArticleResponse(article=article) 56 | 57 | 58 | class ArticlesFeedResponse(BaseModel): 59 | articles: list[ArticleData] 60 | articles_count: int = Field(alias="articlesCount") 61 | 62 | @classmethod 63 | def from_dto(cls, dto: ArticlesFeedDTO) -> "ArticlesFeedResponse": 64 | articles = [ 65 | ArticleResponse.from_dto(dto=article_dto).article 66 | for article_dto in dto.articles 67 | ] 68 | return ArticlesFeedResponse(articles=articles, articlesCount=dto.articles_count) 69 | -------------------------------------------------------------------------------- /conduit/domain/repositories/article.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | from conduit.domain.dtos.article import ( 5 | ArticleDTO, 6 | ArticleRecordDTO, 7 | CreateArticleDTO, 8 | UpdateArticleDTO, 9 | ) 10 | 11 | 12 | class IArticleRepository(abc.ABC): 13 | """Article repository interface.""" 14 | 15 | @abc.abstractmethod 16 | async def add( 17 | self, session: Any, author_id: int, create_item: CreateArticleDTO 18 | ) -> ArticleRecordDTO: ... 19 | 20 | @abc.abstractmethod 21 | async def get_by_slug_or_none( 22 | self, session: Any, slug: str 23 | ) -> ArticleRecordDTO | None: ... 24 | 25 | @abc.abstractmethod 26 | async def get_by_slug(self, session: Any, slug: str) -> ArticleRecordDTO: ... 27 | 28 | @abc.abstractmethod 29 | async def delete_by_slug(self, session: Any, slug: str) -> None: ... 30 | 31 | @abc.abstractmethod 32 | async def update_by_slug( 33 | self, session: Any, slug: str, update_item: UpdateArticleDTO 34 | ) -> ArticleRecordDTO: ... 35 | 36 | @abc.abstractmethod 37 | async def list_by_followings( 38 | self, session: Any, user_id: int, limit: int, offset: int 39 | ) -> list[ArticleRecordDTO]: ... 40 | 41 | @abc.abstractmethod 42 | async def list_by_followings_v2( 43 | self, session: Any, user_id: int, limit: int, offset: int 44 | ) -> list[ArticleDTO]: ... 45 | 46 | @abc.abstractmethod 47 | async def list_by_filters( 48 | self, 49 | session: Any, 50 | limit: int, 51 | offset: int, 52 | tag: str | None = None, 53 | author: str | None = None, 54 | favorited: str | None = None, 55 | ) -> list[ArticleRecordDTO]: ... 56 | 57 | @abc.abstractmethod 58 | async def list_by_filters_v2( 59 | self, 60 | session: Any, 61 | user_id: int | None, 62 | limit: int, 63 | offset: int, 64 | tag: str | None = None, 65 | author: str | None = None, 66 | favorited: str | None = None, 67 | ) -> list[ArticleDTO]: ... 68 | 69 | @abc.abstractmethod 70 | async def count_by_followings(self, session: Any, user_id: int) -> int: ... 71 | 72 | @abc.abstractmethod 73 | async def count_by_filters( 74 | self, 75 | session: Any, 76 | tag: str | None = None, 77 | author: str | None = None, 78 | favorited: str | None = None, 79 | ) -> int: ... 80 | -------------------------------------------------------------------------------- /conduit/domain/services/article.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any 3 | 4 | from conduit.domain.dtos.article import ( 5 | ArticleDTO, 6 | ArticlesFeedDTO, 7 | CreateArticleDTO, 8 | UpdateArticleDTO, 9 | ) 10 | from conduit.domain.dtos.user import UserDTO 11 | 12 | 13 | class IArticleService(abc.ABC): 14 | 15 | @abc.abstractmethod 16 | async def create_new_article( 17 | self, session: Any, author_id: int, article_to_create: CreateArticleDTO 18 | ) -> ArticleDTO: ... 19 | 20 | @abc.abstractmethod 21 | async def get_article_by_slug( 22 | self, session: Any, slug: str, current_user: UserDTO | None 23 | ) -> ArticleDTO: ... 24 | 25 | @abc.abstractmethod 26 | async def delete_article_by_slug( 27 | self, session: Any, slug: str, current_user: UserDTO 28 | ) -> None: ... 29 | 30 | @abc.abstractmethod 31 | async def get_articles_feed( 32 | self, session: Any, current_user: UserDTO, limit: int, offset: int 33 | ) -> ArticlesFeedDTO: ... 34 | 35 | @abc.abstractmethod 36 | async def get_articles_feed_v2( 37 | self, session: Any, current_user: UserDTO, limit: int, offset: int 38 | ) -> ArticlesFeedDTO: ... 39 | 40 | @abc.abstractmethod 41 | async def get_articles_by_filters( 42 | self, 43 | session: Any, 44 | current_user: UserDTO | None, 45 | limit: int, 46 | offset: int, 47 | tag: str | None = None, 48 | author: str | None = None, 49 | favorited: str | None = None, 50 | ) -> ArticlesFeedDTO: ... 51 | 52 | @abc.abstractmethod 53 | async def get_articles_by_filters_v2( 54 | self, 55 | session: Any, 56 | current_user: UserDTO | None, 57 | limit: int, 58 | offset: int, 59 | tag: str | None = None, 60 | author: str | None = None, 61 | favorited: str | None = None, 62 | ) -> ArticlesFeedDTO: ... 63 | 64 | @abc.abstractmethod 65 | async def update_article_by_slug( 66 | self, 67 | session: Any, 68 | slug: str, 69 | article_to_update: UpdateArticleDTO, 70 | current_user: UserDTO, 71 | ) -> ArticleDTO: ... 72 | 73 | @abc.abstractmethod 74 | async def add_article_into_favorites( 75 | self, session: Any, slug: str, current_user: UserDTO 76 | ) -> ArticleDTO: ... 77 | 78 | @abc.abstractmethod 79 | async def remove_article_from_favorites( 80 | self, session: Any, slug: str, current_user: UserDTO 81 | ) -> ArticleDTO: ... 82 | -------------------------------------------------------------------------------- /conduit/services/auth.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | from structlog import get_logger 3 | 4 | from conduit.core.exceptions import IncorrectLoginInputException, UserNotFoundException 5 | from conduit.domain.dtos.user import ( 6 | CreatedUserDTO, 7 | CreateUserDTO, 8 | LoggedInUserDTO, 9 | LoginUserDTO, 10 | ) 11 | from conduit.domain.services.auth import IUserAuthService 12 | from conduit.domain.services.auth_token import IAuthTokenService 13 | from conduit.domain.services.user import IUserService 14 | from conduit.services.password import verify_password 15 | 16 | logger = get_logger() 17 | 18 | 19 | class UserAuthService(IUserAuthService): 20 | """Service to handle users auth logic.""" 21 | 22 | def __init__( 23 | self, user_service: IUserService, auth_token_service: IAuthTokenService 24 | ): 25 | self._user_service = user_service 26 | self._auth_token_service = auth_token_service 27 | 28 | async def sign_up_user( 29 | self, session: AsyncSession, user_to_create: CreateUserDTO 30 | ) -> CreatedUserDTO: 31 | user = await self._user_service.create_user( 32 | session=session, user_to_create=user_to_create 33 | ) 34 | jwt_token = self._auth_token_service.generate_jwt_token(user=user) 35 | return CreatedUserDTO( 36 | id=user.id, 37 | email=user.email, 38 | username=user.username, 39 | bio=user.bio, 40 | image=user.image_url, 41 | token=jwt_token, 42 | ) 43 | 44 | async def sign_in_user( 45 | self, session: AsyncSession, user_to_login: LoginUserDTO 46 | ) -> LoggedInUserDTO: 47 | try: 48 | user = await self._user_service.get_user_by_email( 49 | session=session, email=user_to_login.email 50 | ) 51 | except UserNotFoundException: 52 | logger.error("User not found", email=user_to_login.email) 53 | raise IncorrectLoginInputException() 54 | 55 | if not verify_password( 56 | plain_password=user_to_login.password, hashed_password=user.password_hash 57 | ): 58 | logger.error("Incorrect password", user_id=user_to_login.email) 59 | raise IncorrectLoginInputException() 60 | 61 | jwt_token = self._auth_token_service.generate_jwt_token(user=user) 62 | return LoggedInUserDTO( 63 | email=user.email, 64 | username=user.username, 65 | bio=user.bio, 66 | image=user.image_url, 67 | token=jwt_token, 68 | ) 69 | -------------------------------------------------------------------------------- /conduit/infrastructure/repositories/comment.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import delete, insert, select 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | from sqlalchemy.sql.functions import count 6 | 7 | from conduit.core.exceptions import CommentNotFoundException 8 | from conduit.domain.dtos.comment import CommentRecordDTO, CreateCommentDTO 9 | from conduit.domain.mapper import IModelMapper 10 | from conduit.domain.repositories.comment import ICommentRepository 11 | from conduit.infrastructure.models import Comment 12 | 13 | 14 | class CommentRepository(ICommentRepository): 15 | 16 | def __init__(self, comment_mapper: IModelMapper[Comment, CommentRecordDTO]): 17 | self._comment_mapper = comment_mapper 18 | 19 | async def add( 20 | self, 21 | session: AsyncSession, 22 | author_id: int, 23 | article_id: int, 24 | create_item: CreateCommentDTO, 25 | ) -> CommentRecordDTO: 26 | query = ( 27 | insert(Comment) 28 | .values( 29 | author_id=author_id, 30 | article_id=article_id, 31 | body=create_item.body, 32 | created_at=datetime.now(), 33 | updated_at=datetime.now(), 34 | ) 35 | .returning(Comment) 36 | ) 37 | result = await session.execute(query) 38 | return self._comment_mapper.to_dto(result.scalar()) 39 | 40 | async def get_or_none( 41 | self, session: AsyncSession, comment_id: int 42 | ) -> CommentRecordDTO | None: 43 | query = select(Comment).where(Comment.id == comment_id) 44 | if comment := await session.scalar(query): 45 | return self._comment_mapper.to_dto(comment) 46 | 47 | async def get(self, session: AsyncSession, comment_id: int) -> CommentRecordDTO: 48 | query = select(Comment).where(Comment.id == comment_id) 49 | if not (comment := await session.scalar(query)): 50 | raise CommentNotFoundException() 51 | return self._comment_mapper.to_dto(comment) 52 | 53 | async def list( 54 | self, session: AsyncSession, article_id: int 55 | ) -> list[CommentRecordDTO]: 56 | query = select(Comment).where(Comment.article_id == article_id) 57 | comments = await session.scalars(query) 58 | return [self._comment_mapper.to_dto(comment) for comment in comments] 59 | 60 | async def delete(self, session: AsyncSession, comment_id: int) -> None: 61 | query = delete(Comment).where(Comment.id == comment_id) 62 | await session.execute(query) 63 | 64 | async def count(self, session: AsyncSession, article_id: int) -> int: 65 | query = select(count(Comment.id)).where(Comment.article_id == article_id) 66 | result = await session.execute(query) 67 | return result.scalar() 68 | -------------------------------------------------------------------------------- /conduit/api/schemas/responses/user.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from conduit.domain.dtos.user import ( 4 | CreatedUserDTO, 5 | LoggedInUserDTO, 6 | UpdatedUserDTO, 7 | UserDTO, 8 | ) 9 | 10 | 11 | class UserIDData(BaseModel): 12 | id: int 13 | 14 | 15 | class UserBaseData(BaseModel): 16 | email: str 17 | username: str 18 | bio: str 19 | image: str 20 | token: str 21 | 22 | 23 | class LoggedInUserData(UserBaseData): 24 | pass 25 | 26 | 27 | class RegisteredUserData(UserIDData, UserBaseData): 28 | pass 29 | 30 | 31 | class CurrentUserData(UserIDData, UserBaseData): 32 | pass 33 | 34 | 35 | class UpdatedUserData(UserIDData, UserBaseData): 36 | pass 37 | 38 | 39 | class UserRegistrationResponse(BaseModel): 40 | user: RegisteredUserData 41 | 42 | @classmethod 43 | def from_dto(cls, dto: CreatedUserDTO) -> "UserRegistrationResponse": 44 | return UserRegistrationResponse( 45 | user=RegisteredUserData( 46 | id=dto.id, 47 | email=dto.email, 48 | username=dto.username, 49 | bio=dto.bio, 50 | image=dto.image, 51 | token=dto.token, 52 | ) 53 | ) 54 | 55 | 56 | class UserLoginResponse(BaseModel): 57 | user: LoggedInUserData 58 | 59 | @classmethod 60 | def from_dto(cls, dto: LoggedInUserDTO) -> "UserLoginResponse": 61 | return UserLoginResponse( 62 | user=LoggedInUserData( 63 | email=dto.email, 64 | username=dto.username, 65 | bio=dto.bio, 66 | image=dto.image, 67 | token=dto.token, 68 | ) 69 | ) 70 | 71 | 72 | class CurrentUserResponse(BaseModel): 73 | user: CurrentUserData 74 | 75 | @classmethod 76 | def from_dto(cls, dto: UserDTO, token: str) -> "CurrentUserResponse": 77 | return CurrentUserResponse( 78 | user=CurrentUserData( 79 | id=dto.id, 80 | email=dto.email, 81 | username=dto.username, 82 | bio=dto.bio, 83 | image=dto.image_url, 84 | token=token, 85 | ) 86 | ) 87 | 88 | 89 | class UpdatedUserResponse(BaseModel): 90 | user: UpdatedUserData 91 | 92 | @classmethod 93 | def from_dto(cls, dto: UpdatedUserDTO, token: str) -> "UpdatedUserResponse": 94 | return UpdatedUserResponse( 95 | user=UpdatedUserData( 96 | id=dto.id, 97 | email=dto.email, 98 | username=dto.username, 99 | bio=dto.bio, 100 | image=dto.image, 101 | token=token, 102 | ) 103 | ) 104 | -------------------------------------------------------------------------------- /conduit/infrastructure/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from functools import partial 3 | 4 | from sqlalchemy import ForeignKey 5 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 6 | 7 | 8 | class Base(DeclarativeBase): 9 | pass 10 | 11 | 12 | relationship = partial(relationship, lazy="raise") 13 | 14 | 15 | class User(Base): 16 | __tablename__ = "user" 17 | 18 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 19 | username: Mapped[str] = mapped_column(unique=True) 20 | email: Mapped[str] = mapped_column(unique=True) 21 | password_hash: Mapped[str] 22 | bio: Mapped[str] 23 | image_url: Mapped[str] = mapped_column(nullable=True) 24 | created_at: Mapped[datetime] 25 | updated_at: Mapped[datetime] = mapped_column(nullable=True) 26 | 27 | 28 | class Follower(Base): 29 | __tablename__ = "follower" 30 | 31 | # "follower" is a user who follows a user. 32 | follower_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True) 33 | # "following" is a user who you follow. 34 | following_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True) 35 | created_at: Mapped[datetime] 36 | 37 | 38 | class Article(Base): 39 | __tablename__ = "article" 40 | 41 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 42 | author_id: Mapped[int] = mapped_column(ForeignKey("user.id"), nullable=False) 43 | slug: Mapped[str] = mapped_column(nullable=False, unique=True) 44 | title: Mapped[str] 45 | description: Mapped[str] 46 | body: Mapped[str] 47 | created_at: Mapped[datetime] 48 | updated_at: Mapped[datetime] = mapped_column(nullable=True) 49 | 50 | 51 | class Tag(Base): 52 | __tablename__ = "tag" 53 | 54 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 55 | tag: Mapped[str] = mapped_column(nullable=False, unique=True) 56 | created_at: Mapped[datetime] 57 | 58 | 59 | class ArticleTag(Base): 60 | __tablename__ = "article_tag" 61 | 62 | article_id: Mapped[int] = mapped_column( 63 | ForeignKey("article.id", ondelete="CASCADE"), primary_key=True 64 | ) 65 | tag_id: Mapped[int] = mapped_column(ForeignKey("tag.id"), primary_key=True) 66 | created_at: Mapped[datetime] 67 | 68 | 69 | class Favorite(Base): 70 | __tablename__ = "favorite" 71 | 72 | user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True) 73 | article_id: Mapped[int] = mapped_column( 74 | ForeignKey("article.id", ondelete="CASCADE"), primary_key=True 75 | ) 76 | created_at: Mapped[datetime] 77 | 78 | 79 | class Comment(Base): 80 | __tablename__ = "comment" 81 | 82 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 83 | article_id: Mapped[int] = mapped_column( 84 | ForeignKey("article.id", ondelete="CASCADE"), nullable=False 85 | ) 86 | author_id: Mapped[int] = mapped_column(ForeignKey("user.id"), nullable=False) 87 | body: Mapped[str] 88 | created_at: Mapped[datetime] 89 | updated_at: Mapped[datetime] = mapped_column(nullable=True) 90 | -------------------------------------------------------------------------------- /conduit/services/user.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from conduit.core.exceptions import ( 6 | EmailAlreadyTakenException, 7 | UserNameAlreadyTakenException, 8 | ) 9 | from conduit.domain.dtos.user import ( 10 | CreateUserDTO, 11 | UpdatedUserDTO, 12 | UpdateUserDTO, 13 | UserDTO, 14 | ) 15 | from conduit.domain.repositories.user import IUserRepository 16 | from conduit.domain.services.user import IUserService 17 | 18 | 19 | class UserService(IUserService): 20 | """Service to handle user get & update logic.""" 21 | 22 | def __init__(self, user_repo: IUserRepository) -> None: 23 | self._user_repo = user_repo 24 | 25 | async def create_user( 26 | self, session: AsyncSession, user_to_create: CreateUserDTO 27 | ) -> UserDTO: 28 | if await self._user_repo.get_by_email_or_none( 29 | session=session, email=user_to_create.email 30 | ): 31 | raise EmailAlreadyTakenException() 32 | 33 | if await self._user_repo.get_by_username_or_none( 34 | session=session, username=user_to_create.username 35 | ): 36 | raise UserNameAlreadyTakenException() 37 | 38 | return await self._user_repo.add(session=session, create_item=user_to_create) 39 | 40 | async def get_user_by_id(self, session: AsyncSession, user_id: int) -> UserDTO: 41 | return await self._user_repo.get(session=session, user_id=user_id) 42 | 43 | async def get_user_by_email(self, session: AsyncSession, email: str) -> UserDTO: 44 | return await self._user_repo.get_by_email(session=session, email=email) 45 | 46 | async def get_user_by_username( 47 | self, session: AsyncSession, username: str 48 | ) -> UserDTO: 49 | return await self._user_repo.get_by_username(session=session, username=username) 50 | 51 | async def get_users_by_ids( 52 | self, session: AsyncSession, user_ids: Collection[int] 53 | ) -> list[UserDTO]: 54 | return await self._user_repo.list_by_users(session=session, user_ids=user_ids) 55 | 56 | async def update_user( 57 | self, 58 | session: AsyncSession, 59 | current_user: UserDTO, 60 | user_to_update: UpdateUserDTO, 61 | ) -> UpdatedUserDTO: 62 | if user_to_update.username and user_to_update.username != current_user.username: 63 | if await self._user_repo.get_by_username_or_none( 64 | session=session, username=user_to_update.username 65 | ): 66 | raise UserNameAlreadyTakenException() 67 | 68 | if user_to_update.email and user_to_update.email != current_user.email: 69 | if await self._user_repo.get_by_email_or_none( 70 | session=session, email=user_to_update.email 71 | ): 72 | raise EmailAlreadyTakenException() 73 | 74 | updated_user = await self._user_repo.update( 75 | session=session, user_id=current_user.id, update_item=user_to_update 76 | ) 77 | return UpdatedUserDTO( 78 | id=updated_user.id, 79 | email=updated_user.email, 80 | username=updated_user.username, 81 | bio=updated_user.bio, 82 | image=updated_user.image_url, 83 | ) 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](.github/assets/logo.png) 2 | 3 | 4 | > ### Python / FastAPI codebase containing real world examples (CRUD, auth, middlewares advanced patterns, etc.) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 5 | 6 | 7 | ### [Demo](https://demo.realworld.io/)    [RealWorld](https://github.com/gothinkster/realworld) 8 | 9 | 10 | This codebase was created to demonstrate a fully fledged backend application built with **[FastAPI](https://fastapi.tiangolo.com/)** including CRUD operations, authentication, routing, and more. 11 | 12 | For more information on how this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 13 | 14 | 15 | ## Description 16 | This project is a Python-based API that uses PostgreSQL as its database. 17 | It is built with FastAPI, a modern, fast (high-performance), web framework for building APIs with Python 3 based on standard Python type hints. 18 | 19 | ## Prerequisites 20 | - Python 3.12 21 | - FastAPI 22 | - PostgreSQL 23 | - Pytest 24 | - Docker 25 | 26 | ## Installation 27 | 28 | Create a virtual environment: 29 | 30 | ```sh 31 | make ve 32 | ``` 33 | 34 | Install dependencies: 35 | 36 | ```sh 37 | pip install -r requirements.txt 38 | ``` 39 | 40 | Configuration 41 | -------------- 42 | 43 | Replace `.env.example` with real `.env`, changing placeholders 44 | 45 | ``` 46 | SECRET_KEY=your_secret_key 47 | POSTGRES_USER=your_postgres_user 48 | POSTGRES_PASSWORD=your_postgres_password 49 | POSTGRES_DB=your_postgres_db 50 | POSTGRES_HOST=your_postgres_host 51 | POSTGRES_PORT=your_postgres_port 52 | JWT_SECRET_KEY=your_jwt_secret_key 53 | ``` 54 | 55 | Run with Docker 56 | -------------- 57 | You must have ``docker`` and ``docker-compose`` installed on your machine to start this application. 58 | 59 | Setup PostgreSQL database with docker-compose: 60 | 61 | ```sh 62 | make docker_build_postgres 63 | ``` 64 | 65 | Run the migrations: 66 | 67 | ```sh 68 | make migrate 69 | ``` 70 | 71 | Run the application server: 72 | 73 | ```sh 74 | make runserver 75 | ``` 76 | 77 | Also, you can run the fully Dockerized application with `docker-compose`: 78 | 79 | ```sh 80 | make docker_build 81 | ``` 82 | 83 | And after that run migrations: 84 | 85 | ```sh 86 | docker exec -it conduit-api alembic upgrade head 87 | ``` 88 | 89 | Run tests 90 | --------- 91 | 92 | Tests for this project are defined in the ``tests/`` folder. 93 | 94 | For running tests, you can have to create separate `.env.test` file the same as `.env` file, but with different database name.: 95 | 96 | ``` 97 | POSTGRES_DB=conduit_test 98 | ``` 99 | 100 | Then run the tests: 101 | 102 | ```sh 103 | make test 104 | ``` 105 | 106 | Or run the tests with coverage: 107 | 108 | ```sh 109 | make test-cov 110 | ``` 111 | 112 | Run Conduit Postman collection tests 113 | --------- 114 | 115 | For running tests for local application: 116 | 117 | ```sh 118 | APIURL=http://127.0.0.1:8000/api ./postman/run-api-tests.sh 119 | ``` 120 | 121 | For running tests for fully Dockerized application: 122 | 123 | ```sh 124 | APIURL=http://127.0.0.1:8080/api ./postman/run-api-tests.sh 125 | ``` 126 | 127 | Web routes 128 | ----------- 129 | All routes are available on / or /redoc paths with Swagger or ReDoc. 130 | -------------------------------------------------------------------------------- /conduit/core/dependencies.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import Depends, Query 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from conduit.api.schemas.requests.article import ArticlesFilters, ArticlesPagination 7 | from conduit.core.container import container 8 | from conduit.core.security import HTTPTokenHeader 9 | from conduit.domain.dtos.user import UserDTO 10 | from conduit.services.article import ArticleService 11 | from conduit.services.auth import UserAuthService 12 | from conduit.services.auth_token import AuthTokenService 13 | from conduit.services.comment import CommentService 14 | from conduit.services.profile import ProfileService 15 | from conduit.services.tag import TagService 16 | from conduit.services.user import UserService 17 | 18 | token_security = HTTPTokenHeader( 19 | name="Authorization", 20 | scheme_name="JWT Token", 21 | description="Token Format: `Token xxxxxx.yyyyyyy.zzzzzz`", 22 | raise_error=True, 23 | ) 24 | token_security_optional = HTTPTokenHeader( 25 | name="Authorization", 26 | scheme_name="JWT Token", 27 | description="Token Format: `Token xxxxxx.yyyyyyy.zzzzzz`", 28 | raise_error=False, 29 | ) 30 | 31 | JWTToken = Annotated[str, Depends(token_security)] 32 | JWTTokenOptional = Annotated[str, Depends(token_security_optional)] 33 | 34 | DBSession = Annotated[AsyncSession, Depends(container.session)] 35 | 36 | IAuthTokenService = Annotated[AuthTokenService, Depends(container.auth_token_service)] 37 | IUserAuthService = Annotated[UserAuthService, Depends(container.user_auth_service)] 38 | IUserService = Annotated[UserService, Depends(container.user_service)] 39 | IProfileService = Annotated[ProfileService, Depends(container.profile_service)] 40 | ITagService = Annotated[TagService, Depends(container.tag_service)] 41 | IArticleService = Annotated[ArticleService, Depends(container.article_service)] 42 | ICommentService = Annotated[CommentService, Depends(container.comment_service)] 43 | 44 | DEFAULT_ARTICLES_LIMIT = 20 45 | DEFAULT_ARTICLES_OFFSET = 0 46 | 47 | 48 | def get_articles_pagination( 49 | limit: int = Query(DEFAULT_ARTICLES_LIMIT, ge=1), 50 | offset: int = Query(DEFAULT_ARTICLES_OFFSET, ge=0), 51 | ) -> ArticlesPagination: 52 | limit = min(limit, DEFAULT_ARTICLES_LIMIT) 53 | return ArticlesPagination(limit=limit, offset=offset) 54 | 55 | 56 | def get_articles_filters( 57 | tag: str | None = None, author: str | None = None, favorited: str | None = None 58 | ) -> ArticlesFilters: 59 | return ArticlesFilters(tag=tag, author=author, favorited=favorited) 60 | 61 | 62 | async def get_current_user_or_none( 63 | token: JWTTokenOptional, 64 | session: DBSession, 65 | auth_token_service: IAuthTokenService, 66 | user_service: IUserService, 67 | ) -> UserDTO | None: 68 | if token: 69 | jwt_user = auth_token_service.parse_jwt_token(token=token) 70 | current_user_dto = await user_service.get_user_by_id( 71 | session=session, user_id=jwt_user.user_id 72 | ) 73 | return current_user_dto 74 | 75 | 76 | async def get_current_user( 77 | token: JWTToken, 78 | session: DBSession, 79 | auth_token_service: IAuthTokenService, 80 | user_service: IUserService, 81 | ) -> UserDTO: 82 | jwt_user = auth_token_service.parse_jwt_token(token=token) 83 | current_user_dto = await user_service.get_user_by_id( 84 | session=session, user_id=jwt_user.user_id 85 | ) 86 | return current_user_dto 87 | 88 | 89 | Pagination = Annotated[ArticlesPagination, Depends(get_articles_pagination)] 90 | QueryFilters = Annotated[ArticlesFilters, Depends(get_articles_filters)] 91 | CurrentOptionalUser = Annotated[UserDTO | None, Depends(get_current_user_or_none)] 92 | CurrentUser = Annotated[UserDTO, Depends(get_current_user)] 93 | -------------------------------------------------------------------------------- /conduit/core/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import structlog 4 | from structlog.typing import EventDict, Processor 5 | 6 | from conduit.core.config import get_app_settings 7 | 8 | __all__ = ["configure_logger"] 9 | 10 | DEFAULT_LOGGER_NAME = "conduit-api" 11 | 12 | settings = get_app_settings() 13 | 14 | 15 | def rename_event_key(_: logging.Logger, __: str, event_dict: EventDict) -> EventDict: 16 | """ 17 | Rename `event` field to `message`. 18 | """ 19 | event_dict["message"] = event_dict.pop("event") 20 | return event_dict 21 | 22 | 23 | def drop_color_message_key( 24 | _: logging.Logger, __: str, event_dict: EventDict 25 | ) -> EventDict: 26 | """ 27 | Uvicorn logs the message a second time in the extra `color_message`, but we don't 28 | need it. 29 | This processor drops the key from the event dict if it exists. 30 | """ 31 | event_dict.pop("color_message", None) 32 | return event_dict 33 | 34 | 35 | def configure_logger(json_logs: bool = False) -> None: 36 | timestamper = structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S") 37 | 38 | shared_processors: list[Processor] = [ 39 | structlog.contextvars.merge_contextvars, 40 | structlog.stdlib.add_logger_name, 41 | structlog.stdlib.add_log_level, 42 | structlog.stdlib.PositionalArgumentsFormatter(), 43 | structlog.stdlib.ExtraAdder(), 44 | drop_color_message_key, 45 | timestamper, 46 | structlog.processors.StackInfoRenderer(), 47 | ] 48 | 49 | if json_logs: 50 | # We rename the `event` key to `message` only in JSON logs. 51 | shared_processors.append(rename_event_key) 52 | # Format the exception only for JSON logs, as we want to pretty-print them when 53 | # using the ConsoleRenderer. 54 | shared_processors.append(structlog.processors.format_exc_info) 55 | 56 | structlog.configure( 57 | processors=shared_processors 58 | + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter], 59 | logger_factory=structlog.stdlib.LoggerFactory(), 60 | cache_logger_on_first_use=True, 61 | ) 62 | 63 | log_renderer = ( 64 | structlog.processors.JSONRenderer() 65 | if json_logs 66 | else structlog.dev.ConsoleRenderer() 67 | ) 68 | 69 | _configure_default_logging_by_custom(shared_processors, log_renderer) 70 | 71 | 72 | def _configure_default_logging_by_custom( 73 | shared_processors: list[Processor], log_renderer: structlog.types.Processor 74 | ) -> None: 75 | # Use `ProcessorFormatter` to format all `logging` entries. 76 | formatter = structlog.stdlib.ProcessorFormatter( 77 | foreign_pre_chain=shared_processors, 78 | processors=[ 79 | # Remove _record & _from_structlog. 80 | structlog.stdlib.ProcessorFormatter.remove_processors_meta, 81 | log_renderer, 82 | ], 83 | ) 84 | 85 | handler = logging.StreamHandler() 86 | # Use structlog `ProcessorFormatter` to format all `logging` entries. 87 | handler.setFormatter(formatter) 88 | 89 | # Disable the `passlib` logger. 90 | logging.getLogger("passlib").setLevel(logging.ERROR) 91 | logging.getLogger("asyncio").setLevel(logging.WARNING) 92 | 93 | # Set logging level. 94 | root_logger = logging.getLogger() 95 | root_logger.addHandler(handler) 96 | root_logger.setLevel(settings.logging_level) 97 | 98 | for _log in ["uvicorn", "uvicorn.error", "uvicorn.access"]: 99 | # Clear the log handlers for uvicorn loggers, and enable propagation 100 | # so the messages are caught by our root logger and formatted correctly 101 | # by structlog. 102 | logging.getLogger(_log).handlers.clear() 103 | logging.getLogger(_log).propagate = True 104 | -------------------------------------------------------------------------------- /tests/api/routes/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from conduit.domain.dtos.user import UserDTO 6 | from conduit.infrastructure.repositories.user import UserRepository 7 | from conduit.services.password import verify_password 8 | 9 | 10 | @pytest.mark.anyio 11 | async def test_failed_login_with_invalid_token_prefix( 12 | test_client: AsyncClient, test_user: UserDTO, jwt_token: str 13 | ) -> None: 14 | response = await test_client.get( 15 | url="/user", headers={"Authorization": f"JWTToken {jwt_token}"} 16 | ) 17 | assert response.status_code == 403 18 | 19 | 20 | @pytest.mark.anyio 21 | async def test_failed_login_when_user_does_not_exist( 22 | test_client: AsyncClient, not_exists_jwt_token: str 23 | ) -> None: 24 | response = await test_client.get( 25 | url="/user", headers={"Authorization": f"Token {not_exists_jwt_token}"} 26 | ) 27 | assert response.status_code == 404 28 | 29 | 30 | @pytest.mark.anyio 31 | async def test_user_can_not_get_profile_without_auth( 32 | test_client: AsyncClient, test_user: UserDTO 33 | ) -> None: 34 | response = await test_client.get(url="/user") 35 | assert response.status_code == 403 36 | 37 | 38 | @pytest.mark.anyio 39 | async def test_user_can_get_own_profile( 40 | authorized_test_client: AsyncClient, jwt_token: str 41 | ) -> None: 42 | response = await authorized_test_client.get(url="/user") 43 | assert response.status_code == 200 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "update_field, update_value", 48 | ( 49 | ("username", "updated_username"), 50 | ("email", "updated_email@gmail.com"), 51 | ("bio", "Updated bio"), 52 | ("image", "https://image.com/best-image-ever"), 53 | ), 54 | ) 55 | @pytest.mark.anyio 56 | async def test_user_can_update_own_profile( 57 | authorized_test_client: AsyncClient, 58 | test_user: UserDTO, 59 | update_value: str, 60 | update_field: str, 61 | ) -> None: 62 | response = await authorized_test_client.put( 63 | url="/user", json={"user": {update_field: update_value}} 64 | ) 65 | assert response.status_code == 200 66 | 67 | updated_user = response.json() 68 | assert updated_user["user"][update_field] == update_value 69 | 70 | 71 | @pytest.mark.anyio 72 | async def test_user_can_change_password( 73 | authorized_test_client: AsyncClient, 74 | test_user: UserDTO, 75 | session: AsyncSession, 76 | user_repository: UserRepository, 77 | ) -> None: 78 | response = await authorized_test_client.put( 79 | url="/user", json={"user": {"password": "new_password"}} 80 | ) 81 | assert response.status_code == 200 82 | 83 | user = await user_repository.get_by_email(session=session, email=test_user.email) 84 | assert verify_password( 85 | plain_password="new_password", hashed_password=user.password_hash 86 | ) 87 | 88 | 89 | @pytest.mark.parametrize( 90 | "update_field, update_value", 91 | (("username", "taken_username"), ("email", "taken@gmail.com")), 92 | ) 93 | @pytest.mark.anyio 94 | async def test_user_can_not_use_taken_credentials( 95 | authorized_test_client: AsyncClient, update_field: str, update_value: str 96 | ) -> None: 97 | payload = { 98 | "user": { 99 | "username": "free_username", 100 | "password": "password", 101 | "email": "free_email@gmail.com", 102 | } 103 | } 104 | payload["user"].update({update_field: update_value}) 105 | response = await authorized_test_client.post("/users", json=payload) 106 | assert response.status_code == 200 107 | 108 | response = await authorized_test_client.put( 109 | url="/user", json={"user": {update_field: update_value}} 110 | ) 111 | assert response.status_code == 400 112 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = ./conduit/infrastructure/alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 20 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to ZoneInfo() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to ./conduit/infrastructure/database/alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:./conduit/infrastructure/database/alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | output_encoding = utf-8 62 | 63 | [post_write_hooks] 64 | # post_write_hooks defines scripts or Python functions that are run 65 | # on newly generated revision scripts. See the documentation for further 66 | # detail and examples 67 | 68 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 69 | hooks = black 70 | black.type = console_scripts 71 | black.entrypoint = black 72 | black.options = -l 79 REVISION_SCRIPT_FILENAME 73 | 74 | 75 | # Logging configuration 76 | [loggers] 77 | keys = root,sqlalchemy,alembic 78 | 79 | [handlers] 80 | keys = console 81 | 82 | [formatters] 83 | keys = generic 84 | 85 | [logger_root] 86 | level = WARN 87 | handlers = console 88 | qualname = 89 | 90 | [logger_sqlalchemy] 91 | level = WARN 92 | handlers = 93 | qualname = sqlalchemy.engine 94 | 95 | [logger_alembic] 96 | level = INFO 97 | handlers = 98 | qualname = alembic 99 | 100 | [handler_console] 101 | class = StreamHandler 102 | args = (sys.stderr,) 103 | level = NOTSET 104 | formatter = generic 105 | 106 | [formatter_generic] 107 | format = %(levelname)-5.5s [%(name)s] %(message)s 108 | datefmt = %H:%M:%S 109 | -------------------------------------------------------------------------------- /conduit/services/comment.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from conduit.core.exceptions import CommentPermissionException 4 | from conduit.domain.dtos.comment import ( 5 | CommentDTO, 6 | CommentRecordDTO, 7 | CommentsListDTO, 8 | CreateCommentDTO, 9 | ) 10 | from conduit.domain.dtos.profile import ProfileDTO 11 | from conduit.domain.dtos.user import UserDTO 12 | from conduit.domain.repositories.article import IArticleRepository 13 | from conduit.domain.repositories.comment import ICommentRepository 14 | from conduit.domain.services.comment import ICommentService 15 | from conduit.domain.services.profile import IProfileService 16 | 17 | 18 | class CommentService(ICommentService): 19 | 20 | def __init__( 21 | self, 22 | article_repo: IArticleRepository, 23 | comment_repo: ICommentRepository, 24 | profile_service: IProfileService, 25 | ) -> None: 26 | self._article_repo = article_repo 27 | self._comment_repo = comment_repo 28 | self._profile_service = profile_service 29 | 30 | async def create_article_comment( 31 | self, 32 | session: AsyncSession, 33 | slug: str, 34 | comment_to_create: CreateCommentDTO, 35 | current_user: UserDTO, 36 | ) -> CommentDTO: 37 | article = await self._article_repo.get_by_slug(session=session, slug=slug) 38 | profile = ProfileDTO( 39 | user_id=current_user.id, 40 | username=current_user.username, 41 | bio=current_user.bio, 42 | image=current_user.image_url, 43 | following=False, 44 | ) 45 | comment_record_dto = await self._comment_repo.add( 46 | session=session, 47 | author_id=current_user.id, 48 | article_id=article.id, 49 | create_item=comment_to_create, 50 | ) 51 | return CommentDTO( 52 | id=comment_record_dto.id, 53 | body=comment_record_dto.body, 54 | author=profile, 55 | created_at=comment_record_dto.created_at, 56 | updated_at=comment_record_dto.updated_at, 57 | ) 58 | 59 | async def get_article_comments( 60 | self, session: AsyncSession, slug: str, current_user: UserDTO | None = None 61 | ) -> CommentsListDTO: 62 | article = await self._article_repo.get_by_slug(session=session, slug=slug) 63 | comment_records = await self._comment_repo.list( 64 | session=session, article_id=article.id 65 | ) 66 | profiles_map = await self._get_profiles_mapping( 67 | session=session, comments=comment_records, current_user=current_user 68 | ) 69 | comments = [ 70 | CommentDTO( 71 | id=comment_record_dto.id, 72 | body=comment_record_dto.body, 73 | author=profiles_map[comment_record_dto.author_id], 74 | created_at=comment_record_dto.created_at, 75 | updated_at=comment_record_dto.updated_at, 76 | ) 77 | for comment_record_dto in comment_records 78 | ] 79 | comments_count = await self._comment_repo.count( 80 | session=session, article_id=article.id 81 | ) 82 | return CommentsListDTO(comments=comments, comments_count=comments_count) 83 | 84 | async def delete_article_comment( 85 | self, session: AsyncSession, slug: str, comment_id: int, current_user: UserDTO 86 | ) -> None: 87 | # Check if article exists before deleting the comment. 88 | await self._article_repo.get_by_slug(session=session, slug=slug) 89 | 90 | comment = await self._comment_repo.get(session=session, comment_id=comment_id) 91 | if comment.author_id != current_user.id: 92 | raise CommentPermissionException() 93 | 94 | await self._comment_repo.delete(session=session, comment_id=comment_id) 95 | 96 | async def _get_profiles_mapping( 97 | self, 98 | session: AsyncSession, 99 | comments: list[CommentRecordDTO], 100 | current_user: UserDTO | None, 101 | ) -> dict[int, ProfileDTO]: 102 | comments_profiles = await self._profile_service.get_profiles_by_user_ids( 103 | session=session, 104 | user_ids=list({comment.author_id for comment in comments}), 105 | current_user=current_user, 106 | ) 107 | return {profile.user_id: profile for profile in comments_profiles} 108 | -------------------------------------------------------------------------------- /conduit/infrastructure/repositories/user.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection 2 | from datetime import datetime 3 | 4 | from sqlalchemy import insert, select, update 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from conduit.core.exceptions import UserNotFoundException 8 | from conduit.domain.dtos.user import CreateUserDTO, UpdateUserDTO, UserDTO 9 | from conduit.domain.mapper import IModelMapper 10 | from conduit.domain.repositories.user import IUserRepository 11 | from conduit.infrastructure.models import User 12 | from conduit.services.password import get_password_hash 13 | 14 | 15 | class UserRepository(IUserRepository): 16 | """Repository for User model.""" 17 | 18 | def __init__(self, user_mapper: IModelMapper[User, UserDTO]): 19 | self._user_mapper = user_mapper 20 | 21 | async def add(self, session: AsyncSession, create_item: CreateUserDTO) -> UserDTO: 22 | query = ( 23 | insert(User) 24 | .values( 25 | username=create_item.username, 26 | email=create_item.email, 27 | password_hash=get_password_hash(create_item.password), 28 | image_url="https://api.realworld.io/images/smiley-cyrus.jpeg", 29 | bio="", 30 | created_at=datetime.now(), 31 | ) 32 | .returning(User) 33 | ) 34 | result = await session.execute(query) 35 | return self._user_mapper.to_dto(result.scalar()) 36 | 37 | async def get_by_email_or_none( 38 | self, session: AsyncSession, email: str 39 | ) -> UserDTO | None: 40 | query = select(User).where(User.email == email) 41 | if user := await session.scalar(query): 42 | return self._user_mapper.to_dto(user) 43 | 44 | async def get_by_email(self, session: AsyncSession, email: str) -> UserDTO: 45 | query = select(User).where(User.email == email) 46 | if not (user := await session.scalar(query)): 47 | raise UserNotFoundException() 48 | return self._user_mapper.to_dto(user) 49 | 50 | async def get_or_none(self, session: AsyncSession, user_id: int) -> UserDTO | None: 51 | query = select(User).where(User.id == user_id) 52 | if user := await session.scalar(query): 53 | return self._user_mapper.to_dto(user) 54 | 55 | async def get(self, session: AsyncSession, user_id: int) -> UserDTO: 56 | query = select(User).where(User.id == user_id) 57 | if not (user := await session.scalar(query)): 58 | raise UserNotFoundException() 59 | return self._user_mapper.to_dto(user) 60 | 61 | async def list_by_users( 62 | self, session: AsyncSession, user_ids: Collection[int] 63 | ) -> list[UserDTO]: 64 | query = select(User).where(User.id.in_(user_ids)) 65 | users = await session.scalars(query) 66 | return [self._user_mapper.to_dto(user) for user in users] 67 | 68 | async def get_by_username_or_none( 69 | self, session: AsyncSession, username: str 70 | ) -> UserDTO | None: 71 | query = select(User).where(User.username == username) 72 | if user := await session.scalar(query): 73 | return self._user_mapper.to_dto(user) 74 | 75 | async def get_by_username(self, session: AsyncSession, username: str) -> UserDTO: 76 | query = select(User).where(User.username == username) 77 | if not (user := await session.scalar(query)): 78 | raise UserNotFoundException() 79 | return self._user_mapper.to_dto(user) 80 | 81 | async def update( 82 | self, session: AsyncSession, user_id: int, update_item: UpdateUserDTO 83 | ) -> UserDTO: 84 | query = ( 85 | update(User) 86 | .where(User.id == user_id) 87 | .values(updated_at=datetime.now()) 88 | .returning(User) 89 | ) 90 | if update_item.username is not None: 91 | query = query.values(username=update_item.username) 92 | if update_item.email is not None: 93 | query = query.values(email=update_item.email) 94 | if update_item.password is not None: 95 | query = query.values(password_hash=get_password_hash(update_item.password)) 96 | if update_item.bio is not None: 97 | query = query.values(bio=update_item.bio) 98 | if update_item.image_url is not None: 99 | query = query.values(image_url=update_item.image_url) 100 | 101 | result = await session.execute(query) 102 | return self._user_mapper.to_dto(result.scalar()) 103 | -------------------------------------------------------------------------------- /conduit/infrastructure/alembic/versions/666cc53a93be_add_tables.py: -------------------------------------------------------------------------------- 1 | """add tables 2 | 3 | Revision ID: 666cc53a93be 4 | Revises: 5 | Create Date: 2024-04-15 21:23:56.595004 6 | 7 | """ 8 | 9 | from collections.abc import Sequence 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | revision: str = "666cc53a93be" 15 | down_revision: str | None = None 16 | branch_labels: str | Sequence[str] | None = None 17 | depends_on: str | Sequence[str] | None = None 18 | 19 | 20 | def upgrade() -> None: 21 | op.create_table( 22 | "tag", 23 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 24 | sa.Column("tag", sa.String(), nullable=False), 25 | sa.Column("created_at", sa.DateTime(), nullable=False), 26 | sa.PrimaryKeyConstraint("id"), 27 | sa.UniqueConstraint("tag"), 28 | ) 29 | op.create_table( 30 | "user", 31 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 32 | sa.Column("username", sa.String(), nullable=False), 33 | sa.Column("email", sa.String(), nullable=False), 34 | sa.Column("password_hash", sa.String(), nullable=False), 35 | sa.Column("bio", sa.String(), nullable=False), 36 | sa.Column("image_url", sa.String(), nullable=True), 37 | sa.Column("created_at", sa.DateTime(), nullable=False), 38 | sa.Column("updated_at", sa.DateTime(), nullable=True), 39 | sa.PrimaryKeyConstraint("id"), 40 | sa.UniqueConstraint("email"), 41 | sa.UniqueConstraint("username"), 42 | ) 43 | op.create_table( 44 | "article", 45 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 46 | sa.Column("author_id", sa.Integer(), nullable=False), 47 | sa.Column("slug", sa.String(), nullable=False), 48 | sa.Column("title", sa.String(), nullable=False), 49 | sa.Column("description", sa.String(), nullable=False), 50 | sa.Column("body", sa.String(), nullable=False), 51 | sa.Column("created_at", sa.DateTime(), nullable=False), 52 | sa.Column("updated_at", sa.DateTime(), nullable=True), 53 | sa.ForeignKeyConstraint(["author_id"], ["user.id"]), 54 | sa.PrimaryKeyConstraint("id"), 55 | sa.UniqueConstraint("slug"), 56 | ) 57 | op.create_table( 58 | "follower", 59 | sa.Column("follower_id", sa.Integer(), nullable=False), 60 | sa.Column("following_id", sa.Integer(), nullable=False), 61 | sa.Column("created_at", sa.DateTime(), nullable=False), 62 | sa.ForeignKeyConstraint(["follower_id"], ["user.id"]), 63 | sa.ForeignKeyConstraint(["following_id"], ["user.id"]), 64 | sa.PrimaryKeyConstraint("follower_id", "following_id"), 65 | ) 66 | op.create_table( 67 | "article_tag", 68 | sa.Column("article_id", sa.Integer(), nullable=False), 69 | sa.Column("tag_id", sa.Integer(), nullable=False), 70 | sa.Column("created_at", sa.DateTime(), nullable=False), 71 | sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"), 72 | sa.ForeignKeyConstraint(["tag_id"], ["tag.id"]), 73 | sa.PrimaryKeyConstraint("article_id", "tag_id"), 74 | ) 75 | op.create_table( 76 | "comment", 77 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 78 | sa.Column("article_id", sa.Integer(), nullable=False), 79 | sa.Column("author_id", sa.Integer(), nullable=False), 80 | sa.Column("body", sa.String(), nullable=False), 81 | sa.Column("created_at", sa.DateTime(), nullable=False), 82 | sa.Column("updated_at", sa.DateTime(), nullable=True), 83 | sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"), 84 | sa.ForeignKeyConstraint(["author_id"], ["user.id"]), 85 | sa.PrimaryKeyConstraint("id"), 86 | ) 87 | op.create_table( 88 | "favorite", 89 | sa.Column("user_id", sa.Integer(), nullable=False), 90 | sa.Column("article_id", sa.Integer(), nullable=False), 91 | sa.Column("created_at", sa.DateTime(), nullable=False), 92 | sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"), 93 | sa.ForeignKeyConstraint(["user_id"], ["user.id"]), 94 | sa.PrimaryKeyConstraint("user_id", "article_id"), 95 | ) 96 | 97 | 98 | def downgrade() -> None: 99 | op.drop_table("favorite") 100 | op.drop_table("comment") 101 | op.drop_table("article_tag") 102 | op.drop_table("follower") 103 | op.drop_table("article") 104 | op.drop_table("user") 105 | op.drop_table("tag") 106 | -------------------------------------------------------------------------------- /conduit/api/routes/article.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from starlette import status 3 | 4 | from conduit.api.schemas.requests.article import ( 5 | CreateArticleRequest, 6 | UpdateArticleRequest, 7 | ) 8 | from conduit.api.schemas.responses.article import ArticleResponse, ArticlesFeedResponse 9 | from conduit.core.dependencies import ( 10 | CurrentOptionalUser, 11 | CurrentUser, 12 | DBSession, 13 | IArticleService, 14 | Pagination, 15 | QueryFilters, 16 | ) 17 | 18 | router = APIRouter() 19 | 20 | 21 | @router.get("/feed", response_model=ArticlesFeedResponse) 22 | async def get_article_feed( 23 | pagination: Pagination, 24 | session: DBSession, 25 | current_user: CurrentUser, 26 | article_service: IArticleService, 27 | ) -> ArticlesFeedResponse: 28 | """ 29 | Get article feed from following users. 30 | """ 31 | articles_feed_dto = await article_service.get_articles_feed_v2( 32 | session=session, 33 | current_user=current_user, 34 | limit=pagination.limit, 35 | offset=pagination.offset, 36 | ) 37 | return ArticlesFeedResponse.from_dto(dto=articles_feed_dto) 38 | 39 | 40 | @router.get("", response_model=ArticlesFeedResponse) 41 | async def get_global_article_feed( 42 | pagination: Pagination, 43 | articles_filters: QueryFilters, 44 | session: DBSession, 45 | current_user: CurrentOptionalUser, 46 | article_service: IArticleService, 47 | ) -> ArticlesFeedResponse: 48 | """ 49 | Get global article feed. 50 | """ 51 | articles_feed_dto = await article_service.get_articles_by_filters_v2( 52 | session=session, 53 | current_user=current_user, 54 | tag=articles_filters.tag, 55 | author=articles_filters.author, 56 | favorited=articles_filters.favorited, 57 | limit=pagination.limit, 58 | offset=pagination.offset, 59 | ) 60 | return ArticlesFeedResponse.from_dto(dto=articles_feed_dto) 61 | 62 | 63 | @router.get("/{slug}", response_model=ArticleResponse) 64 | async def get_article( 65 | slug: str, 66 | session: DBSession, 67 | current_user: CurrentOptionalUser, 68 | article_service: IArticleService, 69 | ) -> ArticleResponse: 70 | """ 71 | Get new article by slug. 72 | """ 73 | article_dto = await article_service.get_article_by_slug( 74 | session=session, slug=slug, current_user=current_user 75 | ) 76 | return ArticleResponse.from_dto(dto=article_dto) 77 | 78 | 79 | @router.post("", response_model=ArticleResponse) 80 | async def create_article( 81 | payload: CreateArticleRequest, 82 | session: DBSession, 83 | current_user: CurrentUser, 84 | article_service: IArticleService, 85 | ) -> ArticleResponse: 86 | """ 87 | Create new article. 88 | """ 89 | article_dto = await article_service.create_new_article( 90 | session=session, author_id=current_user.id, article_to_create=payload.to_dto() 91 | ) 92 | return ArticleResponse.from_dto(dto=article_dto) 93 | 94 | 95 | @router.put("/{slug}", response_model=ArticleResponse) 96 | async def update_article( 97 | slug: str, 98 | payload: UpdateArticleRequest, 99 | session: DBSession, 100 | current_user: CurrentUser, 101 | article_service: IArticleService, 102 | ) -> ArticleResponse: 103 | """ 104 | Update an article. 105 | """ 106 | article_dto = await article_service.update_article_by_slug( 107 | session=session, 108 | slug=slug, 109 | article_to_update=payload.to_dto(), 110 | current_user=current_user, 111 | ) 112 | return ArticleResponse.from_dto(dto=article_dto) 113 | 114 | 115 | @router.delete("/{slug}", status_code=status.HTTP_204_NO_CONTENT) 116 | async def delete_article( 117 | slug: str, 118 | session: DBSession, 119 | current_user: CurrentUser, 120 | article_service: IArticleService, 121 | ) -> None: 122 | """ 123 | Delete an article by slug. 124 | """ 125 | await article_service.delete_article_by_slug( 126 | session=session, slug=slug, current_user=current_user 127 | ) 128 | 129 | 130 | @router.post("/{slug}/favorite", response_model=ArticleResponse) 131 | async def favorite_article( 132 | slug: str, 133 | session: DBSession, 134 | current_user: CurrentUser, 135 | article_service: IArticleService, 136 | ) -> ArticleResponse: 137 | """ 138 | Favorite an article. 139 | """ 140 | article_dto = await article_service.add_article_into_favorites( 141 | session=session, slug=slug, current_user=current_user 142 | ) 143 | return ArticleResponse.from_dto(dto=article_dto) 144 | 145 | 146 | @router.delete("/{slug}/favorite", response_model=ArticleResponse) 147 | async def unfavorite_article( 148 | slug: str, 149 | session: DBSession, 150 | current_user: CurrentUser, 151 | article_service: IArticleService, 152 | ) -> ArticleResponse: 153 | """ 154 | Unfavorite an article. 155 | """ 156 | article_dto = await article_service.remove_article_from_favorites( 157 | session=session, slug=slug, current_user=current_user 158 | ) 159 | return ArticleResponse.from_dto(dto=article_dto) 160 | -------------------------------------------------------------------------------- /conduit/services/profile.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | from structlog import get_logger 3 | 4 | from conduit.core.exceptions import ( 5 | OwnProfileFollowingException, 6 | ProfileAlreadyFollowedException, 7 | ProfileNotFollowedFollowedException, 8 | ProfileNotFoundException, 9 | UserNotFoundException, 10 | ) 11 | from conduit.domain.dtos.profile import ProfileDTO 12 | from conduit.domain.dtos.user import UserDTO 13 | from conduit.domain.repositories.follower import IFollowerRepository 14 | from conduit.domain.services.profile import IProfileService 15 | from conduit.domain.services.user import IUserService 16 | 17 | logger = get_logger() 18 | 19 | 20 | class ProfileService(IProfileService): 21 | """Service to handle user profiles and following logic.""" 22 | 23 | def __init__(self, user_service: IUserService, follower_repo: IFollowerRepository): 24 | self._user_service = user_service 25 | self._follower_repo = follower_repo 26 | 27 | async def get_profile_by_username( 28 | self, session: AsyncSession, username: str, current_user: UserDTO | None = None 29 | ) -> ProfileDTO: 30 | try: 31 | target_user = await self._user_service.get_user_by_username( 32 | session=session, username=username 33 | ) 34 | except UserNotFoundException: 35 | logger.exception("Profile not found", username=username) 36 | raise ProfileNotFoundException() 37 | 38 | profile = ProfileDTO( 39 | user_id=target_user.id, 40 | username=target_user.username, 41 | bio=target_user.bio, 42 | image=target_user.image_url, 43 | ) 44 | if current_user: 45 | profile.following = await self._follower_repo.exists( 46 | session=session, 47 | follower_id=current_user.id, 48 | following_id=target_user.id, 49 | ) 50 | return profile 51 | 52 | async def get_profile_by_user_id( 53 | self, session: AsyncSession, user_id: int, current_user: UserDTO | None = None 54 | ) -> ProfileDTO: 55 | target_user = await self._user_service.get_user_by_id( 56 | session=session, user_id=user_id 57 | ) 58 | profile = ProfileDTO( 59 | user_id=target_user.id, 60 | username=target_user.username, 61 | bio=target_user.bio, 62 | image=target_user.image_url, 63 | ) 64 | if current_user: 65 | profile.following = await self._follower_repo.exists( 66 | session=session, 67 | follower_id=current_user.id, 68 | following_id=target_user.id, 69 | ) 70 | return profile 71 | 72 | async def get_profiles_by_user_ids( 73 | self, session: AsyncSession, user_ids: list[int], current_user: UserDTO | None 74 | ) -> list[ProfileDTO]: 75 | target_users = await self._user_service.get_users_by_ids( 76 | session=session, user_ids=user_ids 77 | ) 78 | following_user_ids = ( 79 | await self._follower_repo.list( 80 | session=session, 81 | follower_id=current_user.id, 82 | following_ids=[user.id for user in target_users], 83 | ) 84 | if current_user 85 | else [] 86 | ) 87 | return [ 88 | ProfileDTO( 89 | user_id=user_dto.id, 90 | username=user_dto.username, 91 | bio=user_dto.bio, 92 | image=user_dto.image_url, 93 | following=user_dto.id in following_user_ids, 94 | ) 95 | for user_dto in target_users 96 | ] 97 | 98 | async def follow_user( 99 | self, session: AsyncSession, username: str, current_user: UserDTO 100 | ) -> None: 101 | if username == current_user.username: 102 | raise OwnProfileFollowingException() 103 | 104 | target_user = await self._user_service.get_user_by_username( 105 | session=session, username=username 106 | ) 107 | if await self._follower_repo.exists( 108 | session, follower_id=current_user.id, following_id=target_user.id 109 | ): 110 | raise ProfileAlreadyFollowedException() 111 | 112 | await self._follower_repo.create( 113 | session=session, follower_id=current_user.id, following_id=target_user.id 114 | ) 115 | 116 | async def unfollow_user( 117 | self, session: AsyncSession, username: str, current_user: UserDTO 118 | ) -> None: 119 | if username == current_user.username: 120 | raise OwnProfileFollowingException() 121 | 122 | target_user = await self._user_service.get_user_by_username( 123 | session=session, username=username 124 | ) 125 | if not await self._follower_repo.exists( 126 | session, follower_id=current_user.id, following_id=target_user.id 127 | ): 128 | logger.exception("User not followed", username=username) 129 | raise ProfileNotFollowedFollowedException() 130 | 131 | await self._follower_repo.delete( 132 | session=session, follower_id=current_user.id, following_id=target_user.id 133 | ) 134 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections.abc import Generator 3 | from datetime import datetime 4 | from typing import TypeAlias 5 | 6 | import pytest 7 | from fastapi import FastAPI 8 | from httpx import AsyncClient 9 | from sqlalchemy import create_engine 10 | from sqlalchemy.ext.asyncio import AsyncSession 11 | from sqlalchemy_utils import create_database, database_exists, drop_database 12 | 13 | from conduit.app import create_app 14 | from conduit.core.config import get_app_settings 15 | from conduit.core.container import Container 16 | from conduit.core.dependencies import IArticleService, IAuthTokenService 17 | from conduit.core.settings.base import BaseAppSettings 18 | from conduit.domain.dtos.article import ArticleDTO, CreateArticleDTO 19 | from conduit.domain.dtos.user import CreateUserDTO, UserDTO 20 | from conduit.domain.repositories.article import IArticleRepository 21 | from conduit.domain.repositories.user import IUserRepository 22 | from conduit.infrastructure.models import Base 23 | 24 | SetupFixture: TypeAlias = None 25 | 26 | 27 | @pytest.fixture 28 | def anyio_backend() -> str: 29 | return "asyncio" 30 | 31 | 32 | @pytest.fixture(autouse=True) 33 | def check_app_env_mode_enabled() -> None: 34 | assert os.getenv("APP_ENV") == "test" 35 | 36 | 37 | @pytest.fixture(scope="session") 38 | def create_test_db(settings: BaseAppSettings) -> Generator[None, None, None]: 39 | test_db_sql_uri = settings.sql_db_uri.set(drivername="postgresql") 40 | 41 | if database_exists(url=test_db_sql_uri): 42 | drop_database(url=test_db_sql_uri) 43 | 44 | create_database(url=test_db_sql_uri) 45 | yield 46 | 47 | drop_database(url=test_db_sql_uri) 48 | 49 | 50 | @pytest.fixture(autouse=True) 51 | def create_tables(settings: BaseAppSettings) -> Generator[None, None, None]: 52 | engine = create_engine( 53 | url=settings.sql_db_uri.set(drivername="postgresql"), 54 | isolation_level="AUTOCOMMIT", 55 | ) 56 | Base.metadata.create_all(bind=engine) 57 | yield 58 | 59 | Base.metadata.drop_all(bind=engine) 60 | 61 | 62 | @pytest.fixture(scope="session") 63 | def application(create_test_db: SetupFixture) -> FastAPI: 64 | return create_app() 65 | 66 | 67 | @pytest.fixture(scope="session") 68 | def settings() -> BaseAppSettings: 69 | return get_app_settings() 70 | 71 | 72 | @pytest.fixture(scope="session") 73 | def di_container(settings: BaseAppSettings) -> Container: 74 | return Container(settings=settings) 75 | 76 | 77 | @pytest.fixture 78 | async def session(di_container: Container) -> AsyncSession: 79 | async with di_container.context_session() as session: 80 | yield session 81 | 82 | 83 | @pytest.fixture 84 | def user_repository(di_container: Container) -> IUserRepository: 85 | return di_container.user_repository() 86 | 87 | 88 | @pytest.fixture 89 | def article_repository(di_container: Container) -> IArticleRepository: 90 | return di_container.article_repository() 91 | 92 | 93 | @pytest.fixture 94 | def article_service(di_container: Container) -> IArticleService: 95 | return di_container.article_service() 96 | 97 | 98 | @pytest.fixture 99 | def auth_token_service(di_container: Container) -> IAuthTokenService: 100 | return di_container.auth_token_service() 101 | 102 | 103 | @pytest.fixture 104 | def user_to_create() -> CreateUserDTO: 105 | return CreateUserDTO(username="test", email="test@gmail.com", password="password") 106 | 107 | 108 | @pytest.fixture 109 | def article_to_create() -> CreateArticleDTO: 110 | return CreateArticleDTO( 111 | title="Test Article", 112 | description="Test Description", 113 | body="Test Body", 114 | tags=["tag1", "tag2"], 115 | ) 116 | 117 | 118 | @pytest.fixture 119 | def not_exists_user() -> UserDTO: 120 | dto = UserDTO( 121 | username="username", 122 | email="email", 123 | password_hash="hash", 124 | bio="bio", 125 | image_url="link", 126 | created_at=datetime.now(), 127 | ) 128 | dto.id = 9999 129 | return dto 130 | 131 | 132 | @pytest.fixture 133 | async def test_user( 134 | session: AsyncSession, 135 | user_repository: IUserRepository, 136 | user_to_create: CreateUserDTO, 137 | ) -> UserDTO: 138 | return await user_repository.add(session=session, create_item=user_to_create) 139 | 140 | 141 | @pytest.fixture 142 | async def test_article( 143 | session: AsyncSession, 144 | article_service: IArticleService, 145 | article_to_create: CreateArticleDTO, 146 | test_user: UserDTO, 147 | ) -> ArticleDTO: 148 | return await article_service.create_new_article( 149 | session=session, author_id=test_user.id, article_to_create=article_to_create 150 | ) 151 | 152 | 153 | @pytest.fixture 154 | async def jwt_token(auth_token_service: IAuthTokenService, test_user: UserDTO) -> str: 155 | return auth_token_service.generate_jwt_token(user=test_user) 156 | 157 | 158 | @pytest.fixture 159 | async def not_exists_jwt_token( 160 | auth_token_service: IAuthTokenService, not_exists_user: UserDTO 161 | ) -> str: 162 | return auth_token_service.generate_jwt_token(user=not_exists_user) 163 | 164 | 165 | @pytest.fixture 166 | async def test_client(application: FastAPI) -> AsyncClient: 167 | async with AsyncClient( 168 | app=application, 169 | base_url="http://testserver/api", 170 | headers={"Content-Type": "application/json"}, 171 | ) as client: 172 | yield client 173 | 174 | 175 | @pytest.fixture 176 | async def authorized_test_client(application: FastAPI, jwt_token: str) -> AsyncClient: 177 | async with AsyncClient( 178 | app=application, 179 | base_url="http://testserver/api", 180 | headers={ 181 | "Authorization": f"Token {jwt_token}", 182 | "Content-Type": "application/json", 183 | }, 184 | ) as client: 185 | yield client 186 | -------------------------------------------------------------------------------- /tests/api/routes/test_article.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from conduit.api.schemas.responses.article import ArticleResponse 6 | from conduit.domain.dtos.article import ArticleDTO 7 | from conduit.infrastructure.repositories.article import ArticleRepository 8 | from conduit.infrastructure.repositories.user import UserRepository 9 | from tests.utils import create_another_test_article, create_another_test_user 10 | 11 | 12 | @pytest.mark.anyio 13 | async def test_user_can_create_new_article(authorized_test_client: AsyncClient) -> None: 14 | payload = { 15 | "article": { 16 | "title": "Test Article", 17 | "body": "test body", 18 | "description": "test description", 19 | "tagList": ["tag1", "tag2", "tag3"], 20 | } 21 | } 22 | response = await authorized_test_client.post(url="/articles", json=payload) 23 | assert response.status_code == 200 24 | 25 | 26 | @pytest.mark.anyio 27 | async def test_user_can_create_article_without_tags( 28 | authorized_test_client: AsyncClient, test_article: ArticleDTO 29 | ) -> None: 30 | payload = { 31 | "article": { 32 | "title": "Test Article", 33 | "body": "test body", 34 | "description": "test description", 35 | "tagList": [], 36 | } 37 | } 38 | response = await authorized_test_client.post(url="/articles", json=payload) 39 | assert response.status_code == 200 40 | 41 | 42 | @pytest.mark.anyio 43 | async def test_user_can_create_article_without_duplicated_tags( 44 | authorized_test_client: AsyncClient, 45 | ) -> None: 46 | payload = { 47 | "article": { 48 | "title": "Test Article", 49 | "body": "test body", 50 | "description": "test description", 51 | "tagList": ["tag1", "tag2", "tag2", "tag3", "tag3"], 52 | } 53 | } 54 | response = await authorized_test_client.post(url="/articles", json=payload) 55 | article = ArticleResponse(**response.json()) 56 | assert set(article.article.tags) == {"tag1", "tag2", "tag3"} 57 | 58 | 59 | @pytest.mark.anyio 60 | async def test_user_can_create_article_with_existing_title( 61 | authorized_test_client: AsyncClient, test_article: ArticleDTO 62 | ) -> None: 63 | payload = { 64 | "article": { 65 | "title": test_article.title, 66 | "body": "test body", 67 | "description": "test description", 68 | "tagList": test_article.tags, 69 | } 70 | } 71 | response = await authorized_test_client.post(url="/articles", json=payload) 72 | assert response.status_code == 200 73 | 74 | 75 | @pytest.mark.anyio 76 | async def test_user_can_retrieve_article_without_tags( 77 | authorized_test_client: AsyncClient, 78 | ) -> None: 79 | payload = { 80 | "article": { 81 | "title": "Test Article", 82 | "body": "test body", 83 | "description": "test description", 84 | "tagList": [], 85 | } 86 | } 87 | response = await authorized_test_client.post(url="/articles", json=payload) 88 | assert response.status_code == 200 89 | 90 | article = ArticleResponse(**response.json()) 91 | response = await authorized_test_client.get(url=f"/articles/{article.article.slug}") 92 | assert response.status_code == 200 93 | 94 | 95 | @pytest.mark.anyio 96 | async def test_user_can_not_retrieve_not_existing_article( 97 | authorized_test_client: AsyncClient, 98 | ) -> None: 99 | response = await authorized_test_client.get( 100 | url="/articles/not-existing-article-slug" 101 | ) 102 | assert response.status_code == 404 103 | 104 | 105 | @pytest.mark.anyio 106 | async def test_user_can_retrieve_article_if_exists( 107 | authorized_test_client: AsyncClient, test_article: ArticleDTO 108 | ) -> None: 109 | response = await authorized_test_client.get(url=f"/articles/{test_article.slug}") 110 | article = ArticleResponse(**response.json()) 111 | assert article.article.slug == test_article.slug 112 | assert article.article.description == test_article.description 113 | assert article.article.body == test_article.body 114 | 115 | 116 | @pytest.mark.anyio 117 | async def test_user_can_not_delete_foreign_article( 118 | authorized_test_client: AsyncClient, 119 | session: AsyncSession, 120 | user_repository: UserRepository, 121 | article_repository: ArticleRepository, 122 | ) -> None: 123 | new_user = await create_another_test_user( 124 | session=session, user_repository=user_repository 125 | ) 126 | new_article = await create_another_test_article( 127 | session=session, article_repository=article_repository, author_id=new_user.id 128 | ) 129 | response = await authorized_test_client.delete(url=f"/articles/{new_article.slug}") 130 | assert response.status_code == 403 131 | 132 | 133 | @pytest.mark.anyio 134 | async def test_user_can_not_update_foreign_article( 135 | authorized_test_client: AsyncClient, 136 | session: AsyncSession, 137 | user_repository: UserRepository, 138 | article_repository: ArticleRepository, 139 | ) -> None: 140 | new_user = await create_another_test_user( 141 | session=session, user_repository=user_repository 142 | ) 143 | new_article = await create_another_test_article( 144 | session=session, article_repository=article_repository, author_id=new_user.id 145 | ) 146 | response = await authorized_test_client.put( 147 | url=f"/articles/{new_article.slug}", 148 | json={"article": {"title": "New Updated Title"}}, 149 | ) 150 | assert response.status_code == 403 151 | 152 | 153 | @pytest.mark.anyio 154 | async def test_user_can_delete_own_article( 155 | authorized_test_client: AsyncClient, test_article: ArticleDTO 156 | ) -> None: 157 | response = await authorized_test_client.delete(url=f"/articles/{test_article.slug}") 158 | assert response.status_code == 204 159 | 160 | response = await authorized_test_client.get(url=f"/articles/{test_article.slug}") 161 | assert response.status_code == 404 162 | -------------------------------------------------------------------------------- /tests/api/routes/test_profile.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from conduit.api.schemas.responses.profile import ProfileResponse 6 | from conduit.domain.dtos.user import UserDTO 7 | from conduit.infrastructure.repositories.user import UserRepository 8 | from tests.utils import create_another_test_user 9 | 10 | 11 | @pytest.mark.anyio 12 | async def test_not_authenticated_user_can_get_profile( 13 | test_client: AsyncClient, test_user: UserDTO 14 | ) -> None: 15 | response = await test_client.get(url=f"/profiles/{test_user.username}") 16 | profile = ProfileResponse(**response.json()) 17 | assert profile.profile.username == test_user.username 18 | assert not profile.profile.following 19 | 20 | 21 | @pytest.mark.anyio 22 | async def test_authenticated_user_can_get_own_profile( 23 | authorized_test_client: AsyncClient, test_user: UserDTO 24 | ) -> None: 25 | response = await authorized_test_client.get(url=f"/profiles/{test_user.username}") 26 | profile = ProfileResponse(**response.json()) 27 | assert profile.profile.username == test_user.username 28 | assert not profile.profile.following 29 | 30 | 31 | @pytest.mark.anyio 32 | async def test_not_authenticated_user_can_not_follow_another_user_profile( 33 | test_client: AsyncClient, test_user: UserDTO 34 | ) -> None: 35 | response = await test_client.post(url=f"/profiles/{test_user.username}/follow") 36 | assert response.status_code == 403 37 | 38 | 39 | @pytest.mark.anyio 40 | async def test_authenticated_user_cant_follow_own_profile( 41 | authorized_test_client: AsyncClient, test_user: UserDTO 42 | ) -> None: 43 | response = await authorized_test_client.post( 44 | url=f"/profiles/{test_user.username}/follow" 45 | ) 46 | assert response.status_code == 403 47 | 48 | 49 | @pytest.mark.anyio 50 | async def test_authenticated_user_cant_follow_another_profile( 51 | authorized_test_client: AsyncClient, 52 | test_user: UserDTO, 53 | user_repository: UserRepository, 54 | session: AsyncSession, 55 | ) -> None: 56 | new_user = await create_another_test_user( 57 | session=session, user_repository=user_repository 58 | ) 59 | response = await authorized_test_client.post( 60 | url=f"/profiles/{new_user.username}/follow" 61 | ) 62 | assert response.status_code == 200 63 | assert response.json()["profile"]["following"] is True 64 | 65 | 66 | @pytest.mark.anyio 67 | async def test_authenticated_user_cant_follow_already_followed_profile( 68 | authorized_test_client: AsyncClient, 69 | test_user: UserDTO, 70 | user_repository: UserRepository, 71 | session: AsyncSession, 72 | ) -> None: 73 | new_user = await create_another_test_user( 74 | session=session, user_repository=user_repository 75 | ) 76 | response = await authorized_test_client.post( 77 | url=f"/profiles/{new_user.username}/follow" 78 | ) 79 | assert response.status_code == 200 80 | assert response.json()["profile"]["following"] is True 81 | 82 | response = await authorized_test_client.post( 83 | url=f"/profiles/{new_user.username}/follow" 84 | ) 85 | assert response.status_code == 400 86 | 87 | 88 | @pytest.mark.anyio 89 | async def test_authenticated_user_cant_unfollow_not_followed_profile( 90 | authorized_test_client: AsyncClient, 91 | user_repository: UserRepository, 92 | session: AsyncSession, 93 | ) -> None: 94 | new_user = await create_another_test_user( 95 | session=session, user_repository=user_repository 96 | ) 97 | response = await authorized_test_client.delete( 98 | url=f"/profiles/{new_user.username}/follow" 99 | ) 100 | assert response.status_code == 400 101 | 102 | 103 | @pytest.mark.anyio 104 | async def test_authenticated_user_can_unfollow_followed_profile( 105 | authorized_test_client: AsyncClient, 106 | user_repository: UserRepository, 107 | session: AsyncSession, 108 | ) -> None: 109 | new_user = await create_another_test_user( 110 | session=session, user_repository=user_repository 111 | ) 112 | response = await authorized_test_client.post( 113 | url=f"/profiles/{new_user.username}/follow" 114 | ) 115 | assert response.status_code == 200 116 | assert response.json()["profile"]["following"] is True 117 | 118 | response = await authorized_test_client.delete( 119 | url=f"/profiles/{new_user.username}/follow" 120 | ) 121 | assert response.status_code == 200 122 | assert response.json()["profile"]["following"] is False 123 | 124 | 125 | @pytest.mark.anyio 126 | async def test_authenticated_user_can_unfollow_already_unfollowed_profile( 127 | authorized_test_client: AsyncClient, 128 | user_repository: UserRepository, 129 | session: AsyncSession, 130 | ) -> None: 131 | new_user = await create_another_test_user( 132 | session=session, user_repository=user_repository 133 | ) 134 | response = await authorized_test_client.post( 135 | url=f"/profiles/{new_user.username}/follow" 136 | ) 137 | assert response.status_code == 200 138 | assert response.json()["profile"]["following"] is True 139 | 140 | response = await authorized_test_client.delete( 141 | url=f"/profiles/{new_user.username}/follow" 142 | ) 143 | assert response.status_code == 200 144 | assert response.json()["profile"]["following"] is False 145 | 146 | response = await authorized_test_client.delete( 147 | url=f"/profiles/{new_user.username}/follow" 148 | ) 149 | assert response.status_code == 400 150 | 151 | 152 | @pytest.mark.parametrize( 153 | "api_method, api_path", 154 | ( 155 | ("GET", "/profiles/{username}"), 156 | ("POST", "/profiles/{username}/follow"), 157 | ("DELETE", "/profiles/{username}/follow"), 158 | ), 159 | ) 160 | @pytest.mark.anyio 161 | async def test_user_can_not_retrieve_not_existing_profile( 162 | authorized_test_client: AsyncClient, api_method: str, api_path: str 163 | ) -> None: 164 | response = await authorized_test_client.request( 165 | method=api_method, url=api_path.format(username="not-existing-username") 166 | ) 167 | assert response.status_code == 404 168 | -------------------------------------------------------------------------------- /conduit/core/container.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from collections.abc import AsyncIterator 3 | 4 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 5 | 6 | from conduit.core.config import get_app_settings 7 | from conduit.core.settings.base import BaseAppSettings 8 | from conduit.domain.mapper import IModelMapper 9 | from conduit.domain.repositories.article import IArticleRepository 10 | from conduit.domain.repositories.article_tag import IArticleTagRepository 11 | from conduit.domain.repositories.comment import ICommentRepository 12 | from conduit.domain.repositories.favorite import IFavoriteRepository 13 | from conduit.domain.repositories.follower import IFollowerRepository 14 | from conduit.domain.repositories.tag import ITagRepository 15 | from conduit.domain.repositories.user import IUserRepository 16 | from conduit.domain.services.article import IArticleService 17 | from conduit.domain.services.auth import IUserAuthService 18 | from conduit.domain.services.auth_token import IAuthTokenService 19 | from conduit.domain.services.comment import ICommentService 20 | from conduit.domain.services.profile import IProfileService 21 | from conduit.domain.services.tag import ITagService 22 | from conduit.domain.services.user import IUserService 23 | from conduit.infrastructure.mappers.article import ArticleModelMapper 24 | from conduit.infrastructure.mappers.comment import CommentModelMapper 25 | from conduit.infrastructure.mappers.tag import TagModelMapper 26 | from conduit.infrastructure.mappers.user import UserModelMapper 27 | from conduit.infrastructure.repositories.article import ArticleRepository 28 | from conduit.infrastructure.repositories.article_tag import ArticleTagRepository 29 | from conduit.infrastructure.repositories.comment import CommentRepository 30 | from conduit.infrastructure.repositories.favorite import FavoriteRepository 31 | from conduit.infrastructure.repositories.follower import FollowerRepository 32 | from conduit.infrastructure.repositories.tag import TagRepository 33 | from conduit.infrastructure.repositories.user import UserRepository 34 | from conduit.services.article import ArticleService 35 | from conduit.services.auth import UserAuthService 36 | from conduit.services.auth_token import AuthTokenService 37 | from conduit.services.comment import CommentService 38 | from conduit.services.profile import ProfileService 39 | from conduit.services.tag import TagService 40 | from conduit.services.user import UserService 41 | 42 | 43 | class Container: 44 | """Dependency injector project container.""" 45 | 46 | def __init__(self, settings: BaseAppSettings) -> None: 47 | self._settings = settings 48 | self._engine = create_async_engine(**settings.sqlalchemy_engine_props) 49 | self._session = async_sessionmaker(bind=self._engine, expire_on_commit=False) 50 | 51 | @contextlib.asynccontextmanager 52 | async def context_session(self) -> AsyncIterator[AsyncSession]: 53 | session = self._session() 54 | try: 55 | yield session 56 | await session.commit() 57 | except Exception: 58 | await session.rollback() 59 | raise 60 | finally: 61 | await session.close() 62 | 63 | async def session(self) -> AsyncIterator[AsyncSession]: 64 | async with self._session() as session: 65 | try: 66 | yield session 67 | await session.commit() 68 | except Exception: 69 | await session.rollback() 70 | raise 71 | finally: 72 | await session.close() 73 | 74 | @staticmethod 75 | def user_model_mapper() -> IModelMapper: 76 | return UserModelMapper() 77 | 78 | @staticmethod 79 | def tag_model_mapper() -> IModelMapper: 80 | return TagModelMapper() 81 | 82 | @staticmethod 83 | def article_model_mapper() -> IModelMapper: 84 | return ArticleModelMapper() 85 | 86 | @staticmethod 87 | def comment_model_mapper() -> IModelMapper: 88 | return CommentModelMapper() 89 | 90 | def user_repository(self) -> IUserRepository: 91 | return UserRepository(user_mapper=self.user_model_mapper()) 92 | 93 | @staticmethod 94 | def follower_repository() -> IFollowerRepository: 95 | return FollowerRepository() 96 | 97 | def tags_repository(self) -> ITagRepository: 98 | return TagRepository(tag_mapper=self.tag_model_mapper()) 99 | 100 | def article_repository(self) -> IArticleRepository: 101 | return ArticleRepository(article_mapper=self.article_model_mapper()) 102 | 103 | def article_tag_repository(self) -> IArticleTagRepository: 104 | return ArticleTagRepository(tag_mapper=self.tag_model_mapper()) 105 | 106 | def comment_repository(self) -> ICommentRepository: 107 | return CommentRepository(comment_mapper=self.comment_model_mapper()) 108 | 109 | @staticmethod 110 | def favorite_repository() -> IFavoriteRepository: 111 | return FavoriteRepository() 112 | 113 | def auth_token_service(self) -> IAuthTokenService: 114 | return AuthTokenService( 115 | secret_key=self._settings.jwt_secret_key, 116 | token_expiration_minutes=self._settings.jwt_token_expiration_minutes, 117 | algorithm=self._settings.jwt_algorithm, 118 | ) 119 | 120 | def user_auth_service(self) -> IUserAuthService: 121 | return UserAuthService( 122 | user_service=self.user_service(), 123 | auth_token_service=self.auth_token_service(), 124 | ) 125 | 126 | def user_service(self) -> IUserService: 127 | return UserService(user_repo=self.user_repository()) 128 | 129 | def profile_service(self) -> IProfileService: 130 | return ProfileService( 131 | user_service=self.user_service(), follower_repo=self.follower_repository() 132 | ) 133 | 134 | def tag_service(self) -> ITagService: 135 | return TagService(tag_repo=self.tags_repository()) 136 | 137 | def article_service(self) -> IArticleService: 138 | return ArticleService( 139 | article_repo=self.article_repository(), 140 | article_tag_repo=self.article_tag_repository(), 141 | favorite_repo=self.favorite_repository(), 142 | profile_service=self.profile_service(), 143 | ) 144 | 145 | def comment_service(self) -> ICommentService: 146 | return CommentService( 147 | article_repo=self.article_repository(), 148 | comment_repo=self.comment_repository(), 149 | profile_service=self.profile_service(), 150 | ) 151 | 152 | 153 | container = Container(settings=get_app_settings()) 154 | -------------------------------------------------------------------------------- /conduit/core/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import FastAPI 4 | from fastapi.exceptions import RequestValidationError 5 | from starlette.exceptions import HTTPException 6 | from starlette.requests import Request 7 | from starlette.responses import JSONResponse 8 | 9 | from conduit.core.utils.errors import format_errors 10 | 11 | 12 | class BaseInternalException(Exception): 13 | """ 14 | Base error class for inherit all internal errors. 15 | """ 16 | 17 | _status_code = 0 18 | _message = "" 19 | _errors: dict = {} 20 | 21 | def __init__( 22 | self, 23 | status_code: int | None = None, 24 | message: str | None = None, 25 | errors: dict[str, dict[Any, Any]] | None = None, 26 | ) -> None: 27 | self.status_code = status_code 28 | self.message = message 29 | self.errors = errors 30 | 31 | def get_status_code(self) -> int: 32 | return self.status_code or self._status_code 33 | 34 | def get_message(self) -> str: 35 | return self.message or self._message 36 | 37 | def get_errors(self) -> dict[str, dict[Any, Any]]: 38 | return self.errors or self._errors 39 | 40 | @classmethod 41 | def get_response(cls) -> JSONResponse: 42 | return JSONResponse( 43 | status_code=cls._status_code, 44 | content={ 45 | "status": "error", 46 | "status_code": cls._status_code, 47 | "type": cls.__name__, 48 | "message": cls._message, 49 | "errors": cls._errors, 50 | }, 51 | ) 52 | 53 | 54 | class UserNotFoundException(BaseInternalException): 55 | """Exception raised when user not found in database.""" 56 | 57 | _status_code = 404 58 | _message = "User with this username does not exist." 59 | 60 | 61 | class ArticleNotFoundException(BaseInternalException): 62 | """Exception raised when article not found in database.""" 63 | 64 | _status_code = 404 65 | _message = "Article with this slug does not exist." 66 | 67 | 68 | class ArticleAlreadyFavoritedException(BaseInternalException): 69 | """Exception raised when article already marked favorited.""" 70 | 71 | _status_code = 400 72 | _message = "Article has already been marked as a favorite." 73 | 74 | 75 | class ArticleNotFavoritedException(BaseInternalException): 76 | """Exception raised when article is not favorited.""" 77 | 78 | _status_code = 400 79 | _message = "Article is not favorited." 80 | 81 | 82 | class ArticlePermissionException(BaseInternalException): 83 | """Exception raised when user does not have permission to access the article.""" 84 | 85 | _status_code = 403 86 | _message = "Current user does not have permission to access the article." 87 | 88 | 89 | class CommentNotFoundException(BaseInternalException): 90 | """Exception raised when comment not found in database.""" 91 | 92 | _status_code = 404 93 | _message = "Comment with this id does not exist." 94 | 95 | 96 | class CommentPermissionException(BaseInternalException): 97 | """Exception raised when user does not have permission to access the comment.""" 98 | 99 | _status_code = 403 100 | _message = "Current user does not have permission to access the comment." 101 | 102 | 103 | class EmailAlreadyTakenException(BaseInternalException): 104 | """Exception raised when email was found in database while registration.""" 105 | 106 | _status_code = 400 107 | _message = "User with this email already exists." 108 | _errors = {"email": ["user with this email already exists."]} 109 | 110 | 111 | class UserNameAlreadyTakenException(BaseInternalException): 112 | """Exception raised when username was found in database while registration.""" 113 | 114 | _status_code = 400 115 | _message = "User with this username already exists." 116 | _errors = {"username": ["user with this username already exists."]} 117 | 118 | 119 | class IncorrectLoginInputException(BaseInternalException): 120 | """Exception raised when email or password was incorrect while login.""" 121 | 122 | _status_code = 400 123 | _message = "Incorrect email or password." 124 | _errors = { 125 | "email": ["incorrect email or password."], 126 | "password": ["incorrect email or password."], 127 | } 128 | 129 | 130 | class IncorrectJWTTokenException(BaseInternalException): 131 | """Exception raised when user provided invalid JWT token.""" 132 | 133 | _status_code = 403 134 | _message = "Invalid JWT token." 135 | 136 | 137 | class ProfileNotFoundException(BaseInternalException): 138 | """Exception raised when specific profile not found.""" 139 | 140 | _status_code = 404 141 | _message = "Profile with this username does not exist." 142 | 143 | 144 | class OwnProfileFollowingException(BaseInternalException): 145 | """Exception raised when user is trying to follow own profile.""" 146 | 147 | _status_code = 403 148 | _message = "Own profile cannot be followed or unfollowed." 149 | 150 | 151 | class ProfileAlreadyFollowedException(BaseInternalException): 152 | """Exception raised when user is trying to follow already followed profile.""" 153 | 154 | _status_code = 400 155 | _message = "Profile already followed." 156 | 157 | 158 | class ProfileNotFollowedFollowedException(BaseInternalException): 159 | """Exception raised when user is trying to unfollow not followed profile.""" 160 | 161 | _status_code = 400 162 | _message = "Profile was not followed." 163 | 164 | 165 | class RateLimitExceededException(BaseInternalException): 166 | """Exception raised when rate limit exceeded during specific time.""" 167 | 168 | _status_code = 429 169 | _message = "Rate limit exceeded. Please try again later." 170 | 171 | 172 | def add_internal_exception_handler(app: FastAPI) -> None: 173 | """ 174 | Handle all internal exceptions. 175 | """ 176 | 177 | @app.exception_handler(BaseInternalException) 178 | async def _exception_handler( 179 | _: Request, exc: BaseInternalException 180 | ) -> JSONResponse: 181 | return JSONResponse( 182 | status_code=exc.get_status_code(), 183 | content={ 184 | "status": "error", 185 | "status_code": exc.get_status_code(), 186 | "type": type(exc).__name__, 187 | "message": exc.get_message(), 188 | "errors": exc.get_errors(), 189 | }, 190 | ) 191 | 192 | 193 | def add_request_exception_handler(app: FastAPI) -> None: 194 | """ 195 | Handle request validation errors exceptions. 196 | """ 197 | 198 | @app.exception_handler(RequestValidationError) 199 | async def _exception_handler( 200 | _: Request, exc: RequestValidationError 201 | ) -> JSONResponse: 202 | return JSONResponse( 203 | status_code=422, 204 | content={ 205 | "status": "error", 206 | "status_code": 422, 207 | "type": "RequestValidationError", 208 | "message": "Schema validation error", 209 | "errors": format_errors(errors=exc.errors()), 210 | }, 211 | ) 212 | 213 | 214 | def add_http_exception_handler(app: FastAPI) -> None: 215 | """ 216 | Handle http exceptions. 217 | """ 218 | 219 | @app.exception_handler(HTTPException) 220 | async def _exception_handler(_: Request, exc: HTTPException) -> JSONResponse: 221 | return JSONResponse( 222 | status_code=exc.status_code, 223 | content={ 224 | "status": "error", 225 | "status_code": exc.status_code, 226 | "type": "HTTPException", 227 | "message": exc.detail, 228 | }, 229 | ) 230 | 231 | 232 | def add_exception_handlers(app: FastAPI) -> None: 233 | """ 234 | Set all exception handlers to app object. 235 | """ 236 | add_internal_exception_handler(app=app) 237 | add_request_exception_handler(app=app) 238 | add_http_exception_handler(app=app) 239 | -------------------------------------------------------------------------------- /conduit/services/article.py: -------------------------------------------------------------------------------- 1 | from dataclasses import asdict 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from conduit.core.exceptions import ( 6 | ArticleAlreadyFavoritedException, 7 | ArticleNotFavoritedException, 8 | ArticlePermissionException, 9 | ) 10 | from conduit.domain.dtos.article import ( 11 | ArticleAuthorDTO, 12 | ArticleDTO, 13 | ArticleRecordDTO, 14 | ArticlesFeedDTO, 15 | CreateArticleDTO, 16 | UpdateArticleDTO, 17 | ) 18 | from conduit.domain.dtos.profile import ProfileDTO 19 | from conduit.domain.dtos.user import UserDTO 20 | from conduit.domain.repositories.article import IArticleRepository 21 | from conduit.domain.repositories.article_tag import IArticleTagRepository 22 | from conduit.domain.repositories.favorite import IFavoriteRepository 23 | from conduit.domain.services.article import IArticleService 24 | from conduit.domain.services.profile import IProfileService 25 | 26 | 27 | class ArticleService(IArticleService): 28 | """Service to handle articles logic.""" 29 | 30 | def __init__( 31 | self, 32 | article_repo: IArticleRepository, 33 | article_tag_repo: IArticleTagRepository, 34 | favorite_repo: IFavoriteRepository, 35 | profile_service: IProfileService, 36 | ) -> None: 37 | self._article_repo = article_repo 38 | self._article_tag_repo = article_tag_repo 39 | self._favorite_repo = favorite_repo 40 | self._profile_service = profile_service 41 | 42 | async def create_new_article( 43 | self, session: AsyncSession, author_id: int, article_to_create: CreateArticleDTO 44 | ) -> ArticleDTO: 45 | article = await self._article_repo.add( 46 | session=session, author_id=author_id, create_item=article_to_create 47 | ) 48 | profile = await self._profile_service.get_profile_by_user_id( 49 | session=session, user_id=author_id 50 | ) 51 | if article_to_create.tags: 52 | await self._article_tag_repo.add_many( 53 | session=session, article_id=article.id, tags=article_to_create.tags 54 | ) 55 | return ArticleDTO( 56 | **asdict(article), 57 | author=ArticleAuthorDTO( 58 | username=profile.username, 59 | bio=profile.bio, 60 | image=profile.image, 61 | following=profile.following, 62 | ), 63 | tags=article_to_create.tags, 64 | favorited=False, 65 | favorites_count=0, 66 | ) 67 | 68 | async def get_article_by_slug( 69 | self, session: AsyncSession, slug: str, current_user: UserDTO | None 70 | ) -> ArticleDTO: 71 | article = await self._article_repo.get_by_slug(session=session, slug=slug) 72 | profile = await self._profile_service.get_profile_by_user_id( 73 | session=session, user_id=article.author_id, current_user=current_user 74 | ) 75 | return await self._get_article_info( 76 | session=session, 77 | article=article, 78 | profile=profile, 79 | user_id=current_user.id if current_user else None, 80 | ) 81 | 82 | async def delete_article_by_slug( 83 | self, session: AsyncSession, slug: str, current_user: UserDTO 84 | ) -> None: 85 | article = await self._article_repo.get_by_slug(session=session, slug=slug) 86 | 87 | if article.author_id != current_user.id: 88 | raise ArticlePermissionException() 89 | 90 | await self._article_repo.delete_by_slug(session=session, slug=slug) 91 | 92 | async def update_article_by_slug( 93 | self, 94 | session: AsyncSession, 95 | slug: str, 96 | article_to_update: UpdateArticleDTO, 97 | current_user: UserDTO, 98 | ) -> ArticleDTO: 99 | article = await self._article_repo.get_by_slug(session=session, slug=slug) 100 | 101 | if article.author_id != current_user.id: 102 | raise ArticlePermissionException() 103 | 104 | article = await self._article_repo.update_by_slug( 105 | session=session, slug=slug, update_item=article_to_update 106 | ) 107 | profile = await self._profile_service.get_profile_by_user_id( 108 | session=session, user_id=article.author_id, current_user=current_user 109 | ) 110 | return await self._get_article_info( 111 | session=session, article=article, profile=profile, user_id=current_user.id 112 | ) 113 | 114 | async def get_articles_by_filters( 115 | self, 116 | session: AsyncSession, 117 | current_user: UserDTO | None, 118 | limit: int, 119 | offset: int, 120 | tag: str | None = None, 121 | author: str | None = None, 122 | favorited: str | None = None, 123 | ) -> ArticlesFeedDTO: 124 | articles = await self._article_repo.list_by_filters( 125 | session=session, 126 | limit=limit, 127 | offset=offset, 128 | tag=tag, 129 | author=author, 130 | favorited=favorited, 131 | ) 132 | profiles_map = await self._get_profiles_mapping( 133 | session=session, articles=articles, current_user=current_user 134 | ) 135 | articles_with_extra = [ 136 | await self._get_article_info( 137 | session=session, 138 | article=article, 139 | profile=profiles_map[article.author_id], 140 | user_id=current_user.id if current_user else None, 141 | ) 142 | for article in articles 143 | ] 144 | articles_count = await self._article_repo.count_by_filters( 145 | session=session, tag=tag, author=author, favorited=favorited 146 | ) 147 | return ArticlesFeedDTO( 148 | articles=articles_with_extra, articles_count=articles_count 149 | ) 150 | 151 | async def get_articles_by_filters_v2( 152 | self, 153 | session: AsyncSession, 154 | current_user: UserDTO | None, 155 | limit: int, 156 | offset: int, 157 | tag: str | None = None, 158 | author: str | None = None, 159 | favorited: str | None = None, 160 | ) -> ArticlesFeedDTO: 161 | articles = await self._article_repo.list_by_filters_v2( 162 | session=session, 163 | user_id=current_user.id if current_user else None, 164 | limit=limit, 165 | offset=offset, 166 | tag=tag, 167 | author=author, 168 | favorited=favorited, 169 | ) 170 | articles_count = await self._article_repo.count_by_filters( 171 | session=session, tag=tag, author=author, favorited=favorited 172 | ) 173 | return ArticlesFeedDTO(articles=articles, articles_count=articles_count) 174 | 175 | async def get_articles_feed( 176 | self, session: AsyncSession, current_user: UserDTO, limit: int, offset: int 177 | ) -> ArticlesFeedDTO: 178 | articles = await self._article_repo.list_by_followings( 179 | session=session, user_id=current_user.id, limit=limit, offset=offset 180 | ) 181 | profiles_map = await self._get_profiles_mapping( 182 | session=session, articles=articles, current_user=current_user 183 | ) 184 | articles_with_extra = [ 185 | await self._get_article_info( 186 | session=session, 187 | article=article, 188 | profile=profiles_map[article.author_id], 189 | user_id=current_user.id, 190 | ) 191 | for article in articles 192 | ] 193 | articles_count = await self._article_repo.count_by_followings( 194 | session=session, user_id=current_user.id 195 | ) 196 | return ArticlesFeedDTO( 197 | articles=articles_with_extra, articles_count=articles_count 198 | ) 199 | 200 | async def get_articles_feed_v2( 201 | self, session: AsyncSession, current_user: UserDTO, limit: int, offset: int 202 | ) -> ArticlesFeedDTO: 203 | articles = await self._article_repo.list_by_followings_v2( 204 | session=session, user_id=current_user.id, limit=limit, offset=offset 205 | ) 206 | articles_count = await self._article_repo.count_by_followings( 207 | session=session, user_id=current_user.id 208 | ) 209 | return ArticlesFeedDTO(articles=articles, articles_count=articles_count) 210 | 211 | async def add_article_into_favorites( 212 | self, session: AsyncSession, slug: str, current_user: UserDTO 213 | ) -> ArticleDTO: 214 | article = await self.get_article_by_slug( 215 | session=session, slug=slug, current_user=current_user 216 | ) 217 | if article.favorited: 218 | raise ArticleAlreadyFavoritedException() 219 | 220 | await self._favorite_repo.create( 221 | session=session, article_id=article.id, user_id=current_user.id 222 | ) 223 | return ArticleDTO.with_updated_fields( 224 | dto=article, 225 | updated_fields=dict( 226 | favorited=True, favorites_count=article.favorites_count + 1 227 | ), 228 | ) 229 | 230 | async def remove_article_from_favorites( 231 | self, session: AsyncSession, slug: str, current_user: UserDTO 232 | ) -> ArticleDTO: 233 | article = await self.get_article_by_slug( 234 | session=session, slug=slug, current_user=current_user 235 | ) 236 | if not article.favorited: 237 | raise ArticleNotFavoritedException() 238 | 239 | await self._favorite_repo.delete( 240 | session=session, article_id=article.id, user_id=current_user.id 241 | ) 242 | return ArticleDTO.with_updated_fields( 243 | dto=article, 244 | updated_fields=dict( 245 | favorited=False, favorites_count=article.favorites_count - 1 246 | ), 247 | ) 248 | 249 | async def _get_article_info( 250 | self, 251 | session: AsyncSession, 252 | article: ArticleRecordDTO, 253 | profile: ProfileDTO, 254 | user_id: int | None = None, 255 | ) -> ArticleDTO: 256 | article_tags = [ 257 | tag.tag 258 | for tag in await self._article_tag_repo.list( 259 | session=session, article_id=article.id 260 | ) 261 | ] 262 | favorites_count = await self._favorite_repo.count( 263 | session=session, article_id=article.id 264 | ) 265 | is_favorited_by_user = ( 266 | await self._favorite_repo.exists( 267 | session=session, author_id=user_id, article_id=article.id 268 | ) 269 | if user_id 270 | else False 271 | ) 272 | return ArticleDTO( 273 | **asdict(article), 274 | author=ArticleAuthorDTO( 275 | username=profile.username, 276 | bio=profile.bio, 277 | image=profile.image, 278 | following=profile.following, 279 | ), 280 | tags=article_tags, 281 | favorited=is_favorited_by_user, 282 | favorites_count=favorites_count, 283 | ) 284 | 285 | async def _get_profiles_mapping( 286 | self, 287 | session: AsyncSession, 288 | articles: list[ArticleRecordDTO], 289 | current_user: UserDTO | None, 290 | ) -> dict[int, ProfileDTO]: 291 | following_profiles = await self._profile_service.get_profiles_by_user_ids( 292 | session=session, 293 | user_ids=[article.author_id for article in articles], 294 | current_user=current_user, 295 | ) 296 | return {profile.user_id: profile for profile in following_profiles} 297 | -------------------------------------------------------------------------------- /conduit/infrastructure/repositories/article.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any 3 | 4 | from sqlalchemy import case, delete, exists, func, insert, select, true, update 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from sqlalchemy.orm import aliased 7 | from sqlalchemy.sql.functions import count 8 | 9 | from conduit.core.exceptions import ArticleNotFoundException 10 | from conduit.core.utils.slug import ( 11 | get_slug_unique_part, 12 | make_slug_from_title, 13 | make_slug_from_title_and_code, 14 | ) 15 | from conduit.domain.dtos.article import ( 16 | ArticleAuthorDTO, 17 | ArticleDTO, 18 | ArticleRecordDTO, 19 | CreateArticleDTO, 20 | UpdateArticleDTO, 21 | ) 22 | from conduit.domain.mapper import IModelMapper 23 | from conduit.domain.repositories.article import IArticleRepository 24 | from conduit.infrastructure.models import ( 25 | Article, 26 | ArticleTag, 27 | Favorite, 28 | Follower, 29 | Tag, 30 | User, 31 | ) 32 | 33 | # Aliases for the models if needed. 34 | FavoriteAlias = aliased(Favorite) 35 | 36 | 37 | class ArticleRepository(IArticleRepository): 38 | 39 | def __init__(self, article_mapper: IModelMapper[Article, ArticleRecordDTO]): 40 | self._article_mapper = article_mapper 41 | 42 | async def add( 43 | self, session: AsyncSession, author_id: int, create_item: CreateArticleDTO 44 | ) -> ArticleRecordDTO: 45 | query = ( 46 | insert(Article) 47 | .values( 48 | author_id=author_id, 49 | slug=make_slug_from_title(title=create_item.title), 50 | title=create_item.title, 51 | description=create_item.description, 52 | body=create_item.body, 53 | created_at=datetime.now(), 54 | updated_at=datetime.now(), 55 | ) 56 | .returning(Article) 57 | ) 58 | result = await session.execute(query) 59 | return self._article_mapper.to_dto(result.scalar()) 60 | 61 | async def get_by_slug_or_none( 62 | self, session: AsyncSession, slug: str 63 | ) -> ArticleRecordDTO | None: 64 | slug_unique_part = get_slug_unique_part(slug=slug) 65 | query = select(Article).where( 66 | Article.slug == slug or Article.slug.contains(slug_unique_part) 67 | ) 68 | if article := await session.scalar(query): 69 | return self._article_mapper.to_dto(article) 70 | 71 | async def get_by_slug(self, session: AsyncSession, slug: str) -> ArticleRecordDTO: 72 | slug_unique_part = get_slug_unique_part(slug=slug) 73 | query = select(Article).where( 74 | Article.slug == slug or Article.slug.contains(slug_unique_part) 75 | ) 76 | if not (article := await session.scalar(query)): 77 | raise ArticleNotFoundException() 78 | return self._article_mapper.to_dto(article) 79 | 80 | async def delete_by_slug(self, session: AsyncSession, slug: str) -> None: 81 | query = delete(Article).where(Article.slug == slug) 82 | await session.execute(query) 83 | 84 | async def update_by_slug( 85 | self, session: AsyncSession, slug: str, update_item: UpdateArticleDTO 86 | ) -> ArticleRecordDTO: 87 | query = ( 88 | update(Article) 89 | .where(Article.slug == slug) 90 | .values(updated_at=datetime.now()) 91 | .returning(Article) 92 | ) 93 | if update_item.title is not None: 94 | updated_slug = make_slug_from_title_and_code( 95 | title=update_item.title, code=get_slug_unique_part(slug=slug) 96 | ) 97 | query = query.values(title=update_item.title, slug=updated_slug) 98 | if update_item.description is not None: 99 | query = query.values(description=update_item.description) 100 | if update_item.body is not None: 101 | query = query.values(body=update_item.body) 102 | 103 | article = await session.scalar(query) 104 | return self._article_mapper.to_dto(article) 105 | 106 | async def list_by_followings( 107 | self, session: AsyncSession, user_id: int, limit: int, offset: int 108 | ) -> list[ArticleRecordDTO]: 109 | query = ( 110 | ( 111 | select( 112 | Article.id, 113 | Article.author_id, 114 | Article.slug, 115 | Article.title, 116 | Article.description, 117 | Article.body, 118 | Article.created_at, 119 | Article.updated_at, 120 | User.username, 121 | User.bio, 122 | User.image_url, 123 | ) 124 | ) 125 | .join( 126 | Follower, 127 | ( 128 | (Follower.following_id == Article.author_id) 129 | & (Follower.follower_id == user_id) 130 | ), 131 | ) 132 | .join(User, (User.id == Article.author_id)) 133 | .order_by(Article.created_at) 134 | ) 135 | query = query.limit(limit).offset(offset) 136 | articles = await session.execute(query) 137 | return [self._article_mapper.to_dto(article) for article in articles] 138 | 139 | async def list_by_followings_v2( 140 | self, session: AsyncSession, user_id: int, limit: int, offset: int 141 | ) -> list[ArticleDTO]: 142 | query = ( 143 | select( 144 | Article.id.label("id"), 145 | Article.author_id.label("author_id"), 146 | Article.slug.label("slug"), 147 | Article.title.label("title"), 148 | Article.description.label("description"), 149 | Article.body.label("body"), 150 | Article.created_at.label("created_at"), 151 | Article.updated_at.label("updated_at"), 152 | User.id.label("user_id"), 153 | User.username.label("username"), 154 | User.bio.label("bio"), 155 | User.email.label("email"), 156 | User.image_url.label("image_url"), 157 | true().label("following"), 158 | # Subquery for favorites count. 159 | select(func.count(Favorite.article_id)) 160 | .where(Favorite.article_id == Article.id) 161 | .scalar_subquery() 162 | .label("favorites_count"), 163 | # Subquery to check if favorited by user with id `user_id`. 164 | exists() 165 | .where( 166 | (Favorite.user_id == user_id) & (Favorite.article_id == Article.id) 167 | ) 168 | .label("favorited"), 169 | # Concatenate tags. 170 | func.string_agg(Tag.tag, ", ").label("tags"), 171 | ) 172 | .join(User, Article.author_id == User.id) 173 | .join(ArticleTag, Article.id == ArticleTag.article_id, isouter=True) 174 | .join(Tag, Tag.id == ArticleTag.tag_id, isouter=True) 175 | .filter( 176 | User.id.in_( 177 | select(Follower.following_id) 178 | .where(Follower.follower_id == user_id) 179 | .scalar_subquery() 180 | ) 181 | ) 182 | .group_by( 183 | Article.id, 184 | Article.author_id, 185 | Article.slug, 186 | Article.title, 187 | Article.description, 188 | Article.body, 189 | Article.created_at, 190 | Article.updated_at, 191 | User.id, 192 | User.username, 193 | User.bio, 194 | User.email, 195 | User.image_url, 196 | ) 197 | ) 198 | query = query.limit(limit).offset(offset) 199 | articles = await session.execute(query) 200 | 201 | return [self._to_article_dto(article) for article in articles] 202 | 203 | async def list_by_filters( 204 | self, 205 | session: AsyncSession, 206 | limit: int, 207 | offset: int, 208 | tag: str | None = None, 209 | author: str | None = None, 210 | favorited: str | None = None, 211 | ) -> list[ArticleRecordDTO]: 212 | query = ( 213 | select( 214 | Article.id, 215 | Article.author_id, 216 | Article.slug, 217 | Article.title, 218 | Article.description, 219 | Article.body, 220 | Article.created_at, 221 | Article.updated_at, 222 | ) 223 | ).order_by(Article.created_at) 224 | 225 | if tag: 226 | # fmt: off 227 | query = query.join( 228 | ArticleTag, 229 | (Article.id == ArticleTag.article_id), 230 | ).where( 231 | ArticleTag.tag_id == select(Tag.id).where( 232 | Tag.tag == tag 233 | ).scalar_subquery() 234 | ) 235 | # fmt: on 236 | 237 | if author: 238 | # fmt: off 239 | query = query.join( 240 | User, 241 | (User.id == Article.author_id) 242 | ).where( 243 | User.username == author 244 | ) 245 | # fmt: on 246 | 247 | if favorited: 248 | # fmt: off 249 | query = query.join( 250 | Favorite, 251 | (Favorite.article_id == Article.id) 252 | ).where( 253 | Favorite.user_id == select(User.id).where( 254 | User.username == favorited 255 | ).scalar_subquery() 256 | ) 257 | # fmt: on 258 | 259 | query = query.limit(limit).offset(offset) 260 | articles = await session.execute(query) 261 | return [self._article_mapper.to_dto(article) for article in articles] 262 | 263 | async def list_by_filters_v2( 264 | self, 265 | session: AsyncSession, 266 | user_id: int | None, 267 | limit: int, 268 | offset: int, 269 | tag: str | None = None, 270 | author: str | None = None, 271 | favorited: str | None = None, 272 | ) -> list[ArticleDTO]: 273 | query = ( 274 | # fmt: off 275 | select( 276 | Article.id.label("id"), 277 | Article.author_id.label("author_id"), 278 | Article.slug.label("slug"), 279 | Article.title.label("title"), 280 | Article.description.label("description"), 281 | Article.body.label("body"), 282 | Article.created_at.label("created_at"), 283 | Article.updated_at.label("updated_at"), 284 | User.id.label("user_id"), 285 | User.username.label("username"), 286 | User.bio.label("bio"), 287 | User.email.label("email"), 288 | User.image_url.label("image_url"), 289 | exists() 290 | .where( 291 | (Follower.follower_id == user_id) & 292 | (Follower.following_id == Article.author_id) 293 | ) 294 | .label("following"), 295 | # Subquery for favorites count. 296 | select( 297 | func.count(Favorite.article_id) 298 | ).where( 299 | Favorite.article_id == Article.id).scalar_subquery() 300 | .label("favorites_count"), 301 | # Subquery to check if favorited by user with id `user_id`. 302 | exists() 303 | .where( 304 | (Favorite.user_id == user_id) & 305 | (Favorite.article_id == Article.id) 306 | ) 307 | .label("favorited"), 308 | # Concatenate tags. 309 | func.string_agg(Tag.tag, ", ").label("tags"), 310 | ) 311 | .outerjoin(User, Article.author_id == User.id) 312 | .outerjoin(ArticleTag, Article.id == ArticleTag.article_id) 313 | .outerjoin(FavoriteAlias, FavoriteAlias.article_id == Article.id) 314 | .outerjoin(Tag, Tag.id == ArticleTag.tag_id) 315 | .filter( 316 | # Filter by author username if provided. 317 | case((author is not None, User.username == author), else_=True), 318 | # Filter by tag if provided. 319 | case((tag is not None, Tag.tag == tag), else_=True), 320 | # Filter by "favorited by" username if provided. 321 | case( 322 | ( 323 | favorited is not None, 324 | FavoriteAlias.user_id == select(User.id) 325 | .where(User.username == favorited) 326 | .scalar_subquery(), 327 | ), 328 | else_=True, 329 | ), 330 | ) 331 | .group_by( 332 | Article.id, 333 | Article.author_id, 334 | Article.slug, 335 | Article.title, 336 | Article.description, 337 | Article.body, 338 | Article.created_at, 339 | Article.updated_at, 340 | User.id, 341 | User.username, 342 | User.bio, 343 | User.email, 344 | User.image_url, 345 | ) 346 | # fmt: on 347 | ) 348 | 349 | query = query.limit(limit).offset(offset) 350 | articles = await session.execute(query) 351 | return [self._to_article_dto(article) for article in articles] 352 | 353 | async def count_by_followings(self, session: AsyncSession, user_id: int) -> int: 354 | query = select(count(Article.id)).join( 355 | Follower, 356 | ( 357 | (Follower.following_id == Article.author_id) 358 | & (Follower.follower_id == user_id) 359 | ), 360 | ) 361 | result = await session.execute(query) 362 | return result.scalar() 363 | 364 | async def count_by_filters( 365 | self, 366 | session: AsyncSession, 367 | tag: str | None = None, 368 | author: str | None = None, 369 | favorited: str | None = None, 370 | ) -> int: 371 | query = select(count(Article.id)) 372 | 373 | if tag: 374 | # fmt: off 375 | query = query.join( 376 | ArticleTag, 377 | (Article.id == ArticleTag.article_id), 378 | ).where( 379 | ArticleTag.tag_id == select(Tag.id).where( 380 | Tag.tag == tag 381 | ).scalar_subquery() 382 | ) 383 | # fmt: on 384 | 385 | if author: 386 | # fmt: off 387 | query = query.join( 388 | User, 389 | (User.id == Article.author_id) 390 | ).where( 391 | User.username == author 392 | ) 393 | # fmt: on 394 | 395 | if favorited: 396 | # fmt: off 397 | query = query.join( 398 | Favorite, 399 | (Favorite.article_id == Article.id) 400 | ).where( 401 | Favorite.user_id == select(User.id).where( 402 | User.username == favorited 403 | ).scalar_subquery() 404 | ) 405 | # fmt: on 406 | 407 | result = await session.execute(query) 408 | return result.scalar() 409 | 410 | @staticmethod 411 | def _to_article_dto(res: Any) -> ArticleDTO: 412 | return ArticleDTO( 413 | id=res.id, 414 | author_id=res.author_id, 415 | slug=res.slug, 416 | title=res.title, 417 | description=res.description, 418 | body=res.body, 419 | tags=res.tags.split(", ") if res.tags else [], 420 | author=ArticleAuthorDTO( 421 | username=res.username, 422 | bio=res.bio, 423 | image=res.image_url, 424 | following=res.following, 425 | ), 426 | created_at=res.created_at, 427 | updated_at=res.updated_at, 428 | favorited=res.favorited, 429 | favorites_count=res.favorites_count, 430 | ) 431 | --------------------------------------------------------------------------------