diff --git a/.github/skills/add-connector-type/SKILL.md b/.github/skills/add-connector-type/SKILL.md index fa61a8c..c1c4c0f 100644 --- a/.github/skills/add-connector-type/SKILL.md +++ b/.github/skills/add-connector-type/SKILL.md @@ -21,6 +21,27 @@ Checklist for adding a new SDK type to the `azurefunctions-extensions-connectors ### Step 1: Create the SDK Type Wrapper +Do not modify the generated Azure Connectors SDK model or add a `from_json()` +method to it. Trigger callback normalization and conversion belong to this +extension because those rules vary by SDK binding type. + +First add a type-specific function to the connector's `_deserialization.py`: + +```python +from .._deserialization import deserialize_model, parse_payload + + +def deserialize_{newTypeName}(data: Datum) -> List[Azure{NewTypeName}]: + """Deserialize the trigger callback into generated SDK models.""" + return [ + deserialize_model(Azure{NewTypeName}, item) + for item in parse_payload(data) + ] +``` + +Add explicit aliases and converters there if the callback field names or value +semantics differ from the generated model contract. + **File:** `azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/{ConnectorName}/{newTypeName}.py` ```python @@ -30,56 +51,26 @@ Checklist for adding a new SDK type to the `azurefunctions-extensions-connectors from typing import List from azure.connectors.{ConnectorName} import {NewTypeName} as Azure{NewTypeName} -from azurefunctions.extensions.base import Datum, SdkType - - -class {NewTypeName}(SdkType, Azure{NewTypeName}): - def __init__(self, *, data: Datum) -> None: - self._json_payload = data - - @classmethod - def supports_deferred_binding(cls) -> bool: - """{ConnectorName} connector does not support deferred binding.""" - return False - - def get_sdk_type(self) -> List[Azure{NewTypeName}]: - if not self._json_payload: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"No data provided." - ) - try: - messages = Azure{NewTypeName}.from_json(self._json_payload) - return messages - except Exception as e: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"Exception: {e}" - ) from e +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_{newTypeName} + + +class {NewTypeName}( + ConnectorSdkType[List[Azure{NewTypeName}]], + Azure{NewTypeName}, +): + """Azure Functions binding for {ConnectorName} trigger values.""" + + _deserialize = staticmethod(deserialize_{newTypeName}) ``` ### Step 2: Update the Converter **File:** `azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/connectorConverter.py` -1. Add import at the top: - ```python - from .{ConnectorName}.{newTypeName} import {NewTypeName} - ``` - -2. Add to `SUPPORTED_SDK_TYPES` tuple: - ```python - SUPPORTED_SDK_TYPES = ( - # ... existing types ... - {NewTypeName} - ) - ``` - -3. Add `elif` branch in `decode()` method: - ```python - elif sdk_type == {NewTypeName}: - return {NewTypeName}(data=data).get_sdk_type() - ``` +No converter change is needed. The converter recognizes every +`ConnectorSdkType` subclass and constructs the selected wrapper, which delegates +to its `_deserialize` function. ### Step 3: Update Package Exports @@ -136,8 +127,24 @@ class {NewTypeName}(SdkType, Azure{NewTypeName}): def test_supports_deferred_binding_false(self): """Test that {NewTypeName} does not support deferred binding""" self.assertFalse({NewTypeName}.supports_deferred_binding()) + + def test_get_sdk_type_deserializes_payload(self): + """Test callback fields and nested values are deserialized""" + data = Datum( + value={"body": {"value": [{"id": "item-1"}]}}, + type="json", + ) + + values = {NewTypeName}(data=data).get_sdk_type() + + self.assertEqual(len(values), 1) + self.assertEqual(values[0].id, "item-1") ``` +Add focused cases for both batch and single-item envelopes, string and decoded +JSON inputs, aliases, scalar conversions, nested generated models, empty input, +malformed input, and the exact return shape required by the SDK type. + ### Step 5: Create Sample Folder **Folder:** `azurefunctions-extensions-connectors/samples/{ConnectorName}_samples_{action_name}/` @@ -250,5 +257,5 @@ Demonstrates how to handle the "{Action Description}" action. The {NewTypeName} | Action | Files | |--------|-------| -| **Modified** | `connectorConverter.py`, `{ConnectorName}/__init__.py`, `tests/test_clientreceivemessage.py`, `samples/README.md` | +| **Modified** | `connectorConverter.py`, `{ConnectorName}/_deserialization.py`, `{ConnectorName}/__init__.py`, `tests/test_clientreceivemessage.py`, `samples/README.md` | | **Created** | `{ConnectorName}/{newTypeName}.py`, `samples/{ConnectorName}_samples_{action_name}/` (4 files) | diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_deserialization.py new file mode 100644 index 0000000..6dbae3f --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_deserialization.py @@ -0,0 +1,125 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Shared deserialization helpers for connector trigger payloads.""" + +from __future__ import annotations + +import json +import types +from dataclasses import Field, fields, is_dataclass +from typing import Any, Callable, TypeVar, Union, get_args, get_origin, get_type_hints + +from azurefunctions.extensions.base import Datum + +ModelT = TypeVar("ModelT") + + +def _snake_to_camel(name: str) -> str: + """Convert a generated Python field name to its likely JSON wire name.""" + normalized_name = name[:-1] if name.endswith("_") else name + first_part, *remaining_parts = normalized_name.split("_") + return first_part + "".join(part.capitalize() for part in remaining_parts) + + +def _read_field_value( + data: dict[str, Any], + model_field: Field[Any], + aliases: dict[str, str], +) -> Any: + """Read a field using binding aliases, generated metadata, and conventions.""" + candidate_names = ( + aliases.get(model_field.name), + _snake_to_camel(model_field.name), + model_field.metadata.get("wire_name"), + model_field.name, + ) + for candidate_name in candidate_names: + if candidate_name is not None and candidate_name in data: + return data[candidate_name] + return None + + +def _deserialize_value(annotation: Any, value: Any) -> Any: + """Deserialize a value according to a generated model annotation.""" + if value is None: + return None + + origin = get_origin(annotation) + if origin in (Union, types.UnionType): + non_none_types = [ + item_type for item_type in get_args(annotation) + if item_type is not type(None) + ] + if len(non_none_types) == 1: + return _deserialize_value(non_none_types[0], value) + + if origin is list: + item_types = get_args(annotation) + item_type = item_types[0] if item_types else Any + if not isinstance(value, list): + return value + return [_deserialize_value(item_type, item) for item in value] + + if isinstance(annotation, type) and is_dataclass(annotation): + if not isinstance(value, dict): + return value + return deserialize_model(annotation, value) + + return value + + +def deserialize_model( + model_type: type[ModelT], + data: dict[str, Any], + *, + aliases: dict[str, str] | None = None, + converters: dict[str, Callable[[Any], Any]] | None = None, +) -> ModelT: + """Deserialize a dictionary into a generated connector dataclass.""" + field_aliases = aliases or {} + field_converters = converters or {} + type_hints = get_type_hints(model_type) + values: dict[str, Any] = {} + + for model_field in fields(model_type): + value = _read_field_value(data, model_field, field_aliases) + converter = field_converters.get(model_field.name) + if converter is not None: + value = converter(value) + else: + value = _deserialize_value( + type_hints.get(model_field.name, Any), + value, + ) + values[model_field.name] = value + + return model_type(**values) + + +def parse_payload(data: Datum) -> list[dict[str, Any]]: + """Normalize a connector callback into a list of item dictionaries.""" + payload: Any = data.value + if isinstance(payload, str): + try: + payload = json.loads(payload) + except json.JSONDecodeError as error: + raise ValueError(f"Invalid JSON payload: {error}.") from error + + if not isinstance(payload, dict): + raise ValueError("Connector payload must contain a JSON object.") + + body = payload.get("body", payload) + if body is None: + return [] + if not isinstance(body, dict): + raise ValueError("Connector payload body must contain a JSON object.") + + if set(body) == {"value"} and ( + body["value"] is None or isinstance(body["value"], list) + ): + raw_items = body["value"] or [] + else: + raw_items = [body] + + return [item for item in raw_items if isinstance(item, dict)] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_sdk_type.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_sdk_type.py new file mode 100644 index 0000000..a05b84d --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_sdk_type.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Base SDK type for connector-specific payload deserialization.""" + +from __future__ import annotations + +from typing import Callable, Generic, TypeVar + +from azurefunctions.extensions.base import Datum, SdkType + +ResultT = TypeVar("ResultT") + + +class ConnectorSdkType(SdkType, Generic[ResultT]): + """Delegate connector payload conversion to a type-specific deserializer.""" + + _deserialize: Callable[[Datum], ResultT] + + def __init__(self, *, data: Datum) -> None: + self._json_payload = data + + @classmethod + def supports_deferred_binding(cls) -> bool: + """Connector SDK types do not support deferred binding.""" + return False + + def get_sdk_type(self) -> ResultT: + """Deserialize the stored trigger payload into its Azure SDK type.""" + if self._json_payload is None: + raise ValueError( + f"Unable to create {self.__class__.__name__} SDK type. " + "No data provided." + ) + + try: + return self._deserialize(self._json_payload) + except Exception as error: + raise ValueError( + f"Unable to create {self.__class__.__name__} SDK type. " + f"Exception: {error}" + ) from error diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/connectorConverter.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/connectorConverter.py index 975fdcc..71ea0e1 100644 --- a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/connectorConverter.py +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/connectorConverter.py @@ -5,22 +5,7 @@ from typing import Any, Optional, get_args, get_origin from azurefunctions.extensions.base import Datum, InConverter -from .office365.clientReceiveMessage import ClientReceiveMessage -from .office365.graphClientReceiveMessage import GraphClientReceiveMessage -from .office365.graphCalendarEventListWithActionType import ( - GraphCalendarEventListWithActionType -) -from .office365.graphCalendarEventClientReceive import ( - GraphCalendarEventClientReceive -) - -# Tuple of all supported SDK types for type checking -SUPPORTED_SDK_TYPES = ( - ClientReceiveMessage, - GraphClientReceiveMessage, - GraphCalendarEventListWithActionType, - GraphCalendarEventClientReceive -) +from ._sdk_type import ConnectorSdkType class ConnectorConverter( @@ -34,7 +19,7 @@ def check_input_type_annotation(cls, pytype: type) -> bool: # The annotation is a class/type (not an object) - not iterable if (isinstance(pytype, type) - and issubclass(pytype, SUPPORTED_SDK_TYPES)): + and issubclass(pytype, ConnectorSdkType)): return True # An iterable who only has one inner type and is a subclass of @@ -56,7 +41,7 @@ def _is_iterable_supported_type(cls, annotation: type) -> bool: inner_type = inner_types[0] return (isinstance(inner_type, type) - and issubclass(inner_type, SUPPORTED_SDK_TYPES)) + and issubclass(inner_type, ConnectorSdkType)) @classmethod def _get_sdk_type(cls, pytype: type) -> Optional[type]: @@ -65,7 +50,7 @@ def _get_sdk_type(cls, pytype: type) -> Optional[type]: and List[Type] annotations. """ # Direct type check - if isinstance(pytype, type) and issubclass(pytype, SUPPORTED_SDK_TYPES): + if isinstance(pytype, type) and issubclass(pytype, ConnectorSdkType): return pytype # Check for List[Type] and extract inner type @@ -79,9 +64,8 @@ def _get_sdk_type(cls, pytype: type) -> Optional[type]: @classmethod def decode(cls, data: Datum, *, trigger_metadata, pytype) -> Optional[Any]: """ - Office365 Connector allows for batches. This means the cardinality - can be one or many. This functionality is handled by the Connector - SDK. + Connector triggers can have one or many values. The selected SDK type + handles the connector-specific payload shape. """ if data is None or data.type is None: return None @@ -90,23 +74,12 @@ def decode(cls, data: Datum, *, trigger_metadata, pytype) -> Optional[Any]: sdk_type = cls._get_sdk_type(pytype) try: - # Determines which sdk type to return based on pytype - if sdk_type == ClientReceiveMessage: - return ClientReceiveMessage(data=data).get_sdk_type() - elif sdk_type == GraphClientReceiveMessage: - return GraphClientReceiveMessage(data=data).get_sdk_type() - elif sdk_type == GraphCalendarEventListWithActionType: - return GraphCalendarEventListWithActionType( - data=data - ).get_sdk_type() - elif sdk_type == GraphCalendarEventClientReceive: - return GraphCalendarEventClientReceive( - data=data - ).get_sdk_type() - else: + if sdk_type is None: return None + + return sdk_type(data=data).get_sdk_type() except Exception as e: raise ValueError( - "Failed to decode incoming Office365 Connector batch: " + "Failed to decode incoming connector payload: " + repr(e) ) from e diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/_deserialization.py new file mode 100644 index 0000000..270c18d --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/_deserialization.py @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Deserialize Office 365 trigger payloads into Azure Connector SDK models.""" + +from __future__ import annotations + +from typing import Any + +from azure.connectors.office365 import ( + ClientReceiveMessage, + GraphCalendarEventClientReceive, + GraphCalendarEventClientWithActionType, + GraphCalendarEventListWithActionType, + GraphClientReceiveMessage, +) +from azurefunctions.extensions.base import Datum +from .._deserialization import deserialize_model, parse_payload + +_CLIENT_MESSAGE_ALIASES = { + "to": "toRecipients", + "cc": "ccRecipients", + "bcc": "bccRecipients", + "has_attachment": "hasAttachments", + "date_time_received": "receivedDateTime", +} + + +def _deserialize_importance(value: Any) -> int | None: + """Convert the legacy email importance wire value to its SDK integer.""" + if isinstance(value, int): + return value + if isinstance(value, str): + return {"low": 0, "normal": 1, "high": 2}.get(value.lower()) + return None + + +def deserialize_client_receive_messages( + data: Datum, +) -> list[ClientReceiveMessage]: + """Deserialize legacy Office 365 email trigger messages.""" + return [ + deserialize_model( + ClientReceiveMessage, + item, + aliases=_CLIENT_MESSAGE_ALIASES, + converters={"importance": _deserialize_importance}, + ) + for item in parse_payload(data) + ] + + +def deserialize_graph_client_receive_messages( + data: Datum, +) -> list[GraphClientReceiveMessage]: + """Deserialize Microsoft Graph email trigger messages.""" + return [ + deserialize_model(GraphClientReceiveMessage, item) + for item in parse_payload(data) + ] + + +def deserialize_graph_calendar_events( + data: Datum, +) -> list[GraphCalendarEventClientReceive]: + """Deserialize Microsoft Graph calendar event trigger items.""" + return [ + deserialize_model(GraphCalendarEventClientReceive, item) + for item in parse_payload(data) + ] + + +def deserialize_graph_calendar_events_with_action_type( + data: Datum, +) -> GraphCalendarEventListWithActionType: + """Deserialize changed calendar events into their generated list wrapper.""" + events = [ + deserialize_model(GraphCalendarEventClientWithActionType, item) + for item in parse_payload(data) + ] + return GraphCalendarEventListWithActionType(value=events) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/clientReceiveMessage.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/clientReceiveMessage.py index fcf3af9..82fdd2b 100644 --- a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/clientReceiveMessage.py +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/clientReceiveMessage.py @@ -1,43 +1,15 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List - from azure.connectors.office365 import ClientReceiveMessage as AzureClientReceiveMessage -from azurefunctions.extensions.base import Datum, SdkType - - -class ClientReceiveMessage(SdkType, AzureClientReceiveMessage): - def __init__(self, *, data: Datum) -> None: - self._json_payload = data - - @classmethod - def supports_deferred_binding(cls) -> bool: - """Office365 connector does not support deferred binding.""" - return False +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_client_receive_messages - def get_sdk_type( - self - ) -> List[AzureClientReceiveMessage]: - """ - Uses the from_json method to parse the JSON payload into a list of - ClientReceiveMessage objects. - Returns: - List of ClientReceiveMessage objects parsed from the JSON payload. - """ - if not self._json_payload: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"No data provided." - ) +class ClientReceiveMessage( + ConnectorSdkType[list[AzureClientReceiveMessage]], + AzureClientReceiveMessage, +): + """Azure Functions binding for legacy Office 365 email messages.""" - try: - # Use the Azure SDK's from_json method to parse the payload - messages = AzureClientReceiveMessage.from_json(self._json_payload) - return messages - except Exception as e: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"Exception: {e}" - ) from e + _deserialize = staticmethod(deserialize_client_receive_messages) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventClientReceive.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventClientReceive.py index 2f83fc4..1e53311 100644 --- a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventClientReceive.py +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventClientReceive.py @@ -1,53 +1,17 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List - from azure.connectors.office365 import ( GraphCalendarEventClientReceive as AzureGraphCalendarEventClientReceive ) -from azurefunctions.extensions.base import Datum, SdkType +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_graph_calendar_events class GraphCalendarEventClientReceive( - SdkType, AzureGraphCalendarEventClientReceive + ConnectorSdkType[list[AzureGraphCalendarEventClientReceive]], + AzureGraphCalendarEventClientReceive, ): - def __init__(self, *, data: Datum) -> None: - self._json_payload = data - - @classmethod - def supports_deferred_binding(cls) -> bool: - """office365 connector does not support deferred binding.""" - return False - - def get_sdk_type( - self - ) -> List[AzureGraphCalendarEventClientReceive]: - """ - Uses the from_json method to parse the JSON payload into a list of - GraphCalendarEventClientReceive objects. - - This type is the rich payload returned for the following actions: - - When a new event is created - - When an event is modified - - When an upcoming event is starting soon + """Azure Functions binding for Microsoft Graph calendar events.""" - Returns: - List of GraphCalendarEventClientReceive objects parsed from - the JSON payload. - """ - if not self._json_payload: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"No data provided." - ) - try: - messages = AzureGraphCalendarEventClientReceive.from_json( - self._json_payload - ) - return messages - except Exception as e: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"Exception: {e}" - ) from e + _deserialize = staticmethod(deserialize_graph_calendar_events) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventListWithActionType.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventListWithActionType.py index a51942d..bf4c947 100644 --- a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventListWithActionType.py +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventListWithActionType.py @@ -1,50 +1,21 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List - from azure.connectors.office365 import ( GraphCalendarEventListWithActionType as AzureGraphCalendarEventListWithActionType ) -from azurefunctions.extensions.base import Datum, SdkType +from .._sdk_type import ConnectorSdkType +from ._deserialization import ( + deserialize_graph_calendar_events_with_action_type, +) class GraphCalendarEventListWithActionType( - SdkType, AzureGraphCalendarEventListWithActionType + ConnectorSdkType[AzureGraphCalendarEventListWithActionType], + AzureGraphCalendarEventListWithActionType, ): - def __init__(self, *, data: Datum) -> None: - self._json_payload = data - - @classmethod - def supports_deferred_binding(cls) -> bool: - """Office365 connector does not support deferred binding.""" - return False - - def get_sdk_type( - self - ) -> List[AzureGraphCalendarEventListWithActionType]: - """ - Uses the from_json method to parse the JSON payload into a list of - GraphCalendarEventListWithActionType objects. - - Returns: - List of GraphCalendarEventListWithActionType objects parsed from - the JSON payload. - """ - if not self._json_payload: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"No data provided." - ) + """Azure Functions binding for changed Microsoft Graph calendar events.""" - try: - # Use the Azure SDK's from_json method to parse the payload - messages = AzureGraphCalendarEventListWithActionType.from_json( - self._json_payload - ) - return messages - except Exception as e: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"Exception: {e}" - ) from e + _deserialize = staticmethod( + deserialize_graph_calendar_events_with_action_type + ) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphClientReceiveMessage.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphClientReceiveMessage.py index 1948041..131a8a6 100644 --- a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphClientReceiveMessage.py +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphClientReceiveMessage.py @@ -1,48 +1,17 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List - from azure.connectors.office365 import ( GraphClientReceiveMessage as AzureGraphClientReceiveMessage ) -from azurefunctions.extensions.base import Datum, SdkType - - -class GraphClientReceiveMessage(SdkType, AzureGraphClientReceiveMessage): - def __init__(self, *, data: Datum) -> None: - self._json_payload = data - - @classmethod - def supports_deferred_binding(cls) -> bool: - """Office365 connector does not support deferred binding.""" - return False +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_graph_client_receive_messages - def get_sdk_type( - self - ) -> List[AzureGraphClientReceiveMessage]: - """ - Uses the from_json method to parse the JSON payload into a list of - GraphClientReceiveMessage objects. - Returns: - List of GraphClientReceiveMessage objects parsed from the JSON - payload. - """ - if not self._json_payload: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"No data provided." - ) +class GraphClientReceiveMessage( + ConnectorSdkType[list[AzureGraphClientReceiveMessage]], + AzureGraphClientReceiveMessage, +): + """Azure Functions binding for Microsoft Graph email messages.""" - try: - # Use the Azure SDK's from_json method to parse the payload - messages = AzureGraphClientReceiveMessage.from_json( - self._json_payload - ) - return messages - except Exception as e: - raise ValueError( - f"Unable to create {self.__class__.__name__} SDK type. " - f"Exception: {e}" - ) from e + _deserialize = staticmethod(deserialize_graph_client_receive_messages) diff --git a/azurefunctions-extensions-connectors/tests/test_clientreceivemessage.py b/azurefunctions-extensions-connectors/tests/test_clientreceivemessage.py index 6368f65..c4c12c1 100644 --- a/azurefunctions-extensions-connectors/tests/test_clientreceivemessage.py +++ b/azurefunctions-extensions-connectors/tests/test_clientreceivemessage.py @@ -29,6 +29,44 @@ def test_init_stores_json_payload(self): msg = ClientReceiveMessage(data=test_data) self.assertEqual(msg._json_payload, test_data) + def test_get_sdk_type_deserializes_batch_and_nested_attachment(self): + """Test extension-owned email and attachment deserialization.""" + data = Datum( + value=( + '{"body":{"value":[{"id":"message-1",' + '"toRecipients":"recipient@example.com",' + '"importance":"high","hasAttachments":true,' + '"attachments":[{"id":"attachment-1",' + '"contentType":"text/plain"}]}]}}' + ), + type="json", + ) + + messages = ClientReceiveMessage(data=data).get_sdk_type() + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].id, "message-1") + self.assertEqual(messages[0].to, "recipient@example.com") + self.assertEqual(messages[0].importance, 2) + self.assertTrue(messages[0].has_attachment) + self.assertEqual(messages[0].attachments[0].id, "attachment-1") + self.assertEqual( + messages[0].attachments[0].content_type, + "text/plain", + ) + + def test_get_sdk_type_deserializes_single_item(self): + """Test single-item callbacks are normalized to a list.""" + data = Datum( + value={"body": {"id": "message-1", "subject": "Hello"}}, + type="json", + ) + + messages = ClientReceiveMessage(data=data).get_sdk_type() + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].subject, "Hello") + if __name__ == "__main__": unittest.main() diff --git a/azurefunctions-extensions-connectors/tests/test_connector_converter.py b/azurefunctions-extensions-connectors/tests/test_connector_converter.py index 30ca893..b3b53f8 100644 --- a/azurefunctions-extensions-connectors/tests/test_connector_converter.py +++ b/azurefunctions-extensions-connectors/tests/test_connector_converter.py @@ -4,6 +4,8 @@ import unittest from typing import List +from azurefunctions.extensions.base import Datum +from azurefunctions.extensions.connectors._sdk_type import ConnectorSdkType from azurefunctions.extensions.connectors.office365 import ( ClientReceiveMessage, GraphClientReceiveMessage, @@ -13,6 +15,17 @@ ) +def deserialize_future_connector(data: Datum) -> str: + """Deserialize a stand-in future connector payload.""" + return data.value + + +class FutureConnectorSdkType(ConnectorSdkType[str]): + """Represent a future connector without central converter registration.""" + + _deserialize = staticmethod(deserialize_future_connector) + + class TestConnectorConverter(unittest.TestCase): """Tests for the ConnectorConverter class.""" @@ -148,6 +161,18 @@ def test_decode_unsupported_type_returns_none(self): ) self.assertIsNone(result) + def test_decode_connector_sdk_type_without_registration(self): + """Test that future connector types need no converter registration.""" + data = Datum(value="future-connector-value", type="json") + + result = ConnectorConverter.decode( + data=data, + trigger_metadata=None, + pytype=FutureConnectorSdkType, + ) + + self.assertEqual(result, "future-connector-value") + if __name__ == "__main__": unittest.main() diff --git a/azurefunctions-extensions-connectors/tests/test_graphcalendareventclientreceive.py b/azurefunctions-extensions-connectors/tests/test_graphcalendareventclientreceive.py index a88b77b..2c62732 100644 --- a/azurefunctions-extensions-connectors/tests/test_graphcalendareventclientreceive.py +++ b/azurefunctions-extensions-connectors/tests/test_graphcalendareventclientreceive.py @@ -32,6 +32,34 @@ def test_init_stores_json_payload(self): event = GraphCalendarEventClientReceive(data=test_data) self.assertEqual(event._json_payload, test_data) + def test_get_sdk_type_deserializes_calendar_events(self): + """Test calendar event fields are mapped by the extension.""" + data = Datum( + value={ + "body": { + "value": [ + { + "id": "event-1", + "subject": "Planning", + "startWithTimeZone": "2026-09-02T10:00:00Z", + "isAllDay": False, + } + ] + } + }, + type="json", + ) + + events = GraphCalendarEventClientReceive(data=data).get_sdk_type() + + self.assertEqual(len(events), 1) + self.assertEqual(events[0].subject, "Planning") + self.assertEqual( + events[0].start_with_time_zone, + "2026-09-02T10:00:00Z", + ) + self.assertFalse(events[0].is_all_day) + if __name__ == "__main__": unittest.main() diff --git a/azurefunctions-extensions-connectors/tests/test_graphcalendareventlistwithactiontype.py b/azurefunctions-extensions-connectors/tests/test_graphcalendareventlistwithactiontype.py index 768db37..3b91b73 100644 --- a/azurefunctions-extensions-connectors/tests/test_graphcalendareventlistwithactiontype.py +++ b/azurefunctions-extensions-connectors/tests/test_graphcalendareventlistwithactiontype.py @@ -32,6 +32,29 @@ def test_init_stores_json_payload(self): event = GraphCalendarEventListWithActionType(data=test_data) self.assertEqual(event._json_payload, test_data) + def test_get_sdk_type_deserializes_action_type_wrapper(self): + """Test changed events are returned in the generated wrapper.""" + data = Datum( + value={ + "body": { + "id": "event-1", + "actionType": "updated", + "isUpdated": True, + "subject": "Updated planning", + } + }, + type="json", + ) + + event_list = GraphCalendarEventListWithActionType( + data=data + ).get_sdk_type() + + self.assertEqual(len(event_list.value), 1) + self.assertEqual(event_list.value[0].id, "event-1") + self.assertEqual(event_list.value[0].action_type, "updated") + self.assertTrue(event_list.value[0].is_updated) + if __name__ == "__main__": unittest.main() diff --git a/azurefunctions-extensions-connectors/tests/test_graphclientreceivemessage.py b/azurefunctions-extensions-connectors/tests/test_graphclientreceivemessage.py index dd8f0d2..9ad355b 100644 --- a/azurefunctions-extensions-connectors/tests/test_graphclientreceivemessage.py +++ b/azurefunctions-extensions-connectors/tests/test_graphclientreceivemessage.py @@ -29,6 +29,44 @@ def test_init_stores_json_payload(self): msg = GraphClientReceiveMessage(data=test_data) self.assertEqual(msg._json_payload, test_data) + def test_get_sdk_type_deserializes_nested_graph_models(self): + """Test Graph attachments and sensitivity labels are typed.""" + data = Datum( + value={ + "body": { + "value": [ + { + "id": "message-1", + "from": "sender@example.com", + "attachments": [ + { + "id": "attachment-1", + "contentBytes": "content", + } + ], + "sensitivityLabelInfo": [ + { + "sensitivityLabelId": "label-1", + "displayName": "Confidential", + } + ], + } + ] + } + }, + type="json", + ) + + messages = GraphClientReceiveMessage(data=data).get_sdk_type() + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].from_, "sender@example.com") + self.assertEqual(messages[0].attachments[0].id, "attachment-1") + self.assertEqual( + messages[0].sensitivity_label_info[0].display_name, + "Confidential", + ) + if __name__ == "__main__": unittest.main()