diff --git a/.changeset/fix_unset_import_for_required_multi_body_endpoints.md b/.changeset/fix_unset_import_for_required_multi_body_endpoints.md new file mode 100644 index 000000000..5757af827 --- /dev/null +++ b/.changeset/fix_unset_import_for_required_multi_body_endpoints.md @@ -0,0 +1,11 @@ +--- +default: patch +--- + +# Unset used but not imported for required multi-body endpoints + +#1478 by @rolandgeider + +Closes #1451 + +We ran into this while generating a client from our spec. The multi-body branch of the `arguments` macro read `body_required` before it was ever assigned, so `| Unset = UNSET` was appended to the `body` annotation even for a required request body. \ No newline at end of file diff --git a/end_to_end_tests/baseline_openapi_3.0.json b/end_to_end_tests/baseline_openapi_3.0.json index c47048218..3af5a9441 100644 --- a/end_to_end_tests/baseline_openapi_3.0.json +++ b/end_to_end_tests/baseline_openapi_3.0.json @@ -59,6 +59,60 @@ } } }, + "/bodies/multiple-required": { + "post": { + "description": "Test multiple bodies, all required", + "tags": [ + "bodies" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "a": { + "type": "string" + } + } + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "a": { + "type": "string" + } + } + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "a": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/bodies/json-like": { "post": { "tags": [ diff --git a/end_to_end_tests/baseline_openapi_3.1.yaml b/end_to_end_tests/baseline_openapi_3.1.yaml index 295e6818a..5f89bf903 100644 --- a/end_to_end_tests/baseline_openapi_3.1.yaml +++ b/end_to_end_tests/baseline_openapi_3.1.yaml @@ -57,6 +57,60 @@ info: } } }, + "/bodies/multiple-required": { + "post": { + "description": "Test multiple bodies, all required", + "tags": [ + "bodies" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "a": { + "type": "string" + } + } + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "a": { + "type": "string" + } + } + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "a": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/bodies/json-like": { "post": { "tags": [ "bodies" ], diff --git a/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/bodies/__init__.py b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/bodies/__init__.py index 5ff7fceb8..62ba953f8 100644 --- a/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/bodies/__init__.py +++ b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/bodies/__init__.py @@ -2,7 +2,7 @@ import types -from . import json_like, optional_body, post_bodies_multiple, refs +from . import json_like, optional_body, post_bodies_multiple, post_bodies_multiple_required, refs class BodiesEndpoints: @@ -13,6 +13,13 @@ def post_bodies_multiple(cls) -> types.ModuleType: """ return post_bodies_multiple + @classmethod + def post_bodies_multiple_required(cls) -> types.ModuleType: + """ + Test multiple bodies, all required + """ + return post_bodies_multiple_required + @classmethod def json_like(cls) -> types.ModuleType: """ diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/bodies/post_bodies_multiple_required.py b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/post_bodies_multiple_required.py new file mode 100644 index 000000000..d65901e95 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/post_bodies_multiple_required.py @@ -0,0 +1,131 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.post_bodies_multiple_required_data_body import PostBodiesMultipleRequiredDataBody +from ...models.post_bodies_multiple_required_files_body import PostBodiesMultipleRequiredFilesBody +from ...models.post_bodies_multiple_required_json_body import PostBodiesMultipleRequiredJsonBody +from ...types import File, Response + + +def _get_kwargs( + *, + body: PostBodiesMultipleRequiredJsonBody + | File + | PostBodiesMultipleRequiredDataBody + | PostBodiesMultipleRequiredFilesBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/bodies/multiple-required", + } + + if isinstance(body, PostBodiesMultipleRequiredJsonBody): + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + if isinstance(body, File): + _kwargs["content"] = body.payload + headers["Content-Type"] = "application/octet-stream" + if isinstance(body, PostBodiesMultipleRequiredDataBody): + _kwargs["data"] = body.to_dict() + headers["Content-Type"] = "application/x-www-form-urlencoded" + if isinstance(body, PostBodiesMultipleRequiredFilesBody): + _kwargs["files"] = body.to_multipart() + + headers["Content-Type"] = "multipart/form-data; boundary=+++" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | None: + if response.status_code == 200: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any]: + 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: AuthenticatedClient | Client, + body: PostBodiesMultipleRequiredJsonBody + | File + | PostBodiesMultipleRequiredDataBody + | PostBodiesMultipleRequiredFilesBody, +) -> Response[Any]: + """Test multiple bodies, all required + + Args: + body (PostBodiesMultipleRequiredJsonBody): + body (File): + body (PostBodiesMultipleRequiredDataBody): + body (PostBodiesMultipleRequiredFilesBody): + + 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[Any] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: PostBodiesMultipleRequiredJsonBody + | File + | PostBodiesMultipleRequiredDataBody + | PostBodiesMultipleRequiredFilesBody, +) -> Response[Any]: + """Test multiple bodies, all required + + Args: + body (PostBodiesMultipleRequiredJsonBody): + body (File): + body (PostBodiesMultipleRequiredDataBody): + body (PostBodiesMultipleRequiredFilesBody): + + 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[Any] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py b/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py index c62e4cfa6..85f7b834d 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py @@ -82,6 +82,9 @@ from .post_bodies_multiple_data_body import PostBodiesMultipleDataBody from .post_bodies_multiple_files_body import PostBodiesMultipleFilesBody from .post_bodies_multiple_json_body import PostBodiesMultipleJsonBody +from .post_bodies_multiple_required_data_body import PostBodiesMultipleRequiredDataBody +from .post_bodies_multiple_required_files_body import PostBodiesMultipleRequiredFilesBody +from .post_bodies_multiple_required_json_body import PostBodiesMultipleRequiredJsonBody from .post_form_data_inline_body import PostFormDataInlineBody from .post_naming_property_conflict_with_import_body import PostNamingPropertyConflictWithImportBody from .post_naming_property_conflict_with_import_response_200 import PostNamingPropertyConflictWithImportResponse200 @@ -171,6 +174,9 @@ "PostBodiesMultipleDataBody", "PostBodiesMultipleFilesBody", "PostBodiesMultipleJsonBody", + "PostBodiesMultipleRequiredDataBody", + "PostBodiesMultipleRequiredFilesBody", + "PostBodiesMultipleRequiredJsonBody", "PostFormDataInlineBody", "PostNamingPropertyConflictWithImportBody", "PostNamingPropertyConflictWithImportResponse200", diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/post_bodies_multiple_required_data_body.py b/end_to_end_tests/golden-record/my_test_api_client/models/post_bodies_multiple_required_data_body.py new file mode 100644 index 000000000..0f16f91b1 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/post_bodies_multiple_required_data_body.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PostBodiesMultipleRequiredDataBody") + + +@_attrs_define +class PostBodiesMultipleRequiredDataBody: + """ + Attributes: + a (str | Unset): + """ + + a: str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + a = self.a + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if a is not UNSET: + field_dict["a"] = a + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + a = d.pop("a", UNSET) + + post_bodies_multiple_required_data_body = cls( + a=a, + ) + + post_bodies_multiple_required_data_body.additional_properties = d + return post_bodies_multiple_required_data_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/end_to_end_tests/golden-record/my_test_api_client/models/post_bodies_multiple_required_files_body.py b/end_to_end_tests/golden-record/my_test_api_client/models/post_bodies_multiple_required_files_body.py new file mode 100644 index 000000000..0772f41af --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/post_bodies_multiple_required_files_body.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from .. import types +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PostBodiesMultipleRequiredFilesBody") + + +@_attrs_define +class PostBodiesMultipleRequiredFilesBody: + """ + Attributes: + a (str | Unset): + """ + + a: str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + a = self.a + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if a is not UNSET: + field_dict["a"] = a + + return field_dict + + def to_multipart(self) -> types.RequestFiles: + files: types.RequestFiles = [] + + if not isinstance(self.a, Unset): + files.append(("a", (None, str(self.a).encode(), "text/plain"))) + + for prop_name, prop in self.additional_properties.items(): + files.append((prop_name, (None, str(prop).encode(), "text/plain"))) + + return files + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + a = d.pop("a", UNSET) + + post_bodies_multiple_required_files_body = cls( + a=a, + ) + + post_bodies_multiple_required_files_body.additional_properties = d + return post_bodies_multiple_required_files_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/end_to_end_tests/golden-record/my_test_api_client/models/post_bodies_multiple_required_json_body.py b/end_to_end_tests/golden-record/my_test_api_client/models/post_bodies_multiple_required_json_body.py new file mode 100644 index 000000000..2ac92add7 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/post_bodies_multiple_required_json_body.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PostBodiesMultipleRequiredJsonBody") + + +@_attrs_define +class PostBodiesMultipleRequiredJsonBody: + """ + Attributes: + a (str | Unset): + """ + + a: str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + a = self.a + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if a is not UNSET: + field_dict["a"] = a + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + a = d.pop("a", UNSET) + + post_bodies_multiple_required_json_body = cls( + a=a, + ) + + post_bodies_multiple_required_json_body.additional_properties = d + return post_bodies_multiple_required_json_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/templates/endpoint_macros.py.jinja b/openapi_python_client/templates/endpoint_macros.py.jinja index 9688a4ba2..db2c36fe6 100644 --- a/openapi_python_client/templates/endpoint_macros.py.jinja +++ b/openapi_python_client/templates/endpoint_macros.py.jinja @@ -123,9 +123,9 @@ client: AuthenticatedClient | Client, body: {{ endpoint.bodies[0].prop.get_type_string() }}{% if not endpoint.bodies[0].prop.required %} = UNSET{% endif %}, {% elif endpoint.bodies | length > 1 %} body: - {%- for body in endpoint.bodies -%}{% set body_required = body_required and body.prop.required %} + {%- for body in endpoint.bodies -%} {{ body.prop.get_type_string(no_optional=True) }} {% if not loop.last %} | {% endif %} - {%- endfor -%}{% if not body_required %} | Unset = UNSET{% endif %} + {%- endfor -%}{% if endpoint.bodies | rejectattr("prop.required") | list %} | Unset = UNSET{% endif %} , {% endif %} {# query parameters #}