Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 52 additions & 45 deletions .github/skills/add-connector-type/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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}/`
Expand Down Expand Up @@ -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) |
Original file line number Diff line number Diff line change
@@ -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)]
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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]:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Loading
Loading