Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Adds support for parameter components and parameter references. #615

9 changes: 8 additions & 1 deletion openapi_python_client/parser/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from enum import Enum
from typing import Optional

__all__ = ["ErrorLevel", "GeneratorError", "ParseError", "PropertyError", "ValidationError"]
__all__ = ["ErrorLevel", "GeneratorError", "ParseError", "PropertyError", "ValidationError", "ParameterError"]

from pydantic import BaseModel

Expand Down Expand Up @@ -39,5 +39,12 @@ class PropertyError(ParseError):
header = "Problem creating a Property: "


@dataclass
class ParameterError(ParseError):
"""Error raised when there's a problem creating a Parameter."""

header = "Problem creating a Parameter: "


class ValidationError(Exception):
"""Used internally to exit quickly from property parsing due to some internal exception."""
121 changes: 91 additions & 30 deletions openapi_python_client/parser/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,18 @@
from ..config import Config
from ..utils import PythonIdentifier
from .errors import GeneratorError, ParseError, PropertyError
from .properties import Class, EnumProperty, ModelProperty, Property, Schemas, build_schemas, property_from_data
from .properties import (
Class,
EnumProperty,
ModelProperty,
Parameters,
Property,
Schemas,
build_parameters,
build_schemas,
property_from_data,
)
from .properties.schemas import parameter_from_reference
from .responses import Response, response_from_data

_PATH_PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)}")
Expand All @@ -33,8 +44,8 @@ class EndpointCollection:

@staticmethod
def from_data(
*, data: Dict[str, oai.PathItem], schemas: Schemas, config: Config
) -> Tuple[Dict[utils.PythonIdentifier, "EndpointCollection"], Schemas]:
*, data: Dict[str, oai.PathItem], schemas: Schemas, parameters: Parameters, config: Config
) -> Tuple[Dict[utils.PythonIdentifier, "EndpointCollection"], Schemas, Parameters]:
"""Parse the openapi paths data to get EndpointCollections by tag"""
endpoints_by_tag: Dict[utils.PythonIdentifier, EndpointCollection] = {}

