Repository: apiflask/apiflask (python)

Issue:
Specifying `headers` option in `@output` decorator generates invalid spec for OAS version >= 3.0.0
The OpenAPI Specification for versions 3.0.0 and higher indicates that the `Header` object must not specify either a `name` or an `in` field:
- 3.0.0: https://spec.openapis.org/oas/v3.0.0.html#header-object
- 3.1.0: https://spec.openapis.org/oas/v3.1.0.html#header-object

The current implementation introduced in #511 invokes `schema2parameters` from `apispec`, which leaves the fields in place, resulting in an invalid spec.

```python
import openapi_spec_validator
from apiflask import APIFlask, Schema
from marshmallow.fields import String


class Foo(Schema):
    name = String()


class ResponseHeader(Schema):
    x_token = String(data_key="X-Token", metadata={"description": "A custom token header"})


app = APIFlask(__name__)


@app.route("/foo")
@app.output(Foo, headers=ResponseHeader)
def foo():
    pass


client = app.test_client()
rv = client.get("/openapi.json")
assert rv.status_code == 200
openapi_spec_validator.validate(rv.json)  # Fails
```

The generated spec looks like this:

```json
{
    "components": {
        "schemas": {
            "Foo": {
                "properties": {
                    "name": {
                        "type": "string"
                    }
                },
                "type": "object"
            }
        }
    },
    "info": {
        "title": "APIFlask",
        "version": "0.1.0"
    },
    "openapi": "3.0.3",
    "paths": {
        "/foo": {
            "get": {
                "parameters": [],
                "responses": {
                    "200": {
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/Foo"
                                }
                            }
                        },
                        "description": "Successful response",
                        "headers": {
                            "X-Token": {
                                "description": "A custom token header",
                                "in": "header",
                                "name": "X-Token",
                                "required": false,
                                "schema": {
                                    "type": "string"
                                }
                            }
                        }
                    }
                },
                "summary": "Foo"
            }
        }
    },
    "servers": [
        {
            "url": "http://localhost/"
        }
    ],
    "tags": []
}
```

I considered raising this against `apispec`, since in theory it might be possible to modify `schema2parameters` to not include `name` or `in` when the `location` value is `headers`, but the `name` value is still needed to populate the key of the `headers` dictionary in the spec, so I think it makes more sense to address in this project.


Environment:

- Python version: 3.11
- Flask version: 3.1.0
- APIFlask version: 2.3.0


Pull request description:
Modify response headers to be compliant with OAS 3.0.0+
Modified the response headers generation to omit the `in` and `name` fields from being included in the generated specification, per OAS 3.0.0+.

Fixes #631.

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 b45d0d1..3b9dae5 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,9 +2,11 @@
 
 Released: -
 
+- Fix response headers to be compliant with the OpenAPI specification for versions 3.0.0+ ([issue #631][issue_631]).
 - Fix input data loading implementation when input validation is skipped ([issue #629][issue_629]).
 
 [issue_629]: https://github.com/apiflask/apiflask/issues/629
+[issue_631]: https://github.com/apiflask/apiflask/issues/631
 
 
 ## Version 2.3.1
@@ -88,7 +90,7 @@ Released: 2023/12/16
 - Allow adding multiple media types for a response ([issue #494][issue_494]).
 - Support adding headers to the response schema ([issue #507][issue_507]).
 - Add support for adding decorators to the OpenAPI spec and documentation UI endpoints ([issue #483][issue_483]).
-- Fix the base repsonse schema inconsistency issue.
+- Fix the base response schema inconsistency issue.
 
 [issue_494]: https://github.com/apiflask/apiflask/issues/494
 [issue_507]: https://github.com/apiflask/apiflask/issues/507
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index 1220ffe..8f259d9 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -1253,12 +1253,14 @@ class APIFlask(APIScaffold, Flask):
         if links is not None:
             operation['responses'][status_code]['links'] = links
         if headers_schema is not None:
-            headers = self._ma_plugin.converter.schema2parameters(  # type: ignore
+            header_params = self._ma_plugin.converter.schema2parameters(  # type: ignore
                 headers_schema, location='headers'
             )
-            operation['responses'][status_code]['headers'] = {
-                header['name']: header for header in headers
-            }
+            headers = {header['name']: header for header in header_params}
+            for header in headers.values():
+                header.pop('in', None)
+                header.pop('name', None)
+            operation['responses'][status_code]['headers'] = headers
 
     def _add_response_with_schema(
         self,


Test patch:
diff --git a/tests/test_openapi_headers.py b/tests/test_openapi_headers.py
index 2dd9e54..ab1aa5f 100644
--- a/tests/test_openapi_headers.py
+++ b/tests/test_openapi_headers.py
@@ -1,3 +1,6 @@
+import openapi_spec_validator as osv
+import pytest
+
 from .schemas import Foo
 from .schemas import ResponseHeader
 from apiflask.fields import Boolean
@@ -25,36 +28,26 @@ def test_spec_with_dict_headers(app, client):
     rv = client.get('/openapi.json')
     assert rv.json['paths']['/foo']['get']['responses']['200']['headers'] == {
         'X-boolean': {
-            'name': 'X-boolean',
-            'in': 'header',
             'description': 'A boolean header',
             'required': False,
             'schema': {'type': 'boolean'},
         },
         'X-integer': {
-            'name': 'X-integer',
-            'in': 'header',
             'description': 'An integer header',
             'required': False,
             'schema': {'type': 'integer'},
         },
         'X-number': {
-            'name': 'X-number',
-            'in': 'header',
             'description': 'A number header',
             'required': False,
             'schema': {'type': 'number'},
         },
         'X-string': {
-            'name': 'X-string',
-            'in': 'header',
             'description': 'A string header',
             'required': False,
             'schema': {'type': 'string'},
         },
         'X-array': {
-            'name': 'X-array',
-            'in': 'header',
             'description': 'An array header',
             'required': False,
             'schema': {'items': {'type': 'string'}, 'type': 'array'},
@@ -83,10 +76,22 @@ def test_spec_with_schema_headers(app, client):
     rv = client.get('/openapi.json')
     assert rv.json['paths']['/foo']['get']['responses']['200']['headers'] == {
         'X-Token': {
-            'name': 'X-Token',
-            'in': 'header',
             'description': 'A custom token header',
             'required': True,
             'schema': {'type': 'string'},
         },
     }
+
+
+@pytest.mark.parametrize('openapi_version', ['3.0.0', '3.1.0'])
+def test_spec_validity_with_headers(app, client, openapi_version):
+    app.config['OPENAPI_VERSION'] = openapi_version
+
+    @app.route('/foo')
+    @app.output(Foo, headers=ResponseHeader)
+    def foo():
+        pass
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    osv.validate(rv.json)