diff --git a/docs/en/docs/advanced/path-operation-advanced-configuration.md b/docs/en/docs/advanced/path-operation-advanced-configuration.md index 8d085d754..352fe0764 100644 --- a/docs/en/docs/advanced/path-operation-advanced-configuration.md +++ b/docs/en/docs/advanced/path-operation-advanced-configuration.md @@ -33,7 +33,7 @@ You should do it after adding all your *path operations*. ## Exclude from OpenAPI -To exclude a *path operation* from the generated OpenAPI schema (and thus, from the automatic documentation systems), use the parameter `include_in_schema` and set it to `False`; +To exclude a *path operation* from the generated OpenAPI schema (and thus, from the automatic documentation systems), use the parameter `include_in_schema` and set it to `False`: ```Python hl_lines="6" {!../../../docs_src/path_operation_advanced_configuration/tutorial003.py!} @@ -50,3 +50,121 @@ It won't show up in the documentation, but other tools (such as Sphinx) will be ```Python hl_lines="19-29" {!../../../docs_src/path_operation_advanced_configuration/tutorial004.py!} ``` + +## Additional Responses + +You probably have seen how to declare the `response_model` and `status_code` for a *path operation*. + +That defines the metadata about the main response of a *path operation*. + +You can also declare additional responses with their models, status codes, etc. + +There's a whole chapter here in the documentation about it, you can read it at [Additional Responses in OpenAPI](./additional-responses.md){.internal-link target=_blank}. + +## OpenAPI Extra + +When you declare a *path operation* in your application, **FastAPI** automatically generates the relevant metadata about that *path operation* to be included in the OpenAPI schema. + +!!! note "Technical details" + In the OpenAPI specification it is called the Operation Object. + +It has all the information about the *path operation* and is used to generate the automatic documentation. + +It includes the `tags`, `parameters`, `requestBody`, `responses`, etc. + +This *path operation*-specific OpenAPI schema is normally generated automatically by **FastAPI**, but you can also extend it. + +!!! tip + This is a low level extension point. + + If you only need to declare additonal responses, a more convenient way to do it is with [Additional Responses in OpenAPI](./additional-responses.md){.internal-link target=_blank}. + +You can extend the OpenAPI schema for a *path operation* using the parameter `openapi_extra`. + +### OpenAPI Extensions + +This `openapi_extra` can be helpful, for example, to declare [OpenAPI Extensions](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specificationExtensions): + +```Python hl_lines="6" +{!../../../docs_src/path_operation_advanced_configuration/tutorial005.py!} +``` + +If you open the automatic API docs, your extension will show up at the bottom of the specific *path operation*. + + + +And if you see the resulting OpenAPI (at `/openapi.json` in your API), you will see your extension as part of the specific *path operation* too: + +```JSON hl_lines="22" +{ + "openapi": "3.0.2", + "info": { + "title": "FastAPI", + "version": "0.1.0" + }, + "paths": { + "/items/": { + "get": { + "summary": "Read Items", + "operationId": "read_items_items__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "x-aperture-labs-portal": "blue" + } + } + } +} +``` + +### Custom OpenAPI *path operation* schema + +The dictionary in `openapi_extra` will be deeply merged with the automatically generated OpenAPI schema for the *path operation*. + +So, you could add additional data to the automatically generated schema. + +For example, you could decide to read and validate the request with your own code, without using the automatic features of FastAPI with Pydantic, but you could still want to define the request in the OpenAPI schema. + +You could do that with `openapi_extra`: + +```Python hl_lines="20-37 39-40" +{!../../../docs_src/path_operation_advanced_configuration/tutorial006.py!} +``` + +In this example, we didn't declare any Pydantic model. In fact, the request body is not even parsed as JSON, it is read directly as `bytes`, and the function `magic_data_reader()` would be in charge of parsing it in some way. + +Nevertheless, we can declare the expected schema for the request body. + +### Custom OpenAPI content type + +Using this same trick, you could use a Pydantic model to define the JSON Schema that is then included in the custom OpenAPI schema section for the *path operation*. + +And you could do this even if the data type in the request is not JSON. + +For example, in this application we don't use FastAPI's integrated functionality to extract the JSON Schema from Pydantic models nor the automatic validation for JSON. In fact, we are declaring the request content type as YAML, not JSON: + +```Python hl_lines="17-22 24" +{!../../../docs_src/path_operation_advanced_configuration/tutorial007.py!} +``` + +Nevertheless, although we are not using the default integrated functionality, we are still using a Pydantic model to manually generate the JSON Schema for the data that we want to receive in YAML. + +Then we use the request directly, and extract the body as `bytes`. This means that FastAPI won't even try to parse the request payload as JSON. + +And then in our code, we parse that YAML content directly, and then we are again using the same Pydantic model to validate the YAML content: + +```Python hl_lines="26-33" +{!../../../docs_src/path_operation_advanced_configuration/tutorial007.py!} +``` + +!!! tip + Here we re-use the same Pydantic model. + + But the same way, we could have validated it in some other way. diff --git a/docs/en/docs/img/tutorial/path-operation-advanced-configuration/image01.png b/docs/en/docs/img/tutorial/path-operation-advanced-configuration/image01.png new file mode 100644 index 000000000..554e7c456 Binary files /dev/null and b/docs/en/docs/img/tutorial/path-operation-advanced-configuration/image01.png differ diff --git a/docs_src/path_operation_advanced_configuration/tutorial005.py b/docs_src/path_operation_advanced_configuration/tutorial005.py new file mode 100644 index 000000000..5837ad835 --- /dev/null +++ b/docs_src/path_operation_advanced_configuration/tutorial005.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/items/", openapi_extra={"x-aperture-labs-portal": "blue"}) +async def read_items(): + return [{"item_id": "portal-gun"}] diff --git a/docs_src/path_operation_advanced_configuration/tutorial006.py b/docs_src/path_operation_advanced_configuration/tutorial006.py new file mode 100644 index 000000000..403c3ee3f --- /dev/null +++ b/docs_src/path_operation_advanced_configuration/tutorial006.py @@ -0,0 +1,41 @@ +from fastapi import FastAPI, Request + +app = FastAPI() + + +def magic_data_reader(raw_body: bytes): + return { + "size": len(raw_body), + "content": { + "name": "Maaaagic", + "price": 42, + "description": "Just kiddin', no magic here. ✨", + }, + } + + +@app.post( + "/items/", + openapi_extra={ + "requestBody": { + "content": { + "application/json": { + "schema": { + "required": ["name", "price"], + "type": "object", + "properties": { + "name": {"type": "string"}, + "price": {"type": "number"}, + "description": {"type": "string"}, + }, + } + } + }, + "required": True, + }, + }, +) +async def create_item(request: Request): + raw_body = await request.body() + data = magic_data_reader(raw_body) + return data diff --git a/docs_src/path_operation_advanced_configuration/tutorial007.py b/docs_src/path_operation_advanced_configuration/tutorial007.py new file mode 100644 index 000000000..d51752bb8 --- /dev/null +++ b/docs_src/path_operation_advanced_configuration/tutorial007.py @@ -0,0 +1,34 @@ +from typing import List + +import yaml +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel, ValidationError + +app = FastAPI() + + +class Item(BaseModel): + name: str + tags: List[str] + + +@app.post( + "/items/", + openapi_extra={ + "requestBody": { + "content": {"application/x-yaml": {"schema": Item.schema()}}, + "required": True, + }, + }, +) +async def create_item(request: Request): + raw_body = await request.body() + try: + data = yaml.safe_load(raw_body) + except yaml.YAMLError: + raise HTTPException(status_code=422, detail="Invalid YAML") + try: + item = Item.parse_obj(data) + except ValidationError as e: + raise HTTPException(status_code=422, detail=e.errors()) + return item diff --git a/fastapi/applications.py b/fastapi/applications.py index b013e7b46..0c25026e2 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -236,6 +236,7 @@ class FastAPI(Starlette): JSONResponse ), name: Optional[str] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> None: self.router.add_api_route( path, @@ -260,6 +261,7 @@ class FastAPI(Starlette): include_in_schema=include_in_schema, response_class=response_class, name=name, + openapi_extra=openapi_extra, ) def api_route( @@ -286,6 +288,7 @@ class FastAPI(Starlette): include_in_schema: bool = True, response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: def decorator(func: DecoratedCallable) -> DecoratedCallable: self.router.add_api_route( @@ -311,6 +314,7 @@ class FastAPI(Starlette): include_in_schema=include_in_schema, response_class=response_class, name=name, + openapi_extra=openapi_extra, ) return func @@ -379,6 +383,7 @@ class FastAPI(Starlette): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.router.get( path, @@ -402,6 +407,7 @@ class FastAPI(Starlette): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def put( @@ -428,6 +434,7 @@ class FastAPI(Starlette): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.router.put( path, @@ -451,6 +458,7 @@ class FastAPI(Starlette): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def post( @@ -477,6 +485,7 @@ class FastAPI(Starlette): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.router.post( path, @@ -500,6 +509,7 @@ class FastAPI(Starlette): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def delete( @@ -526,6 +536,7 @@ class FastAPI(Starlette): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.router.delete( path, @@ -549,6 +560,7 @@ class FastAPI(Starlette): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def options( @@ -575,6 +587,7 @@ class FastAPI(Starlette): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.router.options( path, @@ -598,6 +611,7 @@ class FastAPI(Starlette): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def head( @@ -624,6 +638,7 @@ class FastAPI(Starlette): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.router.head( path, @@ -647,6 +662,7 @@ class FastAPI(Starlette): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def patch( @@ -673,6 +689,7 @@ class FastAPI(Starlette): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.router.patch( path, @@ -696,6 +713,7 @@ class FastAPI(Starlette): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def trace( @@ -722,6 +740,7 @@ class FastAPI(Starlette): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.router.trace( path, @@ -745,4 +764,5 @@ class FastAPI(Starlette): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) diff --git a/fastapi/openapi/models.py b/fastapi/openapi/models.py index efbd88ad7..4f55aa001 100644 --- a/fastapi/openapi/models.py +++ b/fastapi/openapi/models.py @@ -227,6 +227,9 @@ class Operation(BaseModel): security: Optional[List[Dict[str, List[str]]]] = None servers: Optional[List[Server]] = None + class Config: + extra = "allow" + class PathItem(BaseModel): ref: Optional[str] = Field(None, alias="$ref") diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 604ba5b00..0e73e21bf 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -317,6 +317,8 @@ def get_openapi_path( "HTTPValidationError": validation_error_response_definition, } ) + if route.openapi_extra: + deep_dict_update(operation, route.openapi_extra) path[method.lower()] = operation return path, security_schemes, definitions diff --git a/fastapi/routing.py b/fastapi/routing.py index f5fe0c0bd..0ad082341 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -320,6 +320,7 @@ class APIRoute(routing.Route): ), dependency_overrides_provider: Optional[Any] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> None: # normalise enums e.g. http.HTTPStatus if isinstance(status_code, enum.IntEnum): @@ -406,6 +407,7 @@ class APIRoute(routing.Route): self.dependency_overrides_provider = dependency_overrides_provider self.callbacks = callbacks self.app = request_response(self.get_route_handler()) + self.openapi_extra = openapi_extra def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: return get_request_handler( @@ -496,6 +498,7 @@ class APIRouter(routing.Router): name: Optional[str] = None, route_class_override: Optional[Type[APIRoute]] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> None: route_class = route_class_override or self.route_class responses = responses or {} @@ -537,6 +540,7 @@ class APIRouter(routing.Router): name=name, dependency_overrides_provider=self.dependency_overrides_provider, callbacks=current_callbacks, + openapi_extra=openapi_extra, ) self.routes.append(route) @@ -565,6 +569,7 @@ class APIRouter(routing.Router): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: def decorator(func: DecoratedCallable) -> DecoratedCallable: self.add_api_route( @@ -591,6 +596,7 @@ class APIRouter(routing.Router): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) return func @@ -695,6 +701,7 @@ class APIRouter(routing.Router): name=route.name, route_class_override=type(route), callbacks=current_callbacks, + openapi_extra=route.openapi_extra, ) elif isinstance(route, routing.Route): methods = list(route.methods or []) # type: ignore # in Starlette @@ -742,6 +749,7 @@ class APIRouter(routing.Router): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.api_route( path=path, @@ -766,6 +774,7 @@ class APIRouter(routing.Router): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def put( @@ -792,6 +801,7 @@ class APIRouter(routing.Router): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.api_route( path=path, @@ -816,6 +826,7 @@ class APIRouter(routing.Router): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def post( @@ -842,6 +853,7 @@ class APIRouter(routing.Router): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.api_route( path=path, @@ -866,6 +878,7 @@ class APIRouter(routing.Router): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def delete( @@ -892,6 +905,7 @@ class APIRouter(routing.Router): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.api_route( path=path, @@ -916,6 +930,7 @@ class APIRouter(routing.Router): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def options( @@ -942,6 +957,7 @@ class APIRouter(routing.Router): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.api_route( path=path, @@ -966,6 +982,7 @@ class APIRouter(routing.Router): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def head( @@ -992,6 +1009,7 @@ class APIRouter(routing.Router): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.api_route( path=path, @@ -1016,6 +1034,7 @@ class APIRouter(routing.Router): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def patch( @@ -1042,6 +1061,7 @@ class APIRouter(routing.Router): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.api_route( path=path, @@ -1066,6 +1086,7 @@ class APIRouter(routing.Router): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) def trace( @@ -1092,6 +1113,7 @@ class APIRouter(routing.Router): response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, + openapi_extra: Optional[Dict[str, Any]] = None, ) -> Callable[[DecoratedCallable], DecoratedCallable]: return self.api_route( @@ -1117,4 +1139,5 @@ class APIRouter(routing.Router): response_class=response_class, name=name, callbacks=callbacks, + openapi_extra=openapi_extra, ) diff --git a/tests/test_openapi_route_extensions.py b/tests/test_openapi_route_extensions.py new file mode 100644 index 000000000..8a1080d69 --- /dev/null +++ b/tests/test_openapi_route_extensions.py @@ -0,0 +1,45 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() + + +@app.get("/", openapi_extra={"x-custom-extension": "value"}) +def route_with_extras(): + return {} + + +client = TestClient(app) + + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/": { + "get": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + }, + "summary": "Route With Extras", + "operationId": "route_with_extras__get", + "x-custom-extension": "value", + } + }, + }, +} + + +def test_openapi(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_get_route(): + response = client.get("/") + assert response.status_code == 200, response.text + assert response.json() == {} diff --git a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial005.py b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial005.py new file mode 100644 index 000000000..5042d1837 --- /dev/null +++ b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial005.py @@ -0,0 +1,36 @@ +from fastapi.testclient import TestClient + +from docs_src.path_operation_advanced_configuration.tutorial005 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/": { + "get": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "summary": "Read Items", + "operationId": "read_items_items__get", + "x-aperture-labs-portal": "blue", + } + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_get(): + response = client.get("/items/") + assert response.status_code == 200, response.text diff --git a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial006.py b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial006.py new file mode 100644 index 000000000..5533b2957 --- /dev/null +++ b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial006.py @@ -0,0 +1,59 @@ +from fastapi.testclient import TestClient + +from docs_src.path_operation_advanced_configuration.tutorial006 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/": { + "post": { + "summary": "Create Item", + "operationId": "create_item_items__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "required": ["name", "price"], + "type": "object", + "properties": { + "name": {"type": "string"}, + "price": {"type": "number"}, + "description": {"type": "string"}, + }, + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + } + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_post(): + response = client.post("/items/", data=b"this is actually not validated") + assert response.status_code == 200, response.text + assert response.json() == { + "size": 30, + "content": { + "name": "Maaaagic", + "price": 42, + "description": "Just kiddin', no magic here. ✨", + }, + } diff --git a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py new file mode 100644 index 000000000..cb5dbc8eb --- /dev/null +++ b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py @@ -0,0 +1,97 @@ +from fastapi.testclient import TestClient + +from docs_src.path_operation_advanced_configuration.tutorial007 import app + +client = TestClient(app) + +openapi_schema = { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/": { + "post": { + "summary": "Create Item", + "operationId": "create_item_items__post", + "requestBody": { + "content": { + "application/x-yaml": { + "schema": { + "title": "Item", + "required": ["name", "tags"], + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string"}, + "tags": { + "title": "Tags", + "type": "array", + "items": {"type": "string"}, + }, + }, + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + } + } + }, +} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +def test_post(): + yaml_data = """ + name: Deadpoolio + tags: + - x-force + - x-men + - x-avengers + """ + response = client.post("/items/", data=yaml_data) + assert response.status_code == 200, response.text + assert response.json() == { + "name": "Deadpoolio", + "tags": ["x-force", "x-men", "x-avengers"], + } + + +def test_post_broken_yaml(): + yaml_data = """ + name: Deadpoolio + tags: + x - x-force + x - x-men + x - x-avengers + """ + response = client.post("/items/", data=yaml_data) + assert response.status_code == 422, response.text + assert response.json() == {"detail": "Invalid YAML"} + + +def test_post_invalid(): + yaml_data = """ + name: Deadpoolio + tags: + - x-force + - x-men + - x-avengers + - sneaky: object + """ + response = client.post("/items/", data=yaml_data) + assert response.status_code == 422, response.text + assert response.json() == { + "detail": [ + {"loc": ["tags", 3], "msg": "str type expected", "type": "type_error.str"} + ] + }