Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add options to support SPAs in StaticFiles #2591

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/staticfiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ Starlette also includes a `StaticFiles` class for serving files in a given direc

### StaticFiles

Signature: `StaticFiles(directory=None, packages=None, html=False, check_dir=True, follow_symlink=False)`
Signature: `StaticFiles(directory=None, packages=None, html=False, check_dir=True, follow_symlink=False, fallback_file=None)`

* `directory` - A string or [os.PathLike][pathlike] denoting a directory path.
* `packages` - A list of strings or list of tuples of strings of python packages.
* `html` - Run in HTML mode. Automatically loads `index.html` for directories if such file exist.
* `check_dir` - Ensure that the directory exists upon instantiation. Defaults to `True`.
* `follow_symlink` - A boolean indicating if symbolic links for files and directories should be followed. Defaults to `False`.
* `fallback_file` - If the requested path doesn't exist return this file instead.

You can combine this ASGI application with Starlette's routing to provide
comprehensive static file serving.
Expand Down Expand Up @@ -63,4 +64,16 @@ You may prefer to include static files directly inside the "static" directory
rather than using Python packaging to include static files, but it can be useful
for bundling up reusable components.

If you are mounting a static SPA that uses client side routing (such as
react-router) the `fallback_file` option may be useful to you.

```python
routes = [
...
Mount('/app', app=StaticFiles(directory='myapp', fallback_file='index.html')
]

app = Starlette(routes=routes)
```

[pathlike]: https://docs.python.org/3/library/os.html#os.PathLike
22 changes: 18 additions & 4 deletions starlette/staticfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,17 @@ def __init__(
html: bool = False,
check_dir: bool = True,
follow_symlink: bool = False,
fallback_file: PathLike | None = None,
) -> None:
self.directory = directory
self.packages = packages
self.check_dir = check_dir
self.all_directories = self.get_directories(directory, packages)
self.html = html
self.config_checked = False
self.follow_symlink = follow_symlink
self.fallback_file = fallback_file

if check_dir and directory is not None and not os.path.isdir(directory):
raise RuntimeError(f"Directory '{directory}' does not exist")

Expand Down Expand Up @@ -82,9 +86,11 @@ def get_directories(
package_directory = os.path.normpath(
os.path.join(spec.origin, "..", statics_dir)
)
assert os.path.isdir(
package_directory
), f"Directory '{statics_dir!r}' in package {package!r} could not be found."
if self.check_dir:
assert os.path.isdir(package_directory), (
f"Directory '{statics_dir!r}' in package {package!r} could not be "
"found."
)
directories.append(package_directory)

return directories
Expand Down Expand Up @@ -155,7 +161,7 @@ async def get_response(self, path: str, scope: Scope) -> Response:
return FileResponse(full_path, stat_result=stat_result, status_code=404)
raise HTTPException(status_code=404)

def lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
def _lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
for directory in self.all_directories:
joined_path = os.path.join(directory, path)
if self.follow_symlink:
Expand All @@ -173,6 +179,14 @@ def lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
continue
return "", None

def lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
path, stat = self._lookup_path(path)

if self.fallback_file is not None and path == "" and stat is None:
return self._lookup_path(str(self.fallback_file))

return path, stat

def file_response(
self,
full_path: PathLike,
Expand Down
24 changes: 24 additions & 0 deletions tests/test_staticfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ def test_staticfiles_with_package(test_client_factory: TestClientFactory) -> Non
assert response.text == "123\n"


def test_staticfiles_with_package_and_missing_dir(
test_client_factory: TestClientFactory,
) -> None:
app = StaticFiles(packages=[("tests", "no_such_directory")], check_dir=False)
client = test_client_factory(app)
with pytest.raises(HTTPException) as exc_info:
client.get("/example.txt")
assert "404: Not Found" in str(exc_info.value)


def test_staticfiles_post(tmpdir: Path, test_client_factory: TestClientFactory) -> None:
path = os.path.join(tmpdir, "example.txt")
with open(path, "w") as file:
Expand Down Expand Up @@ -594,3 +604,17 @@ def test_staticfiles_avoids_path_traversal(tmp_path: Path) -> None:

assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Not Found"


def test_staticfiles_fallback_file(
tmpdir: Path, test_client_factory: TestClientFactory
) -> None:
path = os.path.join(tmpdir, "example.txt")
with open(path, "w") as file:
file.write("<file content>")

app = StaticFiles(directory=tmpdir, fallback_file="example.txt")
client = test_client_factory(app)
response = client.get("/not_the_example_file.txt")
assert response.status_code == 200
assert response.text == "<file content>"