mirror of
https://github.com/fastapi/fastapi.git
synced 2025-12-26 07:40:57 -05:00
Compare commits
45 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 | ||
|
|
417a3ab140 | ||
|
|
a3235ed8de | ||
|
|
38495fffa5 | ||
|
|
b77a43bcac | ||
|
|
483eb73b26 | ||
|
|
51a928d3f5 | ||
|
|
e71636e381 | ||
|
|
f7f17fcfd6 | ||
|
|
033bc2a6c9 | ||
|
|
28d3b9f783 | ||
|
|
0c55553328 | ||
|
|
b66056aa34 | ||
|
|
4f10b8b98d | ||
|
|
06eb421934 | ||
|
|
bf229ad5d8 | ||
|
|
d0319001be | ||
|
|
c4682af13d | ||
|
|
6ca3ce80e4 | ||
|
|
25e85c8522 | ||
|
|
6bf3ab3b7a | ||
|
|
f5ea5eef2a | ||
|
|
46a986cacf |
@@ -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
|
||||
|
||||
4
Pipfile
4
Pipfile
@@ -25,8 +25,8 @@ sqlalchemy = "*"
|
||||
uvicorn = "*"
|
||||
|
||||
[packages]
|
||||
starlette = "==0.12.0"
|
||||
pydantic = "==0.29.0"
|
||||
starlette = "==0.12.7"
|
||||
pydantic = "==0.30.0"
|
||||
databases = {extras = ["sqlite"],version = "*"}
|
||||
hypercorn = "*"
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ But then <a href="https://letsencrypt.org/" target="_blank">Let's Encrypt</a> wa
|
||||
|
||||
It is a project from the Linux Foundation. It provides HTTPS certificates for free. In an automated way. These certificates use all the standard cryptographic security, and are short lived (about 3 months), so, the security is actually increased, by reducing their lifespan.
|
||||
|
||||
The domain's are securely verified and the certificates are generated automatically. This also allows automatizing the renewal of these certificates.
|
||||
The domains are securely verified and the certificates are generated automatically. This also allows automatizing the renewal of these certificates.
|
||||
|
||||
The idea is to automatize the acquisition and renewal of these certificates, so that you can have secure HTTPS, free, forever.
|
||||
|
||||
|
||||
@@ -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,48 @@
|
||||
## 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).
|
||||
* Fix OpenAPI generation when using the same dependency in multiple places for the same *path operation*. PR [#417](https://github.com/tiangolo/fastapi/pull/417) by [@dmontagu](https://github.com/dmontagu).
|
||||
* Allow having empty paths in *path operations* used with `include_router` and a `prefix`.
|
||||
* This allows having a router for `/cats` and all its *path operations*, while having one of them for `/cats`.
|
||||
* Now it doesn't have to be only `/cats/` (with a trailing slash).
|
||||
* To use it, declare the path in the *path operation* as the empty string (`""`).
|
||||
* PR [#415](https://github.com/tiangolo/fastapi/pull/415) by [@vitalik](https://github.com/vitalik).
|
||||
* Fix mypy error after merging PR #415. PR [#462](https://github.com/tiangolo/fastapi/pull/462).
|
||||
|
||||
## 0.35.0
|
||||
|
||||
* Fix typo in routing `assert`. PR [#419](https://github.com/tiangolo/fastapi/pull/419) by [@pablogamboa](https://github.com/pablogamboa).
|
||||
* Fix typo in docs. PR [#411](https://github.com/tiangolo/fastapi/pull/411) by [@bronsen](https://github.com/bronsen).
|
||||
* Fix parsing a body type declared with `Union`. PR [#400](https://github.com/tiangolo/fastapi/pull/400) by [@koxudaxi](https://github.com/koxudaxi).
|
||||
|
||||
## 0.34.0
|
||||
|
||||
* Upgrade Starlette supported range to include the latest `0.12.7`. The new range is `0.11.1,<=0.12.7`. PR [#367](https://github.com/tiangolo/fastapi/pull/367) by [@dedsm](https://github.com/dedsm).
|
||||
|
||||
* Add test for OpenAPI schema with duplicate models from PR [#333](https://github.com/tiangolo/fastapi/pull/333) by [@dmontagu](https://github.com/dmontagu). PR [#385](https://github.com/tiangolo/fastapi/pull/385).
|
||||
|
||||
## 0.33.0
|
||||
|
||||
* Upgrade Pydantic version to `0.30.0`. PR [#384](https://github.com/tiangolo/fastapi/pull/384) by [@jekirl](https://github.com/jekirl).
|
||||
|
||||
## 0.32.0
|
||||
|
||||
* Fix typo in docs for features. PR [#380](https://github.com/tiangolo/fastapi/pull/380) by [@MartinoMensio](https://github.com/MartinoMensio).
|
||||
|
||||
@@ -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.32.0"
|
||||
__version__ = "0.37.0"
|
||||
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
|
||||
@@ -108,7 +108,16 @@ def get_sub_dependant(
|
||||
return sub_dependant
|
||||
|
||||
|
||||
def get_flat_dependant(dependant: Dependant) -> Dependant:
|
||||
CacheKey = Tuple[Optional[Callable], Tuple[str, ...]]
|
||||
|
||||
|
||||
def get_flat_dependant(
|
||||
dependant: Dependant, *, skip_repeats: bool = False, visited: List[CacheKey] = None
|
||||
) -> Dependant:
|
||||
if visited is None:
|
||||
visited = []
|
||||
visited.append(dependant.cache_key)
|
||||
|
||||
flat_dependant = Dependant(
|
||||
path_params=dependant.path_params.copy(),
|
||||
query_params=dependant.query_params.copy(),
|
||||
@@ -120,7 +129,11 @@ def get_flat_dependant(dependant: Dependant) -> Dependant:
|
||||
path=dependant.path,
|
||||
)
|
||||
for sub_dependant in dependant.dependencies:
|
||||
flat_sub = get_flat_dependant(sub_dependant)
|
||||
if skip_repeats and sub_dependant.cache_key in visited:
|
||||
continue
|
||||
flat_sub = get_flat_dependant(
|
||||
sub_dependant, skip_repeats=skip_repeats, visited=visited
|
||||
)
|
||||
flat_dependant.path_params.extend(flat_sub.path_params)
|
||||
flat_dependant.query_params.extend(flat_sub.query_params)
|
||||
flat_dependant.header_params.extend(flat_sub.header_params)
|
||||
@@ -131,12 +144,17 @@ def get_flat_dependant(dependant: Dependant) -> Dependant:
|
||||
|
||||
|
||||
def is_scalar_field(field: Field) -> bool:
|
||||
return (
|
||||
if not (
|
||||
field.shape == Shape.SINGLETON
|
||||
and not lenient_issubclass(field.type_, BaseModel)
|
||||
and not lenient_issubclass(field.type_, sequence_types + (dict,))
|
||||
and not isinstance(field.schema, params.Body)
|
||||
)
|
||||
):
|
||||
return False
|
||||
if field.sub_fields:
|
||||
if not all(is_scalar_field(f) for f in field.sub_fields):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_scalar_sequence_field(field: Field) -> bool:
|
||||
@@ -541,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):
|
||||
@@ -548,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,
|
||||
@@ -556,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,9 +43,18 @@ 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)
|
||||
flat_dependant = get_flat_dependant(dependant, skip_repeats=True)
|
||||
return (
|
||||
flat_dependant.path_params
|
||||
+ flat_dependant.query_params
|
||||
@@ -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(
|
||||
@@ -100,7 +105,7 @@ def get_openapi_operation_request_body(
|
||||
if not body_field:
|
||||
return None
|
||||
assert isinstance(body_field, Field)
|
||||
body_schema, _ = field_schema(
|
||||
body_schema, _, _ = field_schema(
|
||||
body_field, model_name_map=model_name_map, ref_prefix=REF_PREFIX
|
||||
)
|
||||
body_field.schema = cast(Body, body_field.schema)
|
||||
@@ -150,7 +155,7 @@ def get_openapi_path(
|
||||
for method in route.methods:
|
||||
operation = get_openapi_operation_metadata(route=route, method=method)
|
||||
parameters: List[Dict] = []
|
||||
flat_dependant = get_flat_dependant(route.dependant)
|
||||
flat_dependant = get_flat_dependant(route.dependant, skip_repeats=True)
|
||||
security_definitions, operation_security = get_openapi_security_definitions(
|
||||
flat_dependant=flat_dependant
|
||||
)
|
||||
@@ -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(
|
||||
@@ -184,24 +181,27 @@ def get_openapi_path(
|
||||
), "An additional response must be a dict"
|
||||
field = route.response_fields.get(additional_status_code)
|
||||
if field:
|
||||
response_schema, _ = field_schema(
|
||||
response_schema, _, _ = field_schema(
|
||||
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):
|
||||
if route.response_field:
|
||||
response_schema, _ = field_schema(
|
||||
response_schema, _, _ = field_schema(
|
||||
route.response_field,
|
||||
model_name_map=model_name_map,
|
||||
ref_prefix=REF_PREFIX,
|
||||
@@ -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_)
|
||||
@@ -147,7 +149,7 @@ def get_websocket_app(
|
||||
if errors:
|
||||
await websocket.close(code=WS_1008_POLICY_VIOLATION)
|
||||
raise WebSocketRequestValidationError(errors)
|
||||
assert dependant.call is not None, "dependant.call must me a function"
|
||||
assert dependant.call is not None, "dependant.call must be a function"
|
||||
await dependant.call(**values)
|
||||
|
||||
return app
|
||||
@@ -201,7 +203,6 @@ class APIRoute(routing.Route):
|
||||
response_class: Type[Response] = JSONResponse,
|
||||
dependency_overrides_provider: Any = None,
|
||||
) -> None:
|
||||
assert path.startswith("/"), "Routed paths must always start with '/'"
|
||||
self.path = path
|
||||
self.endpoint = endpoint
|
||||
self.name = get_name(endpoint) if name is None else name
|
||||
@@ -316,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,
|
||||
@@ -346,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,
|
||||
@@ -448,6 +451,14 @@ class APIRouter(routing.Router):
|
||||
assert not prefix.endswith(
|
||||
"/"
|
||||
), "A path prefix must not end with '/', as the routes will start with '/'"
|
||||
else:
|
||||
for r in router.routes:
|
||||
path = getattr(r, "path")
|
||||
name = getattr(r, "name", "unknown")
|
||||
if path is not None and not path:
|
||||
raise Exception(
|
||||
f"Prefix and path cannot be both empty (path operation: {name})"
|
||||
)
|
||||
if responses is None:
|
||||
responses = {}
|
||||
for route in router.routes:
|
||||
|
||||
@@ -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
|
||||
@@ -37,7 +38,7 @@ def get_model_definitions(
|
||||
) -> Dict[str, Any]:
|
||||
definitions: Dict[str, Dict] = {}
|
||||
for model in flat_models:
|
||||
m_schema, m_definitions = model_process_schema(
|
||||
m_schema, m_definitions, m_nested_models = model_process_schema(
|
||||
model, model_name_map=model_name_map, ref_prefix=REF_PREFIX
|
||||
)
|
||||
definitions.update(m_definitions)
|
||||
@@ -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)
|
||||
|
||||
@@ -19,8 +19,8 @@ classifiers = [
|
||||
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
||||
]
|
||||
requires = [
|
||||
"starlette >=0.11.1,<=0.12.0",
|
||||
"pydantic >=0.28,<=0.29.0"
|
||||
"starlette >=0.11.1,<=0.12.7",
|
||||
"pydantic >=0.30,<=0.30.0"
|
||||
]
|
||||
description-file = "README.md"
|
||||
requires-python = ">=3.6"
|
||||
|
||||
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",
|
||||
|
||||
23
tests/test_duplicate_models_openapi.py
Normal file
23
tests/test_duplicate_models_openapi.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def test_get_openapi():
|
||||
app = FastAPI()
|
||||
|
||||
class Model(BaseModel):
|
||||
pass
|
||||
|
||||
class Model2(BaseModel):
|
||||
a: Model
|
||||
|
||||
class Model3(BaseModel):
|
||||
c: Model
|
||||
d: Model2
|
||||
|
||||
@app.get("/", response_model=Model3)
|
||||
def f():
|
||||
pass # pragma: no cover
|
||||
|
||||
openapi = app.openapi()
|
||||
assert isinstance(openapi, dict)
|
||||
33
tests/test_empty_router.py
Normal file
33
tests/test_empty_router.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_empty():
|
||||
return ["OK"]
|
||||
|
||||
|
||||
app.include_router(router, prefix="/prefix")
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_use_empty():
|
||||
with client:
|
||||
response = client.get("/prefix")
|
||||
assert response.json() == ["OK"]
|
||||
|
||||
response = client.get("/prefix/")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_include_empty():
|
||||
# if both include and router.path are empty - it should raise exception
|
||||
with pytest.raises(Exception):
|
||||
app.include_router(router)
|
||||
@@ -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
|
||||
|
||||
103
tests/test_repeated_dependency_schema.py
Normal file
103
tests/test_repeated_dependency_schema.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from fastapi import Depends, FastAPI, Header
|
||||
from starlette.status import HTTP_200_OK
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def get_header(*, someheader: str = Header(...)):
|
||||
return someheader
|
||||
|
||||
|
||||
def get_something_else(*, someheader: str = Depends(get_header)):
|
||||
return f"{someheader}123"
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def get_deps(dep1: str = Depends(get_header), dep2: str = Depends(get_something_else)):
|
||||
return {"dep1": dep1, "dep2": dep2}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
schema = {
|
||||
"components": {
|
||||
"schemas": {
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
"items": {"$ref": "#/components/schemas/ValidationError"},
|
||||
"title": "Detail",
|
||||
"type": "array",
|
||||
}
|
||||
},
|
||||
"title": "HTTPValidationError",
|
||||
"type": "object",
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"loc": {
|
||||
"items": {"type": "string"},
|
||||
"title": "Location",
|
||||
"type": "array",
|
||||
},
|
||||
"msg": {"title": "Message", "type": "string"},
|
||||
"type": {"title": "Error " "Type", "type": "string"},
|
||||
},
|
||||
"required": ["loc", "msg", "type"],
|
||||
"title": "ValidationError",
|
||||
"type": "object",
|
||||
},
|
||||
}
|
||||
},
|
||||
"info": {"title": "Fast API", "version": "0.1.0"},
|
||||
"openapi": "3.0.2",
|
||||
"paths": {
|
||||
"/": {
|
||||
"get": {
|
||||
"operationId": "get_deps__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "someheader",
|
||||
"required": True,
|
||||
"schema": {"title": "Someheader", "type": "string"},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
"description": "Successful " "Response",
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation " "Error",
|
||||
},
|
||||
},
|
||||
"summary": "Get Deps",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_schema():
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
actual_schema = response.json()
|
||||
assert actual_schema == schema
|
||||
assert (
|
||||
len(actual_schema["paths"]["/"]["get"]["parameters"]) == 1
|
||||
) # primary goal of this test
|
||||
|
||||
|
||||
def test_response():
|
||||
response = client.get("/", headers={"someheader": "hello"})
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {"dep1": "hello", "dep2": "hello123"}
|
||||
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]},
|
||||
]
|
||||
33
tests/test_skip_defaults.py
Normal file
33
tests/test_skip_defaults.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class SubModel(BaseModel):
|
||||
a: Optional[str] = "foo"
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
x: Optional[int]
|
||||
sub: SubModel
|
||||
|
||||
|
||||
class ModelSubclass(Model):
|
||||
y: int
|
||||
|
||||
|
||||
@app.get("/", response_model=Model, response_model_skip_defaults=True)
|
||||
def get() -> ModelSubclass:
|
||||
return ModelSubclass(sub={}, y=1)
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_return_defaults():
|
||||
response = client.get("/")
|
||||
assert response.json() == {"sub": {}}
|
||||
124
tests/test_union_body.py
Normal file
124
tests/test_union_body.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class OtherItem(BaseModel):
|
||||
price: int
|
||||
|
||||
|
||||
@app.post("/items/")
|
||||
def save_union_body(item: Union[OtherItem, Item]):
|
||||
return {"item": item}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
item_openapi_schema = {
|
||||
"openapi": "3.0.2",
|
||||
"info": {"title": "Fast API", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/items/": {
|
||||
"post": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
"summary": "Save Union Body",
|
||||
"operationId": "save_union_body_items__post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"title": "Item",
|
||||
"anyOf": [
|
||||
{"$ref": "#/components/schemas/OtherItem"},
|
||||
{"$ref": "#/components/schemas/Item"},
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"OtherItem": {
|
||||
"title": "OtherItem",
|
||||
"required": ["price"],
|
||||
"type": "object",
|
||||
"properties": {"price": {"title": "Price", "type": "integer"}},
|
||||
},
|
||||
"Item": {
|
||||
"title": "Item",
|
||||
"type": "object",
|
||||
"properties": {"name": {"title": "Name", "type": "string"}},
|
||||
},
|
||||
"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"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_item_openapi_schema():
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == item_openapi_schema
|
||||
|
||||
|
||||
def test_post_other_item():
|
||||
response = client.post("/items/", json={"price": 100})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"item": {"price": 100}}
|
||||
|
||||
|
||||
def test_post_item():
|
||||
response = client.post("/items/", json={"name": "Foo"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"item": {"name": "Foo"}}
|
||||
140
tests/test_union_inherited_body.py
Normal file
140
tests/test_union_inherited_body.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import sys
|
||||
from typing import Optional, Union
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
# In Python 3.6:
|
||||
# u = Union[ExtendedItem, Item] == __main__.Item
|
||||
|
||||
# But in Python 3.7:
|
||||
# u = Union[ExtendedItem, Item] == typing.Union[__main__.ExtendedItem, __main__.Item]
|
||||
skip_py36 = pytest.mark.skipif(sys.version_info < (3, 7), reason="skip python3.6")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class ExtendedItem(Item):
|
||||
age: int
|
||||
|
||||
|
||||
@app.post("/items/")
|
||||
def save_union_different_body(item: Union[ExtendedItem, Item]):
|
||||
return {"item": item}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
inherited_item_openapi_schema = {
|
||||
"openapi": "3.0.2",
|
||||
"info": {"title": "Fast API", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/items/": {
|
||||
"post": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
"summary": "Save Union Different Body",
|
||||
"operationId": "save_union_different_body_items__post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"title": "Item",
|
||||
"anyOf": [
|
||||
{"$ref": "#/components/schemas/ExtendedItem"},
|
||||
{"$ref": "#/components/schemas/Item"},
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Item": {
|
||||
"title": "Item",
|
||||
"type": "object",
|
||||
"properties": {"name": {"title": "Name", "type": "string"}},
|
||||
},
|
||||
"ExtendedItem": {
|
||||
"title": "ExtendedItem",
|
||||
"required": ["age"],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"age": {"title": "Age", "type": "integer"},
|
||||
},
|
||||
},
|
||||
"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"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@skip_py36
|
||||
def test_inherited_item_openapi_schema():
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == inherited_item_openapi_schema
|
||||
|
||||
|
||||
@skip_py36
|
||||
def test_post_extended_item():
|
||||
response = client.post("/items/", json={"name": "Foo", "age": 5})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"item": {"name": "Foo", "age": 5}}
|
||||
|
||||
|
||||
@skip_py36
|
||||
def test_post_item():
|
||||
response = client.post("/items/", json={"name": "Foo"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"item": {"name": "Foo"}}
|
||||
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