Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/skills/add-connector-type/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ def {ConnectorName}_{action_name}_batch(

**requirements.txt:**
```
azure-functions>=2.2.0b4
azure-functions
azurefunctions-extensions-connectors
```

Expand Down
3 changes: 1 addition & 2 deletions .github/skills/trigger-registration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ dotnet add package Azure.Connectors.Sdk --prerelease
Add to `requirements.txt` (include packages based on your approach):

```text
# >=2.2.0b4 only required for @app.connector_trigger decorator (Python 3.13+ only), regular azure-functions is enough for generic_trigger
azure-functions>=2.2.0b4
azure-functions

# Currently only supports Office 365 OnNewEmail operation
azurefunctions-extensions-connectors
Expand Down
27 changes: 27 additions & 0 deletions azurefunctions-extensions-connectors/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
Comment thread
hallvictoria marked this conversation as resolved.

### Added
- Add the Power Query Dataflows `RefreshModel` SDK binding for refresh-completion
polling callbacks, with a sample.
- Add Azure DevOps SDK bindings for builds, Git pushes and pull requests, TFVC
changesets, and V2 work-item triggers, with samples.
- Add the Salesforce `Item` SDK binding for record-created and record-updated
triggers, preserving standard and custom fields, with a sample.
- Add the Planner `GetTaskResponseV2` SDK binding for new, completed, and assigned
task triggers, with typed categories, connector assignment arrays, and a sample.
- Add Dataverse/CDS `Item`, `RowChangeNotification`, and `ActionEventNotification`
bindings that preserve dynamic polling and webhook fields, with samples.
- Add Microsoft Teams SDK bindings for channel messages, membership changes,
and seven webhook notification types, with polling and webhook samples.
- Add the OneDrive for Business `BlobMetadata` SDK binding for file-property
triggers, with raw-array and Poll MANY support and a sample.
- Add SharePoint Online `Item` and `DeletedItem` SDK bindings for list-item,
file-property, and deletion triggers, with dynamic-column preservation and samples.
- Add the `GraphCalendarEventClientWithActionType` Office 365 binding for changed
calendar events. The existing `GraphCalendarEventListWithActionType` binding
remains supported with the same list-returning behavior.

### Fixed
- Deserialize nested SDK models when their annotations also accept mappings,
preserving typed Office 365 attachments with newer generated SDKs.

## [1.0.0b1] - 2026-05-11

### Added
Expand Down
327 changes: 315 additions & 12 deletions azurefunctions-extensions-connectors/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import json
import types
from collections.abc import Mapping
from dataclasses import Field, fields, is_dataclass
from typing import Any, Callable, TypeVar, Union, get_args, get_origin, get_type_hints

Expand Down Expand Up @@ -54,6 +55,18 @@ def _deserialize_value(annotation: Any, value: Any) -> Any:
if len(non_none_types) == 1:
return _deserialize_value(non_none_types[0], value)

if isinstance(value, dict):
model_types = [
item_type for item_type in non_none_types
if isinstance(item_type, type) and is_dataclass(item_type)
]
if len(model_types) == 1 and all(
item_type is model_types[0]
or (get_origin(item_type) or item_type) in (dict, Mapping)
for item_type in non_none_types
):
return _deserialize_value(model_types[0], value)

if origin is list:
item_types = get_args(annotation)
item_type = item_types[0] if item_types else Any
Expand Down Expand Up @@ -97,13 +110,20 @@ def deserialize_model(
return model_type(**values)


def parse_payload(data: Datum) -> list[dict[str, Any]]:
def parse_payload(
data: Datum,
*,
reject_malformed_members: bool = False,
) -> list[dict[str, Any]]:
"""Normalize direct records or callbacks with a sole outer body property."""
if data.type in ("collection_string", "collection_bytes"):
return [
item
for event in data.python_value
for item in parse_payload(Datum(value=event, type="json"))
for item in parse_payload(
Datum(value=event, type="json"),
reject_malformed_members=reject_malformed_members,
)
]

payload: Any = data.value
Expand All @@ -122,11 +142,21 @@ def parse_payload(data: Datum) -> list[dict[str, Any]]:
if not isinstance(body, dict):
raise ValueError("Connector payload body must contain a JSON object.")

if reject_malformed_members and "value" in body and (
body["value"] is not None and not isinstance(body["value"], list)
):
raise ValueError("Connector batch value must contain a JSON array or null.")

if "value" in body and (
body["value"] is None or isinstance(body["value"], list)
):
raw_items = body["value"] or []
else:
raw_items = [body]

if reject_malformed_members and any(
not isinstance(item, dict) for item in raw_items
):
raise ValueError("Each connector batch item must contain a JSON object.")
Comment thread
hallvictoria marked this conversation as resolved.

return [item for item in raw_items if isinstance(item, dict)]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

"""SDK payload bindings for the Dataverse commondataservice connector."""

from ..connectorConverter import ConnectorConverter
from .actionEventNotification import ActionEventNotification
from .item import Item
from .rowChangeNotification import RowChangeNotification

__all__ = [
"ActionEventNotification", "ConnectorConverter", "Item", "RowChangeNotification",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

"""Preserve dynamic Dataverse records without treating schema metadata as data."""

from __future__ import annotations

import json
from typing import Any

from azure.connectors.commondataservice import Item, ObjectEntity
from azurefunctions.extensions.base import Datum


def _parse_payload(data: Datum, *, unwrap_value: bool) -> list[dict[str, Any]]:
"""Extract polling batches, but keep webhook value fields as record data."""
if data.type in ("collection_string", "collection_bytes"):
return [
item
for event in data.python_value
for item in _parse_payload(
Datum(value=event, type="json"), unwrap_value=unwrap_value,
)
]

payload: Any = data.value
if isinstance(payload, (str, bytes, bytearray)):
try:
payload = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError) as error:
raise ValueError(f"Invalid JSON payload: {error}.") from error

if isinstance(payload, dict) and set(payload) == {"body"}:
payload = payload["body"]
if payload is None:
return []

if isinstance(payload, dict):
if unwrap_value and "value" in payload and all(
name == "value" or name.startswith(("@odata.", "odata."))
for name in payload
):
payload = payload["value"]
if payload is None:
return []
if not isinstance(payload, list):
raise ValueError("CDS polling batch value must contain a JSON array.")
else:
payload = [payload]

if not isinstance(payload, list):
raise ValueError("CDS payload must contain a JSON object or array.")
if any(not isinstance(item, dict) for item in payload):
raise ValueError("Each CDS payload item must contain a JSON object.")

return payload


def deserialize_items(data: Datum) -> list[Item]:
"""Store legacy polling columns in the generated SDK dynamic property bag."""
result = []
for record in _parse_payload(data, unwrap_value=True):
if set(record) in ({"dynamicProperties"}, {"dynamic_properties"}):
properties = next(iter(record.values()))
if properties is not None and not isinstance(properties, dict):
raise ValueError("CDS dynamicProperties must be an object or null.")
else:
properties = record

result.append(Item(
dynamic_properties=dict(properties) if properties is not None else None,
))

return result


def deserialize_notifications(data: Datum) -> list[ObjectEntity]:
"""Keep complete webhook records in SDK ObjectEntity additional_properties."""
return [
ObjectEntity(additional_properties=dict(record))
for record in _parse_payload(data, unwrap_value=False)
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

"""Bind dynamic BusinessEventsTrigger action-event notifications."""

from azure.connectors.commondataservice import ObjectEntity

from .._sdk_type import ConnectorSdkType
from ._deserialization import deserialize_notifications


class ActionEventNotification(ConnectorSdkType[list[ObjectEntity]], ObjectEntity):
"""Return SDK ObjectEntity records with action fields in additional_properties."""

_deserialize = staticmethod(deserialize_notifications)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

"""Bind legacy CDS polling records using the generated SDK item model."""

from azure.connectors.commondataservice import Item as AzureItem

from .._sdk_type import ConnectorSdkType
from ._deserialization import deserialize_items


class Item(ConnectorSdkType[list[AzureItem]], AzureItem):
"""Return legacy polling items with raw JSON in dynamic_properties."""

_deserialize = staticmethod(deserialize_items)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

"""Bind dynamic SubscribeWebhookTrigger row-change notifications."""

from azure.connectors.commondataservice import ObjectEntity

from .._sdk_type import ConnectorSdkType
from ._deserialization import deserialize_notifications


class RowChangeNotification(ConnectorSdkType[list[ObjectEntity]], ObjectEntity):
"""Return SDK ObjectEntity records with row fields in additional_properties."""

_deserialize = staticmethod(deserialize_notifications)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

"""SDK refresh bindings for Power Query Dataflows connector triggers."""

from ..connectorConverter import ConnectorConverter
from .refreshModel import RefreshModel

__all__ = ["ConnectorConverter", "RefreshModel"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

"""Normalize Dataflows refresh records without inventing a source collection."""

from __future__ import annotations

import json
from typing import Any

from azure.connectors.dataflows import RefreshModel
from azurefunctions.extensions.base import Datum

from .._deserialization import deserialize_model


def _parse_payload(data: Datum) -> list[dict[str, Any]]:
"""Read single refresh callbacks and optional transport-level aggregates."""
if data.type in ("collection_string", "collection_bytes"):
return [
item
for event in data.python_value
for item in _parse_payload(Datum(value=event, type="json"))
]

payload: Any = data.value
if isinstance(payload, (str, bytes, bytearray)):
try:
payload = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError) as error:
raise ValueError(f"Invalid JSON payload: {error}.") from error

if isinstance(payload, dict) and set(payload) == {"body"}:
payload = payload["body"]
if payload is None:
return []

if isinstance(payload, dict):
payload = [payload]
if not isinstance(payload, list):
raise ValueError("Dataflows payload must contain a JSON object or array.")
if any(not isinstance(item, dict) for item in payload):
raise ValueError("Each Dataflows transport item must contain a JSON object.")
if any("value" in item and all(
name == "value" or name.startswith(("@odata.", "odata.")) for name in item
) for item in payload):
raise ValueError(
"Dataflows OnRefreshComplete returns a refresh record, "
"not a value collection."
)

return payload


def deserialize_refreshes(data: Datum) -> list[RefreshModel]:
"""Map the six nullable SDK string fields without changing their values."""
return [deserialize_model(RefreshModel, item) for item in _parse_payload(data)]
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

"""Bind Dataflows refresh callbacks using the generated SDK refresh model."""

from azure.connectors.dataflows import RefreshModel as AzureRefreshModel

from .._sdk_type import ConnectorSdkType
from ._deserialization import deserialize_refreshes


class RefreshModel(ConnectorSdkType[list[AzureRefreshModel]], AzureRefreshModel):
"""Return SDK refresh items for OnRefreshComplete polling callbacks."""

_deserialize = staticmethod(deserialize_refreshes)
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
from .graphCalendarEventClientReceive import (
GraphCalendarEventClientReceive
)
from .graphCalendarEventClientWithActionType import (
GraphCalendarEventClientWithActionType
)
from ..connectorConverter import ConnectorConverter

__all__ = [
"ClientReceiveMessage",
"GraphClientReceiveMessage",
"GraphCalendarEventListWithActionType",
"GraphCalendarEventClientReceive",
"GraphCalendarEventClientWithActionType",
"ConnectorConverter",
]
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,13 @@ def deserialize_graph_calendar_events(

def deserialize_graph_calendar_events_with_action_type(
data: Datum,
*,
reject_malformed_members: bool = False,
) -> list[GraphCalendarEventClientWithActionType]:
"""Deserialize changed calendar events into a list for trigger handlers."""
return [
deserialize_model(GraphCalendarEventClientWithActionType, item)
for item in parse_payload(data)
for item in parse_payload(
data, reject_malformed_members=reject_malformed_members,
)
]
Loading
Loading