diff --git a/README.md b/README.md index 1278782..6f9638a 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,36 @@ class DevelopmentConfig(ConfigDefaultTypesMixin): ) ``` +### StorageController +`StorageModule` also registers `StorageController` which is useful when retrieving saved files. +This can be disabled by setting `disable_storage_controller` to `True`. + +Also, `StorageController` is not protected and will be accessible to the public. +However, it can be protected by simply applying `@Guard` or `@Authorize` decorator. + +#### Retrieving Saved Data +By using `request.url_for`, we can generate a download link for the file we wish to retrieve +For example: + +```python +from ellar.common import Inject, post +from ellar.core import Request + +@post('/get-books') +def get_book_by_id(self, req: Request, book_id, session: Inject[Session]): + book = session.execute( + select(Book).where(Book.title == "Pointless Meetings") + ).scalar_one() + + return { + "title": book.title, + "cover": req.url_for("storage:download", path="{storage_name}/{file_name}"), + "thumbnail": req.url_for("storage:download", path=book.thumbnail.path) + } +``` +With `req.url_for("storage:download", path="{storage_name}/{file_name}")`, +we are able to create a download link to retrieve saved files. + ### StorageService At the end of the `StorageModule` setup, `StorageService` is registered into the Ellar DI system. Here's a quick example of how to use it: diff --git a/ellar_storage/controller.py b/ellar_storage/controller.py new file mode 100644 index 0000000..dcc0985 --- /dev/null +++ b/ellar_storage/controller.py @@ -0,0 +1,42 @@ +import typing as t + +import ellar.common as ecm +from ellar.common import NotFound +from ellar.core import Request +from libcloud.storage.types import ObjectDoesNotExistError +from starlette.responses import RedirectResponse, StreamingResponse + +from ellar_storage.services import StorageService + + +@ecm.Controller(name="storage") +class StorageController: + def __init__(self, storage_service: StorageService): + self._storage_service = storage_service + + @ecm.get("/download/{path:path}", name="download", include_in_schema=False) + @ecm.file() + def download_file(self, req: Request, path: str) -> t.Any: + try: + res = self._storage_service.get(path) + + if res.get_cdn_url() is None: # pragma: no cover + return StreamingResponse( + res.object.as_stream(), + media_type=res.content_type, + headers={ + "Content-Disposition": f"attachment;filename={res.filename}" + }, + ) + + if res.object.driver.name != "Local Storage": # pragma: no cover + return RedirectResponse(res.get_cdn_url()) # type:ignore[arg-type] + + return { + "path": res.get_cdn_url(), # since we are using a local storage, this will return a path to the file + "filename": res.filename, + "media_type": res.content_type, + } + + except ObjectDoesNotExistError as obex: + raise NotFound() from obex diff --git a/ellar_storage/module.py b/ellar_storage/module.py index 185d591..93a5cd3 100644 --- a/ellar_storage/module.py +++ b/ellar_storage/module.py @@ -5,6 +5,7 @@ from ellar.core.modules import DynamicModule, ModuleBase from ellar.di import ProviderConfig +from ellar_storage.controller import StorageController from ellar_storage.schemas import StorageSetup from ellar_storage.services import StorageService from ellar_storage.storage import StorageDriver @@ -23,14 +24,24 @@ class _StorageSetupKey(t.TypedDict): class StorageModule(ModuleBase, IModuleSetup): @classmethod def setup( - cls, default: t.Optional[str] = None, **kwargs: _StorageSetupKey + cls, + default: t.Optional[str] = None, + disable_storage_controller: bool = False, + **kwargs: _StorageSetupKey, ) -> DynamicModule: - schema = StorageSetup(storages=kwargs, default=default) # type:ignore[arg-type] + schema = StorageSetup( + storages=kwargs, # type:ignore[arg-type] + default=default, + disable_storage_controller=disable_storage_controller, + ) return DynamicModule( cls, providers=[ ProviderConfig(StorageService, use_value=StorageService(schema)), ], + controllers=[] + if schema.disable_storage_controller + else [StorageController], ) @classmethod @@ -48,5 +59,8 @@ def __register_setup_factory( providers=[ ProviderConfig(StorageService, use_value=StorageService(schema)), ], + controllers=[] + if schema.disable_storage_controller + else [StorageController], ) raise RuntimeError("Could not find `STORAGE_CONFIG` in application config.") diff --git a/ellar_storage/schemas.py b/ellar_storage/schemas.py index be1a789..67f5f13 100644 --- a/ellar_storage/schemas.py +++ b/ellar_storage/schemas.py @@ -24,6 +24,8 @@ class StorageSetup(BaseModel): default: t.Optional[str] = None # storage configurations storages: t.Dict[str, _StorageSetupItem] + # disable StorageController + disable_storage_controller: bool = False @model_validator(mode="before") def post_default_validate(cls, values: t.Dict) -> t.Any: diff --git a/pyproject.toml b/pyproject.toml index fde6b42..b2a6ec8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ classifiers = [ ] dependencies = [ - "ellar >= 0.7.3", + "ellar >= 0.7.7", "apache-libcloud >=3.6, <3.9", "fasteners ==0.19" ] diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..f9a9983 --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,41 @@ +from ellar.common.datastructures import ContentFile +from ellar.testing import Test + +from ellar_storage import StorageService + +from .test_service import module_config + + +def test_storage_controller_download_file(clear_dir): + tm = Test.create_test_module(**module_config) + + storage_service: StorageService = tm.get(StorageService) + + storage_service.save(ContentFile(b"File saving worked", name="get.txt")) + storage_service.save( + ContentFile(b"File saving worked in images", name="get.txt"), + upload_storage="images", + ) + + url = tm.create_application().url_path_for( + "storage:download", path="images/get.txt" + ) + res = tm.get_test_client().get(url) + + assert res.status_code == 200 + assert res.stream + assert res.text == "File saving worked in images" + + url = tm.create_application().url_path_for("storage:download", path="files/get.txt") + res = tm.get_test_client().get(url) + + assert res.status_code == 200 + assert res.stream + assert res.text == "File saving worked" + + res = tm.get_test_client().get( + url=tm.create_application().url_path_for( + "storage:download", path="files/get342.txt" + ) + ) + assert res.status_code == 404 diff --git a/tests/test_module.py b/tests/test_module.py index 5bcadbd..ec93c62 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -2,6 +2,7 @@ import pytest from ellar.testing import Test +from starlette.routing import NoMatchFound from ellar_storage import Provider, StorageModule, StorageService, get_driver @@ -70,6 +71,11 @@ def test_module_register_setup_with_default(): assert storage_service._storage_default == "images" assert storage_service.get_container("images").driver.name == "Local Storage" + url = tm.create_application().url_path_for( + "storage:download", path="file/anyfile.ex" + ) + assert url == "/storage/download/file/anyfile.ex" + def test_module_register_fails_config_key_absents(): tm = Test.create_test_module( @@ -81,3 +87,26 @@ def test_module_register_fails_config_key_absents(): RuntimeError, match="Could not find `STORAGE_CONFIG` in application config." ): tm.create_application() + + +def test_disable_storage_controller(): + tm = Test.create_test_module( + modules=[StorageModule.register_setup()], + config_module={ + "STORAGE_CONFIG": { + "default": "files", + "storages": { + "files": { + "driver": get_driver(Provider.LOCAL), + "options": {"key": os.path.join(DUMB_DIRS, "fixtures")}, + }, + }, + "disable_storage_controller": True, + } + }, + ) + + with pytest.raises(NoMatchFound): + tm.create_application().url_path_for( + "storage:download", path="files/anyfile.ex" + )