Skip to content

Commit c5ece3c

Browse files
app: Add logic to handle $ref in parameters BNCH-125141 BNCH-125943 (#230)
In December of 2020, when we diverged from the upstream branch, the client generator could not yet handle $ref in the parameters list. They added that feature in August 2022 but we never pulled it in. In this PR, I try to pull in the same implementation from the upstream while modifying our repo as little as possible. Only the tests are really hand-written. * Improve configuration for vscode development - Set ruler to 120 characters, which is the configured value in black - Set up vscode to use pytest with the same configuration as task unit - Turn off mypy in tests. They are so noncompliant that it's useless * Copy ParameterError from upstream * Copy ClassName from upstream * Copy parameter reference handling from upstream This builds a library of parameter references from the openapi spec and gives a utility for looking them up. * Copy build_parameters from upstream This builds the actual Parameters object from the OpenAPI spec, using the logic we just added to schemas. * Copy parameter reference logic from upstream Intead of ignoring reference-typed parameters, look them up in the Parameters object. * Update unit tests * Update version to 1.0.7 * Remove explicit local python interpreter * Format launch.json * eng: Update code to be editable BNCH-125859 (#229) * Update code to be editable Fix the prod/1.x branch so it's possible to run the client library generator from a local clone. #### Changes to make it work at all: `pyproject.toml` - We'll use python 3.9 because 3.7 is dead-dead - PyYAML <6 isn't installable anymore, thanks to PEP 517 - shellingham 1.5.0 was yanked, so we need a later version - Include type annotations for PyYAML for good measure #### Changes to make the generated output match what's already published: `poetry.lock` - Downgrade black so it tolerates leading and trailing spaces in triple- quoted strings - Upgrade autoflake to match the version in aurelia - Upgrade isort to match the version in aurelia `openapi_python_client/templates/pyproject.toml` - Enforce line length of 110 characters - Configure isort to use the same settings as in aurelia (don't separate by "from foo import" vs "import"; don't separate constants-style imports from other imports) `openapi_python_client/__init__.py` - Run autoflake from outside the generated client package. This sidesteps an issue where [autoflake will crash if it runs against a file called types.py](https://discuss.python.org/t/my-python-dont-work/80726). We have a file called types.py. * Run poetry lock * Fix unit tests * Fix flake8 * Remove difficult-to-fix checks and add todo's * Pin the black version * Minimal changes to enable debugging
1 parent ed54092 commit c5ece3c

File tree

12 files changed

+387
-39
lines changed

12 files changed

+387
-39
lines changed

.vscode/launch.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
"version": "0.2.0",
6+
"configurations": [
7+
{
8+
"name": "Run openapi-python-client CLI",
9+
"type": "debugpy",
10+
"request": "launch",
11+
"module": "openapi_python_client.cli",
12+
"args": [
13+
"generate",
14+
"--path",
15+
"../aurelia/benchling/dev_platform/specs/__dist/la/api/v2/openapi.yaml",
16+
"--custom-template-path",
17+
"../aurelia/packages/api_client_generation/client_gen/templates"
18+
]
19+
}
20+
]
21+
}

.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"editor.rulers": [120],
3+
"mypy-type-checker.args": ["--config-file=mypy.ini"],
4+
"python.testing.pytestArgs": ["openapi_python_client", "tests"],
5+
"python.testing.pytestEnabled": true,
6+
"python.testing.unittestEnabled": false
7+
}

mypy.ini

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ disallow_any_generics = True
33
disallow_untyped_defs = True
44
warn_redundant_casts = True
55
strict_equality = True
6+
exclude = "(^tests/)"
67

78
[mypy-importlib_metadata]
89
ignore_missing_imports = True
@@ -12,3 +13,9 @@ ignore_missing_imports = True
1213

1314
[mypy-typer]
1415
ignore_missing_imports = True
16+
17+
[mypy-tests.*]
18+
ignore_errors = True
19+
20+
[mypy-tests]
21+
ignore_errors = True

