From ec65b5697d26fc42bc297175f164cd68e33f4928 Mon Sep 17 00:00:00 2001 From: Max Linke Date: Fri, 17 Jul 2026 08:16:12 +0200 Subject: [PATCH 1/2] fix: support content-only parameters --- .../test_content_parameters.py | 54 +++++++++++++++++++ openapi_python_client/parser/openapi.py | 9 +++- tests/test_parser/test_openapi.py | 25 +++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 end_to_end_tests/functional_tests/generated_code_execution/test_content_parameters.py diff --git a/end_to_end_tests/functional_tests/generated_code_execution/test_content_parameters.py b/end_to_end_tests/functional_tests/generated_code_execution/test_content_parameters.py new file mode 100644 index 000000000..66395a8fd --- /dev/null +++ b/end_to_end_tests/functional_tests/generated_code_execution/test_content_parameters.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock + +import httpx + +from end_to_end_tests.functional_tests.helpers import ( + with_generated_client_fixture, + with_generated_code_import, +) + + +@with_generated_client_fixture( +""" +paths: + "/items/{item_id}": + get: + operationId: getItem + parameters: + - name: item_id + in: path + required: true + content: + application/json: + schema: + type: string + enum: [a, b, c] + responses: + "200": + description: Success + content: + application/json: + schema: + type: string +""") +@with_generated_code_import(".api.default.get_item.sync_detailed") +@with_generated_code_import(".client.Client") +class TestContentOnlyPathParameters: + def test_content_only_path_parameter_generates_callable_endpoint(self, sync_detailed, Client): + mock_httpx_client = MagicMock(spec=httpx.Client) + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = "ok" + mock_response.content = b'"ok"' + mock_response.headers = {} + mock_httpx_client.request.return_value = mock_response + + client = Client(base_url="https://api.example.com") + client.set_httpx_client(mock_httpx_client) + + response = sync_detailed(item_id="a", client=client) + + mock_httpx_client.request.assert_called_once() + call_kwargs = mock_httpx_client.request.call_args[1] + assert call_kwargs["url"] == "/items/a" + assert response.parsed == "ok" diff --git a/openapi_python_client/parser/openapi.py b/openapi_python_client/parser/openapi.py index 4f83ae93e..85b00563d 100644 --- a/openapi_python_client/parser/openapi.py +++ b/openapi_python_client/parser/openapi.py @@ -249,7 +249,12 @@ def add_parameters( return param_or_error, schemas, parameters param = param_or_error # noqa: PLW2901 - if param.param_schema is None: + param_schema = param.param_schema + if param_schema is None and param.content: + first_media_type = next(iter(param.content.values())) + param_schema = first_media_type.media_type_schema + + if param_schema is None: continue unique_param = (param.name, param.param_in) @@ -278,7 +283,7 @@ def add_parameters( prop, new_schemas = property_from_data( name=param.name, required=param.required, - data=param.param_schema, + data=param_schema, schemas=schemas, parent_name=endpoint.name, config=config, diff --git a/tests/test_parser/test_openapi.py b/tests/test_parser/test_openapi.py index 3bd743804..7bb59a6a7 100644 --- a/tests/test_parser/test_openapi.py +++ b/tests/test_parser/test_openapi.py @@ -183,6 +183,31 @@ def test__add_parameters_skips_params_without_schemas(self, config): assert isinstance(endpoint, Endpoint) assert len(endpoint.path_parameters) == 0 + def test__add_parameters_uses_content_schema_when_param_schema_missing(self, config): + endpoint = self.make_endpoint() + data = oai.Operation.model_construct( + parameters=[ + oai.Parameter.model_construct( + name="item_id", + required=True, + param_in=oai.ParameterLocation.PATH, + content={ + "application/json": oai.MediaType.model_construct( + media_type_schema=oai.Schema.model_construct(type="string") + ) + }, + ), + ] + ) + + (endpoint, _, _) = endpoint.add_parameters( + endpoint=endpoint, data=data, schemas=Schemas(), parameters=Parameters(), config=config + ) + + assert isinstance(endpoint, Endpoint) + assert len(endpoint.path_parameters) == 1 + assert endpoint.path_parameters[0].name == "item_id" + def test__add_parameters_same_identifier_conflict(self, config): endpoint = self.make_endpoint() data = oai.Operation.model_construct( From 314e6522ba6677da5513656c67f7769d8c0321c9 Mon Sep 17 00:00:00 2001 From: Max Linke Date: Fri, 17 Jul 2026 08:31:17 +0200 Subject: [PATCH 2/2] fix: cover content-only parameters in 3.1 snapshots --- end_to_end_tests/3.1_specific.openapi.yaml | 19 +++ .../api/content/__init__.py | 1 + .../api/content/get_content_item_id.py | 146 ++++++++++++++++++ .../models/__init__.py | 2 + .../models/get_content_item_id_item_id.py | 10 ++ 5 files changed, 178 insertions(+) create mode 100644 end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/content/__init__.py create mode 100644 end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/content/get_content_item_id.py create mode 100644 end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/get_content_item_id_item_id.py diff --git a/end_to_end_tests/3.1_specific.openapi.yaml b/end_to_end_tests/3.1_specific.openapi.yaml index 04d693449..4693f63f6 100644 --- a/end_to_end_tests/3.1_specific.openapi.yaml +++ b/end_to_end_tests/3.1_specific.openapi.yaml @@ -4,6 +4,25 @@ info: description: "Test new OpenAPI 3.1 features" version: "0.1.0" paths: + "/content/{item_id}": + get: + tags: [ "content" ] + parameters: + - name: "item_id" + in: "path" + required: true + content: + "application/json": + schema: + type: string + enum: ["a", "b", "c"] + responses: + "200": + description: "Successful Response" + content: + "application/json": + schema: + type: string "/const/{path}": post: tags: [ "const" ] diff --git a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/content/__init__.py b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/content/__init__.py new file mode 100644 index 000000000..2d7c0b23d --- /dev/null +++ b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/content/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/content/get_content_item_id.py b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/content/get_content_item_id.py new file mode 100644 index 000000000..9afbacab7 --- /dev/null +++ b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/api/content/get_content_item_id.py @@ -0,0 +1,146 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.get_content_item_id_item_id import GetContentItemIdItemId +from ...types import Response + + +def _get_kwargs( + item_id: GetContentItemIdItemId, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/content/{item_id}".format( + item_id=quote(str(item_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> str | None: + if response.status_code == 200: + 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: 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( + item_id: GetContentItemIdItemId, + *, + client: AuthenticatedClient | Client, +) -> Response[str]: + """ + Args: + item_id (GetContentItemIdItemId): + + 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( + item_id=item_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + item_id: GetContentItemIdItemId, + *, + client: AuthenticatedClient | Client, +) -> str | None: + """ + Args: + item_id (GetContentItemIdItemId): + + 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( + item_id=item_id, + client=client, + ).parsed + + +async def asyncio_detailed( + item_id: GetContentItemIdItemId, + *, + client: AuthenticatedClient | Client, +) -> Response[str]: + """ + Args: + item_id (GetContentItemIdItemId): + + 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( + item_id=item_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + item_id: GetContentItemIdItemId, + *, + client: AuthenticatedClient | Client, +) -> str | None: + """ + Args: + item_id (GetContentItemIdItemId): + + 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( + item_id=item_id, + client=client, + ) + ).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 aeafedd08..4a47802af 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,9 +1,11 @@ """Contains all the data models used in inputs/outputs""" +from .get_content_item_id_item_id import GetContentItemIdItemId from .post_const_path_body import PostConstPathBody from .post_prefix_items_body import PostPrefixItemsBody __all__ = ( + "GetContentItemIdItemId", "PostConstPathBody", "PostPrefixItemsBody", ) diff --git a/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/get_content_item_id_item_id.py b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/get_content_item_id_item_id.py new file mode 100644 index 000000000..8441a014a --- /dev/null +++ b/end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/models/get_content_item_id_item_id.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class GetContentItemIdItemId(str, Enum): + A = "a" + B = "b" + C = "c" + + def __str__(self) -> str: + return str(self.value)