Compare commits

...

63 Commits

Author SHA1 Message Date
Sebastián Ramírez
53da56146e 🔖 Release version 0.37.0 2019-08-30 19:10:43 -05:00
Sebastián Ramírez
3799b9027e 📝 Update release notes 2019-08-30 19:09:12 -05:00
dmontagu
c70f3f1198 Add support for custom route class (#468) 2019-08-30 19:05:59 -05:00
Sebastián Ramírez
58dddc5e4f 📝 Update release notes 2019-08-30 19:02:29 -05:00
b1-luettje
c90c4fb6c1 Allow disabling Google fonts in ReDoc (#481) 2019-08-30 19:00:55 -05:00
Sebastián Ramírez
5b3df28f0c 📝 Update release notes 2019-08-30 18:59:08 -05:00
dmontagu
6c6bdb6233 🔒 Ensure skip_defaults doesn't cause extra fields to be serialized (#485) 2019-08-30 18:56:14 -05:00
Sebastián Ramírez
f156f45193 📝 Update release notes 2019-08-30 18:37:42 -05:00
Aliaksei Urbanski
f24d744a3b Enable tests for Python 3.8-dev (#465) 2019-08-30 18:34:49 -05:00
Sebastián Ramírez
937b462cdd 📝 Update release notes 2019-08-30 18:15:08 -05:00
dconathan
3025a368c6 Add support and tests for Pydantic dataclasses in response_model (#454) 2019-08-30 18:12:15 -05:00
Sebastián Ramírez
c218e0d560 📝 Update release notes 2019-08-30 17:40:14 -05:00
Pablo Marti
1ed5aa23e6 ✏️ Fix typo in oauth2-jwt.md (#447) 2019-08-30 17:35:52 -05:00
Sebastián Ramírez
106d2171d8 📝 Update release notes 2019-08-30 17:34:45 -05:00
Zoltan Papp
c5817912d2 🐛 use media_type from Body params for OpenAPI requestBody (Fixes: #431) (#439) 2019-08-30 17:32:39 -05:00
Sebastián Ramírez
a7a92bc637 📝 Update release notes 2019-08-30 17:02:40 -05:00
naxty
68d1fea961 📝 Add article: Deploying a scikit-learn model with ONNX and FastAPI (#438) 2019-08-30 17:00:00 -05:00
Sebastián Ramírez
8c6b2d5804 📝 Update release notes 2019-08-30 16:48:53 -05:00
Zoltan Papp
19c53b21c1 Allow using custom 422 validation error and use media type from response class in schema (#437)
* media_type of additional responses from the response_class

* Use HTTPValidationError only if a custom one is not defined (Fixes: #429)
2019-08-30 16:46:05 -05:00
Sebastián Ramírez
44d63cd555 📝 Update release notes 2019-08-30 16:36:18 -05:00
Sebastián Ramírez
55c4b5fb0b 🐛 Fix "default" extra response with extra status codes (#489)
* 🐛 Fix lowercase "default" extra response

* 🐛 Fix model for responses, to allow "default" plus status codes

*  Add test for "default" extra response
2019-08-30 16:34:47 -05:00
Sebastián Ramírez
c32e800c23 📝 Update release notes 2019-08-30 11:30:52 -05:00
Zoltan Papp
73dbbeab55 Allow additional responses to use status ranges and "default" (#435) 2019-08-30 11:17:42 -05:00
Sebastián Ramírez
417a3ab140 🔖 Release 0.36.0 2019-08-26 08:28:33 -05:00
Sebastián Ramírez
a3235ed8de 📝 Update release notes 2019-08-26 08:27:31 -05:00
dmontagu
38495fffa5 🐛 Fix skip_defaults implementation when returning a Pydantic model (#422) 2019-08-26 08:24:58 -05:00
Sebastián Ramírez
b77a43bcac 📝 Update release notes 2019-08-24 22:08:10 -05:00
dmontagu
483eb73b26 🐛 Use caching logic to determine OpenAPI spec for duplicate dependencies (#417) 2019-08-24 21:55:25 -05:00
Sebastián Ramírez
51a928d3f5 📝 Update release notes 2019-08-24 20:08:04 -05:00
Sebastián Ramírez
e71636e381 🐛 Fix mypy route errors after merging #415 (#462) 2019-08-24 20:05:44 -05:00
Vitaliy Kucheryaviy
f7f17fcfd6 Allow empty routed path (issue #414) (#415) 2019-08-24 19:39:48 -05:00
Sebastián Ramírez
033bc2a6c9 🔖 Release 0.35.0 2019-08-07 14:12:15 -05:00
Sebastián Ramírez
28d3b9f783 📝 Update release notes 2019-08-07 14:09:50 -05:00
Pablo Marti
0c55553328 ✏️ Fix typo in assert statement (#419) 2019-08-07 14:03:11 -05:00
Bronsen
b66056aa34 📝 Fix plural-s without apostrophe in docs (#411) 2019-08-07 14:01:31 -05:00
Sebastián Ramírez
4f10b8b98d 📝 Update release notes 2019-08-07 13:57:41 -05:00
Koudai Aono
06eb421934 Fix request body parsing with Union (#400) 2019-08-07 13:55:33 -05:00
Sebastián Ramírez
bf229ad5d8 🔖 Release 0.34.0 upgrading Starlette 2019-08-06 07:22:06 -05:00
Sebastián Ramírez
d0319001be 📝 Update Release Notes 2019-08-06 07:13:24 -05:00
David De Sousa
c4682af13d ⬆️ Upgrade Starlette max range to 0.12.7 (#367) 2019-08-06 07:10:29 -05:00
Sebastián Ramírez
6ca3ce80e4 📝 Update release notes 2019-07-12 19:15:21 -05:00
Sebastián Ramírez
25e85c8522 Add test from @dmontagu in #333 for duplicate models (#385) 2019-07-12 19:13:28 -05:00
Sebastián Ramírez
6bf3ab3b7a 🔖 Release 0.33.0, including Pydantic 0.30.0 2019-07-12 19:01:27 -05:00
Sebastián Ramírez
f5ea5eef2a 📝 Update release notes 2019-07-12 18:58:09 -05:00
James Kaplan
46a986cacf ⬆️ Upgrade Pydantic to 0.30 (#384)
* bump pydantic to 0.30

* 📌 Pin Pydantic to 0.30 as 0.31 hasn't been released
2019-07-12 18:56:25 -05:00
Sebastián Ramírez
e620aeb46d 🔖 Release 0.32.0, as PR ##347 might be a breaking change
in some specific cases
2019-07-12 18:32:30 -05:00
Sebastián Ramírez
d1e2e46b80 🔖 Release 0.31.1 2019-07-12 18:30:54 -05:00
Sebastián Ramírez
b1c4a8acd5 📝 Update release notes 2019-07-12 18:29:49 -05:00
Martino Mensio
362e2cdc79 📝 Fix small typo in docs for features (#380) 2019-07-12 18:28:07 -05:00
Sebastián Ramírez
93e6a08acd 📝 Update release notes 2019-07-12 18:25:04 -05:00
Ben Williams
3ec4342282 📝 Change limit default parameter to 10 in Query docs (#366)
Rest of docs reference 10 as the default.
2019-07-12 18:22:21 -05:00
Sebastián Ramírez
dc483478eb 📝 Update release notes 2019-07-12 18:20:02 -05:00
Chris Withers
bdd251a05b 📝 Tweak wording on OAuth2 scopes (#371) 2019-07-12 18:17:34 -05:00
Sebastián Ramírez
195559ccba 📝 Update release notes 2019-06-28 21:29:29 +02:00
Sebastián Ramírez
9a71672a95 📝 Update enum examples to use str, and improve Swagger UI in examples (#351) 2019-06-28 21:27:27 +02:00
Sebastián Ramírez
7e48be1561 📝 Update release notes 2019-06-28 20:57:14 +02:00
Sebastián Ramírez
508f9ce954 🐛 Fix regression, Swagger UI with deep linking (#350) 2019-06-28 20:56:48 +02:00
Sebastián Ramírez
afbdf2546f 📝 Update release notes 2019-06-28 20:16:53 +02:00
Sebastián Ramírez
62df417807 Add test for templates in include_router path (#349) 2019-06-28 20:15:17 +02:00
Sebastián Ramírez
09d2747a70 📝 Update release notes 2019-06-28 20:00:24 +02:00
Sebastián Ramírez
d3ea6f7514 📝 Add note to docs about including same router multiple times (#348) 2019-06-28 19:54:49 +02:00
Sebastián Ramírez
02187636ea 📝 Update release notes 2019-06-28 19:40:31 +02:00
Sebastián Ramírez
687065509b 🏗️ Fix same function names in different modules with composite bodies (#347)
* 🏗️ Implement unique IDs for dynamic models

like those used for composite bodies and responses. IDs based on path (not only on function name, as it can be duplicated in a different module).

*  Add tests for same function name and composite body

*  Update OpenAPI in tests with new dynamic model ID generation
2019-06-28 19:35:16 +02:00
61 changed files with 1578 additions and 146 deletions

View File

@@ -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

View File

@@ -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 = "*"

View File

@@ -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.

View File

@@ -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">FastAPIDB接続してCRUDするPython製APIサーバーを構築</a> by <a href="https://qiita.com/mtitg" target="_blank">@mtitg</a>.

View File

@@ -37,7 +37,7 @@ from datetime import date
from pydantic import BaseModel
# Declare a variable as an str
# Declare a variable as a str
# and get editor support inside the function
def main(user_id: str):
return user_id

View File

@@ -1,5 +1,72 @@
## 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).
* Fix source code `limit` for example in [Query Parameters](https://fastapi.tiangolo.com/tutorial/query-params/). PR [#366](https://github.com/tiangolo/fastapi/pull/366) by [@Smashman](https://github.com/Smashman).
* Update wording in docs about [OAuth2 scopes](https://fastapi.tiangolo.com/tutorial/security/oauth2-scopes/). PR [#371](https://github.com/tiangolo/fastapi/pull/371) by [@cjw296](https://github.com/cjw296).
* Update docs for `Enum`s to inherit from `str` and improve Swagger UI rendering. PR [#351](https://github.com/tiangolo/fastapi/pull/351).
* Fix regression, add Swagger UI deep linking again. PR [#350](https://github.com/tiangolo/fastapi/pull/350).
* Add test for having path templates in `prefix` of `.include_router`. PR [#349](https://github.com/tiangolo/fastapi/pull/349).
* Add note to docs: [Include the same router multiple times with different `prefix`](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-the-same-router-multiple-times-with-different-prefix). PR [#348](https://github.com/tiangolo/fastapi/pull/348).
* Fix OpenAPI/JSON Schema generation for two functions with the same name (in different modules) with the same composite bodies.
* Composite bodies' IDs are now based on path, not only on route name, as the auto-generated name uses the function names, that can be duplicated in different modules.
* The same new ID generation applies to response models.
* This also changes the generated title for those models.
* Only composite bodies and response models are affected because those are generated dynamically, they don't have a module (a Python file).
* This also adds the possibility of using `.include_router()` with the same `APIRouter` *multiple* times, with different prefixes, e.g. `/api/v2` and `/api/latest`, and it will now work correctly.
* PR [#347](https://github.com/tiangolo/fastapi/pull/347).
## 0.31.0
* Upgrade Pydantic supported version to `0.29.0`.

View File

@@ -3,7 +3,7 @@ from enum import Enum
from fastapi import FastAPI
class ModelName(Enum):
class ModelName(str, Enum):
alexnet = "alexnet"
resnet = "resnet"
lenet = "lenet"

View File

@@ -6,5 +6,5 @@ fake_items_db = [{"item_name": "Foo"}, {"item_name": "Bar"}, {"item_name": "Baz"
@app.get("/items/")
async def read_item(skip: int = 0, limit: int = 100):
async def read_item(skip: int = 0, limit: int = 10):
return fake_items_db[skip : skip + limit]

View File

@@ -174,7 +174,6 @@ from app.routers import items, users
To learn more about Python Packages and Modules, read <a href="https://docs.python.org/3/tutorial/modules.html" target="_blank">the official Python documentation about Modules</a>.
### Avoid name collisions
We are importing the submodule `items` directly, instead of importing just its variable `router`.
@@ -216,7 +215,6 @@ It will include all the routes from that router as part of it.
So, behind the scenes, it will actually work as if everything was the same single app.
!!! check
You don't have to worry about performance when including routers.
@@ -295,7 +293,6 @@ The end result is that the item paths are now:
As we cannot just isolate them and "mount" them independently of the rest, the path operations are "cloned" (re-created), not included directly.
## Check the automatic API docs
Now, run `uvicorn`, using the module `app.main` and the variable `app`:
@@ -309,3 +306,11 @@ And open the docs at <a href="http://127.0.0.1:8000/docs" target="_blank">http:/
You will see the automatic API docs, including the paths from all the submodules, using the correct paths (and prefixes) and the correct tags:
<img src="/img/tutorial/bigger-applications/image01.png">
## Include the same router multiple times with different `prefix`
You can also use `.include_router()` multiple times with the *same* router using different prefixes.
This could be useful, for example, to expose the same API under different prefixes, e.g. `/api/v1` and `/api/latest`.
This is an advanced usage that you might not really need, but it's there in case you do.

View File

@@ -119,7 +119,9 @@ If you have a *path operation* that receives a *path parameter*, but you want th
### Create an `Enum` class
Import `Enum` and create a sub-class that inherits from it.
Import `Enum` and create a sub-class that inherits from `str` and from `Enum`.
By inheriting from `str` the API docs will be able to know that the values must be of type `string` and will be able to render correctly.
And create class attributes with fixed values, those fixed values will be the available valid values:

View File

@@ -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`).

View File

@@ -176,9 +176,9 @@ For this, we use `security_scopes.scopes`, that contains a `list` with all these
Let's review again this dependency tree and the scopes.
As the other dependency `get_current_active_user` has as a sub-dependency this `get_current_user`, the scope `"me"` declared at `get_current_active_user` will be included in the `security_scopes.scopes` `list` inside of `get_current_user`.
As the `get_current_active_user` dependency has as a sub-dependency on `get_current_user`, the scope `"me"` declared at `get_current_active_user` will be included in the list of required scopes in the `security_scopes.scopes` passed to `get_current_user`.
And as the *path operation* itself also declares a scope `"items"`, it will also be part of this `list` `security_scopes.scopes` in `get_current_user`.
The *path operation* itself also declares a scope, `"items"`, so this will also be in the list of `security_scopes.scopes` passed to `get_current_user`.
Here's how the hierarchy of dependencies and scopes looks like:

View File

@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.31.0"
__version__ = "0.37.0"
from starlette.background import BackgroundTasks

View File

@@ -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

View File

@@ -40,7 +40,8 @@ def get_swagger_ui_html(
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
layout: "BaseLayout",
deepLinking: true
})
</script>
</body>
@@ -55,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>
@@ -64,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

View File

@@ -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

View File

@@ -8,7 +8,11 @@ from fastapi.encoders import jsonable_encoder
from fastapi.openapi.constants import METHODS_WITH_BODY, REF_PREFIX
from fastapi.openapi.models import OpenAPI
from fastapi.params import Body, Param
from fastapi.utils import get_flat_models_from_routes, get_model_definitions
from fastapi.utils import (
generate_operation_id_for_path,
get_flat_models_from_routes,
get_model_definitions,
)
from pydantic.fields import Field
from pydantic.schema import field_schema, get_model_name_map
from pydantic.utils import lenient_issubclass
@@ -39,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
@@ -67,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,
@@ -87,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(
@@ -96,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)
@@ -113,10 +122,7 @@ def generate_operation_id(*, route: routing.APIRoute, method: str) -> str:
if route.operation_id:
return route.operation_id
path: str = route.path_format
operation_id = route.name + path
operation_id = operation_id.replace("{", "_").replace("}", "_").replace("/", "_")
operation_id = operation_id + "_" + method.lower()
return operation_id
return generate_operation_id_for_path(name=route.name, path=path, method=method)
def generate_operation_summary(*, route: routing.APIRoute, method: str) -> str:
@@ -149,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
)
@@ -158,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
@@ -171,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(
@@ -183,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,
@@ -215,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": {
@@ -224,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

View File

@@ -13,7 +13,7 @@ from fastapi.dependencies.utils import (
)
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
from fastapi.utils import create_cloned_field
from fastapi.utils import create_cloned_field, generate_operation_id_for_path
from pydantic import BaseConfig, BaseModel, Schema
from pydantic.error_wrappers import ErrorWrapper, ValidationError
from pydantic.fields import Field
@@ -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,16 +203,22 @@ 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
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
if methods is None:
methods = ["GET"]
self.methods = set([method.upper() for method in methods])
self.unique_id = generate_operation_id_for_path(
name=self.name, path=self.path_format, method=list(methods)[0]
)
self.response_model = response_model
if self.response_model:
assert lenient_issubclass(
response_class, JSONResponse
), "To declare a type the response must be a JSON response"
response_name = "Response_" + self.name
response_name = "Response_" + self.unique_id
self.response_field: Optional[Field] = Field(
name=response_name,
type_=self.response_model,
@@ -251,7 +259,7 @@ class APIRoute(routing.Route):
assert lenient_issubclass(
model, BaseModel
), "A response model must be a Pydantic model"
response_name = f"Response_{additional_status_code}_{self.name}"
response_name = f"Response_{additional_status_code}_{self.unique_id}"
response_field = Field(
name=response_name,
type_=model,
@@ -267,9 +275,6 @@ class APIRoute(routing.Route):
else:
self.response_fields = {}
self.deprecated = deprecated
if methods is None:
methods = ["GET"]
self.methods = set([method.upper() for method in methods])
self.operation_id = operation_id
self.response_model_include = response_model_include
self.response_model_exclude = response_model_exclude
@@ -278,7 +283,6 @@ class APIRoute(routing.Route):
self.include_in_schema = include_in_schema
self.response_class = response_class
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
assert inspect.isfunction(endpoint) or inspect.ismethod(
endpoint
), f"An endpoint must be a function or method"
@@ -288,7 +292,7 @@ class APIRoute(routing.Route):
0,
get_parameterless_sub_dependant(depends=depends, path=self.path_format),
)
self.body_field = get_body_field(dependant=self.dependant, name=self.name)
self.body_field = get_body_field(dependant=self.dependant, name=self.unique_id)
self.dependency_overrides_provider = dependency_overrides_provider
self.app = request_response(
get_app(
@@ -313,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,
@@ -343,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,
@@ -445,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:

View File

@@ -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)
@@ -93,3 +96,10 @@ def create_cloned_field(field: Field) -> Field:
new_field.shape = field.shape
new_field._populate_validators()
return new_field
def generate_operation_id_for_path(*, name: str, path: str, method: str) -> str:
operation_id = name + path
operation_id = operation_id.replace("{", "_").replace("}", "_").replace("/", "_")
operation_id = operation_id + "_" + method.lower()
return operation_id

View File

@@ -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"

View 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")

View 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

View 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

View 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

View File

@@ -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",

View 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)

View 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)

View File

@@ -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

View File

View File

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter, Body
router = APIRouter()
@router.post("/compute")
def compute(a: int = Body(...), b: str = Body(...)):
return {"a": a, "b": b}

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter, Body
router = APIRouter()
@router.post("/compute/")
def compute(a: int = Body(...), b: str = Body(...)):
return {"a": a, "b": b}

View File

@@ -0,0 +1,8 @@
from fastapi import FastAPI
from . import a, b
app = FastAPI()
app.include_router(a.router, prefix="/a")
app.include_router(b.router, prefix="/b")

View File

@@ -0,0 +1,155 @@
from starlette.testclient import TestClient
from .app.main import app
client = TestClient(app)
openapi_schema = {
"openapi": "3.0.2",
"info": {"title": "Fast API", "version": "0.1.0"},
"paths": {
"/a/compute": {
"post": {
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
"summary": "Compute",
"operationId": "compute_a_compute_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_compute_a_compute_post"
}
}
},
"required": True,
},
}
},
"/b/compute/": {
"post": {
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
"summary": "Compute",
"operationId": "compute_b_compute__post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_compute_b_compute__post"
}
}
},
"required": True,
},
}
},
},
"components": {
"schemas": {
"Body_compute_b_compute__post": {
"title": "Body_compute_b_compute__post",
"required": ["a", "b"],
"type": "object",
"properties": {
"a": {"title": "A", "type": "integer"},
"b": {"title": "B", "type": "string"},
},
},
"Body_compute_a_compute_post": {
"title": "Body_compute_a_compute_post",
"required": ["a", "b"],
"type": "object",
"properties": {
"a": {"title": "A", "type": "integer"},
"b": {"title": "B", "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_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200
assert response.json() == openapi_schema
def test_post_a():
data = {"a": 2, "b": "foo"}
response = client.post("/a/compute", json=data)
assert response.status_code == 200
data = response.json()
def test_post_a_invalid():
data = {"a": "bar", "b": "foo"}
response = client.post("/a/compute", json=data)
assert response.status_code == 422
def test_post_b():
data = {"a": 2, "b": "foo"}
response = client.post("/b/compute/", json=data)
assert response.status_code == 200
data = response.json()
def test_post_b_invalid():
data = {"a": "bar", "b": "foo"}
response = client.post("/b/compute/", json=data)
assert response.status_code == 422

View 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"}

View 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
)

View File

@@ -0,0 +1,23 @@
from fastapi import APIRouter, FastAPI
from starlette.testclient import TestClient
app = FastAPI()
router = APIRouter()
@router.get("/users/{id}")
def read_user(segment: str, id: str):
return {"segment": segment, "id": id}
app.include_router(router, prefix="/{segment}")
client = TestClient(app)
def test_get():
response = client.get("/seg/users/foo")
assert response.status_code == 200
assert response.json() == {"segment": "seg", "id": "foo"}

View File

@@ -66,7 +66,7 @@ openapi_schema = {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_read_current_user"
"$ref": "#/components/schemas/Body_read_current_user_login_post"
}
}
},
@@ -90,8 +90,8 @@ openapi_schema = {
},
"components": {
"schemas": {
"Body_read_current_user": {
"title": "Body_read_current_user",
"Body_read_current_user_login_post": {
"title": "Body_read_current_user_login_post",
"required": ["grant_type", "username", "password"],
"type": "object",
"properties": {

View File

@@ -73,7 +73,7 @@ openapi_schema = {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_read_current_user"
"$ref": "#/components/schemas/Body_read_current_user_login_post"
}
}
},
@@ -97,8 +97,8 @@ openapi_schema = {
},
"components": {
"schemas": {
"Body_read_current_user": {
"title": "Body_read_current_user",
"Body_read_current_user_login_post": {
"title": "Body_read_current_user_login_post",
"required": ["grant_type", "username", "password"],
"type": "object",
"properties": {

View File

@@ -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]},
]

View 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]},
]

View 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": {}}

View File

@@ -14,7 +14,7 @@ openapi_schema = {
"content": {
"application/json": {
"schema": {
"title": "Response_Read_Notes",
"title": "Response_Read_Notes_Notes__Get",
"type": "array",
"items": {"$ref": "#/components/schemas/Note"},
}

View File

@@ -40,7 +40,9 @@ openapi_schema = {
"requestBody": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Body_update_item"}
"schema": {
"$ref": "#/components/schemas/Body_update_item_items__item_id__put"
}
}
},
"required": True,
@@ -70,8 +72,8 @@ openapi_schema = {
"full_name": {"title": "Full_Name", "type": "string"},
},
},
"Body_update_item": {
"title": "Body_update_item",
"Body_update_item_items__item_id__put": {
"title": "Body_update_item_items__item_id__put",
"required": ["item", "user", "importance"],
"type": "object",
"properties": {

View File

@@ -41,7 +41,9 @@ openapi_schema = {
"requestBody": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Body_update_item"}
"schema": {
"$ref": "#/components/schemas/Body_update_item_items__item_id__put"
}
}
},
"required": True,
@@ -71,8 +73,8 @@ openapi_schema = {
"tax": {"title": "Tax", "type": "number"},
},
},
"Body_update_item": {
"title": "Body_update_item",
"Body_update_item_items__item_id__put": {
"title": "Body_update_item_items__item_id__put",
"required": ["item"],
"type": "object",
"properties": {"item": {"$ref": "#/components/schemas/Item"}},

View File

@@ -44,7 +44,9 @@ openapi_schema = {
"requestBody": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Body_read_items"}
"schema": {
"$ref": "#/components/schemas/Body_read_items_items__item_id__put"
}
}
}
},
@@ -53,8 +55,8 @@ openapi_schema = {
},
"components": {
"schemas": {
"Body_read_items": {
"title": "Body_read_items",
"Body_read_items_items__item_id__put": {
"title": "Body_read_items_items__item_id__put",
"type": "object",
"properties": {
"start_datetime": {

View File

@@ -16,7 +16,7 @@ openapi_schema = {
"content": {
"application/json": {
"schema": {
"title": "Response_Read_Item",
"title": "Response_Read_Item_Items__Item_Id__Get",
"anyOf": [
{"$ref": "#/components/schemas/PlaneItem"},
{"$ref": "#/components/schemas/CarItem"},

View File

@@ -16,7 +16,7 @@ openapi_schema = {
"content": {
"application/json": {
"schema": {
"title": "Response_Read_Items",
"title": "Response_Read_Items_Items__Get",
"type": "array",
"items": {"$ref": "#/components/schemas/Item"},
}

View File

@@ -16,7 +16,7 @@ openapi_schema = {
"content": {
"application/json": {
"schema": {
"title": "Response_Read_Keyword_Weights",
"title": "Response_Read_Keyword_Weights_Keyword-Weights__Get",
"type": "object",
"additionalProperties": {"type": "number"},
}

View File

@@ -35,6 +35,7 @@ openapi_schema = {
"schema": {
"title": "Model_Name",
"enum": ["alexnet", "resnet", "lenet"],
"type": "string",
},
"name": "model_name",
"in": "path",

View File

@@ -33,7 +33,9 @@ openapi_schema = {
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {"$ref": "#/components/schemas/Body_create_file"}
"schema": {
"$ref": "#/components/schemas/Body_create_file_files__post"
}
}
},
"required": True,
@@ -64,7 +66,7 @@ openapi_schema = {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_create_upload_file"
"$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post"
}
}
},
@@ -75,16 +77,16 @@ openapi_schema = {
},
"components": {
"schemas": {
"Body_create_file": {
"title": "Body_create_file",
"Body_create_upload_file_uploadfile__post": {
"title": "Body_create_upload_file_uploadfile__post",
"required": ["file"],
"type": "object",
"properties": {
"file": {"title": "File", "type": "string", "format": "binary"}
},
},
"Body_create_upload_file": {
"title": "Body_create_upload_file",
"Body_create_file_files__post": {
"title": "Body_create_file_files__post",
"required": ["file"],
"type": "object",
"properties": {

View File

@@ -33,7 +33,9 @@ openapi_schema = {
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {"$ref": "#/components/schemas/Body_create_files"}
"schema": {
"$ref": "#/components/schemas/Body_create_files_files__post"
}
}
},
"required": True,
@@ -64,7 +66,7 @@ openapi_schema = {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_create_upload_files"
"$ref": "#/components/schemas/Body_create_upload_files_uploadfiles__post"
}
}
},
@@ -87,8 +89,8 @@ openapi_schema = {
},
"components": {
"schemas": {
"Body_create_files": {
"title": "Body_create_files",
"Body_create_upload_files_uploadfiles__post": {
"title": "Body_create_upload_files_uploadfiles__post",
"required": ["files"],
"type": "object",
"properties": {
@@ -99,8 +101,8 @@ openapi_schema = {
}
},
},
"Body_create_upload_files": {
"title": "Body_create_upload_files",
"Body_create_files_files__post": {
"title": "Body_create_files_files__post",
"required": ["files"],
"type": "object",
"properties": {

View File

@@ -32,7 +32,9 @@ openapi_schema = {
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {"$ref": "#/components/schemas/Body_login"}
"schema": {
"$ref": "#/components/schemas/Body_login_login__post"
}
}
},
"required": True,
@@ -42,8 +44,8 @@ openapi_schema = {
},
"components": {
"schemas": {
"Body_login": {
"title": "Body_login",
"Body_login_login__post": {
"title": "Body_login_login__post",
"required": ["username", "password"],
"type": "object",
"properties": {

View File

@@ -34,7 +34,9 @@ openapi_schema = {
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {"$ref": "#/components/schemas/Body_create_file"}
"schema": {
"$ref": "#/components/schemas/Body_create_file_files__post"
}
}
},
"required": True,
@@ -44,8 +46,8 @@ openapi_schema = {
},
"components": {
"schemas": {
"Body_create_file": {
"title": "Body_create_file",
"Body_create_file_files__post": {
"title": "Body_create_file_files__post",
"required": ["file", "fileb", "token"],
"type": "object",
"properties": {

View File

@@ -31,7 +31,9 @@ openapi_schema = {
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {"$ref": "#/components/schemas/Body_login"}
"schema": {
"$ref": "#/components/schemas/Body_login_token_post"
}
}
},
"required": True,
@@ -54,8 +56,8 @@ openapi_schema = {
},
"components": {
"schemas": {
"Body_login": {
"title": "Body_login",
"Body_login_token_post": {
"title": "Body_login_token_post",
"required": ["username", "password"],
"type": "object",
"properties": {

View File

@@ -42,7 +42,7 @@ openapi_schema = {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_login_for_access_token"
"$ref": "#/components/schemas/Body_login_for_access_token_token_post"
}
}
},
@@ -116,8 +116,8 @@ openapi_schema = {
"token_type": {"title": "Token_Type", "type": "string"},
},
},
"Body_login_for_access_token": {
"title": "Body_login_for_access_token",
"Body_login_for_access_token_token_post": {
"title": "Body_login_for_access_token_token_post",
"required": ["username", "password"],
"type": "object",
"properties": {
@@ -177,6 +177,12 @@ openapi_schema = {
}
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200
assert response.json() == openapi_schema
def get_access_token(username="johndoe", password="secret", scope=None):
data = {"username": username, "password": password}
if scope:
@@ -187,12 +193,6 @@ def get_access_token(username="johndoe", password="secret", scope=None):
return access_token
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200
assert response.json() == openapi_schema
def test_login():
response = client.post("/token", data={"username": "johndoe", "password": "secret"})
assert response.status_code == 200

View File

@@ -16,7 +16,7 @@ openapi_schema = {
"content": {
"application/json": {
"schema": {
"title": "Response_Read_Users",
"title": "Response_Read_Users_Users__Get",
"type": "array",
"items": {"$ref": "#/components/schemas/User"},
}
@@ -168,7 +168,7 @@ openapi_schema = {
"content": {
"application/json": {
"schema": {
"title": "Response_Read_Items",
"title": "Response_Read_Items_Items__Get",
"type": "array",
"items": {"$ref": "#/components/schemas/Item"},
}

124
tests/test_union_body.py Normal file
View 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"}}

View 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"}}

View 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")

View 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")