openapi_python_client/cli.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,7 @@ def update(
142142

143143
errors = update_existing_client(url=url, path=path, custom_template_path=custom_template_path)
144144
handle_errors(errors)
145+
146+
147+
if __name__ == "__main__":
148+
app()

openapi_python_client/parser/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,10 @@ class PropertyError(ParseError):
4141

4242
class ValidationError(Exception):
4343
pass
44+
45+
46+
@dataclass
47+
class ParameterError(ParseError):
48+
"""Error raised when there's a problem creating a Parameter."""
49+
50+
header = "Problem creating a Parameter: "

openapi_python_client/parser/openapi.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,20 @@
55

66
from pydantic import ValidationError
77

8+
from .properties.schemas import Parameters, parameter_from_reference
9+
810
from .. import schema as oai
911
from .. import utils
1012
from .errors import GeneratorError, ParseError, PropertyError
11-
from .properties import EnumProperty, ModelProperty, Property, Schemas, build_schemas, property_from_data
13+
from .properties import (
14+
EnumProperty,
15+
ModelProperty,
16+
Property,
17+
Schemas,
18+
build_parameters,
19+
build_schemas,
20+
property_from_data,
21+
)
1222
from .reference import Reference
1323
from .responses import Response, response_from_data
1424

@@ -36,7 +46,7 @@ class EndpointCollection:
3646

3747
@staticmethod
3848
def from_data(
39-
*, data: Dict[str, oai.PathItem], schemas: Schemas
49+
*, data: Dict[str, oai.PathItem], schemas: Schemas, parameters: Parameters
4050
) -> Tuple[Dict[str, "EndpointCollection"], Schemas]:
4151
""" Parse the openapi paths data to get EndpointCollections by tag """
4252
endpoints_by_tag: Dict[str, EndpointCollection] = {}
@@ -51,7 +61,7 @@ def from_data(
5161
tag = (operation.tags or ["default"])[0]
5262
collection = endpoints_by_tag.setdefault(tag, EndpointCollection(tag=tag))
5363
endpoint, schemas = Endpoint.from_data(
54-
data=operation, path=path, method=method, tag=tag, schemas=schemas
64+
data=operation, path=path, method=method, tag=tag, schemas=schemas, parameters=parameters
5565
)
5666
if isinstance(endpoint, ParseError):
5767
endpoint.header = (
@@ -217,14 +227,26 @@ def _add_responses(*, endpoint: "Endpoint", data: oai.Responses, schemas: Schema
217227

218228
@staticmethod
219229
def _add_parameters(
220-
*, endpoint: "Endpoint", data: oai.Operation, schemas: Schemas
230+
*,
231+
endpoint: "Endpoint",
232+
data: oai.Operation,
233+
schemas: Schemas,
234+
parameters: Parameters,
221235
) -> Tuple[Union["Endpoint", ParseError], Schemas]:
222236
endpoint = deepcopy(endpoint)
223237
if data.parameters is None:
224238
return endpoint, schemas
239+
225240
for param in data.parameters:
226-
if isinstance(param, oai.Reference) or param.param_schema is None:
241+
# Obtain the parameter from the reference or just the parameter itself
242+
param_or_error = parameter_from_reference(param=param, parameters=parameters)
243+
if isinstance(param_or_error, ParseError):
244+
return param_or_error, schemas
245+
param = param_or_error # noqa: PLW2901
246+
247+
if param.param_schema is None:
227248
continue
249+
228250
prop, schemas = property_from_data(
229251
name=param.name,
230252
required=param.required,
@@ -248,7 +270,7 @@ def _add_parameters(
248270

249271
@staticmethod
250272
def from_data(
251-
*, data: oai.Operation, path: str, method: str, tag: str, schemas: Schemas
273+
*, data: oai.Operation, path: str, method: str, tag: str, schemas: Schemas, parameters: Parameters
252274
) -> Tuple[Union["Endpoint", ParseError], Schemas]:
253275
""" Construct an endpoint from the OpenAPI data """
254276

@@ -266,7 +288,7 @@ def from_data(
266288
tag=tag,
267289
)
268290

269-
result, schemas = Endpoint._add_parameters(endpoint=endpoint, data=data, schemas=schemas)
291+
result, schemas = Endpoint._add_parameters(endpoint=endpoint, data=data, schemas=schemas, parameters=parameters)
270292
if isinstance(result, ParseError):
271293
return result, schemas
272294
result, schemas = Endpoint._add_responses(endpoint=result, data=data.responses, schemas=schemas)
@@ -298,7 +320,13 @@ def from_dict(d: Dict[str, Dict[str, Any]]) -> Union["GeneratorData", GeneratorE
298320
schemas = Schemas()
299321
else:
300322
schemas = build_schemas(components=openapi.components.schemas)
301-
endpoint_collections_by_tag, schemas = EndpointCollection.from_data(data=openapi.paths, schemas=schemas)
323+
if openapi.components is None or openapi.components.parameters is None:
324+
parameters = Parameters()
325+
else:
326+
parameters = build_parameters(components=openapi.components.parameters)
327+
endpoint_collections_by_tag, schemas = EndpointCollection.from_data(
328+
data=openapi.paths, schemas=schemas, parameters=parameters
329+
)
302330
enums = schemas.enums
303331

304332
return GeneratorData(

openapi_python_client/parser/properties/__init__.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@
22

33
from itertools import chain
44
from typing import Any, ClassVar, Dict, Generic, Iterable, Iterator, List, Optional, Set, Tuple, TypeVar, Union
5+
from xml.etree.ElementTree import ParseError
56

67
import attr
78

89
from ... import schema as oai
910
from ... import utils
10-
from ..errors import PropertyError, ValidationError
11+
from ..errors import ParameterError, PropertyError, ValidationError
1112
from ..reference import Reference
1213
from .converter import convert, convert_chain
1314
from .enum_property import EnumProperty
1415
from .model_property import ModelProperty
1516
from .property import Property
16-
from .schemas import Schemas
17+
from .schemas import Parameters, Schemas, parse_reference_path, update_parameters_with_data
1718

1819

1920
@attr.s(auto_attribs=True, frozen=True)
@@ -671,3 +672,44 @@ def build_schemas(*, components: Dict[str, Union[oai.Reference, oai.Schema]]) ->
671672
schemas.errors.extend(errors)
672673
schemas.errors.extend(resolve_errors)
673674
return schemas
675+
676+
677+
def build_parameters(
678+
*,
679+
components: Dict[str, Union[oai.Reference, oai.Parameter]],
680+
) -> Parameters:
681+
"""Get a list of Parameters from an OpenAPI dict"""
682+
parameters = Parameters()
683+
to_process: Iterable[Tuple[str, Union[oai.Reference, oai.Parameter]]] = []
684+
if components is not None:
685+
to_process = components.items()
686+
still_making_progress = True
687+
errors: list[ParameterError] = []
688+
689+
# References could have forward References so keep going as long as we are making progress
690+
while still_making_progress:
691+
still_making_progress = False
692+
errors = []
693+
next_round = []
694+
# Only accumulate errors from the last round, since we might fix some along the way
695+
for name, data in to_process:
696+
if isinstance(data, oai.Reference):
697+
parameters.errors.append(ParameterError(data=data, detail="Reference parameters are not supported."))
698+
continue
699+
ref_path = parse_reference_path(f"#/components/parameters/{name}")
700+
if isinstance(ref_path, ParseError):
701+
parameters.errors.append(ParameterError(detail=ref_path.detail, data=data))
702+
continue
703+
parameters_or_err = update_parameters_with_data(
704+
ref_path=ref_path, data=data, parameters=parameters
705+
)
706+
if isinstance(parameters_or_err, ParameterError):
707+
next_round.append((name, data))
708+
errors.append(parameters_or_err)
709+
continue
710+
parameters = parameters_or_err
711+
still_making_progress = True
712+
to_process = next_round
713+
714+
parameters.errors.extend(errors)
715+
return parameters
Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
11
__all__ = ["Schemas"]
22

3-
from typing import Dict, List
3+
from typing import Dict, List, NewType, Tuple, Union, cast
4+
from urllib.parse import urlparse
45

56
import attr
67

7-
from ..errors import ParseError
8+
from ... import schema as oai
9+
from ...schema.parameter import Parameter
10+
11+
from ..errors import ParameterError, ParseError
812
from .enum_property import EnumProperty
13+
from ...utils import ClassName
914
from .model_property import ModelProperty
1015

16+
ReferencePath = NewType("ReferencePath", str)
17+
18+
19+
def parse_reference_path(ref_path_raw: str) -> Union[ReferencePath, ParseError]:
20+
"""
21+
Takes a raw string provided in a `$ref` and turns it into a validated `_ReferencePath` or a `ParseError` if
22+
validation fails.
23+
24+
See Also:
25+
- https://swagger.io/docs/specification/using-ref/
26+
"""
27+
parsed = urlparse(ref_path_raw)
28+
if parsed.scheme or parsed.path:
29+
return ParseError(detail=f"Remote references such as {ref_path_raw} are not supported yet.")
30+
return cast(ReferencePath, parsed.fragment)
31+
1132

1233
@attr.s(auto_attribs=True, frozen=True)
1334
class Schemas:
@@ -16,3 +37,104 @@ class Schemas:
1637
enums: Dict[str, EnumProperty] = attr.ib(factory=dict)
1738
models: Dict[str, ModelProperty] = attr.ib(factory=dict)
1839
errors: List[ParseError] = attr.ib(factory=list)
40+
41+
42+
@attr.s(auto_attribs=True, frozen=True)
43+
class Parameters:
44+
"""Structure for containing all defined, shareable, and reusable parameters"""
45+
46+
classes_by_reference: dict[ReferencePath, Parameter] = attr.ib(factory=dict)
47+
classes_by_name: dict[ClassName, Parameter] = attr.ib(factory=dict)
48+
errors: list[ParseError] = attr.ib(factory=list)
49+
50+
51+
def parameter_from_data(
52+
*,
53+
name: str,
54+
required: bool,
55+
data: Union[oai.Reference, oai.Parameter],
56+
parameters: Parameters,
57+
) -> Tuple[Union[Parameter, ParameterError], Parameters]:
58+
"""Generates parameters from an OpenAPI Parameter spec."""
59+
60+
if isinstance(data, oai.Reference):
61+
return ParameterError("Unable to resolve another reference"), parameters
62+
63+
if data.param_schema is None:
64+
return ParameterError("Parameter has no schema"), parameters
65+
66+
new_param = Parameter(
67+
name=name,
68+
required=required,
69+
explode=data.explode,
70+
style=data.style,
71+
param_schema=data.param_schema,
72+
param_in=data.param_in,
73+
)
74+
parameters = attr.evolve(parameters, classes_by_name={**parameters.classes_by_name, name: new_param})
75+
return new_param, parameters
76+
77+
78+
def update_parameters_with_data(
79+
*, ref_path: ReferencePath, data: oai.Parameter, parameters: Parameters
80+
) -> Union[Parameters, ParameterError]:
81+
"""
82+
Update a `Parameters` using some new reference.
83+
84+
Args:
85+
ref_path: The output of `parse_reference_path` (validated $ref).
86+
data: The schema of the thing to add to Schemas.
87+
parameters: `Parameters` up until now.
88+
89+
Returns:
90+
Either the updated `parameters` input or a `PropertyError` if something went wrong.
91+
92+
See Also:
93+
- https://swagger.io/docs/specification/using-ref/
94+
"""
95+
param, parameters = parameter_from_data(data=data, name=data.name, parameters=parameters, required=True)
96+
97+
if isinstance(param, ParameterError):
98+
param.detail = f"{param.header}: {param.detail}"
99+
param.header = f"Unable to parse parameter {ref_path}"
100+
if isinstance(param.data, oai.Reference) and param.data.ref.endswith(ref_path): # pragma: nocover
101+
param.detail += (
102+
"\n\nRecursive and circular references are not supported. "
103+
"See https://github.com/openapi-generators/openapi-python-client/issues/466"
104+
)
105+
return param
106+
107+
parameters = attr.evolve(parameters, classes_by_reference={ref_path: param, **parameters.classes_by_reference})
108+
return parameters
109+
110+
111+
def parameter_from_reference(
112+
*,
113+
param: Union[oai.Reference, Parameter],
114+
parameters: Parameters,
115+
) -> Union[Parameter, ParameterError]:
116+
"""
117+
Returns a Parameter from a Reference or the Parameter itself if one was provided.
118+
119+
Args:
120+
param: A parameter by `Reference`.
121+
parameters: `Parameters` up until now.
122+
123+
Returns:
124+
Either the updated `schemas` input or a `PropertyError` if something went wrong.
125+
126+
See Also:
127+
- https://swagger.io/docs/specification/using-ref/
128+
"""
129+
if isinstance(param, Parameter):
130+
return param
131+
132+
ref_path = parse_reference_path(param.ref)
133+
134+
if isinstance(ref_path, ParseError):
135+
return ParameterError(detail=ref_path.detail)
136+
137+
_resolved_parameter_class = parameters.classes_by_reference.get(ref_path, None)
138+
if _resolved_parameter_class is None:
139+
return ParameterError(detail=f"Reference `{ref_path}` not found.")
140+
return _resolved_parameter_class

0 commit comments

Comments
 (0)