diff --git a/starmap_client/client.py b/starmap_client/client.py index 14419ed..cb00ca3 100644 --- a/starmap_client/client.py +++ b/starmap_client/client.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later import logging -from typing import Any, Dict, Iterator, List, Optional +from typing import Any, Dict, Iterator, List, Optional, Type, Union from starmap_client.models import ( Destination, @@ -8,7 +8,7 @@ PaginatedRawData, Policy, QueryResponse, - Workflow, + QueryResponseContainer, ) from starmap_client.providers import StarmapProvider from starmap_client.session import StarmapBaseSession, StarmapSession @@ -16,6 +16,15 @@ log = logging.getLogger(__name__) +Q = Union[QueryResponse, QueryResponseContainer] + +API_QUERY_RESPONSE: Dict[str, Type[Q]] = { + "v1": QueryResponse, + "v2": QueryResponseContainer, + "default": QueryResponseContainer, +} + + class StarmapClient(object): """Implement the StArMap client.""" @@ -25,7 +34,7 @@ class StarmapClient(object): def __init__( self, url: Optional[str] = None, - api_version: str = "v1", + api_version: str = "v2", session: Optional[StarmapBaseSession] = None, session_params: Optional[Dict[str, Any]] = None, provider: Optional[StarmapProvider] = None, @@ -38,7 +47,7 @@ def __init__( URL of the StArMap endpoint. Required when session is not set. api_version (str, optional) - The StArMap API version. Defaults to `v1`. + The StArMap API version. Defaults to `v2`. session (StarmapBaseSession, optional) Defines the session object to use. Defaults to `StarmapSession` when not set session_params (dict, optional) @@ -51,18 +60,23 @@ def __init__( raise ValueError( "Cannot initialize the client without defining either an \"url\" or \"session\"." ) + if provider and provider.api != api_version: + raise ValueError( + f"API mismatch: Provider has API {provider.api} but the client expects: {api_version}" # noqa: E501 + ) session_params = session_params or {} url = url or "" # just to make mypy happy. The URL is mandatory if session is not defined self.session = session or StarmapSession(url, api_version, **session_params) + self.api_version = api_version self._provider = provider self._policies: List[Policy] = [] - def _query(self, params: Dict[str, Any]) -> Optional[QueryResponse]: + def _query(self, params: Dict[str, Any]) -> Optional[Q]: qr = None if self._provider: qr = self._provider.query(params) rsp = qr or self.session.get("/query", params=params) - if isinstance(rsp, QueryResponse): + if isinstance(rsp, QueryResponse) or isinstance(rsp, QueryResponseContainer): log.debug( "Returning response from the local provider %s", self._provider.__class__.__name__ ) @@ -71,41 +85,40 @@ def _query(self, params: Dict[str, Any]) -> Optional[QueryResponse]: log.error(f"Marketplace mappings not defined for {params}") return None rsp.raise_for_status() - return QueryResponse.from_json(json=rsp.json()) + converter = API_QUERY_RESPONSE.get(self.api_version, API_QUERY_RESPONSE["default"]) + return converter.from_json(json=rsp.json()) - def query_image( - self, nvr: str, workflow: Workflow = Workflow.stratosphere - ) -> Optional[QueryResponse]: + def query_image(self, nvr: str, **kwargs) -> Optional[Q]: """ Query StArMap using an image NVR. Args: nvr (str): The image archive name or NVR. - workflow(Workflow, optional): The desired workflow to retrieve the mappings from. + workflow(Workflow, optional): The desired workflow to retrieve the mappings (APIv1 Only) Returns: - QueryResponse: The query result when found or None. + Q: The query result when found or None. """ - return self._query(params={"image": nvr, "workflow": workflow.value}) + return self._query(params={"image": nvr, **kwargs}) def query_image_by_name( self, name: str, version: Optional[str] = None, - workflow: Workflow = Workflow.stratosphere, - ) -> Optional[QueryResponse]: + **kwargs, + ) -> Optional[Q]: """ Query StArMap using an image NVR. Args: name (str): The image name from NVR. version (str, optional): The version from NVR. - workflow (Workflow, optional): The desired workflow to retrieve the mappings from. + workflow(Workflow, optional): The desired workflow to retrieve the mappings (APIv1 Only) Returns: - QueryResponse: The query result when found or None. + Q: The query result when found or None. """ - params = {"name": name, "workflow": workflow.value} + params = {"name": name, **kwargs} if version: params.update({"version": version}) return self._query(params=params) diff --git a/starmap_client/models.py b/starmap_client/models.py index c251786..2f5a02f 100644 --- a/starmap_client/models.py +++ b/starmap_client/models.py @@ -1,26 +1,37 @@ # SPDX-License-Identifier: GPL-3.0-or-later import sys +from copy import deepcopy from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Type if sys.version_info >= (3, 8): from typing import TypedDict # pragma: no cover else: from typing_extensions import TypedDict # pragma: no cover -from attrs import Attribute, Factory, field, frozen +from attrs import Attribute, Factory, asdict, evolve, field, frozen from attrs.validators import deep_iterable, deep_mapping, instance_of, min_len, optional +from starmap_client.utils import assert_is_dict, dict_merge + __all__ = [ + 'BillingCodeRule', + 'BillingImageType', 'Destination', 'Mapping', 'Policy', 'QueryResponse', + 'QueryResponseEntity', + 'QueryResponseContainer', 'PaginatedRawData', 'PaginationMetadata', + 'Workflow', ] +# ============================================ Common ============================================== + + class PaginationMetadata(TypedDict): """Datastructure of the metadata about the paginated query.""" @@ -222,6 +233,9 @@ class Policy(StarmapBaseData): """The policy workflow name.""" +# ============================================ APIv1 =============================================== + + @frozen class QueryResponse(StarmapJSONDecodeMixin): """Represent a query response from StArMap.""" @@ -262,3 +276,296 @@ def _preprocess_json(cls, json: Any) -> Dict[str, Any]: mappings[c] = dst json["clouds"] = mappings return json + + +# ============================================ APIv2 =============================================== + + +class BillingImageType(str, Enum): + """Define the image type for :class:`~BillingCodeRule` for APIv2.""" + + access = "access" + hourly = "hourly" + marketplace = "marketplace" + + +@frozen +class BillingCodeRule(StarmapJSONDecodeMixin): + """Define a single Billing Code Configuration rule for APIv2.""" + + codes: List[str] = field( + validator=deep_iterable( + member_validator=instance_of(str), iterable_validator=instance_of(list) + ) + ) + """The billing codes to insert when this rule is matched.""" + + image_name: str = field(validator=instance_of(str)) + """The image name to match the rule.""" + + image_types: List[BillingImageType] = field( + converter=lambda x: [BillingImageType[d] for d in x], + validator=deep_iterable( + member_validator=instance_of(BillingImageType), iterable_validator=instance_of(list) + ), + ) + """Image types list. Supported values are ``access`` and ``hourly``.""" + + name: Optional[str] + """The billing code rule name.""" + + +@frozen +class MappingResponseObject(StarmapJSONDecodeMixin, MetaMixin): + """Represent a single mapping response from :class:`~QueryResponseObject` for APIv2.""" + + destinations: List[Destination] = field( + validator=deep_iterable( + iterable_validator=instance_of(list), member_validator=instance_of(Destination) + ) + ) + """List of destinations for the mapping response object.""" + + provider: Optional[str] = field(validator=optional(instance_of(str))) + """The provider name for the community workflow.""" + + @staticmethod + def _unify_meta_with_destinations(json: Dict[str, Any]) -> None: + """Merge the ``meta`` data from mappings into the destinations.""" + destinations = json.get("destinations", []) + if not isinstance(destinations, list): + raise ValueError(f"Expected destinations to be a list, got \"{type(destinations)}\"") + meta = json.get("meta", {}) + for d in destinations: + d["meta"] = dict_merge(meta, d.get("meta", {})) + + @classmethod + def _preprocess_json(cls, json: Any) -> Dict[str, Any]: + """ + Properly adjust the Destinations list for building this object. + + Params: + json (dict): A JSON containing a StArMap Query response. + Returns: + dict: The modified JSON. + """ + cls._unify_meta_with_destinations(json) + provider = json.get("provider", None) + destinations = json.get("destinations", []) + converted_destinations = [] + for d in destinations: + d["provider"] = provider + converted_destinations.append(Destination.from_json(d)) + json["destinations"] = converted_destinations + return json + + +@frozen +class QueryResponseEntity(StarmapJSONDecodeMixin, MetaMixin): + """Represent a single query response entity from StArMap APIv2.""" + + name: str = field(validator=instance_of(str)) + """The :class:`~Policy` name.""" + + billing_code_config: Optional[Dict[str, BillingCodeRule]] = field( + validator=optional( + deep_mapping( + key_validator=instance_of(str), + value_validator=instance_of(BillingCodeRule), + mapping_validator=instance_of(dict), + ) + ) + ) + """The Billing Code Configuration for the community workflow.""" + + cloud: str = field(validator=instance_of(str)) + """The cloud name where the destinations are meant to.""" + + workflow: Workflow = field(converter=lambda x: Workflow(x)) + """The :class:`~Policy` workflow.""" + + mappings: Dict[str, MappingResponseObject] = field( + validator=deep_mapping( + key_validator=instance_of(str), + value_validator=instance_of(MappingResponseObject), + mapping_validator=instance_of(dict), + ), + ) + """Dictionary with the cloud account names and MappingResponseObjects.""" + + @property + def account_names(self) -> List[str]: + """Return the list of cloud account names declared on ``mappings``.""" + return list(self.mappings.keys()) + + @property + def all_mappings(self) -> List[MappingResponseObject]: + """Return all ``MappingResponseObject`` stored in ``mappings``.""" + return list(self.mappings.values()) + + def get_mapping_for_account(self, account: str) -> MappingResponseObject: + """Return a single ``MappingResponseObject`` for a given account name. + + Args: + account (str): + The account name to retrieve the ``MappingResponseObject`` + Returns: + MappingResponseObject: The required mapping when found + Raises: KeyError when not found + """ + obj = self.mappings.get(account, None) + if not obj: + raise KeyError(f"No mappings found for account name {account}") + return obj + + def to_classic_query_response(self) -> QueryResponse: + """Return the representation of this object as a :class:`~QueryResponse` from APIv1.""" + + def add_bc_to_dst_meta(clouds: Dict[str, List[Destination]]): + if self.billing_code_config: + bc_data = {k: asdict(v) for k, v in self.billing_code_config.items()} + for dst_list in clouds.values(): + for d in dst_list: + meta = d.meta or {} + meta["billing-code-config"] = bc_data + d = evolve(d, meta=meta) + + clouds: Dict[str, List[Destination]] = {} + for k, v in self.mappings.items(): + clouds[k] = [deepcopy(d) for d in v.destinations] + + if self.billing_code_config: + add_bc_to_dst_meta(clouds) + + return QueryResponse(name=self.name, workflow=self.workflow, clouds=clouds) + + @staticmethod + def _unify_meta_with_mappings(json: Dict[str, Any]) -> None: + """Merge the ``meta`` data from package into the mappings.""" + mappings = json.get("mappings", {}) + meta = json.get("meta", {}) + for k, v in mappings.items(): + mappings[k]["meta"] = dict_merge(meta, v.get("meta", {})) + + @classmethod + def _preprocess_json(cls, json: Dict[str, Any]) -> Dict[str, Any]: + """ + Properly adjust the MappingResponseObject list and BillingCodeConfig dict for building this object. + + Params: + json (dict): A JSON containing a StArMap Query response. + Returns: + dict: The modified JSON. + """ # noqa: D202 E501 + + def parse_entity_build_obj( + entity_name: str, converter_type: Type[StarmapJSONDecodeMixin] + ) -> None: + entity = json.pop(entity_name, {}) + for k in entity.keys(): + assert_is_dict(entity[k]) + obj = converter_type.from_json(entity[k]) + entity[k] = obj + json[entity_name] = entity + + bcc = json.pop("billing-code-config", {}) + json["billing_code_config"] = bcc + cls._unify_meta_with_mappings(json) + parse_entity_build_obj("mappings", MappingResponseObject) + parse_entity_build_obj("billing_code_config", BillingCodeRule) + return json + + +@frozen +class QueryResponseContainer: + """Represent a full query response from APIv2.""" + + responses: List[QueryResponseEntity] = field( + validator=deep_iterable( + member_validator=instance_of(QueryResponseEntity), iterable_validator=instance_of(list) + ) + ) + """List with all responses from a Query V2 mapping.""" + + @classmethod + def from_json(cls, json: Any): + """ + Convert the APIv2 response JSON into this object. + + Args: + json (list) + A JSON containing a StArMap APIv2 response. + Returns: + The converted object from JSON. + """ + if not isinstance(json, list): + raise ValueError(f"Expected root to be a list, got \"{type(json)}\".") + + responses = [QueryResponseEntity.from_json(qre) for qre in json] + return cls(responses) + + def filter_by_name( + self, name: str, responses: Optional[List[QueryResponseEntity]] = None + ) -> List[QueryResponseEntity]: + """Return a sublist of the responses with only the selected image name. + + Args: + name (str): + The image name to filter the list of responses + responses (list, optional): + List of existing responses to filter. Default by the container's own list. + Returns: + list: The sublist with only the selected image name. + """ + if responses == []: + return responses + rsp = responses or self.responses + return [x for x in rsp if x.name == name] + + def filter_by_workflow( + self, workflow: Workflow, responses: Optional[List[QueryResponseEntity]] = None + ) -> List[QueryResponseEntity]: + """Return a sublist of the responses with only the selected workflow. + + Args: + workflow (Workflow): + The workflow to filter the list of responses + responses (list, optional): + List of existing responses to filter. Default by the container's own list. + Returns: + list: The sublist with only the selected workflows + """ + if responses == []: + return responses + rsp = responses or self.responses + return [x for x in rsp if x.workflow == workflow] + + def filter_by_cloud( + self, cloud: str, responses: Optional[List[QueryResponseEntity]] = None + ) -> List[QueryResponseEntity]: + """Return a sublist of the responses with only the selected cloud name. + + Args: + cloud (str): + The cloud name to filter the list of responses + responses (list, optional): + List of existing responses to filter. Default by the container's own list. + Returns: + list: The sublist with only the selected cloud name. + """ + if responses == []: + return responses + rsp = responses or self.responses + return [x for x in rsp if x.cloud == cloud] + + def filter_by(self, **kwargs) -> List[QueryResponseEntity]: + """Return a sublist of the responses with the selected filters.""" + filters = { + "name": self.filter_by_name, + "cloud": self.filter_by_cloud, + "workflow": self.filter_by_workflow, + } + res = self.responses + for k, v in kwargs.items(): + res = filters[k](v, responses=res) + return res diff --git a/starmap_client/providers/__init__.py b/starmap_client/providers/__init__.py index 239ee16..2db582d 100644 --- a/starmap_client/providers/__init__.py +++ b/starmap_client/providers/__init__.py @@ -1,3 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later from starmap_client.providers.base import StarmapProvider # noqa: F401 -from starmap_client.providers.memory import InMemoryMapProvider # noqa: F401 +from starmap_client.providers.memory import ( # noqa: F401 + InMemoryMapProviderV1, + InMemoryMapProviderV2, +) diff --git a/starmap_client/providers/base.py b/starmap_client/providers/base.py index 7415009..32ef2a2 100644 --- a/starmap_client/providers/base.py +++ b/starmap_client/providers/base.py @@ -1,14 +1,19 @@ from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Generic, List, Optional, TypeVar -from starmap_client.models import QueryResponse +from starmap_client.models import QueryResponse, QueryResponseContainer, QueryResponseEntity +T = TypeVar("T", QueryResponse, QueryResponseContainer, QueryResponseEntity) -class StarmapProvider(ABC): + +class StarmapProvider(ABC, Generic[T]): """Define the interface for a local mappings provider.""" + api = "default" + """The provider's API level implementation.""" + @abstractmethod - def query(self, params: Dict[str, Any]) -> Optional[QueryResponse]: + def query(self, params: Dict[str, Any]) -> Optional[T]: """Retrieve the mapping without using the server. It relies in the local provider to retrieve the correct mapping @@ -22,14 +27,14 @@ def query(self, params: Dict[str, Any]) -> Optional[QueryResponse]: """ @abstractmethod - def list_content(self) -> List[QueryResponse]: - """Return a list with all stored QueryResponse objects.""" + def list_content(self) -> List[T]: + """Return a list with all stored responses.""" @abstractmethod - def store(self, query_response: QueryResponse) -> None: - """Store a single query_response into the local provider. + def store(self, response: T) -> None: + """Store a single response into the local provider. Args: - query_response (query_response): + response (response): The object to store. """ diff --git a/starmap_client/providers/memory.py b/starmap_client/providers/memory.py index 91d0107..c6ee874 100644 --- a/starmap_client/providers/memory.py +++ b/starmap_client/providers/memory.py @@ -1,12 +1,14 @@ from typing import Any, Dict, List, Optional -from starmap_client.models import QueryResponse +from starmap_client.models import QueryResponse, QueryResponseContainer, QueryResponseEntity from starmap_client.providers.base import StarmapProvider from starmap_client.providers.utils import get_image_name -class InMemoryMapProvider(StarmapProvider): - """Provide in memory (RAM) QueryResponse mapping objects.""" +class InMemoryMapProviderV1(StarmapProvider): + """Provide in memory (RAM) QueryResponse mapping objects for APIv1.""" + + api = "v1" def __init__( self, map_responses: Optional[List[QueryResponse]] = None, *args, **kwargs @@ -43,15 +45,15 @@ def list_content(self) -> List[QueryResponse]: """Return a list of stored content.""" return list(self._content.values()) - def store(self, query_response: QueryResponse) -> None: + def store(self, response: QueryResponse) -> None: """Store/replace a single QueryResponse object. Args: - query_response (query_response): + response (QueryResponse): The object to store. """ - key = f"{query_response.name}{self._separator}{query_response.workflow.value}" - self._content[key] = query_response + key = f"{response.name}{self._separator}{response.workflow.value}" + self._content[key] = response def query(self, params: Dict[str, Any]) -> Optional[QueryResponse]: """Return the mapping from memory according to the received params. @@ -66,3 +68,56 @@ def query(self, params: Dict[str, Any]) -> Optional[QueryResponse]: workflow = str(params.get("workflow", "")) search_key = f"{name}{self._separator}{workflow}" return self._content.get(search_key) + + +class InMemoryMapProviderV2(StarmapProvider): + """Provide in memory (RAM) QueryResponseContainer objects for APIv2.""" + + api = "v2" + + def __init__(self, container: QueryResponseContainer, *args, **kwargs) -> None: + """Crete a new InMemoryMapProvider object. + + Args: + container (QueryResponseContainer) + QueryResponseContainer object to load into memory. It will be + used by query to return the correct response based on name + and workflow. + """ + self._container = container + super(StarmapProvider, self).__init__() + + def query(self, params: Dict[str, Any]) -> Optional[QueryResponseContainer]: + """Retrieve the mapping without using the server. + + It relies in the local provider to retrieve the correct mappings + according to the parameters. + + Args: + params (dict): + The request params to retrieve the mapping. + Returns: + The requested container with mappings when found. + """ + filter_params = {"name": params.get("name") or get_image_name(params.get("image"))} + for k in ["cloud", "workflow"]: + v = params.get(k) + if v: + filter_params.update({k: v}) + res = self._container.filter_by(**filter_params) + if res: + return QueryResponseContainer(res) + return None + + def list_content(self) -> List[QueryResponseEntity]: + """Return a the responses stored in the container.""" + return self._container.responses + + def store(self, response: QueryResponseEntity) -> None: + """Store a single response into the local provider's container. + + Args: + container (container): + The container to store. + """ + self._container.responses.append(response) diff --git a/starmap_client/utils.py b/starmap_client/utils.py new file mode 100644 index 0000000..a6f575a --- /dev/null +++ b/starmap_client/utils.py @@ -0,0 +1,23 @@ +from typing import Any, Dict + + +def assert_is_dict(data: Any) -> None: + """Ensure the incoming data is a dictionary, raises ``ValueError`` if not.""" + if not isinstance(data, dict): + raise ValueError(f"Expected dictionary, got {type(data)}: {data}") + + +def dict_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: + """Return a new dictionary with the combination of A and B. + + Args: + a (dict): + The left dictionary to be combined + b (dict): + The right dictionary to be combined. It will override the same keys from A. + Returns: + dict: A new dictionary with combination of A and B. + """ + for x in [a, b]: + assert_is_dict(x) + return a | b diff --git a/tests/data/query/invalid_quer1.json b/tests/data/query_v1/invalid_quer1.json similarity index 100% rename from tests/data/query/invalid_quer1.json rename to tests/data/query_v1/invalid_quer1.json diff --git a/tests/data/query/invalid_quer2.json b/tests/data/query_v1/invalid_quer2.json similarity index 100% rename from tests/data/query/invalid_quer2.json rename to tests/data/query_v1/invalid_quer2.json diff --git a/tests/data/query/valid_quer1.json b/tests/data/query_v1/valid_quer1.json similarity index 100% rename from tests/data/query/valid_quer1.json rename to tests/data/query_v1/valid_quer1.json diff --git a/tests/data/query/valid_quer2.json b/tests/data/query_v1/valid_quer2.json similarity index 100% rename from tests/data/query/valid_quer2.json rename to tests/data/query_v1/valid_quer2.json diff --git a/tests/data/query/valid_quer3.json b/tests/data/query_v1/valid_quer3.json similarity index 100% rename from tests/data/query/valid_quer3.json rename to tests/data/query_v1/valid_quer3.json diff --git a/tests/data/query/valid_quer4.json b/tests/data/query_v1/valid_quer4.json similarity index 100% rename from tests/data/query/valid_quer4.json rename to tests/data/query_v1/valid_quer4.json diff --git a/tests/data/query/valid_quer5.json b/tests/data/query_v1/valid_quer5.json similarity index 100% rename from tests/data/query/valid_quer5.json rename to tests/data/query_v1/valid_quer5.json diff --git a/tests/data/query_v2/mapping_response_obj/invalid_mro1.json b/tests/data/query_v2/mapping_response_obj/invalid_mro1.json new file mode 100644 index 0000000..c70d2ee --- /dev/null +++ b/tests/data/query_v2/mapping_response_obj/invalid_mro1.json @@ -0,0 +1,12 @@ +{ + "destinations": { + "architecture": "x86_64", + "destination": "fake_destination", + "overwrite": false, + "restrict_version": true, + "meta": { + "description": "Description on destination level." + } + }, + "error": "Expected destinations to be a list, got \"\"" +} diff --git a/tests/data/query_v2/mapping_response_obj/invalid_mro2.json b/tests/data/query_v2/mapping_response_obj/invalid_mro2.json new file mode 100644 index 0000000..ae96700 --- /dev/null +++ b/tests/data/query_v2/mapping_response_obj/invalid_mro2.json @@ -0,0 +1,4 @@ +{ + "destinations": true, + "error": "Expected destinations to be a list, got \"\"" +} diff --git a/tests/data/query_v2/mapping_response_obj/invalid_mro3.json b/tests/data/query_v2/mapping_response_obj/invalid_mro3.json new file mode 100644 index 0000000..a8ca886 --- /dev/null +++ b/tests/data/query_v2/mapping_response_obj/invalid_mro3.json @@ -0,0 +1,13 @@ +{ + "destinations": [ + { + "architecture": "x86_64", + "destination": "fake_destination", + "overwrite": false, + "restrict_version": true + } + ], + "meta": [], + "provider": "AWS", + "error": "Expected dictionary, got : \\[\\]" +} diff --git a/tests/data/query_v2/mapping_response_obj/valid_mro1.json b/tests/data/query_v2/mapping_response_obj/valid_mro1.json new file mode 100644 index 0000000..7652c21 --- /dev/null +++ b/tests/data/query_v2/mapping_response_obj/valid_mro1.json @@ -0,0 +1,14 @@ +{ + "destinations": [ + { + "architecture": "x86_64", + "destination": "fake_destination", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "Description on map level." + }, + "provider": "AWS" +} diff --git a/tests/data/query_v2/mapping_response_obj/valid_mro1_meta.json b/tests/data/query_v2/mapping_response_obj/valid_mro1_meta.json new file mode 100644 index 0000000..f384c34 --- /dev/null +++ b/tests/data/query_v2/mapping_response_obj/valid_mro1_meta.json @@ -0,0 +1,3 @@ +{ + "description": "Description on map level." +} diff --git a/tests/data/query_v2/mapping_response_obj/valid_mro2.json b/tests/data/query_v2/mapping_response_obj/valid_mro2.json new file mode 100644 index 0000000..5889b6d --- /dev/null +++ b/tests/data/query_v2/mapping_response_obj/valid_mro2.json @@ -0,0 +1,16 @@ +{ + "destinations": [ + { + "architecture": "x86_64", + "destination": "fake_destination", + "overwrite": false, + "restrict_version": true, + "meta": {"description": "Description on destination level."} + } + ], + "meta": { + "description": "Description on map level.", + "foo": "bar" + }, + "provider": "AZURE" +} diff --git a/tests/data/query_v2/mapping_response_obj/valid_mro2_meta.json b/tests/data/query_v2/mapping_response_obj/valid_mro2_meta.json new file mode 100644 index 0000000..80082fe --- /dev/null +++ b/tests/data/query_v2/mapping_response_obj/valid_mro2_meta.json @@ -0,0 +1,4 @@ +{ + "description": "Description on destination level.", + "foo": "bar" +} diff --git a/tests/data/query_v2/mapping_response_obj/valid_mro3.json b/tests/data/query_v2/mapping_response_obj/valid_mro3.json new file mode 100644 index 0000000..07495cc --- /dev/null +++ b/tests/data/query_v2/mapping_response_obj/valid_mro3.json @@ -0,0 +1,11 @@ +{ + "destinations": [ + { + "architecture": "x86_64", + "destination": "fake_destination", + "overwrite": false, + "restrict_version": true, + "meta": {"description": "Description on destination level."} + } + ] +} diff --git a/tests/data/query_v2/mapping_response_obj/valid_mro3_meta.json b/tests/data/query_v2/mapping_response_obj/valid_mro3_meta.json new file mode 100644 index 0000000..4816b7a --- /dev/null +++ b/tests/data/query_v2/mapping_response_obj/valid_mro3_meta.json @@ -0,0 +1,3 @@ +{ + "description": "Description on destination level." +} diff --git a/tests/data/query_v2/query_response_container/invalid_qrc1.json b/tests/data/query_v2/query_response_container/invalid_qrc1.json new file mode 100644 index 0000000..92ac197 --- /dev/null +++ b/tests/data/query_v2/query_response_container/invalid_qrc1.json @@ -0,0 +1,36 @@ +{ + "cloud": "test", + "mappings": { + "test-emea": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination1", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "EMEA description" + }, + "provider": null + }, + "test-na": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination2", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "NA description" + }, + "provider": null + } + }, + "meta": {"foo": "bar"}, + "name": "product-test", + "workflow": "stratosphere" +} diff --git a/tests/data/query_v2/query_response_container/valid_qrc1.json b/tests/data/query_v2/query_response_container/valid_qrc1.json new file mode 100644 index 0000000..f47be56 --- /dev/null +++ b/tests/data/query_v2/query_response_container/valid_qrc1.json @@ -0,0 +1,116 @@ +[ + { + "cloud": "test", + "mappings": { + "test-emea": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination1", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "EMEA description" + }, + "provider": null + }, + "test-na": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination2", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "NA description" + }, + "provider": null + } + }, + "meta": { + "foo": "bar" + }, + "name": "product-test", + "workflow": "stratosphere" + }, + { + "cloud": "test", + "billing-code-config": { + "test-access": { + "codes": [ + "code-000" + ], + "image_name": "test", + "image_types": [ + "access" + ], + "name": "Access2" + }, + "test-marketplace": { + "codes": [], + "image_name": "test", + "image_types": [ + "marketplace" + ], + "name": "Marketplace" + }, + "test-hourly": { + "codes": [ + "code-0002" + ], + "image_name": "rhel", + "image_types": [ + "hourly" + ], + "name": "Hourly2" + } + }, + "mappings": { + "test-storage": { + "destinations": [ + { + "destination": "test-destination/foo/bar", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "second-test-destination/foo/bar", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null, + "meta": { + "foo": "bar" + } + }, + "another-storage": { + "destinations": [ + { + "destination": "aaaaaaaaaaaaaaa", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "bbbbbbbbbbbbb", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null, + "meta": { + "foo": "bar" + } + } + }, + "workflow": "community", + "meta": { + "lorem": "ipsum" + }, + "name": "sample-product" + } +] diff --git a/tests/data/query_v2/query_response_container/valid_qrc2.json b/tests/data/query_v2/query_response_container/valid_qrc2.json new file mode 100644 index 0000000..c1396fe --- /dev/null +++ b/tests/data/query_v2/query_response_container/valid_qrc2.json @@ -0,0 +1,78 @@ +[ + { + "cloud": "test", + "mappings": { + "test-emea": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination1", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "EMEA description" + }, + "provider": null + }, + "test-na": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination2", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "NA description" + }, + "provider": null + } + }, + "meta": { + "foo": "bar" + }, + "name": "product-test", + "workflow": "stratosphere" + }, + { + "cloud": "anothertest", + "mappings": { + "anothertest-emea": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination3", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "EMEA description" + }, + "provider": null + }, + "anothertest-na": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination4", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "NA description" + }, + "provider": null + } + }, + "meta": { + "foo": "bar" + }, + "name": "product-test", + "workflow": "stratosphere" + } +] diff --git a/tests/data/query_v2/query_response_container/valid_qrc3.json b/tests/data/query_v2/query_response_container/valid_qrc3.json new file mode 100644 index 0000000..891cdb4 --- /dev/null +++ b/tests/data/query_v2/query_response_container/valid_qrc3.json @@ -0,0 +1,154 @@ +[ + { + "cloud": "test", + "mappings": { + "test-emea": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination1", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "EMEA description" + }, + "provider": null + }, + "test-na": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination2", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "NA description" + }, + "provider": null + } + }, + "meta": { + "foo": "bar" + }, + "name": "product-test", + "workflow": "stratosphere" + }, + { + "cloud": "test", + "billing-code-config": { + "test-access": { + "codes": [ + "code-000" + ], + "image_name": "test", + "image_types": [ + "access" + ], + "name": "Access2" + }, + "test-marketplace": { + "codes": [], + "image_name": "test", + "image_types": [ + "marketplace" + ], + "name": "Marketplace" + }, + "test-hourly": { + "codes": [ + "code-0002" + ], + "image_name": "rhel", + "image_types": [ + "hourly" + ], + "name": "Hourly2" + } + }, + "mappings": { + "test-storage": { + "destinations": [ + { + "destination": "test-destination/foo/bar", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "second-test-destination/foo/bar", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null, + "meta": { + "foo": "bar" + } + }, + "another-storage": { + "destinations": [ + { + "destination": "aaaaaaaaaaaaaaa", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "bbbbbbbbbbbbb", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null, + "meta": { + "foo": "bar" + } + } + }, + "workflow": "community", + "meta": { + "lorem": "ipsum" + }, + "name": "sample-product" + }, + { + "cloud": "anothertest", + "mappings": { + "anothertest-emea": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination3", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "EMEA description" + }, + "provider": null + }, + "anothertest-na": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination4", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "NA description" + }, + "provider": null + } + }, + "meta": { + "foo": "bar" + }, + "name": "product-test", + "workflow": "stratosphere" + } +] diff --git a/tests/data/query_v2/query_response_entity/valid_qre1.json b/tests/data/query_v2/query_response_entity/valid_qre1.json new file mode 100644 index 0000000..92ac197 --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre1.json @@ -0,0 +1,36 @@ +{ + "cloud": "test", + "mappings": { + "test-emea": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination1", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "EMEA description" + }, + "provider": null + }, + "test-na": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "destination2", + "overwrite": false, + "restrict_version": true + } + ], + "meta": { + "description": "NA description" + }, + "provider": null + } + }, + "meta": {"foo": "bar"}, + "name": "product-test", + "workflow": "stratosphere" +} diff --git a/tests/data/query_v2/query_response_entity/valid_qre1_meta.json b/tests/data/query_v2/query_response_entity/valid_qre1_meta.json new file mode 100644 index 0000000..588a8eb --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre1_meta.json @@ -0,0 +1,10 @@ +{ + "test-emea": { + "description": "EMEA description", + "foo": "bar" + }, + "test-na": { + "description": "NA description", + "foo": "bar" + } +} diff --git a/tests/data/query_v2/query_response_entity/valid_qre2.json b/tests/data/query_v2/query_response_entity/valid_qre2.json new file mode 100644 index 0000000..ce65f24 --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre2.json @@ -0,0 +1,40 @@ +{ + "cloud": "test", + "mappings": { + "test-marketplace": { + "destinations": [ + { + "destination": "test-destination/foo/bar", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "second-test-destination/foo/bar", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null + }, + "another-marketplace": { + "destinations": [ + { + "destination": "aaaaaaaaaaaaaaa", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "bbbbbbbbbbbbb", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null + } + }, + "workflow": "stratosphere", + "name": "sample-product", + "meta": { + "foo": "bar" + } +} diff --git a/tests/data/query_v2/query_response_entity/valid_qre2_meta.json b/tests/data/query_v2/query_response_entity/valid_qre2_meta.json new file mode 100644 index 0000000..c994957 --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre2_meta.json @@ -0,0 +1,8 @@ +{ + "test-marketplace": { + "foo": "bar" + }, + "another-marketplace": { + "foo": "bar" + } +} diff --git a/tests/data/query_v2/query_response_entity/valid_qre3.json b/tests/data/query_v2/query_response_entity/valid_qre3.json new file mode 100644 index 0000000..29c7c9e --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre3.json @@ -0,0 +1,43 @@ +{ + "cloud": "test", + "mappings": { + "test-marketplace": { + "destinations": [ + { + "destination": "test-destination/foo/bar", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "second-test-destination/foo/bar", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null, + "meta": { + "foo": "bar" + } + }, + "another-marketplace": { + "destinations": [ + { + "destination": "aaaaaaaaaaaaaaa", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "bbbbbbbbbbbbb", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null, + "meta": { + "foo": "bar" + } + } + }, + "workflow": "stratosphere", + "name": "sample-product" +} diff --git a/tests/data/query_v2/query_response_entity/valid_qre3_meta.json b/tests/data/query_v2/query_response_entity/valid_qre3_meta.json new file mode 100644 index 0000000..c994957 --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre3_meta.json @@ -0,0 +1,8 @@ +{ + "test-marketplace": { + "foo": "bar" + }, + "another-marketplace": { + "foo": "bar" + } +} diff --git a/tests/data/query_v2/query_response_entity/valid_qre4.json b/tests/data/query_v2/query_response_entity/valid_qre4.json new file mode 100644 index 0000000..106ee0b --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre4.json @@ -0,0 +1,74 @@ +{ + "cloud": "test", + "billing-code-config": { + "test-access": { + "codes": [ + "code-000" + ], + "image_name": "test", + "image_types": [ + "access" + ], + "name": "Access2" + }, + "test-marketplace": { + "codes": [], + "image_name": "test", + "image_types": [ + "marketplace" + ], + "name": "Marketplace" + }, + "test-hourly": { + "codes": [ + "code-0002" + ], + "image_name": "rhel", + "image_types": [ + "hourly" + ], + "name": "Hourly2" + } + }, + "mappings": { + "test-storage": { + "destinations": [ + { + "destination": "test-destination/foo/bar", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "second-test-destination/foo/bar", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null, + "meta": { + "foo": "bar" + } + }, + "another-storage": { + "destinations": [ + { + "destination": "aaaaaaaaaaaaaaa", + "overwrite": false, + "restrict_version": false + }, + { + "destination": "bbbbbbbbbbbbb", + "overwrite": true, + "restrict_version": false + } + ], + "provider": null, + "meta": { + "foo": "bar" + } + } + }, + "workflow": "community", + "meta": {"lorem": "ipsum"}, + "name": "sample-product" +} diff --git a/tests/data/query_v2/query_response_entity/valid_qre4_meta.json b/tests/data/query_v2/query_response_entity/valid_qre4_meta.json new file mode 100644 index 0000000..06f27fe --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre4_meta.json @@ -0,0 +1,10 @@ +{ + "test-storage": { + "foo": "bar", + "lorem": "ipsum" + }, + "another-storage": { + "foo": "bar", + "lorem": "ipsum" + } +} diff --git a/tests/data/query_v2/query_response_entity/valid_qre_converted_old_api.json b/tests/data/query_v2/query_response_entity/valid_qre_converted_old_api.json new file mode 100644 index 0000000..794db70 --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre_converted_old_api.json @@ -0,0 +1,35 @@ +{ + "mappings": + { + "test-marketplace": [ + { + "destination": "test-destination/foo/bar", + "overwrite": false, + "restrict_version": false, + "meta": {"foo": "bar"} + }, + { + "destination": "second-test-destination/foo/bar", + "overwrite": true, + "restrict_version": false, + "meta": {"foo": "bar"} + } + ], + "another-marketplace": [ + { + "destination": "aaaaaaaaaaaaaaa", + "overwrite": false, + "restrict_version": false, + "meta": {"foo": "bar"} + }, + { + "destination": "bbbbbbbbbbbbb", + "overwrite": true, + "restrict_version": false, + "meta": {"foo": "bar"} + } + ] + }, + "workflow": "stratosphere", + "name": "sample-product" +} diff --git a/tests/data/query_v2/query_response_entity/valid_qre_converted_old_api_community.json b/tests/data/query_v2/query_response_entity/valid_qre_converted_old_api_community.json new file mode 100644 index 0000000..0c03e92 --- /dev/null +++ b/tests/data/query_v2/query_response_entity/valid_qre_converted_old_api_community.json @@ -0,0 +1,166 @@ +{ + "mappings": { + "test-storage": [ + { + "destination": "test-destination/foo/bar", + "overwrite": false, + "restrict_version": false, + "meta": { + "billing-code-config": { + "test-access": { + "codes": [ + "code-000" + ], + "image_name": "test", + "image_types": [ + "access" + ], + "name": "Access2" + }, + "test-marketplace": { + "codes": [], + "image_name": "test", + "image_types": [ + "marketplace" + ], + "name": "Marketplace" + }, + "test-hourly": { + "codes": [ + "code-0002" + ], + "image_name": "rhel", + "image_types": [ + "hourly" + ], + "name": "Hourly2" + } + }, + "foo": "bar", + "lorem": "ipsum" + } + }, + { + "destination": "second-test-destination/foo/bar", + "overwrite": true, + "restrict_version": false, + "meta": { + "billing-code-config": { + "test-access": { + "codes": [ + "code-000" + ], + "image_name": "test", + "image_types": [ + "access" + ], + "name": "Access2" + }, + "test-marketplace": { + "codes": [], + "image_name": "test", + "image_types": [ + "marketplace" + ], + "name": "Marketplace" + }, + "test-hourly": { + "codes": [ + "code-0002" + ], + "image_name": "rhel", + "image_types": [ + "hourly" + ], + "name": "Hourly2" + } + }, + "foo": "bar", + "lorem": "ipsum" + } + } + ], + "another-storage": [ + { + "destination": "aaaaaaaaaaaaaaa", + "overwrite": false, + "restrict_version": false, + "meta": { + "billing-code-config": { + "test-access": { + "codes": [ + "code-000" + ], + "image_name": "test", + "image_types": [ + "access" + ], + "name": "Access2" + }, + "test-marketplace": { + "codes": [], + "image_name": "test", + "image_types": [ + "marketplace" + ], + "name": "Marketplace" + }, + "test-hourly": { + "codes": [ + "code-0002" + ], + "image_name": "rhel", + "image_types": [ + "hourly" + ], + "name": "Hourly2" + } + }, + "foo": "bar", + "lorem": "ipsum" + } + }, + { + "destination": "bbbbbbbbbbbbb", + "overwrite": true, + "restrict_version": false, + "meta": { + "billing-code-config": { + "test-access": { + "codes": [ + "code-000" + ], + "image_name": "test", + "image_types": [ + "access" + ], + "name": "Access2" + }, + "test-marketplace": { + "codes": [], + "image_name": "test", + "image_types": [ + "marketplace" + ], + "name": "Marketplace" + }, + "test-hourly": { + "codes": [ + "code-0002" + ], + "image_name": "rhel", + "image_types": [ + "hourly" + ], + "name": "Hourly2" + } + }, + "foo": "bar", + "lorem": "ipsum" + } + } + ] + }, + "workflow": "community", + "name": "sample-product" +} diff --git a/tests/test_client.py b/tests/test_client.py index e6d918a..6773018 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,8 +9,14 @@ from requests.exceptions import HTTPError from starmap_client import StarmapClient -from starmap_client.models import Destination, Mapping, Policy, QueryResponse -from starmap_client.providers import InMemoryMapProvider +from starmap_client.models import ( + Destination, + Mapping, + Policy, + QueryResponse, + QueryResponseContainer, +) +from starmap_client.providers import InMemoryMapProviderV1, InMemoryMapProviderV2 from starmap_client.session import StarmapMockSession @@ -26,10 +32,12 @@ def inject_fixtures(self, caplog: LogCaptureFixture): self._caplog = caplog def setUp(self) -> None: - self.svc = StarmapClient("https://test.starmap.com", api_version="v1") + self.svc_v1 = StarmapClient("https://test.starmap.com", api_version="v1") + self.svc_v2 = StarmapClient("https://test.starmap.com", api_version="v2") self.mock_requests = mock.patch('starmap_client.session.requests').start() - self.mock_session = mock.patch.object(self.svc, 'session').start() + self.mock_session_v1 = mock.patch.object(self.svc_v1, 'session').start() + self.mock_session_v2 = mock.patch.object(self.svc_v2, 'session').start() self.image_name = "foo-bar" self.image_version = "1.0-1" @@ -45,85 +53,162 @@ def setUp(self) -> None: def tearDown(self): mock.patch.stopall() - def test_query_image_success(self): - fpath = "tests/data/query/valid_quer1.json" + def test_query_image_success_APIv1(self): + fpath = "tests/data/query_v1/valid_quer1.json" self.mock_resp_success.json.return_value = load_json(fpath) - self.mock_session.get.return_value = self.mock_resp_success + self.mock_session_v1.get.return_value = self.mock_resp_success - res = self.svc.query_image(self.image) + res = self.svc_v1.query_image(self.image, workflow="stratosphere") expected_params = {"image": self.image, "workflow": "stratosphere"} - self.mock_session.get.assert_called_once_with("/query", params=expected_params) + self.mock_session_v1.get.assert_called_once_with("/query", params=expected_params) self.mock_resp_success.raise_for_status.assert_called_once() # Note: JSON need to be loaded twice as `from_json` pops its original data self.assertEqual(res, QueryResponse.from_json(load_json(fpath))) - def test_in_memory_query_image(self): - fpath = "tests/data/query/valid_quer1.json" + def test_query_image_success_APIv2(self): + fpath = "tests/data/query_v2/query_response_container/valid_qrc1.json" + self.mock_resp_success.json.return_value = load_json(fpath) + self.mock_session_v2.get.return_value = self.mock_resp_success + + res = self.svc_v2.query_image(self.image) + + expected_params = {"image": self.image} + self.mock_session_v2.get.assert_called_once_with("/query", params=expected_params) + self.mock_resp_success.raise_for_status.assert_called_once() + # Note: JSON need to be loaded twice as `from_json` pops its original data + self.assertEqual(res, QueryResponseContainer.from_json(load_json(fpath))) + + def test_in_memory_query_image_APIv1(self): + fpath = "tests/data/query_v1/valid_quer1.json" data = [QueryResponse.from_json(load_json(fpath))] - provider = InMemoryMapProvider(data) + provider = InMemoryMapProviderV1(data) - self.svc = StarmapClient("https://test.starmap.com", api_version="v1", provider=provider) + self.svc_v1 = StarmapClient("https://test.starmap.com", api_version="v1", provider=provider) - res = self.svc.query_image("sample-policy-1.0-1.raw.xz") - self.mock_session.get.assert_not_called() + res = self.svc_v1.query_image("sample-policy-1.0-1.raw.xz", workflow="stratosphere") + self.mock_session_v1.get.assert_not_called() self.assertEqual(res, data[0]) - def test_query_image_not_found(self): - self.mock_session.get.return_value = self.mock_resp_not_found + def test_in_memory_query_image_APIv2(self): + fpath = "tests/data/query_v2/query_response_container/valid_qrc1.json" + data = QueryResponseContainer.from_json(load_json(fpath)) + provider = InMemoryMapProviderV2(data) - with self._caplog.at_level(logging.ERROR): - res = self.svc.query_image(self.image) + self.svc_v2 = StarmapClient("https://test.starmap.com", api_version="v2", provider=provider) - expected_msg = ( - "Marketplace mappings not defined for {'image': '%s', 'workflow': 'stratosphere'}" - % self.image - ) - assert expected_msg in self._caplog.text + res = self.svc_v2.query_image("product-test-1.0-1.raw.xz", workflow="stratosphere") + self.mock_session_v1.get.assert_not_called() + self.assertEqual(res.responses, [data.responses[0]]) - self.assertIsNone(res) + def test_in_memory_api_mismatch(self): + fpath = "tests/data/query_v1/valid_quer1.json" + data = [QueryResponse.from_json(load_json(fpath))] + provider_v1 = InMemoryMapProviderV1(data) + + fpath = "tests/data/query_v2/query_response_container/valid_qrc1.json" + data = QueryResponseContainer.from_json(load_json(fpath)) + provider_v2 = InMemoryMapProviderV2(data) + + err = "API mismatch: Provider has API v[1-2] but the client expects: v[1-2]" + with pytest.raises(ValueError, match=err): + StarmapClient("foo", api_version="v2", provider=provider_v1) + StarmapClient("foo", api_version="v1", provider=provider_v2) - def test_query_image_by_name(self): - fpath = "tests/data/query/valid_quer1.json" + def test_query_image_not_found(self): + self.mock_session_v1.get.return_value = self.mock_resp_not_found + self.mock_session_v2.get.return_value = self.mock_resp_not_found + + for svc in [self.svc_v1, self.svc_v2]: + with self._caplog.at_level(logging.ERROR): + res = svc.query_image(self.image) + expected_msg = "Marketplace mappings not defined for {'image': '%s'}" % self.image + assert expected_msg in self._caplog.text + self.assertIsNone(res) + + def test_query_image_by_name_APIv1(self): + fpath = "tests/data/query_v1/valid_quer1.json" self.mock_resp_success.json.return_value = load_json(fpath) - self.mock_session.get.return_value = self.mock_resp_success + self.mock_session_v1.get.return_value = self.mock_resp_success - res = self.svc.query_image_by_name(name=self.image_name) + res = self.svc_v1.query_image_by_name(name=self.image_name, workflow="stratosphere") expected_params = {"name": self.image_name, "workflow": "stratosphere"} - self.mock_session.get.assert_called_once_with("/query", params=expected_params) + self.mock_session_v1.get.assert_called_once_with("/query", params=expected_params) self.mock_resp_success.raise_for_status.assert_called_once() # Note: JSON need to be loaded twice as `from_json` pops its original data self.assertEqual(res, QueryResponse.from_json(load_json(fpath))) - def test_in_memory_query_image_by_name(self): - fpath = "tests/data/query/valid_quer1.json" + def test_query_image_by_name_APIv2(self): + fpath = "tests/data/query_v2/query_response_container/valid_qrc1.json" + self.mock_resp_success.json.return_value = load_json(fpath) + self.mock_session_v2.get.return_value = self.mock_resp_success + + res = self.svc_v2.query_image_by_name(name=self.image_name) + + expected_params = {"name": self.image_name} + self.mock_session_v2.get.assert_called_once_with("/query", params=expected_params) + self.mock_resp_success.raise_for_status.assert_called_once() + # Note: JSON need to be loaded twice as `from_json` pops its original data + self.assertEqual(res, QueryResponseContainer.from_json(load_json(fpath))) + + def test_in_memory_query_image_by_name_APIv1(self): + fpath = "tests/data/query_v1/valid_quer1.json" data = [QueryResponse.from_json(load_json(fpath))] - provider = InMemoryMapProvider(data) + provider = InMemoryMapProviderV1(data) - self.svc = StarmapClient("https://test.starmap.com", api_version="v1", provider=provider) + self.svc_v1 = StarmapClient("https://test.starmap.com", api_version="v1", provider=provider) - res = self.svc.query_image_by_name(name="sample-policy") - self.mock_session.get.assert_not_called() + res = self.svc_v1.query_image_by_name(name="sample-policy", workflow="stratosphere") + self.mock_session_v1.get.assert_not_called() self.assertEqual(res, data[0]) - def test_query_image_by_name_version(self): - fpath = "tests/data/query/valid_quer1.json" + def test_in_memory_query_image_by_name_APIv2(self): + fpath = "tests/data/query_v2/query_response_container/valid_qrc1.json" + data = QueryResponseContainer.from_json(load_json(fpath)) + provider = InMemoryMapProviderV2(data) + + self.svc_v2 = StarmapClient("https://test.starmap.com", api_version="v2", provider=provider) + + res = self.svc_v2.query_image_by_name(name="product-test", workflow="stratosphere") + self.mock_session_v1.get.assert_not_called() + self.assertEqual(res.responses, [data.responses[0]]) + + def test_query_image_by_name_version_APIv1(self): + fpath = "tests/data/query_v1/valid_quer1.json" self.mock_resp_success.json.return_value = load_json(fpath) - self.mock_session.get.return_value = self.mock_resp_success + self.mock_session_v1.get.return_value = self.mock_resp_success - res = self.svc.query_image_by_name(name=self.image_name, version=self.image_version) + res = self.svc_v1.query_image_by_name( + name=self.image_name, version=self.image_version, workflow="stratosphere" + ) expected_params = { "name": self.image_name, "version": self.image_version, "workflow": "stratosphere", } - self.mock_session.get.assert_called_once_with("/query", params=expected_params) + self.mock_session_v1.get.assert_called_once_with("/query", params=expected_params) self.mock_resp_success.raise_for_status.assert_called_once() # Note: JSON need to be loaded twice as `from_json` pops its original data self.assertEqual(res, QueryResponse.from_json(load_json(fpath))) + def test_query_image_by_name_version_APIv2(self): + fpath = "tests/data/query_v2/query_response_container/valid_qrc1.json" + self.mock_resp_success.json.return_value = load_json(fpath) + self.mock_session_v2.get.return_value = self.mock_resp_success + + res = self.svc_v2.query_image_by_name(name=self.image_name, version=self.image_version) + + expected_params = { + "name": self.image_name, + "version": self.image_version, + } + self.mock_session_v2.get.assert_called_once_with("/query", params=expected_params) + self.mock_resp_success.raise_for_status.assert_called_once() + # Note: JSON need to be loaded twice as `from_json` pops its original data + self.assertEqual(res, QueryResponseContainer.from_json(load_json(fpath))) + def test_policies_single_page(self): fpath = "tests/data/policy/valid_pol1.json" single_page = { @@ -139,18 +224,18 @@ def test_policies_single_page(self): "total_pages": 1, }, } - self.svc.POLICIES_PER_PAGE = 1 + self.svc_v1.POLICIES_PER_PAGE = 1 self.mock_resp_success.json.return_value = single_page - self.mock_session.get.return_value = self.mock_resp_success + self.mock_session_v1.get.return_value = self.mock_resp_success # Iterate over all policies from StarmapClient property and # ensure each of them has a valid format. - for p in self.svc.policies: + for p in self.svc_v1.policies: self.assertEqual(p, Policy.from_json(load_json(fpath))) expected_params = {"page": 1, "per_page": 1} - self.mock_session.get.assert_called_once_with("policy", params=expected_params) - self.mock_session.get.call_count == 1 + self.mock_session_v1.get.assert_called_once_with("policy", params=expected_params) + self.mock_session_v1.get.call_count == 1 self.mock_resp_success.raise_for_status.assert_called_once() def test_policies_multi_page(self): @@ -171,13 +256,13 @@ def test_policies_multi_page(self): page2 = deepcopy(page1) page2["nav"]["next"] = None page2["nav"]["page"] = 2 - self.svc.POLICIES_PER_PAGE = 1 + self.svc_v1.POLICIES_PER_PAGE = 1 self.mock_resp_success.json.side_effect = [page1, page2] - self.mock_session.get.return_value = self.mock_resp_success + self.mock_session_v1.get.return_value = self.mock_resp_success # Iterate over all policies from StarmapClient property and # ensure each of them has a valid format. - for p in self.svc.policies: + for p in self.svc_v1.policies: self.assertEqual(p, Policy.from_json(load_json(fpath))) get_calls = [ @@ -188,17 +273,17 @@ def test_policies_multi_page(self): mock.call().raise_for_status, mock.call().json(), ] - self.mock_session.get.assert_has_calls(get_calls) - self.mock_session.get.call_count == 2 + self.mock_session_v1.get.assert_has_calls(get_calls) + self.mock_session_v1.get.call_count == 2 self.mock_resp_success.raise_for_status.call_count == 2 def test_policies_not_found(self): - self.svc.POLICIES_PER_PAGE = 1 - self.mock_session.get.return_value = self.mock_resp_not_found + self.svc_v1.POLICIES_PER_PAGE = 1 + self.mock_session_v1.get.return_value = self.mock_resp_not_found with self._caplog.at_level(logging.ERROR): with pytest.raises(StopIteration): - next(self.svc.policies) + next(self.svc_v1.policies) assert "No policies registered in StArMap." in self._caplog.text @mock.patch('starmap_client.StarmapClient.policies') @@ -209,34 +294,34 @@ def test_list_policies(self, mock_policies: mock.MagicMock): mock_policies.__iter__.return_value = pol_list # Test cached policies list - self.svc._policies = pol_list - res = self.svc.list_policies() + self.svc_v1._policies = pol_list + res = self.svc_v1.list_policies() mock_policies.__iter__.assert_not_called() - self.assertEqual(res, self.svc._policies) + self.assertEqual(res, self.svc_v1._policies) # Test uncached policies list - self.svc._policies = [] - res = self.svc.list_policies() + self.svc_v1._policies = [] + res = self.svc_v1.list_policies() mock_policies.__iter__.assert_called_once() self.assertEqual(res, pol_list) def test_get_policy(self): fpath = "tests/data/policy/valid_pol1.json" self.mock_resp_success.json.return_value = load_json(fpath) - self.mock_session.get.return_value = self.mock_resp_success + self.mock_session_v1.get.return_value = self.mock_resp_success - res = self.svc.get_policy(policy_id="policy-id") + res = self.svc_v1.get_policy(policy_id="policy-id") - self.mock_session.get.assert_called_once_with("/policy/policy-id") + self.mock_session_v1.get.assert_called_once_with("/policy/policy-id") self.mock_resp_success.raise_for_status.assert_called_once() # Note: JSON need to be loaded twice as `from_json` pops its original data self.assertEqual(res, Policy.from_json(load_json(fpath))) def test_get_policy_not_found(self): - self.mock_session.get.return_value = self.mock_resp_not_found + self.mock_session_v1.get.return_value = self.mock_resp_not_found with self._caplog.at_level(logging.ERROR): - res = self.svc.get_policy(policy_id="policy-id") + res = self.svc_v1.get_policy(policy_id="policy-id") expected_msg = "Policy not found with ID = \"policy-id\"" assert expected_msg in self._caplog.text @@ -249,7 +334,7 @@ def test_list_mappings(self, mock_get_policy: mock.MagicMock) -> None: p = Policy.from_json(load_json(fpath)) mock_get_policy.return_value = p - res = self.svc.list_mappings(policy_id="policy-id") + res = self.svc_v1.list_mappings(policy_id="policy-id") mock_get_policy.assert_called_once_with("policy-id") self.assertEqual(res, p.mappings) @@ -257,7 +342,7 @@ def test_list_mappings(self, mock_get_policy: mock.MagicMock) -> None: @mock.patch("starmap_client.StarmapClient.get_policy") def test_list_mappings_not_found(self, mock_get_policy: mock.MagicMock) -> None: mock_get_policy.return_value = None - res = self.svc.list_mappings(policy_id="policy-id") + res = self.svc_v1.list_mappings(policy_id="policy-id") mock_get_policy.assert_called_once_with("policy-id") @@ -266,20 +351,20 @@ def test_list_mappings_not_found(self, mock_get_policy: mock.MagicMock) -> None: def test_get_mapping(self): fpath = "tests/data/mapping/valid_map1.json" self.mock_resp_success.json.return_value = load_json(fpath) - self.mock_session.get.return_value = self.mock_resp_success + self.mock_session_v1.get.return_value = self.mock_resp_success - res = self.svc.get_mapping(mapping_id="mapping-id") + res = self.svc_v1.get_mapping(mapping_id="mapping-id") - self.mock_session.get.assert_called_once_with("/mapping/mapping-id") + self.mock_session_v1.get.assert_called_once_with("/mapping/mapping-id") self.mock_resp_success.raise_for_status.assert_called_once() # Note: JSON need to be loaded twice as `from_json` pops its original data self.assertEqual(res, Mapping.from_json(load_json(fpath))) def test_get_mapping_not_found(self): - self.mock_session.get.return_value = self.mock_resp_not_found + self.mock_session_v1.get.return_value = self.mock_resp_not_found with self._caplog.at_level(logging.ERROR): - res = self.svc.get_mapping(mapping_id="mapping-id") + res = self.svc_v1.get_mapping(mapping_id="mapping-id") expected_msg = "Marketplace Mapping not found with ID = \"mapping-id\"" assert expected_msg in self._caplog.text @@ -292,7 +377,7 @@ def test_list_destinations(self, mock_get_mapping: mock.MagicMock) -> None: m = Mapping.from_json(load_json(fpath)) mock_get_mapping.return_value = m - res = self.svc.list_destinations(mapping_id="mapping-id") + res = self.svc_v1.list_destinations(mapping_id="mapping-id") mock_get_mapping.assert_called_once_with("mapping-id") self.assertEqual(res, m.destinations) @@ -300,7 +385,7 @@ def test_list_destinations(self, mock_get_mapping: mock.MagicMock) -> None: @mock.patch("starmap_client.StarmapClient.get_mapping") def test_list_destinations_not_found(self, mock_get_mapping: mock.MagicMock) -> None: mock_get_mapping.return_value = None - res = self.svc.list_destinations(mapping_id="mapping-id") + res = self.svc_v1.list_destinations(mapping_id="mapping-id") mock_get_mapping.assert_called_once_with("mapping-id") @@ -309,20 +394,20 @@ def test_list_destinations_not_found(self, mock_get_mapping: mock.MagicMock) -> def test_get_destination(self): fpath = "tests/data/destination/valid_dest1.json" self.mock_resp_success.json.return_value = load_json(fpath) - self.mock_session.get.return_value = self.mock_resp_success + self.mock_session_v1.get.return_value = self.mock_resp_success - res = self.svc.get_destination(destination_id="destination-id") + res = self.svc_v1.get_destination(destination_id="destination-id") - self.mock_session.get.assert_called_once_with("/destination/destination-id") + self.mock_session_v1.get.assert_called_once_with("/destination/destination-id") self.mock_resp_success.raise_for_status.assert_called_once() # Note: JSON need to be loaded twice as `from_json` pops its original data self.assertEqual(res, Destination.from_json(load_json(fpath))) def test_get_destination_not_found(self): - self.mock_session.get.return_value = self.mock_resp_not_found + self.mock_session_v1.get.return_value = self.mock_resp_not_found with self._caplog.at_level(logging.ERROR): - res = self.svc.get_destination(destination_id="destination-id") + res = self.svc_v1.get_destination(destination_id="destination-id") expected_msg = "Destination not found with ID = \"destination-id\"" assert expected_msg in self._caplog.text @@ -337,23 +422,23 @@ def test_client_requires_url_or_session(self): def test_offline_client(): """Ensure the cient can be used offline with a local provider.""" - fpath = "tests/data/query/valid_quer1.json" + fpath = "tests/data/query_v1/valid_quer1.json" qr = QueryResponse.from_json(load_json(fpath)) # The provider will have the QueryResponse from fpath - provider = InMemoryMapProvider([qr]) + provider = InMemoryMapProviderV1([qr]) # The session will prevent StarmapClient to request a real server session = StarmapMockSession("fake.starmap.url", "v1") # Offline client - svc = StarmapClient(session=session, provider=provider) + svc = StarmapClient(session=session, api_version="v1", provider=provider) assert session.json_data == {} assert session.status_code == 404 - assert svc.query_image("sample-policy-123.132-0.123") == qr - assert svc.query_image_by_name("sample-policy", "8.0") == qr + assert svc.query_image("sample-policy-123.132-0.123", workflow="stratosphere") == qr + assert svc.query_image_by_name("sample-policy", "8.0", workflow="stratosphere") == qr assert svc.get_destination("test") is None assert svc.get_mapping("test") is None assert svc.get_policy("test") is None diff --git a/tests/test_models.py b/tests/test_models.py index e214481..782cb88 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,10 +1,21 @@ import json +from copy import deepcopy from typing import Any import pytest +from attrs import asdict from attrs.exceptions import FrozenInstanceError -from starmap_client.models import Destination, Mapping, Policy, QueryResponse +from starmap_client.models import ( + Destination, + Mapping, + MappingResponseObject, + Policy, + QueryResponse, + QueryResponseContainer, + QueryResponseEntity, + Workflow, +) def load_json(json_file: str) -> Any: @@ -181,15 +192,15 @@ def test_frozen_policy(self): p.name = "test" -class TestQueryResponse: +class TestV1QueryResponse: @pytest.mark.parametrize( "json_file", [ - "tests/data/query/valid_quer1.json", - "tests/data/query/valid_quer2.json", - "tests/data/query/valid_quer3.json", - "tests/data/query/valid_quer4.json", - "tests/data/query/valid_quer5.json", + "tests/data/query_v1/valid_quer1.json", + "tests/data/query_v1/valid_quer2.json", + "tests/data/query_v1/valid_quer3.json", + "tests/data/query_v1/valid_quer4.json", + "tests/data/query_v1/valid_quer5.json", ], ) def test_valid_query_resp_json(self, json_file: str) -> None: @@ -212,8 +223,8 @@ def test_valid_query_resp_json(self, json_file: str) -> None: @pytest.mark.parametrize( "json_file", [ - "tests/data/query/invalid_quer1.json", - "tests/data/query/invalid_quer2.json", + "tests/data/query_v1/invalid_quer1.json", + "tests/data/query_v1/invalid_quer2.json", ], ) def test_invalid_clouds(self, json_file) -> None: @@ -224,9 +235,199 @@ def test_invalid_clouds(self, json_file) -> None: QueryResponse.from_json(data) def test_frozen_query(self): - data = load_json("tests/data/query/valid_quer1.json") + data = load_json("tests/data/query_v1/valid_quer1.json") q = QueryResponse.from_json(data) with pytest.raises(FrozenInstanceError): q.name = "test" + + +class TestV2MappingResponseObject: + @pytest.mark.parametrize( + "json_file,meta,provider", + [ + ( + "tests/data/query_v2/mapping_response_obj/valid_mro1.json", + "tests/data/query_v2/mapping_response_obj/valid_mro1_meta.json", + "AWS", + ), + ( + "tests/data/query_v2/mapping_response_obj/valid_mro2.json", + "tests/data/query_v2/mapping_response_obj/valid_mro2_meta.json", + "AZURE", + ), + ( + "tests/data/query_v2/mapping_response_obj/valid_mro3.json", + "tests/data/query_v2/mapping_response_obj/valid_mro3_meta.json", + None, + ), + ], + ) + def test_valid_mapping_response_obj(self, json_file, meta, provider) -> None: + data = load_json(json_file) + expected_meta = load_json(meta) + + m = MappingResponseObject.from_json(data) + assert m.provider == provider + for d in m.destinations: + assert d.meta == expected_meta + assert d.provider == provider + + @pytest.mark.parametrize( + "json_file", + [ + "tests/data/query_v2/mapping_response_obj/invalid_mro1.json", + "tests/data/query_v2/mapping_response_obj/invalid_mro2.json", + "tests/data/query_v2/mapping_response_obj/invalid_mro3.json", + ], + ) + def test_invalid_clouds(self, json_file) -> None: + data = load_json(json_file) + err = data.pop("error") + + with pytest.raises((TypeError, ValueError), match=err): + MappingResponseObject.from_json(data) + + +class TestV2QueryResponseEntity: + @pytest.mark.parametrize( + "json_file,meta", + [ + ( + "tests/data/query_v2/query_response_entity/valid_qre1.json", + "tests/data/query_v2/query_response_entity/valid_qre1_meta.json", + ), + ( + "tests/data/query_v2/query_response_entity/valid_qre2.json", + "tests/data/query_v2/query_response_entity/valid_qre2_meta.json", + ), + ( + "tests/data/query_v2/query_response_entity/valid_qre3.json", + "tests/data/query_v2/query_response_entity/valid_qre3_meta.json", + ), + ( + "tests/data/query_v2/query_response_entity/valid_qre4.json", + "tests/data/query_v2/query_response_entity/valid_qre4_meta.json", + ), + ], + ) + def test_valid_query_response_entity(self, json_file, meta) -> None: + data = load_json(json_file) + d = deepcopy(data) + expected_meta_dict = load_json(meta) + + q = QueryResponseEntity.from_json(data) + for account_name in q.account_names: + assert q.mappings[account_name].meta == expected_meta_dict[account_name] + + if q.billing_code_config: + bc_asdict = {k: asdict(v) for k, v in q.billing_code_config.items()} + assert bc_asdict == d["billing-code-config"] + + def test_query_response_properties(self) -> None: + data = load_json("tests/data/query_v2/query_response_entity/valid_qre1.json") + q = QueryResponseEntity.from_json(data) + + assert q.account_names == ["test-emea", "test-na"] + assert q.all_mappings == list(q.mappings.values()) + + def test_get_mapping_for_account(self) -> None: + data = load_json("tests/data/query_v2/query_response_entity/valid_qre1.json") + q = QueryResponseEntity.from_json(data) + + assert q.get_mapping_for_account("test-na") == q.mappings["test-na"] + + with pytest.raises(KeyError): + q.get_mapping_for_account("foo-bar") + + @pytest.mark.parametrize( + "json_file,old_api_file", + [ + ( + "tests/data/query_v2/query_response_entity/valid_qre2.json", + "tests/data/query_v2/query_response_entity/valid_qre_converted_old_api.json", + ), + ( + "tests/data/query_v2/query_response_entity/valid_qre3.json", + "tests/data/query_v2/query_response_entity/valid_qre_converted_old_api.json", + ), + ( + "tests/data/query_v2/query_response_entity/valid_qre4.json", + "tests/data/query_v2/query_response_entity/valid_qre_converted_old_api_community.json", # noqa: E501 + ), + ], + ) + def test_to_classic_query_response(self, json_file, old_api_file) -> None: + old_api_data = load_json(old_api_file) + expected_qr = QueryResponse.from_json(old_api_data) + + data = load_json(json_file) + q = QueryResponseEntity.from_json(data) + + assert q.to_classic_query_response() == expected_qr + + +class TestV2QueryResponseContainer: + + @pytest.mark.parametrize( + "json_file", ["tests/data/query_v2/query_response_container/invalid_qrc1.json"] + ) + def test_invalid_query_response_container(self, json_file) -> None: + data = load_json(json_file) + err = f"Expected root to be a list, got \"{type(data)}\"" + with pytest.raises(ValueError, match=err): + QueryResponseContainer.from_json(data) + + def test_filter_by_name(self) -> None: + data = load_json("tests/data/query_v2/query_response_container/valid_qrc1.json") + qc = QueryResponseContainer.from_json(data) + + stt = QueryResponseEntity.from_json( + load_json("tests/data/query_v2/query_response_entity/valid_qre1.json") + ) + + assert qc.filter_by_name("product-test")[0] == stt + assert qc.filter_by_name("product-test", responses=[]) == [] + assert qc.filter_by_name("foo") == [] + assert qc.filter_by(name="product-test")[0] == stt + + def test_filter_by_workflow(self) -> None: + data = load_json("tests/data/query_v2/query_response_container/valid_qrc1.json") + qc = QueryResponseContainer.from_json(data) + + stt = QueryResponseEntity.from_json( + load_json("tests/data/query_v2/query_response_entity/valid_qre1.json") + ) + cmt = QueryResponseEntity.from_json( + load_json("tests/data/query_v2/query_response_entity/valid_qre4.json") + ) + + assert qc.filter_by_workflow(Workflow.stratosphere)[0] == stt + assert qc.filter_by_workflow(Workflow.community)[0] == cmt + assert qc.filter_by_workflow(Workflow.stratosphere, responses=[]) == [] + assert qc.filter_by_workflow(Workflow.community, responses=[]) == [] + assert qc.filter_by(workflow=Workflow.stratosphere)[0] == stt + assert qc.filter_by(workflow=Workflow.community)[0] == cmt + + def test_filter_by_cloud(self) -> None: + data = load_json("tests/data/query_v2/query_response_container/valid_qrc2.json") + qc = QueryResponseContainer.from_json(data) + + expected = QueryResponseEntity.from_json( + load_json("tests/data/query_v2/query_response_entity/valid_qre1.json") + ) + + assert qc.filter_by_cloud("test")[0] == expected + assert qc.filter_by_cloud("foo") == [] + assert qc.filter_by_cloud("test", responses=[]) == [] + assert qc.filter_by(cloud="test")[0] == expected + + def test_multiple_filters(slef) -> None: + data = load_json("tests/data/query_v2/query_response_container/valid_qrc3.json") + qc = QueryResponseContainer.from_json(data) + expected = QueryResponseEntity.from_json( + load_json("tests/data/query_v2/query_response_entity/valid_qre1.json") + ) + + assert qc.filter_by(cloud="test", workflow=Workflow.stratosphere)[0] == expected diff --git a/tests/test_providers/conftest.py b/tests/test_providers/conftest.py index 06f5280..945ff1a 100644 --- a/tests/test_providers/conftest.py +++ b/tests/test_providers/conftest.py @@ -1,8 +1,11 @@ -from typing import Any, Dict +from copy import deepcopy +from typing import Any, Dict, List import pytest -from starmap_client.models import QueryResponse +from starmap_client.models import QueryResponse, QueryResponseContainer, QueryResponseEntity + +# ============================================ APIv1 =============================================== @pytest.fixture @@ -73,3 +76,100 @@ def qr1_object(qr1) -> QueryResponse: @pytest.fixture def qr2_object(qr2) -> QueryResponse: return QueryResponse.from_json(qr2) + + +# ============================================ APIv2 =============================================== + + +@pytest.fixture +def qre1() -> Dict[str, Any]: + return { + "name": "sample-product", + "workflow": "stratosphere", + "cloud": "aws", + "mappings": { + "aws-na": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "overwrite": True, + "restrict_version": False, + "meta": {"tag1": "aws-na-value1", "tag2": "aws-na-value2"}, + "tags": {"key1": "value1", "key2": "value2"}, + } + ], + "provider": None, + }, + "aws-emea": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "00000000-0000-0000-0000-000000000000", + "overwrite": True, + "restrict_version": False, + "meta": {"tag1": "aws-emea-value1", "tag2": "aws-emea-value2"}, + "tags": {"key3": "value3", "key4": "value4"}, + } + ], + "provider": None, + }, + }, + } + + +@pytest.fixture +def qre2() -> Dict[str, Any]: + return { + "name": "sample-product", + "workflow": "community", + "cloud": "aws", + "mappings": { + "aws-na": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "test-dest-1", + "overwrite": True, + "restrict_version": False, + "meta": {"tag1": "aws-na-value1", "tag2": "aws-na-value2"}, + "tags": {"key1": "value1", "key2": "value2"}, + } + ], + "provider": "AWS", + }, + "aws-emea": { + "destinations": [ + { + "architecture": "x86_64", + "destination": "test-dest-2", + "overwrite": True, + "restrict_version": False, + "meta": {"tag1": "aws-emea-value1", "tag2": "aws-emea-value2"}, + "tags": {"key3": "value3", "key4": "value4"}, + } + ], + "provider": "AWS", + }, + }, + } + + +@pytest.fixture +def qrc(qre1, qre2) -> List[Dict[str, Any]]: + return [deepcopy(qre1), deepcopy(qre2)] + + +@pytest.fixture +def qre1_object(qre1) -> QueryResponseEntity: + return QueryResponseEntity.from_json(deepcopy(qre1)) + + +@pytest.fixture +def qre2_object(qre2) -> QueryResponseEntity: + return QueryResponseEntity.from_json(deepcopy(qre2)) + + +@pytest.fixture +def qrc_object(qrc) -> QueryResponseContainer: + return QueryResponseContainer.from_json(qrc) diff --git a/tests/test_providers/test_memory.py b/tests/test_providers/test_memory.py index d601e10..8407ee6 100644 --- a/tests/test_providers/test_memory.py +++ b/tests/test_providers/test_memory.py @@ -2,21 +2,21 @@ import pytest -from starmap_client.models import QueryResponse -from starmap_client.providers import InMemoryMapProvider +from starmap_client.models import QueryResponse, QueryResponseContainer, QueryResponseEntity +from starmap_client.providers import InMemoryMapProviderV1, InMemoryMapProviderV2 -class TestInMemoryMapProvider: +class TestInMemoryMapProviderV1: def test_list_content(self, qr1_object: QueryResponse, qr2_object: QueryResponse) -> None: data = [qr1_object, qr2_object] - provider = InMemoryMapProvider(map_responses=data) + provider = InMemoryMapProviderV1(map_responses=data) assert provider.list_content() == data def test_store(self, qr1_object: QueryResponse, qr2_object: QueryResponse) -> None: - provider = InMemoryMapProvider() + provider = InMemoryMapProviderV1() assert provider.list_content() == [] @@ -49,10 +49,61 @@ def test_query( request: pytest.FixtureRequest, ) -> None: data = [qr1_object, qr2_object] - provider = InMemoryMapProvider(data) + provider = InMemoryMapProviderV1(data) if expected is not None: expected = request.getfixturevalue(expected) qr = provider.query(params) assert qr == expected + + +class TestInMemoryMapProviderV2: + + def test_list_content(self, qrc_object: QueryResponseContainer) -> None: + provider = InMemoryMapProviderV2(container=qrc_object) + + assert provider.list_content() == qrc_object.responses + + def test_store( + self, + qre1: Dict[str, Any], + qre1_object: QueryResponseEntity, + qre2_object: QueryResponseEntity, + ) -> None: + container = QueryResponseContainer.from_json([qre1]) + provider = InMemoryMapProviderV2(container=container) + + assert provider.list_content() == [qre1_object] + + provider.store(qre2_object) + assert provider.list_content() == [qre1_object, qre2_object] + + @pytest.mark.parametrize( + "params, expected", + [ + ( + {"name": "sample-product", "version": "8.0", "workflow": "stratosphere"}, + 'qre1', + ), + ({"name": "sample-product", "version": "8.1", "workflow": "community"}, 'qre2'), + ({"name": "another-product", "version": "8.2", "workflow": "stratosphere"}, None), + ({"name": "another-product", "version": "8.2", "workflow": "community"}, None), + ], + ) + def test_query( + self, + params: Dict[str, Any], + expected: Optional[str], + qrc_object: QueryResponseContainer, + request: pytest.FixtureRequest, + ) -> None: + provider = InMemoryMapProviderV2(container=qrc_object) + expected_container = None + + if expected is not None: + qre = request.getfixturevalue(expected) + expected_container = QueryResponseContainer.from_json([qre]) + + qr = provider.query(params) + assert qr == expected_container diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..81bf496 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,30 @@ +from typing import Any, Dict + +import pytest + +from starmap_client.utils import assert_is_dict, dict_merge + + +def test_assert_is_dict() -> None: + assert_is_dict({}) + + for x in [[], (), "a", 1, 1.0, True]: + err = f"Expected dictionary, got {type(x)}: {x}" + err = err.replace("[", "\\[").replace("]", "\\]") # Avoid bad regex match + with pytest.raises(ValueError, match=err): + assert_is_dict(x) + + +@pytest.mark.parametrize( + "a,b,expected", + [ + ({}, {}, {}), + ({"foo": "bar"}, {}, {"foo": "bar"}), + ({}, {"foo": "bar"}, {"foo": "bar"}), + ({"1": 1, "3": 3}, {"2": 2}, {"1": 1, "2": 2, "3": 3}), + ({"1": 1, "3": 3}, {"2": 2, "3": 4}, {"1": 1, "2": 2, "3": 4}), + ({"A": True, "B": True, "C": True}, {"B": False}, {"A": True, "B": False, "C": True}), + ], +) +def test_dict_merge(a: Dict[str, Any], b: Dict[str, Any], expected: Dict[str, Any]) -> None: + assert dict_merge(a, b) == expected