Skip to content

Commit 8fcddc2

Browse files
authored
Merge pull request #13 from python-ellar/storage_controller
Feat: Storage Controller
2 parents 12072f0 + 7faeda6 commit 8fcddc2

File tree

7 files changed

+161
-3
lines changed

7 files changed

+161
-3
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,36 @@ class DevelopmentConfig(ConfigDefaultTypesMixin):
109109
)
110110
```
111111

112+
### StorageController
113+
`StorageModule` also registers `StorageController` which is useful when retrieving saved files.
114+
This can be disabled by setting `disable_storage_controller` to `True`.
115+
116+
Also, `StorageController` is not protected and will be accessible to the public.
117+
However, it can be protected by simply applying `@Guard` or `@Authorize` decorator.
118+
119+
#### Retrieving Saved Data
120+
By using `request.url_for`, we can generate a download link for the file we wish to retrieve
121+
For example:
122+
123+
```python
124+
from ellar.common import Inject, post
125+
from ellar.core import Request
126+
127+
@post('/get-books')
128+
def get_book_by_id(self, req: Request, book_id, session: Inject[Session]):
129+
book = session.execute(
130+
select(Book).where(Book.title == "Pointless Meetings")
131+
).scalar_one()
132+
133+
return {
134+
"title": book.title,
135+
"cover": req.url_for("storage:download", path="{storage_name}/{file_name}"),
136+
"thumbnail": req.url_for("storage:download", path=book.thumbnail.path)
137+
}
138+
```
139+
With `req.url_for("storage:download", path="{storage_name}/{file_name}")`,
140+
we are able to create a download link to retrieve saved files.
141+
112142
### StorageService
113143
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:
114144

ellar_storage/controller.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import typing as t
2+
3+
import ellar.common as ecm
4+
from ellar.common import NotFound
5+
from ellar.core import Request
6+
from libcloud.storage.types import ObjectDoesNotExistError
7+
from starlette.responses import RedirectResponse, StreamingResponse
8+
9+
from ellar_storage.services import StorageService
10+
11+
12+
@ecm.Controller(name="storage")
13+
class StorageController:
14+
def __init__(self, storage_service: StorageService):
15+
self._storage_service = storage_service
16+
17+
@ecm.get("/download/{path:path}", name="download", include_in_schema=False)
18+
@ecm.file()
19+
def download_file(self, req: Request, path: str) -> t.Any:
20+
try:
21+
res = self._storage_service.get(path)
22+
23+
if res.get_cdn_url() is None: # pragma: no cover
24+
return StreamingResponse(
25+
res.object.as_stream(),
26+
media_type=res.content_type,
27+
headers={
28+
"Content-Disposition": f"attachment;filename={res.filename}"
29+
},
30+
)
31+
32+
if res.object.driver.name != "Local Storage": # pragma: no cover
33+
return RedirectResponse(res.get_cdn_url()) # type:ignore[arg-type]
34+
35+
return {
36+
"path": res.get_cdn_url(), # since we are using a local storage, this will return a path to the file
37+
"filename": res.filename,
38+
"media_type": res.content_type,
39+
}
40+
41+
except ObjectDoesNotExistError as obex:
42+
raise NotFound() from obex

ellar_storage/module.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from ellar.core.modules import DynamicModule, ModuleBase
66
from ellar.di import ProviderConfig
77

8+
from ellar_storage.controller import StorageController
89
from ellar_storage.schemas import StorageSetup
910
from ellar_storage.services import StorageService
1011
from ellar_storage.storage import StorageDriver
@@ -23,14 +24,24 @@ class _StorageSetupKey(t.TypedDict):
2324
class StorageModule(ModuleBase, IModuleSetup):
2425
@classmethod
2526
def setup(
26-
cls, default: t.Optional[str] = None, **kwargs: _StorageSetupKey
27+
cls,
28+
default: t.Optional[str] = None,
29+
disable_storage_controller: bool = False,
30+
**kwargs: _StorageSetupKey,
2731
) -> DynamicModule:
28-
schema = StorageSetup(storages=kwargs, default=default) # type:ignore[arg-type]
32+
schema = StorageSetup(
33+
storages=kwargs, # type:ignore[arg-type]
34+
default=default,
35+
disable_storage_controller=disable_storage_controller,
36+
)
2937
return DynamicModule(
3038
cls,
3139
providers=[
3240
ProviderConfig(StorageService, use_value=StorageService(schema)),
3341
],
42+
controllers=[]
43+
if schema.disable_storage_controller
44+
else [StorageController],
3445
)
3546

3647
@classmethod
@@ -48,5 +59,8 @@ def __register_setup_factory(
4859
providers=[
4960
ProviderConfig(StorageService, use_value=StorageService(schema)),
5061
],
62+
controllers=[]
63+
if schema.disable_storage_controller
64+
else [StorageController],
5165
)
5266
raise RuntimeError("Could not find `STORAGE_CONFIG` in application config.")

ellar_storage/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class StorageSetup(BaseModel):
2424
default: t.Optional[str] = None
2525
# storage configurations
2626
storages: t.Dict[str, _StorageSetupItem]
27+
# disable StorageController
28+
disable_storage_controller: bool = False
2729

2830
@model_validator(mode="before")
2931
def post_default_validate(cls, values: t.Dict) -> t.Any:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ classifiers = [
4040
]
4141

4242
dependencies = [
43-
"ellar >= 0.7.3",
43+
"ellar >= 0.7.7",
4444
"apache-libcloud >=3.6, <3.9",
4545
"fasteners ==0.19"
4646
]

tests/test_controller.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from ellar.common.datastructures import ContentFile
2+
from ellar.testing import Test
3+
4+
from ellar_storage import StorageService
5+
6+
from .test_service import module_config
7+
8+
9+
def test_storage_controller_download_file(clear_dir):
10+
tm = Test.create_test_module(**module_config)
11+
12+
storage_service: StorageService = tm.get(StorageService)
13+
14+
storage_service.save(ContentFile(b"File saving worked", name="get.txt"))
15+
storage_service.save(
16+
ContentFile(b"File saving worked in images", name="get.txt"),
17+
upload_storage="images",
18+
)
19+
20+
url = tm.create_application().url_path_for(
21+
"storage:download", path="images/get.txt"
22+
)
23+
res = tm.get_test_client().get(url)
24+
25+
assert res.status_code == 200
26+
assert res.stream
27+
assert res.text == "File saving worked in images"
28+
29+
url = tm.create_application().url_path_for("storage:download", path="files/get.txt")
30+
res = tm.get_test_client().get(url)
31+
32+
assert res.status_code == 200
33+
assert res.stream
34+
assert res.text == "File saving worked"
35+
36+
res = tm.get_test_client().get(
37+
url=tm.create_application().url_path_for(
38+
"storage:download", path="files/get342.txt"
39+
)
40+
)
41+
assert res.status_code == 404

tests/test_module.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44
from ellar.testing import Test
5+
from starlette.routing import NoMatchFound
56

67
from ellar_storage import Provider, StorageModule, StorageService, get_driver
78

@@ -70,6 +71,11 @@ def test_module_register_setup_with_default():
7071
assert storage_service._storage_default == "images"
7172
assert storage_service.get_container("images").driver.name == "Local Storage"
7273

74+
url = tm.create_application().url_path_for(
75+
"storage:download", path="file/anyfile.ex"
76+
)
77+
assert url == "/storage/download/file/anyfile.ex"
78+
7379

7480
def test_module_register_fails_config_key_absents():
7581
tm = Test.create_test_module(
@@ -81,3 +87,26 @@ def test_module_register_fails_config_key_absents():
8187
RuntimeError, match="Could not find `STORAGE_CONFIG` in application config."
8288
):
8389
tm.create_application()
90+
91+
92+
def test_disable_storage_controller():
93+
tm = Test.create_test_module(
94+
modules=[StorageModule.register_setup()],
95+
config_module={
96+
"STORAGE_CONFIG": {
97+
"default": "files",
98+
"storages": {
99+
"files": {
100+
"driver": get_driver(Provider.LOCAL),
101+
"options": {"key": os.path.join(DUMB_DIRS, "fixtures")},
102+
},
103+
},
104+
"disable_storage_controller": True,
105+
}
106+
},
107+
)
108+
109+
with pytest.raises(NoMatchFound):
110+
tm.create_application().url_path_for(
111+
"storage:download", path="files/anyfile.ex"
112+
)

0 commit comments

Comments
 (0)