diff --git a/.changeset/const_values_are_now_validated_at_runtime.md b/.changeset/const_values_are_now_validated_at_runtime.md new file mode 100644 index 000000000..0987d18ce --- /dev/null +++ b/.changeset/const_values_are_now_validated_at_runtime.md @@ -0,0 +1,10 @@ +--- +default: major +--- + +# `const` values in responses are now validated at runtime + +Prior to this version, `const` values returned from servers were assumed to always be correct. Now, if a server returns +an unexpected value, the client will raise a `ValueError`. This should enable better usage with `oneOf`. + +PR #1024. Thanks @peter-greenatlas! diff --git a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/const/post_const_path.py b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/const/post_const_path.py index 3f864b3dc..929f417f4 100644 --- a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/const/post_const_path.py +++ b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/const/post_const_path.py @@ -46,6 +46,10 @@ def _parse_response( ) -> Optional[Literal["Why have a fixed response? I dunno"]]: if response.status_code == HTTPStatus.OK: response_200 = cast(Literal["Why have a fixed response? I dunno"], response.json()) + if response_200 != "Why have a fixed response? I dunno": + raise ValueError( + f"response_200 must match const 'Why have a fixed response? I dunno', got '{response_200}'" + ) return response_200 if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) diff --git a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/post_const_path_body.py b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/post_const_path_body.py index 387e693e0..9ac2f9102 100644 --- a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/post_const_path_body.py +++ b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/post_const_path_body.py @@ -46,16 +46,26 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: d = src_dict.copy() - required = d.pop("required") + required = cast(Literal["this always goes in the body"], d.pop("required")) + if required != "this always goes in the body": + raise ValueError(f"required must match const 'this always goes in the body', got '{required}'") def _parse_nullable(data: object) -> Union[Literal["this or null goes in the body"], None]: if data is None: return data + nullable_type_1 = cast(Literal["this or null goes in the body"], data) + if nullable_type_1 != "this or null goes in the body": + raise ValueError( + f"nullable_type_1 must match const 'this or null goes in the body', got '{nullable_type_1}'" + ) + return nullable_type_1 return cast(Union[Literal["this or null goes in the body"], None], data) nullable = _parse_nullable(d.pop("nullable")) - optional = d.pop("optional", UNSET) + optional = cast(Union[Literal["this sometimes goes in the body"], Unset], d.pop("optional", UNSET)) + if optional != "this sometimes goes in the body" and not isinstance(optional, Unset): + raise ValueError(f"optional must match const 'this sometimes goes in the body', got '{optional}'") post_const_path_body = cls( required=required, diff --git a/openapi_python_client/parser/properties/const.py b/openapi_python_client/parser/properties/const.py index 88a398893..aec624afd 100644 --- a/openapi_python_client/parser/properties/const.py +++ b/openapi_python_client/parser/properties/const.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, overload +from typing import Any, ClassVar, overload from attr import define @@ -12,7 +12,7 @@ @define class ConstProperty(PropertyProtocol): - """A property representing a Union (anyOf) of other properties""" + """A property representing a const value""" name: str required: bool @@ -21,6 +21,7 @@ class ConstProperty(PropertyProtocol): python_name: PythonIdentifier description: str | None example: None + template: ClassVar[str] = "const_property.py.jinja" @classmethod def build( diff --git a/openapi_python_client/templates/property_templates/const_property.py.jinja b/openapi_python_client/templates/property_templates/const_property.py.jinja new file mode 100644 index 000000000..ea48ab73e --- /dev/null +++ b/openapi_python_client/templates/property_templates/const_property.py.jinja @@ -0,0 +1,5 @@ +{% macro construct(property, source) %} +{{ property.python_name }} = cast({{ property.get_type_string() }} , {{ source }}) +if {{ property.python_name }} != {{ property.value }}{% if not property.required %}and not isinstance({{ property.python_name }}, Unset){% endif %}: + raise ValueError(f"{{ property.name }} must match const {{ property.value }}, got '{{'{' + property.python_name + '}' }}'") +{%- endmacro %}