diff --git a/package-lock.json b/package-lock.json index 56659ee..3e394ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "husky": "^8.0.1", "lint-staged": "^13.0.3", "prettier": "2.8.3", - "pyright": "^1.1.299" + "pyright": "^1.1.329" } }, "node_modules/@babel/code-frame": { @@ -1063,6 +1063,20 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -2215,9 +2229,9 @@ } }, "node_modules/pyright": { - "version": "1.1.299", - "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.299.tgz", - "integrity": "sha512-37agqu0oNRsftFHNyGtaN3SBJJ+Qe2pembdgIpif9iL0VNqCU7j3wauSm8j0peXg1uNHFcS8UMrft4hT7w2XvQ==", + "version": "1.1.329", + "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.329.tgz", + "integrity": "sha512-5AT98Mi0OYcDiQ5lD1nPJ3cq8gX/HHaXrQ5WjJ/QZkaJtGqnEdrUp5Gq5wBPipWgOnv/l5e50YScaaNDMjoy9Q==", "dev": true, "bin": { "pyright": "index.js", @@ -2225,6 +2239,9 @@ }, "engines": { "node": ">=12.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" } }, "node_modules/q": { @@ -3883,6 +3900,13 @@ "universalify": "^2.0.0" } }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -4702,10 +4726,13 @@ "dev": true }, "pyright": { - "version": "1.1.299", - "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.299.tgz", - "integrity": "sha512-37agqu0oNRsftFHNyGtaN3SBJJ+Qe2pembdgIpif9iL0VNqCU7j3wauSm8j0peXg1uNHFcS8UMrft4hT7w2XvQ==", - "dev": true + "version": "1.1.329", + "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.329.tgz", + "integrity": "sha512-5AT98Mi0OYcDiQ5lD1nPJ3cq8gX/HHaXrQ5WjJ/QZkaJtGqnEdrUp5Gq5wBPipWgOnv/l5e50YScaaNDMjoy9Q==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } }, "q": { "version": "1.5.1", diff --git a/package.json b/package.json index 50cf231..6db49a1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "husky": "^8.0.1", "lint-staged": "^13.0.3", "prettier": "2.8.3", - "pyright": "^1.1.299" + "pyright": "^1.1.329" }, "hooks": { "commit-msg": "commitlint" diff --git a/src/fastapi_app/alembic/versions/2023-10-02_9749b063b095_added_initial_table.py b/src/fastapi_app/alembic/versions/2023-10-02_9749b063b095_added_initial_table.py new file mode 100644 index 0000000..e300471 --- /dev/null +++ b/src/fastapi_app/alembic/versions/2023-10-02_9749b063b095_added_initial_table.py @@ -0,0 +1,37 @@ +"""Added initial table + +Revision ID: 9749b063b095 +Revises: +Create Date: 2023-10-02 19:46:05.078494 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9749b063b095" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "joke", + sa.Column("type", sa.String(), nullable=False), + sa.Column("setup", sa.String(), nullable=False), + sa.Column("punchline", sa.String(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("joke") + # ### end Alembic commands ### diff --git a/src/fastapi_app/lib/api/v1/handlers/__init__.py b/src/fastapi_app/lib/api/v1/handlers/__init__.py index e679678..0335d3f 100644 --- a/src/fastapi_app/lib/api/v1/handlers/__init__.py +++ b/src/fastapi_app/lib/api/v1/handlers/__init__.py @@ -1,5 +1,4 @@ -from .health import * +from .health import basic_router +from .joke import JokeHandler -__all__ = [ - "basic_router", -] +__all__ = ["JokeHandler", "basic_router"] diff --git a/src/fastapi_app/lib/api/v1/handlers/joke.py b/src/fastapi_app/lib/api/v1/handlers/joke.py new file mode 100644 index 0000000..0743aeb --- /dev/null +++ b/src/fastapi_app/lib/api/v1/handlers/joke.py @@ -0,0 +1,25 @@ +import fastapi + +import lib.api.v1.schemas as api_shemas +import lib.joke.services as joke_services + + +class JokeHandler: + def __init__(self, joke_service: joke_services.JokeService): + self.joke_service = joke_service + self.router = fastapi.APIRouter() + self.router.add_api_route( + "/", + self.get_joke, + methods=["GET"], + summary="Статус работоспособности", + description="Проверяет доступность сервиса FastAPI.", + ) + + async def get_joke(self): + joke = await self.joke_service.get_joke() + if joke: + return api_shemas.JokeResponse( + joke=f"{joke.setup}\n{joke.punchline}", id=joke.id_field, category=joke.type_field + ) + return api_shemas.JokeResponse(joke="No joke for you!", id=0, category="No category") diff --git a/src/fastapi_app/lib/api/v1/schemas/__init__.py b/src/fastapi_app/lib/api/v1/schemas/__init__.py index 3a43b09..d608846 100644 --- a/src/fastapi_app/lib/api/v1/schemas/__init__.py +++ b/src/fastapi_app/lib/api/v1/schemas/__init__.py @@ -1,5 +1,4 @@ from .base import HealthResponseModel +from .joke import JokeResponse -__all__ = [ - "HealthResponseModel", -] +__all__ = ["HealthResponseModel", "JokeResponse"] diff --git a/src/fastapi_app/lib/api/v1/schemas/joke.py b/src/fastapi_app/lib/api/v1/schemas/joke.py new file mode 100644 index 0000000..d146a9f --- /dev/null +++ b/src/fastapi_app/lib/api/v1/schemas/joke.py @@ -0,0 +1,7 @@ +import pydantic + + +class JokeResponse(pydantic.BaseModel): + id_field: int = pydantic.Field(alias="id") + joke: str + category: str diff --git a/src/fastapi_app/lib/app/app.py b/src/fastapi_app/lib/app/app.py index f20bb8d..1366d98 100644 --- a/src/fastapi_app/lib/app/app.py +++ b/src/fastapi_app/lib/app/app.py @@ -11,6 +11,8 @@ import lib.app.errors as app_errors import lib.app.settings as app_settings import lib.app.split_settings as app_split_settings import lib.clients as clients +import lib.joke.repository as joke_repository +import lib.joke.services as joke_services logger = logging.getLogger(__name__) @@ -45,7 +47,6 @@ class Application: logger.info("Initializing global clients") postgres_client = clients.AsyncPostgresClient(settings=settings) - http_client = clients.get_async_http_session() disposable_resources.append( DisposableResource( @@ -61,6 +62,7 @@ class Application: # Repositories logger.info("Initializing repositories") + jk_repository = joke_repository.JokeRepository(async_session=postgres_client.get_async_session()) # Caches @@ -69,12 +71,13 @@ class Application: # Services logger.info("Initializing services") + jk_serivces = joke_services.JokeService(jk_repository) # Handlers logger.info("Initializing handlers") liveness_probe_handler = api_v1_handlers.basic_router - + joke_handler = api_v1_handlers.JokeHandler(joke_service=jk_serivces).router logger.info("Creating application") @@ -88,6 +91,7 @@ class Application: # Routes fastapi_app.include_router(liveness_probe_handler, prefix="/api/v1/health", tags=["health"]) + fastapi_app.include_router(joke_handler, prefix="/test", tags=["some"]) application = Application( settings=settings, diff --git a/src/fastapi_app/lib/clients/__init__.py b/src/fastapi_app/lib/clients/__init__.py index 40d0e2f..1fbe64c 100644 --- a/src/fastapi_app/lib/clients/__init__.py +++ b/src/fastapi_app/lib/clients/__init__.py @@ -1,4 +1,3 @@ -from .httpx import get_async_http_session from .postgres import AsyncPostgresClient -__all__ = ["AsyncPostgresClient", "get_async_http_session"] +__all__ = ["AsyncPostgresClient"] diff --git a/src/fastapi_app/lib/clients/httpx.py b/src/fastapi_app/lib/clients/httpx.py deleted file mode 100644 index 5570d2c..0000000 --- a/src/fastapi_app/lib/clients/httpx.py +++ /dev/null @@ -1,16 +0,0 @@ -import contextlib -import typing - -import httpx - - -@contextlib.asynccontextmanager -async def get_async_http_session( - settings: dict[str, typing.Any] | None = None -) -> typing.AsyncGenerator[httpx.AsyncClient, None]: - """Async http client.""" - if settings is None: - settings = {} - client = httpx.AsyncClient(**settings) # Insert your own settings here - async with client as ac: - yield ac diff --git a/src/fastapi_app/lib/joke/__init__.py b/src/fastapi_app/lib/joke/__init__.py new file mode 100644 index 0000000..a26e7d9 --- /dev/null +++ b/src/fastapi_app/lib/joke/__init__.py @@ -0,0 +1,4 @@ +from .repository import JokeRepository +from .services import JokeService + +__all__ = ["JokeRepository", "JokeService"] diff --git a/src/fastapi_app/lib/joke/repository.py b/src/fastapi_app/lib/joke/repository.py new file mode 100644 index 0000000..dc11183 --- /dev/null +++ b/src/fastapi_app/lib/joke/repository.py @@ -0,0 +1,30 @@ +import logging + +import sqlalchemy.exc +import sqlalchemy.ext.asyncio as sa_asyncio + +import lib.models as models + + +class JokeRepository: + def __init__(self, async_session: sa_asyncio.async_sessionmaker[sa_asyncio.AsyncSession]): + self.async_session = async_session + self.logger = logging.getLogger(__name__) + + async def get_joke_by(self, id: int) -> models.JokeORM | None: + try: + async with self.async_session() as session: + joke = await session.get(models.JokeORM, id) + return joke + except sqlalchemy.exc.SQLAlchemyError as error: + self.logger.exception("Error: %s", error) + + async def add_joke(self, joke: models.JokeORM) -> models.JokeORM | None: + try: + async with self.async_session() as session: + session.add(joke) + await session.commit() + await session.refresh(joke) + return joke + except sqlalchemy.exc.SQLAlchemyError as error: + self.logger.exception("Error: %s", error) diff --git a/src/fastapi_app/lib/joke/services.py b/src/fastapi_app/lib/joke/services.py new file mode 100644 index 0000000..7569500 --- /dev/null +++ b/src/fastapi_app/lib/joke/services.py @@ -0,0 +1,30 @@ +import logging + +import httpx +import pydantic + +import lib.joke.repository as joke_repository +import lib.models as models + + +class JokeService: + def __init__(self, repository: joke_repository.JokeRepository): + self.repository = repository + self.logger = logging.getLogger(__name__) + + async def get_joke(self) -> models.Joke | None: + try: + async with httpx.AsyncClient() as client: + response = await client.get("https://official-joke-api.appspot.com/random_joke") + content = response.json() + self.logger.info("Joke retrieved from API") + formatted_joke = models.JokeORM( + type_field=content["type"], setup=content["setup"], punchline=content["punchline"] + ) + await self.repository.add_joke(formatted_joke) + self.logger.info("Joke added to database") + return models.Joke(**content) + except pydantic.ValidationError as error: + self.logger.exception("Validation Error: %s", error) + except httpx.HTTPError as error: + self.logger.exception("HTTP Error: %s", error) diff --git a/src/fastapi_app/lib/models/__init__.py b/src/fastapi_app/lib/models/__init__.py index 33fb529..db9c34f 100644 --- a/src/fastapi_app/lib/models/__init__.py +++ b/src/fastapi_app/lib/models/__init__.py @@ -1,4 +1,5 @@ -from .base_sqlalchemy import Base, IdCreatedUpdatedBaseMixin +from .joke import Joke +from .orm import Base, IdCreatedUpdatedBaseMixin, JokeORM from .token import Token -__all__ = ["Base", "IdCreatedUpdatedBaseMixin", "Token"] +__all__ = ["Base", "IdCreatedUpdatedBaseMixin", "Joke", "JokeORM", "Token"] diff --git a/src/fastapi_app/lib/models/base_sqlalchemy.py b/src/fastapi_app/lib/models/base_sqlalchemy.py deleted file mode 100644 index 2023439..0000000 --- a/src/fastapi_app/lib/models/base_sqlalchemy.py +++ /dev/null @@ -1,36 +0,0 @@ -import uuid - -import sqlalchemy -import sqlalchemy.dialects.postgresql -import sqlalchemy.ext.declarative -import sqlalchemy.orm - - -class Base(sqlalchemy.orm.DeclarativeBase): - """Base class for all models.""" - - pass - - -class IdCreatedUpdatedBaseMixin(Base): - @sqlalchemy.ext.declarative.declared_attr - def uuid(cls): - return sqlalchemy.Column( - sqlalchemy.dialects.postgresql.UUID(as_uuid=True), - primary_key=True, - default=uuid.uuid4, - unique=True, - nullable=False, - ) - - @sqlalchemy.ext.declarative.declared_attr - def created_at(cls): - return sqlalchemy.Column(sqlalchemy.DateTime, server_default=sqlalchemy.sql.func.now()) - - @sqlalchemy.ext.declarative.declared_attr - def updated_at(cls): - return sqlalchemy.Column(sqlalchemy.DateTime, server_default=sqlalchemy.sql.func.now()) - - @sqlalchemy.ext.declarative.declared_attr.directive - def __tablename__(cls) -> str: - return cls.__name__.lower() diff --git a/src/fastapi_app/lib/models/joke.py b/src/fastapi_app/lib/models/joke.py new file mode 100644 index 0000000..c17abae --- /dev/null +++ b/src/fastapi_app/lib/models/joke.py @@ -0,0 +1,10 @@ +import pydantic + + +class Joke(pydantic.BaseModel): + """Joke model.""" + + id_field: int = pydantic.Field(alias="id") + type_field: str = pydantic.Field(alias="type") + setup: str + punchline: str diff --git a/src/fastapi_app/lib/models/orm/__init__.py b/src/fastapi_app/lib/models/orm/__init__.py new file mode 100644 index 0000000..f01a3d8 --- /dev/null +++ b/src/fastapi_app/lib/models/orm/__init__.py @@ -0,0 +1,4 @@ +from .base import Base, IdCreatedUpdatedBaseMixin +from .joke import JokeORM + +__all__ = ["Base", "IdCreatedUpdatedBaseMixin", "JokeORM"] diff --git a/src/fastapi_app/lib/models/orm/base.py b/src/fastapi_app/lib/models/orm/base.py new file mode 100644 index 0000000..3d1ace3 --- /dev/null +++ b/src/fastapi_app/lib/models/orm/base.py @@ -0,0 +1,35 @@ +import datetime +import uuid + +import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlalchemy.ext.declarative +import sqlalchemy.orm as sa_orm +import sqlalchemy.sql as sa_sql + + +class Base(sa_orm.DeclarativeBase): + """Base class for all models.""" + + @sqlalchemy.ext.declarative.declared_attr.directive + def __tablename__(cls): + return cls.__name__.lower() + + __mapper_args__ = {"eager_defaults": True} + + id: sa_orm.Mapped[uuid.UUID] = sa_orm.mapped_column(primary_key=True, default=uuid.uuid4) + + +class IdCreatedUpdatedBaseMixin: + # id: sa_orm.Mapped[int] = sa_orm.mapped_column(primary_key=True) + # id_field: sa_orm.Mapped[uuid.UUID] = sa_orm.mapped_column(name="uuid", primary_key=True, unique=True, default=uuid.uuid4, nullable=False) + created: sa_orm.Mapped[datetime.datetime] = sa_orm.mapped_column(server_default=sa_sql.func.now()) + updated: sa_orm.Mapped[datetime.datetime] = sa_orm.mapped_column( + server_default=sa_sql.func.now(), onupdate=sa_sql.func.now() + ) + + # __mapper_args__ = {"eager_defaults": True} + + # @sqlalchemy.ext.declarative.declared_attr.directive + # def __tablename__(cls) -> str: + # return cls.__name__.lower() diff --git a/src/fastapi_app/lib/models/orm/joke.py b/src/fastapi_app/lib/models/orm/joke.py new file mode 100644 index 0000000..38aa64d --- /dev/null +++ b/src/fastapi_app/lib/models/orm/joke.py @@ -0,0 +1,13 @@ +import sqlalchemy.orm as sa_orm + +import lib.models.orm.base as base + +Base = base.Base + + +class JokeORM(Base): + __tablename__ = "joke" # type: ignore + + type_field: sa_orm.Mapped[str] = sa_orm.mapped_column(name="type", nullable=False) + setup: sa_orm.Mapped[str] = sa_orm.mapped_column() + punchline: sa_orm.Mapped[str] = sa_orm.mapped_column() diff --git a/src/fastapi_app/poetry.lock b/src/fastapi_app/poetry.lock index 8f5de35..cfa62cc 100644 --- a/src/fastapi_app/poetry.lock +++ b/src/fastapi_app/poetry.lock @@ -988,13 +988,13 @@ pytest = ">=4.6" [[package]] name = "pyright" -version = "1.1.327" +version = "1.1.329" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.327-py3-none-any.whl", hash = "sha256:3462cda239e9140276238bbdbd0b59d77406f1c2e14d8cb8c20c8e25639c6b3c"}, - {file = "pyright-1.1.327.tar.gz", hash = "sha256:ba74148ad64f22020dbbed6781c4bdb38ecb8a7ca90dc3c87a4f08d1c0e11592"}, + {file = "pyright-1.1.329-py3-none-any.whl", hash = "sha256:c16f88a7ac14ddd0513e62fec56d69c37e3c6b412161ad16aa23a9c7e3dabaf4"}, + {file = "pyright-1.1.329.tar.gz", hash = "sha256:5baf82ff5ecb8c8b3ac400e8536348efbde0b94a09d83d5b440c0d143fd151a8"}, ] [package.dependencies] diff --git a/src/fastapi_app/pyproject.toml b/src/fastapi_app/pyproject.toml index 441feb0..54d66d3 100644 --- a/src/fastapi_app/pyproject.toml +++ b/src/fastapi_app/pyproject.toml @@ -129,6 +129,8 @@ ignore = [ "D415", # Type-checkers interpret redundant `as` as exporting an item "PLC0414", + # Permit using alias for 'import' + "PLR0402", # Causes churn and awful looking import blocks for little gain "TCH" ]