Repository: litestar-org/litestar

Issue:
Bug: ResponseHeaders results in invalid OpenAPI schema
### Description

Use of `response_headers` on the Litestar object seems to result in invalid OpenAPI 3.1 schema generation.

The Header object on the response is invalid aiui according to the spec (see https://spec.openapis.org/oas/v3.1.0#header-object) and fails validation when using `redocly lint`, as it includes the `name` and `in` fields.

The schema is as follows,

```json
{
    "info": {
        "title": "Litestar API",
        "version": "1.0.0"
    },
    "openapi": "3.1.0",
    "servers": [
        {
            "url": "/"
        }
    ],
    "paths": {
        "/": {
            "get": {
                "summary": "HelloWorld1",
                "operationId": "HelloWorld1",
                "responses": {
                    "200": {
                        "description": "Request fulfilled, document follows",
                        "headers": {
                            "X-Version": {
                                "schema": {
                                    "type": "string"
                                },
                                "name": "X-Version",
                                "in": "header",
                                "description": "Test",
                                "required": false,
                                "deprecated": false,
                                "allowEmptyValue": false,
                                "allowReserved": false
                            }
                        }
                    }
                },
                "deprecated": false
            }
        }
    },
    "components": {
        "schemas": {}
    }
}
```


### URL to code causing the issue

_No response_

### MCVE

```python
from litestar import Litestar, Request, get
from litestar.datastructures import ResponseHeader

@get("/")
async def hello_world1(request: Request) -> None:
    request.logger.info("inside request")
    return None

app1 = Litestar(
    route_handlers=[hello_world1],
    response_headers=[ResponseHeader(name="X-Version", value="ABCD", description="Test")],
)
```

This endpoint works, but the resulting schema fails validation.

```bash
$ litestar schema openapi --output openapi_schema.json
$ npx @redocly/cli lint openapi_schema.json

...
[1] openapi_schema.json:25:33 at #/paths/~1/get/responses/200/headers/X-Version/name

Property `name` is not expected here.

23 |     "type": "string"
24 | },
25 | "name": "X-Version",
26 | "in": "header",
27 | "description": "Test",

Error was generated by the spec rule.


[2] openapi_schema.json:26:33 at #/paths/~1/get/responses/200/headers/X-Version/in

Property `in` is not expected here.

24 | },
25 | "name": "X-Version",
26 | "in": "header",
27 | "description": "Test",
28 | "required": false,
...

```


### Steps to reproduce

_No response_

### Screenshots

_No response_

### Logs

_No response_

### Litestar Version

2.8.2, using Python 3.12.2

### Platform

- [ ] Linux
- [X] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/3416">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/3416/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/3416/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
Thanks for reporting!

We go to a lot of effort to document that these shouldn't be included also:

https://github.com/litestar-org/litestar/blob/a31d1c6347714ae51cc72c08f0333cae414f7f22/litestar/openapi/spec/header.py#L18-L36

Resolving patch:
diff --git a/litestar/openapi/spec/base.py b/litestar/openapi/spec/base.py
--- a/litestar/openapi/spec/base.py
+++ b/litestar/openapi/spec/base.py
@@ -2,7 +2,11 @@
 
 from dataclasses import asdict, dataclass, fields, is_dataclass
 from enum import Enum
-from typing import Any
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+    from dataclasses import Field
 
 __all__ = ("BaseSchemaObject",)
 
@@ -34,13 +38,16 @@ def _normalize_value(value: Any) -> Any:
 class BaseSchemaObject:
     """Base class for schema spec objects"""
 
+    def _iter_fields(self) -> Iterator[Field[Any]]:
+        yield from fields(self)
+
     def to_schema(self) -> dict[str, Any]:
         """Transform the spec dataclass object into a string keyed dictionary. This method traverses all nested values
         recursively.
         """
         result: dict[str, Any] = {}
 
-        for field in fields(self):
+        for field in self._iter_fields():
             value = _normalize_value(getattr(self, field.name, None))
 
             if value is not None:
diff --git a/litestar/openapi/spec/header.py b/litestar/openapi/spec/header.py
--- a/litestar/openapi/spec/header.py
+++ b/litestar/openapi/spec/header.py
@@ -1,7 +1,9 @@
 from __future__ import annotations
 
-from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Literal
+from dataclasses import Field, dataclass
+from typing import TYPE_CHECKING, Any, Iterator, Literal
+
+from typing_extensions import override
 
 from litestar.openapi.spec.base import BaseSchemaObject
 
@@ -119,3 +121,7 @@ class OpenAPIHeader(BaseSchemaObject):
 
     The key is the media type and the value describes it. The map MUST only contain one entry.
     """
+
+    @override
+    def _iter_fields(self) -> Iterator[Field[Any]]:
+        yield from (f for f in super()._iter_fields() if f.name not in {"name", "param_in"})


Test patch:
diff --git a/tests/e2e/test_openapi/__init__.py b/tests/e2e/test_openapi/__init__.py
new file mode 100644
diff --git a/tests/e2e/test_openapi/test_spec_headers.py b/tests/e2e/test_openapi/test_spec_headers.py
new file mode 100644
--- /dev/null
+++ b/tests/e2e/test_openapi/test_spec_headers.py
@@ -0,0 +1,29 @@
+from litestar import Litestar, Request, get
+from litestar.datastructures import ResponseHeader
+
+
+@get("/")
+async def hello_world1(request: Request) -> None:
+    request.logger.info("inside request")
+    return
+
+
+app1 = Litestar(
+    route_handlers=[hello_world1],
+    response_headers=[ResponseHeader(name="X-Version", value="ABCD", description="Test")],
+)
+
+
+def test_included_header_fields() -> None:
+    # https://github.com/litestar-org/litestar/issues/3416
+
+    assert app1.openapi_schema.to_schema()["paths"]["/"]["get"]["responses"]["200"]["headers"] == {
+        "X-Version": {
+            "allowEmptyValue": False,
+            "allowReserved": False,
+            "deprecated": False,
+            "description": "Test",
+            "required": False,
+            "schema": {"type": "string"},
+        }
+    }
diff --git a/tests/unit/test_openapi/test_config.py b/tests/unit/test_openapi/test_config.py
--- a/tests/unit/test_openapi/test_config.py
+++ b/tests/unit/test_openapi/test_config.py
@@ -28,16 +28,12 @@ def test_merged_components_correct() -> None:
         "examples": {"example-one": {"summary": "an example"}},
         "headers": {
             "one": {
-                "name": "",
-                "in": "header",
                 "required": False,
                 "deprecated": False,
                 "allowEmptyValue": False,
                 "allowReserved": False,
             },
             "two": {
-                "name": "",
-                "in": "header",
                 "required": False,
                 "deprecated": False,
                 "allowEmptyValue": False,