Repository: apiflask/apiflask (python)

Issue:
Allow defining additional media types for a response
As mentioned in #492, it would be nice if APIFlask would provide an easier way to specify additional media types for a view's main response.

Currently the way to add additional media types is to use `app.doc(responses={})` but by doing so we need to provide the full OpenAPI definition for each response, including the main response (which we could define using `app.output()`). Ideally it should be possible to let APIFlask autogenerate the OpenAPI spec for the main response (_i.e._ the one specified with `app.output()`) and then let me merge in additional `responses.200.content.<my-media-type>` media types by using `app.doc()`.

The idea is to make it easier for APIs to perform [content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation), while still enjoying automation in generating the OpenAPI description, as much as possible.

My main use case is being able to return either `JSON` or `HTML` from a view, while providing minimal detail to the `app.doc()` decorator.

I'm thinking something similar to what [fastAPI does](https://fastapi.tiangolo.com/advanced/additional-responses/#additional-media-types-for-the-main-response), where it is possible to have a main response's media type and then merge the relevant portion of the OpenAPI definition with additional media types. Something that would enable this type of usage:

```python
@app.get("/pets/<int:pet_id>")
@app.input(Accept, location="headers")
@app.output(PetOut)  # still have a main response of media type 'application/json'
@app.doc(responses={
    200: {
        'description': 'Return the resource in either JSON or HTML',
        'content': {
            'text/html': {}  # have an additional media type for the main response
        }
    }
})
def get_pet(pet_id, headers_data):
    pet = pets[pet_id]
    # depending on the content of the `Accept` header we may return JSON or HTML
    if "html" in headers_data.get('accept'):
        result = render_template('pets/pet-detail.j2.html', pet=pet)
    else:
        result = pet
    return result
```

This would be interpreted by APIFlask as:

- Use `@app.output` to define the relevant OpenAPI response with suitable media type and schema - in this case, it means adding the `application/json` media type with the `PetOut` schema
- Additionally, take my custom `@app.doc` and use it to add the additional HTML media type

The end result being something like this:

```yaml
paths:
    /pets/{pet_id}:
      get:
        responses:
          '200':
            content:
              text/html:
              application/json:
                schema:
                  $ref: '#/components/schemas/PetOut'
```

<!--
Replace this comment with a description of what the improvement should do.
Include details such as links to relevant specs or previous discussions.
-->

<!--
Replace this comment with an example of the problem which this change
would resolve or the ideal usage you suggested. Is this problem solvable
without changes to APIFlask, such as by subclassing or using an extension?
-->


Pull request description:
Allow adding multiple media types for a response
This PR modifies the code that handles `@app.doc(responses=...)`. The proposed implementation deals with cases where
a `dict` is passed.

The previous behavior assumed the passed dict would be used as the OpenAPI definition of the view's responses. This proposed implementation instead merges the contents of the passed dict taking into account the `content` key of each
status code. This is done in order to preserve whatever information might have already been automatically generated in
previous steps, namely what had been added while processing `app.output()`.

This means that with the current PR, APIFlask would preserve a view's main response media type, while also allowing
the addition of other, secondary media types, thus addressing the contents of issue #494

fixes #494

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the `docs` folder and in code docstring.
- [x] Add an entry in `CHANGES.md` summarizing the change and linking to the issue.
- [] Add `*Version changed*` or `*Version added*` note in any relevant docs and docstring. - IMO this doesn't seem relevant for the current PR
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 1ed3e6a..7c0d550 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,10 @@
 
 Released: -
 
