Skip to content

Commit

Permalink
feature: Use canonical upload URL & provide docs
Browse files Browse the repository at this point in the history
One step closer to `1.0.0` release. Fix the issue with upload URL keys
for tus configs mapping and provide brief documentation, which covers
most of `aiohttp-tus` parts.
  • Loading branch information
playpauseandstop committed Mar 18, 2020
1 parent 2b51f1a commit 631697d
Show file tree
Hide file tree
Showing 11 changed files with 200 additions and 18 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ ChangeLog
1.0.0b1 (In Development)
========================

- Add docs
- Add brief documentation
- Use canonical upload URL for tus config mapping

1.0.0b0 (2020-03-15)
====================
Expand Down
2 changes: 1 addition & 1 deletion aiohttp_tus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

__all__ = ("setup_tus",)
__license__ = "BSD-3-Clause"
__version__ = "1.0.0b0"
__version__ = "1.0.0b1"
15 changes: 14 additions & 1 deletion aiohttp_tus/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ def upload_url_id(self) -> str:

@attr.dataclass(frozen=True, slots=True)
class Resource:
"""Dataclass to store resource metadata.
Given dataclass used internally in between resource chunk uploads and is passed
to ``on_upload_done`` callback if one is defined at :func:`aiohttp_tus.setup_tus`
call.
:param uid: Resource UUID. By default: ``str(uuid.uuid4())``
:param file_name: Resource file name.
:param file_size: Resource file size.
:param offset: Current resource offset.
:param metadata_header: Metadata header sent on initiating resource upload.
"""

file_name: str
file_size: int
offset: int
Expand Down Expand Up @@ -130,7 +143,7 @@ def save_metadata(
return (path, data)


ResourceCallback = Callable[[Resource, Path], Awaitable[None]]
ResourceCallback = Callable[[web.Request, Resource, Path], Awaitable[None]]


def delete_path(path: Path) -> bool:
Expand Down
48 changes: 46 additions & 2 deletions aiohttp_tus/tus.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,47 @@ def setup_tus(
json_dumps: JsonDumps = json.dumps,
json_loads: JsonLoads = json.loads,
) -> web.Application:
"""Setup tus protocol server implementation for aiohttp.web application."""
"""Setup tus protocol server implementation for aiohttp.web application.
It is a cornerstone of ``aiohttp-tus`` library and in most cases only thing
developers need to know for setting up tus.io server for aiohttp.web application.
:param app: :class:`aiohttp.web.Application` instance
:param upload_path:
:class:`pathlib.Path` instance to point the directory where to store uploaded
files. Please, esnure that given directory is exists before application start
and is writeable for current user.
It is possible to prepend any ``match_info`` param from named URL.
:param upload_url:
tus.io upload URL. Can be plain as ``/uploads`` or named as
``/users/{username}/uploads``. By default: ``"/uploads"``
:param allow_overwrite_files:
When enabled allow to overwrite already uploaded files. This may harm
consistency of stored data, cause please use this param with caution. By
default: ``False``
:param decorator:
In case of guarding upload views it might be useful to decorate them with
given decorator function. By default: ``None`` (which means **ANY** client will
able to upload files)
:param on_upload_done:
Coroutine to call after upload is done. Coroutine will receive three arguments:
``request``, ``resource`` & ``file_path``. Request is current
:class:`aiohttp.web.Request` instance. Resource will contain all data about
uploaded resource such as file name, file size
(:class:`aiohttp_tus.data.Resource` instance). While file path will contain
:class:`pathlib.Path` instance of uploaded file.
:param json_dumps:
To store resource metadata between chunk uploads ``aiohttp-tus`` using JSON
files, stored into ``upload_path / ".metadata"`` directory.
To dump the data builtin Python function used: :func:`json.dumps`, but you
might customize things if interested in using ``ujson``, ``orjson``,
``rapidjson`` or other implementation.
:param json_loads:
Similarly to ``json_dumps``, but for loading data from JSON metadata files.
By default: :func:`json.loads`
"""

def decorate(handler: Handler) -> Handler:
if decorator is None:
Expand All @@ -30,6 +70,10 @@ def decorate(handler: Handler) -> Handler:
# Ensure support of multiple tus upload URLs for one application
app.setdefault(APP_TUS_CONFIG_KEY, {})

# Need to find out canonical dynamic resource URL if any and use it for storing
# tus config into the app
canonical_upload_url = web.DynamicResource(upload_url).canonical

# Store tus config in application
config = Config(
upload_path=upload_path,
Expand All @@ -39,7 +83,7 @@ def decorate(handler: Handler) -> Handler:
json_dumps=json_dumps,
json_loads=json_loads,
)
set_config(app, upload_url, config)
set_config(app, canonical_upload_url, config)

# Views for upload management
upload_resource = app.router.add_resource(
Expand Down
4 changes: 2 additions & 2 deletions aiohttp_tus/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ def get_resource_or_410(request: web.Request) -> Resource:


async def on_upload_done(
*, config: Config, resource: Resource, file_path: Path
*, request: web.Request, config: Config, resource: Resource, file_path: Path
) -> None:
if not config.on_upload_done:
return

await config.on_upload_done(resource, file_path)
await config.on_upload_done(request, resource, file_path)


def parse_upload_metadata(metadata_header: str) -> MappingStrBytes:
Expand Down
4 changes: 3 additions & 1 deletion aiohttp_tus/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ async def upload_resource(request: web.Request) -> web.Response:
next_offset = resource.offset + chunk_size
if next_offset == resource.file_size:
file_path = resource.complete(config=config, match_info=match_info)
await on_upload_done(config=config, resource=resource, file_path=file_path)
await on_upload_done(
request=request, config=config, resource=resource, file_path=file_path
)
# But if it is not - store new metadata
else:
next_resource = attr.evolve(resource, offset=next_offset)
Expand Down
3 changes: 2 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
API Reference
=============

TODO
.. autofunction:: aiohttp_tus.setup_tus
.. autoclass:: aiohttp_tus.data.Resource
5 changes: 4 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,7 @@
)
]

