diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8fb7d69..2dc10db 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ==================== diff --git a/aiohttp_tus/__init__.py b/aiohttp_tus/__init__.py index 6720bc9..6acecb4 100644 --- a/aiohttp_tus/__init__.py +++ b/aiohttp_tus/__init__.py @@ -2,4 +2,4 @@ __all__ = ("setup_tus",) __license__ = "BSD-3-Clause" -__version__ = "1.0.0b0" +__version__ = "1.0.0b1" diff --git a/aiohttp_tus/data.py b/aiohttp_tus/data.py index 71c8764..45959b9 100644 --- a/aiohttp_tus/data.py +++ b/aiohttp_tus/data.py @@ -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 @@ -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: diff --git a/aiohttp_tus/tus.py b/aiohttp_tus/tus.py index 8e86e67..4172ca0 100644 --- a/aiohttp_tus/tus.py +++ b/aiohttp_tus/tus.py @@ -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: @@ -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, @@ -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( diff --git a/aiohttp_tus/utils.py b/aiohttp_tus/utils.py index 018fbab..b7f4f7e 100644 --- a/aiohttp_tus/utils.py +++ b/aiohttp_tus/utils.py @@ -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: diff --git a/aiohttp_tus/views.py b/aiohttp_tus/views.py index 0ddb381..7c17a4c 100644 --- a/aiohttp_tus/views.py +++ b/aiohttp_tus/views.py @@ -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) diff --git a/docs/api.rst b/docs/api.rst index f3b273a..201b865 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2,4 +2,5 @@ API Reference ============= -TODO +.. autofunction:: aiohttp_tus.setup_tus +.. autoclass:: aiohttp_tus.data.Resource diff --git a/docs/conf.py b/docs/conf.py index a926dbc..c2c5055 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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, +} diff --git a/docs/usage.rst b/docs/usage.rst index c5e9f50..562c402 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -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, + ) diff --git a/pyproject.toml b/pyproject.toml index 7176a9e..9347458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "BSD-3-Clause" diff --git a/tests/test_tus.py b/tests/test_tus.py index e442e86..8064128 100644 --- a/tests/test_tus.py +++ b/tests/test_tus.py @@ -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( @@ -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", @@ -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) @@ -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()