]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
:sparkles: Generate correct OpenAPI docs for responses with no content (#621)
authorDaniel Brotsky <dev@brotsky.com>
Sun, 24 Nov 2019 13:15:39 +0000 (05:15 -0800)
committerSebastián Ramírez <tiangolo@gmail.com>
Sun, 24 Nov 2019 13:15:39 +0000 (14:15 +0100)
docs/tutorial/additional-responses.md
docs/tutorial/custom-response.md
docs/tutorial/response-status-code.md
fastapi/openapi/constants.py
fastapi/openapi/utils.py
fastapi/routing.py
tests/test_response_class_no_mediatype.py [new file with mode: 0644]
tests/test_response_code_no_body.py [new file with mode: 0644]

index a74e431f8b9b58a9b07461cffd90af25792b7cec..15ff23d6d979cb7e4d39fdadee4b3d928573f3ab 100644 (file)
@@ -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.
index 600033f15ba9904dad5d1ad72f49c59664232c4e..2ab2b512cbcd9597de0dd450a04acb15ef3598d5 100644 (file)
@@ -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`.
index f87035ca7468d1ccfb5b824131c651c9e37614ab..45b82a254acce976c77ab6703945c7f8c20aef7d 100644 (file)
@@ -22,6 +22,10 @@ It will:
 
 <img src="/img/tutorial/response-status-code/image01.png">
 
+!!! 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`.
index 3b50b05bd963bfbce1eb5a0950a1fe61426a10fd..bba050a1a2295c832925f5be3103bd42607a713a 100644 (file)
@@ -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/"
index 2d6e38ee059352e45e85d4dc56906cdf65d1c07b..311fb25a733f3018b7f6d9b9fa156187c7ce928a 100644 (file)
@@ -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(
index 2a4e0bc8d4f2e665acaf0c6428b68d21e3e4becb..b2a900b7e01025aedb0c7341390cedb0e753ffe7 100644 (file)
@@ -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 (file)
index 0000000..d5e35f3
--- /dev/null
@@ -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 (file)
index 0000000..19a59df
--- /dev/null
@@ -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