Skip to content

Commit 0bdd8ba

Browse files
eli-bldbanty
andauthored
better support for merging properties with allOf (#1096)
Fixes #1090 (with some limitations - see below) This PR allows the generator to work with some valid schemas where the same property name is specified by more than one schema within an `allOf`, where previously it would have failed. # Background As described in the issue, OpenAPI and JSON Schema define `allOf` in a fairly vague and tolerant way. If a schema is defined like this: ```yaml C: allOf: - $ref: "/path/to/schema/A" - $ref: "/path/to/schema/B" ``` —then the validation for C is "must pass validation for A, and for B". A and B are both objects, and they both have property X, then the value for X must pass validation for A.X and B.X; the validation is the strictest subset of both. This does not map very cleanly to the client generator's internal model. Because its ultimate goal is to producing Python model classes with properties of specific types, we need to be able to "merge" A.X and B.X to produce a single instance of some ___Property class, whose attributes are derived from both. The logic for doing this was narrower than necessary. If, for instance, A.X and B.X only differed in terms of their `description`, or if one of them specified a `default` value and the other did not, the generator would reject them as being too different. # Solution This PR defines a more nuanced property merge logic, as follows: - If both versions of the property are of the same type, the result is a property of the same type. Its validation rules, if any, are the strictest subset of the validation rules for both (for instance, "string with max length 3" and "string with max length 5" produces "string with max length 3"). - Int and number (aka float) properties can always be merged; the result is an int property (since "must be integer" is a stricter rule than "must be a number"). This is only invalid if there is a non-integer default value. - "Any" can be merged with any property type; the result is that property type (since "must be a ___" is stricter than "can be anything"). - For common string-valued attributes such as `description`, earlier values are overridden by later values, on the assumption that the second schema is meant to be an extension or specialization of the first. It also preserves helpful aspects of the original implementation: - Two enums can be merged, if the values for one are a subset of the other; the result uses the subset. - A string enum can be merged with a string, or an int enum with an int; the result is an enum. # Limitations Due to limitations of our internal model, some combinations of properties that are valid in OpenAPI will still be rejected by this implementation: - Two string properties with different `pattern` attributes cannot be merged, even if there are values that could match both of those regexes. - Two list types that contain different schemas cannot be merged, even though logically this should mean "every element in the list must match both of those schemas". - OpenAPI allows `minimum` and `maximum` to be specified for numeric types, but (as far as I can tell) the generator currently ignores these altogether, so the merge logic doesn't take them into account either. # Compatibility This PR preserves the previous schema parsing behavior in all cases where the generator would have previously succeeded. The new behaviors all apply to valid schemas where the generator would have previously failed. --------- Co-authored-by: Dylan Anthony <[email protected]> Co-authored-by: Dylan Anthony <[email protected]>
1 parent 0d4196c commit 0bdd8ba

20 files changed

+786
-179
lines changed
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
default: minor
3+
---
4+
5+
# Improved property-merging behavior with `allOf`
6+
7+
When using `allOf` to extend a base object type, `openapi-python-client` is now able to handle some kinds of modifications to an existing property that would have previously caused an error:
8+
9+
- Overriding attributes that do not affect validation, such as `description`.
10+
- Combining properties that this generator ignores, like `maxLength` or `pattern`.
11+
- Combining a generic numeric type with `int` (resulting in `int`).
12+
- Adding a `format` to a string.
13+
- Combining `any` with a specific type (resulting in that specific type).
14+
- Adding or overriding a `default`
15+
16+
> [!NOTE]
17+
> `pattern` and `max_length` are no longer fields on `StringProperty`, which may impact custom templates.
18+
19+
This also fixes a bug where properties of inline objects (as opposed to references) were not using the
20+
merge logic, but were simply overwriting previous definitions of the same property.

end_to_end_tests/baseline_openapi_3.0.json

+50
Original file line numberDiff line numberDiff line change
@@ -2152,6 +2152,56 @@
21522152
"additionalProperties": {}
21532153
}
21542154
},
2155+
"ModelWithMergedProperties": {
2156+
"title": "ModelWithMergedProperties",
2157+
"allOf": [
2158+
{
2159+
"type": "object",
2160+
"properties": {
2161+
"simpleString": {
2162+
"type": "string",
2163+
"description": "base simpleString description"
2164+
},
2165+
"stringToEnum": {
2166+
"type": "string",
2167+
"default": "a"
2168+
},
2169+
"stringToDate": {
2170+
"type": "string"
2171+
},
2172+
"numberToInt": {
2173+
"type": "number"
2174+
},
2175+
"anyToString": {}
2176+
}
2177+
},
2178+
{
2179+
"type": "object",
2180+
"properties": {
2181+
"simpleString": {
2182+
"type": "string",
2183+
"description": "extended simpleString description",
2184+
"default": "new default"
2185+
},
2186+
"stringToEnum": {
2187+
"type": "string",
2188+
"enum": ["a", "b"]
2189+
},
2190+
"stringToDate": {
2191+
"type": "string",
2192+
"format": "date"
2193+
},
2194+
"numberToInt": {
2195+
"type": "integer"
2196+
},
2197+
"anyToString": {
2198+
"type": "string",
2199+
"default": "x"
2200+
}
2201+
}
2202+
}
2203+
]
2204+
},
21552205
"ModelWithPrimitiveAdditionalProperties": {
21562206
"title": "ModelWithPrimitiveAdditionalProperties",
21572207
"type": "object",

end_to_end_tests/baseline_openapi_3.1.yaml

+50
Original file line numberDiff line numberDiff line change
@@ -2146,6 +2146,56 @@ info:
21462146
"additionalProperties": { }
21472147
}
21482148
},
2149+
"ModelWithMergedProperties": {
2150+
"title": "ModelWithMergedProperties",
2151+
"allOf": [
2152+
{
2153+
"type": "object",
2154+
"properties": {
2155+
"simpleString": {
2156+
"type": "string",
2157+
"description": "base simpleString description"
2158+
},
2159+
"stringToEnum": {
2160+
"type": "string",
2161+
"default": "a"
2162+
},
2163+
"stringToDate": {
2164+
"type": "string"
2165+
},
2166+
"numberToInt": {
2167+
"type": "number"
2168+
},
2169+
"anyToString": {}
2170+
}
2171+
},
2172+
{
2173+
"type": "object",
2174+
"properties": {
2175+
"simpleString": {
2176+
"type": "string",
2177+
"description": "extended simpleString description",
2178+
"default": "new default"
2179+
},
2180+
"stringToEnum": {
2181+
"type": "string",
2182+
"enum": ["a", "b"]
2183+
},
2184+
"stringToDate": {
2185+
"type": "string",
2186+
"format": "date"
2187+
},
2188+
"numberToInt": {
2189+
"type": "integer"
2190+
},
2191+
"anyToString": {
2192+
"type": "string",
2193+
"default": "x"
2194+
}
2195+
}
2196+
}
2197+
]
2198+
},
21492199
"ModelWithPrimitiveAdditionalProperties": {
21502200
"title": "ModelWithPrimitiveAdditionalProperties",
21512201
"type": "object",

end_to_end_tests/golden-record/my_test_api_client/models/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
from .model_with_circular_ref_in_additional_properties_b import ModelWithCircularRefInAdditionalPropertiesB
6161
from .model_with_date_time_property import ModelWithDateTimeProperty
6262
from .model_with_discriminated_union import ModelWithDiscriminatedUnion
63+
from .model_with_merged_properties import ModelWithMergedProperties
64+
from .model_with_merged_properties_string_to_enum import ModelWithMergedPropertiesStringToEnum
6365
from .model_with_no_properties import ModelWithNoProperties
6466
from .model_with_primitive_additional_properties import ModelWithPrimitiveAdditionalProperties
6567
from .model_with_primitive_additional_properties_a_date_holder import ModelWithPrimitiveAdditionalPropertiesADateHolder
@@ -138,6 +140,8 @@
138140
"ModelWithCircularRefInAdditionalPropertiesB",
139141
"ModelWithDateTimeProperty",
140142
"ModelWithDiscriminatedUnion",
143+
"ModelWithMergedProperties",
144+
"ModelWithMergedPropertiesStringToEnum",
141145
"ModelWithNoProperties",
142146
"ModelWithPrimitiveAdditionalProperties",
143147
"ModelWithPrimitiveAdditionalPropertiesADateHolder",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import datetime
2+
from typing import Any, Dict, List, Type, TypeVar, Union
3+
4+
from attrs import define as _attrs_define
5+
from attrs import field as _attrs_field
6+
from dateutil.parser import isoparse
7+
8+
from ..models.model_with_merged_properties_string_to_enum import ModelWithMergedPropertiesStringToEnum
9+
from ..types import UNSET, Unset
10+
11+
T = TypeVar("T", bound="ModelWithMergedProperties")
12+
13+
14+
@_attrs_define
15+
class ModelWithMergedProperties:
16+
"""
17+
Attributes:
18+
simple_string (Union[Unset, str]): extended simpleString description Default: 'new default'.
19+
string_to_enum (Union[Unset, ModelWithMergedPropertiesStringToEnum]): Default:
20+
ModelWithMergedPropertiesStringToEnum.A.
21+
string_to_date (Union[Unset, datetime.date]):
22+
number_to_int (Union[Unset, int]):
23+
any_to_string (Union[Unset, str]): Default: 'x'.
24+
"""
25+
26+
simple_string: Union[Unset, str] = "new default"
27+
string_to_enum: Union[Unset, ModelWithMergedPropertiesStringToEnum] = ModelWithMergedPropertiesStringToEnum.A
28+
string_to_date: Union[Unset, datetime.date] = UNSET
29+
number_to_int: Union[Unset, int] = UNSET
30+
any_to_string: Union[Unset, str] = "x"
31+
additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict)
32+
33+
def to_dict(self) -> Dict[str, Any]:
34+
simple_string = self.simple_string
35+
36+
string_to_enum: Union[Unset, str] = UNSET
37+
if not isinstance(self.string_to_enum, Unset):
38+
string_to_enum = self.string_to_enum.value
39+
40+
string_to_date: Union[Unset, str] = UNSET
41+
if not isinstance(self.string_to_date, Unset):
42+
string_to_date = self.string_to_date.isoformat()
43+
44+
number_to_int = self.number_to_int
45+
46+
any_to_string = self.any_to_string
47+
48+
field_dict: Dict[str, Any] = {}
49+
field_dict.update(self.additional_properties)
50+
field_dict.update({})
51+
if simple_string is not UNSET:
52+
field_dict["simpleString"] = simple_string
53+
if string_to_enum is not UNSET:
54+
field_dict["stringToEnum"] = string_to_enum
55+
if string_to_date is not UNSET:
56+
field_dict["stringToDate"] = string_to_date
57+
if number_to_int is not UNSET:
58+
field_dict["numberToInt"] = number_to_int
59+
if any_to_string is not UNSET:
60+
field_dict["anyToString"] = any_to_string
61+
62+
return field_dict
63+
64+
@classmethod
65+
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
66+
d = src_dict.copy()
67+
simple_string = d.pop("simpleString", UNSET)
68+
69+
_string_to_enum = d.pop("stringToEnum", UNSET)
70+
string_to_enum: Union[Unset, ModelWithMergedPropertiesStringToEnum]
71+
if isinstance(_string_to_enum, Unset):
72+
string_to_enum = UNSET
73+
else:
74+
string_to_enum = ModelWithMergedPropertiesStringToEnum(_string_to_enum)
75+
76+
_string_to_date = d.pop("stringToDate", UNSET)
77+
string_to_date: Union[Unset, datetime.date]
78+
if isinstance(_string_to_date, Unset):
79+
string_to_date = UNSET
80+
else:
81+
string_to_date = isoparse(_string_to_date).date()
82+
83+
number_to_int = d.pop("numberToInt", UNSET)
84+
85+
any_to_string = d.pop("anyToString", UNSET)
86+
87+
model_with_merged_properties = cls(
88+
simple_string=simple_string,
89+
string_to_enum=string_to_enum,
90+
string_to_date=string_to_date,
91+
number_to_int=number_to_int,
92+
any_to_string=any_to_string,
93+
)
94+
95+
model_with_merged_properties.additional_properties = d
96+
return model_with_merged_properties
97+
98+
@property
99+
def additional_keys(self) -> List[str]:
100+
return list(self.additional_properties.keys())
101+
102+
def __getitem__(self, key: str) -> Any:
103+
return self.additional_properties[key]
104+
105+
def __setitem__(self, key: str, value: Any) -> None:
106+
self.additional_properties[key] = value
107+
108+
def __delitem__(self, key: str) -> None:
109+
del self.additional_properties[key]
110+
111+
def __contains__(self, key: str) -> bool:
112+
return key in self.additional_properties
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from enum import Enum
2+
3+
4+
class ModelWithMergedPropertiesStringToEnum(str, Enum):
5+
A = "a"
6+
B = "b"
7+
8+
def __str__(self) -> str:
9+
return str(self.value)