intersphinx_mapping = {"https://docs.python.org/3/": None}
intersphinx_mapping = {
"https://docs.python.org/3/": None,
"https://aiohttp.readthedocs.io/en/stable/": None,
}
100 changes: 99 additions & 1 deletion docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,102 @@
Usage
=====

TODO
Default
=======

To allow upload files to ``../uploads`` directory for all clients via ``/uploads`` URL,

.. code-block:: python
from pathlib import Path
from aiohttp import web
from aiohttp_tus import setup_tus
app = setup_tus(
web.Application(),
upload_path=Path(__file__).parent.parent / "uploads"
)
User Uploads
============

To allow upload files to ``/files/{username}`` directory only for authenticated users
via ``/users/{username}/uploads`` URL,

.. code-block:: python
from aiohttp_tus.annotations import Handler
def upload_user_required(handler: Handler) -> Handler:
async def decorator(request: web.Request) -> web.Response:
# Change ``is_user_authenticated`` call to actual call,
# checking whether user authetnicated for given request
# or not
if not is_user_authenticated(request):
raise web.HTTPForbidden()
return await handler(request)
return decorator
app = setup_tus(
web.Application(),
upload_path=Path("/files") / r"{username}",
upload_url=r"/users/{username}/uploads",
decorator=upload_user_required,
)
Callback
========

There is a possibility to run any coroutine after upload is done. Example below,
illustrates how to achieve that,

.. code-block:: python
from aiohttp_tus.data import Resource
async def notify_on_upload(
request: web.Request,
resource: Resource,
file_path: Path,
) -> None:
redis = request.config_dict["redis"]
await redis.rpush("uploaded_files", resource.file_name)
app = setup_tus(
web.Application(),
upload_path=Path(__file__).parent.parent / "uploads",
on_upload_done=notify_on_upload,
)
Mutliple TUS upload URLs
========================

It is possible to setup multiple TUS upload URLs. Example below illustrates, how to
achieve anonymous & authenticated uploads in same time for one
:class:`aiohttp.web.Application` instance.

.. code-block:: python
app = web.Application()
base_upload_path = Path(__file__).parent.parent / "uploads"
# Anonymous users uploads
setup_tus(
app,
upload_path=base_upload_path / "anonymous"
)
# Authenticated users uploads
setup_tus(
app,
upload_path=base_upload_path / r"{username}",
upload_url=r"/users/{username}/uploads",
decorator=upload_user_required,
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ show_missing = true

[tool.poetry]
name = "aiohttp-tus"
version = "1.0.0b0"
version = "1.0.0b1"
description = "tus.io protocol implementation for aiohttp.web applications"
authors = ["Igor Davydenko <iam@igordavydenko.com>"]
license = "BSD-3-Clause"
Expand Down
32 changes: 26 additions & 6 deletions tests/test_tus.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async def test_on_upload_callback(aiohttp_test_client, loop):
data = {}
upload = partial(tus.upload, file_name=TEST_FILE_NAME)

async def on_upload_done(resource, file_path):
async def on_upload_done(request, resource, file_path):
data[resource.file_name] = file_path

async with aiohttp_test_client(
Expand Down Expand Up @@ -149,11 +149,25 @@ async def test_overwrite_file_disallowed(aiohttp_test_client, loop):


@pytest.mark.parametrize(
"upload_url, upload_suffix, tus_upload_url, match_info",
"upload_url, canonical_upload_url, upload_suffix, tus_upload_url, match_info",
(
(TEST_UPLOAD_URL, None, TEST_UPLOAD_URL, {}),
(r"/user/{username}/uploads", None, "/user/playpauseanddtop/uploads", {}),
(TEST_UPLOAD_URL, TEST_UPLOAD_URL, None, TEST_UPLOAD_URL, {}),
(
r"/user/{username}/uploads",
r"/user/{username}/uploads",
None,
"/user/playpauseanddtop/uploads",
{},
),
(
r"/user/{username:([a-zA-Z0-9_-])+}/uploads",
r"/user/{username}/uploads",
None,
"/user/playpauseanddtop/uploads",
{},
),
(
r"/user/{username}/uploads",
r"/user/{username}/uploads",
r"{username}",
"/user/playpauseandstop/uploads",
Expand All @@ -162,7 +176,13 @@ async def test_overwrite_file_disallowed(aiohttp_test_client, loop):
),
)
async def test_upload(
aiohttp_test_client, loop, upload_url, upload_suffix, tus_upload_url, match_info,
aiohttp_test_client,
loop,
upload_url,
canonical_upload_url,
upload_suffix,
tus_upload_url,
match_info,
):
upload = partial(tus.upload, file_name=TEST_FILE_NAME)

Expand All @@ -174,7 +194,7 @@ async def test_upload(
None, upload, handler, get_upload_url(client, tus_upload_url)
)

config: Config = client.app[APP_TUS_CONFIG_KEY][upload_url]
config: Config = client.app[APP_TUS_CONFIG_KEY][canonical_upload_url]
expected_upload_path = config.resolve_upload_path(match_info) / TEST_FILE_NAME
assert expected_upload_path.exists()
assert expected_upload_path.read_bytes() == TEST_FILE_PATH.read_bytes()
Expand Down

0 comments on commit 631697d

Please sign in to comment.