Expand All @@ -47,13 +58,19 @@ def from_data(
continue
tag = utils.PythonIdentifier(value=(operation.tags or ["default"])[0], prefix="tag")
collection = endpoints_by_tag.setdefault(tag, EndpointCollection(tag=tag))
endpoint, schemas = Endpoint.from_data(
data=operation, path=path, method=method, tag=tag, schemas=schemas, config=config
endpoint, schemas, parameters = Endpoint.from_data(
data=operation,
path=path,
method=method,
tag=tag,
schemas=schemas,
parameters=parameters,
config=config,
)
# Add `PathItem` parameters
if not isinstance(endpoint, ParseError):
endpoint, schemas = Endpoint.add_parameters(
endpoint=endpoint, data=path_data, schemas=schemas, config=config
endpoint, schemas, parameters = Endpoint.add_parameters(
endpoint=endpoint, data=path_data, schemas=schemas, parameters=parameters, config=config
)
if not isinstance(endpoint, ParseError):
endpoint = Endpoint.sort_parameters(endpoint=endpoint)
Expand All @@ -68,7 +85,7 @@ def from_data(
collection.parse_errors.append(error)
collection.endpoints.append(endpoint)

return endpoints_by_tag, schemas
return endpoints_by_tag, schemas, parameters


def generate_operation_id(*, path: str, method: str) -> str:
Expand Down Expand Up @@ -248,8 +265,13 @@ def _add_responses(
# pylint: disable=too-many-return-statements
@staticmethod
def add_parameters(
*, endpoint: "Endpoint", data: Union[oai.Operation, oai.PathItem], schemas: Schemas, config: Config
) -> Tuple[Union["Endpoint", ParseError], Schemas]:
*,
endpoint: "Endpoint",
data: Union[oai.Operation, oai.PathItem],
schemas: Schemas,
parameters: Parameters,
config: Config,
) -> Tuple[Union["Endpoint", ParseError], Schemas, Parameters]:
"""Process the defined `parameters` for an Endpoint.

Any existing parameters will be ignored, so earlier instances of a parameter take precedence. PathItem
Expand All @@ -259,6 +281,7 @@ def add_parameters(
endpoint: The endpoint to add parameters to.
data: The Operation or PathItem to add parameters from.
schemas: The cumulative Schemas of processing so far which should contain details for any references.
parameters: The cumulative Parameters of processing so far which should contain details for any references.
config: User-provided config for overrides within parameters.

Returns:
Expand All @@ -270,10 +293,13 @@ def add_parameters(
- https://swagger.io/docs/specification/describing-parameters/
- https://swagger.io/docs/specification/paths-and-operations/
"""
# pylint: disable=too-many-branches, too-many-locals
# There isn't much value in breaking down this function further other than to satisfy the linter.

endpoint = deepcopy(endpoint)
if data.parameters is None:
return endpoint, schemas
return endpoint, schemas, parameters

endpoint = deepcopy(endpoint)

unique_parameters: Set[Tuple[str, oai.ParameterLocation]] = set()
parameters_by_location = {
Expand All @@ -284,17 +310,30 @@ def add_parameters(
}

for param in data.parameters:
if isinstance(param, oai.Reference) or param.param_schema is None:
# Obtain the parameter from the reference or just the parameter itself
param_or_error = parameter_from_reference(param=param, parameters=parameters)
if isinstance(param_or_error, ParseError):
return param_or_error, schemas, parameters
param = param_or_error

if param.param_schema is None:
continue

unique_param = (param.name, param.param_in)
if unique_param in unique_parameters:
duplication_detail = (
"Parameters MUST NOT contain duplicates. "
"A unique parameter is defined by a combination of a name and location. "
f"Duplicated parameters named `{param.name}` detected in `{param.param_in}`."
if (param.name, param.param_in) in unique_parameters:
jsanchez7SC marked this conversation as resolved.
Show resolved Hide resolved
return (
ParseError(
data=data,
detail=(
"Parameters MUST NOT contain duplicates. "
"A unique parameter is defined by a combination of a name and location. "
f"Duplicated parameters named `{param.name}` detected in `{param.param_in}`."
),
),
schemas,
parameters,
)
return ParseError(data=data, detail=duplication_detail), schemas

unique_parameters.add(unique_param)

prop, new_schemas = property_from_data(
Expand All @@ -305,13 +344,21 @@ def add_parameters(
parent_name=endpoint.name,
config=config,
)

if isinstance(prop, ParseError):
return ParseError(detail=f"cannot parse parameter of endpoint {endpoint.name}", data=prop.data), schemas
return (
ParseError(detail=f"cannot parse parameter of endpoint {endpoint.name}", data=prop.data),
schemas,
parameters,
)

schemas = new_schemas

location_error = prop.validate_location(param.param_in)
if location_error is not None:
location_error.data = param
return location_error, schemas
schemas = new_schemas
return location_error, schemas, parameters

if prop.name in parameters_by_location[param.param_in]:
# This parameter was defined in the Operation, so ignore the PathItem definition
continue
Expand All @@ -331,6 +378,7 @@ def add_parameters(
data=data,
),
schemas,
parameters,
)
endpoint.used_python_identifiers.add(existing_prop.python_name)
prop.set_python_name(new_name=f"{param.name}_{param.param_in}", config=config)
Expand All @@ -341,6 +389,7 @@ def add_parameters(
detail=f"Parameters with same Python identifier `{prop.python_name}` detected", data=data
),
schemas,
parameters,
)
if param.param_in == oai.ParameterLocation.QUERY and (prop.nullable or not prop.required):
# There is no NULL for query params, so nullable and not required are the same.
Expand All @@ -350,7 +399,7 @@ def add_parameters(
endpoint.used_python_identifiers.add(prop.python_name)
parameters_by_location[param.param_in][prop.name] = prop

return endpoint, schemas
return endpoint, schemas, parameters

@staticmethod
def sort_parameters(*, endpoint: "Endpoint") -> Union["Endpoint", ParseError]:
Expand Down Expand Up @@ -382,8 +431,15 @@ def sort_parameters(*, endpoint: "Endpoint") -> Union["Endpoint", ParseError]:

@staticmethod
def from_data(
*, data: oai.Operation, path: str, method: str, tag: str, schemas: Schemas, config: Config
) -> Tuple[Union["Endpoint", ParseError], Schemas]:
*,
data: oai.Operation,
path: str,
method: str,
tag: str,
schemas: Schemas,
parameters: Parameters,
config: Config,
) -> Tuple[Union["Endpoint", ParseError], Schemas, Parameters]:
"""Construct an endpoint from the OpenAPI data"""

if data.operationId is None:
Expand All @@ -401,13 +457,15 @@ def from_data(
tag=tag,
)

result, schemas = Endpoint.add_parameters(endpoint=endpoint, data=data, schemas=schemas, config=config)
result, schemas, parameters = Endpoint.add_parameters(
endpoint=endpoint, data=data, schemas=schemas, parameters=parameters, config=config
)
if isinstance(result, ParseError):
return result, schemas
return result, schemas, parameters
result, schemas = Endpoint._add_responses(endpoint=result, data=data.responses, schemas=schemas, config=config)
result, schemas = Endpoint._add_body(endpoint=result, data=data, schemas=schemas, config=config)

return result, schemas
return result, schemas, parameters

def response_type(self) -> str:
"""Get the Python type of any response from this endpoint"""
Expand Down Expand Up @@ -459,10 +517,13 @@ def from_dict(data: Dict[str, Any], *, config: Config) -> Union["GeneratorData",
)
return GeneratorError(header="Failed to parse OpenAPI document", detail=detail)
schemas = Schemas()
parameters = Parameters()
if openapi.components and openapi.components.schemas:
schemas = build_schemas(components=openapi.components.schemas, schemas=schemas, config=config)
endpoint_collections_by_tag, schemas = EndpointCollection.from_data(
data=openapi.paths, schemas=schemas, config=config
if openapi.components and openapi.components.parameters:
parameters = build_parameters(components=openapi.components.parameters, parameters=parameters)
endpoint_collections_by_tag, schemas, parameters = EndpointCollection.from_data(
data=openapi.paths, schemas=schemas, parameters=parameters, config=config
)

enums = (prop for prop in schemas.classes_by_name.values() if isinstance(prop, EnumProperty))
Expand Down
52 changes: 50 additions & 2 deletions openapi_python_client/parser/properties/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
"Class",
"EnumProperty",
"ModelProperty",
"Parameters",
"Property",
"Schemas",
"build_schemas",
"build_parameters",
"property_from_data",
]

Expand All @@ -17,12 +19,19 @@
from ... import Config
from ... import schema as oai
from ... import utils
from ..errors import ParseError, PropertyError, ValidationError
from ..errors import ParameterError, ParseError, PropertyError, ValidationError
from .converter import convert, convert_chain
from .enum_property import EnumProperty
from .model_property import ModelProperty, build_model_property
from .property import Property
from .schemas import Class, Schemas, parse_reference_path, update_schemas_with_data
from .schemas import (
Class,
Parameters,
Schemas,
parse_reference_path,
update_parameters_with_data,
update_schemas_with_data,
)


@attr.s(auto_attribs=True, frozen=True)
Expand Down Expand Up @@ -728,3 +737,42 @@ def build_schemas(

schemas.errors.extend(errors)
return schemas


def build_parameters(
*,
components: Dict[str, Union[oai.Reference, oai.Parameter]],
parameters: Parameters,
) -> Parameters:
"""Get a list of Parameters from an OpenAPI dict"""
to_process: Iterable[Tuple[str, Union[oai.Reference, oai.Parameter]]] = []
if components is not None:
to_process = components.items()
still_making_progress = True
errors: List[ParameterError] = []

# References could have forward References so keep going as long as we are making progress
while still_making_progress:
still_making_progress = False
errors = []
next_round = []
# Only accumulate errors from the last round, since we might fix some along the way
for name, data in to_process:
if isinstance(data, oai.Reference):
parameters.errors.append(ParameterError(data=data, detail="Reference parameters are not supported."))
continue
ref_path = parse_reference_path(f"#/components/parameters/{name}")
if isinstance(ref_path, ParseError):
parameters.errors.append(ParameterError(detail=ref_path.detail, data=data))
continue
parameters_or_err = update_parameters_with_data(ref_path=ref_path, data=data, parameters=parameters)
if isinstance(parameters_or_err, ParameterError):
next_round.append((name, data))
errors.append(parameters_or_err)
continue
parameters = parameters_or_err
still_making_progress = True
to_process = next_round

parameters.errors.extend(errors)
return parameters