diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 601125050..c354b4348 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -4171,6 +4171,16 @@ - [X] put_object +## meteringmarketplace +
+25% implemented + +- [X] batch_meter_usage +- [ ] meter_usage +- [ ] register_usage +- [ ] resolve_customer +
+ ## mq
86% implemented @@ -6480,7 +6490,6 @@ - mediapackage-vod - mediatailor - memorydb -- meteringmarketplace - mgh - mgn - migration-hub-refactor-spaces diff --git a/docs/docs/services/meteringmarketplace.rst b/docs/docs/services/meteringmarketplace.rst new file mode 100644 index 000000000..36d0cfe86 --- /dev/null +++ b/docs/docs/services/meteringmarketplace.rst @@ -0,0 +1,32 @@ +.. _implementedservice_meteringmarketplace: + +.. |start-h3| raw:: html + +

+ +.. |end-h3| raw:: html + +

+ +=================== +meteringmarketplace +=================== + +|start-h3| Example usage |end-h3| + +.. sourcecode:: python + + @mock_meteringmarketplace + def test_meteringmarketplace_behaviour: + boto3.client("meteringmarketplace") + ... + + + +|start-h3| Implemented features for this service |end-h3| + +- [X] batch_meter_usage +- [ ] meter_usage +- [ ] register_usage +- [ ] resolve_customer + diff --git a/moto/__init__.py b/moto/__init__.py index 2f594f16f..5ecd606f8 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -106,6 +106,7 @@ mock_mediastore = lazy_load(".mediastore", "mock_mediastore") mock_mediastoredata = lazy_load( ".mediastoredata", "mock_mediastoredata", boto3_name="mediastore-data" ) +mock_meteringmarketplace = lazy_load(".meteringmarketplace", "mock_meteringmarketplace") mock_mq = lazy_load(".mq", "mock_mq", boto3_name="mq") mock_opsworks = lazy_load(".opsworks", "mock_opsworks") mock_organizations = lazy_load(".organizations", "mock_organizations") diff --git a/moto/backend_index.py b/moto/backend_index.py index 0e16a849d..d634817fd 100644 --- a/moto/backend_index.py +++ b/moto/backend_index.py @@ -102,6 +102,11 @@ backend_url_patterns = [ "mediastore-data", re.compile("https?://data\\.mediastore\\.(.+)\\.amazonaws.com"), ), + ( + "meteringmarketplace", + re.compile("https?://metering.marketplace.(.+).amazonaws.com"), + ), + ("meteringmarketplace", re.compile("https?://aws-marketplace.(.+).amazonaws.com")), ("mq", re.compile("https?://mq\\.(.+)\\.amazonaws\\.com")), ("opsworks", re.compile("https?://opsworks\\.us-east-1\\.amazonaws.com")), ("organizations", re.compile("https?://organizations\\.(.+)\\.amazonaws\\.com")), diff --git a/moto/meteringmarketplace/__init__.py b/moto/meteringmarketplace/__init__.py new file mode 100644 index 000000000..cb50b9840 --- /dev/null +++ b/moto/meteringmarketplace/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals +from .models import meteringmarketplace_backends +from ..core.models import base_decorator + +meteringmarketplace_backend = meteringmarketplace_backends["us-east-1"] +mock_meteringmarketplace = base_decorator(meteringmarketplace_backends) diff --git a/moto/meteringmarketplace/exceptions.py b/moto/meteringmarketplace/exceptions.py new file mode 100644 index 000000000..25fcc98e6 --- /dev/null +++ b/moto/meteringmarketplace/exceptions.py @@ -0,0 +1,60 @@ +from __future__ import unicode_literals + +import json +from werkzeug.exceptions import BadRequest + + +class DisabledApiException(BadRequest): + def __init__(self, message): + super().__init__() + self.description = json.dumps( + {"message": message, "__type": "DisabledApiException"} + ) + + +class InternalServiceErrorException(BadRequest): + def __init__(self, message): + super().__init__() + self.description = json.dumps( + {"message": message, "__type": "InternalServiceErrorException"} + ) + + +class InvalidCustomerIdentifierException(BadRequest): + def __init__(self, message): + super().__init__() + self.description = json.dumps( + {"message": message, "__type": "InvalidCustomerIdentifierException"} + ) + + +class InvalidProductCodeException(BadRequest): + def __init__(self, message): + super().__init__() + self.description = json.dumps( + {"message": message, "__type": "InvalidProductCodeException"} + ) + + +class InvalidUsageDimensionException(BadRequest): + def __init__(self, message): + super().__init__() + self.description = json.dumps( + {"message": message, "__type": "InvalidUsageDimensionException"} + ) + + +class ThrottlingException(BadRequest): + def __init__(self, message): + super().__init__() + self.description = json.dumps( + {"message": message, "__type": "ThrottlingException"} + ) + + +class TimestampOutOfBoundsException(BadRequest): + def __init__(self, message): + super().__init__() + self.description = json.dumps( + {"message": message, "__type": "TimestampOutOfBoundsException"} + ) diff --git a/moto/meteringmarketplace/models.py b/moto/meteringmarketplace/models.py new file mode 100644 index 000000000..b08bfcfa7 --- /dev/null +++ b/moto/meteringmarketplace/models.py @@ -0,0 +1,143 @@ +import collections +from moto.core import BaseBackend, BaseModel +from moto.moto_api._internal import mock_random +from moto.core.utils import BackendDict + + +class UsageRecord(BaseModel, dict): + def __init__(self, timestamp, customer_identifier, dimension, quantity=0): + super().__init__() + self.timestamp = timestamp + self.customer_identifier = customer_identifier + self.dimension = dimension + self.quantity = quantity + self.metering_record_id = mock_random.uuid4().hex + + @classmethod + def from_data(cls, data): + cls( + timestamp=data.get("Timestamp"), + customer_identifier=data.get("CustomerIdentifier"), + dimension=data.get("Dimension"), + quantity=data.get("Quantity", 0), + ) + + @property + def timestamp(self): + return self["Timestamp"] + + @timestamp.setter + def timestamp(self, value): + self["Timestamp"] = value + + @property + def customer_identifier(self): + return self["CustomerIdentifier"] + + @customer_identifier.setter + def customer_identifier(self, value): + self["CustomerIdentifier"] = value + + @property + def dimension(self): + return self["Dimension"] + + @dimension.setter + def dimension(self, value): + self["Dimension"] = value + + @property + def quantity(self): + return self["Quantity"] + + @quantity.setter + def quantity(self, value): + self["Quantity"] = value + + +class Result(BaseModel, dict): + SUCCESS = "Success" + CUSTOMER_NOT_SUBSCRIBED = "CustomerNotSubscribed" + DUPLICATE_RECORD = "DuplicateRecord" + + def __init__(self, **kwargs): + self.usage_record = UsageRecord( + timestamp=kwargs["Timestamp"], + customer_identifier=kwargs["CustomerIdentifier"], + dimension=kwargs["Dimension"], + quantity=kwargs["Quantity"], + ) + self.status = Result.SUCCESS + self["MeteringRecordId"] = self.usage_record.metering_record_id + + @property + def metering_record_id(self): + return self["MeteringRecordId"] + + @property + def status(self): + return self["Status"] + + @status.setter + def status(self, value): + self["Status"] = value + + @property + def usage_record(self): + return self["UsageRecord"] + + @usage_record.setter + def usage_record(self, value): + if not isinstance(value, UsageRecord): + value = UsageRecord.from_data(value) + self["UsageRecord"] = value + + def is_duplicate(self, other): + """ + DuplicateRecord - Indicates that the UsageRecord was invalid and not honored. + A previously metered UsageRecord had the same customer, dimension, and time, + but a different quantity. + """ + assert isinstance(other, Result), "Needs to be a Result type" + usage_record, other = other.usage_record, self.usage_record + return ( + other.customer_identifier == usage_record.customer_identifier + and other.dimension == usage_record.dimension + and other.timestamp == usage_record.timestamp + and other.quantity != usage_record.quantity + ) + + +class CustomerDeque(collections.deque): + def is_subscribed(self, customer): + return customer in self + + +class ResultDeque(collections.deque): + def is_duplicate(self, result): + return any(record.is_duplicate(result) for record in self) + + +class MeteringMarketplaceBackend(BaseBackend): + def __init__(self, region_name, account_id): + super().__init__(region_name, account_id) + self.customers_by_product = collections.defaultdict(CustomerDeque) + self.records_by_product = collections.defaultdict(ResultDeque) + + def batch_meter_usage(self, product_code, usage_records): + results = [] + for usage in usage_records: + result = Result(**usage) + if not self.customers_by_product[product_code].is_subscribed( + result.usage_record.customer_identifier + ): + result.status = result.CUSTOMER_NOT_SUBSCRIBED + elif self.records_by_product[product_code].is_duplicate(result): + result.status = result.DUPLICATE_RECORD + results.append(result) + return results + + +meteringmarketplace_backends = BackendDict( + MeteringMarketplaceBackend, "meteringmarketplace" +) diff --git a/moto/meteringmarketplace/responses.py b/moto/meteringmarketplace/responses.py new file mode 100644 index 000000000..d3addb966 --- /dev/null +++ b/moto/meteringmarketplace/responses.py @@ -0,0 +1,17 @@ +import json + +from moto.core.responses import BaseResponse +from .models import meteringmarketplace_backends, MeteringMarketplaceBackend + + +class MarketplaceMeteringResponse(BaseResponse): + @property + def backend(self) -> MeteringMarketplaceBackend: + return meteringmarketplace_backends[self.current_account][self.region] + + def batch_meter_usage(self): + results = [] + usage_records = json.loads(self.body)["UsageRecords"] + product_code = json.loads(self.body)["ProductCode"] + results = self.backend.batch_meter_usage(product_code, usage_records) + return json.dumps({"Results": results, "UnprocessedRecords": []}) diff --git a/moto/meteringmarketplace/urls.py b/moto/meteringmarketplace/urls.py new file mode 100644 index 000000000..a351525e2 --- /dev/null +++ b/moto/meteringmarketplace/urls.py @@ -0,0 +1,9 @@ +from __future__ import unicode_literals +from .responses import MarketplaceMeteringResponse + +url_bases = [ + "https?://metering.marketplace.(.+).amazonaws.com", + "https?://aws-marketplace.(.+).amazonaws.com", +] + +url_paths = {"{0}/$": MarketplaceMeteringResponse.dispatch} diff --git a/tests/test_meteringmarketplace/test_meteringmarketplace.py b/tests/test_meteringmarketplace/test_meteringmarketplace.py new file mode 100644 index 000000000..cbc5928a9 --- /dev/null +++ b/tests/test_meteringmarketplace/test_meteringmarketplace.py @@ -0,0 +1,95 @@ +import boto3 +import copy +import sure # noqa # pylint: disable=unused-import +from datetime import datetime +from moto import mock_meteringmarketplace +from moto.meteringmarketplace.models import Result + + +USAGE_RECORDS = [ + { + "Timestamp": datetime(2019, 8, 25, 21, 1, 38), + "CustomerIdentifier": "EqCDbVpkMBaQzPQv", + "Dimension": "HIDaByYzIxgGZqyz", + "Quantity": 6984, + }, + { + "Timestamp": datetime(2019, 9, 7, 16, 4, 47), + "CustomerIdentifier": "ITrDnpWEiebXybRJ", + "Dimension": "BSWatHqyhbyTQHCS", + "Quantity": 6388, + }, + { + "Timestamp": datetime(2019, 6, 15, 23, 17, 49), + "CustomerIdentifier": "YfzTVRheDsXEehgQ", + "Dimension": "FsVxwLkTAynaWwGT", + "Quantity": 3532, + }, + { + "Timestamp": datetime(2019, 9, 10, 19, 56, 35), + "CustomerIdentifier": "kmgmFPcWhpDGSSDm", + "Dimension": "PBXGdBHQWVOwudRK", + "Quantity": 9897, + }, + { + "Timestamp": datetime(2019, 1, 12, 1, 28, 36), + "CustomerIdentifier": "OyxECDjaaDVUeQIB", + "Dimension": "VzwLdmFjbTBBbHJg", + "Quantity": 5142, + }, + { + "Timestamp": datetime(2019, 8, 5, 18, 27, 41), + "CustomerIdentifier": "PkeNkaJVfceGvYAX", + "Dimension": "mHTtIbsLAYrCVSNM", + "Quantity": 6503, + }, + { + "Timestamp": datetime(2019, 7, 18, 3, 22, 18), + "CustomerIdentifier": "ARzQRYYuXGHCVDkW", + "Dimension": "RMjZbVpxehPTNtDL", + "Quantity": 5465, + }, + { + "Timestamp": datetime(2019, 6, 24, 9, 19, 14), + "CustomerIdentifier": "kzlvIayJzpyfBPaH", + "Dimension": "MmsydkBEHERXDooi", + "Quantity": 6135, + }, + { + "Timestamp": datetime(2019, 9, 28, 20, 29, 5), + "CustomerIdentifier": "nygdXONtkiqktTMn", + "Dimension": "GKbTFPMGRyZObEEz", + "Quantity": 3416, + }, + { + "Timestamp": datetime(2019, 6, 17, 2, 5, 34), + "CustomerIdentifier": "JlIGIXHHPphnBhfV", + "Dimension": "dcBPhccyHYSdPUUO", + "Quantity": 2184, + }, +] + + +@mock_meteringmarketplace() +def test_batch_meter_usage(): + client = boto3.client("meteringmarketplace", region_name="us-east-1") + + res = client.batch_meter_usage( + UsageRecords=USAGE_RECORDS, ProductCode="PUFXZLyUElvQvrsG" + ) + + res.should.have.key("Results").length_of(10) + + records_without_time = copy.copy(USAGE_RECORDS) + for r in records_without_time: + r.pop("Timestamp") + + for record in res["Results"]: + record["UsageRecord"].pop("Timestamp") + assert record["UsageRecord"] in records_without_time + assert record["MeteringRecordId"] + assert record["Status"] in [ + Result.DUPLICATE_RECORD, + Result.CUSTOMER_NOT_SUBSCRIBED, + Result.SUCCESS, + ]