From 23909aa3dac93ccfb535bfcdd29657a6b12b1899 Mon Sep 17 00:00:00 2001 From: Emil Styrke Date: Wed, 5 Jun 2024 11:06:55 +0200 Subject: [PATCH 1/2] feat: support prefixItems for arrays Generates a union of all types in `prefixItems` and `items` for the inner list item type --- end_to_end_tests/3.1_specific.openapi.yaml | 31 ++++ .../api/prefix_items/__init__.py | 0 .../api/prefix_items/post_prefix_items.py | 150 ++++++++++++++++++ .../models/__init__.py | 6 +- .../models/post_prefix_items_body.py | 103 ++++++++++++ .../parser/properties/list_property.py | 22 ++- .../schema/openapi_schema_pydantic/schema.py | 1 + .../test_properties/test_list_property.py | 109 ++++++++++++- 8 files changed, 410 insertions(+), 12 deletions(-) create mode 100644 end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/prefix_items/__init__.py create mode 100644 end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/prefix_items/post_prefix_items.py create mode 100644 end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/post_prefix_items_body.py diff --git a/end_to_end_tests/3.1_specific.openapi.yaml b/end_to_end_tests/3.1_specific.openapi.yaml index 3540d04ac..04d693449 100644 --- a/end_to_end_tests/3.1_specific.openapi.yaml +++ b/end_to_end_tests/3.1_specific.openapi.yaml @@ -47,3 +47,34 @@ paths: "application/json": schema: const: "Why have a fixed response? I dunno" + "/prefixItems": + post: + tags: [ "prefixItems" ] + requestBody: + required: true + content: + "application/json": + schema: + type: object + properties: + prefixItemsAndItems: + type: array + prefixItems: + - type: string + const: "prefix" + - type: string + items: + type: number + prefixItemsOnly: + type: array + prefixItems: + - type: string + - type: number + maxItems: 2 + responses: + "200": + description: "Successful Response" + content: + "application/json": + schema: + type: string diff --git a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/prefix_items/__init__.py b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/prefix_items/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/prefix_items/post_prefix_items.py b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/prefix_items/post_prefix_items.py new file mode 100644 index 000000000..2f0443fe3 --- /dev/null +++ b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/prefix_items/post_prefix_items.py @@ -0,0 +1,150 @@ +from http import HTTPStatus +from typing import Any, Dict, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.post_prefix_items_body import PostPrefixItemsBody +from ...types import Response + + +def _get_kwargs( + *, + body: PostPrefixItemsBody, +) -> Dict[str, Any]: + headers: Dict[str, Any] = {} + + _kwargs: Dict[str, Any] = { + "method": "post", + "url": "/prefixItems", + } + + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[str]: + if response.status_code == HTTPStatus.OK: + response_200 = cast(str, response.json()) + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[str]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: Union[AuthenticatedClient, Client], + body: PostPrefixItemsBody, +) -> Response[str]: + """ + Args: + body (PostPrefixItemsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[str] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: Union[AuthenticatedClient, Client], + body: PostPrefixItemsBody, +) -> Optional[str]: + """ + Args: + body (PostPrefixItemsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + str + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: Union[AuthenticatedClient, Client], + body: PostPrefixItemsBody, +) -> Response[str]: + """ + Args: + body (PostPrefixItemsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[str] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: Union[AuthenticatedClient, Client], + body: PostPrefixItemsBody, +) -> Optional[str]: + """ + Args: + body (PostPrefixItemsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + str + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/__init__.py b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/__init__.py index f923a5c37..aeafedd08 100644 --- a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/__init__.py +++ b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/__init__.py @@ -1,5 +1,9 @@ """Contains all the data models used in inputs/outputs""" from .post_const_path_body import PostConstPathBody +from .post_prefix_items_body import PostPrefixItemsBody -__all__ = ("PostConstPathBody",) +__all__ = ( + "PostConstPathBody", + "PostPrefixItemsBody", +) diff --git a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/post_prefix_items_body.py b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/post_prefix_items_body.py new file mode 100644 index 000000000..f3edd841d --- /dev/null +++ b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/post_prefix_items_body.py @@ -0,0 +1,103 @@ +from typing import Any, Dict, List, Literal, Type, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PostPrefixItemsBody") + + +@_attrs_define +class PostPrefixItemsBody: + """ + Attributes: + prefix_items_and_items (Union[Unset, List[Union[Literal['prefix'], float, str]]]): + prefix_items_only (Union[Unset, List[Union[float, str]]]): + """ + + prefix_items_and_items: Union[Unset, List[Union[Literal["prefix"], float, str]]] = UNSET + prefix_items_only: Union[Unset, List[Union[float, str]]] = UNSET + additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + prefix_items_and_items: Union[Unset, List[Union[Literal["prefix"], float, str]]] = UNSET + if not isinstance(self.prefix_items_and_items, Unset): + prefix_items_and_items = [] + for prefix_items_and_items_item_data in self.prefix_items_and_items: + prefix_items_and_items_item: Union[Literal["prefix"], float, str] + prefix_items_and_items_item = prefix_items_and_items_item_data + prefix_items_and_items.append(prefix_items_and_items_item) + + prefix_items_only: Union[Unset, List[Union[float, str]]] = UNSET + if not isinstance(self.prefix_items_only, Unset): + prefix_items_only = [] + for prefix_items_only_item_data in self.prefix_items_only: + prefix_items_only_item: Union[float, str] + prefix_items_only_item = prefix_items_only_item_data + prefix_items_only.append(prefix_items_only_item) + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if prefix_items_and_items is not UNSET: + field_dict["prefixItemsAndItems"] = prefix_items_and_items + if prefix_items_only is not UNSET: + field_dict["prefixItemsOnly"] = prefix_items_only + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + prefix_items_and_items = [] + _prefix_items_and_items = d.pop("prefixItemsAndItems", UNSET) + for prefix_items_and_items_item_data in _prefix_items_and_items or []: + + def _parse_prefix_items_and_items_item(data: object) -> Union[Literal["prefix"], float, str]: + prefix_items_and_items_item_type_0 = cast(Literal["prefix"], data) + if prefix_items_and_items_item_type_0 != "prefix": + raise ValueError( + f"prefixItemsAndItems_item_type_0 must match const 'prefix', got '{prefix_items_and_items_item_type_0}'" + ) + return prefix_items_and_items_item_type_0 + return cast(Union[Literal["prefix"], float, str], data) + + prefix_items_and_items_item = _parse_prefix_items_and_items_item(prefix_items_and_items_item_data) + + prefix_items_and_items.append(prefix_items_and_items_item) + + prefix_items_only = [] + _prefix_items_only = d.pop("prefixItemsOnly", UNSET) + for prefix_items_only_item_data in _prefix_items_only or []: + + def _parse_prefix_items_only_item(data: object) -> Union[float, str]: + return cast(Union[float, str], data) + + prefix_items_only_item = _parse_prefix_items_only_item(prefix_items_only_item_data) + + prefix_items_only.append(prefix_items_only_item) + + post_prefix_items_body = cls( + prefix_items_and_items=prefix_items_and_items, + prefix_items_only=prefix_items_only, + ) + + post_prefix_items_body.additional_properties = d + return post_prefix_items_body + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/openapi_python_client/parser/properties/list_property.py b/openapi_python_client/parser/properties/list_property.py index c78e50513..47a52cda3 100644 --- a/openapi_python_client/parser/properties/list_property.py +++ b/openapi_python_client/parser/properties/list_property.py @@ -58,12 +58,28 @@ def build( """ from . import property_from_data - if data.items is None: - return PropertyError(data=data, detail="type array must have items defined"), schemas + if data.items is None and not data.prefixItems: + return ( + PropertyError( + data=data, + detail="type array must have items or prefixItems defined", + ), + schemas, + ) + + items = data.prefixItems or [] + if data.items: + items.append(data.items) + + if len(items) == 1: + inner_schema = items[0] + else: + inner_schema = oai.Schema(anyOf=items) + inner_prop, schemas = property_from_data( name=f"{name}_item", required=True, - data=data.items, + data=inner_schema, schemas=schemas, parent_name=parent_name, config=config, diff --git a/openapi_python_client/schema/openapi_schema_pydantic/schema.py b/openapi_python_client/schema/openapi_schema_pydantic/schema.py index 9bd6f5cde..a3e4cb522 100644 --- a/openapi_python_client/schema/openapi_schema_pydantic/schema.py +++ b/openapi_python_client/schema/openapi_schema_pydantic/schema.py @@ -43,6 +43,7 @@ class Schema(BaseModel): anyOf: List[Union[Reference, "Schema"]] = Field(default_factory=list) schema_not: Optional[Union[Reference, "Schema"]] = Field(default=None, alias="not") items: Optional[Union[Reference, "Schema"]] = None + prefixItems: Optional[List[Union[Reference, "Schema"]]] = Field(default_factory=list) properties: Optional[Dict[str, Union[Reference, "Schema"]]] = None additionalProperties: Optional[Union[bool, Reference, "Schema"]] = None description: Optional[str] = None diff --git a/tests/test_parser/test_properties/test_list_property.py b/tests/test_parser/test_properties/test_list_property.py index bac87e669..60fb0a35d 100644 --- a/tests/test_parser/test_properties/test_list_property.py +++ b/tests/test_parser/test_properties/test_list_property.py @@ -1,9 +1,11 @@ import attr import openapi_python_client.schema as oai -from openapi_python_client.parser.errors import PropertyError +from openapi_python_client.parser.errors import ParseError, PropertyError from openapi_python_client.parser.properties import ListProperty +from openapi_python_client.parser.properties.schemas import ReferencePath from openapi_python_client.schema import DataType +from openapi_python_client.utils import ClassName def test_build_list_property_no_items(config): @@ -22,10 +24,10 @@ def test_build_list_property_no_items(config): parent_name="parent", config=config, process_properties=True, - roots={"root"}, + roots={ReferencePath("root")}, ) - assert p == PropertyError(data=data, detail="type array must have items defined") + assert p == PropertyError(data=data, detail="type array must have items or prefixItems defined") assert new_schemas == schemas @@ -36,11 +38,11 @@ def test_build_list_property_invalid_items(config): required = True data = oai.Schema( type=DataType.ARRAY, - items=oai.Reference(ref="doesnt exist"), + items=oai.Reference.model_validate({"$ref": "doesnt exist"}), ) - schemas = properties.Schemas(errors=["error"]) + schemas = properties.Schemas(errors=[ParseError("error")]) process_properties = False - roots = {"root"} + roots: set[ReferencePath | ClassName] = {ReferencePath("root")} p, new_schemas = ListProperty.build( name=name, @@ -67,7 +69,7 @@ def test_build_list_property(any_property_factory, config): type=DataType.ARRAY, items=oai.Schema(), ) - schemas = properties.Schemas(errors=["error"]) + schemas = properties.Schemas(errors=[ParseError("error")]) p, new_schemas = ListProperty.build( name=name, @@ -76,10 +78,101 @@ def test_build_list_property(any_property_factory, config): schemas=schemas, parent_name="parent", config=config, - roots={"root"}, + roots={ReferencePath("root")}, process_properties=True, ) assert isinstance(p, properties.ListProperty) assert p.inner_property == any_property_factory(name=f"{name}_item") assert new_schemas == schemas + + +def test_build_list_property_single_prefix_item(any_property_factory, config): + from openapi_python_client.parser import properties + + name = "prop" + data = oai.Schema( + type=DataType.ARRAY, + prefixItems=[oai.Schema()], + ) + schemas = properties.Schemas(errors=[ParseError("error")]) + + p, new_schemas = ListProperty.build( + name=name, + required=True, + data=data, + schemas=schemas, + parent_name="parent", + config=config, + roots={ReferencePath("root")}, + process_properties=True, + ) + + assert isinstance(p, properties.ListProperty) + assert p.inner_property == any_property_factory(name=f"{name}_item") + assert new_schemas == schemas + + +def test_build_list_property_items_and_prefix_items( + union_property_factory, + string_property_factory, + none_property_factory, + int_property_factory, + config, +): + from openapi_python_client.parser import properties + + name = "list_prop" + required = True + data = oai.Schema( + type=DataType.ARRAY, + items=oai.Schema(type=DataType.INTEGER), + prefixItems=[oai.Schema(type=DataType.STRING), oai.Schema(type=DataType.NULL)], + ) + schemas = properties.Schemas() + + p, new_schemas = ListProperty.build( + name=name, + required=required, + data=data, + schemas=schemas, + parent_name="parent", + config=config, + process_properties=True, + roots={ReferencePath("root")}, + ) + + assert isinstance(p, properties.ListProperty) + assert p.inner_property == union_property_factory( + name=f"{name}_item", + inner_properties=[ + string_property_factory(name=f"{name}_item_type_0"), + none_property_factory(name=f"{name}_item_type_1"), + int_property_factory(name=f"{name}_item_type_2"), + ], + ) + assert new_schemas == schemas + + +def test_build_list_property_prefix_items_only(any_property_factory, config): + from openapi_python_client.parser import properties + + name = "list_prop" + required = True + data = oai.Schema(type=DataType.ARRAY, prefixItems=[oai.Schema()]) + schemas = properties.Schemas() + + p, new_schemas = ListProperty.build( + name=name, + required=required, + data=data, + schemas=schemas, + parent_name="parent", + config=config, + process_properties=True, + roots={ReferencePath("root")}, + ) + + assert isinstance(p, properties.ListProperty) + assert p.inner_property == any_property_factory(name=f"{name}_item") + assert new_schemas == schemas From e2139cbb0da47c9f13f034fd6b0c037954187d6e Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Sun, 20 Oct 2024 15:46:27 -0600 Subject: [PATCH 2/2] Update snapshots --- end_to_end_tests/__snapshots__/test_end_to_end.ambr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/end_to_end_tests/__snapshots__/test_end_to_end.ambr b/end_to_end_tests/__snapshots__/test_end_to_end.ambr index dac0127aa..fc962aa96 100644 --- a/end_to_end_tests/__snapshots__/test_end_to_end.ambr +++ b/end_to_end_tests/__snapshots__/test_end_to_end.ambr @@ -36,7 +36,7 @@ Path parameter must be required - Parameter(name='optional', param_in=, description=None, required=False, deprecated=False, allowEmptyValue=False, style=None, explode=False, allowReserved=False, param_schema=Schema(title=None, multipleOf=None, maximum=None, exclusiveMaximum=None, minimum=None, exclusiveMinimum=None, maxLength=None, minLength=None, pattern=None, maxItems=None, minItems=None, uniqueItems=None, maxProperties=None, minProperties=None, required=None, enum=None, const=None, type=, allOf=[], oneOf=[], anyOf=[], schema_not=None, items=None, properties=None, additionalProperties=None, description=None, schema_format=None, default=None, nullable=False, discriminator=None, readOnly=None, writeOnly=None, xml=None, externalDocs=None, example=None, deprecated=None), example=None, examples=None, content=None) + Parameter(name='optional', param_in=, description=None, required=False, deprecated=False, allowEmptyValue=False, style=None, explode=False, allowReserved=False, param_schema=Schema(title=None, multipleOf=None, maximum=None, exclusiveMaximum=None, minimum=None, exclusiveMinimum=None, maxLength=None, minLength=None, pattern=None, maxItems=None, minItems=None, uniqueItems=None, maxProperties=None, minProperties=None, required=None, enum=None, const=None, type=, allOf=[], oneOf=[], anyOf=[], schema_not=None, items=None, prefixItems=[], properties=None, additionalProperties=None, description=None, schema_format=None, default=None, nullable=False, discriminator=None, readOnly=None, writeOnly=None, xml=None, externalDocs=None, example=None, deprecated=None), example=None, examples=None, content=None) If you believe this was a mistake or this tool is missing a feature you need, please open an issue at https://github.com/openapi-generators/openapi-python-client/issues/new/choose