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 cd12c2407..15e304e77 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 @@ -4,6 +4,7 @@ from .default import DefaultEndpoints from .location import LocationEndpoints +from .parameter_references import ParameterReferencesEndpoints from .parameters import ParametersEndpoints from .responses import ResponsesEndpoints from .tag1 import Tag1Endpoints @@ -39,3 +40,7 @@ def location(cls) -> Type[LocationEndpoints]: @classmethod def true_(cls) -> Type[True_Endpoints]: return True_Endpoints + + @classmethod + def parameter_references(cls) -> Type[ParameterReferencesEndpoints]: + return ParameterReferencesEndpoints diff --git a/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/parameter_references/__init__.py b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/parameter_references/__init__.py new file mode 100644 index 000000000..f96e9b318 --- /dev/null +++ b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/parameter_references/__init__.py @@ -0,0 +1,14 @@ +""" Contains methods for accessing the API Endpoints """ + +import types + +from . import get_parameter_references_path_param + + +class ParameterReferencesEndpoints: + @classmethod + def get_parameter_references_path_param(cls) -> types.ModuleType: + """ + Test different types of parameter references + """ + return get_parameter_references_path_param diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/parameter_references/__init__.py b/end_to_end_tests/golden-record/my_test_api_client/api/parameter_references/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/parameter_references/get_parameter_references_path_param.py b/end_to_end_tests/golden-record/my_test_api_client/api/parameter_references/get_parameter_references_path_param.py new file mode 100644 index 000000000..33028801f --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/api/parameter_references/get_parameter_references_path_param.py @@ -0,0 +1,126 @@ +from typing import Any, Dict + +import httpx + +from ...client import Client +from ...types import UNSET, Response + + +def _get_kwargs( + path_param: str, + *, + client: Client, + string_param: str, + integer_param: int = 0, + header_param: str, + cookie_param: str, +) -> Dict[str, Any]: + url = "{}/parameter-references/{path_param}".format(client.base_url, path_param=path_param) + + headers: Dict[str, str] = client.get_headers() + cookies: Dict[str, Any] = client.get_cookies() + + headers["header param"] = header_param + + cookies["cookie param"] = cookie_param + + params: Dict[str, Any] = {} + params["string param"] = string_param + + params["integer param"] = integer_param + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + return { + "method": "get", + "url": url, + "headers": headers, + "cookies": cookies, + "timeout": client.get_timeout(), + "params": params, + } + + +def _build_response(*, response: httpx.Response) -> Response[Any]: + return Response( + status_code=response.status_code, + content=response.content, + headers=response.headers, + parsed=None, + ) + + +def sync_detailed( + path_param: str, + *, + client: Client, + string_param: str, + integer_param: int = 0, + header_param: str, + cookie_param: str, +) -> Response[Any]: + """Test different types of parameter references + + Args: + path_param (str): + string_param (str): + integer_param (int): + header_param (str): + cookie_param (str): + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + path_param=path_param, + client=client, + string_param=string_param, + integer_param=integer_param, + header_param=header_param, + cookie_param=cookie_param, + ) + + response = httpx.request( + verify=client.verify_ssl, + **kwargs, + ) + + return _build_response(response=response) + + +async def asyncio_detailed( + path_param: str, + *, + client: Client, + string_param: str, + integer_param: int = 0, + header_param: str, + cookie_param: str, +) -> Response[Any]: + """Test different types of parameter references + + Args: + path_param (str): + string_param (str): + integer_param (int): + header_param (str): + cookie_param (str): + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + path_param=path_param, + client=client, + string_param=string_param, + integer_param=integer_param, + header_param=header_param, + cookie_param=cookie_param, + ) + + async with httpx.AsyncClient(verify=client.verify_ssl) as _client: + response = await _client.request(**kwargs) + + return _build_response(response=response) diff --git a/end_to_end_tests/openapi.json b/end_to_end_tests/openapi.json index a05d19d36..efae13e86 100644 --- a/end_to_end_tests/openapi.json +++ b/end_to_end_tests/openapi.json @@ -1082,6 +1082,30 @@ } } } + }, + "/parameter-references/{path_param}": { + "get": { + "tags": [ + "parameter-references" + ], + "summary": "Test different types of parameter references", + "parameters": [ + { + "$ref": "#/components/parameters/string-param" + }, + { + "$ref": "#/components/parameters/integer-param" + }, + {"$ref": "#/components/parameters/header-param"}, + {"$ref": "#/components/parameters/cookie-param"}, + {"$ref": "#/components/parameters/path-param"} + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } } }, "components": { @@ -1956,6 +1980,70 @@ "type": "object", "description": "A Model with periods in its reference" } + }, + "parameters": { + "integer-param": { + "name": "integer param", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "integer", + "default": 0 + } + }, + "string-param": { + "name": "string param", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + "object-param": { + "name": "object param", + "in": "query", + "required": false, + "schema": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "number": { + "type": "number" + } + } + } + }, + "header-param": { + "name": "header param", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + }, + "cookie-param": { + "name": "cookie param", + "in": "cookie", + "required": false, + "schema": { + "type": "string" + } + }, + "path-param": { + "name": "path_param", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } } } } diff --git a/openapi_python_client/parser/errors.py b/openapi_python_client/parser/errors.py index dfa2d54cb..d7111a7b8 100644 --- a/openapi_python_client/parser/errors.py +++ b/openapi_python_client/parser/errors.py @@ -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 @@ -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.""" diff --git a/openapi_python_client/parser/openapi.py b/openapi_python_client/parser/openapi.py index a013551c5..f945e844b 100644 --- a/openapi_python_client/parser/openapi.py +++ b/openapi_python_client/parser/openapi.py @@ -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_]*)}") @@ -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] = {} @@ -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) @@ -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: @@ -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 @@ -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: @@ -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 = { @@ -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}`." + 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( @@ -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 @@ -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) @@ -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. @@ -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]: @@ -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: @@ -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""" @@ -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)) @@ -474,6 +535,6 @@ def from_dict(data: Dict[str, Any], *, config: Config) -> Union["GeneratorData", version=openapi.info.version, endpoint_collections_by_tag=endpoint_collections_by_tag, models=models, - errors=schemas.errors, + errors=schemas.errors + parameters.errors, enums=enums, ) diff --git a/openapi_python_client/parser/properties/__init__.py b/openapi_python_client/parser/properties/__init__.py index 524ff5ba0..3eb678c62 100644 --- a/openapi_python_client/parser/properties/__init__.py +++ b/openapi_python_client/parser/properties/__init__.py @@ -3,9 +3,11 @@ "Class", "EnumProperty", "ModelProperty", + "Parameters", "Property", "Schemas", "build_schemas", + "build_parameters", "property_from_data", ] @@ -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) @@ -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 diff --git a/openapi_python_client/parser/properties/schemas.py b/openapi_python_client/parser/properties/schemas.py index 9951f149f..a0606b8c1 100644 --- a/openapi_python_client/parser/properties/schemas.py +++ b/openapi_python_client/parser/properties/schemas.py @@ -1,14 +1,24 @@ -__all__ = ["Class", "Schemas", "parse_reference_path", "update_schemas_with_data"] - -from typing import TYPE_CHECKING, Dict, List, NewType, Union, cast +__all__ = [ + "Class", + "Schemas", + "Parameters", + "parse_reference_path", + "update_schemas_with_data", + "update_parameters_with_data", + "parameter_from_reference", + "parameter_from_data", +] + +from typing import TYPE_CHECKING, Dict, List, NewType, Tuple, Union, cast from urllib.parse import urlparse import attr from ... import Config from ... import schema as oai +from ...schema.openapi_schema_pydantic import Parameter from ...utils import ClassName, PythonIdentifier -from ..errors import ParseError, PropertyError +from ..errors import ParameterError, ParseError, PropertyError if TYPE_CHECKING: # pragma: no cover from .property import Property @@ -105,3 +115,104 @@ def update_schemas_with_data( schemas = attr.evolve(schemas, classes_by_reference={ref_path: prop, **schemas.classes_by_reference}) return schemas + + +@attr.s(auto_attribs=True, frozen=True) +class Parameters: + """Structure for containing all defined, shareable, and reusable parameters""" + + classes_by_reference: Dict[_ReferencePath, Parameter] = attr.ib(factory=dict) + classes_by_name: Dict[ClassName, Parameter] = attr.ib(factory=dict) + errors: List[ParseError] = attr.ib(factory=list) + + +def parameter_from_data( + *, + name: str, + required: bool, + data: Union[oai.Reference, oai.Parameter], + parameters: Parameters, +) -> Tuple[Union[Parameter, ParameterError], Parameters]: + """Generates parameters from an OpenAPI Parameter spec.""" + + if isinstance(data, oai.Reference): + return ParameterError("Unable to resolve another reference"), parameters + + if data.param_schema is None: + return ParameterError("Parameter has no schema"), parameters + + new_param = Parameter( + name=name, + required=required, + explode=data.explode, + style=data.style, + param_schema=data.param_schema, + param_in=data.param_in, + ) + parameters = attr.evolve(parameters, classes_by_name={**parameters.classes_by_name, name: new_param}) + return new_param, parameters + + +def update_parameters_with_data( + *, ref_path: _ReferencePath, data: oai.Parameter, parameters: Parameters +) -> Union[Parameters, ParameterError]: + """ + Update a `Parameters` using some new reference. + + Args: + ref_path: The output of `parse_reference_path` (validated $ref). + data: The schema of the thing to add to Schemas. + parameters: `Parameters` up until now. + + Returns: + Either the updated `parameters` input or a `PropertyError` if something went wrong. + + See Also: + - https://swagger.io/docs/specification/using-ref/ + """ + param, parameters = parameter_from_data(data=data, name=data.name, parameters=parameters, required=True) + + if isinstance(param, ParameterError): + param.detail = f"{param.header}: {param.detail}" + param.header = f"Unable to parse parameter {ref_path}" + if isinstance(param.data, oai.Reference) and param.data.ref.endswith(ref_path): # pragma: nocover + param.detail += ( + "\n\nRecursive and circular references are not supported. " + "See https://github.com/openapi-generators/openapi-python-client/issues/466" + ) + return param + + parameters = attr.evolve(parameters, classes_by_reference={ref_path: param, **parameters.classes_by_reference}) + return parameters + + +def parameter_from_reference( + *, + param: Union[oai.Reference, Parameter], + parameters: Parameters, +) -> Union[Parameter, ParameterError]: + """ + Returns a Parameter from a Reference or the Parameter itself if one was provided. + + Args: + param: A parameter by `Reference`. + parameters: `Parameters` up until now. + + Returns: + Either the updated `schemas` input or a `PropertyError` if something went wrong. + + See Also: + - https://swagger.io/docs/specification/using-ref/ + """ + if isinstance(param, Parameter): + return param + + ref_path = parse_reference_path(param.ref) + + if isinstance(ref_path, ParseError): + return ParameterError(detail=ref_path.detail) + + _resolved_parameter_class = parameters.classes_by_reference.get(ref_path, None) + if _resolved_parameter_class is None: + return ParameterError(detail=f"Reference `{ref_path}` not found.") + return _resolved_parameter_class diff --git a/openapi_python_client/schema/__init__.py b/openapi_python_client/schema/__init__.py index 151fe298e..d3de0e493 100644 --- a/openapi_python_client/schema/__init__.py +++ b/openapi_python_client/schema/__init__.py @@ -6,6 +6,7 @@ "ParameterLocation", "DataType", "PathItem", + "Parameter", "Reference", "RequestBody", "Response", diff --git a/pyproject.toml b/pyproject.toml index 16c4def23..1a7d156ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ exclude = ''' /( | \.git | \.venv + | env | \.mypy_cache | openapi_python_client/templates | tests/test_templates @@ -96,7 +97,7 @@ exclude = ''' [tool.isort] line_length = 120 profile = "black" -skip = [".venv", "tests/test_templates", "integration-tests"] +skip = [".venv", "tests/test_templates", "integration-tests", "env"] [tool.coverage.run] omit = ["openapi_python_client/templates/*"] diff --git a/tests/conftest.py b/tests/conftest.py index 2a683f102..a1391ab4d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,8 @@ StringProperty, UnionProperty, ) +from openapi_python_client.schema.openapi_schema_pydantic import Parameter +from openapi_python_client.schema.parameter_location import ParameterLocation @pytest.fixture @@ -222,6 +224,25 @@ def _factory(**kwargs): return _factory +@pytest.fixture +def param_factory() -> Callable[..., Parameter]: + """ + This fixture surfaces in the test as a function which manufactures a Parameter with defaults. + + You can pass the same params into this as the Parameter constructor to override defaults. + """ + + def _factory(**kwargs): + kwargs = { + "name": "", + "in": ParameterLocation.QUERY, + **kwargs, + } + return Parameter(**kwargs) + + return _factory + + def _common_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]: kwargs = { "name": "test", diff --git a/tests/test_parser/test_openapi.py b/tests/test_parser/test_openapi.py index 3b8d1c672..cf2c77215 100644 --- a/tests/test_parser/test_openapi.py +++ b/tests/test_parser/test_openapi.py @@ -7,7 +7,7 @@ from openapi_python_client import Config, GeneratorError from openapi_python_client.parser.errors import ParseError from openapi_python_client.parser.openapi import Endpoint, EndpointCollection -from openapi_python_client.parser.properties import IntProperty, Schemas +from openapi_python_client.parser.properties import IntProperty, Parameters, Schemas MODULE_NAME = "openapi_python_client.parser.openapi" @@ -17,14 +17,17 @@ def test_from_dict(self, mocker, model_property_factory, enum_property_factory): from openapi_python_client.parser.properties import Schemas build_schemas = mocker.patch(f"{MODULE_NAME}.build_schemas") + build_parameters = mocker.patch(f"{MODULE_NAME}.build_parameters") EndpointCollection = mocker.patch(f"{MODULE_NAME}.EndpointCollection") schemas = mocker.MagicMock() schemas.classes_by_name = { "Model": model_property_factory(), "Enum": enum_property_factory(), } + parameters = Parameters() + endpoints_collections_by_tag = mocker.MagicMock() - EndpointCollection.from_data.return_value = (endpoints_collections_by_tag, schemas) + EndpointCollection.from_data.return_value = (endpoints_collections_by_tag, schemas, parameters) OpenAPI = mocker.patch(f"{MODULE_NAME}.oai.OpenAPI") openapi = OpenAPI.parse_obj.return_value openapi.openapi = mocker.MagicMock(major=3) @@ -37,24 +40,33 @@ def test_from_dict(self, mocker, model_property_factory, enum_property_factory): OpenAPI.parse_obj.assert_called_once_with(in_dict) build_schemas.assert_called_once_with(components=openapi.components.schemas, config=config, schemas=Schemas()) + build_parameters.assert_called_once_with( + components=openapi.components.parameters, + parameters=parameters, + ) EndpointCollection.from_data.assert_called_once_with( - data=openapi.paths, schemas=build_schemas.return_value, config=config + data=openapi.paths, + schemas=build_schemas.return_value, + parameters=build_parameters.return_value, + config=config, ) assert generator_data.title == openapi.info.title assert generator_data.description == openapi.info.description assert generator_data.version == openapi.info.version assert generator_data.endpoint_collections_by_tag == endpoints_collections_by_tag - assert generator_data.errors == schemas.errors + assert generator_data.errors == schemas.errors + parameters.errors assert list(generator_data.models) == [schemas.classes_by_name["Model"]] assert list(generator_data.enums) == [schemas.classes_by_name["Enum"]] # Test no components openapi.components = None build_schemas.reset_mock() + build_parameters.reset_mock() GeneratorData.from_dict(in_dict, config=config) build_schemas.assert_not_called() + build_parameters.assert_not_called() def test_from_dict_invalid_schema(self, mocker): Schemas = mocker.patch(f"{MODULE_NAME}.Schemas") @@ -481,33 +493,37 @@ def test_add_parameters_handles_no_params(self): endpoint = self.make_endpoint() schemas = Schemas() + parameters = Parameters() config = MagicMock() # Just checking there's no exception here assert Endpoint.add_parameters( - endpoint=endpoint, data=oai.Operation.construct(), schemas=schemas, config=config - ) == ( - endpoint, - schemas, - ) + endpoint=endpoint, data=oai.Operation.construct(), schemas=schemas, parameters=parameters, config=config + ) == (endpoint, schemas, parameters) def test_add_parameters_parse_error(self, mocker): from openapi_python_client.parser.openapi import Endpoint endpoint = self.make_endpoint() initial_schemas = mocker.MagicMock() + initial_parameters = mocker.MagicMock() parse_error = ParseError(data=mocker.MagicMock()) property_schemas = mocker.MagicMock() mocker.patch(f"{MODULE_NAME}.property_from_data", return_value=(parse_error, property_schemas)) param = oai.Parameter.construct(name="test", required=True, param_schema=mocker.MagicMock(), param_in="cookie") config = MagicMock() - result = Endpoint.add_parameters( - endpoint=endpoint, data=oai.Operation.construct(parameters=[param]), schemas=initial_schemas, config=config + result, schemas, parameters = Endpoint.add_parameters( + endpoint=endpoint, + data=oai.Operation.construct(parameters=[param]), + schemas=initial_schemas, + parameters=initial_parameters, + config=config, ) - assert result == ( + assert (result, schemas, parameters) == ( ParseError(data=parse_error.data, detail=f"cannot parse parameter of endpoint {endpoint.name}"), initial_schemas, + initial_parameters, ) @pytest.mark.parametrize( @@ -526,13 +542,18 @@ def test_add_parameters_header_types(self, data_type, allowed): endpoint = self.make_endpoint() initial_schemas = Schemas() + parameters = Parameters() param = oai.Parameter.construct( name="test", required=True, param_schema=oai.Schema(type=data_type), param_in=oai.ParameterLocation.HEADER ) config = Config() result = Endpoint.add_parameters( - endpoint=endpoint, data=oai.Operation.construct(parameters=[param]), schemas=initial_schemas, config=config + endpoint=endpoint, + data=oai.Operation.construct(parameters=[param]), + schemas=initial_schemas, + parameters=parameters, + config=config, ) if allowed: assert isinstance(result[0], Endpoint) @@ -548,11 +569,16 @@ def test__add_parameters_parse_error_on_non_required_path_param(self): param_in=oai.ParameterLocation.PATH, ) schemas = Schemas() + parameters = Parameters() result = Endpoint.add_parameters( - endpoint=endpoint, data=oai.Operation.construct(parameters=[param]), schemas=schemas, config=Config() + endpoint=endpoint, + data=oai.Operation.construct(parameters=[param]), + parameters=parameters, + schemas=schemas, + config=Config(), ) - assert result == (ParseError(data=param, detail="Path parameter must be required"), schemas) + assert result == (ParseError(data=param, detail="Path parameter must be required"), schemas, parameters) def test_validation_error_when_location_not_supported(self, mocker): parsed_schemas = mocker.MagicMock() @@ -599,9 +625,12 @@ def test__add_parameters_with_location_postfix_conflict1(self, mocker, property_ ] ) initial_schemas = mocker.MagicMock() + parameters = mocker.MagicMock() config = MagicMock() - result = Endpoint.add_parameters(endpoint=endpoint, data=data, schemas=initial_schemas, config=config)[0] + result = Endpoint.add_parameters( + endpoint=endpoint, data=data, schemas=initial_schemas, parameters=parameters, config=config + )[0] assert isinstance(result, ParseError) assert result.detail == "Parameters with same Python identifier `prop_name_path` detected" @@ -642,13 +671,16 @@ def test__add_parameters_with_location_postfix_conflict2(self, mocker, property_ ] ) initial_schemas = mocker.MagicMock() + parameters = mocker.MagicMock() config = MagicMock() - result = Endpoint.add_parameters(endpoint=endpoint, data=data, schemas=initial_schemas, config=config)[0] + result = Endpoint.add_parameters( + endpoint=endpoint, data=data, schemas=initial_schemas, parameters=parameters, config=config + )[0] assert isinstance(result, ParseError) assert result.detail == "Parameters with same Python identifier `prop_name_path` detected" - def test__add_parameters_skips_references(self): + def test__add_parameters_handles_invalid_references(self): """References are not supported as direct params yet""" endpoint = self.make_endpoint() data = oai.Operation.construct( @@ -657,17 +689,37 @@ def test__add_parameters_skips_references(self): ] ) - (endpoint, _) = endpoint.add_parameters(endpoint=endpoint, data=data, schemas=Schemas(), config=Config()) + parameters = Parameters() + (error, _, return_parameters) = endpoint.add_parameters( + endpoint=endpoint, data=data, schemas=Schemas(), parameters=parameters, config=Config() + ) - assert isinstance(endpoint, Endpoint) - assert ( - len(endpoint.path_parameters) - + len(endpoint.query_parameters) - + len(endpoint.cookie_parameters) - + len(endpoint.header_parameters) - == 0 + assert isinstance(error, ParseError) + assert parameters == return_parameters + + def test__add_parameters_resolves_references(self, mocker, param_factory): + """References are not supported as direct params yet""" + endpoint = self.make_endpoint() + data = oai.Operation.construct( + parameters=[ + oai.Reference.construct(ref="#components/parameters/blah"), + ] + ) + + parameters = mocker.MagicMock() + new_param = param_factory(name="blah", schema=oai.Schema.construct(nullable=False, type="string")) + parameters.classes_by_name = { + "blah": new_param, + } + parameters.classes_by_reference = {"components/parameters/blah": new_param} + + (endpoint, _, return_parameters) = endpoint.add_parameters( + endpoint=endpoint, data=data, schemas=Schemas(), parameters=parameters, config=Config() ) + assert isinstance(endpoint, Endpoint) + assert parameters == return_parameters + def test__add_parameters_skips_params_without_schemas(self): """Params without schemas are allowed per spec, but the any type doesn't make sense as a parameter""" endpoint = self.make_endpoint() @@ -680,7 +732,9 @@ def test__add_parameters_skips_params_without_schemas(self): ] ) - (endpoint, _) = endpoint.add_parameters(endpoint=endpoint, data=data, schemas=Schemas(), config=Config()) + (endpoint, _, _) = endpoint.add_parameters( + endpoint=endpoint, data=data, schemas=Schemas(), parameters=Parameters(), config=Config() + ) assert isinstance(endpoint, Endpoint) assert len(endpoint.path_parameters) == 0 @@ -709,7 +763,9 @@ def test__add_parameters_same_identifier_conflict(self): ] ) - (err, _) = endpoint.add_parameters(endpoint=endpoint, data=data, schemas=Schemas(), config=Config()) + (err, _, _) = endpoint.add_parameters( + endpoint=endpoint, data=data, schemas=Schemas(), parameters=Parameters(), config=Config() + ) assert isinstance(err, ParseError) assert "param_path" in err.detail @@ -745,7 +801,9 @@ def test__add_parameters_query_optionality(self): ] ) - (endpoint, _) = endpoint.add_parameters(endpoint=endpoint, data=data, schemas=Schemas(), config=Config()) + (endpoint, _, _) = endpoint.add_parameters( + endpoint=endpoint, data=data, schemas=Schemas(), parameters=Parameters(), config=Config() + ) assert len(endpoint.query_parameters) == 4, "Not all query params were added" for param in endpoint.query_parameters.values(): @@ -765,9 +823,12 @@ def test_add_parameters_duplicate_properties(self): ) data = oai.Operation.construct(parameters=[param, param]) schemas = Schemas() + parameters = Parameters() config = MagicMock() - result = Endpoint.add_parameters(endpoint=endpoint, data=data, schemas=schemas, config=config) + result = Endpoint.add_parameters( + endpoint=endpoint, data=data, schemas=schemas, parameters=parameters, config=config + ) assert result == ( ParseError( data=data, @@ -776,6 +837,7 @@ def test_add_parameters_duplicate_properties(self): "Duplicated parameters named `test` detected in `path`.", ), schemas, + parameters, ) def test_add_parameters_duplicate_properties_different_location(self): @@ -789,12 +851,14 @@ def test_add_parameters_duplicate_properties_different_location(self): name="test", required=True, param_schema=oai.Schema.construct(type="string"), param_in="query" ) schemas = Schemas() + parameters = Parameters() config = MagicMock() result = Endpoint.add_parameters( endpoint=endpoint, data=oai.Operation.construct(parameters=[path_param, query_param]), schemas=schemas, + parameters=parameters, config=config, )[0] assert isinstance(result, Endpoint) @@ -852,21 +916,31 @@ def test_from_data_bad_params(self, mocker): method = mocker.MagicMock() parse_error = ParseError(data=mocker.MagicMock()) return_schemas = mocker.MagicMock() - add_parameters = mocker.patch.object(Endpoint, "add_parameters", return_value=(parse_error, return_schemas)) + return_parameters = mocker.MagicMock() + add_parameters = mocker.patch.object( + Endpoint, "add_parameters", return_value=(parse_error, return_schemas, return_parameters) + ) data = oai.Operation.construct( description=mocker.MagicMock(), operationId=mocker.MagicMock(), security={"blah": "bloo"}, responses=mocker.MagicMock(), ) - inital_schemas = mocker.MagicMock() + initial_schemas = mocker.MagicMock() + parameters = Parameters() config = MagicMock() result = Endpoint.from_data( - data=data, path=path, method=method, tag="default", schemas=inital_schemas, config=config + data=data, + path=path, + method=method, + tag="default", + schemas=initial_schemas, + parameters=parameters, + config=config, ) - assert result == (parse_error, return_schemas) + assert result == (parse_error, return_schemas, return_parameters) def test_from_data_bad_responses(self, mocker): from openapi_python_client.parser.openapi import Endpoint @@ -875,8 +949,9 @@ def test_from_data_bad_responses(self, mocker): method = mocker.MagicMock() parse_error = ParseError(data=mocker.MagicMock()) param_schemas = mocker.MagicMock() + return_parameters = mocker.MagicMock() add_parameters = mocker.patch.object( - Endpoint, "add_parameters", return_value=(mocker.MagicMock(), param_schemas) + Endpoint, "add_parameters", return_value=(mocker.MagicMock(), param_schemas, return_parameters) ) response_schemas = mocker.MagicMock() _add_responses = mocker.patch.object(Endpoint, "_add_responses", return_value=(parse_error, response_schemas)) @@ -887,13 +962,20 @@ def test_from_data_bad_responses(self, mocker): responses=mocker.MagicMock(), ) initial_schemas = mocker.MagicMock() + initial_parameters = mocker.MagicMock() config = MagicMock() result = Endpoint.from_data( - data=data, path=path, method=method, tag="default", schemas=initial_schemas, config=config + data=data, + path=path, + method=method, + tag="default", + schemas=initial_schemas, + parameters=initial_parameters, + config=config, ) - assert result == (parse_error, response_schemas) + assert result == (parse_error, response_schemas, return_parameters) def test_from_data_standard(self, mocker): from openapi_python_client.parser.openapi import Endpoint @@ -902,7 +984,10 @@ def test_from_data_standard(self, mocker): method = mocker.MagicMock() param_schemas = mocker.MagicMock() param_endpoint = mocker.MagicMock() - add_parameters = mocker.patch.object(Endpoint, "add_parameters", return_value=(param_endpoint, param_schemas)) + return_parameters = mocker.MagicMock() + add_parameters = mocker.patch.object( + Endpoint, "add_parameters", return_value=(param_endpoint, param_schemas, return_parameters) + ) response_schemas = mocker.MagicMock() response_endpoint = mocker.MagicMock() _add_responses = mocker.patch.object( @@ -918,15 +1003,22 @@ def test_from_data_standard(self, mocker): responses=mocker.MagicMock(), ) initial_schemas = mocker.MagicMock() + initial_parameters = mocker.MagicMock() config = MagicMock() mocker.patch("openapi_python_client.utils.remove_string_escapes", return_value=data.description) endpoint = Endpoint.from_data( - data=data, path=path, method=method, tag="default", schemas=initial_schemas, config=config + data=data, + path=path, + method=method, + tag="default", + schemas=initial_schemas, + parameters=initial_parameters, + config=config, ) - assert endpoint == _add_body.return_value + assert (endpoint[0], endpoint[1]) == _add_body.return_value add_parameters.assert_called_once_with( endpoint=Endpoint( @@ -940,6 +1032,7 @@ def test_from_data_standard(self, mocker): ), data=data, schemas=initial_schemas, + parameters=initial_parameters, config=config, ) _add_responses.assert_called_once_with( @@ -955,7 +1048,7 @@ def test_from_data_no_operation_id(self, mocker): path = "/path/with/{param}/" method = "get" add_parameters = mocker.patch.object( - Endpoint, "add_parameters", return_value=(mocker.MagicMock(), mocker.MagicMock()) + Endpoint, "add_parameters", return_value=(mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock()) ) _add_responses = mocker.patch.object( Endpoint, "_add_responses", return_value=(mocker.MagicMock(), mocker.MagicMock()) @@ -970,10 +1063,13 @@ def test_from_data_no_operation_id(self, mocker): schemas = mocker.MagicMock() mocker.patch("openapi_python_client.utils.remove_string_escapes", return_value=data.description) config = MagicMock() + parameters = mocker.MagicMock() - result = Endpoint.from_data(data=data, path=path, method=method, tag="default", schemas=schemas, config=config) + endpoint, return_schemas, return_params = Endpoint.from_data( + data=data, path=path, method=method, tag="default", schemas=schemas, parameters=parameters, config=config + ) - assert result == _add_body.return_value + assert (endpoint, return_schemas) == _add_body.return_value add_parameters.assert_called_once_with( endpoint=Endpoint( @@ -988,6 +1084,7 @@ def test_from_data_no_operation_id(self, mocker): data=data, schemas=schemas, config=config, + parameters=parameters, ) _add_responses.assert_called_once_with( endpoint=add_parameters.return_value[0], @@ -1009,7 +1106,7 @@ def test_from_data_no_security(self, mocker): responses=mocker.MagicMock(), ) add_parameters = mocker.patch.object( - Endpoint, "add_parameters", return_value=(mocker.MagicMock(), mocker.MagicMock()) + Endpoint, "add_parameters", return_value=(mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock()) ) _add_responses = mocker.patch.object( Endpoint, "_add_responses", return_value=(mocker.MagicMock(), mocker.MagicMock()) @@ -1019,9 +1116,12 @@ def test_from_data_no_security(self, mocker): method = mocker.MagicMock() mocker.patch("openapi_python_client.utils.remove_string_escapes", return_value=data.description) schemas = mocker.MagicMock() + parameters = mocker.MagicMock() config = MagicMock() - Endpoint.from_data(data=data, path=path, method=method, tag="a", schemas=schemas, config=config) + Endpoint.from_data( + data=data, path=path, method=method, tag="a", schemas=schemas, parameters=parameters, config=config + ) add_parameters.assert_called_once_with( endpoint=Endpoint( @@ -1034,6 +1134,7 @@ def test_from_data_no_security(self, mocker): tag="a", ), data=data, + parameters=parameters, schemas=schemas, config=config, ) @@ -1099,26 +1200,52 @@ def test_from_data(self, mocker): schemas_1 = mocker.MagicMock() schemas_2 = mocker.MagicMock() schemas_3 = mocker.MagicMock() + parameters_1 = mocker.MagicMock() + parameters_2 = mocker.MagicMock() + parameters_3 = mocker.MagicMock() endpoint_from_data = mocker.patch.object( Endpoint, "from_data", - side_effect=[(endpoint_1, schemas_1), (endpoint_2, schemas_2), (endpoint_3, schemas_3)], + 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() config = MagicMock() - result = EndpointCollection.from_data(data=data, schemas=schemas, config=config) + result = EndpointCollection.from_data(data=data, schemas=schemas, parameters=parameters, config=config) endpoint_from_data.assert_has_calls( [ mocker.call( - data=path_1_put, path="path_1", method="put", tag="default", schemas=schemas, config=config + data=path_1_put, + path="path_1", + method="put", + tag="default", + schemas=schemas, + parameters=parameters, + config=config, ), mocker.call( - data=path_1_post, path="path_1", method="post", tag="tag_2", schemas=schemas_1, config=config + data=path_1_post, + path="path_1", + method="post", + tag="tag_2", + schemas=schemas_1, + parameters=parameters_1, + config=config, ), mocker.call( - data=path_2_get, path="path_2", method="get", tag="default", schemas=schemas_2, config=config + data=path_2_get, + path="path_2", + method="get", + tag="default", + schemas=schemas_2, + parameters=parameters_2, + config=config, ), ], ) @@ -1128,6 +1255,7 @@ def test_from_data(self, mocker): "tag_2": EndpointCollection("tag_2", endpoints=[endpoint_2]), }, schemas_3, + parameters_3, ) def test_from_data_overrides_path_item_params_with_operation_params(self): @@ -1149,9 +1277,10 @@ def test_from_data_overrides_path_item_params_with_operation_params(self): ) } - collections, schemas = EndpointCollection.from_data( + collections, schemas, parameters = EndpointCollection.from_data( data=data, schemas=Schemas(), + parameters=Parameters(), config=Config(), ) collection: EndpointCollection = collections["default"] @@ -1170,30 +1299,54 @@ def test_from_data_errors(self, mocker): schemas_1 = mocker.MagicMock() schemas_2 = mocker.MagicMock() schemas_3 = mocker.MagicMock() + parameters_1 = mocker.MagicMock() + parameters_2 = mocker.MagicMock() + parameters_3 = mocker.MagicMock() endpoint_from_data = mocker.patch.object( Endpoint, "from_data", side_effect=[ - (ParseError(data="1"), schemas_1), - (ParseError(data="2"), schemas_2), - (mocker.MagicMock(errors=[ParseError(data="3")], path="path_2"), schemas_3), + (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() config = MagicMock() - result, result_schemas = EndpointCollection.from_data(data=data, schemas=schemas, config=config) + result, result_schemas, result_parameters = EndpointCollection.from_data( + data=data, schemas=schemas, config=config, parameters=parameters + ) endpoint_from_data.assert_has_calls( [ mocker.call( - data=path_1_put, path="path_1", method="put", tag="default", schemas=schemas, config=config + data=path_1_put, + path="path_1", + method="put", + tag="default", + schemas=schemas, + parameters=parameters, + config=config, ), mocker.call( - data=path_1_post, path="path_1", method="post", tag="tag_2", schemas=schemas_1, config=config + data=path_1_post, + path="path_1", + method="post", + tag="tag_2", + schemas=schemas_1, + parameters=parameters_1, + config=config, ), mocker.call( - data=path_2_get, path="path_2", method="get", tag="default", schemas=schemas_2, config=config + data=path_2_get, + path="path_2", + method="get", + tag="default", + schemas=schemas_2, + parameters=parameters_2, + config=config, ), ], ) @@ -1220,20 +1373,34 @@ def test_from_data_tags_snake_case_sanitizer(self, mocker): schemas_1 = mocker.MagicMock() schemas_2 = mocker.MagicMock() schemas_3 = mocker.MagicMock() + parameters_1 = mocker.MagicMock() + parameters_2 = mocker.MagicMock() + parameters_3 = mocker.MagicMock() endpoint_from_data = mocker.patch.object( Endpoint, "from_data", - side_effect=[(endpoint_1, schemas_1), (endpoint_2, schemas_2), (endpoint_3, schemas_3)], + 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() config = MagicMock() - result = EndpointCollection.from_data(data=data, schemas=schemas, config=config) + result = EndpointCollection.from_data(data=data, schemas=schemas, parameters=parameters, config=config) endpoint_from_data.assert_has_calls( [ mocker.call( - data=path_1_put, path="path_1", method="put", tag="default", schemas=schemas, config=config + data=path_1_put, + path="path_1", + method="put", + tag="default", + schemas=schemas, + parameters=parameters, + config=config, ), mocker.call( data=path_1_post, @@ -1241,10 +1408,17 @@ def test_from_data_tags_snake_case_sanitizer(self, mocker): method="post", tag="amf_subscription_info_document", schemas=schemas_1, + parameters=parameters_1, config=config, ), mocker.call( - data=path_2_get, path="path_2", method="get", tag="tag3_abc", schemas=schemas_2, config=config + data=path_2_get, + path="path_2", + method="get", + tag="tag3_abc", + schemas=schemas_2, + parameters=parameters_2, + config=config, ), ], ) @@ -1257,4 +1431,5 @@ def test_from_data_tags_snake_case_sanitizer(self, mocker): "tag3_abc": EndpointCollection("tag3_abc", endpoints=[endpoint_3]), }, schemas_3, + parameters_3, ) diff --git a/tests/test_parser/test_properties/test_init.py b/tests/test_parser/test_properties/test_init.py index 3d2de6519..3c063365e 100644 --- a/tests/test_parser/test_properties/test_init.py +++ b/tests/test_parser/test_properties/test_init.py @@ -6,15 +6,8 @@ import openapi_python_client.schema as oai from openapi_python_client import Config -from openapi_python_client.parser.errors import PropertyError, ValidationError -from openapi_python_client.parser.properties import ( - BooleanProperty, - FloatProperty, - IntProperty, - NoneProperty, - Property, - Schemas, -) +from openapi_python_client.parser.errors import ParameterError, PropertyError, ValidationError +from openapi_python_client.parser.properties import BooleanProperty, FloatProperty, IntProperty, Property, Schemas MODULE_NAME = "openapi_python_client.parser.properties" @@ -995,6 +988,84 @@ def test_retries_failing_properties_while_making_progress(self, mocker): assert result.errors == [PropertyError()] +class TestBuildParameters: + def test_skips_references_and_keeps_going(self, mocker): + from openapi_python_client.parser.properties import Parameters, build_parameters + from openapi_python_client.schema import Parameter, Reference + + parameters = { + "reference": Reference(ref="#/components/parameters/another_parameter"), + "defined": Parameter( + name="page", + param_in="query", + required=False, + style="form", + explode=True, + schema=oai.Schema(type="integer", default=0), + ), + } + + update_parameters_with_data = mocker.patch(f"{MODULE_NAME}.update_parameters_with_data") + parse_reference_path = mocker.patch(f"{MODULE_NAME}.parse_reference_path") + + result = build_parameters(components=parameters, parameters=Parameters()) + # Should not even try to parse a path for the Reference + parse_reference_path.assert_called_once_with("#/components/parameters/defined") + update_parameters_with_data.assert_called_once_with( + ref_path=parse_reference_path.return_value, + data=parameters["defined"], + parameters=Parameters( + errors=[ParameterError(detail="Reference parameters are not supported.", data=parameters["reference"])] + ), + ) + assert result == update_parameters_with_data.return_value + + def test_records_bad_uris_and_keeps_going(self, mocker): + from openapi_python_client.parser.properties import Parameters, build_parameters + from openapi_python_client.schema import Parameter + + parameters = {"first": Parameter.construct(), "second": Parameter.construct()} + update_parameters_with_data = mocker.patch(f"{MODULE_NAME}.update_parameters_with_data") + parse_reference_path = mocker.patch( + f"{MODULE_NAME}.parse_reference_path", side_effect=[ParameterError(detail="some details"), "a_path"] + ) + + result = build_parameters(components=parameters, parameters=Parameters()) + parse_reference_path.assert_has_calls( + [ + call("#/components/parameters/first"), + call("#/components/parameters/second"), + ] + ) + update_parameters_with_data.assert_called_once_with( + ref_path="a_path", + data=parameters["second"], + parameters=Parameters(errors=[ParameterError(detail="some details", data=parameters["first"])]), + ) + assert result == update_parameters_with_data.return_value + + def test_retries_failing_parameters_while_making_progress(self, mocker): + from openapi_python_client.parser.properties import Parameters, build_parameters + from openapi_python_client.schema import Parameter + + parameters = {"first": Parameter.construct(), "second": Parameter.construct()} + update_parameters_with_data = mocker.patch( + f"{MODULE_NAME}.update_parameters_with_data", side_effect=[ParameterError(), Parameters(), ParameterError()] + ) + + parse_reference_path = mocker.patch(f"{MODULE_NAME}.parse_reference_path") + result = build_parameters(components=parameters, parameters=Parameters()) + parse_reference_path.assert_has_calls( + [ + call("#/components/parameters/first"), + call("#/components/parameters/second"), + call("#/components/parameters/first"), + ] + ) + assert update_parameters_with_data.call_count == 3 + assert result.errors == [ParameterError()] + + def test_build_enum_property_conflict(): from openapi_python_client.parser.properties import Schemas, build_enum_property diff --git a/tests/test_parser/test_properties/test_schemas.py b/tests/test_parser/test_properties/test_schemas.py index 42dd6c323..629286cae 100644 --- a/tests/test_parser/test_properties/test_schemas.py +++ b/tests/test_parser/test_properties/test_schemas.py @@ -1,5 +1,12 @@ import pytest +from openapi_python_client.parser.errors import ParameterError +from openapi_python_client.parser.properties import Class, Parameters +from openapi_python_client.parser.properties.schemas import parameter_from_reference +from openapi_python_client.schema import Parameter, Reference + +MODULE_NAME = "openapi_python_client.parser.properties.schemas" + def test_class_from_string_default_config(): from openapi_python_client import Config @@ -32,3 +39,109 @@ def test_class_from_string(class_override, module_override, expected_class, expe result = Class.from_string(string=ref, config=config) assert result.name == expected_class assert result.module_name == expected_module + + +class TestParameterFromData: + def test_cannot_parse_parameters_by_reference(self): + from openapi_python_client.parser.properties import Parameters + from openapi_python_client.parser.properties.schemas import parameter_from_data + + ref = Reference.construct(ref="#/components/parameters/a_param") + parameters = Parameters() + param_or_error, new_parameters = parameter_from_data( + name="a_param", required=True, data=ref, parameters=parameters + ) + assert param_or_error == ParameterError("Unable to resolve another reference") + assert new_parameters == parameters + + def test_parameters_without_schema_are_ignored(self): + from openapi_python_client.parser.properties import Parameters + from openapi_python_client.parser.properties.schemas import parameter_from_data + from openapi_python_client.schema import ParameterLocation, Schema + + param = Parameter(name="a_schemaless_param", param_in=ParameterLocation.QUERY) + parameters = Parameters() + param_or_error, new_parameters = parameter_from_data( + name=param.name, required=param.required, data=param, parameters=parameters + ) + assert param_or_error == ParameterError("Parameter has no schema") + assert new_parameters == parameters + + def test_registers_new_parameters(self): + from openapi_python_client.parser.properties import Parameters + from openapi_python_client.parser.properties.schemas import parameter_from_data + from openapi_python_client.schema import ParameterLocation, Schema + + param = Parameter.construct(name="a_param", param_in=ParameterLocation.QUERY, param_schema=Schema.construct()) + parameters = Parameters() + param_or_error, new_parameters = parameter_from_data( + name=param.name, required=param.required, data=param, parameters=parameters + ) + assert param_or_error == param + assert new_parameters.classes_by_name[param.name] == param + + +class TestParameterFromReference: + def test_returns_parameter_if_parameter_provided(self): + param = Parameter.construct() + params = Parameters() + param_or_error = parameter_from_reference(param=param, parameters=params) + assert param_or_error == param + + def test_errors_out_if_reference_not_in_parameters(self): + ref = Reference.construct(ref="#/components/parameters/a_param") + class_info = Class(name="a_param", module_name="module_name") + existing_param = Parameter.construct(name="a_param") + param_by_ref = Reference.construct(ref="#/components/parameters/another_param") + params = Parameters( + classes_by_name={class_info.name: existing_param}, classes_by_reference={ref.ref: existing_param} + ) + param_or_error = parameter_from_reference(param=param_by_ref, parameters=params) + assert param_or_error == ParameterError( + detail="Reference `/components/parameters/another_param` not found.", + ) + + def test_returns_reference_from_registry(self): + existing_param = Parameter.construct(name="a_param") + class_info = Class(name="MyParameter", module_name="module_name") + params = Parameters( + classes_by_name={class_info.name: existing_param}, + classes_by_reference={"/components/parameters/a_param": existing_param}, + ) + + param_by_ref = Reference.construct(ref="#/components/parameters/a_param") + param_or_error = parameter_from_reference(param=param_by_ref, parameters=params) + assert param_or_error == existing_param + + +class TestUpdateParametersFromData: + def test_reports_parameters_with_errors(self, mocker): + from openapi_python_client.parser.properties.schemas import update_parameters_with_data + from openapi_python_client.schema import ParameterLocation, Schema + + parameters = Parameters() + param = Parameter.construct(name="a_param", param_in=ParameterLocation.QUERY, param_schema=Schema.construct()) + parameter_from_data = mocker.patch( + f"{MODULE_NAME}.parameter_from_data", side_effect=[(ParameterError(), parameters)] + ) + ref_path = Reference.construct(ref="#/components/parameters/a_param") + new_parameters_or_error = update_parameters_with_data(ref_path=ref_path.ref, data=param, parameters=parameters) + + parameter_from_data.assert_called_once() + assert new_parameters_or_error == ParameterError( + detail="Unable to parse this part of your OpenAPI document: : None", + header="Unable to parse parameter #/components/parameters/a_param", + ) + + def test_records_references_to_parameters(self, mocker): + from openapi_python_client.parser.properties.schemas import update_parameters_with_data + from openapi_python_client.schema import ParameterLocation, Schema + + parameters = Parameters() + param = Parameter.construct(name="a_param", param_in=ParameterLocation.QUERY, param_schema=Schema.construct()) + parameter_from_data = mocker.patch(f"{MODULE_NAME}.parameter_from_data", side_effect=[(param, parameters)]) + ref_path = "#/components/parameters/a_param" + new_parameters = update_parameters_with_data(ref_path=ref_path, data=param, parameters=parameters) + + parameter_from_data.assert_called_once() + assert new_parameters.classes_by_reference[ref_path] == param