Repository: litestar-org/litestar

Issue:
Enhancement: Allow `Parameter(schema_extra=...)` / `ResponseSpec(schema_extra=...)`
### Summary

Pydantic provides `Field(json_schema_extra=...)` which allows to do something like:

```py
def forbid_internal_labels(value):
    if re.match(r"^reserved-for-internal-use:.*$", value):
        raise ValueError(...)

class Foo(BaseModel):
    label: Annotated[str, AfterValidator(forbid_internal_labels), Field(json_schema_extra={
        "not": {
            {
                "type": "string",
                "pattern": "^reserved-for-internal-use:.*$"
            },
        }
    })]
```

This would generate:

```json
                "type": "string",
                "not": {
                    {
                        "type": "string",
                        "pattern": "^reserved-for-internal-use:.*$"
                    },
                }
```

It doesn't actually _add/do any validation_, it merely impacts the generated JSON schema.

Similar mechanism is needed for `Parameter`, to inject _discriminators_ (e.g. `anyOf`, `not`) into the OpenAPI spec (although the validation is decoupled from the schema declaration).

The builtin generation uses `oneOf` for union types (IIRC Pydantic uses `anyOf` for those). But I don't think it's possible to declare something like above.

Workarounds at this point would be:

- To adjust via custom `Operation` class (did not try but I assume it could work)
- To adjust the OpenAPI generation on a more global level (by extending `OpenAPIPlugin`?)

Both are quite ugly hacks, while something like this should be supported out-of-the-box.

### Basic Example

_No response_

### Drawbacks and Impact

_No response_

### Unresolved questions

_No response_

<!-- 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/3022">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/3022/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/3022/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
As mentioned in https://github.com/litestar-org/litestar/issues/3018#issuecomment-1924002760 `Parameter(schema_extra=...)` could be supported, and that's what this ticket is about.

> Having an independent schema_extra for Parameter seems fine though.

I guess `ResponseSpec(schema_extra=...)` should exist, too.

Resolving patch:
diff --git a/litestar/_openapi/schema_generation/schema.py b/litestar/_openapi/schema_generation/schema.py
--- a/litestar/_openapi/schema_generation/schema.py
+++ b/litestar/_openapi/schema_generation/schema.py
@@ -50,7 +50,7 @@
 from litestar.exceptions import ImproperlyConfiguredException
 from litestar.openapi.spec.enums import OpenAPIFormat, OpenAPIType
 from litestar.openapi.spec.schema import Schema, SchemaDataContainer
-from litestar.params import BodyKwarg, ParameterKwarg
+from litestar.params import BodyKwarg, KwargDefinition, ParameterKwarg
 from litestar.plugins import OpenAPISchemaPlugin
 from litestar.types import Empty
 from litestar.types.builtin_types import NoneType
@@ -569,6 +569,14 @@ def process_schema_result(self, field: FieldDefinition, schema: Schema) -> Schem
                     if getattr(schema, schema_key, None) is None:
                         setattr(schema, schema_key, value)
 
+            if isinstance(field.kwarg_definition, KwargDefinition) and (extra := field.kwarg_definition.schema_extra):
+                for schema_key, value in extra.items():
+                    if not hasattr(schema, schema_key):
+                        raise ValueError(
+                            f"`schema_extra` declares key `{schema_key}` which does not exist in `Schema` object"
+                        )
+                    setattr(schema, schema_key, value)
+
         if not schema.examples and self.generate_examples:
             from litestar._openapi.schema_generation.examples import create_examples_for_field
 
diff --git a/litestar/params.py b/litestar/params.py
--- a/litestar/params.py
+++ b/litestar/params.py
@@ -112,6 +112,13 @@ class KwargDefinition:
     """A sequence of valid values."""
     read_only: bool | None = field(default=None)
     """A boolean flag dictating whether this parameter is read only."""
+    schema_extra: dict[str, Any] | None = field(default=None)
+    """Extensions to the generated schema.
+
+    If set, will overwrite the matching fields in the generated schema.
+
+    .. versionadded:: 2.8.0
+    """
 
     @property
     def is_constrained(self) -> bool:
