Repository: litestar-org/litestar

Issue:
Bug: openapi schema generation fails for Union of/in msgspec.Struct models
### Description

Hello!

In the latest versions(s) (I think this originates from the changes regarding nested models in openapi generation) we cannot use Unions of `msgspec.Struct`s anymore. Neither as direct return types for routes nor nested within return types. 

The result is a 500 Error. The MCVE below raises `'types.UnionType' object has no attribute '__qualname__'` internally. In our production app I get `typing.Union is not a module, class, method, or function.` instead.

Cheers

### URL to code causing the issue

_No response_

### MCVE

```python
import msgspec
import uvicorn
from litestar import Litestar, get


class SubStructA(msgspec.Struct):
    a: int


class SubStructB(msgspec.Struct):
    a: int


class StructyStruct(msgspec.Struct):
    sub: SubStructA | SubStructB


@get("/subunion")
async def testSubUnion() -> StructyStruct:
    return StructyStruct(SubStructA(0))


@get("/union")
async def testUnion() -> SubStructA | SubStructB:
    return SubStructA(0)


app = Litestar(route_handlers=[test2]) # or test
uvicorn.run(app)
```


### Steps to reproduce

```bash
Run the example and browse to `localhost:8000/schema`
```


### Screenshots

_No response_

### Logs

_No response_

### Litestar Version

2.5.0

### Platform

- [X] Linux
- [ ] 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/2971">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2971/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2971/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
Thanks for report @aedify-swi, confirmed.

Guessing that you are using 3.8 or 3.9 in prod app with `Union` instead of  ` | `?

Issue is with our internal schema generation plugin for `Struct` types (actually, other plugin types are likely vulnerable to this also).

https://github.com/litestar-org/litestar/blob/cc45c1132584210250dd725595612c9c95c4bf68/litestar/_openapi/schema_generation/plugins/struct.py#L21-L22

`FieldDefinition.is_subclass_of()` will return `True` if all elements of the union are subclasses of `Struct`, however the plugin doesn't make any effort to handle unions, nor should it b/c the schema generation scaffolding above it manages that. So if it receives a union, irrespective of the member types, it should return `False`.

Changing that to:

```py
    def is_plugin_supported_field(self, field_definition: FieldDefinition) -> bool:
        return not field_definition.is_union and field_definition.is_subclass_of(Struct)
```

seems to sort it:

![image](https://github.com/litestar-org/litestar/assets/20659309/2f4028c9-2bc2-48e8-ab6f-7925fde07957)

Thanks for your quick reply @peterschutt!

>Guessing that you are using 3.8 or 3.9 in prod app with Union instead of |?

Nope. 3.12 and exclusively `|`s. Your fix works regardless for our project even though the error message is a little different. ;)

> Nope. 3.12

Any chance you can provide a traceback for your prod issue please?
Even better, I was able to reproduce it. 

```python
import msgspec
import uvicorn
from litestar import Litestar, get


class SubStructA(msgspec.Struct):
    a: int


class SubStructB(msgspec.Struct):
    a: int


@get("/union")
async def testOptionalUnion() -> SubStructA | SubStructB | None:
    return SubStructA(0)


app = Litestar(route_handlers=[testOptionalUnion])  # or test
uvicorn.run(app)
```
This raises `typing.Union is not a module, class, method, or function.` in `litestar.utils.typing.get_type_hints_with_generics_resolved`

Resolving patch:
diff --git a/litestar/_openapi/schema_generation/plugins/struct.py b/litestar/_openapi/schema_generation/plugins/struct.py
--- a/litestar/_openapi/schema_generation/plugins/struct.py
+++ b/litestar/_openapi/schema_generation/plugins/struct.py
@@ -19,7 +19,7 @@
 
 class StructSchemaPlugin(OpenAPISchemaPlugin):
     def is_plugin_supported_field(self, field_definition: FieldDefinition) -> bool:
-        return field_definition.is_subclass_of(Struct)
+        return not field_definition.is_union and field_definition.is_subclass_of(Struct)
 
     def to_openapi_schema(self, field_definition: FieldDefinition, schema_creator: SchemaCreator) -> Schema:
         def is_field_required(field: FieldInfo) -> bool:


Test patch:
diff --git a/tests/unit/test_openapi/test_schema.py b/tests/unit/test_openapi/test_schema.py
--- a/tests/unit/test_openapi/test_schema.py
+++ b/tests/unit/test_openapi/test_schema.py
@@ -491,3 +491,61 @@ def test_process_schema_result_with_unregistered_object_schema() -> None:
     schema = Schema(title="has title", type=OpenAPIType.OBJECT)
     field_definition = FieldDefinition.from_annotation(dict)
     assert SchemaCreator().process_schema_result(field_definition, schema) is schema
+
+
+@pytest.mark.parametrize("base_type", [msgspec.Struct, TypedDict, dataclass])
+def test_type_union(base_type: type) -> None:
+    if base_type is dataclass:  # type: ignore[comparison-overlap]
+
+        @dataclass
+        class ModelA:  # pyright: ignore
+            pass
+
+        @dataclass
+        class ModelB:  # pyright: ignore
+            pass
+    else:
+
+        class ModelA(base_type):  # type: ignore[no-redef, misc]
+            pass
+
+        class ModelB(base_type):  # type: ignore[no-redef, misc]
+            pass
+
+    schema = get_schema_for_field_definition(
+        FieldDefinition.from_kwarg(name="Lookup", annotation=Union[ModelA, ModelB])
+    )
+    assert schema.one_of == [
+        Reference(ref="#/components/schemas/tests_unit_test_openapi_test_schema_test_type_union.ModelA"),
+        Reference(ref="#/components/schemas/tests_unit_test_openapi_test_schema_test_type_union.ModelB"),
+    ]
+
+
+@pytest.mark.parametrize("base_type", [msgspec.Struct, TypedDict, dataclass])
+def test_type_union_with_none(base_type: type) -> None:
+    # https://github.com/litestar-org/litestar/issues/2971
+    if base_type is dataclass:  # type: ignore[comparison-overlap]
+
+        @dataclass
+        class ModelA:  # pyright: ignore
+            pass
+
+        @dataclass
+        class ModelB:  # pyright: ignore
+            pass
+    else:
+
+        class ModelA(base_type):  # type: ignore[no-redef, misc]
+            pass
+
+        class ModelB(base_type):  # type: ignore[no-redef, misc]
+            pass
+
+    schema = get_schema_for_field_definition(
+        FieldDefinition.from_kwarg(name="Lookup", annotation=Union[ModelA, ModelB, None])
+    )
+    assert schema.one_of == [
+        Schema(type=OpenAPIType.NULL),
+        Reference(ref="#/components/schemas/tests_unit_test_openapi_test_schema_test_type_union_with_none.ModelA"),
+        Reference("#/components/schemas/tests_unit_test_openapi_test_schema_test_type_union_with_none.ModelB"),
+    ]