Skip to content

Commit cdc0d8c

Browse files
authored
fix(parser): Attempt to deduplicate endpoint parameters based on name and location (fixes #305) (#406)
* fix(parser): Attempt to deduplicate endpoint parameters based on name and location (fixes #305) * refactor: Use location prefix for all duplicate params
1 parent 890411a commit cdc0d8c

File tree

10 files changed

+193
-17
lines changed

10 files changed

+193
-17
lines changed

end_to_end_tests/golden-record/my_test_api_client/api/parameters/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from typing import Any, Dict, Union
2+
3+
import httpx
4+
5+
from ...client import Client
6+
from ...types import UNSET, Response, Unset
7+
8+
9+
def _get_kwargs(
10+
*,
11+
client: Client,
12+
param_path: Union[Unset, str] = UNSET,
13+
param_query: Union[Unset, str] = UNSET,
14+
) -> Dict[str, Any]:
15+
url = "{}/same-name-multiple-locations/{param}".format(client.base_url, param=param_path)
16+
17+
headers: Dict[str, Any] = client.get_headers()
18+
cookies: Dict[str, Any] = client.get_cookies()
19+
20+
params: Dict[str, Any] = {
21+
"param": param_query,
22+
}
23+
params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
24+
25+
return {
26+
"url": url,
27+
"headers": headers,
28+
"cookies": cookies,
29+
"timeout": client.get_timeout(),
30+
"params": params,
31+
}
32+
33+
34+
def _build_response(*, response: httpx.Response) -> Response[None]:
35+
return Response(
36+
status_code=response.status_code,
37+
content=response.content,
38+
headers=response.headers,
39+
parsed=None,
40+
)
41+
42+
43+
def sync_detailed(
44+
*,
45+
client: Client,
46+
param_path: Union[Unset, str] = UNSET,
47+
param_query: Union[Unset, str] = UNSET,
48+
) -> Response[None]:
49+
kwargs = _get_kwargs(
50+
client=client,
51+
param_path=param_path,
52+
param_query=param_query,
53+
)
54+
55+
response = httpx.get(
56+
**kwargs,
57+
)
58+
59+
return _build_response(response=response)
60+
61+
62+
async def asyncio_detailed(
63+
*,
64+
client: Client,
65+
param_path: Union[Unset, str] = UNSET,
66+
param_query: Union[Unset, str] = UNSET,
67+
) -> Response[None]:
68+
kwargs = _get_kwargs(
69+
client=client,
70+
param_path=param_path,
71+
param_query=param_query,
72+
)
73+
74+
async with httpx.AsyncClient() as _client:
75+
response = await _client.get(**kwargs)
76+
77+
return _build_response(response=response)

end_to_end_tests/openapi.json

+27
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,33 @@
760760
"200": {"description": "Success"}
761761
}
762762
}
763+
},
764+
"/same-name-multiple-locations/{param}": {
765+
"description": "Test that if you have a property of the same name in multiple locations, it produces valid code",
766+
"get": {
767+
"tags": ["parameters"],
768+
"parameters": [
769+
{
770+
"name": "param",
771+
"in": "query",
772+
"schema": {
773+
"type": "string"
774+
}
775+
},
776+
{
777+
"name": "param",
778+
"in": "path",
779+
"schema": {
780+
"type": "string"
781+
}
782+
}
783+
],
784+
"responses": {
785+
"200": {
786+
"description": ""
787+
}
788+
}
789+
}
763790
}
764791
},
765792
"components": {

openapi_python_client/parser/openapi.py

+27-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import itertools
12
from copy import deepcopy
23
from dataclasses import dataclass, field
3-
from enum import Enum
44
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union
55

66
from pydantic import ValidationError
@@ -13,15 +13,6 @@
1313
from .responses import Response, response_from_data
1414

1515

16-
class ParameterLocation(str, Enum):
17-
"""The places Parameters can be put when calling an Endpoint"""
18-
19-
QUERY = "query"
20-
PATH = "path"
21-
HEADER = "header"
22-
COOKIE = "cookie"
23-
24-
2516
def import_string_from_class(class_: Class, prefix: str = "") -> str:
2617
"""Create a string which is used to import a reference"""
2718
return f"from {prefix}.{class_.module_name} import {class_.name}"
@@ -217,6 +208,7 @@ def _add_parameters(
217208
*, endpoint: "Endpoint", data: Union[oai.Operation, oai.PathItem], schemas: Schemas, config: Config
218209
) -> Tuple[Union["Endpoint", ParseError], Schemas]:
219210
endpoint = deepcopy(endpoint)
211+
used_python_names: Dict[str, Tuple[Property, oai.ParameterLocation]] = {}
220212
if data.parameters is None:
221213
return endpoint, schemas
222214
for param in data.parameters:
@@ -232,18 +224,39 @@ def _add_parameters(
232224
)
233225
if isinstance(prop, ParseError):
234226
return ParseError(detail=f"cannot parse parameter of endpoint {endpoint.name}", data=prop.data), schemas
227+
228+
if prop.python_name in used_python_names:
229+
duplicate, duplicate_location = used_python_names[prop.python_name]
230+
if duplicate.python_name == prop.python_name: # Existing should be converted too for consistency
231+
duplicate.set_python_name(f"{duplicate.python_name}_{duplicate_location}")
232+
prop.set_python_name(f"{prop.python_name}_{param.param_in}")
233+
else:
234+
used_python_names[prop.python_name] = (prop, param.param_in)
235+
235236
endpoint.relative_imports.update(prop.get_imports(prefix="..."))
236237

237-
if param.param_in == ParameterLocation.QUERY:
238+
if param.param_in == oai.ParameterLocation.QUERY:
238239
endpoint.query_parameters.append(prop)
239-
elif param.param_in == ParameterLocation.PATH:
240+
elif param.param_in == oai.ParameterLocation.PATH:
240241
endpoint.path_parameters.append(prop)
241-
elif param.param_in == ParameterLocation.HEADER:
242+
elif param.param_in == oai.ParameterLocation.HEADER:
242243
endpoint.header_parameters.append(prop)
243-
elif param.param_in == ParameterLocation.COOKIE:
244+
elif param.param_in == oai.ParameterLocation.COOKIE:
244245
endpoint.cookie_parameters.append(prop)
245246
else:
246247
return ParseError(data=param, detail="Parameter must be declared in path or query"), schemas
248+
249+
name_check = set()
250+
for prop in itertools.chain(
251+
endpoint.query_parameters, endpoint.path_parameters, endpoint.header_parameters, endpoint.cookie_parameters
252+
):
253+
if prop.python_name in name_check:
254+
return (
255+
ParseError(data=data, detail=f"Could not reconcile duplicate parameters named {prop.python_name}"),
256+
schemas,
257+
)
258+
name_check.add(prop.python_name)
259+
247260
return endpoint, schemas
248261

249262
@staticmethod

openapi_python_client/parser/properties/property.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ class Property:
3232
json_is_dict: ClassVar[bool] = False
3333

3434
def __attrs_post_init__(self) -> None:
35-
object.__setattr__(self, "python_name", utils.to_valid_python_identifier(utils.snake_case(self.name)))
35+
self.set_python_name(self.name)
36+
37+
def set_python_name(self, new_name: str) -> None:
38+
object.__setattr__(self, "python_name", utils.to_valid_python_identifier(utils.snake_case(new_name)))
3639

3740
def get_base_type_string(self) -> str:
3841
return self._type_string

openapi_python_client/schema/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"OpenAPI",
44
"Operation",
55
"Parameter",
6+
"ParameterLocation",
67
"PathItem",
78
"Reference",
89
"RequestBody",
@@ -18,6 +19,7 @@
1819
from .openapi_schema_pydantic import MediaType
1920
from .openapi_schema_pydantic import OpenAPI as _OpenAPI
2021
from .openapi_schema_pydantic import Operation, Parameter, PathItem, Reference, RequestBody, Response, Responses, Schema
22+
from .parameter_location import ParameterLocation
2123

2224
regex = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)")
2325

