Repository: tiangolo/fastapi

Issue:
204 No Content support for OpenAPI schema
I would like to be able to specify in the endpoint decorator that the endpoint will have no response (204 No Content). This is documented in the [swagger.io specification](https://swagger.io/docs/specification/describing-responses/) under the heading Empty Response Body.

**Is your feature request related to a problem? Please describe.**
This would allow my `openapi-generator`-generated client to detect that there will not be a response, and will automatically make use of an appropriate response (non-)decoder. 

It's not a terribly hard issue to work around manually by tweaking my generated client, but it would simplify things if I could generate an OpenAPI schema without the content field for the 204-response endpoints.

**Describe the solution you'd like**
So far, my favorite version of an end-user-facing API would involve introducing a new response class (perhaps called `NoContentResponse`) to pass in the endpoint decorator. 

(But I'm not sure how that would interact with `starlette` or if detecting the response_class is even feasible when generating the OpenAPI spec.)


Discussion:
I see. I'll check it.
Any news on this issue? I'm facing the same problem and tried something like

```python
        HTTP_204_NO_CONTENT: dict(
            description="Successful action.",
            content=None,
        )
```
Which only lead to `AttributeError: 'NoneType' object has no attribute 'setdefault'`.

This seemed like an intuitive way to me, but it would be even more intuitive if no content was the default for a 204 with a warning for the user if he declared `content` explicitly.
I just ran into this as well.  I have an idea about how to make `get_openapi_path` for response codes that don't allow bodies or additional status codes that don't specify body parameters.  That would be backward-compatible with existing code, and would make decorators such as:
```python
@app.delete('/frotz/{fid}', status_code=204)
```
and
```python
@app.post(
    '/frotz/', 
    status_code=201,
    responses={302: {'description': 'Such a frotz already exists'}
)
```
do the right thing.
 

Resolving patch:
diff --git a/fastapi/openapi/constants.py b/fastapi/openapi/constants.py
--- 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
--- a/fastapi/openapi/utils.py
+++ b/fastapi/openapi/utils.py
@@ -5,7 +5,11 @@
 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 (
@@ -79,7 +83,7 @@ def get_openapi_security_definitions(flat_dependant: Dependant) -> Tuple[Dict, L
 
 
 def get_openapi_operation_parameters(
-    all_route_params: Sequence[Field]
+    all_route_params: Sequence[Field],
 ) -> List[Dict[str, Any]]:
     parameters = []
     for param in all_route_params:
@@ -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
--- a/fastapi/routing.py
+++ b/fastapi/routing.py
@@ -13,6 +13,7 @@
 )
 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 @@ def __init__(
         )
         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 @@ def __init__(
             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"


Test patch:
diff --git a/tests/test_response_class_no_mediatype.py b/tests/test_response_class_no_mediatype.py
new file mode 100644
--- /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
--- /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