├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yaml └── workflows │ ├── check-lint.yaml │ ├── check-pytest.yaml │ └── release-pypi.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── asgi_middleware_static_file ├── __init__.py └── core.py ├── example ├── example_django │ ├── RunOnASGI.sh │ ├── example_django │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── static │ │ │ └── DEMO.txt │ │ ├── urls.py │ │ └── wsgi.py │ └── manage.py ├── example_pure_asgi.py ├── example_quart.py ├── example_static │ └── DEMO └── example_wsgi_app.py ├── publish_to_pypi.sh ├── pyproject.toml ├── requirements.txt ├── requirements ├── base.txt ├── dev.txt ├── pypi.txt └── test.txt ├── setup.cfg └── tests ├── __init__.py ├── asgi_test_kit.py ├── static └── text-file.txt ├── test_asgi_middleware_path.py ├── test_param_parser.py ├── test_secure.py └── test_with_mock_app.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = asgi_middleware_static_file 3 | omit = setup.py,examples.py,conftest.py,test_*,venv* 4 | 5 | [html] 6 | directory = htmlcov -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ## Environment 14 | - OS version: 15 | - Python version: 16 | 17 | ## Expected behavior 18 | A clear and concise description of what you expected to happen. 19 | 20 | ## Log information 21 | If applicable, add log information to help explain your problem. 22 | 23 | ## Additional context 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/requirements" 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /.github/workflows/check-lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint with Black and flake8 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - "docs/**" 9 | - ".github/**" 10 | 11 | pull_request: 12 | branches: 13 | - main 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-python@v5 21 | - name: Lint with Black 22 | uses: psf/black@stable 23 | with: 24 | args: ". --check" 25 | - name: Install dependencies 26 | run: | 27 | pip install -U flake8 28 | - name: Lint with flake8 29 | run: | 30 | flake8 --verbose asgi_middleware_static_file 31 | -------------------------------------------------------------------------------- /.github/workflows/check-pytest.yaml: -------------------------------------------------------------------------------- 1 | name: pytest 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - "*.md" 7 | - "docs/**" 8 | - ".github/**" 9 | 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Cache pip 28 | uses: actions/cache@v3 29 | with: 30 | # This path is specific to Ubuntu 31 | path: ~/.cache/pip 32 | # Look to see if there is a cache hit for the corresponding requirements file 33 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} 34 | restore-keys: | 35 | ${{ runner.os }}-pip- 36 | ${{ runner.os }}- 37 | - name: Install dependencies 38 | run: | 39 | pip install -U -r requirements/base.txt 40 | pip install -U -r requirements/test.txt 41 | - name: Test with pytest 42 | run: | 43 | pytest --cov=asgi_middleware_static_file --cov-report=xml 44 | - name: Upload coverage to Codecov 45 | uses: codecov/codecov-action@v3 46 | with: 47 | verbose: true # optional (default = false) 48 | -------------------------------------------------------------------------------- /.github/workflows/release-pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish package to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | publish: 10 | name: Publish 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install dependencies 15 | run: | 16 | python3 -m pip install -U -r requirements/pypi.txt 17 | - name: Build wheels 18 | run: | 19 | python -m build 20 | - name: Publish a Python distribution to PyPI 21 | uses: pypa/gh-action-pypi-publish@release/v1 22 | with: 23 | password: ${{ secrets.PYPI_API_TOKEN }} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | /DEMO* 132 | /example/DEMO* 133 | /example/example_django/staticfiles/ 134 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.15.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py37-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 23.11.0 9 | hooks: 10 | - id: black 11 | args: ["--target-version", "py37"] 12 | - repo: https://github.com/pycqa/isort 13 | rev: 5.13.1 14 | hooks: 15 | - id: isort 16 | name: isort (python) 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rex Zhang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include requirements *.txt 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASGIMiddlewareStaticFile 2 | 3 | ![GitHub](https://img.shields.io/github/license/rexzhang/asgi-middleware-static-file) 4 | [![](https://img.shields.io/pypi/v/ASGIMiddlewareStaticFile.svg)](https://pypi.org/project/ASGIMiddlewareStaticFile/) 5 | [![](https://img.shields.io/pypi/pyversions/ASGIMiddlewareStaticFile.svg)](https://pypi.org/project/ASGIMiddlewareStaticFile/) 6 | ![Pytest Workflow Status](https://github.com/rexzhang/asgi-middleware-static-file/actions/workflows/check-pytest.yaml/badge.svg) 7 | [![codecov](https://codecov.io/gh/rexzhang/asgi-middleware-static-file/branch/main/graph/badge.svg?token=083O4RHEZE)](https://codecov.io/gh/rexzhang/asgi-middleware-static-file) 8 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 9 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/ASGIMiddlewareStaticFile) 10 | 11 | ASGI Middleware for serving static file. 12 | 13 | ## Why? 14 | 15 | > ASGIMiddlewareStaticFile is a solution when we need to distribute the whole project with static files in Docker; or 16 | > when the deployment environment has very limited resources; or Internal network(Unable to reach CDN). 17 | 18 | ## Features 19 | 20 | - Standard ASGI middleware implement 21 | - Async file IO 22 | - Support ETag, base on md5(file_size + last_modified) 23 | 24 | ## Install 25 | 26 | ```shell 27 | pip3 install -U ASGIMiddlewareStaticFile 28 | ``` 29 | 30 | ## Usage 31 | 32 | ### Common 33 | 34 | #### Prepare 35 | 36 | ```shell 37 | pip3 install -U ASGIMiddlewareStaticFile 38 | git clone https://github.com/rexzhang/asgi-middleware-static-file.git 39 | cd asgi-middleware-static-file/example 40 | ``` 41 | 42 | #### Test with wget 43 | 44 | ```shell 45 | (venv) ➜ example git:(main) ✗ wget http://127.0.0.1:8000/static/DEMO 46 | --2022-02-10 16:02:07-- http://127.0.0.1:8000/static/DEMO 47 | 正在连接 127.0.0.1:8000... 已连接。 48 | 已发出 HTTP 请求,正在等待回应... 200 OK 49 | 长度:26 [] 50 | 正在保存至: “DEMO” 51 | 52 | DEMO 100%[===========================================================================>] 26 --.-KB/s 用时 0s 53 | 54 | 2022-02-10 16:02:08 (529 KB/s) - 已保存 “DEMO” [26/26]) 55 | ``` 56 | 57 | ### [Pure ASGI](https://asgi.readthedocs.io/en/latest/introduction.html) 58 | 59 | #### Code 60 | 61 | [`example_pure_asgi.py`](https://github.com/rexzhang/asgi-middleware-static-file/blob/main/example/example_pure_asgi.py) 62 | 63 | #### Start Server 64 | 65 | ```shell 66 | (venv) ➜ example git:(main) ✗ uvicorn example_pure_asgi:app 67 | ``` 68 | 69 | ### [Django](https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/) 3.0+ 70 | 71 | #### Code 72 | 73 | [`/example_django/asgi.py`](https://github.com/rexzhang/asgi-middleware-static-file/blob/main/example/example_django/example_django/asgi.py) 74 | 75 | #### Collect static file 76 | 77 | ```shell 78 | (venv) ➜ example git:(main) cd example_django 79 | (venv) ➜ example_django git:(main) ✗ python manage.py collectstatic 80 | 81 | 129 static files copied to '/Users/rex/p/asgi-middleware-static-file/example/example_django/staticfiles'. 82 | ``` 83 | 84 | #### Start Server 85 | 86 | ```shell 87 | (venv) ➜ example_django git:(main) ✗ uvicorn example_django.asgi:application 88 | ``` 89 | 90 | ### [Quart](https://pgjones.gitlab.io/quart/tutorials/quickstart.html) (Flask like) 91 | 92 | #### Code 93 | 94 | [`example_quart.py`](https://github.com/rexzhang/asgi-middleware-static-file/blob/main/example/example_quart.py) 95 | 96 | #### Start Server 97 | 98 | ```shell 99 | (venv) ➜ example git:(main) ✗ uvicorn example_quart:app 100 | ``` 101 | 102 | ### [WSGI app](https://www.python.org/dev/peps/pep-3333/) eg: Flask, Django on WSGI mode 103 | 104 | #### Code 105 | 106 | [`example_wsgi_app.py`](https://github.com/rexzhang/asgi-middleware-static-file/blob/main/example/example_wsgi_app.py) 107 | 108 | #### Start Server 109 | 110 | ``` 111 | (venv) ➜ example git:(main) ✗ uvicorn example_wsgi_app:app 112 | ``` 113 | 114 | ## FAQ 115 | 116 | ### My static files are distributed in several different directories 117 | 118 | You can send a list to `static_root_paths`; example: 119 | 120 | ```python 121 | static_root_paths = [ "/path/a", "path/b" ] 122 | application = ASGIMiddlewareStaticFile( 123 | application, 124 | static_url=settings.STATIC_URL, 125 | static_root_paths=static_root_paths, 126 | ) 127 | ``` 128 | 129 | ## History 130 | 131 | ### 0.6.1 - 20231219 132 | 133 | - Maintenance update 134 | - Change depend policy 135 | 136 | ### 0.6.0 - 20230210 137 | 138 | - Update aiofiles to 23.1.0 139 | - Use more async API 140 | 141 | ### 0.5.0 - 20220909 142 | 143 | - Use more aiofiles api 144 | - Dropped Python 3.6 support. If you require it, use version 0.4.0 145 | - Update package for pep517/pep621 146 | 147 | ### v0.4.0 - 20220422 148 | 149 | - Rewrite some code 150 | - Fix bug #3(Cannot serve files from root (static_url="/" becomes "//")) 151 | 152 | ### v0.3.2 153 | 154 | - Maintenance release 155 | - Drop Py35 156 | 157 | ### v0.3.1 158 | 159 | - Compatible Py37- 160 | 161 | ### v0.3.0 162 | 163 | - Check cross border access 164 | - Add more type hints 165 | 166 | ### v0.2.1 167 | 168 | - Fix bug 169 | 170 | ### v0.2.0 171 | 172 | - Update for aiofiles 173 | - Fix bug 174 | 175 | ### v0.1.0 176 | 177 | - First release 178 | 179 | ## Alternative 180 | 181 | - ASGI Middleware 182 | - django.contrib.staticfiles.handlers.ASGIStaticFilesHandler 183 | 184 | - WSGI Middleware 185 | - 186 | - 187 | 188 | - View 189 | - starlette.staticfiles.StaticFiles 190 | 191 | ## TODO 192 | 193 | - zero copy 194 | - file extension filter, 195 | - Cache Control 196 | -------------------------------------------------------------------------------- /asgi_middleware_static_file/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | from .core import ASGIMiddlewarePath, ASGIMiddlewareStaticFile # noqa: F401 5 | 6 | VERSION = "0.6.1" 7 | -------------------------------------------------------------------------------- /asgi_middleware_static_file/core.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os 3 | from datetime import datetime 4 | from hashlib import md5 5 | from os import PathLike 6 | from pathlib import Path 7 | from typing import Callable, List, Optional, Union 8 | 9 | import aiofiles 10 | import aiofiles.os 11 | import aiofiles.ospath 12 | 13 | _FILE_BLOCK_SIZE = 64 * 1024 14 | 15 | 16 | class ASGIMiddlewarePath: 17 | def __init__(self, path: Union[PathLike, str]): 18 | if not isinstance(path, Path): 19 | path = Path(path) 20 | 21 | self.path = path.resolve() 22 | self.path_as_str = self.path.as_posix() 23 | self.parts = self.path.parts 24 | self.count = len(self.parts) 25 | 26 | def join_path(self, path: Union[PathLike, str]) -> "ASGIMiddlewarePath": 27 | return ASGIMiddlewarePath(self.path.joinpath(path)) 28 | 29 | def startswith(self, path: "ASGIMiddlewarePath") -> bool: 30 | return self.parts[: path.count] == path.parts 31 | 32 | async def accessible(self) -> bool: 33 | if await aiofiles.ospath.isfile(self.path) and await aiofiles.os.access( 34 | self.path, os.R_OK 35 | ): 36 | return True 37 | 38 | return False 39 | 40 | 41 | class ASGIMiddlewareStaticFile: 42 | def __init__( 43 | self, app, static_url: str, static_root_paths: List[Union[PathLike, str]] 44 | ) -> None: 45 | self.app = app 46 | 47 | static_url = static_url.strip("/").rstrip("/") 48 | if len(static_url) == 0: 49 | self.static_url = "/" 50 | else: 51 | self.static_url = f"/{static_url}/" 52 | 53 | self.static_url_length = len(self.static_url) 54 | self.static_root_paths = [ASGIMiddlewarePath(p) for p in static_root_paths] 55 | 56 | async def __call__(self, scope, receive, send) -> None: 57 | if scope["type"] != "http": 58 | await self.app(scope, receive, send) 59 | return 60 | 61 | s_path = scope.get("path") 62 | if s_path is None or s_path[: self.static_url_length] != self.static_url: 63 | await self.app(scope, receive, send) 64 | return 65 | 66 | if scope["method"] == "HEAD": # TODO 67 | await self._handle(send, s_path[self.static_url_length :], is_head=True) 68 | return 69 | 70 | elif scope["method"] == "GET": 71 | await self._handle(send, s_path[self.static_url_length :]) 72 | return 73 | 74 | else: 75 | # 405 76 | await self.send_response_in_one_call(send, 405, b"405 METHOD NOT ALLOWED") 77 | return 78 | 79 | async def _handle(self, send, sub_path, is_head=False) -> None: 80 | # search file 81 | try: 82 | abs_path = await self.locate_the_file(sub_path) 83 | except ValueError: 84 | await self.send_response_in_one_call( 85 | send, 403, b"403 FORBIDDEN, CROSS BORDER ACCESS" 86 | ) 87 | return 88 | 89 | if abs_path is None: 90 | await self.send_response_in_one_call(send, 404, b"404 NOT FOUND") 91 | return 92 | 93 | # create headers 94 | content_type, encoding = mimetypes.guess_type(abs_path) 95 | if content_type is None: 96 | content_type = b"" 97 | else: 98 | content_type = content_type.encode("utf-8") 99 | if encoding is None: 100 | encoding = b"" 101 | else: 102 | encoding = encoding.encode("utf-8") 103 | stat_result = await aiofiles.os.stat(abs_path) 104 | file_size = str(stat_result.st_size).encode("utf-8") 105 | last_modified = ( 106 | datetime.fromtimestamp(stat_result.st_mtime) 107 | .strftime("%a, %d %b %Y %H:%M:%S GMT") 108 | .encode("utf-8") 109 | ) 110 | headers = [ 111 | (b"Content-Encodings", encoding), 112 | (b"Content-Type", content_type), 113 | (b"Content-Length", file_size), 114 | (b"Accept-Ranges", b"bytes"), 115 | (b"Last-Modified", last_modified), 116 | (b"ETag", md5(file_size + last_modified).hexdigest().encode("utf-8")), 117 | ] 118 | 119 | # send headers 120 | await send( 121 | { 122 | "type": "http.response.start", 123 | "status": 200, 124 | "headers": headers, 125 | } 126 | ) 127 | if is_head: 128 | await send( 129 | { 130 | "type": "http.response.body", 131 | } 132 | ) 133 | 134 | return 135 | 136 | # send file 137 | async with aiofiles.open(abs_path, mode="rb") as f: 138 | more_body = True 139 | while more_body: 140 | data = await f.read(_FILE_BLOCK_SIZE) 141 | more_body = len(data) == _FILE_BLOCK_SIZE 142 | await send( 143 | { 144 | "type": "http.response.body", 145 | "body": data, 146 | "more_body": more_body, 147 | } 148 | ) 149 | 150 | return 151 | 152 | async def locate_the_file(self, sub_path: Union[PathLike, str]) -> Optional[str]: 153 | """location the file in self.static_root_paths""" 154 | for root_path in self.static_root_paths: 155 | abs_path = root_path.join_path(sub_path) 156 | if not abs_path.startswith(root_path): 157 | raise ValueError 158 | 159 | if await abs_path.accessible(): 160 | return abs_path.path_as_str 161 | 162 | return None 163 | 164 | @staticmethod 165 | async def send_response_in_one_call( 166 | send: Callable, status: int, message: bytes 167 | ) -> None: 168 | await send( 169 | { 170 | "type": "http.response.start", 171 | "status": status, 172 | "headers": [(b"Content-Type", b"text/plain; UTF-8")], 173 | } 174 | ) 175 | 176 | await send( 177 | { 178 | "type": "http.response.body", 179 | "body": message, 180 | } 181 | ) 182 | return 183 | -------------------------------------------------------------------------------- /example/example_django/RunOnASGI.sh: -------------------------------------------------------------------------------- 1 | ./manage.py collectstatic --no-input 2 | uvicorn django_example.asgi:application 3 | -------------------------------------------------------------------------------- /example/example_django/example_django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rexzhang/asgi-middleware-static-file/4f59239cf4f03b3e5e25fd809e72ade7119f0e34/example/example_django/example_django/__init__.py -------------------------------------------------------------------------------- /example/example_django/example_django/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example_django project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | from pathlib import Path 12 | 13 | from django.conf import settings 14 | from django.core.asgi import get_asgi_application 15 | 16 | from asgi_middleware_static_file import ASGIMiddlewareStaticFile 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_django.settings") 19 | 20 | # Django's `settings.STATIC_ROOT` is `example/example_django/staticfiles` in this example. 21 | # This directory is generated by command `python manage.py collectstatic` 22 | # If your static files are distributed in several different directories, you can send a list to `static_root_paths` 23 | outside_statice_path = Path(__file__).parent.parent.parent.joinpath("example_static") 24 | 25 | application = get_asgi_application() 26 | application = ASGIMiddlewareStaticFile( 27 | application, 28 | static_url=settings.STATIC_URL, 29 | static_root_paths=[settings.STATIC_ROOT, outside_statice_path], 30 | ) 31 | -------------------------------------------------------------------------------- /example/example_django/example_django/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example_django project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = "8s3twfeu5ydq4(+#2)_n2yoj&$$1o_#_)gtl@ixlp=+844(a^c" 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = [ 32 | "django.contrib.admin", 33 | "django.contrib.auth", 34 | "django.contrib.contenttypes", 35 | "django.contrib.sessions", 36 | "django.contrib.messages", 37 | "django.contrib.staticfiles", 38 | ] 39 | 40 | MIDDLEWARE = [ 41 | "django.middleware.security.SecurityMiddleware", 42 | "django.contrib.sessions.middleware.SessionMiddleware", 43 | "django.middleware.common.CommonMiddleware", 44 | "django.middleware.csrf.CsrfViewMiddleware", 45 | "django.contrib.auth.middleware.AuthenticationMiddleware", 46 | "django.contrib.messages.middleware.MessageMiddleware", 47 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 48 | ] 49 | 50 | ROOT_URLCONF = "example_django.urls" 51 | 52 | TEMPLATES = [ 53 | { 54 | "BACKEND": "django.template.backends.django.DjangoTemplates", 55 | "DIRS": [], 56 | "APP_DIRS": True, 57 | "OPTIONS": { 58 | "context_processors": [ 59 | "django.template.context_processors.debug", 60 | "django.template.context_processors.request", 61 | "django.contrib.auth.context_processors.auth", 62 | "django.contrib.messages.context_processors.messages", 63 | ], 64 | }, 65 | }, 66 | ] 67 | 68 | WSGI_APPLICATION = "example_django.wsgi.application" 69 | 70 | # Database 71 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 72 | 73 | DATABASES = { 74 | "default": { 75 | "ENGINE": "django.db.backends.sqlite3", 76 | "NAME": BASE_DIR / "db.sqlite3", 77 | } 78 | } 79 | 80 | # Password validation 81 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 82 | 83 | AUTH_PASSWORD_VALIDATORS = [ 84 | { 85 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 86 | }, 87 | { 88 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 89 | }, 90 | { 91 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 92 | }, 93 | { 94 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 95 | }, 96 | ] 97 | 98 | # Internationalization 99 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 100 | 101 | LANGUAGE_CODE = "en-us" 102 | 103 | TIME_ZONE = "UTC" 104 | 105 | USE_I18N = True 106 | 107 | USE_L10N = True 108 | 109 | USE_TZ = True 110 | 111 | # Static files (CSS, JavaScript, Images) 112 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 113 | 114 | STATIC_URL = "/static/" 115 | STATICFILES_DIRS = [BASE_DIR.joinpath("example_django", "static")] 116 | STATIC_ROOT = BASE_DIR.joinpath("staticfiles") 117 | -------------------------------------------------------------------------------- /example/example_django/example_django/static/DEMO.txt: -------------------------------------------------------------------------------- 1 | This's is static demo file -------------------------------------------------------------------------------- /example/example_django/example_django/urls.py: -------------------------------------------------------------------------------- 1 | """example_django URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path("admin/", admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /example/example_django/example_django/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example_django project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_django.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/example_django/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_django.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /example/example_pure_asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from asgi_middleware_static_file import ASGIMiddlewareStaticFile 4 | 5 | BASE_DIR = os.path.dirname(__name__) 6 | STATIC_DIRS = [os.path.join(BASE_DIR, "example_static")] 7 | 8 | 9 | async def app(scope, receive, send): 10 | assert scope["type"] == "http" 11 | 12 | await send( 13 | { 14 | "type": "http.response.start", 15 | "status": 200, 16 | "headers": [ 17 | [b"content-type", b"text/plain"], 18 | ], 19 | } 20 | ) 21 | await send( 22 | { 23 | "type": "http.response.body", 24 | "body": b"Hello, world!", 25 | } 26 | ) 27 | 28 | 29 | app = ASGIMiddlewareStaticFile(app, static_url="static", static_root_paths=STATIC_DIRS) 30 | -------------------------------------------------------------------------------- /example/example_quart.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from quart import Quart 4 | 5 | from asgi_middleware_static_file import ASGIMiddlewareStaticFile 6 | 7 | BASE_DIR = os.path.dirname(__name__) 8 | STATIC_DIRS = [os.path.join(BASE_DIR, "example_static")] 9 | 10 | app = Quart(__name__) 11 | 12 | 13 | @app.route("/") 14 | async def hello(): 15 | return "hello" 16 | 17 | 18 | app = ASGIMiddlewareStaticFile(app, static_url="static", static_root_paths=STATIC_DIRS) 19 | -------------------------------------------------------------------------------- /example/example_static/DEMO: -------------------------------------------------------------------------------- 1 | This's is static demo file -------------------------------------------------------------------------------- /example/example_wsgi_app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from asgiref.wsgi import WsgiToAsgi 4 | 5 | from asgi_middleware_static_file import ASGIMiddlewareStaticFile 6 | 7 | BASE_DIR = os.path.dirname(__name__) 8 | STATIC_DIRS = [os.path.join(BASE_DIR, "example_static")] 9 | 10 | 11 | def wsgi_app(environ, start_response): 12 | status = "200 OK" 13 | response_headers = [("Content-type", "text/plain")] 14 | start_response(status, response_headers) 15 | return ["Hello world!"] 16 | 17 | 18 | app = ASGIMiddlewareStaticFile( 19 | WsgiToAsgi(wsgi_app), static_url="static", static_root_paths=STATIC_DIRS 20 | ) 21 | -------------------------------------------------------------------------------- /publish_to_pypi.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | python -m pip install -U -r requirements/pypi.txt 4 | 5 | rm -rf build/* 6 | rm -rf dist/* 7 | python -m build 8 | 9 | #python -m twine upload --repository-url https://upload.pypi.org/legacy/ dist/* 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # https://peps.python.org/pep-0621 3 | # https://setuptools.pypa.io/en/latest/userguide/quickstart.html 4 | # https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html 5 | requires = [ 6 | "setuptools>=61.0", 7 | "wheel", 8 | ] 9 | build-backend = "setuptools.build_meta" 10 | 11 | [project] 12 | name = "ASGIMiddlewareStaticFile" 13 | description = "ASGI Middleware for serving Static File." 14 | readme = "README.md" 15 | requires-python = ">=3.7" 16 | license = { text = "MIT" } 17 | authors = [ 18 | { name = "Rex Zhang" }, 19 | { email = "rex.zhang@gmail.com" }, 20 | ] 21 | keywords = [ 22 | "staticfile", 23 | "middleware", 24 | "asgi", 25 | "asyncio", 26 | ] 27 | classifiers = [ 28 | "Development Status :: 4 - Beta", 29 | "Programming Language :: Python :: 3.7", 30 | "Programming Language :: Python :: 3.8", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Topic :: Software Development :: Libraries :: Python Modules", 36 | ] 37 | 38 | dynamic = [ 39 | "version", 40 | "dependencies", 41 | ] 42 | 43 | [project.urls] 44 | homepage = "https://github.com/rexzhang/asgi-middleware-static-file" 45 | documentation = "https://github.com/rexzhang/asgi-middleware-static-file/blob/main/README.md" 46 | repository = "https://github.com/rexzhang/asgi-middleware-static-file" 47 | changelog = "https://github.com/rexzhang/asgi-middleware-static-file/blob/main/README.md#history" 48 | 49 | [tool.setuptools] 50 | packages = [ 51 | "asgi_middleware_static_file", 52 | ] 53 | 54 | [tool.setuptools.dynamic] 55 | version = { attr = "asgi_middleware_static_file.VERSION" } 56 | dependencies = { file = "requirements/base.txt" } 57 | 58 | [tool.pytest.ini_options] 59 | pythonpath = "." 60 | addopts = "--cov=asgi_middleware_static_file --cov-report html" 61 | asyncio_mode = "auto" 62 | 63 | [tool.isort] 64 | profile = "black" 65 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/dev.txt 2 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | aiofiles>=23.2.1 2 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | -r test.txt 3 | -r pypi.txt 4 | 5 | pre-commit 6 | 7 | asgiref 8 | uvicorn 9 | django 10 | quart 11 | 12 | icecream 13 | -------------------------------------------------------------------------------- /requirements/pypi.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | pip 4 | setuptools 5 | build 6 | wheel 7 | twine 8 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pytest-asyncio 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#flake8 3 | # E203 Whitespace before ':' 4 | # E704 Multiple statements on one line (def) 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rexzhang/asgi-middleware-static-file/4f59239cf4f03b3e5e25fd809e72ade7119f0e34/tests/__init__.py -------------------------------------------------------------------------------- /tests/asgi_test_kit.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | from dataclasses import dataclass 3 | from typing import Dict # Deprecated since version 3.9 4 | from typing import List # Deprecated since version 3.9 5 | from typing import Tuple # Deprecated since version 3.9 6 | from typing import Union 7 | 8 | import pytest 9 | 10 | # from: 11 | # - https://gist.github.com/rexzhang/40e7f5ba023ec16fde860509eb9f3253 12 | # python version: 13 | # - 3.6+ 14 | # example: 15 | # - middleware: 16 | # - https://github.com/rexzhang/asgi-middleware-static-file/blob/main/tests/test_with_mock_app.py 17 | # - https://github.com/rexzhang/asgi-webdav/blob/main/tests/test_middleware_cors.py 18 | 19 | MOCK_APP_RESPONSE_SUCCESS = "I'm mock App" 20 | 21 | 22 | class ASGIApp: 23 | def __init__( 24 | self, 25 | response_text: str = MOCK_APP_RESPONSE_SUCCESS, 26 | app_response_header: Union[Dict[str, str], None] = None, 27 | ): 28 | self.response_body = response_text.encode("utf-8") 29 | self.app_response_header = app_response_header 30 | 31 | async def __call__(self, scope, receive, send): 32 | scope_type = scope.get("type") 33 | if scope_type == "http": 34 | return await self._type_http(scope, receive, send) 35 | 36 | elif scope_type == "websocket": 37 | return await self._type_websocket(scope, receive, send) 38 | 39 | else: 40 | raise Exception("type is not http or websocket") 41 | 42 | async def _type_http(self, scope, receive, send): 43 | headers = {"Content-Type": "text/plain"} 44 | if self.app_response_header is not None: 45 | headers.update(self.app_response_header) 46 | 47 | await send( 48 | { 49 | "type": "http.response.start", 50 | "status": 200, 51 | "headers": [ 52 | (k.encode("utf-8"), v.encode("utf-8")) for k, v in headers.items() 53 | ], 54 | } 55 | ) 56 | await send( 57 | { 58 | "type": "http.response.body", 59 | "body": self.response_body, 60 | } 61 | ) 62 | 63 | async def _type_websocket(self, scope, receive, send): 64 | # TODO !!! 65 | await send( 66 | { 67 | "type": "http.response.start", 68 | "status": 200, 69 | "headers": [], 70 | } 71 | ) 72 | 73 | 74 | @dataclass 75 | class ASGIRequest: 76 | type: str 77 | method: str 78 | path: str 79 | headers: Dict[str, str] 80 | data: bytes 81 | 82 | def get_scope(self): 83 | return { 84 | "type": self.type, 85 | "method": self.method, 86 | "headers": [ 87 | (item[0].lower().encode("utf-8"), item[1].encode("utf-8")) 88 | for item in self.headers.items() 89 | ], 90 | "path": self.path, 91 | } 92 | 93 | 94 | @dataclass 95 | class ASGIResponse: 96 | status_code: Union[int, None] = None 97 | _headers: Union[Dict[str, str], None] = None 98 | data: Union[bytes, None] = None 99 | 100 | @property 101 | def headers(self) -> Dict[str, str]: 102 | return self._headers 103 | 104 | @headers.setter 105 | def headers(self, data: List[Tuple[bytes, bytes]]): 106 | print("header in response", data) 107 | self._headers = dict() 108 | try: 109 | for k, v in data: 110 | if isinstance(k, bytes): 111 | k = k.decode("utf-8") 112 | else: 113 | raise Exception(f"type(Key:{k}) isn't bytes: {data}") 114 | if isinstance(v, bytes): 115 | v = v.decode("utf-8") 116 | else: 117 | raise Exception(f"type(Value:{v}) isn't bytes: {data}") 118 | 119 | self._headers[k.lower()] = v 120 | except ValueError as e: 121 | raise ValueError(e, data) 122 | 123 | @property 124 | def text(self) -> str: 125 | return self.data.decode("utf-8") 126 | 127 | 128 | class ASGITestClient: 129 | request: ASGIRequest 130 | response: ASGIResponse 131 | 132 | def __init__( 133 | self, 134 | app, 135 | ): 136 | self.app = app 137 | 138 | async def _fake_receive(self): 139 | return self.request.data 140 | 141 | async def _fake_send(self, data: dict): 142 | data_type = data.get("type") 143 | if data_type == "http.response.start": 144 | self.response.status_code = data["status"] 145 | self.response.headers = data["headers"] 146 | 147 | elif data_type == "http.response.body": 148 | body = data.get("body") 149 | if body is not None: 150 | self.response.data = data["body"] 151 | else: 152 | self.response.data = b"" 153 | 154 | else: 155 | raise NotImplementedError() 156 | 157 | return 158 | 159 | async def _call_method(self) -> ASGIResponse: 160 | print("input", self.request) 161 | headers = { 162 | "user-agent": "ASGITestClient", 163 | } 164 | headers.update(self.request.headers) 165 | self.request.headers = headers 166 | print("prepare", self.request) 167 | 168 | self.response = ASGIResponse() 169 | await self.app( 170 | self.request.get_scope(), 171 | self._fake_receive, 172 | self._fake_send, 173 | ) 174 | 175 | return self.response 176 | 177 | @staticmethod 178 | def create_basic_authorization_headers( 179 | username: str, password: str 180 | ) -> Dict[str, str]: 181 | return { 182 | "authorization": "Basic {}".format( 183 | b64encode(f"{username}:{password}".encode()).decode("utf-8") 184 | ) 185 | } 186 | 187 | async def websocket(self, path, headers: Dict[str, str] = None) -> ASGIResponse: 188 | self.request = ASGIRequest("websocket", "GET", path, {}, b"") # TODO 189 | return await self._call_method() 190 | 191 | async def head(self, path, headers: Dict[str, str] = None) -> ASGIResponse: 192 | if headers is None: 193 | headers = dict() 194 | self.request = ASGIRequest("http", "HEAD", path, headers, b"") 195 | return await self._call_method() 196 | 197 | async def get(self, path, headers: Dict[str, str] = None) -> ASGIResponse: 198 | if headers is None: 199 | headers = dict() 200 | self.request = ASGIRequest("http", "GET", path, headers, b"") 201 | return await self._call_method() 202 | 203 | async def options(self, path, headers: Dict[str, str]) -> ASGIResponse: 204 | self.request = ASGIRequest("http", "OPTIONS", path, headers, b"") 205 | return await self._call_method() 206 | 207 | 208 | @pytest.mark.asyncio 209 | async def test_base(): 210 | client = ASGITestClient(ASGIApp()) 211 | response = await client.get("/") 212 | assert response.status_code == 200 213 | -------------------------------------------------------------------------------- /tests/static/text-file.txt: -------------------------------------------------------------------------------- 1 | this is a text file -------------------------------------------------------------------------------- /tests/test_asgi_middleware_path.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from asgi_middleware_static_file import ASGIMiddlewarePath 6 | 7 | fsp_base = Path(__name__).resolve().parent / "example" / "example_static" 8 | url_base = ASGIMiddlewarePath("/a/b/c/") 9 | 10 | 11 | def test_join_path(): 12 | assert url_base.join_path("d").path_as_str == "/a/b/c/d" 13 | assert url_base.join_path("d/e").path_as_str == "/a/b/c/d/e" 14 | 15 | 16 | def test_startswith(): 17 | assert url_base.startswith(ASGIMiddlewarePath("/a/b")) 18 | assert not url_base.startswith(ASGIMiddlewarePath("/a/b/c/e")) 19 | assert not url_base.startswith(ASGIMiddlewarePath("/x")) 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_accessible(): 24 | this_file = Path(__file__).resolve().as_posix() 25 | assert await ASGIMiddlewarePath(this_file).accessible() 26 | 27 | assert not await ASGIMiddlewarePath(fsp_base.joinpath("not_exists")).accessible() 28 | assert not await ASGIMiddlewarePath(fsp_base).accessible() # is path 29 | -------------------------------------------------------------------------------- /tests/test_param_parser.py: -------------------------------------------------------------------------------- 1 | from asgi_middleware_static_file import ASGIMiddlewareStaticFile 2 | 3 | 4 | def test_param_parser_static_url(): 5 | m = ASGIMiddlewareStaticFile(app=None, static_url="", static_root_paths=[]) 6 | assert m.static_url == "/" 7 | 8 | m = ASGIMiddlewareStaticFile(app=None, static_url="/", static_root_paths=[]) 9 | assert m.static_url == "/" 10 | 11 | m = ASGIMiddlewareStaticFile(app=None, static_url="/a", static_root_paths=[]) 12 | assert m.static_url == "/a/" 13 | 14 | m = ASGIMiddlewareStaticFile(app=None, static_url="/a/", static_root_paths=[]) 15 | assert m.static_url == "/a/" 16 | -------------------------------------------------------------------------------- /tests/test_secure.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import pytest 4 | 5 | from asgi_middleware_static_file import ASGIMiddlewareStaticFile 6 | 7 | BASE_PATH = pathlib.Path(__name__).resolve().parent / "example" / "example_static" 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_cross_border_access(): 12 | print(BASE_PATH) 13 | mw = ASGIMiddlewareStaticFile(None, "static", [BASE_PATH]) 14 | 15 | assert isinstance(await mw.locate_the_file("DEMO"), str) 16 | assert await mw.locate_the_file("not_found") is None 17 | 18 | with pytest.raises(ValueError): 19 | await mw.locate_the_file("../pyproject.toml") 20 | -------------------------------------------------------------------------------- /tests/test_with_mock_app.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from asgi_middleware_static_file.core import ASGIMiddlewareStaticFile 6 | 7 | from .asgi_test_kit import MOCK_APP_RESPONSE_SUCCESS, ASGIApp, ASGITestClient 8 | 9 | static_root_path = Path(__file__).parent.joinpath("static") 10 | 11 | TEXT_FILE_CONTENT = "this is a text file" 12 | 13 | 14 | def get_client(static_url="/static"): 15 | app = ASGIApp() 16 | c = ASGITestClient( 17 | ASGIMiddlewareStaticFile( 18 | app=app, static_url=static_url, static_root_paths=[static_root_path] 19 | ) 20 | ) 21 | return c 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_scope_type(): 26 | c = get_client() 27 | 28 | # websocket 29 | r = await c.websocket("/") 30 | assert r.status_code == 200 31 | 32 | # http 33 | r = await c.get("/") 34 | assert r.status_code == 200 35 | assert r.text == MOCK_APP_RESPONSE_SUCCESS 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_method_head(): 40 | c = get_client() 41 | 42 | r = await c.head("/") 43 | assert r.status_code == 200 44 | assert r.text == MOCK_APP_RESPONSE_SUCCESS 45 | 46 | r = await c.head("/static/text-file.txt") 47 | assert r.status_code == 200 48 | assert r.text == "" 49 | 50 | r = await c.head("/static/does-not-exist.txt") 51 | assert r.status_code == 404 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_method_get(): 56 | c = get_client() 57 | 58 | r = await c.get("/") 59 | assert r.status_code == 200 60 | assert r.text == MOCK_APP_RESPONSE_SUCCESS 61 | 62 | r = await c.get("/static/text-file.txt") 63 | assert r.status_code == 200 64 | assert r.text == TEXT_FILE_CONTENT 65 | 66 | r = await c.get("/static/does-not-exist.txt") 67 | assert r.status_code == 404 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_method_not_allowed(): 72 | c = get_client() 73 | r = await c.options("/static/", headers={}) 74 | assert r.status_code == 405 75 | 76 | 77 | @pytest.mark.asyncio 78 | async def test_cross_border_access(): 79 | c = get_client() 80 | r = await c.get("/static/../aaa", headers={}) 81 | assert r.status_code == 403 82 | 83 | 84 | @pytest.mark.asyncio 85 | async def test_special_static_url(): 86 | c = get_client(static_url="/") 87 | 88 | r = await c.get("/") 89 | assert r.status_code == 404 90 | 91 | r = await c.get("/text-file.txt") 92 | assert r.status_code == 200 93 | assert r.text == TEXT_FILE_CONTENT 94 | --------------------------------------------------------------------------------