diff --git a/azext_iot/_help.py b/azext_iot/_help.py index d40013c8a..1cf0ed68c 100644 --- a/azext_iot/_help.py +++ b/azext_iot/_help.py @@ -1247,6 +1247,17 @@ az iot central app validate-messages --app-id {app_id} --simulate-errors """ +helps[ + "iot central app capability-model show" +] = """ + type: command + short-summary: Get the device model from IoT central. + examples: + - name: Basic usage + text: > + az iot central app capability-model show --app-id {app_id} -d {device_id} + """ + helps[ "iot central device-twin" ] = """ diff --git a/azext_iot/central/__init__.py b/azext_iot/central/__init__.py new file mode 100644 index 000000000..55614acbf --- /dev/null +++ b/azext_iot/central/__init__.py @@ -0,0 +1,5 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/azext_iot/central/providers/__init__.py b/azext_iot/central/providers/__init__.py new file mode 100644 index 000000000..21fd79ac1 --- /dev/null +++ b/azext_iot/central/providers/__init__.py @@ -0,0 +1,9 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from .device_provider import CentralDeviceProvider + +__all__ = ["CentralDeviceProvider"] diff --git a/azext_iot/central/providers/device_provider.py b/azext_iot/central/providers/device_provider.py new file mode 100644 index 000000000..330caec21 --- /dev/null +++ b/azext_iot/central/providers/device_provider.py @@ -0,0 +1,80 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.util import CLIError +from azext_iot.central import services as central_services + + +class CentralDeviceProvider: + def __init__(self, cmd, app_id, token=None): + """ + Provider for device/device_template APIs + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + Useful in scenarios where user doesn't own the app + therefore AAD token won't work, but a SAS token generated by owner will + """ + self._cmd = cmd + self._app_id = app_id + self._token = token + self._device_templates = {} + self._devices = {} + + def get_device_template( + self, device_id, central_dns_suffix="azureiotcentral.com", + ): + device = self.get_device(device_id, central_dns_suffix) + device_template_urn = device["instanceOf"] + + if not device_template_urn: + raise CLIError( + "No device template urn found for device '{}'".format(device_id) + ) + + # get or add to cache + if ( + device_template_urn not in self._device_templates + or not self._device_templates.get(device_template_urn) + ): + self._device_templates[ + device_template_urn + ] = central_services.device_template.get_device_template( + self._cmd, + device_template_urn, + self._app_id, + self._token, + central_dns_suffix, + ) + + device_template = self._device_templates[device_template_urn] + if not device_template: + raise CLIError( + "No device template for device with id: '{}'.".format(device_id) + ) + + return device_template + + def get_device( + self, device_id, central_dns_suffix="azureiotcentral.com", + ): + if not device_id: + raise CLIError("Device id must be specified.") + + # get or add to cache + if device_id not in self._devices or not self._devices.get(device_id): + self._devices[device_id] = central_services.device.get_device( + self._cmd, device_id, self._app_id, self._token, central_dns_suffix + ) + + device = self._devices[device_id] + if not device: + raise CLIError("No device found with id: '{}'.".format(device_id)) + + return device diff --git a/azext_iot/central/services/__init__.py b/azext_iot/central/services/__init__.py new file mode 100644 index 000000000..6e13f4bad --- /dev/null +++ b/azext_iot/central/services/__init__.py @@ -0,0 +1,10 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from . import device, device_template + + +__all__ = ["device", "device_template"] diff --git a/azext_iot/central/services/_utility.py b/azext_iot/central/services/_utility.py new file mode 100644 index 000000000..5f01196e2 --- /dev/null +++ b/azext_iot/central/services/_utility.py @@ -0,0 +1,17 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +# Nothing in this file should be used outside of service/central + +from azext_iot import constants +from azext_iot.common import auth + + +def get_headers(token, cmd): + if not token: + aad_token = auth.get_aad_token(cmd, resource="https://apps.azureiotcentral.com") + token = "Bearer {}".format(aad_token["accessToken"]) + + return {"Authorization": token, "User-Agent": constants.USER_AGENT} diff --git a/azext_iot/central/services/device.py b/azext_iot/central/services/device.py new file mode 100644 index 000000000..de96af8a0 --- /dev/null +++ b/azext_iot/central/services/device.py @@ -0,0 +1,48 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +# This is largely derived from https://docs.microsoft.com/en-us/rest/api/iotcentral/devices + +import requests + +from knack.util import CLIError +from . import _utility as utility + + +def get_device( + cmd, + device_id: str, + app_id: str, + token: str, + central_dns_suffix="azureiotcentral.com", +) -> dict: + """ + Get device info given a device id + + Args: + cmd: command passed into az + device_id: unique case-sensitive device id, + app_id: name of app (used for forming request URL) + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + central_dns_suffix: {centralDnsSuffixInPath} as found in docs + + Returns: + device: dict + """ + + url = "https://{}.{}/api/preview/devices/{}".format( + app_id, central_dns_suffix, device_id + ) + headers = utility.get_headers(token, cmd) + + response = requests.get(url, headers=headers) + + body = response.json() + + if "error" in body: + raise CLIError(body["error"]) + + return body diff --git a/azext_iot/central/services/device_template.py b/azext_iot/central/services/device_template.py new file mode 100644 index 000000000..de239f90a --- /dev/null +++ b/azext_iot/central/services/device_template.py @@ -0,0 +1,47 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +# This is largely derived from https://docs.microsoft.com/en-us/rest/api/iotcentral/devicetemplates + +import requests + +from knack.util import CLIError +from . import _utility as utility + + +def get_device_template( + cmd, + device_template_urn: str, + app_id: str, + token: str, + central_dns_suffix="azureiotcentral.com", +) -> dict: + """ + Get device template given a device id + + Args: + cmd: command passed into az + device_template_urn: case sensitive device template urn, + app_id: name of app (used for forming request URL) + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + central_dns_suffix: {centralDnsSuffixInPath} as found in docs + + Returns: + device: dict + """ + url = "https://{}.{}/api/preview/deviceTemplates/{}".format( + app_id, central_dns_suffix, device_template_urn + ) + headers = utility.get_headers(token, cmd) + + response = requests.get(url, headers=headers) + + body = response.json() + + if "error" in body: + raise CLIError(body["error"]) + + return body diff --git a/azext_iot/commands.py b/azext_iot/commands.py index 2d34b6d6e..fcb88dc99 100644 --- a/azext_iot/commands.py +++ b/azext_iot/commands.py @@ -188,6 +188,13 @@ def load_command_table(self, _): "validate-messages", "iot_central_validate_messages", is_preview=True ) + with self.command_group( + "iot central app capability-model", command_type=iotcentral_ops + ) as cmd_group: + cmd_group.command( + "show", "iot_central_device_capability_model_show", is_preview=True + ) + with self.command_group( "iot central device-twin", command_type=iotcentral_ops ) as cmd_group: diff --git a/azext_iot/common/auth.py b/azext_iot/common/auth.py new file mode 100644 index 000000000..a952b81f9 --- /dev/null +++ b/azext_iot/common/auth.py @@ -0,0 +1,27 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core._profile import Profile + + +def get_aad_token(cmd, resource=None): + """ + get AAD token to access to a specified resource + :param resource: Azure resource endpoints. Default to Azure Resource Manager + Use 'az cloud show' command for other Azure resources + """ + resource = resource or cmd.cli_ctx.cloud.endpoints.active_directory_resource_id + profile = Profile(cli_ctx=cmd.cli_ctx) + creds, subscription, tenant = profile.get_raw_token( + subscription=None, resource=resource + ) + return { + "tokenType": creds[0], + "accessToken": creds[1], + "expiresOn": creds[2].get("expiresOn", "N/A"), + "subscription": subscription, + "tenant": tenant, + } diff --git a/azext_iot/operations/central.py b/azext_iot/operations/central.py index 14e740261..a11b903b9 100644 --- a/azext_iot/operations/central.py +++ b/azext_iot/operations/central.py @@ -10,6 +10,7 @@ from azext_iot.common.shared import SdkType from azext_iot.common.utility import unpack_msrest_error, init_monitoring from azext_iot.common.sas_token_auth import BasicSasTokenAuthentication +from azext_iot.central.providers import CentralDeviceProvider def find_between(s, start, end): @@ -30,6 +31,13 @@ def iot_central_device_show( raise CLIError(unpack_msrest_error(e)) +def iot_central_device_capability_model_show( + cmd, device_id, app_id, central_api_uri="api.azureiotcentral.com" +): + provider = CentralDeviceProvider(cmd, app_id) + return provider.get_device_template(device_id) + + def iot_central_validate_messages( cmd, app_id, @@ -43,19 +51,21 @@ def iot_central_validate_messages( yes=False, central_api_uri="api.azureiotcentral.com", ): + provider = CentralDeviceProvider(cmd, app_id) _events3_runner( - cmd, - app_id, - device_id, - True, - simulate_errors, - consumer_group, - timeout, - enqueued_time, - repair, - properties, - yes, - central_api_uri, + cmd=cmd, + app_id=app_id, + device_id=device_id, + validate_messages=True, + simulate_errors=simulate_errors, + consumer_group=consumer_group, + timeout=timeout, + enqueued_time=enqueued_time, + repair=repair, + properties=properties, + yes=yes, + central_api_uri=central_api_uri, + central_device_provider=provider, ) @@ -72,18 +82,19 @@ def iot_central_monitor_events( central_api_uri="api.azureiotcentral.com", ): _events3_runner( - cmd, - app_id, - device_id, - False, - False, - consumer_group, - timeout, - enqueued_time, - repair, - properties, - yes, - central_api_uri, + cmd=cmd, + app_id=app_id, + device_id=device_id, + validate_messages=False, + simulate_errors=False, + consumer_group=consumer_group, + timeout=timeout, + enqueued_time=enqueued_time, + repair=repair, + properties=properties, + yes=yes, + central_api_uri=central_api_uri, + central_device_provider=None, ) @@ -100,6 +111,7 @@ def _events3_runner( properties, yes, central_api_uri, + central_device_provider, ): (enqueued_time, properties, timeout, output) = init_monitoring( cmd, timeout, properties, enqueued_time, repair, yes @@ -121,4 +133,5 @@ def _events3_runner( output=output, validate_messages=validate_messages, simulate_errors=simulate_errors, + central_device_provider=central_device_provider, ) diff --git a/azext_iot/operations/events3/_events.py b/azext_iot/operations/events3/_events.py index 278ed22ec..0df0a6d12 100644 --- a/azext_iot/operations/events3/_events.py +++ b/azext_iot/operations/events3/_events.py @@ -38,6 +38,7 @@ def executor( pnp_context=None, validate_messages=False, simulate_errors=False, + central_device_provider=None, ): coroutines = [] @@ -56,6 +57,7 @@ def executor( pnp_context, validate_messages, simulate_errors, + central_device_provider, ) ) @@ -113,6 +115,7 @@ async def initiate_event_monitor( pnp_context=None, validate_messages=False, simulate_errors=False, + central_device_provider=None, ): def _get_conn_props(): properties = {} @@ -155,6 +158,7 @@ def _get_conn_props(): pnp_context=pnp_context, validate_messages=validate_messages, simulate_errors=simulate_errors, + central_device_provider=central_device_provider, ) ) return await asyncio.gather(*coroutines, return_exceptions=True) @@ -178,6 +182,7 @@ async def monitor_events( pnp_context=None, validate_messages=False, simulate_errors=False, + central_device_provider=None, ): source = uamqp.address.Source( "amqps://{}/{}/ConsumerGroups/{}/Partitions/{}".format( @@ -214,6 +219,7 @@ async def monitor_events( output, validate_messages, simulate_errors, + central_device_provider, ) except asyncio.CancelledError: @@ -368,6 +374,7 @@ def _output_msg_kpi( output, validate_messages, simulate_errors, + central_device_provider, ): parser = Event3Parser() origin_device_id = parser.parse_device_id(msg) @@ -376,7 +383,13 @@ def _output_msg_kpi( return parsed_msg = parser.parse_message( - msg, pnp_context, interface_name, properties, content_type, simulate_errors + msg, + pnp_context, + interface_name, + properties, + content_type, + simulate_errors, + central_device_provider, ) if output.lower() == "json": diff --git a/azext_iot/operations/events3/_parser.py b/azext_iot/operations/events3/_parser.py index 345d5bc10..72b37c4b3 100644 --- a/azext_iot/operations/events3/_parser.py +++ b/azext_iot/operations/events3/_parser.py @@ -11,6 +11,7 @@ from knack.log import get_logger from uamqp.message import Message from azext_iot.common.utility import parse_entity, unicode_binary_map +from azext_iot.central.providers import CentralDeviceProvider SUPPORTED_ENCODINGS = ["utf-8"] DEVICE_ID_IDENTIFIER = b"iothub-connection-device-id" @@ -36,22 +37,26 @@ def parse_message( properties: dict, content_type_hint: str, simulate_errors: bool, + central_device_provider: CentralDeviceProvider, ) -> dict: self._reset_issues() create_encoding_error = False create_custom_header_warning = False create_payload_error = False + create_payload_name_error = False if not properties: properties = {} # guard against None being passed in - i = random.randint(1, 3) + i = random.randint(1, 4) if simulate_errors and i == 1: create_encoding_error = True if simulate_errors and i == 2: create_custom_header_warning = True if simulate_errors and i == 3: create_payload_error = True + if simulate_errors and i == 4: + create_payload_name_error = True system_properties = self._parse_system_properties(message) @@ -94,6 +99,13 @@ def parse_message( message, origin_device_id, content_type, create_payload_error ) + self._validate_payload_against_dcm( + origin_device_id, + payload, + central_device_provider, + create_payload_name_error, + ) + event["payload"] = payload event_source = {"event": event} @@ -208,6 +220,24 @@ def _parse_content_type( return content_type + def _parse_annotations(self, message: Message): + try: + return unicode_binary_map(message.annotations) + except Exception: + self._warnings.append( + "Unable to decode message.annotations: {}".format(message.annotations) + ) + + def _parse_application_properties(self, message: Message): + try: + return unicode_binary_map(message.application_properties) + except Exception: + self._warnings.append( + "Unable to decode message.application_properties: {}".format( + message.application_properties + ) + ) + def _parse_payload( self, message: Message, origin_device_id, content_type, create_payload_error ): @@ -240,20 +270,57 @@ def _parse_payload( return payload - def _parse_annotations(self, message: Message): + def _validate_payload_against_dcm( + self, + origin_device_id: str, + payload: str, + central_device_provider: CentralDeviceProvider, + create_payload_name_error=False, + ): + if not central_device_provider: + return + + if not hasattr(payload, "keys"): + # some error happend while parsing + # should be captured by _parse_payload method above + return + try: - return unicode_binary_map(message.annotations) - except Exception: - self._warnings.append( - "Unable to decode message.annotations: {}".format(message.annotations) + template = central_device_provider.get_device_template(origin_device_id) + except Exception as e: + self._errors.append( + "Unable to get DCM for device: {}." + "Inner exception: {}".format(origin_device_id, e) ) + return - def _parse_application_properties(self, message: Message): try: - return unicode_binary_map(message.application_properties) + all_schema = self._extract_schema_from_template(template) + all_names = [schema["name"] for schema in all_schema] except Exception: - self._warnings.append( - "Unable to decode message.application_properties: {}".format( - message.application_properties - ) + self._errors.append( + "Unable to extract device schema for device: {}." + "Template: {}".format(origin_device_id, template) ) + return + + for telemetry_name in payload.keys(): + if create_payload_name_error or telemetry_name not in all_names: + self._errors.append( + "Telemetry item '{}' is not present in DCM. " + "Device ID: {}. " + "List of allowed telemetry values for this type of device: {}. " + "NOTE: telemetry names are CASE-SENSITIVE".format( + telemetry_name, origin_device_id, all_names + ) + ) + + def _extract_schema_from_template(self, template): + all_schema = [] + dcm = template["capabilityModel"] + implements = dcm["implements"] + for implementation in implements: + contents = implementation["schema"]["contents"] + all_schema.extend(contents) + + return all_schema diff --git a/azext_iot/tests/central/json/device.json b/azext_iot/tests/central/json/device.json new file mode 100644 index 000000000..0b138571a --- /dev/null +++ b/azext_iot/tests/central/json/device.json @@ -0,0 +1,9 @@ +{ + "id": "device-id", + "etag": "some-etag", + "displayName": "device-id", + "instanceOf": "urn:d9cltbeus:tvj4oal1a0", + "simulated": false, + "provisioned": true, + "approved": true +} \ No newline at end of file diff --git a/azext_iot/tests/central/json/device_template.json b/azext_iot/tests/central/json/device_template.json new file mode 100644 index 000000000..5178f5b18 --- /dev/null +++ b/azext_iot/tests/central/json/device_template.json @@ -0,0 +1,287 @@ +{ + "id": "urn:d9cltbeus:tvj4oal1a0", + "etag": "\"~WgqHZmg+d95gTA53P8AnqBsDLGgj2wa0msOL7xozC9Y=\"", + "types": [ + "DeviceModel" + ], + "displayName": "duplicate-field-name", + "capabilityModel": { + "@id": "urn:sampleApp:modelOne_bz:2", + "@type": [ + "CapabilityModel" + ], + "implements": [ + { + "@id": "urn:sampleApp:modelOne_bz:_rpgcmdpo:1", + "@type": [ + "InterfaceInstance" + ], + "displayName": "Interface", + "name": "modelOne_g4", + "schema": { + "@id": "urn:sampleApp:modelOne_g4:1", + "@type": [ + "Interface" + ], + "displayName": "Interface", + "contents": [ + { + "@id": "urn:sampleApp:modelOne_g4:Bool:1", + "@type": [ + "Telemetry" + ], + "displayName": "Bool", + "name": "Bool", + "schema": "boolean" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Date:1", + "@type": [ + "Telemetry" + ], + "displayName": "Date", + "name": "Date", + "schema": "date" + }, + { + "@id": "urn:sampleApp:modelOne_g4:DateTime:1", + "@type": [ + "Telemetry" + ], + "displayName": "DateTime", + "name": "DateTime", + "schema": "dateTime" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Double:1", + "@type": [ + "Telemetry" + ], + "displayName": "Double", + "name": "Double", + "schema": "double" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Duration:1", + "@type": [ + "Telemetry" + ], + "displayName": "Duration", + "name": "Duration", + "schema": "duration" + }, + { + "@id": "urn:sampleApp:modelOne_g4:IntEnum:1", + "@type": [ + "Telemetry" + ], + "displayName": "IntEnum", + "name": "IntEnum", + "schema": { + "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:1", + "@type": [ + "Enum" + ], + "displayName": "Enum", + "valueSchema": "integer", + "enumValues": [ + { + "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:Enum1:1", + "@type": [ + "EnumValue" + ], + "displayName": "Enum1", + "enumValue": 1, + "name": "Enum1" + }, + { + "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:Enum2:1", + "@type": [ + "EnumValue" + ], + "displayName": "Enum2", + "enumValue": 2, + "name": "Enum2" + } + ] + } + }, + { + "@id": "urn:sampleApp:modelOne_g4:StringEnum:1", + "@type": [ + "Telemetry" + ], + "displayName": "StringEnum", + "name": "StringEnum", + "schema": { + "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:1", + "@type": [ + "Enum" + ], + "displayName": "Enum", + "valueSchema": "string", + "enumValues": [ + { + "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:EnumA:1", + "@type": [ + "EnumValue" + ], + "displayName": "EnumA", + "enumValue": "A", + "name": "EnumA" + }, + { + "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:EnumB:1", + "@type": [ + "EnumValue" + ], + "displayName": "EnumB", + "enumValue": "B", + "name": "EnumB" + } + ] + } + }, + { + "@id": "urn:sampleApp:modelOne_g4:Float:1", + "@type": [ + "Telemetry" + ], + "displayName": "Float", + "name": "Float", + "schema": "float" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Geopoint:1", + "@type": [ + "Telemetry" + ], + "displayName": "Geopoint", + "name": "Geopoint", + "schema": "geopoint" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Int:1", + "@type": [ + "Telemetry" + ], + "displayName": "Int", + "name": "Int", + "schema": "integer" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Long:1", + "@type": [ + "Telemetry" + ], + "displayName": "Long", + "name": "Long", + "schema": "long" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Object:1", + "@type": [ + "Telemetry" + ], + "displayName": "Object", + "name": "Object", + "schema": { + "@id": "urn:sampleApp:modelOne_g4:Object:8ot2x5whp8:1", + "@type": [ + "Object" + ], + "displayName": "Object", + "fields": [ + { + "@id": "urn:sampleApp:modelOne_g4:Object:8ot2x5whp8:Double:1", + "@type": [ + "SchemaField" + ], + "displayName": "Double", + "name": "Double", + "schema": "double" + } + ] + } + }, + { + "@id": "urn:sampleApp:modelOne_g4:String:1", + "@type": [ + "Telemetry" + ], + "displayName": "String", + "name": "String", + "schema": "string" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Time:1", + "@type": [ + "Telemetry" + ], + "displayName": "Time", + "name": "Time", + "schema": "time" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Vector:1", + "@type": [ + "Telemetry" + ], + "displayName": "Vector", + "name": "Vector", + "schema": "vector" + } + ] + } + }, + { + "@id": "urn:sampleApp:modelOne_bz:myxqftpsr:2", + "@type": [ + "InterfaceInstance" + ], + "displayName": "Interface", + "name": "modelTwo_ed", + "schema": { + "@id": "urn:sampleApp:modelTwo_ed:1", + "@type": [ + "Interface" + ], + "displayName": "Interface", + "contents": [ + { + "@id": "urn:sampleApp:modelTwo_ed:Bool:1", + "@type": [ + "Telemetry" + ], + "displayName": "Bool", + "name": "Bool", + "schema": "boolean" + }, + { + "@id": "urn:sampleApp:modelTwo_ed:bool:1", + "@type": [ + "Telemetry" + ], + "displayName": "bool", + "name": "bool", + "schema": "boolean" + } + ] + } + } + ], + "displayName": "larger-telemetry-device", + "@context": [ + "http://azureiot.com/v1/contexts/IoTModel.json" + ] + }, + "solutionModel": { + "@id": "urn:d9cltbeus:lz1tl4a_jz", + "@type": [ + "SolutionModel" + ], + "cloudProperties": [], + "initialValues": [], + "overrides": [] + } +} \ No newline at end of file diff --git a/azext_iot/tests/helpers.py b/azext_iot/tests/helpers.py new file mode 100644 index 000000000..494ba4870 --- /dev/null +++ b/azext_iot/tests/helpers.py @@ -0,0 +1,17 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import os + +from inspect import getsourcefile + +from azext_iot.common.utility import read_file_content + + +def load_json(filename): + os.chdir(os.path.dirname(os.path.abspath(getsourcefile(lambda: 0)))) + return json.loads(read_file_content(filename)) diff --git a/azext_iot/tests/test_constants.py b/azext_iot/tests/test_constants.py new file mode 100644 index 000000000..9519aa82e --- /dev/null +++ b/azext_iot/tests/test_constants.py @@ -0,0 +1,10 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +class FileNames: + central_device_template_file = "central/json/device_template.json" + central_device_file = "central/json/device.json" diff --git a/azext_iot/tests/test_iot_central_unit.py b/azext_iot/tests/test_iot_central_unit.py index 23b60c533..2365bcef3 100644 --- a/azext_iot/tests/test_iot_central_unit.py +++ b/azext_iot/tests/test_iot_central_unit.py @@ -8,10 +8,13 @@ import pytest from knack.util import CLIError +from azure.cli.core.mock import DummyCli from azext_iot.operations import central as subject from azext_iot.common.shared import SdkType -from azure.cli.core.mock import DummyCli -from azext_iot.common.utility import validate_min_python_version +from azext_iot.central.providers import CentralDeviceProvider + +from .helpers import load_json +from .test_constants import FileNames device_id = "mydevice" @@ -160,12 +163,57 @@ def test_device_twin_show_calls_get_twin( assert args[0] == ({"entity": resource}, SdkType.service_sdk) -@pytest.mark.skipif( - not validate_min_python_version(3, 5, exit_on_fail=False), - reason="minimum python version not satisfied", -) class TestMonitorEvents: @pytest.mark.parametrize("timeout, exception", [(-1, CLIError)]) def test_monitor_events_invalid_args(self, timeout, exception, fixture_cmd): with pytest.raises(exception): subject.iot_central_monitor_events(fixture_cmd, app_id, timeout=timeout) + + +class TestCentralDeviceProvider: + _device = load_json(FileNames.central_device_file) + _device_template = load_json(FileNames.central_device_template_file) + + @mock.patch("azext_iot.central.services.device_template") + @mock.patch("azext_iot.central.services.device") + def test_should_return_device(self, mock_device_svc, mock_device_template_svc): + # setup + provider = CentralDeviceProvider(cmd=None, app_id=app_id) + mock_device_svc.get_device.return_value = self._device + mock_device_template_svc.get_device_template.return_value = ( + self._device_template + ) + + # act + device = provider.get_device("someDeviceId") + # check that caching is working + device = provider.get_device("someDeviceId") + + # verify + # call counts should be at most 1 since the provider has a cache + assert mock_device_svc.get_device.call_count == 1 + assert mock_device_svc.get_device_template.call_count == 0 + assert device == self._device + + @mock.patch("azext_iot.central.services.device_template") + @mock.patch("azext_iot.central.services.device") + def test_should_return_device_template( + self, mock_device_svc, mock_device_template_svc + ): + # setup + provider = CentralDeviceProvider(cmd=None, app_id=app_id) + mock_device_svc.get_device.return_value = self._device + mock_device_template_svc.get_device_template.return_value = ( + self._device_template + ) + + # act + template = provider.get_device_template("someDeviceId") + # check that caching is working + template = provider.get_device_template("someDeviceId") + + # verify + # call counts should be at most 1 since the provider has a cache + assert mock_device_svc.get_device.call_count == 1 + assert mock_device_template_svc.get_device_template.call_count == 1 + assert template == self._device_template diff --git a/azext_iot/tests/test_iot_utility_unit.py b/azext_iot/tests/test_iot_utility_unit.py index c6399ca54..c4143098d 100644 --- a/azext_iot/tests/test_iot_utility_unit.py +++ b/azext_iot/tests/test_iot_utility_unit.py @@ -1,36 +1,51 @@ -import pytest +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + import json +import mock +import pytest + from knack.util import CLIError from uamqp.message import Message, MessageProperties from azure.cli.core.extension import get_extension_path -from azext_iot.common.utility import validate_min_python_version +from azext_iot.central.providers import CentralDeviceProvider +from azext_iot.common.utility import ( + validate_min_python_version, + process_json_arg, + read_file_content, + logger, +) from azext_iot.common.deps import ensure_uamqp -from azext_iot._validators import mode2_iot_login_handler from azext_iot.constants import EVENT_LIB, EXTENSION_NAME -from azext_iot.common.utility import process_json_arg, read_file_content, logger from azext_iot.operations.events3 import _parser +from azext_iot._validators import mode2_iot_login_handler +from .helpers import load_json +from .test_constants import FileNames class TestMinPython(object): @pytest.mark.parametrize("pymajor, pyminor", [(3, 6), (3, 4), (2, 7)]) - def test_min_python(self, mocker, pymajor, pyminor): - version_mock = mocker.patch("azext_iot.common.utility.sys.version_info") - version_mock.major = pymajor - version_mock.minor = pyminor + def test_min_python(self, pymajor, pyminor): + with mock.patch("azext_iot.common.utility.sys.version_info") as version_mock: + version_mock.major = pymajor + version_mock.minor = pyminor - assert validate_min_python_version(2, 7) + assert validate_min_python_version(2, 7) @pytest.mark.parametrize( "pymajor, pyminor, exception", [(3, 6, SystemExit), (3, 4, SystemExit), (2, 7, SystemExit)], ) - def test_min_python_error(self, mocker, pymajor, pyminor, exception): - version_mock = mocker.patch("azext_iot.common.utility.sys.version_info") - version_mock.major = 2 - version_mock.minor = 6 + def test_min_python_error(self, pymajor, pyminor, exception): + with mock.patch("azext_iot.common.utility.sys.version_info") as version_mock: + version_mock.major = 2 + version_mock.minor = 6 - with pytest.raises(exception): - validate_min_python_version(pymajor, pyminor) + with pytest.raises(exception): + validate_min_python_version(pymajor, pyminor) class TestMode2Handler(object): @@ -269,13 +284,9 @@ def test_file_json_fail_invalidcontent(self, content, argname, set_cwd, mocker): assert mocked_util_logger.call_count == 0 -@pytest.mark.skipif( - not validate_min_python_version(3, 5, exit_on_fail=False), - reason="minimum python version not satisfied", -) class TestEvents3Parser: device_id = "some-device-id" - payload = {"someProperty": "someValue"} + payload = {"String": "someValue"} encoding = "UTF-8" content_type = "application/json" @@ -283,10 +294,12 @@ class TestEvents3Parser: bad_payload = "bad-payload" bad_content_type = "bad-content-type" + bad_dcm_payload = {"temperature": "someValue"} + def test_parse_message_should_succeed(self): # setup - app_prop_type = "some_app_prop" - app_prop_value = "some_app_value" + app_prop_type = "some_property" + app_prop_value = "some_value" properties = MessageProperties( content_encoding=self.encoding, content_type=self.content_type ) @@ -298,6 +311,10 @@ def test_parse_message_should_succeed(self): ) parser = _parser.Event3Parser() + device_template = load_json(FileNames.central_device_template_file) + provider = CentralDeviceProvider(cmd=None, app_id=None) + provider.get_device_template = mock.MagicMock(return_value=device_template) + # act parsed_msg = parser.parse_message( message=message, @@ -306,6 +323,7 @@ def test_parse_message_should_succeed(self): properties={"all"}, content_type_hint=None, simulate_errors=False, + central_device_provider=provider, ) # verify @@ -347,6 +365,7 @@ def test_parse_message_pnp_should_succeed(self): properties=None, content_type_hint=None, simulate_errors=False, + central_device_provider=None, ) # verify @@ -361,9 +380,7 @@ def test_parse_message_pnp_should_succeed(self): def test_parse_message_bad_content_type_should_warn(self): # setup encoded_payload = json.dumps(self.payload).encode() - properties = MessageProperties( - content_type=self.bad_content_type - ) + properties = MessageProperties(content_type=self.bad_content_type) message = Message( body=encoded_payload, properties=properties, @@ -372,7 +389,15 @@ def test_parse_message_bad_content_type_should_warn(self): parser = _parser.Event3Parser() # act - parsed_msg = parser.parse_message(message, None, None, None, None, False) + parsed_msg = parser.parse_message( + message=message, + pnp_context=False, + interface_name=None, + properties=None, + content_type_hint=None, + simulate_errors=False, + central_device_provider=None, + ) # verify # since the content_encoding header is not present, just dump the raw payload @@ -404,7 +429,15 @@ def test_parse_message_bad_encoding_should_fail(self): parser = _parser.Event3Parser() # act - parser.parse_message(message, None, None, None, None, False) + parser.parse_message( + message=message, + pnp_context=False, + interface_name=None, + properties=None, + content_type_hint=None, + simulate_errors=False, + central_device_provider=None, + ) assert len(parser._errors) == 1 assert len(parser._warnings) == 0 @@ -426,7 +459,15 @@ def test_parse_message_bad_json_should_fail(self): parser = _parser.Event3Parser() # act - parsed_msg = parser.parse_message(message, None, None, None, None, False) + parsed_msg = parser.parse_message( + message=message, + pnp_context=False, + interface_name=None, + properties=None, + content_type_hint=None, + simulate_errors=False, + central_device_provider=None, + ) # verify # parsing should attempt to place raw payload into result even if parsing fails @@ -466,6 +507,7 @@ def test_parse_message_pnp_should_fail(self): properties=None, content_type_hint=None, simulate_errors=False, + central_device_provider=None, ) # verify @@ -483,3 +525,96 @@ def test_parse_message_pnp_should_fail(self): self.device_id, expected_interface_name, actual_interface_name ) assert actual_error == expected_error + + def test_validate_against_template_should_fail(self): + # setup + app_prop_type = "some_property" + app_prop_value = "some_value" + properties = MessageProperties( + content_encoding=self.encoding, content_type=self.content_type + ) + message = Message( + body=json.dumps(self.bad_dcm_payload).encode(), + properties=properties, + annotations={_parser.DEVICE_ID_IDENTIFIER: self.device_id.encode()}, + application_properties={app_prop_type.encode(): app_prop_value.encode()}, + ) + parser = _parser.Event3Parser() + + device_template = load_json(FileNames.central_device_template_file) + provider = CentralDeviceProvider(cmd=None, app_id=None) + provider.get_device_template = mock.MagicMock(return_value=device_template) + + # act + parsed_msg = parser.parse_message( + message=message, + pnp_context=False, + interface_name=None, + properties={"all"}, + content_type_hint=None, + simulate_errors=False, + central_device_provider=provider, + ) + + # verify + assert parsed_msg["event"]["payload"] == self.bad_dcm_payload + assert parsed_msg["event"]["origin"] == self.device_id + device_identifier = str(_parser.DEVICE_ID_IDENTIFIER, "utf8") + assert parsed_msg["event"]["annotations"][device_identifier] == self.device_id + + properties = parsed_msg["event"]["properties"] + assert properties["system"]["content_encoding"] == self.encoding + assert properties["system"]["content_type"] == self.content_type + assert properties["application"][app_prop_type] == app_prop_value + + assert len(parser._errors) == 1 + assert len(parser._warnings) == 0 + assert len(parser._info) == 0 + + actual_error = parser._errors[0] + expected_error = "Telemetry item '{}' is not present in DCM.".format( + list(self.bad_dcm_payload)[0] + ) + assert expected_error in actual_error + + def test_validate_against_bad_template_should_not_throw(self): + # setup + app_prop_type = "some_property" + app_prop_value = "some_value" + properties = MessageProperties( + content_encoding=self.encoding, content_type=self.content_type + ) + message = Message( + body=json.dumps(self.bad_dcm_payload).encode(), + properties=properties, + annotations={_parser.DEVICE_ID_IDENTIFIER: self.device_id.encode()}, + application_properties={app_prop_type.encode(): app_prop_value.encode()}, + ) + parser = _parser.Event3Parser() + + provider = CentralDeviceProvider(cmd=None, app_id=None) + provider.get_device_template = mock.MagicMock( + return_value="an_unparseable_template" + ) + + # act + parsed_msg = parser.parse_message( + message=message, + pnp_context=False, + interface_name=None, + properties={"all"}, + content_type_hint=None, + simulate_errors=False, + central_device_provider=provider, + ) + + # verify + assert parsed_msg["event"]["payload"] == self.bad_dcm_payload + assert parsed_msg["event"]["origin"] == self.device_id + + assert len(parser._errors) == 1 + assert len(parser._warnings) == 0 + assert len(parser._info) == 0 + + actual_error = parser._errors[0] + assert "Unable to extract device schema for device" in actual_error