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 525f8baf2..ca9194a5f 100644 --- a/end_to_end_tests/__snapshots__/test_end_to_end.ambr +++ b/end_to_end_tests/__snapshots__/test_end_to_end.ambr @@ -69,7 +69,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, 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) + Parameter(name='optional', param_in=, description=None, required=False, deprecated=False, allowEmptyValue=False, style=, 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 diff --git a/end_to_end_tests/baseline_openapi_3.0.json b/end_to_end_tests/baseline_openapi_3.0.json index b536f7432..f022370f9 100644 --- a/end_to_end_tests/baseline_openapi_3.0.json +++ b/end_to_end_tests/baseline_openapi_3.0.json @@ -149,6 +149,32 @@ "name": "an_enum_value_with_only_null", "in": "query" }, + { + "required": true, + "schema": { + "title": "Non exploded array", + "type": "array", + "items": { + "type": "string" + } + }, + "name": "non_exploded_array", + "in": "query", + "explode": false + }, + { + "required": false, + "schema": { + "title": "Optional non exploded array", + "type": "array", + "items": { + "type": "string" + } + }, + "name": "optional_non_exploded_array", + "in": "query", + "explode": false + }, { "required": true, "schema": { diff --git a/end_to_end_tests/baseline_openapi_3.1.yaml b/end_to_end_tests/baseline_openapi_3.1.yaml index b9fb3e9f6..abd70bf82 100644 --- a/end_to_end_tests/baseline_openapi_3.1.yaml +++ b/end_to_end_tests/baseline_openapi_3.1.yaml @@ -145,6 +145,33 @@ info: "name": "an_enum_value_with_only_null", "in": "query" }, + { + "required": true, + "schema": { + "title": "Non exploded array", + "type": "array", + "items": { + "type": "string" + } + }, + "name": "non_exploded_array", + "in": "query", + "explode": false + }, + + { + "required": false, + "schema": { + "title": "Optional non exploded array", + "type": "array", + "items": { + "type": "string" + } + }, + "name": "optional_non_exploded_array", + "in": "query", + "explode": false + }, { "required": true, "schema": { diff --git a/end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py b/end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py index d2d560780..59df12b5f 100644 --- a/end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py +++ b/end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py @@ -111,6 +111,13 @@ def test_model_properties(self, MyModel): schema: type: boolean description: Do you want fries with that? + - name: array + in: query + required: false + schema: + type: array + items: + type: string responses: "200": description: Success! @@ -160,4 +167,5 @@ def test_params(self, get_attribute_by_index_sync): "id (str): Which one.", "index (int):", "fries (Union[Unset, bool]): Do you want fries with that?", + "array (Union[Unset, list[str]]):", ] diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/tests/get_user_list.py b/end_to_end_tests/golden-record/my_test_api_client/api/tests/get_user_list.py index a708cf71d..91187b58f 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/api/tests/get_user_list.py +++ b/end_to_end_tests/golden-record/my_test_api_client/api/tests/get_user_list.py @@ -10,7 +10,7 @@ from ...models.an_enum import AnEnum from ...models.an_enum_with_null import AnEnumWithNull from ...models.http_validation_error import HTTPValidationError -from ...types import UNSET, Response +from ...types import UNSET, Response, Unset def _get_kwargs( @@ -18,6 +18,8 @@ def _get_kwargs( an_enum_value: list[AnEnum], an_enum_value_with_null: list[Union[AnEnumWithNull, None]], an_enum_value_with_only_null: list[None], + non_exploded_array: list[str], + optional_non_exploded_array: Union[Unset, list[str]] = UNSET, some_date: Union[datetime.date, datetime.datetime], ) -> dict[str, Any]: params: dict[str, Any] = {} @@ -44,6 +46,17 @@ def _get_kwargs( params["an_enum_value_with_only_null"] = json_an_enum_value_with_only_null + json_non_exploded_array = non_exploded_array + + params["non_exploded_array"] = ",".join(str(item) for item in json_non_exploded_array) + + json_optional_non_exploded_array: Union[Unset, list[str]] = UNSET + if not isinstance(optional_non_exploded_array, Unset): + json_optional_non_exploded_array = optional_non_exploded_array + + if not isinstance(json_optional_non_exploded_array, Unset): + params["optional_non_exploded_array"] = ",".join(str(item) for item in json_optional_non_exploded_array) + json_some_date: str if isinstance(some_date, datetime.date): json_some_date = some_date.isoformat() @@ -106,6 +119,8 @@ def sync_detailed( an_enum_value: list[AnEnum], an_enum_value_with_null: list[Union[AnEnumWithNull, None]], an_enum_value_with_only_null: list[None], + non_exploded_array: list[str], + optional_non_exploded_array: Union[Unset, list[str]] = UNSET, some_date: Union[datetime.date, datetime.datetime], ) -> Response[Union[HTTPValidationError, list["AModel"]]]: """Get List @@ -116,6 +131,8 @@ def sync_detailed( an_enum_value (list[AnEnum]): an_enum_value_with_null (list[Union[AnEnumWithNull, None]]): an_enum_value_with_only_null (list[None]): + non_exploded_array (list[str]): + optional_non_exploded_array (Union[Unset, list[str]]): some_date (Union[datetime.date, datetime.datetime]): Raises: @@ -130,6 +147,8 @@ def sync_detailed( an_enum_value=an_enum_value, an_enum_value_with_null=an_enum_value_with_null, an_enum_value_with_only_null=an_enum_value_with_only_null, + non_exploded_array=non_exploded_array, + optional_non_exploded_array=optional_non_exploded_array, some_date=some_date, ) @@ -146,6 +165,8 @@ def sync( an_enum_value: list[AnEnum], an_enum_value_with_null: list[Union[AnEnumWithNull, None]], an_enum_value_with_only_null: list[None], + non_exploded_array: list[str], + optional_non_exploded_array: Union[Unset, list[str]] = UNSET, some_date: Union[datetime.date, datetime.datetime], ) -> Optional[Union[HTTPValidationError, list["AModel"]]]: """Get List @@ -156,6 +177,8 @@ def sync( an_enum_value (list[AnEnum]): an_enum_value_with_null (list[Union[AnEnumWithNull, None]]): an_enum_value_with_only_null (list[None]): + non_exploded_array (list[str]): + optional_non_exploded_array (Union[Unset, list[str]]): some_date (Union[datetime.date, datetime.datetime]): Raises: @@ -171,6 +194,8 @@ def sync( an_enum_value=an_enum_value, an_enum_value_with_null=an_enum_value_with_null, an_enum_value_with_only_null=an_enum_value_with_only_null, + non_exploded_array=non_exploded_array, + optional_non_exploded_array=optional_non_exploded_array, some_date=some_date, ).parsed @@ -181,6 +206,8 @@ async def asyncio_detailed( an_enum_value: list[AnEnum], an_enum_value_with_null: list[Union[AnEnumWithNull, None]], an_enum_value_with_only_null: list[None], + non_exploded_array: list[str], + optional_non_exploded_array: Union[Unset, list[str]] = UNSET, some_date: Union[datetime.date, datetime.datetime], ) -> Response[Union[HTTPValidationError, list["AModel"]]]: """Get List @@ -191,6 +218,8 @@ async def asyncio_detailed( an_enum_value (list[AnEnum]): an_enum_value_with_null (list[Union[AnEnumWithNull, None]]): an_enum_value_with_only_null (list[None]): + non_exploded_array (list[str]): + optional_non_exploded_array (Union[Unset, list[str]]): some_date (Union[datetime.date, datetime.datetime]): Raises: @@ -205,6 +234,8 @@ async def asyncio_detailed( an_enum_value=an_enum_value, an_enum_value_with_null=an_enum_value_with_null, an_enum_value_with_only_null=an_enum_value_with_only_null, + non_exploded_array=non_exploded_array, + optional_non_exploded_array=optional_non_exploded_array, some_date=some_date, ) @@ -219,6 +250,8 @@ async def asyncio( an_enum_value: list[AnEnum], an_enum_value_with_null: list[Union[AnEnumWithNull, None]], an_enum_value_with_only_null: list[None], + non_exploded_array: list[str], + optional_non_exploded_array: Union[Unset, list[str]] = UNSET, some_date: Union[datetime.date, datetime.datetime], ) -> Optional[Union[HTTPValidationError, list["AModel"]]]: """Get List @@ -229,6 +262,8 @@ async def asyncio( an_enum_value (list[AnEnum]): an_enum_value_with_null (list[Union[AnEnumWithNull, None]]): an_enum_value_with_only_null (list[None]): + non_exploded_array (list[str]): + optional_non_exploded_array (Union[Unset, list[str]]): some_date (Union[datetime.date, datetime.datetime]): Raises: @@ -245,6 +280,8 @@ async def asyncio( an_enum_value=an_enum_value, an_enum_value_with_null=an_enum_value_with_null, an_enum_value_with_only_null=an_enum_value_with_only_null, + non_exploded_array=non_exploded_array, + optional_non_exploded_array=optional_non_exploded_array, some_date=some_date, ) ).parsed diff --git a/openapi_python_client/parser/openapi.py b/openapi_python_client/parser/openapi.py index 0aab5a717..c0833aa87 100644 --- a/openapi_python_client/parser/openapi.py +++ b/openapi_python_client/parser/openapi.py @@ -289,6 +289,7 @@ def add_parameters( schemas=schemas, parent_name=endpoint.name, config=config, + explode=param.explode, ) if isinstance(prop, ParseError): diff --git a/openapi_python_client/parser/properties/__init__.py b/openapi_python_client/parser/properties/__init__.py index ba667347b..df88e9b1d 100644 --- a/openapi_python_client/parser/properties/__init__.py +++ b/openapi_python_client/parser/properties/__init__.py @@ -146,6 +146,7 @@ def property_from_data( # noqa: PLR0911, PLR0912 config: Config, process_properties: bool = True, roots: set[ReferencePath | utils.ClassName] | None = None, + explode: bool | None = None, ) -> tuple[Property | PropertyError, Schemas]: """Generate a Property from the OpenAPI dictionary representation of it""" roots = roots or set() @@ -285,6 +286,7 @@ def property_from_data( # noqa: PLR0911, PLR0912 config=config, process_properties=process_properties, roots=roots, + explode=explode, ) if data.type == oai.DataType.OBJECT or data.allOf or (data.type is None and data.properties): return ModelProperty.build( diff --git a/openapi_python_client/parser/properties/list_property.py b/openapi_python_client/parser/properties/list_property.py index 06d773672..60cdf6859 100644 --- a/openapi_python_client/parser/properties/list_property.py +++ b/openapi_python_client/parser/properties/list_property.py @@ -22,6 +22,7 @@ class ListProperty(PropertyProtocol): description: str | None example: str | None inner_property: PropertyProtocol + explode: bool | None = None template: ClassVar[str] = "list_property.py.jinja" @classmethod @@ -36,6 +37,7 @@ def build( config: Config, process_properties: bool, roots: set[ReferencePath | utils.ClassName], + explode: bool | None = None, ) -> tuple[ListProperty | PropertyError, Schemas]: """ Build a ListProperty the right way, use this instead of the normal constructor. @@ -51,6 +53,7 @@ def build( property data roots: The set of `ReferencePath`s and `ClassName`s to remove from the schemas if a child reference becomes invalid + explode: Whether to use `explode` for array properties. Returns: `(result, schemas)` where `schemas` is an updated version of the input named the same including any inner @@ -98,6 +101,7 @@ def build( python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix), description=data.description, example=data.example, + explode=explode, ), schemas, ) diff --git a/openapi_python_client/schema/openapi_schema_pydantic/header.py b/openapi_python_client/schema/openapi_schema_pydantic/header.py index 2deb6f390..1813d4440 100644 --- a/openapi_python_client/schema/openapi_schema_pydantic/header.py +++ b/openapi_python_client/schema/openapi_schema_pydantic/header.py @@ -1,6 +1,7 @@ from pydantic import ConfigDict, Field from ..parameter_location import ParameterLocation +from ..style import Style from .parameter import Parameter @@ -20,6 +21,7 @@ class Header(Parameter): name: str = Field(default="") param_in: ParameterLocation = Field(default=ParameterLocation.HEADER, alias="in") + style: Style = Field(default=Style.SIMPLE) model_config = ConfigDict( # `Parameter` is not build yet, will rebuild in `__init__.py`: defer_build=True, diff --git a/openapi_python_client/schema/openapi_schema_pydantic/parameter.py b/openapi_python_client/schema/openapi_schema_pydantic/parameter.py index bf4f4cf02..040abff79 100644 --- a/openapi_python_client/schema/openapi_schema_pydantic/parameter.py +++ b/openapi_python_client/schema/openapi_schema_pydantic/parameter.py @@ -1,8 +1,9 @@ from typing import Any, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from ..parameter_location import ParameterLocation +from ..style import Style from .example import Example from .media_type import MediaType from .reference import ReferenceOr @@ -27,13 +28,49 @@ class Parameter(BaseModel): required: bool = False deprecated: bool = False allowEmptyValue: bool = False - style: Optional[str] = None - explode: bool = False + style: Optional[Style] = None + explode: Optional[bool] = None allowReserved: bool = False param_schema: Optional[ReferenceOr[Schema]] = Field(default=None, alias="schema") example: Optional[Any] = None examples: Optional[dict[str, ReferenceOr[Example]]] = None content: Optional[dict[str, MediaType]] = None + + @model_validator(mode="after") + @classmethod + def validate_dependencies(cls, model: "Parameter") -> "Parameter": + param_in = model.param_in + explode = model.explode + + if model.style is None: + if param_in in [ParameterLocation.PATH, ParameterLocation.HEADER]: + model.style = Style.SIMPLE + elif param_in in [ParameterLocation.QUERY, ParameterLocation.COOKIE]: + model.style = Style.FORM + + # Validate style based on parameter location, not all combinations are valid. + # https://swagger.io/docs/specification/v3_0/serialization/ + if param_in == ParameterLocation.PATH: + if model.style not in (Style.SIMPLE, Style.LABEL, Style.MATRIX): + raise ValueError(f"Invalid style '{model.style}' for path parameter") + elif param_in == ParameterLocation.QUERY: + if model.style not in (Style.FORM, Style.SPACE_DELIMITED, Style.PIPE_DELIMITED, Style.DEEP_OBJECT): + raise ValueError(f"Invalid style '{model.style}' for query parameter") + elif param_in == ParameterLocation.HEADER: + if model.style != Style.SIMPLE: + raise ValueError(f"Invalid style '{model.style}' for header parameter") + elif param_in == ParameterLocation.COOKIE: + if model.style != Style.FORM: + raise ValueError(f"Invalid style '{model.style}' for cookie parameter") + + if explode is None: + if model.style == Style.FORM: + model.explode = True + else: + model.explode = False + + return model + model_config = ConfigDict( # `MediaType` is not build yet, will rebuild in `__init__.py`: defer_build=True, diff --git a/openapi_python_client/schema/style.py b/openapi_python_client/schema/style.py new file mode 100644 index 000000000..cb538cfc4 --- /dev/null +++ b/openapi_python_client/schema/style.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class Style(str, Enum): + """The style of a schema is defined by the style keyword + + References: + - https://swagger.io/docs/specification/v3_0/serialization/ + - https://spec.openapis.org/oas/latest.html#fixed-fields-for-use-with-schema + """ + + SIMPLE = "simple" + LABEL = "label" + MATRIX = "matrix" + FORM = "form" + SPACE_DELIMITED = "spaceDelimited" + PIPE_DELIMITED = "pipeDelimited" + DEEP_OBJECT = "deepObject" diff --git a/openapi_python_client/templates/endpoint_macros.py.jinja b/openapi_python_client/templates/endpoint_macros.py.jinja index 1b53becdd..2a35eb51a 100644 --- a/openapi_python_client/templates/endpoint_macros.py.jinja +++ b/openapi_python_client/templates/endpoint_macros.py.jinja @@ -46,8 +46,17 @@ params: dict[str, Any] = {} {% set destination = "json_" + property.python_name %} {{ prop_template.transform(property, property.python_name, destination) }} {% endif %} - {%- if not property.json_is_dict %} + {%- if not property.json_is_dict %} + {% if property.explode == False %} + {% if property.required %} +params["{{ property.name }}"] = ",".join(str(item) for item in {{ destination }}) + {% else %} +if not isinstance({{ destination }}, Unset): + params["{{ property.name }}"] = ",".join(str(item) for item in {{ destination }}) + {% endif %} + {% else %} params["{{ property.name }}"] = {{ destination }} + {% endif %} {% else %} {{ guarded_statement(property, destination, "params.update(" + destination + ")") }} {% endif %} diff --git a/tests/test_parser/test_properties/test_schemas.py b/tests/test_parser/test_properties/test_schemas.py index 7e7af8514..e10ead21f 100644 --- a/tests/test_parser/test_properties/test_schemas.py +++ b/tests/test_parser/test_properties/test_schemas.py @@ -10,6 +10,7 @@ update_parameters_with_data, ) from openapi_python_client.schema import Parameter, ParameterLocation, Reference, Schema +from openapi_python_client.schema.style import Style from openapi_python_client.utils import ClassName MODULE_NAME = "openapi_python_client.parser.properties.schemas" @@ -63,7 +64,11 @@ def test_parameters_without_schema_are_ignored(self, config): def test_registers_new_parameters(self, config): param = Parameter.model_construct( - name="a_param", param_in=ParameterLocation.QUERY, param_schema=Schema.model_construct() + name="a_param", + param_in=ParameterLocation.QUERY, + style=Style.FORM, + explode=True, + param_schema=Schema.model_construct(), ) parameters = Parameters() param_or_error, new_parameters = parameter_from_data(