mirror of
https://github.com/fastapi/fastapi.git
synced 2025-12-28 00:30:58 -05:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53da56146e | ||
|
|
3799b9027e | ||
|
|
c70f3f1198 | ||
|
|
58dddc5e4f | ||
|
|
c90c4fb6c1 | ||
|
|
5b3df28f0c | ||
|
|
6c6bdb6233 | ||
|
|
f156f45193 | ||
|
|
f24d744a3b | ||
|
|
937b462cdd | ||
|
|
3025a368c6 | ||
|
|
c218e0d560 | ||
|
|
1ed5aa23e6 | ||
|
|
106d2171d8 | ||
|
|
c5817912d2 | ||
|
|
a7a92bc637 | ||
|
|
68d1fea961 | ||
|
|
8c6b2d5804 | ||
|
|
19c53b21c1 | ||
|
|
44d63cd555 | ||
|
|
55c4b5fb0b | ||
|
|
c32e800c23 | ||
|
|
73dbbeab55 |
@@ -7,6 +7,13 @@ cache: pip
|
||||
python:
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
- "3.8-dev"
|
||||
- "nightly"
|
||||
|
||||
matrix:
|
||||
allow_failures:
|
||||
- python: "3.8-dev"
|
||||
- python: "nightly"
|
||||
|
||||
install:
|
||||
- pip install flit
|
||||
|
||||
@@ -25,6 +25,8 @@ Here's an incomplete list of some of them.
|
||||
|
||||
* <a href="https://blog.bartab.fr/fastapi-logging-on-the-fly/" target="_blank">FastAPI, a simple use case on logging</a> by <a href="https://blog.bartab.fr/" target="_blank">@euri10</a>.
|
||||
|
||||
* <a href="https://medium.com/@nico.axtmann95/deploying-a-scikit-learn-model-with-onnx-und-fastapi-1af398268915" target="_blank">Deploying a scikit-learn model with ONNX and FastAPI</a> by <a href="https://www.linkedin.com/in/nico-axtmann" target="_blank">Nico Axtmann</a>.
|
||||
|
||||
### Japanese
|
||||
|
||||
* <a href="https://qiita.com/mtitg/items/47770e9a562dd150631d" target="_blank">FastAPI|DB接続してCRUDするPython製APIサーバーを構築</a> by <a href="https://qiita.com/mtitg" target="_blank">@mtitg</a>.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
## Latest changes
|
||||
|
||||
## 0.37.0
|
||||
|
||||
* Add support for custom route classes for advanced use cases. PR [#468](https://github.com/tiangolo/fastapi/pull/468) by [@dmontagu](https://github.com/dmontagu).
|
||||
* Allow disabling Google fonts in ReDoc. PR [#481](https://github.com/tiangolo/fastapi/pull/481) by [@b1-luettje](https://github.com/b1-luettje).
|
||||
* Fix security issue: when returning a sub-class of a response model and using `skip_defaults` it could leak information. PR [#485](https://github.com/tiangolo/fastapi/pull/485) by [@dmontagu](https://github.com/dmontagu).
|
||||
* Enable tests for Python 3.8-dev. PR [#465](https://github.com/tiangolo/fastapi/pull/465) by [@Jamim](https://github.com/Jamim).
|
||||
* Add support and tests for Pydantic dataclasses in `response_model`. PR [#454](https://github.com/tiangolo/fastapi/pull/454) by [@dconathan](https://github.com/dconathan).
|
||||
* Fix typo in OAuth2 JWT tutorial. PR [#447](https://github.com/tiangolo/fastapi/pull/447) by [@pablogamboa](https://github.com/pablogamboa).
|
||||
* Use the `media_type` parameter in `Body()` params to set the media type in OpenAPI for `requestBody`. PR [#439](https://github.com/tiangolo/fastapi/pull/439) by [@divums](https://github.com/divums).
|
||||
* Add article [Deploying a scikit-learn model with ONNX and FastAPI](https://medium.com/@nico.axtmann95/deploying-a-scikit-learn-model-with-onnx-und-fastapi-1af398268915) by [https://www.linkedin.com/in/nico-axtmann](Nico Axtmann). PR [#438](https://github.com/tiangolo/fastapi/pull/438) by [@naxty](https://github.com/naxty).
|
||||
* Allow setting custom `422` (validation error) response/schema in OpenAPI.
|
||||
* And use media type from response class instead of fixed `application/json` (the default).
|
||||
* PR [#437](https://github.com/tiangolo/fastapi/pull/437) by [@divums](https://github.com/divums).
|
||||
* Fix using `"default"` extra response with status codes at the same time. PR [#489](https://github.com/tiangolo/fastapi/pull/489).
|
||||
* Allow additional responses to use status code ranges (like `5XX` and `4XX`) and `"default"`. PR [#435](https://github.com/tiangolo/fastapi/pull/435) by [@divums](https://github.com/divums).
|
||||
|
||||
## 0.36.0
|
||||
|
||||
* Fix implementation for `skip_defaults` when returning a Pydantic model. PR [#422](https://github.com/tiangolo/fastapi/pull/422) by [@dmontagu](https://github.com/dmontagu).
|
||||
|
||||
@@ -156,7 +156,7 @@ Then you could add permissions about that entity, like "drive" (for the car) or
|
||||
|
||||
And then, you could give that JWT token to a user (or bot), and he could use it to perform those actions (drive the car, or edit the blog post) without even needing to have an account, just with the JWT token your API generated for that.
|
||||
|
||||
Using these ideas, JWT can be used for way more sophisticate scenarios.
|
||||
Using these ideas, JWT can be used for way more sophisticated scenarios.
|
||||
|
||||
In those cases, several of those entities could have the same ID, let's say `foo` (a user `foo`, a car `foo`, and a blog post `foo`).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.36.0"
|
||||
__version__ = "0.37.0"
|
||||
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
|
||||
@@ -559,6 +559,8 @@ def get_body_field(*, dependant: Dependant, name: str) -> Optional[Field]:
|
||||
for f in flat_dependant.body_params:
|
||||
BodyModel.__fields__[f.name] = get_schema_compatible_field(field=f)
|
||||
required = any(True for f in flat_dependant.body_params if f.required)
|
||||
|
||||
BodySchema_kwargs: Dict[str, Any] = dict(default=None)
|
||||
if any(isinstance(f.schema, params.File) for f in flat_dependant.body_params):
|
||||
BodySchema: Type[params.Body] = params.File
|
||||
elif any(isinstance(f.schema, params.Form) for f in flat_dependant.body_params):
|
||||
@@ -566,6 +568,14 @@ def get_body_field(*, dependant: Dependant, name: str) -> Optional[Field]:
|
||||
else:
|
||||
BodySchema = params.Body
|
||||
|
||||
body_param_media_types = [
|
||||
getattr(f.schema, "media_type")
|
||||
for f in flat_dependant.body_params
|
||||
if isinstance(f.schema, params.Body)
|
||||
]
|
||||
if len(set(body_param_media_types)) == 1:
|
||||
BodySchema_kwargs["media_type"] = body_param_media_types[0]
|
||||
|
||||
field = Field(
|
||||
name="body",
|
||||
type_=BodyModel,
|
||||
@@ -574,6 +584,6 @@ def get_body_field(*, dependant: Dependant, name: str) -> Optional[Field]:
|
||||
model_config=BaseConfig,
|
||||
class_validators={},
|
||||
alias="body",
|
||||
schema=BodySchema(None),
|
||||
schema=BodySchema(**BodySchema_kwargs),
|
||||
)
|
||||
return field
|
||||
|
||||
@@ -56,6 +56,7 @@ def get_redoc_html(
|
||||
title: str,
|
||||
redoc_js_url: str = "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js",
|
||||
redoc_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png",
|
||||
with_google_fonts: bool = True,
|
||||
) -> HTMLResponse:
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
@@ -65,7 +66,12 @@ def get_redoc_html(
|
||||
<!-- needed for adaptive design -->
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
"""
|
||||
if with_google_fonts:
|
||||
html += """
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
"""
|
||||
html += f"""
|
||||
<link rel="shortcut icon" href="{redoc_favicon_url}">
|
||||
<!--
|
||||
ReDoc doesn't change outer page styles
|
||||
|
||||
@@ -210,10 +210,6 @@ class Response(BaseModel):
|
||||
links: Optional[Dict[str, Union[Link, Reference]]] = None
|
||||
|
||||
|
||||
class Responses(BaseModel):
|
||||
default: Response
|
||||
|
||||
|
||||
class Operation(BaseModel):
|
||||
tags: Optional[List[str]] = None
|
||||
summary: Optional[str] = None
|
||||
@@ -222,7 +218,7 @@ class Operation(BaseModel):
|
||||
operationId: Optional[str] = None
|
||||
parameters: Optional[List[Union[Parameter, Reference]]] = None
|
||||
requestBody: Optional[Union[RequestBody, Reference]] = None
|
||||
responses: Union[Responses, Dict[str, Response]]
|
||||
responses: Dict[str, Response]
|
||||
# Workaround OpenAPI recursive reference
|
||||
callbacks: Optional[Dict[str, Union[Dict[str, Any], Reference]]] = None
|
||||
deprecated: Optional[bool] = None
|
||||
|
||||
@@ -43,6 +43,15 @@ validation_error_response_definition = {
|
||||
},
|
||||
}
|
||||
|
||||
status_code_ranges: Dict[str, str] = {
|
||||
"1XX": "Information",
|
||||
"2XX": "Success",
|
||||
"3XX": "Redirection",
|
||||
"4XX": "Client Error",
|
||||
"5XX": "Server Error",
|
||||
"DEFAULT": "Default Response",
|
||||
}
|
||||
|
||||
|
||||
def get_openapi_params(dependant: Dependant) -> List[Field]:
|
||||
flat_dependant = get_flat_dependant(dependant, skip_repeats=True)
|
||||
@@ -71,15 +80,11 @@ def get_openapi_security_definitions(flat_dependant: Dependant) -> Tuple[Dict, L
|
||||
|
||||
def get_openapi_operation_parameters(
|
||||
all_route_params: Sequence[Field]
|
||||
) -> Tuple[Dict[str, Dict], List[Dict[str, Any]]]:
|
||||
definitions: Dict[str, Dict] = {}
|
||||
) -> List[Dict[str, Any]]:
|
||||
parameters = []
|
||||
for param in all_route_params:
|
||||
schema = param.schema
|
||||
schema = cast(Param, schema)
|
||||
if "ValidationError" not in definitions:
|
||||
definitions["ValidationError"] = validation_error_definition
|
||||
definitions["HTTPValidationError"] = validation_error_response_definition
|
||||
parameter = {
|
||||
"name": param.alias,
|
||||
"in": schema.in_.value,
|
||||
@@ -91,7 +96,7 @@ def get_openapi_operation_parameters(
|
||||
if schema.deprecated:
|
||||
parameter["deprecated"] = schema.deprecated
|
||||
parameters.append(parameter)
|
||||
return definitions, parameters
|
||||
return parameters
|
||||
|
||||
|
||||
def get_openapi_operation_request_body(
|
||||
@@ -159,10 +164,7 @@ def get_openapi_path(
|
||||
if security_definitions:
|
||||
security_schemes.update(security_definitions)
|
||||
all_route_params = get_openapi_params(route.dependant)
|
||||
validation_definitions, operation_parameters = get_openapi_operation_parameters(
|
||||
all_route_params=all_route_params
|
||||
)
|
||||
definitions.update(validation_definitions)
|
||||
operation_parameters = get_openapi_operation_parameters(all_route_params)
|
||||
parameters.extend(operation_parameters)
|
||||
if parameters:
|
||||
operation["parameters"] = parameters
|
||||
@@ -172,11 +174,6 @@ def get_openapi_path(
|
||||
)
|
||||
if request_body_oai:
|
||||
operation["requestBody"] = request_body_oai
|
||||
if "ValidationError" not in definitions:
|
||||
definitions["ValidationError"] = validation_error_definition
|
||||
definitions[
|
||||
"HTTPValidationError"
|
||||
] = validation_error_response_definition
|
||||
if route.responses:
|
||||
for (additional_status_code, response) in route.responses.items():
|
||||
assert isinstance(
|
||||
@@ -188,15 +185,18 @@ def get_openapi_path(
|
||||
field, model_name_map=model_name_map, ref_prefix=REF_PREFIX
|
||||
)
|
||||
response.setdefault("content", {}).setdefault(
|
||||
"application/json", {}
|
||||
route.response_class.media_type, {}
|
||||
)["schema"] = response_schema
|
||||
status_text = http.client.responses.get(int(additional_status_code))
|
||||
status_text: Optional[str] = status_code_ranges.get(
|
||||
str(additional_status_code).upper()
|
||||
) or http.client.responses.get(int(additional_status_code))
|
||||
response.setdefault(
|
||||
"description", status_text or "Additional Response"
|
||||
)
|
||||
operation.setdefault("responses", {})[
|
||||
str(additional_status_code)
|
||||
] = response
|
||||
status_code_key = str(additional_status_code).upper()
|
||||
if status_code_key == "DEFAULT":
|
||||
status_code_key = "default"
|
||||
operation.setdefault("responses", {})[status_code_key] = response
|
||||
status_code = str(route.status_code)
|
||||
response_schema = {"type": "string"}
|
||||
if lenient_issubclass(route.response_class, JSONResponse):
|
||||
@@ -216,8 +216,15 @@ def get_openapi_path(
|
||||
).setdefault("content", {}).setdefault(route.response_class.media_type, {})[
|
||||
"schema"
|
||||
] = response_schema
|
||||
if all_route_params or route.body_field:
|
||||
operation["responses"][str(HTTP_422_UNPROCESSABLE_ENTITY)] = {
|
||||
|
||||
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"]
|
||||
]
|
||||
):
|
||||
operation["responses"][http422] = {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -225,6 +232,13 @@ def get_openapi_path(
|
||||
}
|
||||
},
|
||||
}
|
||||
if "ValidationError" not in definitions:
|
||||
definitions.update(
|
||||
{
|
||||
"ValidationError": validation_error_definition,
|
||||
"HTTPValidationError": validation_error_response_definition,
|
||||
}
|
||||
)
|
||||
path[method.lower()] = operation
|
||||
return path, security_schemes, definitions
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ def serialize_response(
|
||||
) -> Any:
|
||||
if field:
|
||||
errors = []
|
||||
if skip_defaults and isinstance(response, BaseModel):
|
||||
response = response.dict(skip_defaults=skip_defaults)
|
||||
value, errors_ = field.validate(response, {}, loc=("response",))
|
||||
if isinstance(errors_, ErrorWrapper):
|
||||
errors.append(errors_)
|
||||
@@ -52,8 +54,6 @@ def serialize_response(
|
||||
errors.extend(errors_)
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
if skip_defaults and isinstance(response, BaseModel):
|
||||
value = response.dict(skip_defaults=skip_defaults)
|
||||
return jsonable_encoder(
|
||||
value,
|
||||
include=include,
|
||||
@@ -317,11 +317,13 @@ class APIRouter(routing.Router):
|
||||
redirect_slashes: bool = True,
|
||||
default: ASGIApp = None,
|
||||
dependency_overrides_provider: Any = None,
|
||||
route_class: Type[APIRoute] = APIRoute,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
routes=routes, redirect_slashes=redirect_slashes, default=default
|
||||
)
|
||||
self.dependency_overrides_provider = dependency_overrides_provider
|
||||
self.route_class = route_class
|
||||
|
||||
def add_api_route(
|
||||
self,
|
||||
@@ -347,7 +349,7 @@ class APIRouter(routing.Router):
|
||||
response_class: Type[Response] = JSONResponse,
|
||||
name: str = None,
|
||||
) -> None:
|
||||
route = APIRoute(
|
||||
route = self.route_class(
|
||||
path,
|
||||
endpoint=endpoint,
|
||||
response_model=response_model,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
from dataclasses import is_dataclass
|
||||
from typing import Any, Dict, List, Sequence, Set, Type, cast
|
||||
|
||||
from fastapi import routing
|
||||
@@ -52,6 +53,8 @@ def get_path_param_names(path: str) -> Set[str]:
|
||||
|
||||
def create_cloned_field(field: Field) -> Field:
|
||||
original_type = field.type_
|
||||
if is_dataclass(original_type) and hasattr(original_type, "__pydantic_model__"):
|
||||
original_type = original_type.__pydantic_model__ # type: ignore
|
||||
use_type = original_type
|
||||
if lenient_issubclass(original_type, BaseModel):
|
||||
original_type = cast(Type[BaseModel], original_type)
|
||||
|
||||
40
tests/test_additional_responses_bad.py
Normal file
40
tests/test_additional_responses_bad.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/a", responses={"hello": {"description": "Not a valid additional response"}})
|
||||
async def a():
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
openapi_schema = {
|
||||
"openapi": "3.0.2",
|
||||
"info": {"title": "Fast API", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/a": {
|
||||
"get": {
|
||||
"responses": {
|
||||
# this is how one would imagine the openapi schema to be
|
||||
# but since the key is not valid, openapi.utils.get_openapi will raise ValueError
|
||||
"hello": {"description": "Not a valid additional response"},
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
},
|
||||
"summary": "A",
|
||||
"operationId": "a_a_get",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_openapi_schema():
|
||||
with pytest.raises(ValueError):
|
||||
client.get("/openapi.json")
|
||||
100
tests/test_additional_responses_custom_validationerror.py
Normal file
100
tests/test_additional_responses_custom_validationerror.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import typing
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class JsonApiResponse(JSONResponse):
|
||||
media_type = "application/vnd.api+json"
|
||||
|
||||
|
||||
class Error(BaseModel):
|
||||
status: str
|
||||
title: str
|
||||
|
||||
|
||||
class JsonApiError(BaseModel):
|
||||
errors: typing.List[Error]
|
||||
|
||||
|
||||
@app.get(
|
||||
"/a/{id}",
|
||||
response_class=JsonApiResponse,
|
||||
responses={422: {"description": "Error", "model": JsonApiError}},
|
||||
)
|
||||
async def a(id):
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
openapi_schema = {
|
||||
"openapi": "3.0.2",
|
||||
"info": {"title": "Fast API", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/a/{id}": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"422": {
|
||||
"description": "Error",
|
||||
"content": {
|
||||
"application/vnd.api+json": {
|
||||
"schema": {"$ref": "#/components/schemas/JsonApiError"}
|
||||
}
|
||||
},
|
||||
},
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/vnd.api+json": {"schema": {}}},
|
||||
},
|
||||
},
|
||||
"summary": "A",
|
||||
"operationId": "a_a__id__get",
|
||||
"parameters": [
|
||||
{
|
||||
"required": True,
|
||||
"schema": {"title": "Id"},
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Error": {
|
||||
"title": "Error",
|
||||
"required": ["status", "title"],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"title": "Status", "type": "string"},
|
||||
"title": {"title": "Title", "type": "string"},
|
||||
},
|
||||
},
|
||||
"JsonApiError": {
|
||||
"title": "JsonApiError",
|
||||
"required": ["errors"],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"errors": {
|
||||
"title": "Errors",
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/Error"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_openapi_schema():
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == openapi_schema
|
||||
85
tests/test_additional_responses_default_validationerror.py
Normal file
85
tests/test_additional_responses_default_validationerror.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from fastapi import FastAPI
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/a/{id}")
|
||||
async def a(id):
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
openapi_schema = {
|
||||
"openapi": "3.0.2",
|
||||
"info": {"title": "Fast API", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/a/{id}": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
},
|
||||
"summary": "A",
|
||||
"operationId": "a_a__id__get",
|
||||
"parameters": [
|
||||
{
|
||||
"required": True,
|
||||
"schema": {"title": "Id"},
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ValidationError": {
|
||||
"title": "ValidationError",
|
||||
"required": ["loc", "msg", "type"],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"loc": {
|
||||
"title": "Location",
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"msg": {"title": "Message", "type": "string"},
|
||||
"type": {"title": "Error Type", "type": "string"},
|
||||
},
|
||||
},
|
||||
"HTTPValidationError": {
|
||||
"title": "HTTPValidationError",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"detail": {
|
||||
"title": "Detail",
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/ValidationError"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_openapi_schema():
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == openapi_schema
|
||||
117
tests/test_additional_responses_response_class.py
Normal file
117
tests/test_additional_responses_response_class.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import typing
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class JsonApiResponse(JSONResponse):
|
||||
media_type = "application/vnd.api+json"
|
||||
|
||||
|
||||
class Error(BaseModel):
|
||||
status: str
|
||||
title: str
|
||||
|
||||
|
||||
class JsonApiError(BaseModel):
|
||||
errors: typing.List[Error]
|
||||
|
||||
|
||||
@app.get(
|
||||
"/a",
|
||||
response_class=JsonApiResponse,
|
||||
responses={500: {"description": "Error", "model": JsonApiError}},
|
||||
)
|
||||
async def a():
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
@app.get("/b", responses={500: {"description": "Error", "model": Error}})
|
||||
async def b():
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
openapi_schema = {
|
||||
"openapi": "3.0.2",
|
||||
"info": {"title": "Fast API", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/a": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"500": {
|
||||
"description": "Error",
|
||||
"content": {
|
||||
"application/vnd.api+json": {
|
||||
"schema": {"$ref": "#/components/schemas/JsonApiError"}
|
||||
}
|
||||
},
|
||||
},
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/vnd.api+json": {"schema": {}}},
|
||||
},
|
||||
},
|
||||
"summary": "A",
|
||||
"operationId": "a_a_get",
|
||||
}
|
||||
},
|
||||
"/b": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"500": {
|
||||
"description": "Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Error"}
|
||||
}
|
||||
},
|
||||
},
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
},
|
||||
"summary": "B",
|
||||
"operationId": "b_b_get",
|
||||
}
|
||||
},
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Error": {
|
||||
"title": "Error",
|
||||
"required": ["status", "title"],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"title": "Status", "type": "string"},
|
||||
"title": {"title": "Title", "type": "string"},
|
||||
},
|
||||
},
|
||||
"JsonApiError": {
|
||||
"title": "JsonApiError",
|
||||
"required": ["errors"],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"errors": {
|
||||
"title": "Errors",
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/Error"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_openapi_schema():
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == openapi_schema
|
||||
@@ -10,12 +10,25 @@ async def a():
|
||||
return "a"
|
||||
|
||||
|
||||
@router.get("/b", responses={502: {"description": "Error 2"}})
|
||||
@router.get(
|
||||
"/b",
|
||||
responses={
|
||||
502: {"description": "Error 2"},
|
||||
"4XX": {"description": "Error with range, upper"},
|
||||
},
|
||||
)
|
||||
async def b():
|
||||
return "b"
|
||||
|
||||
|
||||
@router.get("/c", responses={501: {"description": "Error 3"}})
|
||||
@router.get(
|
||||
"/c",
|
||||
responses={
|
||||
"400": {"description": "Error with str"},
|
||||
"5xx": {"description": "Error with range, lower"},
|
||||
"default": {"description": "A default response"},
|
||||
},
|
||||
)
|
||||
async def c():
|
||||
return "c"
|
||||
|
||||
@@ -43,6 +56,7 @@ openapi_schema = {
|
||||
"get": {
|
||||
"responses": {
|
||||
"502": {"description": "Error 2"},
|
||||
"4XX": {"description": "Error with range, upper"},
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
@@ -55,11 +69,13 @@ openapi_schema = {
|
||||
"/c": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"501": {"description": "Error 3"},
|
||||
"400": {"description": "Error with str"},
|
||||
"5XX": {"description": "Error with range, lower"},
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
"default": {"description": "A default response"},
|
||||
},
|
||||
"summary": "C",
|
||||
"operationId": "c_c_get",
|
||||
|
||||
@@ -54,3 +54,14 @@ def test_strings_in_custom_redoc():
|
||||
body_content = html.body.decode()
|
||||
assert redoc_js_url in body_content
|
||||
assert redoc_favicon_url in body_content
|
||||
|
||||
|
||||
def test_google_fonts_in_generated_redoc():
|
||||
body_with_google_fonts = get_redoc_html(
|
||||
openapi_url="/docs", title="title"
|
||||
).body.decode()
|
||||
assert "fonts.googleapis.com" in body_with_google_fonts
|
||||
body_without_google_fonts = get_redoc_html(
|
||||
openapi_url="/docs", title="title", with_google_fonts=False
|
||||
).body.decode()
|
||||
assert "fonts.googleapis.com" not in body_without_google_fonts
|
||||
|
||||
67
tests/test_request_body_parameters_media_type.py
Normal file
67
tests/test_request_body_parameters_media_type.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import typing
|
||||
|
||||
from fastapi import Body, FastAPI
|
||||
from pydantic import BaseModel
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
media_type = "application/vnd.api+json"
|
||||
|
||||
# NOTE: These are not valid JSON:API resources
|
||||
# but they are fine for testing requestBody with custom media_type
|
||||
class Product(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
|
||||
class Shop(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@app.post("/products")
|
||||
async def create_product(data: Product = Body(..., media_type=media_type, embed=True)):
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
@app.post("/shops")
|
||||
async def create_shop(
|
||||
data: Shop = Body(..., media_type=media_type),
|
||||
included: typing.List[Product] = Body([], media_type=media_type),
|
||||
):
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
create_product_request_body = {
|
||||
"content": {
|
||||
"application/vnd.api+json": {
|
||||
"schema": {"$ref": "#/components/schemas/Body_create_product_products_post"}
|
||||
}
|
||||
},
|
||||
"required": True,
|
||||
}
|
||||
|
||||
create_shop_request_body = {
|
||||
"content": {
|
||||
"application/vnd.api+json": {
|
||||
"schema": {"$ref": "#/components/schemas/Body_create_shop_shops_post"}
|
||||
}
|
||||
},
|
||||
"required": True,
|
||||
}
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_openapi_schema():
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
openapi_schema = response.json()
|
||||
assert (
|
||||
openapi_schema["paths"]["/products"]["post"]["requestBody"]
|
||||
== create_product_request_body
|
||||
)
|
||||
assert (
|
||||
openapi_schema["paths"]["/shops"]["post"]["requestBody"]
|
||||
== create_shop_request_body
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic import BaseModel
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
@@ -14,38 +13,45 @@ class Item(BaseModel):
|
||||
owner_ids: List[int] = None
|
||||
|
||||
|
||||
@app.get("/items/invalid", response_model=Item)
|
||||
def get_invalid():
|
||||
return {"name": "invalid", "price": "foo"}
|
||||
@app.get("/items/valid", response_model=Item)
|
||||
def get_valid():
|
||||
return {"name": "valid", "price": 1.0}
|
||||
|
||||
|
||||
@app.get("/items/innerinvalid", response_model=Item)
|
||||
def get_innerinvalid():
|
||||
return {"name": "double invalid", "price": "foo", "owner_ids": ["foo", "bar"]}
|
||||
@app.get("/items/coerce", response_model=Item)
|
||||
def get_coerce():
|
||||
return {"name": "coerce", "price": "1.0"}
|
||||
|
||||
|
||||
@app.get("/items/invalidlist", response_model=List[Item])
|
||||
def get_invalidlist():
|
||||
@app.get("/items/validlist", response_model=List[Item])
|
||||
def get_validlist():
|
||||
return [
|
||||
{"name": "foo"},
|
||||
{"name": "bar", "price": "bar"},
|
||||
{"name": "baz", "price": "baz"},
|
||||
{"name": "bar", "price": 1.0},
|
||||
{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]},
|
||||
]
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_invalid():
|
||||
with pytest.raises(ValidationError):
|
||||
client.get("/items/invalid")
|
||||
def test_valid():
|
||||
response = client.get("/items/valid")
|
||||
response.raise_for_status()
|
||||
assert response.json() == {"name": "valid", "price": 1.0, "owner_ids": None}
|
||||
|
||||
|
||||
def test_double_invalid():
|
||||
with pytest.raises(ValidationError):
|
||||
client.get("/items/innerinvalid")
|
||||
def test_coerce():
|
||||
response = client.get("/items/coerce")
|
||||
response.raise_for_status()
|
||||
assert response.json() == {"name": "coerce", "price": 1.0, "owner_ids": None}
|
||||
|
||||
|
||||
def test_invalid_list():
|
||||
with pytest.raises(ValidationError):
|
||||
client.get("/items/invalidlist")
|
||||
def test_validlist():
|
||||
response = client.get("/items/validlist")
|
||||
response.raise_for_status()
|
||||
assert response.json() == [
|
||||
{"name": "foo", "price": None, "owner_ids": None},
|
||||
{"name": "bar", "price": 1.0, "owner_ids": None},
|
||||
{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]},
|
||||
]
|
||||
|
||||
58
tests/test_serialize_response_dataclass.py
Normal file
58
tests/test_serialize_response_dataclass.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic.dataclasses import dataclass
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
name: str
|
||||
price: float = None
|
||||
owner_ids: List[int] = None
|
||||
|
||||
|
||||
@app.get("/items/valid", response_model=Item)
|
||||
def get_valid():
|
||||
return {"name": "valid", "price": 1.0}
|
||||
|
||||
|
||||
@app.get("/items/coerce", response_model=Item)
|
||||
def get_coerce():
|
||||
return {"name": "coerce", "price": "1.0"}
|
||||
|
||||
|
||||
@app.get("/items/validlist", response_model=List[Item])
|
||||
def get_validlist():
|
||||
return [
|
||||
{"name": "foo"},
|
||||
{"name": "bar", "price": 1.0},
|
||||
{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]},
|
||||
]
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_valid():
|
||||
response = client.get("/items/valid")
|
||||
response.raise_for_status()
|
||||
assert response.json() == {"name": "valid", "price": 1.0, "owner_ids": None}
|
||||
|
||||
|
||||
def test_coerce():
|
||||
response = client.get("/items/coerce")
|
||||
response.raise_for_status()
|
||||
assert response.json() == {"name": "coerce", "price": 1.0, "owner_ids": None}
|
||||
|
||||
|
||||
def test_validlist():
|
||||
response = client.get("/items/validlist")
|
||||
response.raise_for_status()
|
||||
assert response.json() == [
|
||||
{"name": "foo", "price": None, "owner_ids": None},
|
||||
{"name": "bar", "price": 1.0, "owner_ids": None},
|
||||
{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]},
|
||||
]
|
||||
@@ -16,9 +16,13 @@ class Model(BaseModel):
|
||||
sub: SubModel
|
||||
|
||||
|
||||
class ModelSubclass(Model):
|
||||
y: int
|
||||
|
||||
|
||||
@app.get("/", response_model=Model, response_model_skip_defaults=True)
|
||||
def get() -> Model:
|
||||
return Model(sub={})
|
||||
def get() -> ModelSubclass:
|
||||
return ModelSubclass(sub={}, y=1)
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
51
tests/test_validate_response.py
Normal file
51
tests/test_validate_response.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
price: float = None
|
||||
owner_ids: List[int] = None
|
||||
|
||||
|
||||
@app.get("/items/invalid", response_model=Item)
|
||||
def get_invalid():
|
||||
return {"name": "invalid", "price": "foo"}
|
||||
|
||||
|
||||
@app.get("/items/innerinvalid", response_model=Item)
|
||||
def get_innerinvalid():
|
||||
return {"name": "double invalid", "price": "foo", "owner_ids": ["foo", "bar"]}
|
||||
|
||||
|
||||
@app.get("/items/invalidlist", response_model=List[Item])
|
||||
def get_invalidlist():
|
||||
return [
|
||||
{"name": "foo"},
|
||||
{"name": "bar", "price": "bar"},
|
||||
{"name": "baz", "price": "baz"},
|
||||
]
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_invalid():
|
||||
with pytest.raises(ValidationError):
|
||||
client.get("/items/invalid")
|
||||
|
||||
|
||||
def test_double_invalid():
|
||||
with pytest.raises(ValidationError):
|
||||
client.get("/items/innerinvalid")
|
||||
|
||||
|
||||
def test_invalid_list():
|
||||
with pytest.raises(ValidationError):
|
||||
client.get("/items/invalidlist")
|
||||
53
tests/test_validate_response_dataclass.py
Normal file
53
tests/test_validate_response_dataclass.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from pydantic import ValidationError
|
||||
from pydantic.dataclasses import dataclass
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
name: str
|
||||
price: float = None
|
||||
owner_ids: List[int] = None
|
||||
|
||||
|
||||
@app.get("/items/invalid", response_model=Item)
|
||||
def get_invalid():
|
||||
return {"name": "invalid", "price": "foo"}
|
||||
|
||||
|
||||
@app.get("/items/innerinvalid", response_model=Item)
|
||||
def get_innerinvalid():
|
||||
return {"name": "double invalid", "price": "foo", "owner_ids": ["foo", "bar"]}
|
||||
|
||||
|
||||
@app.get("/items/invalidlist", response_model=List[Item])
|
||||
def get_invalidlist():
|
||||
return [
|
||||
{"name": "foo"},
|
||||
{"name": "bar", "price": "bar"},
|
||||
{"name": "baz", "price": "baz"},
|
||||
]
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_invalid():
|
||||
with pytest.raises(ValidationError):
|
||||
client.get("/items/invalid")
|
||||
|
||||
|
||||
def test_double_invalid():
|
||||
with pytest.raises(ValidationError):
|
||||
client.get("/items/innerinvalid")
|
||||
|
||||
|
||||
def test_invalid_list():
|
||||
with pytest.raises(ValidationError):
|
||||
client.get("/items/invalidlist")
|
||||
Reference in New Issue
Block a user