Compare commits

...

13 Commits

Author SHA1 Message Date
Sebastián Ramírez
b87072bc12 🔖 Release version 0.58.0 2020-06-15 13:18:36 +02:00
Sebastián Ramírez
04e2bfafbc 📝 Update release notes 2020-06-15 13:13:53 +02:00
Sebastián Ramírez
181a32236a Deep merge OpenAPI responses (#1577)
* override successful response

*  Add deep_dict_udpate

*  Merge additional responses with generated responses

* 🍱 Update docs screenshot

Co-authored-by: rkbeatss <rkaus053@uottawa.ca>
2020-06-15 13:12:12 +02:00
Sebastián Ramírez
1f54a8e0a1 📝 Update release notes 2020-06-15 12:42:48 +02:00
Andrew
d63475bb7d 📝 Mention in docs that subapps don't fire events (#1554)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-06-14 18:25:10 +02:00
Sebastián Ramírez
5a3c5f1523 📝 Update release notes 2020-06-14 18:12:51 +02:00
Andrew
12bc9285f7 🐛 Fix body validation error response, remove variable name when it is not embedded (#1553) 2020-06-14 18:07:39 +02:00
Sebastián Ramírez
31df2ea940 📝 Update release notes 2020-06-14 17:56:12 +02:00
Sebastián Ramírez
50b90dd6a4 📝 Update release notes 2020-06-14 17:55:13 +02:00
Andrew
7dd881334d 🐛 Fix testing security scopes when using dependency overrides (#1549)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-06-14 17:54:46 +02:00
Vinny Do
530fc8ff3f 🐛 Fix JSON Schema "not" keyword (#1548) 2020-06-14 15:46:49 +02:00
Sebastián Ramírez
ef460b4d23 📝 Update release notes 2020-06-14 15:40:18 +02:00
mikaello
b591de2ace Add support for OpenAPI servers metadata (#1547)
* feat: add servers option for OpenAPI

Closes #872

*  Use dicts for OpenAPI servers

* ♻️ Update OpenAPI Server model to support relative URLs

*  Add tests for OpenAPI servers

* ♻️ Re-order parameter location of servers for OpenAPI

* 🎨 Format code

Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-06-14 15:38:29 +02:00
19 changed files with 238 additions and 63 deletions

View File

@@ -4,6 +4,9 @@ You can define event handlers (functions) that need to be executed before the ap
These functions can be declared with `async def` or normal `def`.
!!! warning
Only event handlers for the main application will be executed, not for [Sub Applications - Mounts](./sub-applications.md){.internal-link target=_blank}.
## `startup` event
To add a function that should be run before the application starts, declare it with the event `"startup"`:
@@ -41,4 +44,4 @@ Here, the `shutdown` event handler function will write a text line `"Application
So, we declare the event handler function with standard `def` instead of `async def`.
!!! info
You can read more about these event handlers in <a href="https://www.starlette.io/events/" class="external-link" target="_blank">Starlette's Events' docs</a>.
You can read more about these event handlers in <a href="https://www.starlette.io/events/" class="external-link" target="_blank">Starlette's Events' docs</a>.

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -2,6 +2,15 @@
## Latest changes
## 0.58.0
* Deep merge OpenAPI responses to preserve all the additional metadata. PR [#1577](https://github.com/tiangolo/fastapi/pull/1577).
* Mention in docs that only main app events are run (not sub-apps). PR [#1554](https://github.com/tiangolo/fastapi/pull/1554) by [@amacfie](https://github.com/amacfie).
* Fix body validation error response, do not include body variable when it is not embedded. PR [#1553](https://github.com/tiangolo/fastapi/pull/1553) by [@amacfie](https://github.com/amacfie).
* Fix testing OAuth2 security scopes when using dependency overrides. PR [#1549](https://github.com/tiangolo/fastapi/pull/1549) by [@amacfie](https://github.com/amacfie).
* Fix Model for JSON Schema keyword `not` as a JSON Schema instead of a list. PR [#1548](https://github.com/tiangolo/fastapi/pull/1548) by [@v-do](https://github.com/v-do).
* Add support for OpenAPI `servers`. PR [#1547](https://github.com/tiangolo/fastapi/pull/1547) by [@mikaello](https://github.com/mikaello).
## 0.57.0
* Remove broken link from "External Links". PR [#1565](https://github.com/tiangolo/fastapi/pull/1565) by [@victorphoenix3](https://github.com/victorphoenix3).

View File

@@ -215,7 +215,6 @@ You will receive a response telling you that the data is invalid containing the
{
"loc": [
"body",
"item",
"size"
],
"msg": "value is not a valid integer",

View File

@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.57.0"
__version__ = "0.58.0"
from starlette import status

View File

@@ -38,6 +38,7 @@ class FastAPI(Starlette):
version: str = "0.1.0",
openapi_url: Optional[str] = "/openapi.json",
openapi_tags: Optional[List[Dict[str, Any]]] = None,
servers: Optional[List[Dict[str, Union[str, Any]]]] = None,
default_response_class: Type[Response] = JSONResponse,
docs_url: Optional[str] = "/docs",
redoc_url: Optional[str] = "/redoc",
@@ -70,6 +71,7 @@ class FastAPI(Starlette):
self.title = title
self.description = description
self.version = version
self.servers = servers
self.openapi_url = openapi_url
self.openapi_tags = openapi_tags
# TODO: remove when discarding the openapi_prefix parameter
@@ -106,6 +108,7 @@ class FastAPI(Starlette):
routes=self.routes,
openapi_prefix=openapi_prefix,
tags=self.openapi_tags,
servers=self.servers,
)
return self.openapi_schema

View File

@@ -500,6 +500,7 @@ async def solve_dependencies(
name=sub_dependant.name,
security_scopes=sub_dependant.security_scopes,
)
use_sub_dependant.security_scopes = sub_dependant.security_scopes
solved_result = await solve_dependencies(
request=request,
@@ -641,9 +642,17 @@ async def request_body_to_args(
field = required_params[0]
field_info = get_field_info(field)
embed = getattr(field_info, "embed", None)
if len(required_params) == 1 and not embed:
field_alias_omitted = len(required_params) == 1 and not embed
if field_alias_omitted:
received_body = {field.alias: received_body}
for field in required_params:
loc: Tuple[str, ...]
if field_alias_omitted:
loc = ("body",)
else:
loc = ("body", field.alias)
value: Any = None
if received_body is not None:
if (
@@ -654,7 +663,7 @@ async def request_body_to_args(
try:
value = received_body.get(field.alias)
except AttributeError:
errors.append(get_missing_field_error(field.alias))
errors.append(get_missing_field_error(loc))
continue
if (
value is None
@@ -666,7 +675,7 @@ async def request_body_to_args(
)
):
if field.required:
errors.append(get_missing_field_error(field.alias))
errors.append(get_missing_field_error(loc))
else:
values[field.name] = deepcopy(field.default)
continue
@@ -685,7 +694,9 @@ async def request_body_to_args(
awaitables = [sub_value.read() for sub_value in value]
contents = await asyncio.gather(*awaitables)
value = sequence_shape_to_type[field.shape](contents)
v_, errors_ = field.validate(value, values, loc=("body", field.alias))
v_, errors_ = field.validate(value, values, loc=loc)
if isinstance(errors_, ErrorWrapper):
errors.append(errors_)
elif isinstance(errors_, list):
@@ -695,12 +706,12 @@ async def request_body_to_args(
return values, errors
def get_missing_field_error(field_alias: str) -> ErrorWrapper:
def get_missing_field_error(loc: Tuple[str, ...]) -> ErrorWrapper:
if PYDANTIC_1:
missing_field_error = ErrorWrapper(MissingError(), loc=("body", field_alias))
missing_field_error = ErrorWrapper(MissingError(), loc=loc)
else: # pragma: no cover
missing_field_error = ErrorWrapper( # type: ignore
MissingError(), loc=("body", field_alias), config=BaseConfig,
MissingError(), loc=loc, config=BaseConfig,
)
return missing_field_error

View File

@@ -63,7 +63,7 @@ class ServerVariable(BaseModel):
class Server(BaseModel):
url: AnyUrl
url: Union[AnyUrl, str]
description: Optional[str] = None
variables: Optional[Dict[str, ServerVariable]] = None
@@ -112,7 +112,7 @@ class SchemaBase(BaseModel):
allOf: Optional[List[Any]] = None
oneOf: Optional[List[Any]] = None
anyOf: Optional[List[Any]] = None
not_: Optional[List[Any]] = Field(None, alias="not")
not_: Optional[Any] = Field(None, alias="not")
items: Optional[Any] = None
properties: Optional[Dict[str, Any]] = None
additionalProperties: Optional[Union[Dict[str, Any], bool]] = None
@@ -133,7 +133,7 @@ class Schema(SchemaBase):
allOf: Optional[List[SchemaBase]] = None
oneOf: Optional[List[SchemaBase]] = None
anyOf: Optional[List[SchemaBase]] = None
not_: Optional[List[SchemaBase]] = Field(None, alias="not")
not_: Optional[SchemaBase] = Field(None, alias="not")
items: Optional[SchemaBase] = None
properties: Optional[Dict[str, SchemaBase]] = None
additionalProperties: Optional[Union[Dict[str, Any], bool]] = None

View File

@@ -14,6 +14,7 @@ from fastapi.openapi.constants import (
from fastapi.openapi.models import OpenAPI
from fastapi.params import Body, Param
from fastapi.utils import (
deep_dict_update,
generate_operation_id_for_path,
get_field_info,
get_model_definitions,
@@ -86,7 +87,7 @@ def get_openapi_security_definitions(flat_dependant: Dependant) -> Tuple[Dict, L
def get_openapi_operation_parameters(
*,
all_route_params: Sequence[ModelField],
model_name_map: Dict[Union[Type[BaseModel], Type[Enum]], str]
model_name_map: Dict[Union[Type[BaseModel], Type[Enum]], str],
) -> List[Dict[str, Any]]:
parameters = []
for param in all_route_params:
@@ -112,7 +113,7 @@ def get_openapi_operation_parameters(
def get_openapi_operation_request_body(
*,
body_field: Optional[ModelField],
model_name_map: Dict[Union[Type[BaseModel], Type[Enum]], str]
model_name_map: Dict[Union[Type[BaseModel], Type[Enum]], str],
) -> Optional[Dict]:
if not body_field:
return None
@@ -201,33 +202,6 @@ def get_openapi_path(
)
callbacks[callback.name] = {callback.path: cb_path}
operation["callbacks"] = callbacks
if route.responses:
for (additional_status_code, response) in route.responses.items():
process_response = response.copy()
assert isinstance(
process_response, dict
), "An additional response must be a dict"
field = route.response_fields.get(additional_status_code)
if field:
response_schema, _, _ = field_schema(
field, model_name_map=model_name_map, ref_prefix=REF_PREFIX
)
process_response.setdefault("content", {}).setdefault(
route_response_media_type or "application/json", {}
)["schema"] = response_schema
status_text: Optional[str] = status_code_ranges.get(
str(additional_status_code).upper()
) or http.client.responses.get(int(additional_status_code))
process_response.setdefault(
"description", status_text or "Additional Response"
)
status_code_key = str(additional_status_code).upper()
if status_code_key == "DEFAULT":
status_code_key = "default"
process_response.pop("model", None)
operation.setdefault("responses", {})[
status_code_key
] = process_response
status_code = str(route.status_code)
operation.setdefault("responses", {}).setdefault(status_code, {})[
"description"
@@ -251,7 +225,47 @@ def get_openapi_path(
).setdefault("content", {}).setdefault(route_response_media_type, {})[
"schema"
] = response_schema
if route.responses:
operation_responses = operation.setdefault("responses", {})
for (
additional_status_code,
additional_response,
) in route.responses.items():
process_response = additional_response.copy()
process_response.pop("model", None)
status_code_key = str(additional_status_code).upper()
if status_code_key == "DEFAULT":
status_code_key = "default"
openapi_response = operation_responses.setdefault(
status_code_key, {}
)
assert isinstance(
process_response, dict
), "An additional response must be a dict"
field = route.response_fields.get(additional_status_code)
additional_field_schema: Optional[Dict[str, Any]] = None
if field:
additional_field_schema, _, _ = field_schema(
field, model_name_map=model_name_map, ref_prefix=REF_PREFIX
)
media_type = route_response_media_type or "application/json"
additional_schema = (
process_response.setdefault("content", {})
.setdefault(media_type, {})
.setdefault("schema", {})
)
deep_dict_update(additional_schema, additional_field_schema)
status_text: Optional[str] = status_code_ranges.get(
str(additional_status_code).upper()
) or http.client.responses.get(int(additional_status_code))
description = (
process_response.get("description")
or openapi_response.get("description")
or status_text
or "Additional Response"
)
deep_dict_update(openapi_response, process_response)
openapi_response["description"] = description
http422 = str(HTTP_422_UNPROCESSABLE_ENTITY)
if (all_route_params or route.body_field) and not any(
[
@@ -318,12 +332,15 @@ def get_openapi(
description: str = None,
routes: Sequence[BaseRoute],
openapi_prefix: str = "",
tags: Optional[List[Dict[str, Any]]] = None
tags: Optional[List[Dict[str, Any]]] = None,
servers: Optional[List[Dict[str, Union[str, Any]]]] = None,
) -> Dict:
info = {"title": title, "version": version}
if description:
info["description"] = description
output: Dict[str, Any] = {"openapi": openapi_version, "info": info}
if servers:
output["servers"] = servers
components: Dict[str, Dict] = {}
paths: Dict[str, Dict] = {}
flat_models = get_flat_models_from_routes(routes)

View File

@@ -172,3 +172,15 @@ def generate_operation_id_for_path(*, name: str, path: str, method: str) -> str:
operation_id = re.sub("[^0-9a-zA-Z_]", "_", operation_id)
operation_id = operation_id + "_" + method.lower()
return operation_id
def deep_dict_update(main_dict: dict, update_dict: dict) -> None:
for key in update_dict:
if (
key in main_dict
and isinstance(main_dict[key], dict)
and isinstance(update_dict[key], dict)
):
deep_dict_update(main_dict[key], update_dict[key])
else:
main_dict[key] = update_dict[key]

View File

@@ -0,0 +1,65 @@
from typing import List, Tuple
from fastapi import Depends, FastAPI, Security
from fastapi.security import SecurityScopes
from fastapi.testclient import TestClient
app = FastAPI()
def get_user(required_scopes: SecurityScopes):
return "john", required_scopes.scopes
def get_user_override(required_scopes: SecurityScopes):
return "alice", required_scopes.scopes
def get_data():
return [1, 2, 3]
def get_data_override():
return [3, 4, 5]
@app.get("/user")
def read_user(
user_data: Tuple[str, List[str]] = Security(get_user, scopes=["foo", "bar"]),
data: List[int] = Depends(get_data),
):
return {"user": user_data[0], "scopes": user_data[1], "data": data}
client = TestClient(app)
def test_normal():
response = client.get("/user")
assert response.json() == {
"user": "john",
"scopes": ["foo", "bar"],
"data": [1, 2, 3],
}
def test_override_data():
app.dependency_overrides[get_data] = get_data_override
response = client.get("/user")
assert response.json() == {
"user": "john",
"scopes": ["foo", "bar"],
"data": [3, 4, 5],
}
app.dependency_overrides = {}
def test_override_security():
app.dependency_overrides[get_user] = get_user_override
response = client.get("/user")
assert response.json() == {
"user": "alice",
"scopes": ["foo", "bar"],
"data": [1, 2, 3],
}
app.dependency_overrides = {}

View File

@@ -104,7 +104,7 @@ single_error = {
"detail": [
{
"ctx": {"limit_value": 0.0},
"loc": ["body", "item", 0, "age"],
"loc": ["body", 0, "age"],
"msg": "ensure this value is greater than 0",
"type": "value_error.number.not_gt",
}
@@ -114,22 +114,22 @@ single_error = {
multiple_errors = {
"detail": [
{
"loc": ["body", "item", 0, "name"],
"loc": ["body", 0, "name"],
"msg": "field required",
"type": "value_error.missing",
},
{
"loc": ["body", "item", 0, "age"],
"loc": ["body", 0, "age"],
"msg": "value is not a valid decimal",
"type": "type_error.decimal",
},
{
"loc": ["body", "item", 1, "name"],
"loc": ["body", 1, "name"],
"msg": "field required",
"type": "value_error.missing",
},
{
"loc": ["body", "item", 1, "age"],
"loc": ["body", 1, "age"],
"msg": "value is not a valid decimal",
"type": "type_error.decimal",
},

View File

@@ -0,0 +1,60 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI(
servers=[
{"url": "/", "description": "Default, relative server"},
{
"url": "http://staging.localhost.tiangolo.com:8000",
"description": "Staging but actually localhost still",
},
{"url": "https://prod.example.com"},
]
)
@app.get("/foo")
def foo():
return {"message": "Hello World"}
client = TestClient(app)
openapi_schema = {
"openapi": "3.0.2",
"info": {"title": "FastAPI", "version": "0.1.0"},
"servers": [
{"url": "/", "description": "Default, relative server"},
{
"url": "http://staging.localhost.tiangolo.com:8000",
"description": "Staging but actually localhost still",
},
{"url": "https://prod.example.com"},
],
"paths": {
"/foo": {
"get": {
"summary": "Foo",
"operationId": "foo_foo_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
}
},
}
}
},
}
def test_openapi_servers():
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
assert response.json() == openapi_schema
def test_app():
response = client.get("/foo")
assert response.status_code == 200, response.text

View File

@@ -15,7 +15,7 @@ openapi_schema = {
"get": {
"responses": {
"200": {
"description": "Successful Response",
"description": "Return the JSON item or an image.",
"content": {
"image/png": {},
"application/json": {

View File

@@ -20,7 +20,7 @@ openapi_schema = {
},
},
"200": {
"description": "Successful Response",
"description": "Item requested by ID",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Item"},

View File

@@ -94,7 +94,7 @@ def test_openapi_schema():
price_missing = {
"detail": [
{
"loc": ["body", "item", "price"],
"loc": ["body", "price"],
"msg": "field required",
"type": "value_error.missing",
}
@@ -104,7 +104,7 @@ price_missing = {
price_not_float = {
"detail": [
{
"loc": ["body", "item", "price"],
"loc": ["body", "price"],
"msg": "value is not a valid float",
"type": "type_error.float",
}
@@ -114,12 +114,12 @@ price_not_float = {
name_price_missing = {
"detail": [
{
"loc": ["body", "item", "name"],
"loc": ["body", "name"],
"msg": "field required",
"type": "value_error.missing",
},
{
"loc": ["body", "item", "price"],
"loc": ["body", "price"],
"msg": "field required",
"type": "value_error.missing",
},
@@ -128,11 +128,7 @@ name_price_missing = {
body_missing = {
"detail": [
{
"loc": ["body", "item"],
"msg": "field required",
"type": "value_error.missing",
}
{"loc": ["body"], "msg": "field required", "type": "value_error.missing",}
]
}

View File

@@ -95,7 +95,7 @@ def test_post_invalid_body():
assert response.json() == {
"detail": [
{
"loc": ["body", "weights", "__key__"],
"loc": ["body", "__key__"],
"msg": "value is not a valid integer",
"type": "type_error.integer",
}

View File

@@ -18,7 +18,7 @@ def test_exception_handler_body_access():
"body": '{"numbers": [1, 2, 3]}',
"errors": [
{
"loc": ["body", "numbers"],
"loc": ["body"],
"msg": "value is not a valid list",
"type": "type_error.list",
}

View File

@@ -92,7 +92,7 @@ def test_post_validation_error():
assert response.json() == {
"detail": [
{
"loc": ["body", "item", "size"],
"loc": ["body", "size"],
"msg": "value is not a valid integer",
"type": "type_error.integer",
}