Compare commits

...

16 Commits

Author SHA1 Message Date
github-actions[bot]
041cb0cdfa 📝 Update release notes
[skip ci]
2026-06-20 01:07:09 +00:00
Sebastián Ramírez
10393846ed 📝 Fix typo in release notes (#15807) 2026-06-20 01:06:46 +00:00
github-actions[bot]
0303491b69 📝 Update release notes
[skip ci]
2026-06-20 00:59:37 +00:00
Sebastián Ramírez
190f6e2033 📝 Add Frontend instructions to Agent Library Skill (#15805) 2026-06-20 00:59:14 +00:00
github-actions[bot]
17945e5ab7 📝 Update release notes
[skip ci]
2026-06-20 00:51:57 +00:00
Sebastián Ramírez
2260afaf43 🐛 Fix failing test, update format for raised errors (#15804) 2026-06-20 00:51:31 +00:00
github-actions[bot]
0cd5001d0e 📝 Update release notes
[skip ci]
2026-06-20 00:45:46 +00:00
Sebastián Ramírez
7cb1ab6264 👷 Fix test-alls-green (#15803) 2026-06-20 02:45:19 +02:00
github-actions[bot]
9c7eceb00f 📝 Update release notes
[skip ci]
2026-06-20 00:30:21 +00:00
Sebastián Ramírez
d176e00b9f 📝 Udpate release notes link (#15802) 2026-06-20 00:29:58 +00:00
github-actions[bot]
71e608e00e 📝 Update release notes
[skip ci]
2026-06-20 00:26:30 +00:00
Sebastián Ramírez
459a51097b ✏️ Update white space characters in bigger apps (#15801) 2026-06-20 00:26:04 +00:00
github-actions[bot]
e12833aaa2 📝 Update release notes
[skip ci]
2026-06-20 00:21:10 +00:00
Sebastián Ramírez
4d3dc78b26 Add support for app.frontend("/", directory="dist") and router.frontend("/", directory="dist") (#15800) 2026-06-20 00:20:49 +00:00
github-actions[bot]
f1d750fdda 📝 Update release notes
[skip ci]
2026-06-19 22:28:49 +00:00
Yurii Motov
22f99d9ad3 🔧 Enable checking release-notes.md for typos (#15796) 2026-06-20 00:28:24 +02:00
20 changed files with 1709 additions and 17 deletions

View File

@@ -245,9 +245,10 @@ jobs:
- run: uv run coverage report --fail-under=100
# https://github.com/marketplace/actions/alls-green#why
check: # This job does nothing and is only used for the branch protection
test-alls-green: # This job does nothing and is only used for the branch protection
if: always()
needs:
- test
- coverage-combine
- benchmark
runs-on: ubuntu-latest

View File

@@ -13,6 +13,7 @@ from fastapi import APIRouter
members:
- websocket
- include_router
- frontend
- get
- put
- post

View File

@@ -18,6 +18,7 @@ from fastapi import FastAPI
- openapi
- websocket
- include_router
- frontend
- get
- put
- post

View File

@@ -7,8 +7,17 @@ hide:
## Latest Changes
### Features
* ✨ Add support for `app.frontend("/", directory="dist")` and `router.frontend("/", directory="dist")`. PR [#15800](https://github.com/fastapi/fastapi/pull/15800) by [@tiangolo](https://github.com/tiangolo).
* Read the docs: [Frontend](https://fastapi.tiangolo.com/tutorial/frontend/).
### Docs
* 📝 Fix typo in release notes. PR [#15807](https://github.com/fastapi/fastapi/pull/15807) by [@tiangolo](https://github.com/tiangolo).
* 📝 Add `app.frontend()` instructions to Agent Library Skill. PR [#15805](https://github.com/fastapi/fastapi/pull/15805) by [@tiangolo](https://github.com/tiangolo).
* 📝 Update release notes link. PR [#15802](https://github.com/fastapi/fastapi/pull/15802) by [@tiangolo](https://github.com/tiangolo).
* ✏️ Update white space characters in bigger apps. PR [#15801](https://github.com/fastapi/fastapi/pull/15801) by [@tiangolo](https://github.com/tiangolo).
* ✏️ Fix grammar, typos, and broken links in docs. PR [#15694](https://github.com/fastapi/fastapi/pull/15694) by [@YuriiMotov](https://github.com/YuriiMotov).
### Translations
@@ -17,6 +26,9 @@ hide:
### Internal
* 🐛 Fix failing test, update format for raised errors. PR [#15804](https://github.com/fastapi/fastapi/pull/15804) by [@tiangolo](https://github.com/tiangolo).
* 👷 Fix test-alls-green. PR [#15803](https://github.com/fastapi/fastapi/pull/15803) by [@tiangolo](https://github.com/tiangolo).
* 🔧 Enable checking `release-notes.md` for typos. PR [#15796](https://github.com/fastapi/fastapi/pull/15796) by [@YuriiMotov](https://github.com/YuriiMotov).
* 📝 Tweak wording about deploying to FastAPI Cloud. PR [#15793](https://github.com/fastapi/fastapi/pull/15793) by [@tiangolo](https://github.com/tiangolo).
* 🔨 Use `gpt-5.5` model in `translate.py`, specify `-chat` to avoid warnings. PR [#15792](https://github.com/fastapi/fastapi/pull/15792) by [@YuriiMotov](https://github.com/YuriiMotov).

View File

@@ -17,16 +17,16 @@ Let's say you have a file structure like this:
```
.
├── app
   ├── __init__.py
   ├── main.py
   ├── dependencies.py
   └── routers
   │ ├── __init__.py
   │ ├── items.py
   │ └── users.py
   └── internal
   ├── __init__.py
   └── admin.py
├── __init__.py
├── main.py
├── dependencies.py
└── routers
│ ├── __init__.py
│ ├── items.py
│ └── users.py
└── internal
├── __init__.py
└── admin.py
```
/// tip

View File

@@ -0,0 +1,131 @@
# Frontend { #frontend }
You can serve static frontend apps with `app.frontend()` (or `router.frontend()`).
This is useful for frontend tools that generate static files, like React with Vite, TanStack Router, Astro, Vue, Svelte, Angular, Solid, and others.
With these tools, you normally have a step that builds the frontend, with a command like:
```bash
npm run build
```
That would generate a directory like `./dist/` with your frontend files.
You can use `app.frontend()` to serve that directory following the conventions needed by these frontend frameworks.
**FastAPI** checks *path operations* first. The frontend files are checked only if no normal route matched, so your API won't be affected.
## Serve a Frontend { #serve-a-frontend }
After building your frontend, for example with `npm run build`, put the generated files in a directory, for example, `dist`.
Your project structure could look like this:
```text
.
├── pyproject.toml
├── app
│ ├── __init__.py
│ └── main.py
└── dist
├── index.html
└── assets
└── app.js
```
Then serve it with `app.frontend()`:
{* ../../docs_src/frontend/tutorial001_py310.py hl[5] *}
With this, a request for `/assets/app.js` can serve `dist/assets/app.js`.
If you also have a **FastAPI** *path operation*, the *path operation* wins.
## Client-Side Routing { #client-side-routing }
Many frontend apps, including **single-page apps** (SPAs), use client-side routing. A path like `/dashboard/settings` might not be a real file but the framework would take care of handling it.
So, if accessing that URL directly (instead of navigating through the app), the backend should serve the frontend app from `index.html`, so that the frontend framework can then handle the client-side routing.
For that, use `fallback="index.html"`:
{* ../../docs_src/frontend/tutorial002_py310.py hl[5] *}
**FastAPI** uses this fallback only for requests that look like browser navigation. Missing files like JavaScript, CSS, and images still return `404`.
/// tip
By default, `fallback` has a value of `fallback="auto"`. In most cases you won't need to specify `fallback`. Read below for details.
///
This is what you would want with many frontend apps that use client-side routing, for example, React with TanStack Router, Vue, Angular, SvelteKit, or Solid.
## Custom 404 Page { #custom-404-page }
You can also serve a static `404.html` page for missing frontend paths:
{* ../../docs_src/frontend/tutorial003_py310.py hl[5] *}
That response keeps a status code of `404`.
In this case, **FastAPI** won't serve `index.html` for missing frontend paths. It will return the `404.html` file instead.
/// tip
By default, `fallback` has a value of `fallback="auto"`. With this, if a `404.html` file is found, it will be used as the fallback automatically.
So, you can normally omit the `fallback` argument.
///
This is useful with frontend tools that generate static HTML files for each page, like Astro.
## Fallback Auto { #fallback-auto }
By default, `app.frontend()` uses `fallback="auto"`.
If there is a `404.html` file in the frontend directory, missing frontend paths serve that file with status code `404`.
Otherwise, if there is an `index.html` file, missing browser navigation paths serve `index.html`, which is what many frontend apps with client-side routing expect.
So, in most cases you can use `app.frontend("/", directory="dist")` without specifying the `fallback` argument.
{* ../../docs_src/frontend/tutorial001_py310.py hl[5] *}
## Disable Fallback { #disable-fallback }
If you don't want to serve a fallback file for missing frontend paths, use `fallback=None`:
{* ../../docs_src/frontend/tutorial005_py310.py hl[5] *}
Then missing frontend paths return the normal `404`.
## Check Directory { #check-directory }
By default, `app.frontend()` checks that the directory exists when the app is created.
This helps catch configuration errors early. For example, if the frontend build output directory is missing, **FastAPI** will raise an error on startup.
If your frontend files are created later, for example by a separate build step after the app object is created, set `check_dir=False`:
{* ../../docs_src/frontend/tutorial006_py310.py hl[5] *}
With `check_dir=False`, **FastAPI** will not check the directory when the app is created. If the configured directory is still missing when a request is handled, **FastAPI** will raise an error then.
## Use it with `APIRouter` { #use-it-with-apirouter }
You can also add frontend files to an `APIRouter` and include it with a prefix:
{* ../../docs_src/frontend/tutorial004_py310.py hl[6,7] *}
In this example, frontend paths are served under `/app`.
Any regular *path operations* in the app will still take precedence, including in other routers.
## Static Build Output Only { #static-build-output-only }
`app.frontend()` serves files already generated by your frontend build.
It does not run server-side rendering. It is for frontend frameworks that generate static files, not for frameworks that need dynamic rendering on the server for each request.

View File

@@ -2,6 +2,14 @@
You can serve static files automatically from a directory using `StaticFiles`.
/// tip
If you need to host a frontend, use `app.frontend()` instead, read about it in [Frontend](frontend.md).
`app.frontend()` uses `StaticFiles` underneath, with several additional advantages for frontends, like handling client-side routing.
///
## Use `StaticFiles` { #use-staticfiles }
* Import `StaticFiles`.

View File

@@ -133,6 +133,7 @@ nav:
- tutorial/server-sent-events.md
- tutorial/background-tasks.md
- tutorial/metadata.md
- tutorial/frontend.md
- tutorial/static-files.md
- tutorial/testing.md
- tutorial/debugging.md

View File

View File

@@ -0,0 +1,5 @@
from fastapi import FastAPI
app = FastAPI()
app.frontend("/", directory="dist")

View File

@@ -0,0 +1,5 @@
from fastapi import FastAPI
app = FastAPI()
app.frontend("/", directory="dist", fallback="index.html")

View File

@@ -0,0 +1,5 @@
from fastapi import FastAPI
app = FastAPI()
app.frontend("/", directory="dist", fallback="404.html")

View File

@@ -0,0 +1,7 @@
from fastapi import APIRouter, FastAPI
app = FastAPI()
router = APIRouter()
router.frontend("/", directory="dist", fallback="index.html")
app.include_router(router, prefix="/app")

View File

@@ -0,0 +1,5 @@
from fastapi import FastAPI
app = FastAPI()
app.frontend("/", directory="dist", fallback=None)

View File

@@ -0,0 +1,5 @@
from fastapi import FastAPI
app = FastAPI()
app.frontend("/", directory="dist", check_dir=False)

View File

@@ -288,6 +288,32 @@ There could be exceptions, but try to follow this convention.
Apply shared dependencies at the router level via `dependencies=[Depends(...)]`.
## Serve Frontend Apps
Use `app.frontend()` to serve a built static frontend app, for example a directory generated by Vite, Astro, Angular, Svelte, Vue, or a similar tool.
```python
from fastapi import FastAPI
app = FastAPI()
app.frontend("/", directory="dist")
```
Use `router.frontend()` when the frontend belongs to an `APIRouter`; normal router prefix behavior applies when the router is included.
```python
from fastapi import APIRouter, FastAPI
app = FastAPI()
router = APIRouter(prefix="/admin")
router.frontend("/", directory="admin-dist")
app.include_router(router)
```
`app.frontend()` and `router.frontend()` are low-priority routes: regular API routes are matched first, then frontend files and client-side routing fallbacks. Use this for single-page apps and built frontend assets instead of mounting `StaticFiles` manually.
## Dependency Injection
See [the dependency injection reference](references/dependencies.md) for detailed patterns including `yield` with `scope`, and class dependencies.

View File

@@ -1,6 +1,7 @@
import os
from collections.abc import Awaitable, Callable, Coroutine, Sequence
from enum import Enum
from typing import Annotated, Any, TypeVar
from typing import Annotated, Any, Literal, TypeVar
from annotated_doc import Doc
from fastapi import routing
@@ -1218,6 +1219,79 @@ class FastAPI(Starlette):
generate_unique_id_function=generate_unique_id_function,
)
def frontend(
self,
path: Annotated[
str,
Doc(
"""
The URL path prefix where the frontend build should be served.
"""
),
],
*,
directory: Annotated[
str | os.PathLike[str],
Doc(
"""
The directory containing the static frontend build output.
"""
),
],
fallback: Annotated[
Literal["auto", "index.html", "404.html"] | None,
Doc(
"""
The fallback file behavior for missing frontend paths.
"""
),
] = "auto",
check_dir: Annotated[
bool,
Doc(
"""
Check that the frontend directory exists when the app is created.
"""
),
] = True,
) -> None:
"""
Serve a static frontend build as low-priority routes.
Use this for frontend tools that build static files into a directory,
such as `dist`. **FastAPI** path operations are checked first, and
the frontend files are checked only if no normal route matched.
A typical project could look like this:
```text
.
├── pyproject.toml
├── app
│ ├── __init__.py
│ └── main.py
└── dist
├── index.html
└── assets
└── app.js
```
Then in `app/main.py`:
```python
from fastapi import FastAPI
app = FastAPI()
app.frontend("/", directory="dist")
```
"""
self.router.frontend(
path,
directory=directory,
fallback=fallback,
check_dir=check_dir,
)
def api_route(
self,
path: str,

View File

@@ -1,9 +1,12 @@
import contextlib
import copy
import email.message
import errno
import functools
import inspect
import json
import os
import stat
import types
from collections.abc import (
AsyncIterator,
@@ -28,6 +31,7 @@ from enum import Enum, IntEnum
from typing import (
Annotated,
Any,
Literal,
Protocol,
TypeVar,
cast,
@@ -80,22 +84,25 @@ from starlette import routing
from starlette._exception_handler import wrap_app_handling_exceptions
from starlette._utils import get_route_path, is_async_callable
from starlette.concurrency import iterate_in_threadpool, run_in_threadpool
from starlette.datastructures import FormData, URLPath
from starlette.datastructures import URL, FormData, URLPath
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import (
JSONResponse,
PlainTextResponse,
RedirectResponse,
Response,
StreamingResponse,
)
from starlette.routing import (
BaseRoute,
Match,
NoMatchFound,
compile_path,
get_name,
)
from starlette.routing import Mount as Mount # noqa
from starlette.staticfiles import StaticFiles
from starlette.types import AppType, ASGIApp, Lifespan, Receive, Scope, Send
from starlette.websockets import WebSocket
from typing_extensions import deprecated
@@ -819,6 +826,7 @@ class APIWebSocketRoute(routing.WebSocketRoute):
_FASTAPI_SCOPE_KEY = "fastapi"
_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY = "effective_route_context"
_FASTAPI_FRONTEND_PATH_KEY = "frontend_path"
_FASTAPI_INCLUDED_ROUTER_KEY = "included_router"
_effective_route_context_var: ContextVar[Any | None] = ContextVar(
"fastapi_effective_route_context", default=None
@@ -826,12 +834,25 @@ _effective_route_context_var: ContextVar[Any | None] = ContextVar(
_SCOPE_MISSING = object()
class _RouteWithPath(Protocol):
path: str
def _get_fastapi_scope(scope: Scope) -> dict[str, Any]:
fastapi_scope = scope.setdefault(_FASTAPI_SCOPE_KEY, {})
assert isinstance(fastapi_scope, dict)
return fastapi_scope
def _update_scope(scope: Scope, child_scope: Scope) -> None:
fastapi_child_scope = child_scope.get(_FASTAPI_SCOPE_KEY)
for key, value in child_scope.items():
if key != _FASTAPI_SCOPE_KEY:
scope[key] = value
if isinstance(fastapi_child_scope, dict):
_get_fastapi_scope(scope).update(fastapi_child_scope)
def _get_scope_effective_route_context(scope: Scope) -> Any | None:
return scope.get(_FASTAPI_SCOPE_KEY, {}).get(_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY)
@@ -1305,9 +1326,7 @@ class _RouterIncludeContext:
dependency_overrides_provider=self.dependency_overrides_provider,
)
def path_for(
self, route: APIRoute | routing.Route | routing.WebSocketRoute | routing.Mount
) -> str:
def path_for(self, route: _RouteWithPath) -> str:
return self.prefix + route.path
@@ -1503,6 +1522,10 @@ class _IncludedRouter(BaseRoute):
default_factory=list
)
_effective_candidates_version: int | None = None
_effective_low_priority_routes: list["_EffectiveRouteContext"] = field(
default_factory=list
)
_effective_low_priority_routes_version: int | None = None
def effective_candidates(self) -> list["_EffectiveRouteContext | _IncludedRouter"]:
routes_version = self.original_router._get_routes_version()
@@ -1525,6 +1548,28 @@ class _IncludedRouter(BaseRoute):
self._effective_candidates_version = routes_version
return self._effective_candidates
def effective_low_priority_routes(self) -> list["_EffectiveRouteContext"]:
routes_version = self.original_router._get_routes_version()
if routes_version == self._effective_low_priority_routes_version:
return self._effective_low_priority_routes
self._effective_low_priority_routes = []
for route in self.original_router._low_priority_routes:
route_context = self._build_effective_context(route)
if route_context is not None:
self._effective_low_priority_routes.append(route_context)
for route in self.original_router.routes:
if isinstance(route, _IncludedRouter):
child_context = self.include_context.combine(route.include_context)
child_branch = _IncludedRouter(
original_router=route.original_router,
include_context=child_context,
)
self._effective_low_priority_routes.extend(
child_branch.effective_low_priority_routes()
)
self._effective_low_priority_routes_version = routes_version
return self._effective_low_priority_routes
def _build_effective_context(
self, route: BaseRoute
) -> _EffectiveRouteContext | None:
@@ -1533,6 +1578,11 @@ class _IncludedRouter(BaseRoute):
original_route=route,
include_context=self.include_context,
)
if isinstance(route, _FrontendRouteGroup):
return _EffectiveRouteContext(
original_route=route,
starlette_route=route.with_prefix(self.include_context.prefix),
)
if isinstance(route, routing.Route):
starlette_route: BaseRoute = routing.Route(
self.include_context.path_for(route),
@@ -1720,6 +1770,294 @@ def _iter_routes_with_context(
yield route, None
def _normalize_frontend_path(path: str) -> str:
if not path:
raise AssertionError("A frontend path cannot be empty")
if not path.startswith("/"):
raise AssertionError("A frontend path must start with '/'")
if path != "/":
path = path.rstrip("/")
return path
def _join_frontend_paths(prefix: str, path: str) -> str:
if not prefix:
return path
if path == "/":
return prefix
return prefix + path
def _frontend_path_specificity(path: str) -> int:
if path == "/":
return 0
return len(path)
def _get_resolved_absolute_path(path: str | os.PathLike[str]) -> str:
return os.path.realpath(os.fspath(path))
class _FrontendStaticFiles(StaticFiles):
def __init__(
self,
*,
directory: str | os.PathLike[str],
fallback: Literal["auto", "index.html", "404.html"] | None,
check_dir: bool = True,
) -> None:
self.fallback = fallback
if check_dir and not os.path.isdir(directory):
raise RuntimeError(
f"Frontend directory '{directory}' does not exist. "
f"Resolved absolute path: '{_get_resolved_absolute_path(directory)}'"
)
super().__init__(
directory=directory,
html=True,
check_dir=check_dir,
follow_symlink=False,
)
if check_dir and fallback in {"index.html", "404.html"}:
self._check_fallback_file(fallback)
def _check_fallback_file(self, fallback: str) -> None:
_, stat_result = self.lookup_path(fallback)
if stat_result is None or not stat.S_ISREG(stat_result.st_mode):
raise RuntimeError(
f"Frontend fallback file '{fallback}' does not exist in "
f"directory '{self.directory}'. Resolved absolute directory: "
f"'{self._get_resolved_directory()}'"
)
def _get_resolved_directory(self) -> str:
assert self.directory is not None
return _get_resolved_absolute_path(self.directory)
def get_path(self, scope: Scope) -> str:
path = _get_fastapi_scope(scope).get(_FASTAPI_FRONTEND_PATH_KEY, "")
assert isinstance(path, str)
return os.path.normpath(os.path.join(*path.split("/")))
async def get_response(self, path: str, scope: Scope) -> Response:
if scope["method"] not in ("GET", "HEAD"):
raise HTTPException(status_code=405)
try:
full_path, stat_result = await run_in_threadpool(self.lookup_path, path)
except PermissionError:
raise HTTPException(status_code=401) from None
except OSError as exc:
if exc.errno == errno.ENAMETOOLONG:
raise HTTPException(status_code=404) from None
raise exc
except ValueError:
raise HTTPException(status_code=404) from None
if stat_result and stat.S_ISREG(stat_result.st_mode):
return self.file_response(full_path, stat_result, scope)
if stat_result and stat.S_ISDIR(stat_result.st_mode):
index_path = os.path.join(path, "index.html")
full_path, stat_result = await run_in_threadpool(
self.lookup_path, index_path
)
if stat_result is not None and stat.S_ISREG(stat_result.st_mode):
if not scope["path"].endswith("/"):
url = URL(scope=scope)
url = url.replace(path=url.path + "/")
return RedirectResponse(url=url)
return self.file_response(full_path, stat_result, scope)
if self.fallback == "404.html" or (
self.fallback == "auto" and self._fallback_file_exists("404.html")
):
return await self._fallback_response("404.html", scope, status_code=404)
if (
self.fallback == "index.html"
or (self.fallback == "auto" and self._fallback_file_exists("index.html"))
) and _is_frontend_navigation_request(scope):
return await self._fallback_response("index.html", scope, status_code=200)
raise HTTPException(status_code=404)
def _fallback_file_exists(self, fallback: str) -> bool:
_, stat_result = self.lookup_path(fallback)
return stat_result is not None and stat.S_ISREG(stat_result.st_mode)
async def _fallback_response(
self, fallback: str, scope: Scope, *, status_code: int
) -> Response:
full_path, stat_result = await run_in_threadpool(self.lookup_path, fallback)
if stat_result is None or not stat.S_ISREG(stat_result.st_mode):
raise RuntimeError(
f"Frontend fallback file '{fallback}' does not exist in "
f"directory '{self.directory}'. Resolved absolute directory: "
f"'{self._get_resolved_directory()}'"
)
return self.file_response(
full_path, stat_result, scope, status_code=status_code
)
def _iter_accept_media_types(accept: str) -> Iterator[tuple[str, float]]:
for raw_value in accept.split(","):
message = email.message.Message()
message["content-type"] = raw_value.strip()
q = message.get_param("q")
quality = 1.0
if isinstance(q, str):
try:
quality = float(q)
except ValueError:
pass
yield (
f"{message.get_content_maintype()}/{message.get_content_subtype()}",
quality,
)
def _is_frontend_navigation_request(scope: Scope) -> bool:
route_path = get_route_path(scope)
final_segment = route_path.rsplit("/", 1)[-1]
if os.path.splitext(final_segment)[1]:
return False
request = Request(scope)
wildcard_accepted = False
html_rejected = False
for media_type, quality in _iter_accept_media_types(
request.headers.get("accept", "")
):
if media_type in {"text/html", "application/xhtml+xml"}:
if quality == 0:
html_rejected = True
else:
return True
elif media_type == "*/*" and quality != 0:
wildcard_accepted = True
return wildcard_accepted and not html_rejected
class _FrontendRoute(BaseRoute):
def __init__(
self,
path: str,
*,
directory: str | os.PathLike[str],
fallback: Literal["auto", "index.html", "404.html"] | None = "auto",
check_dir: bool = True,
) -> None:
if fallback not in {"auto", "index.html", "404.html", None}:
raise AssertionError(
"fallback must be 'auto', 'index.html', '404.html', or None"
)
self.path = _normalize_frontend_path(path)
self.methods = {"GET", "HEAD"}
self.app = _FrontendStaticFiles(
directory=directory, fallback=fallback, check_dir=check_dir
)
def with_path(self, path: str) -> "_FrontendRoute":
route = copy.copy(self)
route.path = _normalize_frontend_path(path)
return route
def matches(self, scope: Scope) -> tuple[Match, Scope]:
if scope["type"] != "http":
return Match.NONE, {}
frontend_path = self._get_frontend_path(get_route_path(scope))
if frontend_path is None:
return Match.NONE, {}
child_scope = {_FASTAPI_SCOPE_KEY: {_FASTAPI_FRONTEND_PATH_KEY: frontend_path}}
if scope["method"] not in self.methods:
return Match.PARTIAL, child_scope
return Match.FULL, child_scope
def _get_frontend_path(self, route_path: str) -> str | None:
if self.path == "/":
return route_path.lstrip("/")
if route_path == self.path:
return ""
prefix = self.path + "/"
if route_path.startswith(prefix):
return route_path[len(prefix) :]
return None
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
raise NoMatchFound(name, path_params)
class _FrontendRouteGroup(BaseRoute):
def __init__(self) -> None:
self.routes: list[_FrontendRoute] = []
def add_frontend_route(
self,
path: str,
*,
directory: str | os.PathLike[str],
fallback: Literal["auto", "index.html", "404.html"] | None = "auto",
check_dir: bool = True,
) -> None:
self.routes.append(
_FrontendRoute(
path,
directory=directory,
fallback=fallback,
check_dir=check_dir,
)
)
def with_prefix(self, prefix: str) -> "_FrontendRouteGroup":
route_group = copy.copy(self)
route_group.routes = [
route.with_path(_join_frontend_paths(prefix, route.path))
for route in self.routes
]
return route_group
def matches(self, scope: Scope) -> tuple[Match, Scope]:
match, child_scope, _ = self._match(scope)
return match, child_scope
def _match(self, scope: Scope) -> tuple[Match, Scope, _FrontendRoute | None]:
full: tuple[Scope, _FrontendRoute] | None = None
partial: tuple[Scope, _FrontendRoute] | None = None
for route in self.routes:
match, child_scope = route.matches(scope)
if match == Match.FULL:
if full is None or _frontend_path_specificity(
route.path
) > _frontend_path_specificity(full[1].path):
full = (child_scope, route)
elif match == Match.PARTIAL:
if partial is None or _frontend_path_specificity(
route.path
) > _frontend_path_specificity(partial[1].path):
partial = (child_scope, route)
if full is not None:
child_scope, route = full
return Match.FULL, child_scope, route
if partial is not None:
child_scope, route = partial
return Match.PARTIAL, child_scope, route
return Match.NONE, {}, None
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
match, child_scope, route = self._match(scope)
if match == Match.NONE or route is None:
raise HTTPException(status_code=404)
_update_scope(scope, child_scope)
await route.handle(scope, receive, send)
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
raise NoMatchFound(name, path_params)
class APIRouter(routing.Router):
"""
`APIRouter` class, used to group *path operations*, for example to structure
@@ -2032,6 +2370,8 @@ class APIRouter(routing.Router):
self.generate_unique_id_function = generate_unique_id_function
self.strict_content_type = strict_content_type
self._routes_version = 0
self._low_priority_routes: list[BaseRoute] = []
self._frontend_routes: _FrontendRouteGroup | None = None
def _mark_routes_changed(self) -> None:
self._routes_version += 1
@@ -2093,6 +2433,150 @@ class APIRouter(routing.Router):
super().add_websocket_route(path, endpoint, name=name)
self._mark_routes_changed()
def frontend(
self,
path: Annotated[
str,
Doc(
"""
The URL path prefix where the frontend build should be served.
"""
),
],
*,
directory: Annotated[
str | os.PathLike[str],
Doc(
"""
The directory containing the static frontend build output.
"""
),
],
fallback: Annotated[
Literal["auto", "index.html", "404.html"] | None,
Doc(
"""
The fallback file behavior for missing frontend paths.
"""
),
] = "auto",
check_dir: Annotated[
bool,
Doc(
"""
Check that the frontend directory exists when the app is created.
"""
),
] = True,
) -> None:
"""
Serve a static frontend build as low-priority routes.
Use this for frontend tools that build static files into a directory,
such as `dist`. **FastAPI** path operations are checked first, and
the frontend files are checked only if no normal route matched.
A typical project could look like this:
```text
.
├── pyproject.toml
├── app
│ ├── __init__.py
│ └── main.py
└── dist
├── index.html
└── assets
└── app.js
```
Then in `app/main.py`:
```python
from fastapi import APIRouter, FastAPI
app = FastAPI()
router = APIRouter()
router.frontend("/", directory="dist")
app.include_router(router)
```
"""
normalized_path = _normalize_frontend_path(path)
if self._frontend_routes is None:
self._frontend_routes = _FrontendRouteGroup()
self._low_priority_routes.append(self._frontend_routes)
self._frontend_routes.add_frontend_route(
_join_frontend_paths(self.prefix, normalized_path),
directory=directory,
fallback=fallback,
check_dir=check_dir,
)
self._mark_routes_changed()
async def app(self, scope: Scope, receive: Receive, send: Send) -> None:
assert scope["type"] in ("http", "websocket", "lifespan")
if "router" not in scope:
scope["router"] = self
if scope["type"] == "lifespan":
await self.lifespan(scope, receive, send)
return
partial: tuple[BaseRoute, Scope] | None = None
for route in self.routes:
match, child_scope = route.matches(scope)
if match == Match.FULL:
scope.update(child_scope)
await route.handle(scope, receive, send)
return
if match == Match.PARTIAL and partial is None:
partial = (route, child_scope)
if partial is not None:
route, child_scope = partial
scope.update(child_scope)
await route.handle(scope, receive, send)
return
route_path = get_route_path(scope)
if scope["type"] == "http" and self.redirect_slashes and route_path != "/":
redirect_scope = dict(scope)
if route_path.endswith("/"):
redirect_scope["path"] = redirect_scope["path"].rstrip("/")
else:
redirect_scope["path"] = redirect_scope["path"] + "/"
for route in self.routes:
match, _ = route.matches(redirect_scope)
if match != Match.NONE:
redirect_url = URL(scope=redirect_scope)
response = RedirectResponse(url=str(redirect_url))
await response(scope, receive, send)
return
(
low_priority_match,
low_priority_scope,
low_priority_route,
low_priority_context,
) = self._match_low_priority(scope)
if low_priority_match != Match.NONE and low_priority_route is not None:
_update_scope(scope, low_priority_scope)
if low_priority_context is not None:
_get_fastapi_scope(scope)[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = (
low_priority_context
)
original_route = low_priority_context.original_route
if isinstance(original_route, APIRoute):
scope["route"] = original_route
await original_route.handle(scope, receive, send)
return
await low_priority_route.handle(scope, receive, send)
return
await self.default(scope, receive, send)
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
included_router = _get_scope_included_router(scope)
if (
@@ -2113,6 +2597,60 @@ class APIRouter(routing.Router):
return match, child_scope
return Match.NONE, {}
def _iter_low_priority_routes(
self,
) -> Iterator[BaseRoute | _EffectiveRouteContext]:
yield from self._low_priority_routes
for route in self.routes:
if isinstance(route, _IncludedRouter):
yield from route.effective_low_priority_routes()
def _match_low_priority(
self, scope: Scope
) -> tuple[Match, Scope, BaseRoute | None, _EffectiveRouteContext | None]:
full: tuple[Scope, BaseRoute, _EffectiveRouteContext | None] | None = None
partial: tuple[Scope, BaseRoute, _EffectiveRouteContext | None] | None = None
for candidate in self._iter_low_priority_routes():
route: BaseRoute
if isinstance(candidate, _EffectiveRouteContext):
route_context: _EffectiveRouteContext | None = candidate
original_route = candidate.original_route
if isinstance(original_route, APIRoute):
fastapi_scope = _get_fastapi_scope(scope)
previous_context = fastapi_scope.get(
_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY, _SCOPE_MISSING
)
fastapi_scope[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = route_context
try:
match, child_scope = original_route.matches(scope)
finally:
_restore_fastapi_scope_key(
scope,
_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY,
previous_context,
)
route = original_route
else:
match, child_scope = candidate.matches(scope)
route = candidate.starlette_route or original_route
else:
route_context = None
match, child_scope = candidate.matches(scope)
route = candidate
if match == Match.FULL:
if full is None:
full = (child_scope, route, route_context)
elif match == Match.PARTIAL:
if partial is None:
partial = (child_scope, route, route_context)
if full is not None:
child_scope, route, route_context = full
return Match.FULL, child_scope, route, route_context
if partial is not None:
child_scope, route, route_context = partial
return Match.PARTIAL, child_scope, route, route_context
return Match.NONE, {}, None, None
def route(
self,
path: str,

View File

@@ -319,7 +319,6 @@ extend-exclude = [
"docs/de/",
"docs/en/data/",
"docs/en/docs/img/",
"docs/en/docs/release-notes.md",
"docs/es/",
"docs/fr/",
"docs/ja/",
@@ -340,6 +339,16 @@ extend-exclude = [
"uv.lock",
]
[tool.typos.default]
extend-ignore-re = [
# GitHub usernames in @mentions
"@[a-zA-Z0-9](?:-?[a-zA-Z0-9])*",
# Quoted typo documented in a release note
"'wll' to 'will'",
# German article title in a release note
"FastAPI Modul.",
]
[tool.typos.default.extend-identifiers]
alls = "alls"

858
tests/test_frontend.py Normal file
View File

@@ -0,0 +1,858 @@
import errno
import os
import runpy
from pathlib import Path
import anyio
import pytest
from fastapi import APIRouter, FastAPI, HTTPException, Request, WebSocket
from fastapi.testclient import TestClient
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import PlainTextResponse, Response
from starlette.routing import BaseRoute, Match, NoMatchFound, Route
def write_file(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
def test_frontend_exact_prefix_path_serves_index(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
app.frontend("/app", directory=dist)
response = TestClient(app).get("/app")
assert response.status_code == 200
assert response.text == "app"
def test_apirouter_frontend_with_router_prefix_and_frontend_subpath(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "asset.txt", "asset")
router = APIRouter(prefix="/internal")
router.frontend("/ui", directory=dist)
app = FastAPI()
app.include_router(router, prefix="/prefix")
response = TestClient(app).get("/prefix/internal/ui/asset.txt")
assert response.status_code == 200
assert response.text == "asset"
def test_frontend_fallback_rejects_invalid_fallback(tmp_path: Path):
dist = tmp_path / "dist"
dist.mkdir()
app = FastAPI()
with pytest.raises(AssertionError, match="fallback"):
app.frontend("/", directory=dist, fallback="invalid") # type: ignore[arg-type] # ty: ignore[invalid-argument-type]
def test_index_fallback_ignores_invalid_q_value(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get(
"/dashboard/settings", headers={"accept": "text/html; q=wat"}
)
assert response.status_code == 200
assert response.text == "app shell"
def test_frontend_static_files_lookup_errors(monkeypatch, tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
app.frontend("/", directory=dist)
frontend_routes = app.router._frontend_routes
assert frontend_routes is not None
static_files = frontend_routes.routes[0].app
def raise_permission_error(path: str):
raise PermissionError
monkeypatch.setattr(static_files, "lookup_path", raise_permission_error)
response = TestClient(app).get("/asset.txt")
assert response.status_code == 401
def raise_value_error(path: str):
raise ValueError
monkeypatch.setattr(static_files, "lookup_path", raise_value_error)
response = TestClient(app).get("/asset.txt")
assert response.status_code == 404
def raise_name_too_long(path: str):
raise OSError(errno.ENAMETOOLONG, "name too long")
monkeypatch.setattr(static_files, "lookup_path", raise_name_too_long)
response = TestClient(app).get("/asset.txt")
assert response.status_code == 404
def raise_os_error(path: str):
raise OSError(5, "other")
monkeypatch.setattr(static_files, "lookup_path", raise_os_error)
with pytest.raises(OSError):
TestClient(app).get("/asset.txt")
def test_frontend_route_group_helpers(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
app.frontend("/", directory=dist)
route_group = app.router._frontend_routes
assert route_group is not None
match, child_scope = route_group.matches({"type": "websocket", "path": "/"})
assert match == Match.NONE
assert child_scope == {}
with pytest.raises(StarletteHTTPException) as exc_info:
anyio.run(
route_group.with_prefix("/app").handle,
{"type": "http", "path": "/missing", "method": "GET"},
None,
None,
)
assert exc_info.value.status_code == 404
with pytest.raises(NoMatchFound):
route_group.url_path_for("frontend")
with pytest.raises(NoMatchFound):
route_group.routes[0].url_path_for("frontend")
def test_included_low_priority_routes_cache_is_reused():
async def low_priority_endpoint(request: Request):
return PlainTextResponse("low")
router = APIRouter()
router._low_priority_routes.append(Route("/low", low_priority_endpoint))
router._mark_routes_changed()
app = FastAPI()
app.include_router(router, prefix="/prefix")
included_router = next(
route
for route in app.router.routes
if hasattr(route, "effective_low_priority_routes")
)
first = included_router.effective_low_priority_routes() # ty: ignore[call-non-callable]
second = included_router.effective_low_priority_routes() # ty: ignore[call-non-callable]
response = TestClient(app).get("/prefix/low")
assert first is second
assert response.status_code == 200
assert response.text == "low"
def test_low_priority_api_route_handles_with_context():
app = FastAPI()
async def endpoint(request: Request) -> Response:
return PlainTextResponse(request.scope["path_params"]["item_id"])
route = app.router.route_class("/low/{item_id}", endpoint=endpoint, methods=["GET"])
app.router._low_priority_routes.append(route)
app.router._mark_routes_changed()
response = TestClient(app).get("/low/abc")
assert response.status_code == 200
assert response.text == "abc"
def test_included_low_priority_api_route_handles_with_context():
router = APIRouter()
async def endpoint(request: Request) -> Response:
return PlainTextResponse(request.scope["path_params"]["item_id"])
route = router.route_class("/low/{item_id}", endpoint=endpoint, methods=["GET"])
router._low_priority_routes.append(route)
router._mark_routes_changed()
app = FastAPI()
app.include_router(router, prefix="/prefix")
response = TestClient(app).get("/prefix/low/abc")
assert response.status_code == 200
assert response.text == "abc"
def test_normal_route_partial_match_returns_before_frontend(tmp_path: Path):
class PartialRoute(BaseRoute):
def matches(self, scope):
return Match.PARTIAL, {}
async def handle(self, scope, receive, send):
response = PlainTextResponse("partial", status_code=405)
await response(scope, receive, send)
dist = tmp_path / "dist"
write_file(dist / "index.html", "frontend")
app = FastAPI()
app.router.routes.append(PartialRoute())
app.frontend("/", directory=dist)
response = TestClient(app).get("/anything")
assert response.status_code == 405
assert response.text == "partial"
def test_normal_route_partial_match_wins_before_frontend(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "api", "frontend")
app = FastAPI()
@app.get("/api")
def read_api():
return {"source": "api"}
app.frontend("/", directory=dist)
client = TestClient(app)
response = client.get("/api")
assert response.status_code == 200
assert response.json() == {"source": "api"}
response = client.post("/api")
assert response.status_code == 405
def test_basic_file_serving(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "assets" / "app.js", "console.log('ok')")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/assets/app.js")
assert response.status_code == 200
assert response.text == "console.log('ok')"
assert "etag" in response.headers
assert "last-modified" in response.headers
def test_existing_api_route_wins_over_frontend(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "api" / "users", "frontend")
app = FastAPI()
@app.get("/api/users")
def read_users():
return {"source": "api"}
app.frontend("/", directory=dist)
response = TestClient(app).get("/api/users")
assert response.status_code == 200
assert response.json() == {"source": "api"}
def test_api_route_404_is_not_replaced_by_frontend_fallback(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "frontend")
app = FastAPI()
@app.get("/api/users")
def read_users():
raise HTTPException(status_code=404, detail="api missing")
app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get("/api/users", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.json() == {"detail": "api missing"}
def test_index_fallback_for_navigation_request(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get(
"/dashboard/settings", headers={"accept": "text/html"}
)
assert response.status_code == 200
assert response.text == "app shell"
def test_index_fallback_parses_accept_parameters(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get(
"/dashboard/settings", headers={"accept": "text/html; q=0.8"}
)
assert response.status_code == 200
assert response.text == "app shell"
def test_index_fallback_ignores_q_zero_accept(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get(
"/dashboard/settings", headers={"accept": "text/html; q=0.0"}
)
assert response.status_code == 404
def test_index_fallback_respects_explicit_html_rejection_with_wildcard(
tmp_path: Path,
):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get(
"/dashboard/settings",
headers={"accept": "text/html; q=0, */*; q=1"},
)
assert response.status_code == 404
def test_index_fallback_respects_explicit_xhtml_rejection_with_wildcard(
tmp_path: Path,
):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get(
"/dashboard/settings",
headers={"accept": "application/xhtml+xml; q=0, */*; q=1"},
)
assert response.status_code == 404
@pytest.mark.parametrize(
("path", "accept"),
[
("/assets/missing.js", "*/*"),
("/assets/missing.css", "text/css"),
("/assets/missing.png", "image/png"),
("/api/missing", "application/json"),
("/users/jane.doe", "text/html"),
],
)
def test_index_fallback_does_not_handle_asset_like_or_non_html_requests(
tmp_path: Path, path: str, accept: str
):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist, fallback="index.html")
response = TestClient(app).get(path, headers={"accept": accept})
assert response.status_code == 404
assert response.text != "app shell"
def test_404_fallback_handles_missing_assets(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "404.html", "missing")
app = FastAPI()
app.frontend("/", directory=dist, fallback="404.html")
response = TestClient(app).get("/assets/missing.js")
assert response.status_code == 404
assert response.text == "missing"
def test_auto_fallback_prefers_404_over_index(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
write_file(dist / "404.html", "missing")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.text == "missing"
def test_auto_fallback_uses_index_when_404_is_missing(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.text == "app shell"
def test_auto_fallback_returns_normal_404_without_fallback_files(tmp_path: Path):
dist = tmp_path / "dist"
dist.mkdir()
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.json() == {"detail": "Not Found"}
def test_no_fallback_returns_normal_404(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app shell")
app = FastAPI()
app.frontend("/", directory=dist, fallback=None)
response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})
assert response.status_code == 404
assert response.json() == {"detail": "Not Found"}
def test_directory_index_and_redirect(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "about" / "index.html", "about")
app = FastAPI()
app.frontend("/", directory=dist)
client = TestClient(app)
redirect = client.get("/about", follow_redirects=False)
response = client.get("/about/")
assert redirect.status_code == 307
assert redirect.headers["location"] == "http://testserver/about/"
assert response.status_code == 200
assert response.text == "about"
def test_path_validation_and_trailing_slash_normalization(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "asset.txt", "ok")
app = FastAPI()
with pytest.raises(AssertionError):
app.frontend("", directory=dist)
with pytest.raises(AssertionError):
app.frontend("app", directory=dist)
app.frontend("/app/", directory=dist)
response = TestClient(app).get("/app/asset.txt")
assert response.status_code == 200
assert response.text == "ok"
def test_frontend_path_matching_uses_segment_boundaries(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
app.frontend("/app", directory=dist, fallback="index.html")
response = TestClient(app).get("/application", headers={"accept": "text/html"})
assert response.status_code == 404
def test_multiple_frontends_use_longest_matching_prefix(tmp_path: Path):
site = tmp_path / "site"
admin = tmp_path / "admin"
write_file(site / "index.html", "site")
write_file(admin / "index.html", "admin")
app = FastAPI()
app.frontend("/", directory=site, fallback="index.html")
app.frontend("/admin", directory=admin, fallback="index.html")
response = TestClient(app).get("/admin/settings", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.text == "admin"
def test_apirouter_frontend_uses_include_prefix(tmp_path: Path):
dist = tmp_path / "admin"
write_file(dist / "index.html", "admin")
router = APIRouter()
router.frontend("/", directory=dist, fallback="index.html")
app = FastAPI()
app.include_router(router, prefix="/admin")
response = TestClient(app).get("/admin/settings", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.text == "admin"
def test_global_priority_across_included_routers(tmp_path: Path):
dist = tmp_path / "site"
write_file(dist / "index.html", "site")
site_router = APIRouter()
site_router.frontend("/", directory=dist, fallback="index.html")
api_router = APIRouter()
@api_router.get("/api/users")
def read_users():
return {"source": "api"}
app = FastAPI()
app.include_router(site_router)
app.include_router(api_router)
response = TestClient(app).get("/api/users", headers={"accept": "text/html"})
assert response.status_code == 200
assert response.json() == {"source": "api"}
def test_nested_apirouter_frontend_uses_all_include_prefixes(tmp_path: Path):
dist = tmp_path / "admin"
write_file(dist / "index.html", "admin")
child_router = APIRouter()
child_router.frontend("/", directory=dist, fallback="index.html")
parent_router = APIRouter()
parent_router.include_router(child_router, prefix="/child")
app = FastAPI()
app.include_router(parent_router, prefix="/parent")
response = TestClient(app).get(
"/parent/child/settings", headers={"accept": "text/html"}
)
assert response.status_code == 200
assert response.text == "admin"
def test_low_priority_cache_updates_after_route_added_to_included_router(
tmp_path: Path,
):
dist = tmp_path / "site"
write_file(dist / "index.html", "site")
router = APIRouter()
router.frontend("/", directory=dist, fallback="index.html")
app = FastAPI()
app.include_router(router, prefix="/app")
client = TestClient(app)
frontend_response = client.get("/app/dashboard", headers={"accept": "text/html"})
@router.get("/dashboard")
def read_dashboard():
return {"source": "api"}
api_response = client.get("/app/dashboard", headers={"accept": "text/html"})
assert frontend_response.status_code == 200
assert frontend_response.text == "site"
assert api_response.status_code == 200
assert api_response.json() == {"source": "api"}
def test_normal_route_slash_redirect_wins_before_frontend_redirect(tmp_path: Path):
dist = tmp_path / "site"
write_file(dist / "api" / "index.html", "frontend")
app = FastAPI()
@app.get("/api/")
def read_api():
return {"source": "api"}
app.frontend("/", directory=dist)
response = TestClient(app).get("/api", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "http://testserver/api/"
followed = TestClient(app).get("/api/")
assert followed.status_code == 200
assert followed.json() == {"source": "api"}
def test_frontend_respects_root_path(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "assets" / "app.js", "console.log('ok')")
app = FastAPI()
app.frontend("/app", directory=dist)
response = TestClient(app, root_path="/proxy").get("/app/assets/app.js")
assert response.status_code == 200
assert response.text == "console.log('ok')"
def test_websocket_route_wins_over_frontend(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "ws", "frontend")
app = FastAPI()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
await websocket.send_text("websocket")
await websocket.close()
app.frontend("/", directory=dist)
with TestClient(app).websocket_connect("/ws") as websocket:
data = websocket.receive_text()
assert data == "websocket"
def test_head_requests_work(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "asset.txt", "ok")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).head("/asset.txt")
assert response.status_code == 200
assert response.text == ""
assert response.headers["content-length"] == "2"
def test_unsupported_methods_return_405(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "asset.txt", "ok")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).post("/asset.txt")
assert response.status_code == 405
@pytest.mark.parametrize(
"path",
[
"/../secret.txt",
"/%2e%2e/secret.txt",
"/..%2fsecret.txt",
"/%5c..%5csecret.txt",
"/..%5csecret.txt",
],
)
def test_path_traversal_cannot_escape_directory(tmp_path: Path, path: str):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
write_file(tmp_path / "secret.txt", "secret")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get(path)
assert response.status_code == 404
assert response.text != "secret"
def test_symlink_outside_directory_is_not_served(tmp_path: Path):
dist = tmp_path / "dist"
dist.mkdir()
outside = tmp_path / "secret.txt"
outside.write_text("secret")
link = dist / "secret.txt"
try:
os.symlink(outside, link)
except (OSError, NotImplementedError): # pragma: no cover
pytest.skip("symlinks are not supported")
app = FastAPI()
app.frontend("/", directory=dist)
response = TestClient(app).get("/secret.txt")
assert response.status_code == 404
assert response.text != "secret"
def test_check_dir_true_fails_early_for_missing_directory(monkeypatch, tmp_path: Path):
app = FastAPI()
monkeypatch.chdir(tmp_path)
with pytest.raises(RuntimeError, match="does not exist") as exc_info:
app.frontend("/", directory="missing")
message = str(exc_info.value)
assert "'missing'" in message
assert str(tmp_path / "missing") in message
def test_check_dir_false_allows_missing_directory_and_fails_on_request(tmp_path: Path):
app = FastAPI()
app.frontend("/", directory=tmp_path / "missing", check_dir=False)
with pytest.raises(RuntimeError, match="does not exist"):
TestClient(app).get("/asset.txt")
def test_explicit_fallback_files_fail_clearly_when_missing(monkeypatch, tmp_path: Path):
dist = tmp_path / "dist"
dist.mkdir()
monkeypatch.chdir(tmp_path)
app = FastAPI()
with pytest.raises(RuntimeError, match="index.html") as exc_info:
app.frontend("/", directory="dist", fallback="index.html")
message = str(exc_info.value)
assert "directory 'dist'" in message
assert str(dist) in message
app = FastAPI()
app.frontend("/", directory="dist", fallback="404.html", check_dir=False)
with pytest.raises(RuntimeError, match="404.html") as exc_info:
TestClient(app).get("/missing.js")
message = str(exc_info.value)
assert "directory 'dist'" in message
assert str(dist) in message
def test_frontend_routes_are_not_in_openapi(tmp_path: Path):
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI()
@app.get("/api")
def read_api():
return {"ok": True}
app.frontend("/", directory=dist, fallback="index.html")
schema = TestClient(app).get("/openapi.json").json()
assert set(schema["paths"]) == {"/api"}
response = TestClient(app).get("/api")
assert response.status_code == 200
assert response.json() == {"ok": True}
@pytest.mark.parametrize(
("example", "files", "path", "status_code", "body"),
[
(
"tutorial001_py310.py",
{"asset.txt": "asset"},
"/asset.txt",
200,
"asset",
),
(
"tutorial002_py310.py",
{"index.html": "index"},
"/dashboard",
200,
"index",
),
(
"tutorial003_py310.py",
{"404.html": "missing"},
"/missing",
404,
"missing",
),
(
"tutorial004_py310.py",
{"index.html": "index"},
"/app/dashboard",
200,
"index",
),
(
"tutorial005_py310.py",
{"index.html": "index"},
"/dashboard",
404,
'{"detail":"Not Found"}',
),
(
"tutorial006_py310.py",
{"asset.txt": "asset"},
"/asset.txt",
200,
"asset",
),
],
)
def test_docs_frontend_examples(
tmp_path: Path,
monkeypatch,
example: str,
files: dict[str, str],
path: str,
status_code: int,
body: str,
):
dist = tmp_path / "dist"
for file, content in files.items():
write_file(dist / file, content)
monkeypatch.chdir(tmp_path)
namespace = runpy.run_path(
str(Path(__file__).parents[1] / "docs_src" / "frontend" / example)
)
app = namespace["app"]
assert isinstance(app, FastAPI)
response = TestClient(app).get(path, headers={"accept": "text/html"})
assert response.status_code == status_code
assert response.text == body
def test_low_priority_routes_can_store_non_frontend_routes():
async def low_priority_endpoint(request):
return PlainTextResponse("low")
app = FastAPI()
app.router._low_priority_routes.append(Route("/low", low_priority_endpoint))
app.router._mark_routes_changed()
response = TestClient(app).get("/low")
assert response.status_code == 200
assert response.text == "low"
def test_included_low_priority_routes_can_store_non_frontend_routes():
async def low_priority_endpoint(request):
return PlainTextResponse("low")
router = APIRouter()
router._low_priority_routes.append(Route("/low", low_priority_endpoint))
router._mark_routes_changed()
app = FastAPI()
app.include_router(router, prefix="/prefix")
response = TestClient(app).get("/prefix/low")
assert response.status_code == 200
assert response.text == "low"