Repository: apiflask/apiflask (python)

Issue:
How to properly document a file response?
Hi.

I wonder if there is a proper way to document the output of an endpoint that returns a file as a response. I haven't found anything in the documentation. If a method exists, it would be good to include an example.

Thank you
KR
Eduardo

Pull request description:
Support customizing the media/content type of the response
Add a `content_type` parameter to the `output` decorator:

```py
@app.post('/image')
@app.output(ImageOutSchema, content_type='image/png')
def get_image():
    pass
```

<!--
For features and bug fixes, before opening a PR, please open an issue describing
the bug or feature the PR will address. You can skip this step if it's a typo fix.

Replace this comment with a description of the change. Describe how it
addresses the linked issue.
-->

<!--
Link to relevant issues or previous PRs, one per line. Use "fixes" to
automatically close an issue.
-->

fixes #357

<!--
If needed, ensure each step in the checklist below is complete. If only docs were changed, these aren't relevant and can be removed.
-->

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.
- [x] Add `*Version changed*` or `*Version added*` note in any relevant docs and docstring.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 4f44e08..2ef003f 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,12 +2,15 @@
 ## Version 1.3.0
 
 - Add `scurity_scheme_name` for `HTTPBasicAuth` and `HTTPTokenAuth` to define custom
-OpenAPI security scheme name ([issue #410][issue_410]).
+  OpenAPI security scheme name ([issue #410][issue_410]).
 - Add config `SPEC_PROCESSOR_PASS_OBJECT` to control the argument type of
   spec processor. The `spec` argument will be an `apispec.APISpec` object
   when this config is `True` ([issue #213][issue_213]).
+- Add `content_type` parameter to the `output()` decorator to customize the
+  response's content/media type.
 
 [issue_410]: https://github.com/apiflask/apiflask/issues/410
+[issue_357]: https://github.com/apiflask/apiflask/issues/357
 [issue_213]: https://github.com/apiflask/apiflask/issues/213
 
 
diff --git a/docs/openapi.md b/docs/openapi.md
index 6a0d78f..d4fa13e 100644
--- a/docs/openapi.md
+++ b/docs/openapi.md
@@ -709,12 +709,35 @@ def update_spec(spec):
 
 
 @app.post('/pets')
-@app.output(PetOutSchem, links={'getAddressByUserId': {'$ref': '#/components/links/getAddressByUserId'}})
+@app.output(PetOutSchema, links={'getAddressByUserId': {'$ref': '#/components/links/getAddressByUserId'}})
 def new_pet(data):
     pass
 ```
 
 
+## Request and response content type / media type
+
+For request, the content type is set automically based on the input location:
+
+- `json`: `application/json`
+- `form`: `application/x-www-form-urlencoded`
+- `files`, `form_and_files`: `multipart/form-data`
+
+For response, the default content type is `application/json`. You can set a custom content type with the
+`content_type` parameter (APIFlsak >= 1.3.0) in the `output()` decorator:
+
+```python
+@app.post('/image')
+@app.output(ImageOutSchema, content_type='image/png')
+def get_image():
+    pass
+```
+
+!!! note
+
+    For the consistency with Flask/Werkzeug, we use `content_type` instead of `media_type`.
+
+
 ## Use the `doc` decorator
 
 There is also a `doc` decorator that can be used to set operation fields explicitly.
diff --git a/examples/basic/app.py b/examples/basic/app.py
index 3fa5fb9..2fcbcea 100644
--- a/examples/basic/app.py
+++ b/examples/basic/app.py
@@ -36,7 +36,7 @@ def get_pet(pet_id):
 
 
 @app.get('/pets')
-@app.output(PetOut(many=True))
+@app.output(PetOut(many=True), content_type='image/png')
 def get_pets():
     return pets
 
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index c237129..f33d682 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -861,6 +861,10 @@ class APIFlask(APIScaffold, Flask):
     def _generate_spec(self) -> APISpec:
         """Generate the spec, return an instance of `apispec.APISpec`.
 
+        *Version changed: 1.3.0*
+
+        - Support setting custom response content type.
+
         *Version changed: 1.2.1*
 
         - Set default `servers` value.
@@ -1084,8 +1088,16 @@ class APIFlask(APIScaffold, Flask):
                     example = view_func._spec.get('response')['example']
                     examples = view_func._spec.get('response')['examples']
                     links = view_func._spec.get('response')['links']
+                    content_type = view_func._spec.get('response')['content_type']
                     add_response(
-                        operation, status_code, schema, description, example, examples, links
+                        operation,
+                        status_code,
+                        schema,
+                        description,
+                        example,
+                        examples,
+                        links,
+                        content_type,
                     )
                 else:
                     # add a default 200 response for views without using @app.output
diff --git a/src/apiflask/openapi.py b/src/apiflask/openapi.py
index 56b1eb0..282bb0a 100644
--- a/src/apiflask/openapi.py
+++ b/src/apiflask/openapi.py
@@ -31,6 +31,7 @@ default_response = {
     'example': None,
     'examples': None,
     'links': None,
+    'content_type': 'application/json',
 }
 
 
@@ -163,9 +164,14 @@ def add_response(
     example: t.Optional[t.Any] = None,
     examples: t.Optional[t.Dict[str, t.Any]] = None,
     links: t.Optional[t.Dict[str, t.Any]] = None,
+    content_type: t.Optional[str] = 'application/json',
 ) -> None:
     """Add response to operation.
 
+    *Version changed: 1.3.0*
+
+    - Add parameter `content_type`.
+
     *Version changed: 0.10.0*
 
     - Add `links` parameter.
@@ -173,17 +179,17 @@ def add_response(
     operation['responses'][status_code] = {}
     if status_code != '204':
         operation['responses'][status_code]['content'] = {
-            'application/json': {
+            content_type: {
                 'schema': schema
             }
         }
     operation['responses'][status_code]['description'] = description
     if example is not None:
         operation['responses'][status_code]['content'][
-            'application/json']['example'] = example
+            content_type]['example'] = example
     if examples is not None:
         operation['responses'][status_code]['content'][
-            'application/json']['examples'] = examples
+            content_type]['examples'] = examples
     if links is not None:
         operation['responses'][status_code]['links'] = links
 
diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py
index 847d8ef..9289342 100644
--- a/src/apiflask/scaffold.py
+++ b/src/apiflask/scaffold.py
@@ -333,6 +333,7 @@ class APIScaffold:
         example: t.Optional[t.Any] = None,
         examples: t.Optional[t.Dict[str, t.Any]] = None,
         links: t.Optional[t.Dict[str, t.Any]] = None,
+        content_type: t.Optional[str] = 'application/json',
     ) -> t.Callable[[DecoratedType], DecoratedType]:
         """Add output settings for view functions.
 
@@ -400,6 +401,12 @@ class APIScaffold:
                 See the [docs](https://apiflask.com/openapi/#response-links) for more details
                 about setting response links.
 
+            content_type: The content/media type of the response. It defautls to `application/json`.
+
+        *Version changed: 1.3.0*
+
+        - Add parameter `content_type`.
+
         *Version changed: 0.12.0*
 
         - Move to APIFlask and APIBlueprint classes.
@@ -443,6 +450,7 @@ class APIScaffold:
                 'example': example,
                 'examples': examples,
                 'links': links,
+                'content_type': content_type,
             })
 
             def _jsonify(


Test patch:
diff --git a/tests/test_decorator_output.py b/tests/test_decorator_output.py
index 39d49bd..54627cd 100644
--- a/tests/test_decorator_output.py
+++ b/tests/test_decorator_output.py
@@ -315,3 +315,23 @@ def test_response_links_ref(app, client):
     assert rv.status_code == 200
     validate_spec(rv.json)
     assert 'getFoo' in rv.json['paths']['/foo']['get']['responses']['200']['links']
+
+
+def test_response_content_type(app, client):
+    @app.get('/foo')
+    @app.output(Foo)  # default value is application/json
+    def foo():
+        pass
+
+    @app.get('/bar')
+    @app.output(Foo, content_type='image/png')
+    def bar():
+        pass
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    validate_spec(rv.json)
+    assert len(rv.json['paths']['/foo']['get']['responses']['200']['content']) == 1
+    assert len(rv.json['paths']['/bar']['get']['responses']['200']['content']) == 1
+    assert 'application/json' in rv.json['paths']['/foo']['get']['responses']['200']['content']
+    assert 'image/png' in rv.json['paths']['/bar']['get']['responses']['200']['content']