mirror of
https://github.com/fastapi/fastapi.git
synced 2025-12-25 23:29:34 -05:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32935103b1 | ||
|
|
395ece75aa | ||
|
|
e958d30d1d | ||
|
|
34fca99b28 | ||
|
|
3289796286 | ||
|
|
7167c77a18 | ||
|
|
ba882c10fe | ||
|
|
4ac55af283 | ||
|
|
3390a82832 | ||
|
|
f5844e76b5 | ||
|
|
32cefb9bff | ||
|
|
17e49bc9f7 | ||
|
|
df58ecdee2 | ||
|
|
6595658324 | ||
|
|
c8b729aea7 | ||
|
|
d8b8f211e8 | ||
|
|
ee96a099d8 | ||
|
|
ab03f22635 | ||
|
|
f5e2dd8025 |
4
.github/workflows/build-docs.yml
vendored
4
.github/workflows/build-docs.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
id: cache
|
||||
with:
|
||||
path: ${{ env.pythonLocation }}
|
||||
key: ${{ runner.os }}-python-docs-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-v03
|
||||
key: ${{ runner.os }}-python-docs-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml', 'requirements-docs.txt') }}-v03
|
||||
- name: Install docs extras
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: pip install .[doc]
|
||||
run: pip install -r requirements-docs.txt
|
||||
- name: Install Material for MkDocs Insiders
|
||||
if: ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false ) && steps.cache.outputs.cache-hit != 'true'
|
||||
run: pip install git+https://${{ secrets.ACTIONS_TOKEN }}@github.com/squidfunk/mkdocs-material-insiders.git
|
||||
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -23,15 +23,15 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
# Issue ref: https://github.com/actions/setup-python/issues/436
|
||||
# cache: "pip"
|
||||
cache-dependency-path: pyproject.toml
|
||||
# cache-dependency-path: pyproject.toml
|
||||
- uses: actions/cache@v3
|
||||
id: cache
|
||||
with:
|
||||
path: ${{ env.pythonLocation }}
|
||||
key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-test-v03
|
||||
key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml', 'requirements-tests.txt') }}-test-v03
|
||||
- name: Install Dependencies
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: pip install -e .[all,dev,doc,test]
|
||||
run: pip install -r requirements-tests.txt
|
||||
- name: Lint
|
||||
run: bash scripts/lint.sh
|
||||
- run: mkdir coverage
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
python-version: '3.8'
|
||||
# Issue ref: https://github.com/actions/setup-python/issues/436
|
||||
# cache: "pip"
|
||||
cache-dependency-path: pyproject.toml
|
||||
# cache-dependency-path: pyproject.toml
|
||||
|
||||
- name: Get coverage files
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
@@ -21,24 +21,13 @@ repos:
|
||||
- --py3-plus
|
||||
- --keep-runtime-typing
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.0.254
|
||||
rev: v0.0.272
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
- --fix
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
- id: isort
|
||||
name: isort (cython)
|
||||
types: [cython]
|
||||
- id: isort
|
||||
name: isort (pyi)
|
||||
types: [pyi]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
rev: 23.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
ci:
|
||||
|
||||
@@ -108,7 +108,7 @@ $ python -m pip install --upgrade pip
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ pip install -e ."[dev,doc,test]"
|
||||
$ pip install -r requirements.txt
|
||||
|
||||
---> 100%
|
||||
```
|
||||
|
||||
@@ -108,7 +108,7 @@ After activating the environment as described above:
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ pip install -e ".[dev,doc,test]"
|
||||
$ pip install -r requirements.txt
|
||||
|
||||
---> 100%
|
||||
```
|
||||
@@ -121,10 +121,15 @@ It will install all the dependencies and your local FastAPI in your local enviro
|
||||
|
||||
If you create a Python file that imports and uses FastAPI, and run it with the Python from your local environment, it will use your local FastAPI source code.
|
||||
|
||||
And if you update that local FastAPI source code, as it is installed with `-e`, when you run that Python file again, it will use the fresh version of FastAPI you just edited.
|
||||
And if you update that local FastAPI source code when you run that Python file again, it will use the fresh version of FastAPI you just edited.
|
||||
|
||||
That way, you don't have to "install" your local version to be able to test every change.
|
||||
|
||||
!!! note "Technical Details"
|
||||
This only happens when you install using this included `requiements.txt` instead of installing `pip install fastapi` directly.
|
||||
|
||||
That is because inside of the `requirements.txt` file, the local version of FastAPI is marked to be installed in "editable" mode, with the `-e` option.
|
||||
|
||||
### Format
|
||||
|
||||
There is a script that you can run that will format and clean all your code:
|
||||
|
||||
@@ -3,6 +3,28 @@
|
||||
## Latest Changes
|
||||
|
||||
|
||||
## 0.97.0
|
||||
|
||||
### Features
|
||||
|
||||
* ✨ Add support for `dependencies` in WebSocket routes. PR [#4534](https://github.com/tiangolo/fastapi/pull/4534) by [@paulo-raca](https://github.com/paulo-raca).
|
||||
* ✨ Add exception handler for `WebSocketRequestValidationError` (which also allows to override it). PR [#6030](https://github.com/tiangolo/fastapi/pull/6030) by [@kristjanvalur](https://github.com/kristjanvalur).
|
||||
|
||||
### Refactors
|
||||
|
||||
* ⬆️ Upgrade and fully migrate to Ruff, remove isort, includes a couple of tweaks suggested by the new version of Ruff. PR [#9660](https://github.com/tiangolo/fastapi/pull/9660) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Update internal type annotations and upgrade mypy. PR [#9658](https://github.com/tiangolo/fastapi/pull/9658) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Simplify `AsyncExitStackMiddleware` as without Python 3.6 `AsyncExitStack` is always available. PR [#9657](https://github.com/tiangolo/fastapi/pull/9657) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Upgrades
|
||||
|
||||
* ⬆️ Upgrade Black. PR [#9661](https://github.com/tiangolo/fastapi/pull/9661) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Internal
|
||||
|
||||
* 💚 Update CI cache to fix installs when dependencies change. PR [#9659](https://github.com/tiangolo/fastapi/pull/9659) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ⬇️ Separate requirements for development into their own requirements.txt files, they shouldn't be extras. PR [#9655](https://github.com/tiangolo/fastapi/pull/9655) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.96.1
|
||||
|
||||
### Fixes
|
||||
@@ -14,6 +36,11 @@
|
||||
|
||||
* 📌 Update minimum version of Pydantic to >=1.7.4. This fixes an issue when trying to use an old version of Pydantic. PR [#9567](https://github.com/tiangolo/fastapi/pull/9567) by [@Kludex](https://github.com/Kludex).
|
||||
|
||||
### Refactors
|
||||
|
||||
* ♻ Remove `media_type` from `ORJSONResponse` as it's inherited from the parent class. PR [#5805](https://github.com/tiangolo/fastapi/pull/5805) by [@Kludex](https://github.com/Kludex).
|
||||
* ♻ Instantiate `HTTPException` only when needed, optimization refactor. PR [#5356](https://github.com/tiangolo/fastapi/pull/5356) by [@pawamoy](https://github.com/pawamoy).
|
||||
|
||||
### Docs
|
||||
|
||||
* 🔥 Remove link to Pydantic's benchmark, as it was removed there. PR [#5811](https://github.com/tiangolo/fastapi/pull/5811) by [@Kludex](https://github.com/Kludex).
|
||||
@@ -28,10 +55,8 @@
|
||||
### Internal
|
||||
|
||||
* 🔧 Add sponsor Platform.sh. PR [#9650](https://github.com/tiangolo/fastapi/pull/9650) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻ Remove `media_type` from `ORJSONResponse` as it's inherited from the parent class. PR [#5805](https://github.com/tiangolo/fastapi/pull/5805) by [@Kludex](https://github.com/Kludex).
|
||||
* 👷 Add custom token to Smokeshow and Preview Docs for download-artifact, to prevent API rate limits. PR [#9646](https://github.com/tiangolo/fastapi/pull/9646) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 👷 Add custom tokens for GitHub Actions to avoid rate limits. PR [#9647](https://github.com/tiangolo/fastapi/pull/9647) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻ Instantiate `HTTPException` only when needed, optimization refactor. PR [#5356](https://github.com/tiangolo/fastapi/pull/5356) by [@pawamoy](https://github.com/pawamoy).
|
||||
|
||||
## 0.96.0
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ $ python -m venv env
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ pip install -e ."[dev,doc,test]"
|
||||
$ pip install -r requirements.txt
|
||||
|
||||
---> 100%
|
||||
```
|
||||
|
||||
@@ -98,7 +98,7 @@ Após ativar o ambiente como descrito acima:
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ pip install -e ."[dev,doc,test]"
|
||||
$ pip install -r requirements.txt
|
||||
|
||||
---> 100%
|
||||
```
|
||||
|
||||
@@ -108,7 +108,7 @@ $ python -m pip install --upgrade pip
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ pip install -e ."[dev,doc,test]"
|
||||
$ pip install -r requirements.txt
|
||||
|
||||
---> 100%
|
||||
```
|
||||
|
||||
@@ -97,7 +97,7 @@ $ python -m venv env
|
||||
<div class="termy">
|
||||
|
||||
```console
|
||||
$ pip install -e ."[dev,doc,test]"
|
||||
$ pip install -r requirements.txt
|
||||
|
||||
---> 100%
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.96.1"
|
||||
__version__ = "0.97.0"
|
||||
|
||||
from starlette import status as status
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ from fastapi.encoders import DictIntStrAny, SetIntStr
|
||||
from fastapi.exception_handlers import (
|
||||
http_exception_handler,
|
||||
request_validation_exception_handler,
|
||||
websocket_request_validation_exception_handler,
|
||||
)
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
|
||||
from fastapi.logger import logger
|
||||
from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware
|
||||
from fastapi.openapi.docs import (
|
||||
@@ -145,6 +146,11 @@ class FastAPI(Starlette):
|
||||
self.exception_handlers.setdefault(
|
||||
RequestValidationError, request_validation_exception_handler
|
||||
)
|
||||
self.exception_handlers.setdefault(
|
||||
WebSocketRequestValidationError,
|
||||
# Starlette still has incorrect type specification for the handlers
|
||||
websocket_request_validation_exception_handler, # type: ignore
|
||||
)
|
||||
|
||||
self.user_middleware: List[Middleware] = (
|
||||
[] if middleware is None else list(middleware)
|
||||
@@ -395,15 +401,34 @@ class FastAPI(Starlette):
|
||||
return decorator
|
||||
|
||||
def add_api_websocket_route(
|
||||
self, path: str, endpoint: Callable[..., Any], name: Optional[str] = None
|
||||
self,
|
||||
path: str,
|
||||
endpoint: Callable[..., Any],
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
dependencies: Optional[Sequence[Depends]] = None,
|
||||
) -> None:
|
||||
self.router.add_api_websocket_route(path, endpoint, name=name)
|
||||
self.router.add_api_websocket_route(
|
||||
path,
|
||||
endpoint,
|
||||
name=name,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
def websocket(
|
||||
self, path: str, name: Optional[str] = None
|
||||
self,
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
dependencies: Optional[Sequence[Depends]] = None,
|
||||
) -> Callable[[DecoratedCallable], DecoratedCallable]:
|
||||
def decorator(func: DecoratedCallable) -> DecoratedCallable:
|
||||
self.add_api_websocket_route(path, func, name=name)
|
||||
self.add_api_websocket_route(
|
||||
path,
|
||||
func,
|
||||
name=name,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
|
||||
from fastapi.utils import is_body_allowed_for_status_code
|
||||
from fastapi.websockets import WebSocket
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
|
||||
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY, WS_1008_POLICY_VIOLATION
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
||||
@@ -23,3 +24,11 @@ async def request_validation_exception_handler(
|
||||
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={"detail": jsonable_encoder(exc.errors())},
|
||||
)
|
||||
|
||||
|
||||
async def websocket_request_validation_exception_handler(
|
||||
websocket: WebSocket, exc: WebSocketRequestValidationError
|
||||
) -> None:
|
||||
await websocket.close(
|
||||
code=WS_1008_POLICY_VIOLATION, reason=jsonable_encoder(exc.errors())
|
||||
)
|
||||
|
||||
@@ -10,19 +10,16 @@ class AsyncExitStackMiddleware:
|
||||
self.context_name = context_name
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if AsyncExitStack:
|
||||
dependency_exception: Optional[Exception] = None
|
||||
async with AsyncExitStack() as stack:
|
||||
scope[self.context_name] = stack
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
except Exception as e:
|
||||
dependency_exception = e
|
||||
raise e
|
||||
if dependency_exception:
|
||||
# This exception was possibly handled by the dependency but it should
|
||||
# still bubble up so that the ServerErrorMiddleware can return a 500
|
||||
# or the ExceptionMiddleware can catch and handle any other exceptions
|
||||
raise dependency_exception
|
||||
else:
|
||||
await self.app(scope, receive, send) # pragma: no cover
|
||||
dependency_exception: Optional[Exception] = None
|
||||
async with AsyncExitStack() as stack:
|
||||
scope[self.context_name] = stack
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
except Exception as e:
|
||||
dependency_exception = e
|
||||
raise e
|
||||
if dependency_exception:
|
||||
# This exception was possibly handled by the dependency but it should
|
||||
# still bubble up so that the ServerErrorMiddleware can return a 500
|
||||
# or the ExceptionMiddleware can catch and handle any other exceptions
|
||||
raise dependency_exception
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Union
|
||||
|
||||
from fastapi.logger import logger
|
||||
from pydantic import AnyUrl, BaseModel, Field
|
||||
from typing_extensions import Literal
|
||||
|
||||
try:
|
||||
import email_validator # type: ignore
|
||||
@@ -298,18 +299,18 @@ class APIKeyIn(Enum):
|
||||
|
||||
|
||||
class APIKey(SecurityBase):
|
||||
type_ = Field(SecuritySchemeType.apiKey, alias="type")
|
||||
type_: SecuritySchemeType = Field(default=SecuritySchemeType.apiKey, alias="type")
|
||||
in_: APIKeyIn = Field(alias="in")
|
||||
name: str
|
||||
|
||||
|
||||
class HTTPBase(SecurityBase):
|
||||
type_ = Field(SecuritySchemeType.http, alias="type")
|
||||
type_: SecuritySchemeType = Field(default=SecuritySchemeType.http, alias="type")
|
||||
scheme: str
|
||||
|
||||
|
||||
class HTTPBearer(HTTPBase):
|
||||
scheme = "bearer"
|
||||
scheme: Literal["bearer"] = "bearer"
|
||||
bearerFormat: Optional[str] = None
|
||||
|
||||
|
||||
@@ -349,12 +350,14 @@ class OAuthFlows(BaseModel):
|
||||
|
||||
|
||||
class OAuth2(SecurityBase):
|
||||
type_ = Field(SecuritySchemeType.oauth2, alias="type")
|
||||
type_: SecuritySchemeType = Field(default=SecuritySchemeType.oauth2, alias="type")
|
||||
flows: OAuthFlows
|
||||
|
||||
|
||||
class OpenIdConnect(SecurityBase):
|
||||
type_ = Field(SecuritySchemeType.openIdConnect, alias="type")
|
||||
type_: SecuritySchemeType = Field(
|
||||
default=SecuritySchemeType.openIdConnect, alias="type"
|
||||
)
|
||||
openIdConnectUrl: str
|
||||
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ def get_openapi_operation_metadata(
|
||||
file_name = getattr(route.endpoint, "__globals__", {}).get("__file__")
|
||||
if file_name:
|
||||
message += f" at {file_name}"
|
||||
warnings.warn(message)
|
||||
warnings.warn(message, stacklevel=1)
|
||||
operation_ids.add(operation_id)
|
||||
operation["operationId"] = operation_id
|
||||
if route.deprecated:
|
||||
@@ -332,10 +332,8 @@ def get_openapi_path(
|
||||
openapi_response["description"] = description
|
||||
http422 = str(HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
if (all_route_params or route.body_field) and not any(
|
||||
[
|
||||
status in operation["responses"]
|
||||
for status in [http422, "4XX", "default"]
|
||||
]
|
||||
status in operation["responses"]
|
||||
for status in [http422, "4XX", "default"]
|
||||
):
|
||||
operation["responses"][http422] = {
|
||||
"description": "Validation Error",
|
||||
|
||||
@@ -30,7 +30,11 @@ from fastapi.dependencies.utils import (
|
||||
solve_dependencies,
|
||||
)
|
||||
from fastapi.encoders import DictIntStrAny, SetIntStr, jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
|
||||
from fastapi.exceptions import (
|
||||
FastAPIError,
|
||||
RequestValidationError,
|
||||
WebSocketRequestValidationError,
|
||||
)
|
||||
from fastapi.types import DecoratedCallable
|
||||
from fastapi.utils import (
|
||||
create_cloned_field,
|
||||
@@ -48,15 +52,15 @@ from starlette.concurrency import run_in_threadpool
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import BaseRoute, Match
|
||||
from starlette.routing import Mount as Mount # noqa
|
||||
from starlette.routing import (
|
||||
BaseRoute,
|
||||
Match,
|
||||
compile_path,
|
||||
get_name,
|
||||
request_response,
|
||||
websocket_session,
|
||||
)
|
||||
from starlette.status import WS_1008_POLICY_VIOLATION
|
||||
from starlette.routing import Mount as Mount # noqa
|
||||
from starlette.types import ASGIApp, Lifespan, Scope
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
@@ -283,7 +287,6 @@ def get_websocket_app(
|
||||
)
|
||||
values, errors, _, _2, _3 = solved_result
|
||||
if errors:
|
||||
await websocket.close(code=WS_1008_POLICY_VIOLATION)
|
||||
raise WebSocketRequestValidationError(errors)
|
||||
assert dependant.call is not None, "dependant.call must be a function"
|
||||
await dependant.call(**values)
|
||||
@@ -298,13 +301,21 @@ class APIWebSocketRoute(routing.WebSocketRoute):
|
||||
endpoint: Callable[..., Any],
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
dependencies: Optional[Sequence[params.Depends]] = None,
|
||||
dependency_overrides_provider: Optional[Any] = None,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.endpoint = endpoint
|
||||
self.name = get_name(endpoint) if name is None else name
|
||||
self.dependencies = list(dependencies or [])
|
||||
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
|
||||
self.dependant = get_dependant(path=self.path_format, call=self.endpoint)
|
||||
for depends in self.dependencies[::-1]:
|
||||
self.dependant.dependencies.insert(
|
||||
0,
|
||||
get_parameterless_sub_dependant(depends=depends, path=self.path_format),
|
||||
)
|
||||
|
||||
self.app = websocket_session(
|
||||
get_websocket_app(
|
||||
dependant=self.dependant,
|
||||
@@ -418,10 +429,7 @@ class APIRoute(routing.Route):
|
||||
else:
|
||||
self.response_field = None # type: ignore
|
||||
self.secure_cloned_response_field = None
|
||||
if dependencies:
|
||||
self.dependencies = list(dependencies)
|
||||
else:
|
||||
self.dependencies = []
|
||||
self.dependencies = list(dependencies or [])
|
||||
self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "")
|
||||
# if a "form feed" character (page break) is found in the description text,
|
||||
# truncate description text to the content preceding the first "form feed"
|
||||
@@ -516,7 +524,7 @@ class APIRouter(routing.Router):
|
||||
), "A path prefix must not end with '/', as the routes will start with '/'"
|
||||
self.prefix = prefix
|
||||
self.tags: List[Union[str, Enum]] = tags or []
|
||||
self.dependencies = list(dependencies or []) or []
|
||||
self.dependencies = list(dependencies or [])
|
||||
self.deprecated = deprecated
|
||||
self.include_in_schema = include_in_schema
|
||||
self.responses = responses or {}
|
||||
@@ -690,21 +698,37 @@ class APIRouter(routing.Router):
|
||||
return decorator
|
||||
|
||||
def add_api_websocket_route(
|
||||
self, path: str, endpoint: Callable[..., Any], name: Optional[str] = None
|
||||
self,
|
||||
path: str,
|
||||
endpoint: Callable[..., Any],
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
dependencies: Optional[Sequence[params.Depends]] = None,
|
||||
) -> None:
|
||||
current_dependencies = self.dependencies.copy()
|
||||
if dependencies:
|
||||
current_dependencies.extend(dependencies)
|
||||
|
||||
route = APIWebSocketRoute(
|
||||
self.prefix + path,
|
||||
endpoint=endpoint,
|
||||
name=name,
|
||||
dependencies=current_dependencies,
|
||||
dependency_overrides_provider=self.dependency_overrides_provider,
|
||||
)
|
||||
self.routes.append(route)
|
||||
|
||||
def websocket(
|
||||
self, path: str, name: Optional[str] = None
|
||||
self,
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
dependencies: Optional[Sequence[params.Depends]] = None,
|
||||
) -> Callable[[DecoratedCallable], DecoratedCallable]:
|
||||
def decorator(func: DecoratedCallable) -> DecoratedCallable:
|
||||
self.add_api_websocket_route(path, func, name=name)
|
||||
self.add_api_websocket_route(
|
||||
path, func, name=name, dependencies=dependencies
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -744,7 +768,7 @@ class APIRouter(routing.Router):
|
||||
path = getattr(r, "path") # noqa: B009
|
||||
name = getattr(r, "name", "unknown")
|
||||
if path is not None and not path:
|
||||
raise Exception(
|
||||
raise FastAPIError(
|
||||
f"Prefix and path cannot be both empty (path operation: {name})"
|
||||
)
|
||||
if responses is None:
|
||||
@@ -819,8 +843,16 @@ class APIRouter(routing.Router):
|
||||
name=route.name,
|
||||
)
|
||||
elif isinstance(route, APIWebSocketRoute):
|
||||
current_dependencies = []
|
||||
if dependencies:
|
||||
current_dependencies.extend(dependencies)
|
||||
if route.dependencies:
|
||||
current_dependencies.extend(route.dependencies)
|
||||
self.add_api_websocket_route(
|
||||
prefix + route.path, route.endpoint, name=route.name
|
||||
prefix + route.path,
|
||||
route.endpoint,
|
||||
dependencies=current_dependencies,
|
||||
name=route.name,
|
||||
)
|
||||
elif isinstance(route, routing.WebSocketRoute):
|
||||
self.add_websocket_route(
|
||||
|
||||
@@ -21,7 +21,9 @@ class APIKeyQuery(APIKeyBase):
|
||||
auto_error: bool = True,
|
||||
):
|
||||
self.model: APIKey = APIKey(
|
||||
**{"in": APIKeyIn.query}, name=name, description=description
|
||||
**{"in": APIKeyIn.query}, # type: ignore[arg-type]
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
self.scheme_name = scheme_name or self.__class__.__name__
|
||||
self.auto_error = auto_error
|
||||
@@ -48,7 +50,9 @@ class APIKeyHeader(APIKeyBase):
|
||||
auto_error: bool = True,
|
||||
):
|
||||
self.model: APIKey = APIKey(
|
||||
**{"in": APIKeyIn.header}, name=name, description=description
|
||||
**{"in": APIKeyIn.header}, # type: ignore[arg-type]
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
self.scheme_name = scheme_name or self.__class__.__name__
|
||||
self.auto_error = auto_error
|
||||
@@ -75,7 +79,9 @@ class APIKeyCookie(APIKeyBase):
|
||||
auto_error: bool = True,
|
||||
):
|
||||
self.model: APIKey = APIKey(
|
||||
**{"in": APIKeyIn.cookie}, name=name, description=description
|
||||
**{"in": APIKeyIn.cookie}, # type: ignore[arg-type]
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
self.scheme_name = scheme_name or self.__class__.__name__
|
||||
self.auto_error = auto_error
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.openapi.models import OAuth2 as OAuth2Model
|
||||
@@ -121,7 +121,9 @@ class OAuth2(SecurityBase):
|
||||
description: Optional[str] = None,
|
||||
auto_error: bool = True,
|
||||
):
|
||||
self.model = OAuth2Model(flows=flows, description=description)
|
||||
self.model = OAuth2Model(
|
||||
flows=cast(OAuthFlowsModel, flows), description=description
|
||||
)
|
||||
self.scheme_name = scheme_name or self.__class__.__name__
|
||||
self.auto_error = auto_error
|
||||
|
||||
@@ -148,7 +150,9 @@ class OAuth2PasswordBearer(OAuth2):
|
||||
):
|
||||
if not scopes:
|
||||
scopes = {}
|
||||
flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes})
|
||||
flows = OAuthFlowsModel(
|
||||
password=cast(Any, {"tokenUrl": tokenUrl, "scopes": scopes})
|
||||
)
|
||||
super().__init__(
|
||||
flows=flows,
|
||||
scheme_name=scheme_name,
|
||||
@@ -185,12 +189,15 @@ class OAuth2AuthorizationCodeBearer(OAuth2):
|
||||
if not scopes:
|
||||
scopes = {}
|
||||
flows = OAuthFlowsModel(
|
||||
authorizationCode={
|
||||
"authorizationUrl": authorizationUrl,
|
||||
"tokenUrl": tokenUrl,
|
||||
"refreshUrl": refreshUrl,
|
||||
"scopes": scopes,
|
||||
}
|
||||
authorizationCode=cast(
|
||||
Any,
|
||||
{
|
||||
"authorizationUrl": authorizationUrl,
|
||||
"tokenUrl": tokenUrl,
|
||||
"refreshUrl": refreshUrl,
|
||||
"scopes": scopes,
|
||||
},
|
||||
)
|
||||
)
|
||||
super().__init__(
|
||||
flows=flows,
|
||||
|
||||
@@ -51,47 +51,6 @@ Homepage = "https://github.com/tiangolo/fastapi"
|
||||
Documentation = "https://fastapi.tiangolo.com/"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest >=7.1.3,<8.0.0",
|
||||
"coverage[toml] >= 6.5.0,< 8.0",
|
||||
"mypy ==0.982",
|
||||
"ruff ==0.0.138",
|
||||
"black == 23.1.0",
|
||||
"isort >=5.0.6,<6.0.0",
|
||||
"httpx >=0.23.0,<0.24.0",
|
||||
"email_validator >=1.1.1,<2.0.0",
|
||||
# TODO: once removing databases from tutorial, upgrade SQLAlchemy
|
||||
# probably when including SQLModel
|
||||
"sqlalchemy >=1.3.18,<1.4.43",
|
||||
"peewee >=3.13.3,<4.0.0",
|
||||
"databases[sqlite] >=0.3.2,<0.7.0",
|
||||
"orjson >=3.2.1,<4.0.0",
|
||||
"ujson >=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0",
|
||||
"python-multipart >=0.0.5,<0.0.7",
|
||||
"flask >=1.1.2,<3.0.0",
|
||||
"anyio[trio] >=3.2.1,<4.0.0",
|
||||
"python-jose[cryptography] >=3.3.0,<4.0.0",
|
||||
"pyyaml >=5.3.1,<7.0.0",
|
||||
"passlib[bcrypt] >=1.7.2,<2.0.0",
|
||||
|
||||
# types
|
||||
"types-ujson ==5.7.0.1",
|
||||
"types-orjson ==3.6.2",
|
||||
]
|
||||
doc = [
|
||||
"mkdocs >=1.1.2,<2.0.0",
|
||||
"mkdocs-material >=8.1.4,<9.0.0",
|
||||
"mdx-include >=1.4.1,<2.0.0",
|
||||
"mkdocs-markdownextradata-plugin >=0.1.7,<0.3.0",
|
||||
"typer-cli >=0.0.13,<0.0.14",
|
||||
"typer[all] >=0.6.1,<0.8.0",
|
||||
"pyyaml >=5.3.1,<7.0.0",
|
||||
]
|
||||
dev = [
|
||||
"ruff ==0.0.138",
|
||||
"uvicorn[standard] >=0.12.0,<0.21.0",
|
||||
"pre-commit >=2.17.0,<3.0.0",
|
||||
]
|
||||
all = [
|
||||
"httpx >=0.23.0",
|
||||
"jinja2 >=2.11.2",
|
||||
@@ -107,10 +66,6 @@ all = [
|
||||
[tool.hatch.version]
|
||||
path = "fastapi/__init__.py"
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
known_third_party = ["fastapi", "pydantic", "starlette"]
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
|
||||
@@ -166,7 +121,7 @@ select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
# "I", # isort
|
||||
"I", # isort
|
||||
"C", # flake8-comprehensions
|
||||
"B", # flake8-bugbear
|
||||
]
|
||||
|
||||
8
requirements-docs.txt
Normal file
8
requirements-docs.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
-e .
|
||||
mkdocs >=1.1.2,<2.0.0
|
||||
mkdocs-material >=8.1.4,<9.0.0
|
||||
mdx-include >=1.4.1,<2.0.0
|
||||
mkdocs-markdownextradata-plugin >=0.1.7,<0.3.0
|
||||
typer-cli >=0.0.13,<0.0.14
|
||||
typer[all] >=0.6.1,<0.8.0
|
||||
pyyaml >=5.3.1,<7.0.0
|
||||
25
requirements-tests.txt
Normal file
25
requirements-tests.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
-e .
|
||||
pytest >=7.1.3,<8.0.0
|
||||
coverage[toml] >= 6.5.0,< 8.0
|
||||
mypy ==1.3.0
|
||||
ruff ==0.0.272
|
||||
black == 23.3.0
|
||||
httpx >=0.23.0,<0.24.0
|
||||
email_validator >=1.1.1,<2.0.0
|
||||
# TODO: once removing databases from tutorial, upgrade SQLAlchemy
|
||||
# probably when including SQLModel
|
||||
sqlalchemy >=1.3.18,<1.4.43
|
||||
peewee >=3.13.3,<4.0.0
|
||||
databases[sqlite] >=0.3.2,<0.7.0
|
||||
orjson >=3.2.1,<4.0.0
|
||||
ujson >=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0
|
||||
python-multipart >=0.0.5,<0.0.7
|
||||
flask >=1.1.2,<3.0.0
|
||||
anyio[trio] >=3.2.1,<4.0.0
|
||||
python-jose[cryptography] >=3.3.0,<4.0.0
|
||||
pyyaml >=5.3.1,<7.0.0
|
||||
passlib[bcrypt] >=1.7.2,<2.0.0
|
||||
|
||||
# types
|
||||
types-ujson ==5.7.0.1
|
||||
types-orjson ==3.6.2
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
-e .[all]
|
||||
-r requirements-tests.txt
|
||||
-r requirements-docs.txt
|
||||
uvicorn[standard] >=0.12.0,<0.21.0
|
||||
pre-commit >=2.17.0,<3.0.0
|
||||
@@ -3,4 +3,6 @@
|
||||
set -e
|
||||
set -x
|
||||
|
||||
# Check README.md is up to date
|
||||
python ./scripts/docs.py verify-readme
|
||||
python ./scripts/docs.py build-all
|
||||
|
||||
@@ -3,4 +3,3 @@ set -x
|
||||
|
||||
ruff fastapi tests docs_src scripts --fix
|
||||
black fastapi tests docs_src scripts
|
||||
isort fastapi tests docs_src scripts
|
||||
|
||||
@@ -6,4 +6,3 @@ set -x
|
||||
mypy fastapi
|
||||
ruff fastapi tests docs_src scripts
|
||||
black fastapi tests --check
|
||||
isort fastapi tests docs_src scripts --check-only
|
||||
|
||||
@@ -3,7 +3,5 @@
|
||||
set -e
|
||||
set -x
|
||||
|
||||
# Check README.md is up to date
|
||||
python ./scripts/docs.py verify-readme
|
||||
export PYTHONPATH=./docs_src
|
||||
coverage run -m pytest tests ${@}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.exceptions import FastAPIError
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
@@ -31,5 +32,5 @@ def test_use_empty():
|
||||
|
||||
def test_include_empty():
|
||||
# if both include and router.path are empty - it should raise exception
|
||||
with pytest.raises(Exception):
|
||||
with pytest.raises(FastAPIError):
|
||||
app.include_router(router)
|
||||
|
||||
73
tests/test_ws_dependencies.py
Normal file
73
tests/test_ws_dependencies.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, WebSocket
|
||||
from fastapi.testclient import TestClient
|
||||
from typing_extensions import Annotated
|
||||
|
||||
|
||||
def dependency_list() -> List[str]:
|
||||
return []
|
||||
|
||||
|
||||
DepList = Annotated[List[str], Depends(dependency_list)]
|
||||
|
||||
|
||||
def create_dependency(name: str):
|
||||
def fun(deps: DepList):
|
||||
deps.append(name)
|
||||
|
||||
return Depends(fun)
|
||||
|
||||
|
||||
router = APIRouter(dependencies=[create_dependency("router")])
|
||||
prefix_router = APIRouter(dependencies=[create_dependency("prefix_router")])
|
||||
app = FastAPI(dependencies=[create_dependency("app")])
|
||||
|
||||
|
||||
@app.websocket("/", dependencies=[create_dependency("index")])
|
||||
async def index(websocket: WebSocket, deps: DepList):
|
||||
await websocket.accept()
|
||||
await websocket.send_text(json.dumps(deps))
|
||||
await websocket.close()
|
||||
|
||||
|
||||
@router.websocket("/router", dependencies=[create_dependency("routerindex")])
|
||||
async def routerindex(websocket: WebSocket, deps: DepList):
|
||||
await websocket.accept()
|
||||
await websocket.send_text(json.dumps(deps))
|
||||
await websocket.close()
|
||||
|
||||
|
||||
@prefix_router.websocket("/", dependencies=[create_dependency("routerprefixindex")])
|
||||
async def routerprefixindex(websocket: WebSocket, deps: DepList):
|
||||
await websocket.accept()
|
||||
await websocket.send_text(json.dumps(deps))
|
||||
await websocket.close()
|
||||
|
||||
|
||||
app.include_router(router, dependencies=[create_dependency("router2")])
|
||||
app.include_router(
|
||||
prefix_router, prefix="/prefix", dependencies=[create_dependency("prefix_router2")]
|
||||
)
|
||||
|
||||
|
||||
def test_index():
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect("/") as websocket:
|
||||
data = json.loads(websocket.receive_text())
|
||||
assert data == ["app", "index"]
|
||||
|
||||
|
||||
def test_routerindex():
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect("/router") as websocket:
|
||||
data = json.loads(websocket.receive_text())
|
||||
assert data == ["app", "router2", "router", "routerindex"]
|
||||
|
||||
|
||||
def test_routerprefixindex():
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect("/prefix/") as websocket:
|
||||
data = json.loads(websocket.receive_text())
|
||||
assert data == ["app", "prefix_router2", "prefix_router", "routerprefixindex"]
|
||||
@@ -1,4 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, FastAPI, WebSocket
|
||||
import functools
|
||||
|
||||
import pytest
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
FastAPI,
|
||||
Header,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
status,
|
||||
)
|
||||
from fastapi.middleware import Middleware
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
router = APIRouter()
|
||||
@@ -63,9 +75,44 @@ async def router_native_prefix_ws(websocket: WebSocket):
|
||||
await websocket.close()
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
app.include_router(prefix_router, prefix="/prefix")
|
||||
app.include_router(native_prefix_route)
|
||||
async def ws_dependency_err():
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@router.websocket("/depends-err/")
|
||||
async def router_ws_depends_err(websocket: WebSocket, data=Depends(ws_dependency_err)):
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
async def ws_dependency_validate(x_missing: str = Header()):
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
@router.websocket("/depends-validate/")
|
||||
async def router_ws_depends_validate(
|
||||
websocket: WebSocket, data=Depends(ws_dependency_validate)
|
||||
):
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
class CustomError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@router.websocket("/custom_error/")
|
||||
async def router_ws_custom_error(websocket: WebSocket):
|
||||
raise CustomError()
|
||||
|
||||
|
||||
def make_app(app=None, **kwargs):
|
||||
app = app or FastAPI(**kwargs)
|
||||
app.include_router(router)
|
||||
app.include_router(prefix_router, prefix="/prefix")
|
||||
app.include_router(native_prefix_route)
|
||||
return app
|
||||
|
||||
|
||||
app = make_app(app)
|
||||
|
||||
|
||||
def test_app():
|
||||
@@ -125,3 +172,100 @@ def test_router_with_params():
|
||||
assert data == "path/to/file"
|
||||
data = websocket.receive_text()
|
||||
assert data == "a_query_param"
|
||||
|
||||
|
||||
def test_wrong_uri():
|
||||
"""
|
||||
Verify that a websocket connection to a non-existent endpoing returns in a shutdown
|
||||
"""
|
||||
client = TestClient(app)
|
||||
with pytest.raises(WebSocketDisconnect) as e:
|
||||
with client.websocket_connect("/no-router/"):
|
||||
pass # pragma: no cover
|
||||
assert e.value.code == status.WS_1000_NORMAL_CLOSURE
|
||||
|
||||
|
||||
def websocket_middleware(middleware_func):
|
||||
"""
|
||||
Helper to create a Starlette pure websocket middleware
|
||||
"""
|
||||
|
||||
def middleware_constructor(app):
|
||||
@functools.wraps(app)
|
||||
async def wrapped_app(scope, receive, send):
|
||||
if scope["type"] != "websocket":
|
||||
return await app(scope, receive, send) # pragma: no cover
|
||||
|
||||
async def call_next():
|
||||
return await app(scope, receive, send)
|
||||
|
||||
websocket = WebSocket(scope, receive=receive, send=send)
|
||||
return await middleware_func(websocket, call_next)
|
||||
|
||||
return wrapped_app
|
||||
|
||||
return middleware_constructor
|
||||
|
||||
|
||||
def test_depend_validation():
|
||||
"""
|
||||
Verify that a validation in a dependency invokes the correct exception handler
|
||||
"""
|
||||
caught = []
|
||||
|
||||
@websocket_middleware
|
||||
async def catcher(websocket, call_next):
|
||||
try:
|
||||
return await call_next()
|
||||
except Exception as e: # pragma: no cover
|
||||
caught.append(e)
|
||||
raise
|
||||
|
||||
myapp = make_app(middleware=[Middleware(catcher)])
|
||||
|
||||
client = TestClient(myapp)
|
||||
with pytest.raises(WebSocketDisconnect) as e:
|
||||
with client.websocket_connect("/depends-validate/"):
|
||||
pass # pragma: no cover
|
||||
# the validation error does produce a close message
|
||||
assert e.value.code == status.WS_1008_POLICY_VIOLATION
|
||||
# and no error is leaked
|
||||
assert caught == []
|
||||
|
||||
|
||||
def test_depend_err_middleware():
|
||||
"""
|
||||
Verify that it is possible to write custom WebSocket middleware to catch errors
|
||||
"""
|
||||
|
||||
@websocket_middleware
|
||||
async def errorhandler(websocket: WebSocket, call_next):
|
||||
try:
|
||||
return await call_next()
|
||||
except Exception as e:
|
||||
await websocket.close(code=status.WS_1006_ABNORMAL_CLOSURE, reason=repr(e))
|
||||
|
||||
myapp = make_app(middleware=[Middleware(errorhandler)])
|
||||
client = TestClient(myapp)
|
||||
with pytest.raises(WebSocketDisconnect) as e:
|
||||
with client.websocket_connect("/depends-err/"):
|
||||
pass # pragma: no cover
|
||||
assert e.value.code == status.WS_1006_ABNORMAL_CLOSURE
|
||||
assert "NotImplementedError" in e.value.reason
|
||||
|
||||
|
||||
def test_depend_err_handler():
|
||||
"""
|
||||
Verify that it is possible to write custom WebSocket middleware to catch errors
|
||||
"""
|
||||
|
||||
async def custom_handler(websocket: WebSocket, exc: CustomError) -> None:
|
||||
await websocket.close(1002, "foo")
|
||||
|
||||
myapp = make_app(exception_handlers={CustomError: custom_handler})
|
||||
client = TestClient(myapp)
|
||||
with pytest.raises(WebSocketDisconnect) as e:
|
||||
with client.websocket_connect("/custom_error/"):
|
||||
pass # pragma: no cover
|
||||
assert e.value.code == 1002
|
||||
assert "foo" in e.value.reason
|
||||
|
||||
Reference in New Issue
Block a user