From cc4db1641ca61b8d19e7863418e09bbae16d6027 Mon Sep 17 00:00:00 2001 From: German Arutyunov Date: Fri, 22 Mar 2024 16:49:49 +0400 Subject: [PATCH 1/2] content_type_overrides --- openapi_python_client/config.py | 3 ++ openapi_python_client/parser/responses.py | 8 +++-- tests/test_parser/test_responses.py | 42 +++++++++++++++++++++-- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/openapi_python_client/config.py b/openapi_python_client/config.py index 73aac11a7..f779d90ac 100644 --- a/openapi_python_client/config.py +++ b/openapi_python_client/config.py @@ -35,6 +35,7 @@ class ConfigFile(BaseModel): """ class_overrides: Optional[Dict[str, ClassOverride]] = None + content_type_overrides: Optional[Dict[str, str]] = None project_name_override: Optional[str] = None package_name_override: Optional[str] = None package_version_override: Optional[str] = None @@ -70,6 +71,7 @@ class Config: http_timeout: int document_source: Union[Path, str] file_encoding: str + content_type_overrides: Dict[str, str] @staticmethod def from_sources( @@ -91,6 +93,7 @@ def from_sources( config = Config( meta_type=meta_type, class_overrides=config_file.class_overrides or {}, + content_type_overrides=config_file.content_type_overrides or {}, project_name_override=config_file.project_name_override, package_name_override=config_file.package_name_override, package_version_override=config_file.package_version_override, diff --git a/openapi_python_client/parser/responses.py b/openapi_python_client/parser/responses.py index 3a22deb71..f42ee88a1 100644 --- a/openapi_python_client/parser/responses.py +++ b/openapi_python_client/parser/responses.py @@ -37,8 +37,10 @@ class Response: data: Union[oai.Response, oai.Reference] # Original data which created this response, useful for custom templates -def _source_by_content_type(content_type: str) -> Optional[_ResponseSource]: - parsed_content_type = utils.get_content_type(content_type) +def _source_by_content_type(content_type: str, config: Config) -> Optional[_ResponseSource]: + parsed_content_type = utils.get_content_type( + config.content_type_overrides.get(content_type, content_type) + ) if parsed_content_type is None: return None @@ -114,7 +116,7 @@ def response_from_data( ) for content_type, media_type in content.items(): - source = _source_by_content_type(content_type) + source = _source_by_content_type(content_type, config) if source is not None: schema_data = media_type.media_type_schema break diff --git a/tests/test_parser/test_responses.py b/tests/test_parser/test_responses.py index 0342112c5..7a4a901e6 100644 --- a/tests/test_parser/test_responses.py +++ b/tests/test_parser/test_responses.py @@ -63,12 +63,14 @@ def test_response_from_data_unsupported_content_type(): from openapi_python_client.parser.responses import response_from_data data = oai.Response.model_construct(description="", content={"blah": None}) + config = MagicMock() + config.content_type_overrides = {} response, schemas = response_from_data( status_code=200, data=data, schemas=Schemas(), parent_name="parent", - config=MagicMock(), + config=config, ) assert response == ParseError(data=data, detail="Unsupported content_type {'blah': None}") @@ -81,12 +83,14 @@ def test_response_from_data_no_content_schema(any_property_factory): description="", content={"application/vnd.api+json; version=2.2": oai.MediaType.model_construct()}, ) + config = MagicMock() + config.content_type_overrides = {} response, schemas = response_from_data( status_code=200, data=data, schemas=Schemas(), parent_name="parent", - config=MagicMock(), + config=config, ) assert response == Response( @@ -111,6 +115,7 @@ def test_response_from_data_property_error(mocker): content={"application/json": oai.MediaType.model_construct(media_type_schema="something")}, ) config = MagicMock() + config.content_type_overrides = {} response, schemas = responses.response_from_data( status_code=400, @@ -141,6 +146,7 @@ def test_response_from_data_property(mocker, any_property_factory): content={"application/json": oai.MediaType.model_construct(media_type_schema="something")}, ) config = MagicMock() + config.content_type_overrides = {} response, schemas = responses.response_from_data( status_code=400, @@ -164,3 +170,35 @@ def test_response_from_data_property(mocker, any_property_factory): parent_name="parent", config=config, ) + + +def test_response_from_data_content_type_overrides(any_property_factory): + from openapi_python_client.parser.responses import Response, response_from_data + + data = oai.Response.model_construct( + description="", + content={"application/zip": oai.MediaType.model_construct()}, + ) + config = MagicMock() + config.content_type_overrides = { + "application/zip": "application/octet-stream" + } + response, schemas = response_from_data( + status_code=200, + data=data, + schemas=Schemas(), + parent_name="parent", + config=config, + ) + + assert response == Response( + status_code=200, + prop=any_property_factory( + name="response_200", + default=None, + required=True, + description=data.description, + ), + source=NONE_SOURCE, + data=data, + ) From 0c0664b8b3a5b75aebd5ef0d843958539478001f Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Tue, 26 Mar 2024 18:27:08 -0600 Subject: [PATCH 2/2] Add support for bodies, add e2e test, document changes --- ...config_option_to_override_content_types.md | 16 ++ README.md | 10 ++ end_to_end_tests/baseline_openapi_3.0.json | 30 ++++ end_to_end_tests/baseline_openapi_3.1.yaml | 30 ++++ end_to_end_tests/config.yml | 2 + .../my_test_api_client/api/__init__.py | 5 + .../my_test_api_client/api/config/__init__.py | 14 ++ .../my_test_api_client/api/config/__init__.py | 0 .../api/config/content_type_override.py | 153 ++++++++++++++++++ openapi_python_client/parser/bodies.py | 2 +- openapi_python_client/parser/responses.py | 4 +- openapi_python_client/utils.py | 5 +- tests/test_parser/test_responses.py | 4 +- tests/test_utils.py | 4 +- 14 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 .changeset/add_config_option_to_override_content_types.md create mode 100644 end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/config/__init__.py create mode 100644 end_to_end_tests/golden-record/my_test_api_client/api/config/__init__.py create mode 100644 end_to_end_tests/golden-record/my_test_api_client/api/config/content_type_override.py diff --git a/.changeset/add_config_option_to_override_content_types.md b/.changeset/add_config_option_to_override_content_types.md new file mode 100644 index 000000000..2c9bec8a6 --- /dev/null +++ b/.changeset/add_config_option_to_override_content_types.md @@ -0,0 +1,16 @@ +--- +default: minor +--- + +# Add config option to override content types + +You can now define a `content_type_overrides` field in your `config.yml`: + +```yaml +content_type_overrides: + application/zip: application/octet-stream +``` + +This allows `openapi-python-client` to generate code for content types it doesn't recognize. + +PR #1010 closes #810. Thanks @gaarutyunov! diff --git a/README.md b/README.md index 2c5d0b0b4..4994c0f78 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,16 @@ If this option results in conflicts, you will need to manually override class na By default, the timeout for retrieving the schema file via HTTP is 5 seconds. In case there is an error when retrieving the schema, you might try and increase this setting to a higher value. +### content_type_overrides + +Normally, `openapi-python-client` will skip any bodies or responses that it doesn't recognize the content type for. +This config tells the generator to treat a given content type like another. + +```yaml +content_type_overrides: + application/zip: application/octet-stream +``` + [changelog.md]: CHANGELOG.md [poetry]: https://python-poetry.org/ [PDM]: https://pdm-project.org/latest/ diff --git a/end_to_end_tests/baseline_openapi_3.0.json b/end_to_end_tests/baseline_openapi_3.0.json index e70de4c99..14fdd7c42 100644 --- a/end_to_end_tests/baseline_openapi_3.0.json +++ b/end_to_end_tests/baseline_openapi_3.0.json @@ -1578,6 +1578,36 @@ } } } + }, + "/config/content-type-override": { + "post": { + "tags": [ + "config" + ], + "summary": "Content Type Override", + "operationId": "content_type_override", + "requestBody": { + "content": { + "openapi/python/client": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "openapi/python/client": { + "schema": { + "type": "string" + } + } + } + } + } + } } }, "components": { diff --git a/end_to_end_tests/baseline_openapi_3.1.yaml b/end_to_end_tests/baseline_openapi_3.1.yaml index 1b5664e77..a0e762a95 100644 --- a/end_to_end_tests/baseline_openapi_3.1.yaml +++ b/end_to_end_tests/baseline_openapi_3.1.yaml @@ -1572,6 +1572,36 @@ info: } } } + }, + "/config/content-type-override": { + "post": { + "tags": [ + "config" + ], + "summary": "Content Type Override", + "operationId": "content_type_override", + "requestBody": { + "content": { + "openapi/python/client": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "openapi/python/client": { + "schema": { + "type": "string" + } + } + } + } + } + } } } "components": { diff --git a/end_to_end_tests/config.yml b/end_to_end_tests/config.yml index 05ac674fc..64e58439a 100644 --- a/end_to_end_tests/config.yml +++ b/end_to_end_tests/config.yml @@ -9,3 +9,5 @@ class_overrides: class_name: AnEnumValue module_name: an_enum_value field_prefix: attr_ +content_type_overrides: + openapi/python/client: application/json diff --git a/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/__init__.py b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/__init__.py index f03fd5cfa..79a699a1e 100644 --- a/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/__init__.py +++ b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/__init__.py @@ -3,6 +3,7 @@ from typing import Type from .bodies import BodiesEndpoints +from .config import ConfigEndpoints from .default import DefaultEndpoints from .defaults import DefaultsEndpoints from .enums import EnumsEndpoints @@ -64,3 +65,7 @@ def naming(cls) -> Type[NamingEndpoints]: @classmethod def parameter_references(cls) -> Type[ParameterReferencesEndpoints]: return ParameterReferencesEndpoints + + @classmethod + def config(cls) -> Type[ConfigEndpoints]: + return ConfigEndpoints diff --git a/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/config/__init__.py b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/config/__init__.py new file mode 100644 index 000000000..3e07e8d69 --- /dev/null +++ b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/config/__init__.py @@ -0,0 +1,14 @@ +"""Contains methods for accessing the API Endpoints""" + +import types + +from . import content_type_override + + +class ConfigEndpoints: + @classmethod + def content_type_override(cls) -> types.ModuleType: + """ + Content Type Override + """ + return content_type_override diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/config/__init__.py b/end_to_end_tests/golden-record/my_test_api_client/api/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/config/content_type_override.py b/end_to_end_tests/golden-record/my_test_api_client/api/config/content_type_override.py new file mode 100644 index 000000000..4e9381a74 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/api/config/content_type_override.py @@ -0,0 +1,153 @@ +from http import HTTPStatus +from typing import Any, Dict, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + *, + body: str, +) -> Dict[str, Any]: + headers: Dict[str, Any] = {} + + _kwargs: Dict[str, Any] = { + "method": "post", + "url": "/config/content-type-override", + } + + _body = body + + _kwargs["json"] = _body + headers["Content-Type"] = "openapi/python/client" + + _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: str, +) -> Response[str]: + """Content Type Override + + Args: + body (str): + + 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: str, +) -> Optional[str]: + """Content Type Override + + Args: + body (str): + + 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: str, +) -> Response[str]: + """Content Type Override + + Args: + body (str): + + 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: str, +) -> Optional[str]: + """Content Type Override + + Args: + body (str): + + 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/openapi_python_client/parser/bodies.py b/openapi_python_client/parser/bodies.py index 9ab42cb4f..6b8e4ad72 100644 --- a/openapi_python_client/parser/bodies.py +++ b/openapi_python_client/parser/bodies.py @@ -56,7 +56,7 @@ def body_from_data( prefix_type_names = len(body_content) > 1 for content_type, media_type in body_content.items(): - simplified_content_type = get_content_type(content_type) + simplified_content_type = get_content_type(content_type, config) if simplified_content_type is None: bodies.append( ParseError( diff --git a/openapi_python_client/parser/responses.py b/openapi_python_client/parser/responses.py index f42ee88a1..32412fd35 100644 --- a/openapi_python_client/parser/responses.py +++ b/openapi_python_client/parser/responses.py @@ -38,9 +38,7 @@ class Response: def _source_by_content_type(content_type: str, config: Config) -> Optional[_ResponseSource]: - parsed_content_type = utils.get_content_type( - config.content_type_overrides.get(content_type, content_type) - ) + parsed_content_type = utils.get_content_type(content_type, config) if parsed_content_type is None: return None diff --git a/openapi_python_client/utils.py b/openapi_python_client/utils.py index 834e2666c..22a7bcfa8 100644 --- a/openapi_python_client/utils.py +++ b/openapi_python_client/utils.py @@ -6,6 +6,8 @@ from keyword import iskeyword from typing import Any +from .config import Config + DELIMITERS = r"\. _-" @@ -105,10 +107,11 @@ def remove_string_escapes(value: str) -> str: return value.replace('"', r"\"") -def get_content_type(content_type: str) -> str | None: +def get_content_type(content_type: str, config: Config) -> str | None: """ Given a string representing a content type with optional parameters, returns the content type only """ + content_type = config.content_type_overrides.get(content_type, content_type) message = Message() message.add_header("Content-Type", content_type) diff --git a/tests/test_parser/test_responses.py b/tests/test_parser/test_responses.py index 7a4a901e6..0ac885764 100644 --- a/tests/test_parser/test_responses.py +++ b/tests/test_parser/test_responses.py @@ -180,9 +180,7 @@ def test_response_from_data_content_type_overrides(any_property_factory): content={"application/zip": oai.MediaType.model_construct()}, ) config = MagicMock() - config.content_type_overrides = { - "application/zip": "application/octet-stream" - } + config.content_type_overrides = {"application/zip": "application/octet-stream"} response, schemas = response_from_data( status_code=200, data=data, diff --git a/tests/test_utils.py b/tests/test_utils.py index 3cd213488..e7dccf9a8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -132,5 +132,5 @@ def test_pascalcase(before, after): pytest.param("application/vnd.api+json;charset=utf-8", "application/vnd.api+json"), ], ) -def test_get_content_type(content_type: str, expected: str) -> None: - assert utils.get_content_type(content_type) == expected +def test_get_content_type(content_type: str, expected: str, config) -> None: + assert utils.get_content_type(content_type, config) == expected