openapi_python_client/parser/openapi.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def _add_responses(
155155
ParseError(
156156
detail=(
157157
f"Invalid response status code {code} (not a valid HTTP "
158-
f"status code), response will be ommitted from generated "
158+
f"status code), response will be omitted from generated "
159159
f"client"
160160
)
161161
)
@@ -175,7 +175,7 @@ def _add_responses(
175175
ParseError(
176176
detail=(
177177
f"Cannot parse response for status code {status_code}{detail_suffix}, "
178-
f"response will be ommitted from generated client"
178+
f"response will be omitted from generated client"
179179
),
180180
data=response.data,
181181
)

openapi_python_client/parser/properties/__init__.py

-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ def _string_based_property(
8383
name=name,
8484
default=data.default,
8585
required=required,
86-
pattern=data.pattern,
8786
python_name=python_name,
8887
description=data.description,
8988
example=data.example,

openapi_python_client/parser/properties/enum_property.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
__all__ = ["EnumProperty"]
3+
__all__ = ["EnumProperty", "ValueType"]
44

55
from typing import Any, ClassVar, List, Union, cast
66

@@ -164,6 +164,12 @@ def convert_value(self, value: Any) -> Value | PropertyError | None:
164164
return PropertyError(detail=f"Value {value} is not valid for enum {self.name}")
165165
return PropertyError(detail=f"Cannot convert {value} to enum {self.name} of type {self.value_type}")
166166

167+
def default_to_raw(self) -> ValueType | None:
168+
if self.default is None:
169+
return None
170+
key = self.default.split(".")[1]
171+
return self.values[key]
172+
167173
def get_base_type_string(self, *, quoted: bool = False) -> str:
168174
return self.class_info.name
169175

openapi_python_client/parser/properties/int.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,13 @@ def convert_value(cls, value: Any) -> Value | None | PropertyError:
6060
return value
6161
if isinstance(value, str):
6262
try:
63-
int(value)
63+
value = float(value)
6464
except ValueError:
6565
return PropertyError(f"Invalid int value: {value}")
66-
return Value(value)
66+
if isinstance(value, float):
67+
as_int = int(value)
68+
if value == as_int:
69+
value = as_int
6770
if isinstance(value, int) and not isinstance(value, bool):
6871
return Value(str(value))
6972
return PropertyError(f"Invalid int value: {value}")

0 commit comments

Comments
 (0)