Repository: apiflask/apiflask (python)

Issue:
Invalid spec and missing requestBody when using json_or_form
When using `json_or_form` in `location`, the produced request spec is invalid and misses the `requestBody`.

e.g.:
`@app.input(SomeRequest, location='json_or_form')`

Produces this invalid spec:

```json
{
    "/some-request": {
        "post": {
            "parameters": [
                {
                    "in": "json_or_form",
                    "name": "input",
                    "required": false,
                    "schema": {
                        "type": "string"
                    }
                }
            ],
            "responses": {
                "... redacted": ""
            },
            "summary": "Some Request"
        }
    }
}
```

Instead of `requestBody` it has `parameters`, with an invalid `in` [parameter-locations](https://swagger.io/specification/#parameter-locations).

Example of requestBody with multiple types:

```json
{
    "requestBody": {
        "content": {
            "application/json": {
                "schema": {
                    "$ref": "#/components/schemas/Order"
                }
            },
            "application/xml": {
                "schema": {
                    "$ref": "#/components/schemas/Order"
                }
            },
            "application/x-www-form-urlencoded": {
                "schema": {
                    "$ref": "#/components/schemas/Order"
                }
            }
        }
    }
}
```

See [petstore3.swagger.io/api/v3/openapi.json](https://petstore3.swagger.io/api/v3/openapi.json) for a full example.

> While the OpenAPI file is unusable, the request parsing works in clients with a manual defined `content-type` header.

Environment:

- Python version: 3.10
- Flask version: 3.0.3
- APIFlask version: 2.2.0


Pull request description:
Fix OpenAPI spec for multiple content types (json_for_form)
The `json_or_form` location will generate the following spec:

```yaml
requestBody:
  content:
    application/json:
      schema:
        type: object
        properties:
          ...
    application/x-www-form-urlencoded:
      schema:
        type: object
        properties:
          ...
```

fixes #600

<!--
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] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index eaab7aa..011aeaa 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -3,6 +3,10 @@
 
 Released: -
 
+- Fix OpenAPI spec for multiple content types with `json_or_form` location ([issue #600][issue_600]).
+
+[issue_600]: https://github.com/apiflask/apiflask/issues/600
+
 
 ## Version 2.2.0
 
diff --git a/docs/request.md b/docs/request.md
index 5db9474..ac7ed0c 100644
--- a/docs/request.md
+++ b/docs/request.md
@@ -46,6 +46,34 @@ def hello(headers_data, query_data, json_data):
 ```
 
 
+## Request body content types
+
+In the generated OpenAPI spec, the request body content type is set based on the input location you declared.
+
+- `json`: `application/json`
+- `form`: `application/x-www-form-urlencoded`
+- `files`: `multipart/form-data`
+- `form_and_files`: `multipart/form-data`
+- `json_or_form`: `application/json` and `application/x-www-form-urlencoded`. For this location, APIFlask will
+  try to parse the request body as JSON first, if it fails, it will try to parse it as form data. The OpenAPI spec
+  will show both content types, for example:
+
+    ```yaml
+    requestBody:
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              ...
+        application/x-www-form-urlencoded:
+          schema:
+            type: object
+            properties:
+              ...
+    ```
+
+
 ## Request body validating
 
 When you declared an input with `app.input` decorator, APIFlask (webargs) will get the data
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index 69c8735..1220ffe 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -1093,20 +1093,20 @@ class APIFlask(APIScaffold, Flask):
 
                 # requestBody
                 if view_func._spec.get('body'):
-                    content_type = view_func._spec.get('content_type', 'application/json')
-                    operation['requestBody'] = {
-                        'content': {
-                            content_type: {
-                                'schema': view_func._spec['body'],
-                            }
+                    content_types = view_func._spec.get('content_type')
+                    if not isinstance(content_types, list):
+                        content_types = [content_types]
+                    operation['requestBody'] = {'content': {}}
+                    for content_type in content_types:
+                        operation['requestBody']['content'][content_type] = {
+                            'schema': view_func._spec['body'],
                         }
-                    }
-                    if view_func._spec.get('body_example'):
-                        example = view_func._spec.get('body_example')
-                        operation['requestBody']['content'][content_type]['example'] = example
-                    if view_func._spec.get('body_examples'):
-                        examples = view_func._spec.get('body_examples')
-                        operation['requestBody']['content'][content_type]['examples'] = examples
+                        if view_func._spec.get('body_example'):
+                            example = view_func._spec.get('body_example')
+                            operation['requestBody']['content'][content_type]['example'] = example
+                        if view_func._spec.get('body_examples'):
+                            examples = view_func._spec.get('body_examples')
+                            operation['requestBody']['content'][content_type]['examples'] = examples
 
                 # security
                 if custom_security:  # custom security
diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py
index 2323f9f..688e6f8 100644
--- a/src/apiflask/scaffold.py
+++ b/src/apiflask/scaffold.py
@@ -298,7 +298,13 @@ class APIScaffold:
                     'and "json_or_form").'
                 )
             if location == 'json':
-                _annotate(f, body=schema, body_example=example, body_examples=examples)
+                _annotate(
+                    f,
+                    body=schema,
+                    body_example=example,
+                    body_examples=examples,
+                    content_type='application/json',
+                )
             elif location == 'form':
                 _annotate(
                     f,
@@ -315,6 +321,14 @@ class APIScaffold:
                     body_examples=examples,
                     content_type='multipart/form-data',
                 )
+            elif location == 'json_or_form':
+                _annotate(
+                    f,
+                    body=schema,
+                    body_example=example,
+                    body_examples=examples,
+                    content_type=['application/x-www-form-urlencoded', 'application/json'],
+                )
             else:
                 if not hasattr(f, '_spec') or f._spec.get('args') is None:
                     _annotate(f, args=[])
@@ -406,7 +420,7 @@ 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`.
+            content_type: The content/media type of the response. It defaults to `application/json`.
             headers: The schemas of the headers.
 
         *Version changed: 2.1.0*


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4742f83..3d19ba8 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -22,9 +22,9 @@ jobs:
       fail-fast: false
       matrix:
         include:
-          - {name: Linux, python: '3.12', os: ubuntu-latest, tox: py312}
-          - {name: Windows, python: '3.12', os: windows-latest, tox: py312}
-          - {name: Mac, python: '3.12', os: macos-latest, tox: py312}
+          - {name: '3.12 on Linux', python: '3.12', os: ubuntu-latest, tox: py312}
+          - {name: '3.12 on Windows', python: '3.12', os: windows-latest, tox: py312}
+          - {name: '3.12 on Mac', python: '3.12', os: macos-latest, tox: py312}
           - {name: '3.11', python: '3.11', os: ubuntu-latest, tox: py311}
           - {name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310}
           - {name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39}
diff --git a/tests/test_decorator_input.py b/tests/test_decorator_input.py
index 3a732a0..c473e8b 100644
--- a/tests/test_decorator_input.py
+++ b/tests/test_decorator_input.py
@@ -190,6 +190,43 @@ def test_input_with_form_and_files_location(app, client):
     assert rv.json == {'name': True, 'image': True}
 
 
+def test_input_with_json_or_form_location(app, client):
+    @app.post('/')
+    @app.input(Form, location='json_or_form')
+    def index(json_or_form_data):
+        return json_or_form_data
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    osv.validate(rv.json)
+    assert (
+        'application/x-www-form-urlencoded'
+        in rv.json['paths']['/']['post']['requestBody']['content']
+    )
+    assert 'application/json' in rv.json['paths']['/']['post']['requestBody']['content']
+    assert (
+        rv.json['paths']['/']['post']['requestBody']['content']['application/json']['schema'][
+            '$ref'
+        ]
+        == '#/components/schemas/Form'
+    )
+    assert (
+        rv.json['paths']['/']['post']['requestBody']['content'][
+            'application/x-www-form-urlencoded'
+        ]['schema']['$ref']
+        == '#/components/schemas/Form'
+    )
+    assert 'Form' in rv.json['components']['schemas']
+
+    rv = client.post('/', data={'name': 'foo'})
+    assert rv.status_code == 200
+    assert rv.json == {'name': 'foo'}
+
+    rv = client.post('/', json={'name': 'foo'})
+    assert rv.status_code == 200
+    assert rv.json == {'name': 'foo'}
+
+
 def test_input_with_path_location(app, client):
     @app.get('/<image_type>')
     @app.input(EnumPathParameter, location='path')