diff --git a/.changeset/add_generate_all_tags_config_option.md b/.changeset/add_generate_all_tags_config_option.md new file mode 100644 index 000000000..fb74b9fb0 --- /dev/null +++ b/.changeset/add_generate_all_tags_config_option.md @@ -0,0 +1,8 @@ +--- +default: minor +--- + +# Add `generate_all_tags` config option + +You can now, optionally, generate **duplicate** endpoint functions/modules using _every_ tag for an endpoint, +not just the first one, by setting `generate_all_tags: true` in your configuration file. diff --git a/README.md b/README.md index 871f3a296..a184be377 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,16 @@ literal_enums: true This is especially useful if enum values, when transformed to their Python names, end up conflicting due to case sensitivity or special symbols. +### generate_all_tags + +`openapi-python-client` generates module names within the `api` module based on the OpenAPI `tags` of each endpoint. +By default, only the _first_ tag is generated. If you want to generate **duplicate** endpoint functions using _every_ tag +listed, you can enable this option: + +```yaml +generate_all_tags: true +``` + ### project_name_override and package_name_override Used to change the name of generated client library project/package. If the project name is changed but an override for the package name 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 c87445ffb..525f8baf2 100644 --- a/end_to_end_tests/__snapshots__/test_end_to_end.ambr +++ b/end_to_end_tests/__snapshots__/test_end_to_end.ambr @@ -1,4 +1,18 @@ # serializer version: 1 +# name: test_documents_with_errors[bad-status-code] + ''' + Generating /test-documents-with-errors + Warning(s) encountered while generating. Client was generated, but some pieces may be missing + + WARNING parsing GET / within default. + + Invalid response status code abcdef (not a valid HTTP status code), response will be omitted from generated client + + + 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 + + ''' +# --- # name: test_documents_with_errors[circular-body-ref] ''' Generating /test-documents-with-errors diff --git a/end_to_end_tests/baseline_openapi_3.0.json b/end_to_end_tests/baseline_openapi_3.0.json index 22a786a4f..dc2092dfe 100644 --- a/end_to_end_tests/baseline_openapi_3.0.json +++ b/end_to_end_tests/baseline_openapi_3.0.json @@ -1149,9 +1149,7 @@ }, "/tag_with_number": { "get": { - "tags": [ - "1" - ], + "tags": ["1", "2"], "responses": { "200": { "description": "Success" diff --git a/end_to_end_tests/baseline_openapi_3.1.yaml b/end_to_end_tests/baseline_openapi_3.1.yaml index a19e46ce3..42d5b7384 100644 --- a/end_to_end_tests/baseline_openapi_3.1.yaml +++ b/end_to_end_tests/baseline_openapi_3.1.yaml @@ -1141,9 +1141,7 @@ info: }, "/tag_with_number": { "get": { - "tags": [ - "1" - ], + "tags": ["1", "2"], "responses": { "200": { "description": "Success" diff --git a/end_to_end_tests/config.yml b/end_to_end_tests/config.yml index 64e58439a..a813deddd 100644 --- a/end_to_end_tests/config.yml +++ b/end_to_end_tests/config.yml @@ -11,3 +11,4 @@ class_overrides: field_prefix: attr_ content_type_overrides: openapi/python/client: application/json +generate_all_tags: true 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 69973bee2..d1102fa1a 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 @@ -11,6 +11,7 @@ from .parameters import ParametersEndpoints from .responses import ResponsesEndpoints from .tag1 import Tag1Endpoints +from .tag2 import Tag2Endpoints from .tests import TestsEndpoints from .true_ import True_Endpoints @@ -48,6 +49,10 @@ def parameters(cls) -> type[ParametersEndpoints]: def tag1(cls) -> type[Tag1Endpoints]: return Tag1Endpoints + @classmethod + def tag2(cls) -> type[Tag2Endpoints]: + return Tag2Endpoints + @classmethod def location(cls) -> type[LocationEndpoints]: return LocationEndpoints diff --git a/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/tag2/__init__.py b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/tag2/__init__.py new file mode 100644 index 000000000..65edddf25 --- /dev/null +++ b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/tag2/__init__.py @@ -0,0 +1,11 @@ +"""Contains methods for accessing the API Endpoints""" + +import types + +from . import get_tag_with_number + + +class Tag2Endpoints: + @classmethod + def get_tag_with_number(cls) -> types.ModuleType: + return get_tag_with_number diff --git a/end_to_end_tests/documents_with_errors/bad-status-code.yaml b/end_to_end_tests/documents_with_errors/bad-status-code.yaml new file mode 100644 index 000000000..17c3ab2cf --- /dev/null +++ b/end_to_end_tests/documents_with_errors/bad-status-code.yaml @@ -0,0 +1,14 @@ +openapi: "3.1.0" +info: + title: "There's something wrong with me" + version: "0.1.0" +paths: + "/": + get: + responses: + "abcdef": + description: "Successful Response" + content: + "application/json": + schema: + const: "Why have a fixed response? I dunno" diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/tag2/__init__.py b/end_to_end_tests/golden-record/my_test_api_client/api/tag2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/tag2/get_tag_with_number.py b/end_to_end_tests/golden-record/my_test_api_client/api/tag2/get_tag_with_number.py new file mode 100644 index 000000000..62631355f --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/api/tag2/get_tag_with_number.py @@ -0,0 +1,77 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/tag_with_number", + } + + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]: + 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: Union[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: Union[AuthenticatedClient, Client], +) -> Response[Any]: + """ + 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() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, + client: Union[AuthenticatedClient, Client], +) -> Response[Any]: + """ + 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() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/openapi_python_client/config.py b/openapi_python_client/config.py index c7f5d8ad9..9cc002d12 100644 --- a/openapi_python_client/config.py +++ b/openapi_python_client/config.py @@ -42,6 +42,7 @@ class ConfigFile(BaseModel): use_path_prefixes_for_title_model_names: bool = True post_hooks: Optional[list[str]] = None field_prefix: str = "field_" + generate_all_tags: bool = False http_timeout: int = 5 literal_enums: bool = False @@ -70,6 +71,7 @@ class Config: use_path_prefixes_for_title_model_names: bool post_hooks: list[str] field_prefix: str + generate_all_tags: bool http_timeout: int literal_enums: bool document_source: Union[Path, str] @@ -110,6 +112,7 @@ def from_sources( use_path_prefixes_for_title_model_names=config_file.use_path_prefixes_for_title_model_names, post_hooks=post_hooks, field_prefix=config_file.field_prefix, + generate_all_tags=config_file.generate_all_tags, http_timeout=config_file.http_timeout, literal_enums=config_file.literal_enums, document_source=document_source, diff --git a/openapi_python_client/parser/openapi.py b/openapi_python_client/parser/openapi.py index 43e63c434..117b2ee30 100644 --- a/openapi_python_client/parser/openapi.py +++ b/openapi_python_client/parser/openapi.py @@ -63,13 +63,18 @@ def from_data( operation: Optional[oai.Operation] = getattr(path_data, method) if operation is None: continue - tag = utils.PythonIdentifier(value=(operation.tags or ["default"])[0], prefix="tag") - collection = endpoints_by_tag.setdefault(tag, EndpointCollection(tag=tag)) + + tags = [utils.PythonIdentifier(value=tag, prefix="tag") for tag in operation.tags or ["default"]] + if not config.generate_all_tags: + tags = tags[:1] + + collections = [endpoints_by_tag.setdefault(tag, EndpointCollection(tag=tag)) for tag in tags] + endpoint, schemas, parameters = Endpoint.from_data( data=operation, path=path, method=method, - tag=tag, + tags=tags, schemas=schemas, parameters=parameters, request_bodies=request_bodies, @@ -87,15 +92,16 @@ def from_data( if not isinstance(endpoint, ParseError): endpoint = Endpoint.sort_parameters(endpoint=endpoint) if isinstance(endpoint, ParseError): - endpoint.header = ( - f"WARNING parsing {method.upper()} {path} within {tag}. Endpoint will not be generated." - ) - collection.parse_errors.append(endpoint) + endpoint.header = f"WARNING parsing {method.upper()} {path} within {'/'.join(tags)}. Endpoint will not be generated." + for collection in collections: + collection.parse_errors.append(endpoint) continue for error in endpoint.errors: - error.header = f"WARNING parsing {method.upper()} {path} within {tag}." - collection.parse_errors.append(error) - collection.endpoints.append(endpoint) + error.header = f"WARNING parsing {method.upper()} {path} within {'/'.join(tags)}." + for collection in collections: + collection.parse_errors.append(error) + for collection in collections: + collection.endpoints.append(endpoint) return endpoints_by_tag, schemas, parameters @@ -132,7 +138,7 @@ class Endpoint: description: Optional[str] name: str requires_security: bool - tag: str + tags: list[PythonIdentifier] summary: Optional[str] = "" relative_imports: set[str] = field(default_factory=set) query_parameters: list[Property] = field(default_factory=list) @@ -393,7 +399,7 @@ def from_data( data: oai.Operation, path: str, method: str, - tag: str, + tags: list[PythonIdentifier], schemas: Schemas, parameters: Parameters, request_bodies: dict[str, Union[oai.RequestBody, oai.Reference]], @@ -413,7 +419,7 @@ def from_data( description=utils.remove_string_escapes(data.description) if data.description else "", name=name, requires_security=bool(data.security), - tag=tag, + tags=tags, ) result, schemas, parameters = Endpoint.add_parameters( diff --git a/tests/test_parser/test_openapi.py b/tests/test_parser/test_openapi.py index 6eeadcd78..57a07070b 100644 --- a/tests/test_parser/test_openapi.py +++ b/tests/test_parser/test_openapi.py @@ -67,7 +67,7 @@ def make_endpoint(self): description=None, name="name", requires_security=False, - tag="tag", + tags=["tag"], relative_imports={"import_3"}, ) @@ -472,7 +472,7 @@ def test_from_data_bad_params(self, mocker, config): data=data, path=path, method=method, - tag="default", + tags=["default"], schemas=initial_schemas, parameters=parameters, config=config, @@ -507,7 +507,7 @@ def test_from_data_bad_responses(self, mocker, config): data=data, path=path, method=method, - tag="default", + tags=["default"], schemas=initial_schemas, parameters=initial_parameters, config=config, @@ -547,7 +547,7 @@ def test_from_data_standard(self, mocker, config): data=data, path=path, method=method, - tag="default", + tags=["default"], schemas=initial_schemas, parameters=initial_parameters, config=config, @@ -562,7 +562,7 @@ def test_from_data_standard(self, mocker, config): summary="", name=data.operationId, requires_security=True, - tag="default", + tags=["default"], ), data=data, schemas=initial_schemas, @@ -598,7 +598,7 @@ def test_from_data_no_operation_id(self, mocker, config): data=data, path=path, method=method, - tag="default", + tags=["default"], schemas=schemas, parameters=parameters, config=config, @@ -613,7 +613,7 @@ def test_from_data_no_operation_id(self, mocker, config): summary="", name="get_path_with_param", requires_security=True, - tag="default", + tags=["default"], ), data=data, schemas=schemas, @@ -652,7 +652,7 @@ def test_from_data_no_security(self, mocker, config): data=data, path=path, method=method, - tag="a", + tags=["a"], schemas=schemas, parameters=parameters, config=config, @@ -667,7 +667,7 @@ def test_from_data_no_security(self, mocker, config): summary="", name=data.operationId, requires_security=False, - tag="a", + tags=["a"], ), data=data, parameters=parameters, @@ -695,7 +695,7 @@ def test_from_data_some_bad_bodies(self, config): schemas=Schemas(), config=config, parameters=Parameters(), - tag="tag", + tags=["tag"], path="/", method="get", request_bodies={}, @@ -718,7 +718,7 @@ def test_from_data_all_bodies_bad(self, config): schemas=Schemas(), config=config, parameters=Parameters(), - tag="tag", + tags=["tag"], path="/", method="get", request_bodies={}, @@ -790,93 +790,3 @@ def test_from_data_overrides_path_item_params_with_operation_params(self, config ) collection: EndpointCollection = collections["default"] assert isinstance(collection.endpoints[0].query_parameters[0], IntProperty) - - def test_from_data_errors(self, mocker, config): - from openapi_python_client.parser.openapi import ParseError - - path_1_put = oai.Operation.model_construct() - path_1_post = oai.Operation.model_construct(tags=["tag_2", "tag_3"]) - path_2_get = oai.Operation.model_construct() - data = { - "path_1": oai.PathItem.model_construct(post=path_1_post, put=path_1_put), - "path_2": oai.PathItem.model_construct(get=path_2_get), - } - schemas_1 = mocker.MagicMock() - schemas_2 = mocker.MagicMock() - schemas_3 = mocker.MagicMock() - parameters_1 = mocker.MagicMock() - parameters_2 = mocker.MagicMock() - parameters_3 = mocker.MagicMock() - mocker.patch.object( - Endpoint, - "from_data", - side_effect=[ - (ParseError(data="1"), schemas_1, parameters_1), - (ParseError(data="2"), schemas_2, parameters_2), - (mocker.MagicMock(errors=[ParseError(data="3")], path="path_2"), schemas_3, parameters_3), - ], - ) - schemas = mocker.MagicMock() - parameters = mocker.MagicMock() - - result, result_schemas, result_parameters = EndpointCollection.from_data( - data=data, - schemas=schemas, - config=config, - parameters=parameters, - request_bodies={}, - ) - - assert result["default"].parse_errors[0].data == "1" - assert result["default"].parse_errors[1].data == "3" - assert result["tag_2"].parse_errors[0].data == "2" - assert result_schemas == schemas_3 - - def test_from_data_tags_snake_case_sanitizer(self, mocker, config): - from openapi_python_client.parser.openapi import Endpoint, EndpointCollection - - path_1_put = oai.Operation.model_construct() - path_1_post = oai.Operation.model_construct(tags=["AMF Subscription Info (Document)", "tag_3"]) - path_2_get = oai.Operation.model_construct(tags=["3. ABC"]) - data = { - "path_1": oai.PathItem.model_construct(post=path_1_post, put=path_1_put), - "path_2": oai.PathItem.model_construct(get=path_2_get), - } - endpoint_1 = mocker.MagicMock(autospec=Endpoint, tag="default", relative_imports={"1", "2"}, path="path_1") - endpoint_2 = mocker.MagicMock( - autospec=Endpoint, tag="AMFSubscriptionInfo (Document)", relative_imports={"2"}, path="path_1" - ) - endpoint_3 = mocker.MagicMock(autospec=Endpoint, tag="default", relative_imports={"2", "3"}, path="path_2") - schemas_1 = mocker.MagicMock() - schemas_2 = mocker.MagicMock() - schemas_3 = mocker.MagicMock() - parameters_1 = mocker.MagicMock() - parameters_2 = mocker.MagicMock() - parameters_3 = mocker.MagicMock() - mocker.patch.object( - Endpoint, - "from_data", - side_effect=[ - (endpoint_1, schemas_1, parameters_1), - (endpoint_2, schemas_2, parameters_2), - (endpoint_3, schemas_3, parameters_3), - ], - ) - schemas = mocker.MagicMock() - parameters = mocker.MagicMock() - - result = EndpointCollection.from_data( - data=data, schemas=schemas, parameters=parameters, config=config, request_bodies={} - ) - - assert result == ( - { - "default": EndpointCollection("default", endpoints=[endpoint_1]), - "amf_subscription_info_document": EndpointCollection( - "amf_subscription_info_document", endpoints=[endpoint_2] - ), - "tag3_abc": EndpointCollection("tag3_abc", endpoints=[endpoint_3]), - }, - schemas_3, - parameters_3, - )