From 97eebe9acd836abc8007475863331241569d09fc Mon Sep 17 00:00:00 2001 From: ivankirpichnikov Date: Fri, 21 Nov 2025 10:22:04 +0300 Subject: [PATCH] feature: add s3 --- config.example.toml | 6 +++ pyproject.toml | 5 +++ src/template_project/adapters/s3_storage.py | 40 +++++++++++++++++++ .../application/common/file_storage.py | 8 ++++ src/template_project/web_api/configuration.py | 9 +++++ .../web_api/ioc/connection.py | 16 +++++++- src/template_project/web_api/ioc/make.py | 2 + 7 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/template_project/adapters/s3_storage.py create mode 100644 src/template_project/application/common/file_storage.py diff --git a/config.example.toml b/config.example.toml index 4bee85c..47fef47 100644 --- a/config.example.toml +++ b/config.example.toml @@ -16,3 +16,9 @@ client_secret = "..." [firebase] certificate_path = "firebase.json" + +[s3] +bucket_name = "" +endpoint_url = "" +access_key = "" +secret_key = "" diff --git a/pyproject.toml b/pyproject.toml index 4551f4a..b292a5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "httpx==0.28.1", "psycopg[binary]>=3.2.12", "firebase-admin>=7.1.0", + "aioboto3==15.5.0", ] [dependency-groups] @@ -76,6 +77,10 @@ enable_error_code = [ "deprecated", ] +[[tool.mypy.overrides]] +module = ["aioboto3.*", "aiobotocore.*"] +follow_untyped_imports = true + [tool.ruff] fix = true preview = true diff --git a/src/template_project/adapters/s3_storage.py b/src/template_project/adapters/s3_storage.py new file mode 100644 index 0000000..bb9ed7d --- /dev/null +++ b/src/template_project/adapters/s3_storage.py @@ -0,0 +1,40 @@ +from typing import IO, Protocol, override + +from template_project.application.common.file_storage import FileStorage + + +class AioBoto3ClientLike(Protocol): + async def put_object( + self, + *, + Bucket: str, # noqa: N803 + Key: str, # noqa: N803 + Body: IO[bytes], # noqa: N803 + ) -> None: ... + + +class S3FileStorage(FileStorage): + __slots__ = ( + "_bucket_name", + "_client", + ) + + def __init__( + self, + client: AioBoto3ClientLike, + bucket_name: str, + ) -> None: + self._client = client + self._bucket_name = bucket_name + + @override + async def upload( + self, + path: str, + image: IO[bytes], + ) -> None: + await self._client.put_object( + Bucket=self._bucket_name, + Key=path, + Body=image, + ) diff --git a/src/template_project/application/common/file_storage.py b/src/template_project/application/common/file_storage.py new file mode 100644 index 0000000..f0e0904 --- /dev/null +++ b/src/template_project/application/common/file_storage.py @@ -0,0 +1,8 @@ +from abc import abstractmethod +from typing import IO, Protocol + + +class FileStorage(Protocol): + @abstractmethod + async def upload(self, path: str, image: IO[bytes]) -> None: + raise NotImplementedError diff --git a/src/template_project/web_api/configuration.py b/src/template_project/web_api/configuration.py index 09affcc..0d6ba1a 100644 --- a/src/template_project/web_api/configuration.py +++ b/src/template_project/web_api/configuration.py @@ -20,6 +20,14 @@ class DatabaseConfiguration: url: SecretString +@to_configuration +class S3Config: + bucket_name: str + endpoint_url: str + access_key: str + secret_key: str + + @to_configuration class AccessTokenConfiguration: crypto_key: str @@ -57,6 +65,7 @@ class FirebaseConfiguration: @to_configuration class Configuration: + s3: S3Config server: ServerConfiguration database: DatabaseConfiguration access_token: AccessTokenConfiguration diff --git a/src/template_project/web_api/ioc/connection.py b/src/template_project/web_api/ioc/connection.py index b7e4aec..e1e4841 100644 --- a/src/template_project/web_api/ioc/connection.py +++ b/src/template_project/web_api/ioc/connection.py @@ -1,9 +1,11 @@ from collections.abc import AsyncIterable +from aioboto3.session import Session from dishka import Provider, Scope, provide from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine -from template_project.web_api.configuration import DatabaseConfiguration +from template_project.adapters.s3_storage import AioBoto3ClientLike +from template_project.web_api.configuration import DatabaseConfiguration, S3Config class ConnectionProvider(Provider): @@ -21,3 +23,15 @@ class ConnectionProvider(Provider): ) async with session: yield session + + @provide(scope=Scope.APP) + async def s3_client(self, config: S3Config) -> AsyncIterable[AioBoto3ClientLike]: + session = Session() # type: ignore[no-untyped-call] + + async with session.client( + "s3", + endpoint_url=config.endpoint_url, + aws_access_key_id=config.access_key, + aws_secret_access_key=config.secret_key, + ) as s3_client: + yield s3_client diff --git a/src/template_project/web_api/ioc/make.py b/src/template_project/web_api/ioc/make.py index 3a126fb..9c63d26 100644 --- a/src/template_project/web_api/ioc/make.py +++ b/src/template_project/web_api/ioc/make.py @@ -6,6 +6,7 @@ from template_project.web_api.configuration import ( Configuration, DatabaseConfiguration, FirebaseConfiguration, + S3Config, ServerConfiguration, YandexOAuthConfiguration, ) @@ -38,5 +39,6 @@ def make_ioc(configuration: Configuration) -> AsyncContainer: YandexOAuthConfiguration: configuration.yandex_oauth, FirebaseConfiguration: configuration.firebase, Configuration: configuration, + S3Config: configuration.s3, }, )