Repository: apiflask/apiflask (python)

Issue:
Support creating file schema for file response
We support creating file fields via the `apiflask.schemas.File` field class, and use the metadata to set the type and the format:

```python
class ImageIn(Schema):
    image = File()
```

But we still need to support creating the schema for file response. In that case, the whole response is a file, there isn't a key for the file. For example:

```py
paths:
  /report:
    get:
      summary: Returns the report in the PDF format
      responses:
        '200':
          description: A PDF file
          content:
            application/pdf:
              schema:
                type: string
                format: binary
```

I will create a `FileSchema` class that accepts `type` and `format` as arguments, then create the file schema from it. Example usage:

```py
from apiflask.schemas import FileSchema

@app.get('/files/<filename>')
@app.output(FileSchema(type='string', format='binary'), content_type='image/png', description='An image file')
def get_file(filename):
    return ...
```

Pull request description:
Add `FileSchema` for file response
Example:

```py
from apiflask.schemas import FileSchema

@app.get('/files/<filename>')
@app.output(FileSchema, content_type='image/png')
def get_file(filename):
    return ...
```

fixes #447

<!--
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 501d9c9..e012256 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -8,9 +8,11 @@ Released: -<br>Codename: Gongqing
 - Only pass keyword arguments to the view function. The argument name
   of the parsed data from `app.input()` will be `{location}_data` or the
   value of `arg_name` ([issue #427][issue_427]).
+- Add `FileSchema` to generate OpenAPI file response ([issue #447][issue_447]).
 
-[issue_442]: https://github.com/apiflask/apiflask/issues/442
 [issue_427]: https://github.com/apiflask/apiflask/issues/427
+[issue_442]: https://github.com/apiflask/apiflask/issues/442
+[issue_447]: https://github.com/apiflask/apiflask/issues/447
 
 
 ## Version 1.3.1
diff --git a/docs/openapi.md b/docs/openapi.md
index 7ae7eb8..82af548 100644
--- a/docs/openapi.md
+++ b/docs/openapi.md
@@ -727,9 +727,9 @@ For response, the default content type is `application/json`. You can set a cust
 `content_type` parameter (APIFlask >= 1.3.0) in the `output()` decorator:
 
 ```python
-@app.post('/image')
-@app.output(ImageOutSchema, content_type='image/png')
-def get_image():
+@app.post('/foo')
+@app.output({FooSchema}, content_type='custom/type')
+def get_foo():
     pass
 ```
 
@@ -738,6 +738,49 @@ def get_image():
     For consistency with Flask/Werkzeug, we use `content_type` instead of `media_type`.
 
 
+## File response
+
+!!! warning "Version >= 2.0.0"
+
+    This feature was added in the [version 2.0.0](/changelog/#version-0200).
+
+When you create an endpoint to return a file, the `FileSchema` should be used:
+
+```python
+from apiflask.schemas import FileSchema
+from flask import send_from_directory
+
+@app.get('/images/<filename>')
+@app.output(FileSchema, content_type='image/png')
+def get_image(filename):
+    return send_from_directory(app.config['IMAGE_FOLDER'], filename)
+```
+
+Acoording the OpenAPI spec, the schema may be omit if the file format is binary, so you
+can also use `EmptySchema`:
+
+```python
+from apiflask.schemas import EmptySchema
+
+@app.get('/images/<filename>')
+@app.output(EmptySchema, content_type='image/png')
+```
+
+Or:
+
+```python
+@app.get('/images/<filename>')
+@app.output({}, content_type='image/png')
+```
+
+You can use `type` and `format` to set the custom file type and format
+(one of `binary` and `base64`, defaults to `binary`):
+
+```python
+@app.output(FileSchema(format='base64'), content_type='image/png')
+```
+
+
 ## Use the `doc` decorator
 
 There is also a `doc` decorator that can be used to set operation fields explicitly.
diff --git a/src/apiflask/openapi.py b/src/apiflask/openapi.py
index 282bb0a..405b45d 100644
--- a/src/apiflask/openapi.py
+++ b/src/apiflask/openapi.py
@@ -5,6 +5,7 @@ import typing as t
 from apispec import APISpec
 
 from .exceptions import _bad_schema_message
+from .schemas import FileSchema
 from .security import HTTPBasicAuth
 from .security import HTTPTokenAuth
 from .types import HTTPAuthType
@@ -178,6 +179,8 @@ def add_response(
     """
     operation['responses'][status_code] = {}
     if status_code != '204':
+        if isinstance(schema, FileSchema):
+            schema = {'type': schema.type, 'format': schema.format}
         operation['responses'][status_code]['content'] = {
             content_type: {
                 'schema': schema
diff --git a/src/apiflask/schemas.py b/src/apiflask/schemas.py
index aeb5c4e..2200d89 100644
--- a/src/apiflask/schemas.py
+++ b/src/apiflask/schemas.py
@@ -100,3 +100,64 @@ class PaginationSchema(Schema):
     prev = URL()
     first = URL()
     last = URL()
+
+
+class FileSchema(Schema):
+    """A schema for file response.
+
+    This is used to represent a file response in OpenAPI spec. If you want to
+    embed a file as base64 string in the JSON body, you can use the
+    `apiflask.fields.File` field instead.
+
+    Example:
+
+    ```python
+    from apiflask.schemas import FileSchema
+    from flask import send_from_directory
+
+    @app.get('/images/<filename>')
+    @app.output(
+        FileSchema(type='string', format='binary'),
+        content_type='image/png',
+        description='An image file'
+    )
+    @app.doc(summary="Returns the image file")
+    def get_image(filename):
+        return send_from_directory(app.config['IMAGE_FOLDER'], filename)
+    ```
+
+    The output OpenAPI spec will be:
+
+    ```yaml
+    paths:
+    /images/{filename}:
+      get:
+        summary: Returns the image file
+        responses:
+          '200':
+            description: An image file
+            content:
+              image/png:
+                schema:
+                  type: string
+                  format: binary
+    ```
+
+    *Version Added: 2.0.0*
+    """
+    def __init__(
+        self,
+        *,
+        type: str = 'string',
+        format: str = 'binary'
+    ) -> None:
+        """
+        Arguments:
+            type: The type of the file. Defaults to `string`.
+            format: The format of the file, one of `binary` and `base64`. Defaults to `binary`.
+        """
+        self.type = type
+        self.format = format
+
+    def __repr__(self) -> str:
+        return f'schema: \n  type: {self.type}\n  format: {self.format}'


Test patch:
diff --git a/tests/test_schemas.py b/tests/test_schemas.py
new file mode 100644
index 0000000..72fc7e5
--- /dev/null
+++ b/tests/test_schemas.py
@@ -0,0 +1,26 @@
+from apiflask.schemas import FileSchema
+
+
+def test_file_schema(app, client):
+    @app.get('/image')
+    @app.output(
+        FileSchema(type='string', format='binary'),
+        content_type='image/png',
+        description='An image file'
+    )
+    def get_image():
+        return 'file'
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    content = rv.json['paths']['/image']['get']['responses']['200']['content']
+    assert 'image/png' in content
+    assert content['image/png']['schema'] == {
+        'type': 'string',
+        'format': 'binary'
+    }
+
+
+def test_file_schema_repr():
+    f = FileSchema(type='string', format='binary')
+    assert repr(f) == f'schema: \n  type: {f.type}\n  format: {f.format}'