+- Allow adding multiple media types for a response ([issue #494][issue_494])
+
+[issue_494]: https://github.com/apiflask/apiflask/issues/494
+
 
 ## Version 2.0.2
 
diff --git a/docs/openapi.md b/docs/openapi.md
index e58f444..2d920c2 100644
--- a/docs/openapi.md
+++ b/docs/openapi.md
@@ -717,7 +717,7 @@ def new_pet():
 
 ## Request and response content type / media type
 
-For request, the content type is set automically based on the input location:
+For request, the content type is set automatically based on the input location:
 
 - `json`: `application/json`
 - `form`: `application/x-www-form-urlencoded`
@@ -883,6 +883,48 @@ You can also pass a schema class for the `schema`:
 ```
 
 
+### Multiple media types for a response
+
+You can add additional media types for a response and these will be added to
+the OpenAPI document. This allows you to describe situations where you may
+return a different representation of a resource, perhaps by performing
+[content negotiation][content_negotiation] based on some parameter of the
+request. This is done by supplying custom `responses` and including
+additional media types in the `content` section of each response, as
+in the following:
+
+```python
+@app.get("/pets/<int:pet_id>")
+@app.input(Accept, location="headers")
+@app.output(PetOut)
+@app.doc(responses={
+    200: {
+        'description': 'Return the resource in either JSON or HTML',
+        'content': {
+            'text/html': {}
+        }
+    }
+})
+def get_pet(pet_id, headers_data):
+    pet = pets[pet_id]
+    if "html" in headers_data.get('accept'):
+        result = render_template('pets/pet-detail.j2.html', pet=pet)
+    else:
+        result = pet
+    return result
+```
+
+The previous snippet shows how you could inspect the request's `Accept`
+header and then return either an HTML representation or a JSON representation
+of the same resource.
+
+Note that APIFlask thus allows you to complement the default `application/json`
+media type, which is automatically added by APIFlask for views that include the
+`@app.output` decorator with additional media types.
+
+[content_negotiation]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation
+
+
 ### Mark an operation as `deprecated`
 
 You can mark an operation as deprecated with the `deprecated` parameter:
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index 1c4e91f..18ac387 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -1086,7 +1086,11 @@ class APIFlask(APIScaffold, Flask):
                         status_code: str = str(status_code)  # type: ignore
                         # custom complete response spec
                         if isinstance(value, dict):
-                            operation['responses'][status_code] = value
+                            existing_response = operation['responses'].setdefault(status_code, {})
+                            existing_response_content = existing_response.setdefault('content', {})
+                            existing_response_content.update(value.get('content', {}))
+                            if (new_description := value.get('description')) is not None:
+                                existing_response['description'] = new_description
                             continue
                         else:
                             description = value


Test patch:
diff --git a/tests/test_decorator_doc.py b/tests/test_decorator_doc.py
index e988027..de9ed94 100644
--- a/tests/test_decorator_doc.py
+++ b/tests/test_decorator_doc.py
@@ -291,6 +291,54 @@ def test_doc_responses_custom_spec(app, client):
     }
 
 
+def test_doc_responses_additional_content_type(app, client):
+    """Verify that it is possible to add additional media types for a response's status code."""
+    description = 'something'
+
+    @app.route('/foo')
+    @app.input(Foo)
+    @app.output(Foo)
+    @app.doc(
+        responses={
+            200: {
+                'content': {
+                    'text/html': {},
+                },
+            },
+        }
+    )
+    def foo():
+        pass
+
+    @app.route('/bar')
+    @app.input(Foo)
+    @app.output(Foo)
+    @app.doc(
+        responses={
+            200: {
+                'content': {
+                    'text/html': {},
+                },
+                'description': description,
+            },
+        }
+    )
+    def bar():
+        pass
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    validate_spec(rv.json)
+    assert '200' in rv.json['paths']['/foo']['get']['responses']
+    assert 'application/json' in rv.json['paths']['/foo']['get']['responses']['200']['content']
+    assert 'text/html' in rv.json['paths']['/foo']['get']['responses']['200']['content']
+    assert rv.json['paths']['/foo']['get']['responses']['200']['description'] == 'Successful response'  # noqa: E501
+    assert '200' in rv.json['paths']['/bar']['get']['responses']
+    assert 'application/json' in rv.json['paths']['/bar']['get']['responses']['200']['content']
+    assert 'text/html' in rv.json['paths']['/bar']['get']['responses']['200']['content']
+    assert rv.json['paths']['/bar']['get']['responses']['200']['description'] == description
+
+
 def test_doc_responses_with_methodview(app, client):
     @app.route('/foo')
     class FooAPI(MethodView):