diff --git a/Makefile b/Makefile index 00ea1d1f1..99b7f2620 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,13 @@ SHELL := /bin/bash +ifeq ($(TEST_SERVER_MODE), true) + # exclude test_iot and test_iotdata for now + # because authentication of iot is very complicated + TEST_EXCLUDE := --exclude='test_iot.*' +else + TEST_EXCLUDE := +endif + init: @python setup.py develop @pip install -r requirements.txt @@ -10,8 +18,7 @@ lint: test: lint rm -f .coverage rm -rf cover - @nosetests -sv --with-coverage --cover-html ./tests/ - + @nosetests -sv --with-coverage --cover-html ./tests/ $(TEST_EXCLUDE) test_server: @TEST_SERVER_MODE=true nosetests -sv --with-coverage --cover-html ./tests/ diff --git a/README.md b/README.md index 9a20bbe15..59dc67432 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,9 @@ It gets even better! Moto isn't just for Python code and it isn't just for S3. L |------------------------------------------------------------------------------| | IAM | @mock_iam | core endpoints done | |------------------------------------------------------------------------------| +| IoT | @mock_iot | core endpoints done | +| | @mock_iotdata | core endpoints done | +|------------------------------------------------------------------------------| | Lambda | @mock_lambda | basic endpoints done, requires | | | | docker | |------------------------------------------------------------------------------| @@ -299,6 +302,7 @@ boto3.resource( ## Install + ```console $ pip install moto ``` diff --git a/moto/__init__.py b/moto/__init__.py index 0c0358324..8a4b30979 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -41,6 +41,8 @@ from .swf import mock_swf, mock_swf_deprecated # flake8: noqa from .xray import mock_xray, mock_xray_client, XRaySegment # flake8: noqa from .logs import mock_logs, mock_logs_deprecated # flake8: noqa from .batch import mock_batch # flake8: noqa +from .iot import mock_iot # flake8: noqa +from .iotdata import mock_iotdata # flake8: noqa try: diff --git a/moto/backends.py b/moto/backends.py index 49c1f9f0f..771cd4018 100644 --- a/moto/backends.py +++ b/moto/backends.py @@ -35,6 +35,8 @@ from moto.sqs import sqs_backends from moto.ssm import ssm_backends from moto.sts import sts_backends from moto.xray import xray_backends +from moto.iot import iot_backends +from moto.iotdata import iotdata_backends from moto.batch import batch_backends @@ -76,6 +78,8 @@ BACKENDS = { 'route53': route53_backends, 'lambda': lambda_backends, 'xray': xray_backends, + 'iot': iot_backends, + 'iot-data': iotdata_backends, } diff --git a/moto/core/responses.py b/moto/core/responses.py index b4d94c0ac..be0a4ef45 100644 --- a/moto/core/responses.py +++ b/moto/core/responses.py @@ -214,7 +214,7 @@ class BaseResponse(_TemplateEnvironmentMixin): if not hasattr(self, 'SERVICE_NAME'): return None service = self.SERVICE_NAME - conn = boto3.client(service) + conn = boto3.client(service, region_name=self.region) # make cache if it does not exist yet if not hasattr(self, 'method_urls'): diff --git a/moto/iot/__init__.py b/moto/iot/__init__.py new file mode 100644 index 000000000..199b8aeae --- /dev/null +++ b/moto/iot/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals +from .models import iot_backends +from ..core.models import base_decorator + +iot_backend = iot_backends['us-east-1'] +mock_iot = base_decorator(iot_backends) diff --git a/moto/iot/exceptions.py b/moto/iot/exceptions.py new file mode 100644 index 000000000..0e1ac8937 --- /dev/null +++ b/moto/iot/exceptions.py @@ -0,0 +1,24 @@ +from __future__ import unicode_literals +from moto.core.exceptions import RESTError + + +class IoTClientError(RESTError): + code = 400 + + +class ResourceNotFoundException(IoTClientError): + def __init__(self): + self.code = 400 + super(ResourceNotFoundException, self).__init__( + "ResourceNotFoundException", + "The specified resource does not exist" + ) + + +class InvalidRequestException(IoTClientError): + def __init__(self): + self.code = 400 + super(InvalidRequestException, self).__init__( + "InvalidRequestException", + "The request is not valid." + ) diff --git a/moto/iot/models.py b/moto/iot/models.py new file mode 100644 index 000000000..1efa6690e --- /dev/null +++ b/moto/iot/models.py @@ -0,0 +1,364 @@ +from __future__ import unicode_literals +import time +import boto3 +import string +import random +import hashlib +import uuid +from moto.core import BaseBackend, BaseModel +from collections import OrderedDict +from .exceptions import ( + ResourceNotFoundException, + InvalidRequestException +) + + +class FakeThing(BaseModel): + def __init__(self, thing_name, thing_type, attributes, region_name): + self.region_name = region_name + self.thing_name = thing_name + self.thing_type = thing_type + self.attributes = attributes + self.arn = 'arn:aws:iot:%s:1:thing/%s' % (self.region_name, thing_name) + self.version = 1 + # TODO: we need to handle 'version'? + + # for iot-data + self.thing_shadow = None + + def to_dict(self, include_default_client_id=False): + obj = { + 'thingName': self.thing_name, + 'attributes': self.attributes, + 'version': self.version + } + if self.thing_type: + obj['thingTypeName'] = self.thing_type.thing_type_name + if include_default_client_id: + obj['defaultClientId'] = self.thing_name + return obj + + +class FakeThingType(BaseModel): + def __init__(self, thing_type_name, thing_type_properties, region_name): + self.region_name = region_name + self.thing_type_name = thing_type_name + self.thing_type_properties = thing_type_properties + t = time.time() + self.metadata = { + 'deprecated': False, + 'creationData': int(t * 1000) / 1000.0 + } + self.arn = 'arn:aws:iot:%s:1:thingtype/%s' % (self.region_name, thing_type_name) + + def to_dict(self): + return { + 'thingTypeName': self.thing_type_name, + 'thingTypeProperties': self.thing_type_properties, + 'thingTypeMetadata': self.metadata + } + + +class FakeCertificate(BaseModel): + def __init__(self, certificate_pem, status, region_name): + m = hashlib.sha256() + m.update(str(uuid.uuid4()).encode('utf-8')) + self.certificate_id = m.hexdigest() + self.arn = 'arn:aws:iot:%s:1:cert/%s' % (region_name, self.certificate_id) + self.certificate_pem = certificate_pem + self.status = status + + # TODO: must adjust + self.owner = '1' + self.transfer_data = {} + self.creation_date = time.time() + self.last_modified_date = self.creation_date + self.ca_certificate_id = None + + def to_dict(self): + return { + 'certificateArn': self.arn, + 'certificateId': self.certificate_id, + 'status': self.status, + 'creationDate': self.creation_date + } + + def to_description_dict(self): + """ + You might need keys below in some situation + - caCertificateId + - previousOwnedBy + """ + return { + 'certificateArn': self.arn, + 'certificateId': self.certificate_id, + 'status': self.status, + 'certificatePem': self.certificate_pem, + 'ownedBy': self.owner, + 'creationDate': self.creation_date, + 'lastModifiedDate': self.last_modified_date, + 'transferData': self.transfer_data + } + + +class FakePolicy(BaseModel): + def __init__(self, name, document, region_name): + self.name = name + self.document = document + self.arn = 'arn:aws:iot:%s:1:policy/%s' % (region_name, name) + self.version = '1' # TODO: handle version + + def to_get_dict(self): + return { + 'policyName': self.name, + 'policyArn': self.arn, + 'policyDocument': self.document, + 'defaultVersionId': self.version + } + + def to_dict_at_creation(self): + return { + 'policyName': self.name, + 'policyArn': self.arn, + 'policyDocument': self.document, + 'policyVersionId': self.version + } + + def to_dict(self): + return { + 'policyName': self.name, + 'policyArn': self.arn, + } + + +class IoTBackend(BaseBackend): + def __init__(self, region_name=None): + super(IoTBackend, self).__init__() + self.region_name = region_name + self.things = OrderedDict() + self.thing_types = OrderedDict() + self.certificates = OrderedDict() + self.policies = OrderedDict() + self.principal_policies = OrderedDict() + self.principal_things = OrderedDict() + + def reset(self): + region_name = self.region_name + self.__dict__ = {} + self.__init__(region_name) + + def create_thing(self, thing_name, thing_type_name, attribute_payload): + thing_types = self.list_thing_types() + thing_type = None + if thing_type_name: + filtered_thing_types = [_ for _ in thing_types if _.thing_type_name == thing_type_name] + if len(filtered_thing_types) == 0: + raise ResourceNotFoundException() + thing_type = filtered_thing_types[0] + if attribute_payload is None: + attributes = {} + elif 'attributes' not in attribute_payload: + attributes = {} + else: + attributes = attribute_payload['attributes'] + thing = FakeThing(thing_name, thing_type, attributes, self.region_name) + self.things[thing.arn] = thing + return thing.thing_name, thing.arn + + def create_thing_type(self, thing_type_name, thing_type_properties): + if thing_type_properties is None: + thing_type_properties = {} + thing_type = FakeThingType(thing_type_name, thing_type_properties, self.region_name) + self.thing_types[thing_type.arn] = thing_type + return thing_type.thing_type_name, thing_type.arn + + def list_thing_types(self, thing_type_name=None): + if thing_type_name: + # It's wierd but thing_type_name is filterd by forward match, not complete match + return [_ for _ in self.thing_types.values() if _.thing_type_name.startswith(thing_type_name)] + thing_types = self.thing_types.values() + return thing_types + + def list_things(self, attribute_name, attribute_value, thing_type_name): + # TODO: filter by attributess or thing_type + things = self.things.values() + return things + + def describe_thing(self, thing_name): + things = [_ for _ in self.things.values() if _.thing_name == thing_name] + if len(things) == 0: + raise ResourceNotFoundException() + return things[0] + + def describe_thing_type(self, thing_type_name): + thing_types = [_ for _ in self.thing_types.values() if _.thing_type_name == thing_type_name] + if len(thing_types) == 0: + raise ResourceNotFoundException() + return thing_types[0] + + def delete_thing(self, thing_name, expected_version): + # TODO: handle expected_version + + # can raise ResourceNotFoundError + thing = self.describe_thing(thing_name) + del self.things[thing.arn] + + def delete_thing_type(self, thing_type_name): + # can raise ResourceNotFoundError + thing_type = self.describe_thing_type(thing_type_name) + del self.thing_types[thing_type.arn] + + def update_thing(self, thing_name, thing_type_name, attribute_payload, expected_version, remove_thing_type): + # if attributes payload = {}, nothing + thing = self.describe_thing(thing_name) + thing_type = None + + if remove_thing_type and thing_type_name: + raise InvalidRequestException() + + # thing_type + if thing_type_name: + thing_types = self.list_thing_types() + filtered_thing_types = [_ for _ in thing_types if _.thing_type_name == thing_type_name] + if len(filtered_thing_types) == 0: + raise ResourceNotFoundException() + thing_type = filtered_thing_types[0] + thing.thing_type = thing_type + + if remove_thing_type: + thing.thing_type = None + + # attribute + if attribute_payload is not None and 'attributes' in attribute_payload: + do_merge = attribute_payload.get('merge', False) + attributes = attribute_payload['attributes'] + if not do_merge: + thing.attributes = attributes + else: + thing.attributes.update(attributes) + + def _random_string(self): + n = 20 + random_str = ''.join([random.choice(string.ascii_letters + string.digits) for i in range(n)]) + return random_str + + def create_keys_and_certificate(self, set_as_active): + # implement here + # caCertificate can be blank + key_pair = { + 'PublicKey': self._random_string(), + 'PrivateKey': self._random_string() + } + certificate_pem = self._random_string() + status = 'ACTIVE' if set_as_active else 'INACTIVE' + certificate = FakeCertificate(certificate_pem, status, self.region_name) + self.certificates[certificate.certificate_id] = certificate + return certificate, key_pair + + def delete_certificate(self, certificate_id): + self.describe_certificate(certificate_id) + del self.certificates[certificate_id] + + def describe_certificate(self, certificate_id): + certs = [_ for _ in self.certificates.values() if _.certificate_id == certificate_id] + if len(certs) == 0: + raise ResourceNotFoundException() + return certs[0] + + def list_certificates(self): + return self.certificates.values() + + def update_certificate(self, certificate_id, new_status): + cert = self.describe_certificate(certificate_id) + # TODO: validate new_status + cert.status = new_status + + def create_policy(self, policy_name, policy_document): + policy = FakePolicy(policy_name, policy_document, self.region_name) + self.policies[policy.name] = policy + return policy + + def list_policies(self): + policies = self.policies.values() + return policies + + def get_policy(self, policy_name): + policies = [_ for _ in self.policies.values() if _.name == policy_name] + if len(policies) == 0: + raise ResourceNotFoundException() + return policies[0] + + def delete_policy(self, policy_name): + policy = self.get_policy(policy_name) + del self.policies[policy.name] + + def _get_principal(self, principal_arn): + """ + raise ResourceNotFoundException + """ + if ':cert/' in principal_arn: + certs = [_ for _ in self.certificates.values() if _.arn == principal_arn] + if len(certs) == 0: + raise ResourceNotFoundException() + principal = certs[0] + return principal + else: + # TODO: search for cognito_ids + pass + raise ResourceNotFoundException() + + def attach_principal_policy(self, policy_name, principal_arn): + principal = self._get_principal(principal_arn) + policy = self.get_policy(policy_name) + k = (principal_arn, policy_name) + if k in self.principal_policies: + return + self.principal_policies[k] = (principal, policy) + + def detach_principal_policy(self, policy_name, principal_arn): + # this may raises ResourceNotFoundException + self._get_principal(principal_arn) + self.get_policy(policy_name) + + k = (principal_arn, policy_name) + if k not in self.principal_policies: + raise ResourceNotFoundException() + del self.principal_policies[k] + + def list_principal_policies(self, principal_arn): + policies = [v[1] for k, v in self.principal_policies.items() if k[0] == principal_arn] + return policies + + def list_policy_principals(self, policy_name): + principals = [k[0] for k, v in self.principal_policies.items() if k[1] == policy_name] + return principals + + def attach_thing_principal(self, thing_name, principal_arn): + principal = self._get_principal(principal_arn) + thing = self.describe_thing(thing_name) + k = (principal_arn, thing_name) + if k in self.principal_things: + return + self.principal_things[k] = (principal, thing) + + def detach_thing_principal(self, thing_name, principal_arn): + # this may raises ResourceNotFoundException + self._get_principal(principal_arn) + self.describe_thing(thing_name) + + k = (principal_arn, thing_name) + if k not in self.principal_things: + raise ResourceNotFoundException() + del self.principal_things[k] + + def list_principal_things(self, principal_arn): + thing_names = [k[0] for k, v in self.principal_things.items() if k[0] == principal_arn] + return thing_names + + def list_thing_principals(self, thing_name): + principals = [k[0] for k, v in self.principal_things.items() if k[1] == thing_name] + return principals + + +available_regions = boto3.session.Session().get_available_regions("iot") +iot_backends = {region: IoTBackend(region) for region in available_regions} diff --git a/moto/iot/responses.py b/moto/iot/responses.py new file mode 100644 index 000000000..bbe2bb016 --- /dev/null +++ b/moto/iot/responses.py @@ -0,0 +1,258 @@ +from __future__ import unicode_literals +from moto.core.responses import BaseResponse +from .models import iot_backends +import json + + +class IoTResponse(BaseResponse): + SERVICE_NAME = 'iot' + + @property + def iot_backend(self): + return iot_backends[self.region] + + def create_thing(self): + thing_name = self._get_param("thingName") + thing_type_name = self._get_param("thingTypeName") + attribute_payload = self._get_param("attributePayload") + thing_name, thing_arn = self.iot_backend.create_thing( + thing_name=thing_name, + thing_type_name=thing_type_name, + attribute_payload=attribute_payload, + ) + return json.dumps(dict(thingName=thing_name, thingArn=thing_arn)) + + def create_thing_type(self): + thing_type_name = self._get_param("thingTypeName") + thing_type_properties = self._get_param("thingTypeProperties") + thing_type_name, thing_type_arn = self.iot_backend.create_thing_type( + thing_type_name=thing_type_name, + thing_type_properties=thing_type_properties, + ) + return json.dumps(dict(thingTypeName=thing_type_name, thingTypeArn=thing_type_arn)) + + def list_thing_types(self): + # previous_next_token = self._get_param("nextToken") + # max_results = self._get_int_param("maxResults") + thing_type_name = self._get_param("thingTypeName") + thing_types = self.iot_backend.list_thing_types( + thing_type_name=thing_type_name + ) + + # TODO: support next_token and max_results + next_token = None + return json.dumps(dict(thingTypes=[_.to_dict() for _ in thing_types], nextToken=next_token)) + + def list_things(self): + # previous_next_token = self._get_param("nextToken") + # max_results = self._get_int_param("maxResults") + attribute_name = self._get_param("attributeName") + attribute_value = self._get_param("attributeValue") + thing_type_name = self._get_param("thingTypeName") + things = self.iot_backend.list_things( + attribute_name=attribute_name, + attribute_value=attribute_value, + thing_type_name=thing_type_name, + ) + # TODO: support next_token and max_results + next_token = None + return json.dumps(dict(things=[_.to_dict() for _ in things], nextToken=next_token)) + + def describe_thing(self): + thing_name = self._get_param("thingName") + thing = self.iot_backend.describe_thing( + thing_name=thing_name, + ) + print(thing.to_dict(include_default_client_id=True)) + return json.dumps(thing.to_dict(include_default_client_id=True)) + + def describe_thing_type(self): + thing_type_name = self._get_param("thingTypeName") + thing_type = self.iot_backend.describe_thing_type( + thing_type_name=thing_type_name, + ) + return json.dumps(thing_type.to_dict()) + + def delete_thing(self): + thing_name = self._get_param("thingName") + expected_version = self._get_param("expectedVersion") + self.iot_backend.delete_thing( + thing_name=thing_name, + expected_version=expected_version, + ) + return json.dumps(dict()) + + def delete_thing_type(self): + thing_type_name = self._get_param("thingTypeName") + self.iot_backend.delete_thing_type( + thing_type_name=thing_type_name, + ) + return json.dumps(dict()) + + def update_thing(self): + thing_name = self._get_param("thingName") + thing_type_name = self._get_param("thingTypeName") + attribute_payload = self._get_param("attributePayload") + expected_version = self._get_param("expectedVersion") + remove_thing_type = self._get_param("removeThingType") + self.iot_backend.update_thing( + thing_name=thing_name, + thing_type_name=thing_type_name, + attribute_payload=attribute_payload, + expected_version=expected_version, + remove_thing_type=remove_thing_type, + ) + return json.dumps(dict()) + + def create_keys_and_certificate(self): + set_as_active = self._get_param("setAsActive") + cert, key_pair = self.iot_backend.create_keys_and_certificate( + set_as_active=set_as_active, + ) + return json.dumps(dict( + certificateArn=cert.arn, + certificateId=cert.certificate_id, + certificatePem=cert.certificate_pem, + keyPair=key_pair + )) + + def delete_certificate(self): + certificate_id = self._get_param("certificateId") + self.iot_backend.delete_certificate( + certificate_id=certificate_id, + ) + return json.dumps(dict()) + + def describe_certificate(self): + certificate_id = self._get_param("certificateId") + certificate = self.iot_backend.describe_certificate( + certificate_id=certificate_id, + ) + return json.dumps(dict(certificateDescription=certificate.to_description_dict())) + + def list_certificates(self): + # page_size = self._get_int_param("pageSize") + # marker = self._get_param("marker") + # ascending_order = self._get_param("ascendingOrder") + certificates = self.iot_backend.list_certificates() + # TODO: handle pagination + return json.dumps(dict(certificates=[_.to_dict() for _ in certificates])) + + def update_certificate(self): + certificate_id = self._get_param("certificateId") + new_status = self._get_param("newStatus") + self.iot_backend.update_certificate( + certificate_id=certificate_id, + new_status=new_status, + ) + return json.dumps(dict()) + + def create_policy(self): + policy_name = self._get_param("policyName") + policy_document = self._get_param("policyDocument") + policy = self.iot_backend.create_policy( + policy_name=policy_name, + policy_document=policy_document, + ) + return json.dumps(policy.to_dict_at_creation()) + + def list_policies(self): + # marker = self._get_param("marker") + # page_size = self._get_int_param("pageSize") + # ascending_order = self._get_param("ascendingOrder") + policies = self.iot_backend.list_policies() + + # TODO: handle pagination + return json.dumps(dict(policies=[_.to_dict() for _ in policies])) + + def get_policy(self): + policy_name = self._get_param("policyName") + policy = self.iot_backend.get_policy( + policy_name=policy_name, + ) + return json.dumps(policy.to_get_dict()) + + def delete_policy(self): + policy_name = self._get_param("policyName") + self.iot_backend.delete_policy( + policy_name=policy_name, + ) + return json.dumps(dict()) + + def attach_principal_policy(self): + policy_name = self._get_param("policyName") + principal = self.headers.get('x-amzn-iot-principal') + self.iot_backend.attach_principal_policy( + policy_name=policy_name, + principal_arn=principal, + ) + return json.dumps(dict()) + + def detach_principal_policy(self): + policy_name = self._get_param("policyName") + principal = self.headers.get('x-amzn-iot-principal') + self.iot_backend.detach_principal_policy( + policy_name=policy_name, + principal_arn=principal, + ) + return json.dumps(dict()) + + def list_principal_policies(self): + principal = self.headers.get('x-amzn-iot-principal') + # marker = self._get_param("marker") + # page_size = self._get_int_param("pageSize") + # ascending_order = self._get_param("ascendingOrder") + policies = self.iot_backend.list_principal_policies( + principal_arn=principal + ) + # TODO: handle pagination + next_marker = None + return json.dumps(dict(policies=[_.to_dict() for _ in policies], nextMarker=next_marker)) + + def list_policy_principals(self): + policy_name = self.headers.get('x-amzn-iot-policy') + # marker = self._get_param("marker") + # page_size = self._get_int_param("pageSize") + # ascending_order = self._get_param("ascendingOrder") + principals = self.iot_backend.list_policy_principals( + policy_name=policy_name, + ) + # TODO: handle pagination + next_marker = None + return json.dumps(dict(principals=principals, nextMarker=next_marker)) + + def attach_thing_principal(self): + thing_name = self._get_param("thingName") + principal = self.headers.get('x-amzn-principal') + self.iot_backend.attach_thing_principal( + thing_name=thing_name, + principal_arn=principal, + ) + return json.dumps(dict()) + + def detach_thing_principal(self): + thing_name = self._get_param("thingName") + principal = self.headers.get('x-amzn-principal') + self.iot_backend.detach_thing_principal( + thing_name=thing_name, + principal_arn=principal, + ) + return json.dumps(dict()) + + def list_principal_things(self): + next_token = self._get_param("nextToken") + # max_results = self._get_int_param("maxResults") + principal = self.headers.get('x-amzn-principal') + things = self.iot_backend.list_principal_things( + principal_arn=principal, + ) + # TODO: handle pagination + next_token = None + return json.dumps(dict(things=things, nextToken=next_token)) + + def list_thing_principals(self): + thing_name = self._get_param("thingName") + principals = self.iot_backend.list_thing_principals( + thing_name=thing_name, + ) + return json.dumps(dict(principals=principals)) diff --git a/moto/iot/urls.py b/moto/iot/urls.py new file mode 100644 index 000000000..6d11c15a5 --- /dev/null +++ b/moto/iot/urls.py @@ -0,0 +1,14 @@ +from __future__ import unicode_literals +from .responses import IoTResponse + +url_bases = [ + "https?://iot.(.+).amazonaws.com", +] + + +response = IoTResponse() + + +url_paths = { + '{0}/.*$': response.dispatch, +} diff --git a/moto/iotdata/__init__.py b/moto/iotdata/__init__.py new file mode 100644 index 000000000..214f2e575 --- /dev/null +++ b/moto/iotdata/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals +from .models import iotdata_backends +from ..core.models import base_decorator + +iotdata_backend = iotdata_backends['us-east-1'] +mock_iotdata = base_decorator(iotdata_backends) diff --git a/moto/iotdata/exceptions.py b/moto/iotdata/exceptions.py new file mode 100644 index 000000000..d55b16e3c --- /dev/null +++ b/moto/iotdata/exceptions.py @@ -0,0 +1,23 @@ +from __future__ import unicode_literals +from moto.core.exceptions import RESTError + + +class IoTDataPlaneClientError(RESTError): + code = 400 + + +class ResourceNotFoundException(IoTDataPlaneClientError): + def __init__(self): + self.code = 400 + super(ResourceNotFoundException, self).__init__( + "ResourceNotFoundException", + "The specified resource does not exist" + ) + + +class InvalidRequestException(IoTDataPlaneClientError): + def __init__(self, message): + self.code = 400 + super(InvalidRequestException, self).__init__( + "InvalidRequestException", message + ) diff --git a/moto/iotdata/models.py b/moto/iotdata/models.py new file mode 100644 index 000000000..7ae517109 --- /dev/null +++ b/moto/iotdata/models.py @@ -0,0 +1,189 @@ +from __future__ import unicode_literals +import json +import time +import boto3 +import jsondiff +from moto.core import BaseBackend, BaseModel +from moto.iot import iot_backends +from .exceptions import ( + ResourceNotFoundException, + InvalidRequestException +) + + +class FakeShadow(BaseModel): + """See the specification: + http://docs.aws.amazon.com/iot/latest/developerguide/thing-shadow-document-syntax.html + """ + def __init__(self, desired, reported, requested_payload, version, deleted=False): + self.desired = desired + self.reported = reported + self.requested_payload = requested_payload + self.version = version + self.timestamp = int(time.time()) + self.deleted = deleted + + self.metadata_desired = self._create_metadata_from_state(self.desired, self.timestamp) + self.metadata_reported = self._create_metadata_from_state(self.reported, self.timestamp) + + @classmethod + def create_from_previous_version(cls, previous_shadow, payload): + """ + set None to payload when you want to delete shadow + """ + version, previous_payload = (previous_shadow.version + 1, previous_shadow.to_dict(include_delta=False)) if previous_shadow else (1, {}) + + if payload is None: + # if given payload is None, delete existing payload + # this means the request was delete_thing_shadow + shadow = FakeShadow(None, None, None, version, deleted=True) + return shadow + + # we can make sure that payload has 'state' key + desired = payload['state'].get( + 'desired', + previous_payload.get('state', {}).get('desired', None) + ) + reported = payload['state'].get( + 'reported', + previous_payload.get('state', {}).get('reported', None) + ) + shadow = FakeShadow(desired, reported, payload, version) + return shadow + + @classmethod + def parse_payload(cls, desired, reported): + if desired is None: + delta = reported + elif reported is None: + delta = desired + else: + delta = jsondiff.diff(desired, reported) + return delta + + def _create_metadata_from_state(self, state, ts): + """ + state must be disired or reported stype dict object + replces primitive type with {"timestamp": ts} in dict + """ + if state is None: + return None + + def _f(elem, ts): + if isinstance(elem, dict): + return {_: _f(elem[_], ts) for _ in elem.keys()} + if isinstance(elem, list): + return [_f(_, ts) for _ in elem] + return {"timestamp": ts} + return _f(state, ts) + + def to_response_dict(self): + desired = self.requested_payload['state'].get('desired', None) + reported = self.requested_payload['state'].get('reported', None) + + payload = {} + if desired is not None: + payload['desired'] = desired + if reported is not None: + payload['reported'] = reported + + metadata = {} + if desired is not None: + metadata['desired'] = self._create_metadata_from_state(desired, self.timestamp) + if reported is not None: + metadata['reported'] = self._create_metadata_from_state(reported, self.timestamp) + return { + 'state': payload, + 'metadata': metadata, + 'timestamp': self.timestamp, + 'version': self.version + } + + def to_dict(self, include_delta=True): + """returning nothing except for just top-level keys for now. + """ + if self.deleted: + return { + 'timestamp': self.timestamp, + 'version': self.version + } + delta = self.parse_payload(self.desired, self.reported) + payload = {} + if self.desired is not None: + payload['desired'] = self.desired + if self.reported is not None: + payload['reported'] = self.reported + if include_delta and (delta is not None and len(delta.keys()) != 0): + payload['delta'] = delta + + metadata = {} + if self.metadata_desired is not None: + metadata['desired'] = self.metadata_desired + if self.metadata_reported is not None: + metadata['reported'] = self.metadata_reported + + return { + 'state': payload, + 'metadata': metadata, + 'timestamp': self.timestamp, + 'version': self.version + } + + +class IoTDataPlaneBackend(BaseBackend): + def __init__(self, region_name=None): + super(IoTDataPlaneBackend, self).__init__() + self.region_name = region_name + + def reset(self): + region_name = self.region_name + self.__dict__ = {} + self.__init__(region_name) + + def update_thing_shadow(self, thing_name, payload): + """ + spec of payload: + - need node `state` + - state node must be an Object + - State contains an invalid node: 'foo' + """ + thing = iot_backends[self.region_name].describe_thing(thing_name) + + # validate + try: + payload = json.loads(payload) + except ValueError: + raise InvalidRequestException('invalid json') + if 'state' not in payload: + raise InvalidRequestException('need node `state`') + if not isinstance(payload['state'], dict): + raise InvalidRequestException('state node must be an Object') + if any(_ for _ in payload['state'].keys() if _ not in ['desired', 'reported']): + raise InvalidRequestException('State contains an invalid node') + + new_shadow = FakeShadow.create_from_previous_version(thing.thing_shadow, payload) + thing.thing_shadow = new_shadow + return thing.thing_shadow + + def get_thing_shadow(self, thing_name): + thing = iot_backends[self.region_name].describe_thing(thing_name) + + if thing.thing_shadow is None or thing.thing_shadow.deleted: + raise ResourceNotFoundException() + return thing.thing_shadow + + def delete_thing_shadow(self, thing_name): + """after deleting, get_thing_shadow will raise ResourceNotFound. + But version of the shadow keep increasing... + """ + thing = iot_backends[self.region_name].describe_thing(thing_name) + if thing.thing_shadow is None: + raise ResourceNotFoundException() + payload = None + new_shadow = FakeShadow.create_from_previous_version(thing.thing_shadow, payload) + thing.thing_shadow = new_shadow + return thing.thing_shadow + + +available_regions = boto3.session.Session().get_available_regions("iot-data") +iotdata_backends = {region: IoTDataPlaneBackend(region) for region in available_regions} diff --git a/moto/iotdata/responses.py b/moto/iotdata/responses.py new file mode 100644 index 000000000..d87479011 --- /dev/null +++ b/moto/iotdata/responses.py @@ -0,0 +1,35 @@ +from __future__ import unicode_literals +from moto.core.responses import BaseResponse +from .models import iotdata_backends +import json + + +class IoTDataPlaneResponse(BaseResponse): + SERVICE_NAME = 'iot-data' + + @property + def iotdata_backend(self): + return iotdata_backends[self.region] + + def update_thing_shadow(self): + thing_name = self._get_param("thingName") + payload = self.body + payload = self.iotdata_backend.update_thing_shadow( + thing_name=thing_name, + payload=payload, + ) + return json.dumps(payload.to_response_dict()) + + def get_thing_shadow(self): + thing_name = self._get_param("thingName") + payload = self.iotdata_backend.get_thing_shadow( + thing_name=thing_name, + ) + return json.dumps(payload.to_dict()) + + def delete_thing_shadow(self): + thing_name = self._get_param("thingName") + payload = self.iotdata_backend.delete_thing_shadow( + thing_name=thing_name, + ) + return json.dumps(payload.to_dict()) diff --git a/moto/iotdata/urls.py b/moto/iotdata/urls.py new file mode 100644 index 000000000..a3bcb0a52 --- /dev/null +++ b/moto/iotdata/urls.py @@ -0,0 +1,14 @@ +from __future__ import unicode_literals +from .responses import IoTDataPlaneResponse + +url_bases = [ + "https?://data.iot.(.+).amazonaws.com", +] + + +response = IoTDataPlaneResponse() + + +url_paths = { + '{0}/.*$': response.dispatch, +} diff --git a/setup.py b/setup.py index 44907d3a6..fdd5b5a48 100755 --- a/setup.py +++ b/setup.py @@ -21,7 +21,8 @@ install_requires = [ "python-dateutil<3.0.0,>=2.1", "mock", "docker>=2.5.1", - "aws-xray-sdk>=0.93" + "jsondiff==1.1.1", + "aws-xray-sdk>=0.93", ] extras_require = { diff --git a/tests/test_iot/test_iot.py b/tests/test_iot/test_iot.py new file mode 100644 index 000000000..31631e459 --- /dev/null +++ b/tests/test_iot/test_iot.py @@ -0,0 +1,179 @@ +from __future__ import unicode_literals + +import boto3 +import sure # noqa +from moto import mock_iot + + +@mock_iot +def test_things(): + client = boto3.client('iot', region_name='ap-northeast-1') + name = 'my-thing' + type_name = 'my-type-name' + + # thing type + thing_type = client.create_thing_type(thingTypeName=type_name) + thing_type.should.have.key('thingTypeName').which.should.equal(type_name) + thing_type.should.have.key('thingTypeArn') + + res = client.list_thing_types() + res.should.have.key('thingTypes').which.should.have.length_of(1) + for thing_type in res['thingTypes']: + thing_type.should.have.key('thingTypeName').which.should_not.be.none + + thing_type = client.describe_thing_type(thingTypeName=type_name) + thing_type.should.have.key('thingTypeName').which.should.equal(type_name) + thing_type.should.have.key('thingTypeProperties') + thing_type.should.have.key('thingTypeMetadata') + + # thing + thing = client.create_thing(thingName=name, thingTypeName=type_name) + thing.should.have.key('thingName').which.should.equal(name) + thing.should.have.key('thingArn') + res = client.list_things() + res.should.have.key('things').which.should.have.length_of(1) + for thing in res['things']: + thing.should.have.key('thingName').which.should_not.be.none + + thing = client.update_thing(thingName=name, attributePayload={'attributes': {'k1': 'v1'}}) + res = client.list_things() + res.should.have.key('things').which.should.have.length_of(1) + for thing in res['things']: + thing.should.have.key('thingName').which.should_not.be.none + res['things'][0]['attributes'].should.have.key('k1').which.should.equal('v1') + + thing = client.describe_thing(thingName=name) + thing.should.have.key('thingName').which.should.equal(name) + thing.should.have.key('defaultClientId') + thing.should.have.key('thingTypeName') + thing.should.have.key('attributes') + thing.should.have.key('version') + + # delete thing + client.delete_thing(thingName=name) + res = client.list_things() + res.should.have.key('things').which.should.have.length_of(0) + + # delete thing type + client.delete_thing_type(thingTypeName=type_name) + res = client.list_thing_types() + res.should.have.key('thingTypes').which.should.have.length_of(0) + + +@mock_iot +def test_certs(): + client = boto3.client('iot', region_name='ap-northeast-1') + cert = client.create_keys_and_certificate(setAsActive=True) + cert.should.have.key('certificateArn').which.should_not.be.none + cert.should.have.key('certificateId').which.should_not.be.none + cert.should.have.key('certificatePem').which.should_not.be.none + cert.should.have.key('keyPair') + cert['keyPair'].should.have.key('PublicKey').which.should_not.be.none + cert['keyPair'].should.have.key('PrivateKey').which.should_not.be.none + cert_id = cert['certificateId'] + + cert = client.describe_certificate(certificateId=cert_id) + cert.should.have.key('certificateDescription') + cert_desc = cert['certificateDescription'] + cert_desc.should.have.key('certificateArn').which.should_not.be.none + cert_desc.should.have.key('certificateId').which.should_not.be.none + cert_desc.should.have.key('certificatePem').which.should_not.be.none + cert_desc.should.have.key('status').which.should.equal('ACTIVE') + + res = client.list_certificates() + res.should.have.key('certificates').which.should.have.length_of(1) + for cert in res['certificates']: + cert.should.have.key('certificateArn').which.should_not.be.none + cert.should.have.key('certificateId').which.should_not.be.none + cert.should.have.key('status').which.should_not.be.none + cert.should.have.key('creationDate').which.should_not.be.none + + client.update_certificate(certificateId=cert_id, newStatus='REVOKED') + cert = client.describe_certificate(certificateId=cert_id) + cert_desc.should.have.key('status').which.should.equal('ACTIVE') + + client.delete_certificate(certificateId=cert_id) + res = client.list_certificates() + res.should.have.key('certificates').which.should.have.length_of(0) + +@mock_iot +def test_policy(): + client = boto3.client('iot', region_name='ap-northeast-1') + name = 'my-policy' + doc = '{}' + policy = client.create_policy(policyName=name, policyDocument=doc) + policy.should.have.key('policyName').which.should.equal(name) + policy.should.have.key('policyArn').which.should_not.be.none + policy.should.have.key('policyDocument').which.should.equal(doc) + policy.should.have.key('policyVersionId').which.should.equal('1') + + policy = client.get_policy(policyName=name) + policy.should.have.key('policyName').which.should.equal(name) + policy.should.have.key('policyArn').which.should_not.be.none + policy.should.have.key('policyDocument').which.should.equal(doc) + policy.should.have.key('defaultVersionId').which.should.equal('1') + + res = client.list_policies() + res.should.have.key('policies').which.should.have.length_of(1) + for policy in res['policies']: + policy.should.have.key('policyName').which.should_not.be.none + policy.should.have.key('policyArn').which.should_not.be.none + + client.delete_policy(policyName=name) + res = client.list_policies() + res.should.have.key('policies').which.should.have.length_of(0) + + +@mock_iot +def test_principal_policy(): + client = boto3.client('iot', region_name='ap-northeast-1') + policy_name = 'my-policy' + doc = '{}' + policy = client.create_policy(policyName=policy_name, policyDocument=doc) + cert = client.create_keys_and_certificate(setAsActive=True) + cert_arn = cert['certificateArn'] + + client.attach_principal_policy(policyName=policy_name, principal=cert_arn) + + res = client.list_principal_policies(principal=cert_arn) + res.should.have.key('policies').which.should.have.length_of(1) + for policy in res['policies']: + policy.should.have.key('policyName').which.should_not.be.none + policy.should.have.key('policyArn').which.should_not.be.none + + res = client.list_policy_principals(policyName=policy_name) + res.should.have.key('principals').which.should.have.length_of(1) + for principal in res['principals']: + principal.should_not.be.none + + client.detach_principal_policy(policyName=policy_name, principal=cert_arn) + res = client.list_principal_policies(principal=cert_arn) + res.should.have.key('policies').which.should.have.length_of(0) + res = client.list_policy_principals(policyName=policy_name) + res.should.have.key('principals').which.should.have.length_of(0) + + +@mock_iot +def test_principal_thing(): + client = boto3.client('iot', region_name='ap-northeast-1') + thing_name = 'my-thing' + thing = client.create_thing(thingName=thing_name) + cert = client.create_keys_and_certificate(setAsActive=True) + cert_arn = cert['certificateArn'] + + client.attach_thing_principal(thingName=thing_name, principal=cert_arn) + + res = client.list_principal_things(principal=cert_arn) + res.should.have.key('things').which.should.have.length_of(1) + for thing in res['things']: + thing.should_not.be.none + res = client.list_thing_principals(thingName=thing_name) + res.should.have.key('principals').which.should.have.length_of(1) + for principal in res['principals']: + principal.should_not.be.none + + client.detach_thing_principal(thingName=thing_name, principal=cert_arn) + res = client.list_principal_things(principal=cert_arn) + res.should.have.key('things').which.should.have.length_of(0) + res = client.list_thing_principals(thingName=thing_name) + res.should.have.key('principals').which.should.have.length_of(0) diff --git a/tests/test_iot/test_server.py b/tests/test_iot/test_server.py new file mode 100644 index 000000000..47091531a --- /dev/null +++ b/tests/test_iot/test_server.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals + +import sure # noqa + +import moto.server as server +from moto import mock_iot + +''' +Test the different server responses +''' + +@mock_iot +def test_iot_list(): + backend = server.create_backend_app("iot") + test_client = backend.test_client() + + # just making sure that server is up + res = test_client.get('/things') + res.status_code.should.equal(404) diff --git a/tests/test_iotdata/test_iotdata.py b/tests/test_iotdata/test_iotdata.py new file mode 100644 index 000000000..5768d31c7 --- /dev/null +++ b/tests/test_iotdata/test_iotdata.py @@ -0,0 +1,87 @@ +from __future__ import unicode_literals + +import json +import boto3 +import sure # noqa +from nose.tools import assert_raises +from botocore.exceptions import ClientError +from moto import mock_iotdata, mock_iot + + +@mock_iot +@mock_iotdata +def test_basic(): + iot_client = boto3.client('iot', region_name='ap-northeast-1') + client = boto3.client('iot-data', region_name='ap-northeast-1') + name = 'my-thing' + raw_payload = b'{"state": {"desired": {"led": "on"}}}' + iot_client.create_thing(thingName=name) + + with assert_raises(ClientError): + client.get_thing_shadow(thingName=name) + + res = client.update_thing_shadow(thingName=name, payload=raw_payload) + + payload = json.loads(res['payload'].read()) + expected_state = '{"desired": {"led": "on"}}' + payload.should.have.key('state').which.should.equal(json.loads(expected_state)) + payload.should.have.key('metadata').which.should.have.key('desired').which.should.have.key('led') + payload.should.have.key('version').which.should.equal(1) + payload.should.have.key('timestamp') + + res = client.get_thing_shadow(thingName=name) + payload = json.loads(res['payload'].read()) + expected_state = b'{"desired": {"led": "on"}, "delta": {"led": "on"}}' + payload.should.have.key('state').which.should.equal(json.loads(expected_state)) + payload.should.have.key('metadata').which.should.have.key('desired').which.should.have.key('led') + payload.should.have.key('version').which.should.equal(1) + payload.should.have.key('timestamp') + + client.delete_thing_shadow(thingName=name) + with assert_raises(ClientError): + client.get_thing_shadow(thingName=name) + + +@mock_iot +@mock_iotdata +def test_update(): + iot_client = boto3.client('iot', region_name='ap-northeast-1') + client = boto3.client('iot-data', region_name='ap-northeast-1') + name = 'my-thing' + raw_payload = b'{"state": {"desired": {"led": "on"}}}' + iot_client.create_thing(thingName=name) + + # first update + res = client.update_thing_shadow(thingName=name, payload=raw_payload) + payload = json.loads(res['payload'].read()) + expected_state = '{"desired": {"led": "on"}}' + payload.should.have.key('state').which.should.equal(json.loads(expected_state)) + payload.should.have.key('metadata').which.should.have.key('desired').which.should.have.key('led') + payload.should.have.key('version').which.should.equal(1) + payload.should.have.key('timestamp') + + res = client.get_thing_shadow(thingName=name) + payload = json.loads(res['payload'].read()) + expected_state = b'{"desired": {"led": "on"}, "delta": {"led": "on"}}' + payload.should.have.key('state').which.should.equal(json.loads(expected_state)) + payload.should.have.key('metadata').which.should.have.key('desired').which.should.have.key('led') + payload.should.have.key('version').which.should.equal(1) + payload.should.have.key('timestamp') + + # reporting new state + new_payload = b'{"state": {"reported": {"led": "on"}}}' + res = client.update_thing_shadow(thingName=name, payload=new_payload) + payload = json.loads(res['payload'].read()) + expected_state = '{"reported": {"led": "on"}}' + payload.should.have.key('state').which.should.equal(json.loads(expected_state)) + payload.should.have.key('metadata').which.should.have.key('reported').which.should.have.key('led') + payload.should.have.key('version').which.should.equal(2) + payload.should.have.key('timestamp') + + res = client.get_thing_shadow(thingName=name) + payload = json.loads(res['payload'].read()) + expected_state = b'{"desired": {"led": "on"}, "reported": {"led": "on"}}' + payload.should.have.key('state').which.should.equal(json.loads(expected_state)) + payload.should.have.key('metadata').which.should.have.key('desired').which.should.have.key('led') + payload.should.have.key('version').which.should.equal(2) + payload.should.have.key('timestamp') diff --git a/tests/test_iotdata/test_server.py b/tests/test_iotdata/test_server.py new file mode 100644 index 000000000..42a5c5f22 --- /dev/null +++ b/tests/test_iotdata/test_server.py @@ -0,0 +1,20 @@ +from __future__ import unicode_literals + +import sure # noqa + +import moto.server as server +from moto import mock_iotdata + +''' +Test the different server responses +''' + +@mock_iotdata +def test_iotdata_list(): + backend = server.create_backend_app("iot-data") + test_client = backend.test_client() + + # just making sure that server is up + thing_name = 'nothing' + res = test_client.get('/things/{}/shadow'.format(thing_name)) + res.status_code.should.equal(404)