Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
42 changes: 42 additions & 0 deletions ellar_storage/controller.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 16 additions & 2 deletions ellar_storage/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.")
2 changes: 2 additions & 0 deletions ellar_storage/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ classifiers = [
]

dependencies = [
"ellar >= 0.7.3",
"ellar >= 0.7.7",
"apache-libcloud >=3.6, <3.9",
"fasteners ==0.19"
]
Expand Down
41 changes: 41 additions & 0 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions tests/test_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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"
)