@@ -187,6 +194,7 @@ def Parameter(
     query: str | None = None,
     required: bool | None = None,
     title: str | None = None,
+    schema_extra: dict[str, Any] | None = None,
 ) -> Any:
     """Create an extended parameter kwarg definition.
 
@@ -227,6 +235,10 @@ def Parameter(
         required: A boolean flag dictating whether this parameter is required.
             If set to False, None values will be allowed. Defaults to True.
         title: String value used in the title section of the OpenAPI schema for the given parameter.
+        schema_extra: Extensions to the generated schema. If set, will overwrite the matching fields in the generated
+            schema.
+
+            .. versionadded:: 2.8.0
     """
     return ParameterKwarg(
         annotation=annotation,
@@ -251,6 +263,7 @@ def Parameter(
         min_length=min_length,
         max_length=max_length,
         pattern=pattern,
+        schema_extra=schema_extra,
     )
 
 
@@ -294,6 +307,7 @@ def Body(
     multiple_of: float | None = None,
     pattern: str | None = None,
     title: str | None = None,
+    schema_extra: dict[str, Any] | None = None,
 ) -> Any:
     """Create an extended request body kwarg definition.
 
@@ -331,6 +345,10 @@ def Body(
         pattern: A string representing a regex against which the given string will be matched.
             Equivalent to pattern in the OpenAPI specification.
         title: String value used in the title section of the OpenAPI schema for the given parameter.
+        schema_extra: Extensions to the generated schema. If set, will overwrite the matching fields in the generated
+            schema.
+
+            .. versionadded:: 2.8.0
     """
     return BodyKwarg(
         media_type=media_type,
@@ -352,6 +370,7 @@ def Body(
         max_length=max_length,
         pattern=pattern,
         multipart_form_part_limit=multipart_form_part_limit,
+        schema_extra=schema_extra,
     )
 
 


Test patch:
diff --git a/tests/unit/test_openapi/test_parameters.py b/tests/unit/test_openapi/test_parameters.py
--- a/tests/unit/test_openapi/test_parameters.py
+++ b/tests/unit/test_openapi/test_parameters.py
@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, List, Optional, Type, cast
+from typing import TYPE_CHECKING, Any, List, Optional, Type, cast
 from uuid import UUID
 
 import pytest
@@ -324,6 +324,46 @@ async def index(
         }
 
 
+def test_parameter_schema_extra() -> None:
+    @get()
+    async def handler(
+        query1: Annotated[
+            str,
+            Parameter(
+                schema_extra={
+                    "schema_not": Schema(
+                        any_of=[
+                            Schema(type=OpenAPIType.STRING, pattern=r"^somePrefix:.*$"),
+                            Schema(type=OpenAPIType.STRING, enum=["denied", "values"]),
+                        ]
+                    ),
+                }
+            ),
+        ],
+    ) -> Any:
+        return query1
+
+    @get()
+    async def error_handler(query1: Annotated[str, Parameter(schema_extra={"invalid": "dummy"})]) -> Any:
+        return query1
+
+    # Success
+    app = Litestar([handler])
+    schema = app.openapi_schema.to_schema()
+    assert schema["paths"]["/"]["get"]["parameters"][0]["schema"]["not"] == {
+        "anyOf": [
+            {"type": "string", "pattern": r"^somePrefix:.*$"},
+            {"type": "string", "enum": ["denied", "values"]},
+        ]
+    }
+
+    # Attempt to pass invalid key
+    app = Litestar([error_handler])
+    with pytest.raises(ValueError) as e:
+        app.openapi_schema
+    assert str(e.value).startswith("`schema_extra` declares key")
+
+
 def test_uuid_path_description_generation() -> None:
     # https://github.com/litestar-org/litestar/issues/2967
     @get("str/{id:str}")
diff --git a/tests/unit/test_openapi/test_request_body.py b/tests/unit/test_openapi/test_request_body.py
--- a/tests/unit/test_openapi/test_request_body.py
+++ b/tests/unit/test_openapi/test_request_body.py
@@ -3,8 +3,9 @@
 from unittest.mock import ANY, MagicMock
 
 import pytest
+from typing_extensions import Annotated
 
-from litestar import Controller, Litestar, post
+from litestar import Controller, Litestar, get, post
 from litestar._openapi.datastructures import OpenAPIContext
 from litestar._openapi.request_body import create_request_body
 from litestar.datastructures.upload_file import UploadFile
@@ -56,6 +57,31 @@ def test_create_request_body(person_controller: Type[Controller], create_request
                 assert request_body
 
 
+def test_request_body_schema_extra() -> None:
+    @dataclass
+    class RequestBody:
+        foo: str
+
+    @get()
+    async def handler(
+        body1: Annotated[
+            RequestBody,
+            Body(
+                title="Default title",
+                schema_extra={
+                    "title": "Overridden title",
+                },
+            ),
+        ],
+    ) -> Any:
+        return body1
+
+    app = Litestar([handler])
+    schema = app.openapi_schema.to_schema()
+    resp = next(iter(schema["components"]["schemas"].values()))
+    assert resp["title"] == "Overridden title"
+
+
 def test_upload_single_file_schema_generation() -> None:
     @post(path="/file-upload")
     async def handle_file_upload(