diff --git a/docs/tutorial/additional-responses.md b/docs/tutorial/additional-responses.md index a74e431f8..15ff23d6d 100644 --- a/docs/tutorial/additional-responses.md +++ b/docs/tutorial/additional-responses.md @@ -174,6 +174,11 @@ For example, you can add an additional media type of `image/png`, declaring that !!! note Notice that you have to return the image using a `FileResponse` directly. +!!! info + Unless you specify a different media type explicitly in your `responses` parameter, FastAPI will assume the response has the same media type as the main response class (default `application/json`). + + But if you have specified a custom response class with `None` as its media type, FastAPI will use `application/json` for any additional response that has an associated model. + ## Combining information You can also combine response information from multiple places, including the `response_model`, `status_code`, and `responses` parameters. diff --git a/docs/tutorial/custom-response.md b/docs/tutorial/custom-response.md index 600033f15..2ab2b512c 100644 --- a/docs/tutorial/custom-response.md +++ b/docs/tutorial/custom-response.md @@ -15,6 +15,9 @@ The contents that you return from your *path operation function* will be put ins And if that `Response` has a JSON media type (`application/json`), like is the case with the `JSONResponse` and `UJSONResponse`, the data you return will be automatically converted (and filtered) with any Pydantic `response_model` that you declared in the *path operation decorator*. +!!! note + If you use a response class with no media type, FastAPI will expect your response to have no content, so it will not document the response format in its generated OpenAPI docs. + ## Use `UJSONResponse` For example, if you are squeezing performance, you can install and use `ujson` and set the response to be Starlette's `UJSONResponse`. diff --git a/docs/tutorial/response-status-code.md b/docs/tutorial/response-status-code.md index f87035ca7..45b82a254 100644 --- a/docs/tutorial/response-status-code.md +++ b/docs/tutorial/response-status-code.md @@ -22,6 +22,10 @@ It will: +!!! note + Some response codes (see the next section) indicate that the response does not have a body. + + FastAPI knows this, and will produce OpenAPI docs that state there is no response body. ## About HTTP status codes @@ -34,11 +38,12 @@ These status codes have a name associated to recognize them, but the important p In short: -* `100` and above are for "Information". You rarely use them directly. +* `100` and above are for "Information". You rarely use them directly. Responses with these status codes cannot have a body. * **`200`** and above are for "Successful" responses. These are the ones you would use the most. * `200` is the default status code, which means everything was "OK". * Another example would be `201`, "Created". It is commonly used after creating a new record in the database. -* `300` and above are for "Redirection". + * A special case is `204`, "No Content". This response is used when there is no content to return to the client, and so the response must not have a body. +* **`300`** and above are for "Redirection". Responses with these status codes may or may not have a body, except for `304`, "Not Modified", which must not have one. * **`400`** and above are for "Client error" responses. These are the second type you would probably use the most. * An example is `404`, for a "Not Found" response. * For generic errors from the client, you can just use `400`. diff --git a/fastapi/openapi/constants.py b/fastapi/openapi/constants.py index 3b50b05bd..bba050a1a 100644 --- a/fastapi/openapi/constants.py +++ b/fastapi/openapi/constants.py @@ -1,2 +1,3 @@ METHODS_WITH_BODY = set(("POST", "PUT", "DELETE", "PATCH")) +STATUS_CODES_WITH_NO_BODY = set((100, 101, 102, 103, 204, 304)) REF_PREFIX = "#/components/schemas/" diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 2d6e38ee0..311fb25a7 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -5,7 +5,11 @@ from fastapi import routing from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import get_flat_dependant from fastapi.encoders import jsonable_encoder -from fastapi.openapi.constants import METHODS_WITH_BODY, REF_PREFIX +from fastapi.openapi.constants import ( + METHODS_WITH_BODY, + REF_PREFIX, + STATUS_CODES_WITH_NO_BODY, +) from fastapi.openapi.models import OpenAPI from fastapi.params import Body, Param from fastapi.utils import ( @@ -151,10 +155,8 @@ def get_openapi_path( security_schemes: Dict[str, Any] = {} definitions: Dict[str, Any] = {} assert route.methods is not None, "Methods must be a list" - assert ( - route.response_class and route.response_class.media_type - ), "A response class with media_type is needed to generate OpenAPI" - route_response_media_type: str = route.response_class.media_type + assert route.response_class, "A response class is needed to generate OpenAPI" + route_response_media_type: Optional[str] = route.response_class.media_type if route.include_in_schema: for method in route.methods: operation = get_openapi_operation_metadata(route=route, method=method) @@ -189,7 +191,7 @@ def get_openapi_path( field, model_name_map=model_name_map, ref_prefix=REF_PREFIX ) response.setdefault("content", {}).setdefault( - route_response_media_type, {} + route_response_media_type or "application/json", {} )["schema"] = response_schema status_text: Optional[str] = status_code_ranges.get( str(additional_status_code).upper() @@ -202,24 +204,28 @@ def get_openapi_path( 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( - route.response_field, - model_name_map=model_name_map, - ref_prefix=REF_PREFIX, - ) - else: - response_schema = {} operation.setdefault("responses", {}).setdefault(status_code, {})[ "description" ] = route.response_description - operation.setdefault("responses", {}).setdefault( - status_code, {} - ).setdefault("content", {}).setdefault(route_response_media_type, {})[ - "schema" - ] = response_schema + if ( + route_response_media_type + and route.status_code not in STATUS_CODES_WITH_NO_BODY + ): + response_schema = {"type": "string"} + if lenient_issubclass(route.response_class, JSONResponse): + if route.response_field: + response_schema, _, _ = field_schema( + route.response_field, + model_name_map=model_name_map, + ref_prefix=REF_PREFIX, + ) + else: + response_schema = {} + operation.setdefault("responses", {}).setdefault( + status_code, {} + ).setdefault("content", {}).setdefault(route_response_media_type, {})[ + "schema" + ] = response_schema http422 = str(HTTP_422_UNPROCESSABLE_ENTITY) if (all_route_params or route.body_field) and not any( diff --git a/fastapi/routing.py b/fastapi/routing.py index 2a4e0bc8d..b2a900b7e 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -13,6 +13,7 @@ from fastapi.dependencies.utils import ( ) from fastapi.encoders import DictIntStrAny, SetIntStr, jsonable_encoder from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError +from fastapi.openapi.constants import STATUS_CODES_WITH_NO_BODY 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 @@ -215,6 +216,9 @@ class APIRoute(routing.Route): ) self.response_model = response_model if self.response_model: + assert ( + status_code not in STATUS_CODES_WITH_NO_BODY + ), f"Status code {status_code} must not have a response body" response_name = "Response_" + self.unique_id self.response_field: Optional[Field] = Field( name=response_name, @@ -256,6 +260,9 @@ class APIRoute(routing.Route): assert isinstance(response, dict), "An additional response must be a dict" model = response.get("model") if model: + assert ( + additional_status_code not in STATUS_CODES_WITH_NO_BODY + ), f"Status code {additional_status_code} must not have a response body" assert lenient_issubclass( model, BaseModel ), "A response model must be a Pydantic model" diff --git a/tests/test_response_class_no_mediatype.py b/tests/test_response_class_no_mediatype.py new file mode 100644 index 000000000..d5e35f388 --- /dev/null +++ b/tests/test_response_class_no_mediatype.py @@ -0,0 +1,114 @@ +import typing + +from fastapi import FastAPI +from pydantic import BaseModel +from starlette.responses import JSONResponse, Response +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=Response, + 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/json": { + "schema": {"$ref": "#/components/schemas/JsonApiError"} + } + }, + }, + "200": {"description": "Successful Response"}, + }, + "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 diff --git a/tests/test_response_code_no_body.py b/tests/test_response_code_no_body.py new file mode 100644 index 000000000..19a59df36 --- /dev/null +++ b/tests/test_response_code_no_body.py @@ -0,0 +1,108 @@ +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", + status_code=204, + response_class=JsonApiResponse, + responses={500: {"description": "Error", "model": JsonApiError}}, +) +async def a(): + pass # pragma: no cover + + +@app.get("/b", responses={204: {"description": "No Content"}}) +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"} + } + }, + }, + "204": {"description": "Successful Response"}, + }, + "summary": "A", + "operationId": "a_a_get", + } + }, + "/b": { + "get": { + "responses": { + "204": {"description": "No Content"}, + "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