Skip to content

Commit e6a597f

Browse files
nkrishnaswamiNatarajan Krishnaswamidbanty
authored
avoid misparsing references as other types using Pydantic discriminated unions (#1216)
I’ve found a corner case, I think due to changes to Pydantic’s [smart mode](https://docs.pydantic.dev/latest/concepts/unions/#smart-mode) for disambiguating unions, where a subdocument with a `$ref` key and additional keys (title, description, etc) is validated as a `Schema` instead of as a `Reference` model. I believe these should be ignored per ["any properties added SHALL be ignored"](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#reference-object) () This was a problem for me since I want to use a reference to an enum as a header parameter in FastAPI. The OpenAPI document generated by FastAPI included several fields (title, description, etc) along with `$ref`. The`Schema` instance it was validate to lacked any actual type constraint information, so openapi-python-client needed to treat this as an `AnyParameter`, and disallowed it in headers. I have attached an example OpenAPI document [`oa.json`](https://github.com/user-attachments/files/19170207/oa.json) you can use to replicate this with the current `openapi-python-client`, or you can use the test cases from the first commit in this PR, which resolves it by using a Pydantic discriminated union testing for the presence of `$ref` for all the unions including `Reference`. (I had created an [issue upstream](kuimono/openapi-schema-pydantic#36), but figured I'd share the workaround here.) --------- Co-authored-by: Natarajan Krishnaswami <[email protected]> Co-authored-by: Dylan Anthony <[email protected]>
1 parent b8fbea9 commit e6a597f

16 files changed

+344
-209
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
default: patch
3+
---
4+
5+
# Always parse `$ref` as a reference
6+
7+
If additional attributes were included with a `$ref` (for example `title` or `description`), the property could be
8+
interpreted as a new type instead of a reference, usually resulting in `Any` in the generated code.
9+
Now, any sibling properties to `$ref` will properly be ignored, as per the OpenAPI specification.
10+
11+
Thanks @nkrishnaswami!

end_to_end_tests/baseline_openapi_3.0.json

+10-1
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,16 @@
394394
"content": {
395395
"multipart/form-data": {
396396
"schema": {
397-
"$ref": "#/components/schemas/Body_upload_file_tests_upload_post"
397+
"$ref": "#/components/schemas/Body_upload_file_tests_upload_post",
398+
"title": "Body_upload_file_tests_upload_post",
399+
"required": [
400+
"some_file",
401+
"some_object",
402+
"some_nullable_object",
403+
"some_required_number"
404+
],
405+
"properties": {
406+
}
398407
}
399408
}
400409
},

openapi_python_client/schema/openapi_schema_pydantic/components.py

+11-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional, Union
1+
from typing import Optional
22

33
from pydantic import BaseModel, ConfigDict
44

@@ -7,7 +7,7 @@
77
from .header import Header
88
from .link import Link
99
from .parameter import Parameter
10-
from .reference import Reference
10+
from .reference import ReferenceOr
1111
from .request_body import RequestBody
1212
from .response import Response
1313
from .schema import Schema
@@ -25,15 +25,15 @@ class Components(BaseModel):
2525
- https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#componentsObject
2626
"""
2727

28-
schemas: Optional[dict[str, Union[Schema, Reference]]] = None
29-
responses: Optional[dict[str, Union[Response, Reference]]] = None
30-
parameters: Optional[dict[str, Union[Parameter, Reference]]] = None
31-
examples: Optional[dict[str, Union[Example, Reference]]] = None
32-
requestBodies: Optional[dict[str, Union[RequestBody, Reference]]] = None
33-
headers: Optional[dict[str, Union[Header, Reference]]] = None
34-
securitySchemes: Optional[dict[str, Union[SecurityScheme, Reference]]] = None
35-
links: Optional[dict[str, Union[Link, Reference]]] = None
36-
callbacks: Optional[dict[str, Union[Callback, Reference]]] = None
28+
schemas: Optional[dict[str, ReferenceOr[Schema]]] = None
29+
responses: Optional[dict[str, ReferenceOr[Response]]] = None
30+
parameters: Optional[dict[str, ReferenceOr[Parameter]]] = None
31+
examples: Optional[dict[str, ReferenceOr[Example]]] = None
32+
requestBodies: Optional[dict[str, ReferenceOr[RequestBody]]] = None
33+
headers: Optional[dict[str, ReferenceOr[Header]]] = None
34+
securitySchemes: Optional[dict[str, ReferenceOr[SecurityScheme]]] = None
35+
links: Optional[dict[str, ReferenceOr[Link]]] = None
36+
callbacks: Optional[dict[str, ReferenceOr[Callback]]] = None
3737
model_config = ConfigDict(
3838
# `Callback` contains an unresolvable forward reference, will rebuild in `__init__.py`:
3939
defer_build=True,

openapi_python_client/schema/openapi_schema_pydantic/encoding.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from typing import TYPE_CHECKING, Optional, Union
1+
from typing import TYPE_CHECKING, Optional
22

33
from pydantic import BaseModel, ConfigDict
44

5-
from .reference import Reference
5+
from .reference import ReferenceOr
66

77
if TYPE_CHECKING: # pragma: no cover
88
from .header import Header
@@ -17,7 +17,7 @@ class Encoding(BaseModel):
1717
"""
1818

1919
contentType: Optional[str] = None
20-
headers: Optional[dict[str, Union["Header", Reference]]] = None
20+
headers: Optional[dict[str, ReferenceOr["Header"]]] = None
2121
style: Optional[str] = None
2222
explode: bool = False
2323
allowReserved: bool = False

openapi_python_client/schema/openapi_schema_pydantic/media_type.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
from typing import Any, Optional, Union
1+
from typing import Any, Optional
22

33
from pydantic import BaseModel, ConfigDict, Field
44

55
from .encoding import Encoding
66
from .example import Example
7-
from .reference import Reference
7+
from .reference import ReferenceOr
88
from .schema import Schema
99

1010

@@ -16,9 +16,9 @@ class MediaType(BaseModel):
1616
- https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#mediaTypeObject
1717
"""
1818

19-
media_type_schema: Optional[Union[Reference, Schema]] = Field(default=None, alias="schema")
19+
media_type_schema: Optional[ReferenceOr[Schema]] = Field(default=None, alias="schema")
2020
example: Optional[Any] = None
21-
examples: Optional[dict[str, Union[Example, Reference]]] = None
21+
examples: Optional[dict[str, ReferenceOr[Example]]] = None
2222
encoding: Optional[dict[str, Encoding]] = None
2323
model_config = ConfigDict(
2424
# `Encoding` is not build yet, will rebuild in `__init__.py`:

openapi_python_client/schema/openapi_schema_pydantic/operation.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
from typing import Optional, Union
1+
from typing import Optional
22

33
from pydantic import BaseModel, ConfigDict, Field
44

55
from .callback import Callback
66
from .external_documentation import ExternalDocumentation
77
from .parameter import Parameter
8-
from .reference import Reference
8+
from .reference import ReferenceOr
99
from .request_body import RequestBody
1010
from .responses import Responses
1111
from .security_requirement import SecurityRequirement
@@ -25,8 +25,8 @@ class Operation(BaseModel):
2525
description: Optional[str] = None
2626
externalDocs: Optional[ExternalDocumentation] = None
2727
operationId: Optional[str] = None
28-
parameters: Optional[list[Union[Parameter, Reference]]] = None
29-
request_body: Optional[Union[RequestBody, Reference]] = Field(None, alias="requestBody")
28+
parameters: Optional[list[ReferenceOr[Parameter]]] = None
29+
request_body: Optional[ReferenceOr[RequestBody]] = Field(None, alias="requestBody")
3030
responses: Responses
3131
callbacks: Optional[dict[str, Callback]] = None
3232

openapi_python_client/schema/openapi_schema_pydantic/parameter.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
from typing import Any, Optional, Union
1+
from typing import Any, Optional
22

33
from pydantic import BaseModel, ConfigDict, Field
44

55
from ..parameter_location import ParameterLocation
66
from .example import Example
77
from .media_type import MediaType
8-
from .reference import Reference
8+
from .reference import ReferenceOr
99
from .schema import Schema
1010

1111

@@ -30,9 +30,9 @@ class Parameter(BaseModel):
3030
style: Optional[str] = None
3131
explode: bool = False
3232
allowReserved: bool = False
33-
param_schema: Optional[Union[Reference, Schema]] = Field(default=None, alias="schema")
33+
param_schema: Optional[ReferenceOr[Schema]] = Field(default=None, alias="schema")
3434
example: Optional[Any] = None
35-
examples: Optional[dict[str, Union[Example, Reference]]] = None
35+
examples: Optional[dict[str, ReferenceOr[Example]]] = None
3636
content: Optional[dict[str, MediaType]] = None
3737
model_config = ConfigDict(
3838
# `MediaType` is not build yet, will rebuild in `__init__.py`:

openapi_python_client/schema/openapi_schema_pydantic/path_item.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from typing import TYPE_CHECKING, Optional, Union
1+
from typing import TYPE_CHECKING, Optional
22

33
from pydantic import BaseModel, ConfigDict, Field
44

55
from .parameter import Parameter
6-
from .reference import Reference
6+
from .reference import ReferenceOr
77
from .server import Server
88

99
if TYPE_CHECKING:
@@ -34,7 +34,7 @@ class PathItem(BaseModel):
3434
patch: Optional["Operation"] = None
3535
trace: Optional["Operation"] = None
3636
servers: Optional[list[Server]] = None
37-
parameters: Optional[list[Union[Parameter, Reference]]] = None
37+
parameters: Optional[list[ReferenceOr[Parameter]]] = None
3838
model_config = ConfigDict(
3939
# `Operation` is an unresolvable forward reference, will rebuild in `__init__.py`:
4040
defer_build=True,

openapi_python_client/schema/openapi_schema_pydantic/reference.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from pydantic import BaseModel, ConfigDict, Field
1+
from typing import Annotated, Any, Literal, TypeVar, Union
2+
3+
from pydantic import BaseModel, ConfigDict, Discriminator, Field, Tag
4+
from typing_extensions import TypeAlias
25

36

47
class Reference(BaseModel):
@@ -24,3 +27,17 @@ class Reference(BaseModel):
2427
"examples": [{"$ref": "#/components/schemas/Pet"}, {"$ref": "Pet.json"}, {"$ref": "definitions.json#/Pet"}]
2528
},
2629
)
30+
31+
32+
T = TypeVar("T")
33+
34+
35+
def _reference_discriminator(obj: Any) -> Literal["ref", "other"]:
36+
if isinstance(obj, dict):
37+
return "ref" if "$ref" in obj else "other"
38+
return "ref" if isinstance(obj, Reference) else "other"
39+
40+
41+
ReferenceOr: TypeAlias = Annotated[
42+
Union[Annotated[Reference, Tag("ref")], Annotated[T, Tag("other")]], Discriminator(_reference_discriminator)
43+
]

openapi_python_client/schema/openapi_schema_pydantic/response.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
from typing import Optional, Union
1+
from typing import Optional
22

33
from pydantic import BaseModel, ConfigDict
44

55
from .header import Header
66
from .link import Link
77
from .media_type import MediaType
8-
from .reference import Reference
8+
from .reference import ReferenceOr
99

1010

1111
class Response(BaseModel):
@@ -19,9 +19,9 @@ class Response(BaseModel):
1919
"""
2020

2121
description: str
22-
headers: Optional[dict[str, Union[Header, Reference]]] = None
22+
headers: Optional[dict[str, ReferenceOr[Header]]] = None
2323
content: Optional[dict[str, MediaType]] = None
24-
links: Optional[dict[str, Union[Link, Reference]]] = None
24+
links: Optional[dict[str, ReferenceOr[Link]]] = None
2525
model_config = ConfigDict(
2626
# `MediaType` is not build yet, will rebuild in `__init__.py`:
2727
defer_build=True,

openapi_python_client/schema/openapi_schema_pydantic/responses.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
from typing import Union
2-
3-
from .reference import Reference
1+
from .reference import ReferenceOr
42
from .response import Response
53

6-
Responses = dict[str, Union[Response, Reference]]
4+
Responses = dict[str, ReferenceOr[Response]]
75
"""
86
A container for the expected responses of an operation.
97
The container maps a HTTP response code to the expected response.

openapi_python_client/schema/openapi_schema_pydantic/schema.py

+9-9
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from ..data_type import DataType
66
from .discriminator import Discriminator
77
from .external_documentation import ExternalDocumentation
8-
from .reference import Reference
8+
from .reference import ReferenceOr
99
from .xml import XML
1010

1111

@@ -38,14 +38,14 @@ class Schema(BaseModel):
3838
enum: Union[None, list[Any]] = Field(default=None, min_length=1)
3939
const: Union[None, StrictStr, StrictInt, StrictFloat, StrictBool] = None
4040
type: Union[DataType, list[DataType], None] = Field(default=None)
41-
allOf: list[Union[Reference, "Schema"]] = Field(default_factory=list)
42-
oneOf: list[Union[Reference, "Schema"]] = Field(default_factory=list)
43-
anyOf: list[Union[Reference, "Schema"]] = Field(default_factory=list)
44-
schema_not: Optional[Union[Reference, "Schema"]] = Field(default=None, alias="not")
45-
items: Optional[Union[Reference, "Schema"]] = None
46-
prefixItems: list[Union[Reference, "Schema"]] = Field(default_factory=list)
47-
properties: Optional[dict[str, Union[Reference, "Schema"]]] = None
48-
additionalProperties: Optional[Union[bool, Reference, "Schema"]] = None
41+
allOf: list[ReferenceOr["Schema"]] = Field(default_factory=list)
42+
oneOf: list[ReferenceOr["Schema"]] = Field(default_factory=list)
43+
anyOf: list[ReferenceOr["Schema"]] = Field(default_factory=list)
44+
schema_not: Optional[ReferenceOr["Schema"]] = Field(default=None, alias="not")
45+
items: Optional[ReferenceOr["Schema"]] = None
46+
prefixItems: list[ReferenceOr["Schema"]] = Field(default_factory=list)
47+
properties: Optional[dict[str, ReferenceOr["Schema"]]] = None
48+
additionalProperties: Optional[Union[bool, ReferenceOr["Schema"]]] = None
4949
description: Optional[str] = None
5050
schema_format: Optional[str] = Field(default=None, alias="format")
5151
default: Optional[Any] = None

pdm.lock

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)