openapi_python_client/schema/openapi_schema_pydantic/header.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pydantic import Field
22

3+
from ..parameter_location import ParameterLocation
34
from .parameter import Parameter
45

56

@@ -14,7 +15,7 @@ class Header(Parameter):
1415
"""
1516

1617
name = Field(default="", const=True)
17-
param_in = Field(default="header", const=True, alias="in")
18+
param_in = Field(default=ParameterLocation.HEADER, const=True, alias="in")
1819

1920
class Config:
2021
allow_population_by_field_name = True

openapi_python_client/schema/openapi_schema_pydantic/parameter.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pydantic import BaseModel, Field
44

5+
from ..parameter_location import ParameterLocation
56
from .example import Example
67
from .media_type import MediaType
78
from .reference import Reference
@@ -30,7 +31,7 @@ class Parameter(BaseModel):
3031
- For all other cases, the `name` corresponds to the parameter name used by the [`in`](#parameterIn) property.
3132
"""
3233

33-
param_in: str = Field(alias="in")
34+
param_in: ParameterLocation = Field(alias="in")
3435
"""
3536
**REQUIRED**. The location of the parameter. Possible values are `"query"`, `"header"`, `"path"` or `"cookie"`.
3637
"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from enum import Enum
2+
3+
4+
class ParameterLocation(str, Enum):
5+
"""The places Parameters can be put when calling an Endpoint"""
6+
7+
QUERY = "query"
8+
PATH = "path"
9+
HEADER = "header"
10+
COOKIE = "cookie"

tests/test_parser/test_openapi.py

+42
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,48 @@ def test__add_parameters_happy(self, mocker):
543543
assert endpoint.header_parameters == [header_prop]
544544
assert schemas == schemas_3
545545

546+
def test__add_parameters_duplicate_properties(self, mocker):
547+
from openapi_python_client.parser.openapi import Endpoint, Schemas
548+
549+
endpoint = self.make_endpoint()
550+
param = oai.Parameter.construct(
551+
name="test", required=True, param_schema=oai.Schema.construct(type="string"), param_in="path"
552+
)
553+
data = oai.Operation.construct(parameters=[param, param])
554+
schemas = Schemas()
555+
config = MagicMock()
556+
557+
result = Endpoint._add_parameters(endpoint=endpoint, data=data, schemas=schemas, config=config)
558+
assert result == (
559+
ParseError(data=data, detail="Could not reconcile duplicate parameters named test_path"),
560+
schemas,
561+
)
562+
563+
def test__add_parameters_duplicate_properties_different_location(self):
564+
from openapi_python_client.parser.openapi import Endpoint, Schemas
565+
566+
endpoint = self.make_endpoint()
567+
path_param = oai.Parameter.construct(
568+
name="test", required=True, param_schema=oai.Schema.construct(type="string"), param_in="path"
569+
)
570+
query_param = oai.Parameter.construct(
571+
name="test", required=True, param_schema=oai.Schema.construct(type="string"), param_in="query"
572+
)
573+
schemas = Schemas()
574+
config = MagicMock()
575+
576+
result = Endpoint._add_parameters(
577+
endpoint=endpoint,
578+
data=oai.Operation.construct(parameters=[path_param, query_param]),
579+
schemas=schemas,
580+
config=config,
581+
)[0]
582+
assert isinstance(result, Endpoint)
583+
assert result.path_parameters[0].python_name == "test_path"
584+
assert result.path_parameters[0].name == "test"
585+
assert result.query_parameters[0].python_name == "test_query"
586+
assert result.query_parameters[0].name == "test"
587+
546588
def test_from_data_bad_params(self, mocker):
547589
from openapi_python_client.parser.openapi import Endpoint
548590

0 commit comments

Comments
 (0)