diff --git a/.github/skills/add-connector-type/SKILL.md b/.github/skills/add-connector-type/SKILL.md index c1c4c0f..95139f0 100644 --- a/.github/skills/add-connector-type/SKILL.md +++ b/.github/skills/add-connector-type/SKILL.md @@ -235,7 +235,7 @@ def {ConnectorName}_{action_name}_batch( **requirements.txt:** ``` -azure-functions>=2.2.0b4 +azure-functions azurefunctions-extensions-connectors ``` diff --git a/.github/skills/trigger-registration/SKILL.md b/.github/skills/trigger-registration/SKILL.md index 3f3c0a1..5908194 100644 --- a/.github/skills/trigger-registration/SKILL.md +++ b/.github/skills/trigger-registration/SKILL.md @@ -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 diff --git a/azurefunctions-extensions-connectors/CHANGELOG.md b/azurefunctions-extensions-connectors/CHANGELOG.md index 09a3c7e..a1278e5 100644 --- a/azurefunctions-extensions-connectors/CHANGELOG.md +++ b/azurefunctions-extensions-connectors/CHANGELOG.md @@ -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] + +### 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 diff --git a/azurefunctions-extensions-connectors/README.md b/azurefunctions-extensions-connectors/README.md index 2c6df9f..b0fd209 100644 --- a/azurefunctions-extensions-connectors/README.md +++ b/azurefunctions-extensions-connectors/README.md @@ -1,10 +1,20 @@ # Azure Functions Extensions Connectors library for Python -This library allows Office365 Connector Triggers in Python Function Apps to recognize and bind to client types from the Azure Connectors SDK. - -Office365 client types can be generated from: - -* Office365 Connector Triggers +This library binds Office 365, SharePoint Online, OneDrive for Business, Teams, +Dataverse/CDS, Planner, Salesforce, Azure DevOps, and Dataflows connector trigger +payloads to model types from the Azure Connectors SDK in Python Function Apps. + +Supported connectors: + +* Office 365 (Outlook) +* SharePoint Online +* OneDrive for Business (file properties) +* Microsoft Teams +* Dataverse (the `commondataservice` connector) +* Planner +* Salesforce +* Azure DevOps +* Power Query Dataflows [Source code](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-connectors) | [Package (PyPI)](https://pypi.org/project/azurefunctions-extensions-connectors/) @@ -16,10 +26,10 @@ Office365 client types can be generated from: ### Prerequisites * A supported Python version is required. For more details, see the [Python Functions version support policy](https://learn.microsoft.com/azure/azure-functions/functions-versions?tabs=isolated-process%2Cv4&pivots=programming-language-python#languages). -* You must have an [Azure subscription](https://azure.microsoft.com/free/) and an Office365 account to use this package. +* You must have an [Azure subscription](https://azure.microsoft.com/free/) and an account with access to the selected connector. ### Install the package -Install the Azure Functions Extensions Connectors Office365 library for Python with pip: +Install the connector SDK type-binding extension with pip: ```bash pip install azurefunctions-extensions-connectors @@ -28,16 +38,309 @@ pip install azurefunctions-extensions-connectors ### Bind to the SDK-type The Azure Functions Extensions Connectors Office365 library for Python allows you to create a function app with an Office365 Connector Trigger and define the type as a ClientReceiveMessage or List[ClientReceiveMessage]. Instead of receiving raw data, when the function is executed, the type returned will be the defined SDK-type and have all of the methods available from the `azure-connectors` SDK. +SharePoint Online handlers import `Item` or `DeletedItem` from +`azurefunctions.extensions.connectors.sharepointonline`. Each binding returns +a list of generated SDK items. See the [SharePoint samples](samples/README.md#sharepoint-online-samples). + +OneDrive for Business handlers import `BlobMetadata` from +`azurefunctions.extensions.connectors.onedriveforbusiness`. The binding returns +a list of generated `azure.connectors.onedriveforbusiness.BlobMetadata` models. +See the [OneDrive sample](samples/README.md#onedrive-for-business-sample). + +Teams handlers import their binding types from +`azurefunctions.extensions.connectors.teams`. Polling and webhook bindings return +lists, but chat webhook models keep their own notification-reference collections. +See the [Teams samples](samples/README.md#microsoft-teams-samples). + +Dataverse/CDS handlers import `Item`, `RowChangeNotification`, or +`ActionEventNotification` from `azurefunctions.extensions.connectors.commondataservice`. +See the [CDS samples](samples/README.md#dataverse-cds-samples) for the different +polling and webhook return contracts. + +Planner handlers import `GetTaskResponseV2` from +`azurefunctions.extensions.connectors.planner`. The binding returns generated SDK +task items, not a list-response envelope. See the [Planner sample](samples/README.md#planner-sample). + +Salesforce handlers import `Item` from +`azurefunctions.extensions.connectors.salesforce`. The binding returns generated +SDK items with the selected object's fields in `dynamic_properties`. See the +[Salesforce sample](samples/README.md#salesforce-sample). + +Azure DevOps handlers import `BuildResult`, `GitPush`, `GitPullRequest`, +`TfvcChangeset`, or `TriggerWorkItemResponse` from +`azurefunctions.extensions.connectors.visualstudioteamservices`. See the +[Azure DevOps sample](samples/README.md#azure-devops-sample). + +Dataflows handlers import `RefreshModel` from +`azurefunctions.extensions.connectors.dataflows`. The binding returns SDK refresh +records for `OnRefreshComplete`. See the +[Dataflows sample](samples/README.md#dataflows-sample). + ## Key concepts ### ClientReceiveMessage The `ClientReceiveMessage` type represents a message received from an Office365 connector. The extension automatically parses the incoming JSON payload and converts it into typed ClientReceiveMessage objects from the `azure-connectors` SDK. +### Supported Office 365 trigger types + +Import binding types from `azurefunctions.extensions.connectors.office365`, not +directly from the generated SDK module. The binding converts the callback into +instances of the corresponding `azure.connectors.office365` model. + +| Trigger operations | Binding item type | +| --- | --- | +| `OnNewEmailV3`, `OnFlaggedEmailV4`, `OnNewMentionMeEmailV3`, `SharedMailboxOnNewEmailV2` | `GraphClientReceiveMessage` | +| `CalendarGetOnNewItemsV3`, `CalendarGetOnUpdatedItemsV3`, `OnUpcomingEventsV3` | `GraphCalendarEventClientReceive` | +| `CalendarGetOnChangedItemsV3` | `GraphCalendarEventClientWithActionType` | + +`ClientReceiveMessage` remains supported for legacy email handlers. The existing +`GraphCalendarEventListWithActionType` binding also remains supported; it returns +the same list of changed-calendar SDK items as `GraphCalendarEventClientWithActionType`. +No existing binding name or return shape has changed. + +### Supported SharePoint Online trigger types + +| Trigger operations | Binding item type | +| --- | --- | +| `GetOnNewItems`, `GetOnUpdatedItems`, `GetOnChangedItems`, `GetOnNewFileItems`, `GetOnUpdatedFileItems`, `GetOnUpdatedFileClassifiedTimes`, `GetOnNewItemsFromForm`, `GetOnRecurrenceDigest` | `sharepointonline.Item` | +| `GetOnDeletedItems`, `GetOnDeletedFileItems` | `sharepointonline.DeletedItem` | + +`Item.dynamic_properties` preserves raw JSON columns, including strings, numbers, +booleans, nulls, arrays, and nested objects. Access columns by their exact wire +names, such as `item.dynamic_properties["ID"]`. No fixed model can describe every +list's custom columns. If an input record consists solely of the SDK's +`dynamicProperties` object, the binding uses that object as the property bag. +Records with other columns retain every top-level key, including a column named +`dynamicProperties`, without flattening or merging it. + +`DeletedItem` exposes `id`, `name`, `file_name_with_extension`, +`deleted_by_user_name`, `time_deleted`, and `is_folder`. The binding uses the SDK's +wire-name metadata, including the uppercase `ID` field. + +Both types support single records, callback body envelopes, source batches, and +Functions Poll MANY collections. Malformed batch members raise `ValueError` +rather than being dropped. The legacy binary-content triggers `OnNewFile` and +`OnUpdatedFile` are not covered by these two model bindings. + +### Supported OneDrive for Business trigger types + +| Trigger operations | Binding item type | +| --- | --- | +| `OnNewFilesV2`, `OnUpdatedFilesV2` (properties only) | `onedriveforbusiness.BlobMetadata` | + +The binding exposes all twelve generated SDK metadata fields: `id`, `name`, +`name_no_ext`, `display_name`, `path`, `last_modified`, `size`, `media_type`, +`is_folder`, `e_tag`, `file_locator`, and `last_modified_by`. It maps SDK wire +names such as `NameNoExt`, `ETag`, and `LastModifiedBy`, including SDK versions +that omit field metadata. Date/time and ETag fields retain the SDK's string types. + +Supported shapes include the trigger's raw source array, a split-on record, +callback `body` envelopes, `value` collections, and Functions Poll MANY +collections. Empty source batches return empty lists. Malformed batch members +raise `ValueError` instead of being silently dropped. + +The singular operations `OnNewFileV2` and `OnUpdatedFileV2` return binary file +content, not metadata, and have no generated SDK callback model. They are not +covered by this binding. Encoded `$content` envelopes are rejected rather than +turned into empty metadata models. This is the OneDrive for Business model, not +the Azure Blob Storage or personal OneDrive model with the same class name. + +### Supported Microsoft Teams trigger types + +Import these binding types from `azurefunctions.extensions.connectors.teams`. + +| Trigger operations | Binding type | +| --- | --- | +| `OnNewChannelMessage`, `OnNewChannelMessageMentioningMe` | `ChatMessage` | +| `OnGroupMembershipAdd`, `OnGroupMembershipRemoval` | `OnGroupMemberChangeResponse` | +| `WebhookChatMessageTrigger` | `ChatMessageWebhookResponseSchema` | +| `WebhookNewMessageTrigger` | `DynamicNewMessageWebhookTriggerResponseSchema` | +| `WebhookAtMentionTrigger` | `DynamicAtMentionWebhookTriggerResponseSchema` | +| `WebhookKeywordTrigger` | `DynamicKeywordWebhookTriggerResponseSchema` | +| `WebhookMessageReactionTrigger` | `MessageReactionWebhookResponseSchema` | +| `RecordingTrigger` | `RecordingWebhookResponseSchema` | +| `TranscriptTrigger` | `TranscriptWebhookResponseSchema` | + +`ChatMessage` returns generated message models with nested body, sender, +attachment, mention, and reaction dictionaries. `OnGroupMemberChangeResponse` +returns a list of dictionaries, matching the SDK's alias rather than inventing a +fixed member model. + +Webhook bindings return a list of generated notification models. In particular, +`ChatMessageWebhookResponseSchema.value` contains message references, not full +`ChatMessage` records. The binding preserves that collection inside each model, +including empty or null collections. Dynamic new-message, mention, and keyword +models retain the whole JSON notification in `additional_properties`, including +its nested objects and fields named `value` or `additional_properties`. + +Recording and transcript notifications contain metadata and content URLs, not +downloaded content. Their distinct `id` and `@odata.id` values map to `id` and +`id_2`; `meeting_organizer` becomes a generated `TeamsIdentitySet` containing raw +identity dictionaries. + +Teams polling bindings extract source `value` arrays; webhook bindings do not. +All support raw object arrays, JSON text or bytes, and Poll MANY collections. +An object whose only key is `body` is treated as the callback envelope; a +notification's own body field remains data when other record fields are present. +Malformed batches raise `ValueError` rather than dropping members. + +### Supported Dataverse/CDS trigger types + +These bindings target the `commondataservice` connector. They do not register +triggers, replace connection setup, or provide an alias for the distinct +`commondataserviceforapps` connector. + +| Trigger operations | Extension binding type | Returned SDK models | +| --- | --- | --- | +| `SubscribeWebhookTrigger` | `RowChangeNotification` | `list[azure.connectors.commondataservice.ObjectEntity]` | +| `BusinessEventsTrigger` | `ActionEventNotification` | `list[azure.connectors.commondataservice.ObjectEntity]` | +| `GetOnDeletedItems_V2`, `GetOnNewItems_V2`, `GetOnUpdatedItems_V2` (legacy polling) | `Item` | `list[azure.connectors.commondataservice.Item]` | + +`RowChangeNotification` and `ActionEventNotification` are extension binding +names, not new generated SDK models. Both preserve each complete webhook +record in `ObjectEntity.additional_properties`. Read table columns, action +parameters, and event metadata by their exact JSON keys; no fixed row or action +schema is inferred. Fields named `value`, `schema`, `dynamicProperties`, or +`additional_properties` remain data, not envelopes to flatten or unwrap. + +The generated `WebhookNotificationContent` classes expose a `schema` field for +dynamic schema metadata. They are not the return contract for these bindings; +converting actual notifications into those wrappers would discard their fields. + +Legacy `Item` preserves columns in `dynamic_properties`, including nulls, +booleans, numbers, arrays, nested objects, and OData keys. A record consisting +only of `dynamicProperties` or `dynamic_properties` supplies that bag directly. +Other records retain their entire top-level structure. Polling bindings extract +`value` only when the other envelope fields are OData metadata; webhook bindings +never extract `value`. + +All three bindings support JSON objects or arrays, callback envelopes containing +only `body`, and transport collections. A record's own `body` field stays data +when other record fields are present. Malformed batch members raise `ValueError`. +The legacy operations are deprecated and exist here for compatibility, not as a +recommendation for new registrations. Dynamic callback tests are synthetic; +live Dataverse delivery has not been tested. + +### Supported Planner trigger types + +| Trigger operations | Binding item type | +| --- | --- | +| `OnCompleteTask_V3`, `OnNewTask_V3`, `OnTaskAssignedToMe_V2` | `planner.GetTaskResponseV2` | + +All three operations are polling triggers. The binding returns a list of +`azure.connectors.planner.GetTaskResponseV2` items. It extracts tasks from the +SDK's `ListTasksResponse` envelope without returning that envelope, fetching next +pages, or using the different V3 task model for action responses. + +The fourteen task fields retain the SDK's types. `applied_categories` becomes a +generated `AppliedCategories` model with all 25 nullable category flags. +`created_by` and assignment entries remain raw JSON dictionaries, preserving +nested fields and nulls. Dates stay strings, while false flags, zero counts, +and empty assignment arrays remain unchanged. + +The connector's `_assignments` array maps to `assignments` and takes precedence +over a simultaneous `assignments` field. The latter is accepted as a Python-field +fallback only when `_assignments` is absent. Native Microsoft Graph assignment +objects are not this connector's array contract; invalid assignment shapes raise +`ValueError` instead of being converted into a different model. + +Supported callback shapes include single task records, source arrays, callback +`body` envelopes, `value` batches with OData metadata, and Poll MANY collections. +Malformed batch members raise `ValueError` rather than being silently dropped. +An object containing only `body` is treated as a callback envelope; extra task +fields named `body` or `value` cannot hide the task's SDK fields. + +### Supported Salesforce trigger types + +| Trigger operations | Binding item type | +| --- | --- | +| `GetOnNewItems`, `GetOnUpdatedItems` | `salesforce.Item` | + +Both operations are polling triggers with a required `table` parameter. The +binding returns a list of `azure.connectors.salesforce.Item` models, not an +`ItemsList` envelope. It does not fetch continuation pages or register triggers. + +Each item's `dynamic_properties` preserves standard and custom fields with their +exact wire names, such as `Id` and `Custom__c`. Nested `attributes`, relationships, +arrays, nulls, false flags, zero values, and empty strings remain raw JSON. The +binding does not guess fixed fields or convert dynamic values into empty SDK +objects. A record containing only `dynamicProperties` or `dynamic_properties` +supplies that object or null as the bag. Records with other fields keep their +entire top-level structure without merging a similarly named field. + +Supported inputs include split-on records, source arrays, sole-`body` callback +envelopes, `value` batches with only OData metadata alongside them, and Poll MANY +collections. Records with other fields named `value` or `body` retain those fields. +Malformed batch members raise `ValueError` instead of being silently dropped. +Tests use synthetic callbacks; live Salesforce delivery remains unverified. + +### Supported Azure DevOps trigger types + +| Trigger operations | Binding item type | +| --- | --- | +| `OnBuildCompleted` | `visualstudioteamservices.BuildResult` | +| `OnGitPush` | `visualstudioteamservices.GitPush` | +| `OnGitPullCreated`, `OnGitPullClosed` | `visualstudioteamservices.GitPullRequest` | +| `OnTfvcCheckIn` | `visualstudioteamservices.TfvcChangeset` | +| `OnWorkItemAssignedV2`, `OnWorkItemClosedV2`, `OnWorkItemCreatedV2`, `OnWorkItemUpdatedV2` | `visualstudioteamservices.TriggerWorkItemResponse` | + +These are the nine GET polling operations in the referenced SDK's trigger +registry, not separate webhook subscription actions. Each binding returns a +list of generated SDK items rather than a `VstsList...` response envelope. +Build users, definitions, and `_links.web`; Git repositories, identities, ref +updates, reviewers, commits, and options; and TFVC author identities become +their corresponding generated SDK models. Dates and URLs keep SDK string types. + +`TriggerWorkItemResponse` exposes `id`, `rev`, `url`, and `fields`. Its `fields` +dictionary keeps exact dotted names such as `System.Title` and `Custom.Flag`, +nested JSON, nulls, false flags, zero values, and empty strings. The binding does +not invent a fixed work-item schema or use the different action-response model. +A missing or null `fields` value stays null; other non-object values raise +`ValueError`. + +All five bindings accept single records, arrays, sole-`body` callback envelopes, +OData `value` batches, and Poll MANY transport collections. They preserve order, +reject malformed batch members, and do not fetch continuation pages. Type +annotations do not register an operation or choose split-on behavior. Legacy +work-item operations without `V2` are not part of this inventory. + +Tests use synthetic callbacks, not live Azure DevOps delivery. + +### Supported Dataflows trigger type + +| Trigger operation | Binding item type | +| --- | --- | +| `OnRefreshComplete` | `dataflows.RefreshModel` | + +This Power Query Dataflows operation is a GET polling trigger. Its callback is +one `azure.connectors.dataflows.RefreshModel` record, not a `value` collection. +The binding returns a list containing that SDK record, following the extension's +list-returning convention. It does not use `DataflowModel`, which describes the +separate refresh action response, or fetch related dataflows or continuation pages. + +The six nullable string fields are `dataflow_id`, `dataflow_name`, `refresh_type`, +`start_time`, `end_time`, and `status`. The binding maps the SDK wire names such +as `dataflowId` and `refreshType`. Dates remain strings, nulls and empty strings +stay distinct, and `Failed` or `Cancelled` statuses remain callback data rather +than raising conversion errors. No fixed status enum is imposed. + +Supported inputs include a direct record, a sole-`body` callback envelope, +JSON text or bytes, arrays of transport records, and Functions MANY collections. +Transport arrays do not change the connector's single-record source schema. +Malformed members and value-only collection envelopes raise `ValueError` rather +than producing partial results or empty SDK models. + + ### Cardinality -Office365 Connector Triggers support both single and batch message processing: +All supported SDK bindings return lists for single-item callbacks +and source batches where the connector supports them. For example, use +`List[office365.GraphCalendarEventClientWithActionType]` for changed events. -* **Single message (cardinality=one)**: Annotate your function parameter as `ClientReceiveMessage` to receive one message per function invocation -* **Batch messages (cardinality=many)**: Annotate your function parameter as `List[ClientReceiveMessage]` to receive multiple messages in a single function invocation +Both direct binding annotations and `List[BindingType]` annotations are accepted; +the annotation does not select the return shape or configure trigger cardinality. +For Functions Poll MANY delivery, the bindings flatten transport events and +polling source batches into one list. Teams chat webhooks keep each notification +envelope's reference collection inside the generated model rather than flattening it. ## Examples @@ -77,7 +380,7 @@ def office365_trigger_batch(messages: List[office365.ClientReceiveMessage]): ## Troubleshooting ### General -Office365 Connector extension errors will be raised as `ValueError` exceptions with descriptive error messages. +Connector SDK binding errors are raised as `ValueError` exceptions with descriptive messages. ### Logging Enable verbose logging in your function app to see detailed information about message processing: @@ -90,7 +393,7 @@ logging.basicConfig(level=logging.INFO) ## Next steps ### More sample code -Get started with our [Office365 Connector samples](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-connectors/samples). +Get started with the [connector SDK binding samples](samples/README.md). ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_deserialization.py index e8296a5..b625bf5 100644 --- a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_deserialization.py +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/_deserialization.py @@ -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 @@ -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 @@ -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 @@ -122,6 +142,11 @@ 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) ): @@ -129,4 +154,9 @@ def parse_payload(data: Datum) -> list[dict[str, Any]]: 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.") + return [item for item in raw_items if isinstance(item, dict)] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/__init__.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/__init__.py new file mode 100644 index 0000000..821e873 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/__init__.py @@ -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", +] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/_deserialization.py new file mode 100644 index 0000000..56d4a9d --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/_deserialization.py @@ -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) + ] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/actionEventNotification.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/actionEventNotification.py new file mode 100644 index 0000000..0f57b29 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/actionEventNotification.py @@ -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) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/item.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/item.py new file mode 100644 index 0000000..1be17e3 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/item.py @@ -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) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/rowChangeNotification.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/rowChangeNotification.py new file mode 100644 index 0000000..3d2d70d --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/commondataservice/rowChangeNotification.py @@ -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) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/dataflows/__init__.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/dataflows/__init__.py new file mode 100644 index 0000000..96ea2db --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/dataflows/__init__.py @@ -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"] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/dataflows/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/dataflows/_deserialization.py new file mode 100644 index 0000000..e465e37 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/dataflows/_deserialization.py @@ -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)] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/dataflows/refreshModel.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/dataflows/refreshModel.py new file mode 100644 index 0000000..70aa17c --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/dataflows/refreshModel.py @@ -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) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/__init__.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/__init__.py index 5148c8b..9e9ddc6 100644 --- a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/__init__.py +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/__init__.py @@ -9,6 +9,9 @@ from .graphCalendarEventClientReceive import ( GraphCalendarEventClientReceive ) +from .graphCalendarEventClientWithActionType import ( + GraphCalendarEventClientWithActionType +) from ..connectorConverter import ConnectorConverter __all__ = [ @@ -16,5 +19,6 @@ "GraphClientReceiveMessage", "GraphCalendarEventListWithActionType", "GraphCalendarEventClientReceive", + "GraphCalendarEventClientWithActionType", "ConnectorConverter", ] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/_deserialization.py index d8e42e2..e10c490 100644 --- a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/_deserialization.py +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/_deserialization.py @@ -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, + ) ] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventClientWithActionType.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventClientWithActionType.py new file mode 100644 index 0000000..4d22275 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/office365/graphCalendarEventClientWithActionType.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind changed calendar events using the generated SDK item type.""" + +from functools import partial + +import azure.connectors.office365 as office365 + +from .._sdk_type import ConnectorSdkType +from ._deserialization import ( + deserialize_graph_calendar_events_with_action_type, +) + + +class GraphCalendarEventClientWithActionType( + ConnectorSdkType[list[office365.GraphCalendarEventClientWithActionType]], + office365.GraphCalendarEventClientWithActionType, +): + """Bind calendar changes as a list of SDK items, including split-on events.""" + + _deserialize = staticmethod( + partial( + deserialize_graph_calendar_events_with_action_type, + reject_malformed_members=True, + ) + ) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/onedriveforbusiness/__init__.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/onedriveforbusiness/__init__.py new file mode 100644 index 0000000..e808cbe --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/onedriveforbusiness/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""SDK metadata bindings for OneDrive for Business connector triggers.""" + +from ..connectorConverter import ConnectorConverter +from .blobMetadata import BlobMetadata + +__all__ = ["BlobMetadata", "ConnectorConverter"] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/onedriveforbusiness/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/onedriveforbusiness/_deserialization.py new file mode 100644 index 0000000..010dbd1 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/onedriveforbusiness/_deserialization.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Deserialize file metadata without treating binary content as a model.""" + +from __future__ import annotations + +import json +from typing import Any + +from azure.connectors.onedriveforbusiness import BlobMetadata +from azurefunctions.extensions.base import Datum + +from .._deserialization import deserialize_model + +_BLOB_METADATA_ALIASES = { + "id": "Id", + "name": "Name", + "name_no_ext": "NameNoExt", + "display_name": "DisplayName", + "path": "Path", + "last_modified": "LastModified", + "size": "Size", + "media_type": "MediaType", + "is_folder": "IsFolder", + "e_tag": "ETag", + "file_locator": "FileLocator", + "last_modified_by": "LastModifiedBy", +} + + +def _parse_metadata_payload(data: Datum) -> list[dict[str, Any]]: + """Normalize direct metadata, source arrays, and Poll MANY collections.""" + if data.type in ("collection_string", "collection_bytes"): + return [ + item + for event in data.python_value + for item in _parse_metadata_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): + if "value" in payload and all( + name in ("value", "nextLink", "next_link") + or name.startswith(("@odata.", "odata.")) + for name in payload + ): + payload = payload["value"] + if payload is None: + return [] + if not isinstance(payload, list): + raise ValueError("OneDrive metadata batch value must be a JSON array.") + else: + payload = [payload] + + if not isinstance(payload, list): + raise ValueError("OneDrive metadata must contain a JSON object or array.") + if any(not isinstance(item, dict) for item in payload): + raise ValueError("Each OneDrive metadata batch item must be a JSON object.") + if any("$content" in item or "$content-type" in item for item in payload): + raise ValueError("OneDrive file content cannot be bound to BlobMetadata.") + + return payload + + +def deserialize_blob_metadata(data: Datum) -> list[BlobMetadata]: + """Read metadata wire names, including SDK versions without field metadata.""" + return [ + deserialize_model(BlobMetadata, record, aliases=_BLOB_METADATA_ALIASES) + for record in _parse_metadata_payload(data) + ] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/onedriveforbusiness/blobMetadata.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/onedriveforbusiness/blobMetadata.py new file mode 100644 index 0000000..629459e --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/onedriveforbusiness/blobMetadata.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind OneDrive for Business file-property trigger items.""" + +from azure.connectors.onedriveforbusiness import BlobMetadata as AzureBlobMetadata + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_blob_metadata + + +class BlobMetadata(ConnectorSdkType[list[AzureBlobMetadata]], AzureBlobMetadata): + """Bind file properties as a list of generated OneDrive SDK models.""" + + _deserialize = staticmethod(deserialize_blob_metadata) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/planner/__init__.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/planner/__init__.py new file mode 100644 index 0000000..bedd451 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/planner/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""SDK task bindings for Planner connector polling triggers.""" + +from ..connectorConverter import ConnectorConverter +from .getTaskResponseV2 import GetTaskResponseV2 + +__all__ = ["ConnectorConverter", "GetTaskResponseV2"] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/planner/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/planner/_deserialization.py new file mode 100644 index 0000000..559f59d --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/planner/_deserialization.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Deserialize Planner task callbacks without changing the SDK model contract.""" + +from __future__ import annotations + +import json +from typing import Any + +from azure.connectors.planner import GetTaskResponseV2 +from azurefunctions.extensions.base import Datum + +from .._deserialization import deserialize_model + + +def _parse_payload(data: Datum) -> list[dict[str, Any]]: + """Normalize split records, polling batches, and transport collections.""" + 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): + if "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("Planner batch value must contain a JSON array.") + else: + payload = [payload] + + if not isinstance(payload, list): + raise ValueError("Planner payload must contain a JSON object or array.") + if any(not isinstance(item, dict) for item in payload): + raise ValueError("Each Planner batch item must contain a JSON object.") + + return payload + + +def _deserialize_assignments(value: Any) -> list[dict[str, Any]] | None: + """Keep the connector assignment array distinct from native Graph objects.""" + if value is not None and ( + not isinstance(value, list) + or any(not isinstance(item, dict) for item in value) + ): + raise ValueError("Planner assignments must be an array of objects or null.") + return value + + +def deserialize_tasks(data: Datum) -> list[GetTaskResponseV2]: + """Read SDK task fields, preferring the connector's _assignments wire name.""" + return [ + deserialize_model( + GetTaskResponseV2, record, + aliases={"assignments": "_assignments"}, + converters={"assignments": _deserialize_assignments}, + ) + for record in _parse_payload(data) + ] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/planner/getTaskResponseV2.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/planner/getTaskResponseV2.py new file mode 100644 index 0000000..d121344 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/planner/getTaskResponseV2.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Planner trigger tasks using the generated SDK item model.""" + +from azure.connectors.planner import GetTaskResponseV2 as AzureGetTaskResponseV2 + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_tasks + + +class GetTaskResponseV2( + ConnectorSdkType[list[AzureGetTaskResponseV2]], AzureGetTaskResponseV2, +): + """Return Planner SDK tasks with typed categories and raw assignment entries.""" + + _deserialize = staticmethod(deserialize_tasks) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/salesforce/__init__.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/salesforce/__init__.py new file mode 100644 index 0000000..d2449fd --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/salesforce/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""SDK item bindings for Salesforce connector polling triggers.""" + +from ..connectorConverter import ConnectorConverter +from .item import Item + +__all__ = ["ConnectorConverter", "Item"] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/salesforce/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/salesforce/_deserialization.py new file mode 100644 index 0000000..614ea8d --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/salesforce/_deserialization.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Preserve Salesforce standard and custom fields in SDK item property bags.""" + +from __future__ import annotations + +import json +from typing import Any + +from azure.connectors.salesforce import Item +from azurefunctions.extensions.base import Datum + + +def _parse_payload(data: Datum) -> list[dict[str, Any]]: + """Normalize polling records, source batches, and transport collections.""" + 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): + if "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("Salesforce batch value must contain a JSON array.") + else: + payload = [payload] + + if not isinstance(payload, list): + raise ValueError("Salesforce payload must contain a JSON object or array.") + if any(not isinstance(item, dict) for item in payload): + raise ValueError("Each Salesforce batch item must contain a JSON object.") + + return payload + + +def deserialize_items(data: Datum) -> list[Item]: + """Keep selected-object fields intact instead of guessing a fixed schema.""" + result = [] + for record in _parse_payload(data): + if set(record) in ({"dynamicProperties"}, {"dynamic_properties"}): + properties = next(iter(record.values())) + if properties is not None and not isinstance(properties, dict): + raise ValueError( + "Salesforce 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 diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/salesforce/item.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/salesforce/item.py new file mode 100644 index 0000000..6cb6e12 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/salesforce/item.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Salesforce record callbacks using the generated SDK item model.""" + +from azure.connectors.salesforce import Item as AzureItem + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_items + + +class Item(ConnectorSdkType[list[AzureItem]], AzureItem): + """Return Salesforce SDK items with raw JSON in dynamic_properties.""" + + _deserialize = staticmethod(deserialize_items) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/__init__.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/__init__.py new file mode 100644 index 0000000..68cef23 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""SDK item bindings for SharePoint Online connector triggers.""" + +from ..connectorConverter import ConnectorConverter +from .deletedItem import DeletedItem +from .item import Item + +__all__ = ["ConnectorConverter", "DeletedItem", "Item"] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/_deserialization.py new file mode 100644 index 0000000..217c681 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/_deserialization.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Deserialize SharePoint callbacks without losing dynamic column values.""" + +from __future__ import annotations + +import json +from typing import Any + +from azure.connectors.sharepointonline import DeletedItem, Item +from azurefunctions.extensions.base import Datum + +from .._deserialization import deserialize_model + + +def _parse_sharepoint_payload(data: Datum) -> list[dict[str, Any]]: + """Normalize callbacks, source arrays, and Poll MANY transport collections.""" + if data.type in ("collection_string", "collection_bytes"): + return [ + item + for event in data.python_value + for item in _parse_sharepoint_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): + if "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("SharePoint batch value must contain a JSON array.") + else: + payload = [payload] + + if not isinstance(payload, list): + raise ValueError("SharePoint payload must contain a JSON object or array.") + if any(not isinstance(item, dict) for item in payload): + raise ValueError("Each SharePoint batch item must contain a JSON object.") + + return payload + + +def deserialize_items(data: Datum) -> list[Item]: + """Keep dynamic columns as raw JSON in the generated SDK property bag.""" + result = [] + for record in _parse_sharepoint_payload(data): + if set(record) in ({"dynamicProperties"}, {"dynamic_properties"}): + properties = next(iter(record.values())) + if properties is not None and not isinstance(properties, dict): + raise ValueError( + "SharePoint 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_deleted_items(data: Datum) -> list[DeletedItem]: + """Read deletion fields using the generated SDK wire-name metadata.""" + return [ + deserialize_model(DeletedItem, record) + for record in _parse_sharepoint_payload(data) + ] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/deletedItem.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/deletedItem.py new file mode 100644 index 0000000..ee3038c --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/deletedItem.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind SharePoint item and file deletion trigger metadata.""" + +from azure.connectors.sharepointonline import DeletedItem as AzureDeletedItem + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_deleted_items + + +class DeletedItem(ConnectorSdkType[list[AzureDeletedItem]], AzureDeletedItem): + """Bind deleted items as a list of generated SDK models.""" + + _deserialize = staticmethod(deserialize_deleted_items) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/item.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/item.py new file mode 100644 index 0000000..4a245c8 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/sharepointonline/item.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind SharePoint list and file-property trigger items.""" + +from azure.connectors.sharepointonline import Item as AzureItem + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_items + + +class Item(ConnectorSdkType[list[AzureItem]], AzureItem): + """Bind SDK items with raw JSON columns in dynamic_properties.""" + + _deserialize = staticmethod(deserialize_items) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/__init__.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/__init__.py new file mode 100644 index 0000000..3a93673 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/__init__.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""SDK payload bindings for Teams polling and webhook connector triggers.""" + +from ..connectorConverter import ConnectorConverter +from .chatMessage import ChatMessage +from .chatMessageWebhookResponseSchema import ChatMessageWebhookResponseSchema +from .dynamicAtMentionWebhookTriggerResponseSchema import ( + DynamicAtMentionWebhookTriggerResponseSchema, +) +from .dynamicKeywordWebhookTriggerResponseSchema import ( + DynamicKeywordWebhookTriggerResponseSchema, +) +from .dynamicNewMessageWebhookTriggerResponseSchema import ( + DynamicNewMessageWebhookTriggerResponseSchema, +) +from .messageReactionWebhookResponseSchema import MessageReactionWebhookResponseSchema +from .onGroupMemberChangeResponse import OnGroupMemberChangeResponse +from .recordingWebhookResponseSchema import RecordingWebhookResponseSchema +from .transcriptWebhookResponseSchema import TranscriptWebhookResponseSchema + +__all__ = [ + "ChatMessage", + "ChatMessageWebhookResponseSchema", + "ConnectorConverter", + "DynamicAtMentionWebhookTriggerResponseSchema", + "DynamicKeywordWebhookTriggerResponseSchema", + "DynamicNewMessageWebhookTriggerResponseSchema", + "MessageReactionWebhookResponseSchema", + "OnGroupMemberChangeResponse", + "RecordingWebhookResponseSchema", + "TranscriptWebhookResponseSchema", +] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/_deserialization.py new file mode 100644 index 0000000..7a05a84 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/_deserialization.py @@ -0,0 +1,156 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Normalize Teams polling batches separately from webhook notification bodies.""" + +from __future__ import annotations + +import json +from typing import Any + +import azure.connectors.teams as models +from azurefunctions.extensions.base import Datum + +from .._deserialization import deserialize_model + + +def _parse_payload(data: Datum, *, unwrap_value: bool) -> list[dict[str, Any]]: + """Keep webhook value fields intact while normalizing polling batches.""" + 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("Teams polling batch value must be a JSON array.") + else: + payload = [payload] + + if not isinstance(payload, list): + raise ValueError("Teams payload must contain a JSON object or array.") + if any(not isinstance(item, dict) for item in payload): + raise ValueError("Each Teams payload item must contain a JSON object.") + + return payload + + +def deserialize_chat_messages(data: Datum) -> list[models.ChatMessage]: + """Deserialize polling items while preserving each message's own body.""" + return [ + deserialize_model(models.ChatMessage, record) + for record in _parse_payload(data, unwrap_value=True) + ] + + +def deserialize_membership_changes(data: Datum) -> list[dict[str, Any]]: + """Keep membership changes as dictionaries, matching the SDK list alias.""" + return [dict(record) for record in _parse_payload(data, unwrap_value=True)] + + +def deserialize_chat_webhooks( + data: Datum, +) -> list[models.ChatMessageWebhookResponseSchema]: + """Preserve notification reference arrays inside their SDK envelopes.""" + result = [] + for record in _parse_payload(data, unwrap_value=False): + references = record.get("value") + if references is not None and ( + not isinstance(references, list) + or any(not isinstance(reference, dict) for reference in references) + ): + raise ValueError( + "Teams chat webhook value must be an array of objects or null." + ) + result.append(deserialize_model( + models.ChatMessageWebhookResponseSchema, record, + )) + + return result + + +def deserialize_new_messages( + data: Datum, +) -> list[models.DynamicNewMessageWebhookTriggerResponseSchema]: + """Retain every field of dynamic new-message notifications.""" + return [ + models.DynamicNewMessageWebhookTriggerResponseSchema( + additional_properties=dict(record), + ) + for record in _parse_payload(data, unwrap_value=False) + ] + + +def deserialize_mentions( + data: Datum, +) -> list[models.DynamicAtMentionWebhookTriggerResponseSchema]: + """Retain every field of dynamic mention notifications.""" + return [ + models.DynamicAtMentionWebhookTriggerResponseSchema( + additional_properties=dict(record), + ) + for record in _parse_payload(data, unwrap_value=False) + ] + + +def deserialize_keywords( + data: Datum, +) -> list[models.DynamicKeywordWebhookTriggerResponseSchema]: + """Retain every field of dynamic keyword notifications.""" + return [ + models.DynamicKeywordWebhookTriggerResponseSchema( + additional_properties=dict(record), + ) + for record in _parse_payload(data, unwrap_value=False) + ] + + +def deserialize_reactions( + data: Datum, +) -> list[models.MessageReactionWebhookResponseSchema]: + """Read reaction notification fields using SDK wire-name metadata.""" + return [ + deserialize_model(models.MessageReactionWebhookResponseSchema, record) + for record in _parse_payload(data, unwrap_value=False) + ] + + +def deserialize_recordings(data: Datum) -> list[models.RecordingWebhookResponseSchema]: + """Read recording metadata, including OData IDs and nested organizer identities.""" + return [ + deserialize_model(models.RecordingWebhookResponseSchema, record) + for record in _parse_payload(data, unwrap_value=False) + ] + + +def deserialize_transcripts( + data: Datum, +) -> list[models.TranscriptWebhookResponseSchema]: + """Read transcript metadata, including OData IDs and nested organizer identities.""" + return [ + deserialize_model(models.TranscriptWebhookResponseSchema, record) + for record in _parse_payload(data, unwrap_value=False) + ] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/chatMessage.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/chatMessage.py new file mode 100644 index 0000000..fb44ddc --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/chatMessage.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Teams channel polling messages.""" + +import azure.connectors.teams as models + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_chat_messages + + +class ChatMessage(ConnectorSdkType[list[models.ChatMessage]], models.ChatMessage): + """Return generated chat message items, retaining their nested JSON fields.""" + + _deserialize = staticmethod(deserialize_chat_messages) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/chatMessageWebhookResponseSchema.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/chatMessageWebhookResponseSchema.py new file mode 100644 index 0000000..5c1e803 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/chatMessageWebhookResponseSchema.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind chat webhook notification envelopes, not full chat messages.""" + +import azure.connectors.teams as models + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_chat_webhooks + + +class ChatMessageWebhookResponseSchema( + ConnectorSdkType[list[models.ChatMessageWebhookResponseSchema]], + models.ChatMessageWebhookResponseSchema, +): + """Return notification envelopes with their value collections intact.""" + + _deserialize = staticmethod(deserialize_chat_webhooks) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/dynamicAtMentionWebhookTriggerResponseSchema.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/dynamicAtMentionWebhookTriggerResponseSchema.py new file mode 100644 index 0000000..a9eca95 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/dynamicAtMentionWebhookTriggerResponseSchema.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind dynamic Teams mention notifications.""" + +import azure.connectors.teams as models + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_mentions + + +class DynamicAtMentionWebhookTriggerResponseSchema( + ConnectorSdkType[list[models.DynamicAtMentionWebhookTriggerResponseSchema]], + models.DynamicAtMentionWebhookTriggerResponseSchema, +): + """Return mention models with every JSON field in additional_properties.""" + + _deserialize = staticmethod(deserialize_mentions) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/dynamicKeywordWebhookTriggerResponseSchema.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/dynamicKeywordWebhookTriggerResponseSchema.py new file mode 100644 index 0000000..fa3dd95 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/dynamicKeywordWebhookTriggerResponseSchema.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind dynamic Teams keyword notifications.""" + +import azure.connectors.teams as models + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_keywords + + +class DynamicKeywordWebhookTriggerResponseSchema( + ConnectorSdkType[list[models.DynamicKeywordWebhookTriggerResponseSchema]], + models.DynamicKeywordWebhookTriggerResponseSchema, +): + """Return keyword models with every JSON field in additional_properties.""" + + _deserialize = staticmethod(deserialize_keywords) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/dynamicNewMessageWebhookTriggerResponseSchema.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/dynamicNewMessageWebhookTriggerResponseSchema.py new file mode 100644 index 0000000..9826c99 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/dynamicNewMessageWebhookTriggerResponseSchema.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind dynamic Teams new-message notifications.""" + +import azure.connectors.teams as models + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_new_messages + + +class DynamicNewMessageWebhookTriggerResponseSchema( + ConnectorSdkType[list[models.DynamicNewMessageWebhookTriggerResponseSchema]], + models.DynamicNewMessageWebhookTriggerResponseSchema, +): + """Return message models with every JSON field in additional_properties.""" + + _deserialize = staticmethod(deserialize_new_messages) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/messageReactionWebhookResponseSchema.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/messageReactionWebhookResponseSchema.py new file mode 100644 index 0000000..3223351 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/messageReactionWebhookResponseSchema.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Teams reaction webhook notification metadata.""" + +import azure.connectors.teams as models + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_reactions + + +class MessageReactionWebhookResponseSchema( + ConnectorSdkType[list[models.MessageReactionWebhookResponseSchema]], + models.MessageReactionWebhookResponseSchema, +): + """Return generated reaction notification models.""" + + _deserialize = staticmethod(deserialize_reactions) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/onGroupMemberChangeResponse.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/onGroupMemberChangeResponse.py new file mode 100644 index 0000000..a5d2af2 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/onGroupMemberChangeResponse.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Teams membership changes using the SDK's dictionary item contract.""" + +from typing import Any + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_membership_changes + + +class OnGroupMemberChangeResponse(ConnectorSdkType[list[dict[str, Any]]]): + """Return raw member dictionaries without inventing a fixed SDK item model.""" + + _deserialize = staticmethod(deserialize_membership_changes) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/recordingWebhookResponseSchema.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/recordingWebhookResponseSchema.py new file mode 100644 index 0000000..fb99928 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/recordingWebhookResponseSchema.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Teams recording-available notification metadata.""" + +import azure.connectors.teams as models + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_recordings + + +class RecordingWebhookResponseSchema( + ConnectorSdkType[list[models.RecordingWebhookResponseSchema]], + models.RecordingWebhookResponseSchema, +): + """Return recording metadata and content URLs without downloading content.""" + + _deserialize = staticmethod(deserialize_recordings) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/transcriptWebhookResponseSchema.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/transcriptWebhookResponseSchema.py new file mode 100644 index 0000000..ed349f3 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/teams/transcriptWebhookResponseSchema.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Teams transcript-available notification metadata.""" + +import azure.connectors.teams as models + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_transcripts + + +class TranscriptWebhookResponseSchema( + ConnectorSdkType[list[models.TranscriptWebhookResponseSchema]], + models.TranscriptWebhookResponseSchema, +): + """Return transcript metadata and content URLs without downloading content.""" + + _deserialize = staticmethod(deserialize_transcripts) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/__init__.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/__init__.py new file mode 100644 index 0000000..8134018 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""SDK item bindings for Azure DevOps (VSTS) connector polling triggers.""" + +from ..connectorConverter import ConnectorConverter +from .buildResult import BuildResult +from .gitPullRequest import GitPullRequest +from .gitPush import GitPush +from .tfvcChangeset import TfvcChangeset +from .triggerWorkItemResponse import TriggerWorkItemResponse + +__all__ = [ + "ConnectorConverter", + "BuildResult", + "GitPullRequest", + "GitPush", + "TfvcChangeset", + "TriggerWorkItemResponse", +] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/_deserialization.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/_deserialization.py new file mode 100644 index 0000000..69c6050 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/_deserialization.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Normalize Azure DevOps polling callbacks into generated SDK item models.""" + +from __future__ import annotations + +import json +from typing import Any + +from azure.connectors.visualstudioteamservices import ( + BuildResult, GitPullRequest, GitPush, TfvcChangeset, TriggerWorkItemResponse, +) +from azurefunctions.extensions.base import Datum + +from .._deserialization import deserialize_model + + +def _parse_payload(data: Datum) -> list[dict[str, Any]]: + """Normalize source batches separately from Functions transport batches.""" + 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): + if "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("Azure DevOps batch value must contain a JSON array.") + else: + payload = [payload] + + if not isinstance(payload, list): + raise ValueError("Azure DevOps payload must contain a JSON object or array.") + if any(not isinstance(item, dict) for item in payload): + raise ValueError("Each Azure DevOps batch item must contain a JSON object.") + + return payload + + +def deserialize_builds(data: Datum) -> list[BuildResult]: + """Use the _links wire field before a Python-field fallback.""" + return [deserialize_model(BuildResult, item, aliases={"links": "_links"}) + for item in _parse_payload(data)] + + +def deserialize_pushes(data: Datum) -> list[GitPush]: + """Build SDK repositories, identities, and ref updates for Git pushes.""" + return [deserialize_model(GitPush, item) for item in _parse_payload(data)] + + +def deserialize_pull_requests(data: Datum) -> list[GitPullRequest]: + """Build SDK pull requests and their nested reviewer and option models.""" + return [deserialize_model(GitPullRequest, item) for item in _parse_payload(data)] + + +def deserialize_changesets(data: Datum) -> list[TfvcChangeset]: + """Build SDK TFVC changesets with typed author identities.""" + return [deserialize_model(TfvcChangeset, item) for item in _parse_payload(data)] + + +def deserialize_work_items(data: Datum) -> list[TriggerWorkItemResponse]: + """Keep the generated fields bag as raw JSON, without a fixed column schema.""" + items = _parse_payload(data) + if any(item.get("fields") is not None and not isinstance(item["fields"], dict) + for item in items): + raise ValueError("Azure DevOps work-item fields must be an object or null.") + return [deserialize_model(TriggerWorkItemResponse, item) for item in items] diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/buildResult.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/buildResult.py new file mode 100644 index 0000000..27e9ffc --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/buildResult.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Azure DevOps build callbacks using the generated SDK item model.""" + +from azure.connectors.visualstudioteamservices import BuildResult as AzureBuildResult + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_builds + + +class BuildResult(ConnectorSdkType[list[AzureBuildResult]], AzureBuildResult): + """Return SDK build items for OnBuildCompleted polling callbacks.""" + + _deserialize = staticmethod(deserialize_builds) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/gitPullRequest.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/gitPullRequest.py new file mode 100644 index 0000000..af9358d --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/gitPullRequest.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Azure DevOps pull requests using the generated SDK item model.""" + +from azure.connectors.visualstudioteamservices import ( + GitPullRequest as AzureGitPullRequest, +) + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_pull_requests + + +class GitPullRequest(ConnectorSdkType[list[AzureGitPullRequest]], AzureGitPullRequest): + """Return SDK items for pull-request-created and closed polling callbacks.""" + + _deserialize = staticmethod(deserialize_pull_requests) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/gitPush.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/gitPush.py new file mode 100644 index 0000000..e1428b9 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/gitPush.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Azure DevOps Git pushes using the generated SDK item model.""" + +from azure.connectors.visualstudioteamservices import GitPush as AzureGitPush + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_pushes + + +class GitPush(ConnectorSdkType[list[AzureGitPush]], AzureGitPush): + """Return SDK Git push items for OnGitPush polling callbacks.""" + + _deserialize = staticmethod(deserialize_pushes) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/tfvcChangeset.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/tfvcChangeset.py new file mode 100644 index 0000000..13eb980 --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/tfvcChangeset.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Azure DevOps TFVC changesets using the generated SDK item model.""" + +from azure.connectors.visualstudioteamservices import ( + TfvcChangeset as AzureTfvcChangeset, +) + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_changesets + + +class TfvcChangeset(ConnectorSdkType[list[AzureTfvcChangeset]], AzureTfvcChangeset): + """Return SDK TFVC changeset items for OnTfvcCheckIn polling callbacks.""" + + _deserialize = staticmethod(deserialize_changesets) diff --git a/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/triggerWorkItemResponse.py b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/triggerWorkItemResponse.py new file mode 100644 index 0000000..2adb73f --- /dev/null +++ b/azurefunctions-extensions-connectors/azurefunctions/extensions/connectors/visualstudioteamservices/triggerWorkItemResponse.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Bind Azure DevOps work-item callbacks without losing custom fields.""" + +from azure.connectors.visualstudioteamservices import ( + TriggerWorkItemResponse as AzureTriggerWorkItemResponse, +) + +from .._sdk_type import ConnectorSdkType +from ._deserialization import deserialize_work_items + + +class TriggerWorkItemResponse( + ConnectorSdkType[list[AzureTriggerWorkItemResponse]], AzureTriggerWorkItemResponse, +): + """Return SDK work-item records with raw JSON dictionaries in fields.""" + + _deserialize = staticmethod(deserialize_work_items) diff --git a/azurefunctions-extensions-connectors/pyproject.toml b/azurefunctions-extensions-connectors/pyproject.toml index cc5a01c..a8bdf17 100644 --- a/azurefunctions-extensions-connectors/pyproject.toml +++ b/azurefunctions-extensions-connectors/pyproject.toml @@ -7,18 +7,18 @@ name = "azurefunctions-extensions-connectors" dynamic = ["version"] requires-python = ">=3.10" authors = [{ name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com"}] -description = "Office365 Connector Python worker extension for Azure Functions." +description = "Connector SDK type bindings for Azure Functions." readme = "README.md" license = {text = "MIT License"} classifiers= [ 'License :: OSI Approved :: MIT License', 'Intended Audience :: Developers', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.15', 'Operating System :: Microsoft :: Windows', 'Operating System :: POSIX', 'Operating System :: MacOS :: MacOS X', @@ -27,7 +27,7 @@ classifiers= [ ] dependencies = [ 'azurefunctions-extensions-base>=1.3.0b1', - 'azure-connectors>=0.5.0b1' + 'azure-connectors>=0.6.0b1' ] [project.optional-dependencies] diff --git a/azurefunctions-extensions-connectors/samples/README.md b/azurefunctions-extensions-connectors/samples/README.md index 55c92c5..321418f 100644 --- a/azurefunctions-extensions-connectors/samples/README.md +++ b/azurefunctions-extensions-connectors/samples/README.md @@ -1,12 +1,14 @@ -# Office365 Connector Samples +# Connector SDK Binding Samples -This folder contains samples demonstrating how to use the `azurefunctions-extensions-connectors` extension for Office365. +This folder shows how to use `azurefunctions-extensions-connectors` with +Office 365, SharePoint Online, OneDrive for Business, Microsoft Teams, Dataverse/CDS, +Planner, Salesforce, Azure DevOps, and Power Query Dataflows. ## Samples **Setup:** -1. Create an Office365 connector using the `connection-setup` skill -2. Register the Office365 trigger using the `trigger-registration` skill +1. Create a connection for the selected connector using the `connection-setup` skill +2. Register the selected trigger using the `trigger-registration` skill 3. Install dependencies: `pip install -r requirements.txt` 4. Run the function app: `func start` @@ -25,7 +27,11 @@ Demonstrates how to handle the "On Flagged Email" action. The GraphClientReceive - Batch message processing - splitOn disabled ### office365_samples_on_calendar_event -Demonstrates how to handle the "When an event is added, updated or deleted" action. The GraphCalendarEventListWithActionType type is the rich payload returned when a calendar event is added, updated, or deleted. +Demonstrates `CalendarGetOnChangedItemsV3` ("When an event is added, updated or +deleted"). Annotate handlers with `List[office365.GraphCalendarEventClientWithActionType]` +to receive SDK event items with `action_type`, `is_added`, and `is_updated` fields. +The existing `GraphCalendarEventListWithActionType` binding remains supported and +returns the same list of SDK items. **Features:** - Single event processing - splitOn enabled @@ -41,7 +47,197 @@ Demonstrates how to handle calendar event creation and modification actions. The - Single event processing - splitOn enabled - Batch event processing - splitOn disabled +## SharePoint Online samples + +These bindings are new in this checkout. Until a package containing them is +published, install the extension from the repository into the sample environment +instead of relying on the released package alone. Each sample includes the same +experimental connector extension-bundle configuration as the Office 365 samples. +Start Azurite for the development-storage setting before running locally. + +### sharepointonline_samples_on_new_item + +Demonstrates `GetOnNewItems` with `List[sharepointonline.Item]`. The same type +works for the other list-item and file-property operations. +Read custom columns through `item.dynamic_properties`; values retain their JSON +types and exact field names. + +The sample includes separate split-on and batch handlers. Configure the trigger +to deliver to the handler you want to test; the type annotation does not set the +trigger's split-on behavior. + +### sharepointonline_samples_on_deleted_item + +Demonstrates `GetOnDeletedItems` with `List[sharepointonline.DeletedItem]` and +typed deletion fields such as `id` and `is_folder`. The same binding works for +`GetOnDeletedFileItems`. The sample includes split-on and batch handlers. + +## OneDrive for Business sample + +### onedriveforbusiness_samples_on_new_file_properties + +Demonstrates `OnNewFilesV2` ("When a file is created (properties only)") with +`List[onedrive.BlobMetadata]`, imported from +`azurefunctions.extensions.connectors.onedriveforbusiness`. The same binding +works for `OnUpdatedFilesV2` ("When a file is modified (properties only)"). +Each element is a generated OneDrive for Business SDK model with file metadata +such as `name`, `size`, `is_folder`, and `e_tag`. + +The sample includes separate split-on and batch handlers. Configure the trigger +to deliver to the handler you want to test. The binding accepts both the source +array and a single record, returning a list in either case. Do not register the +singular `OnNewFileV2` or `OnUpdatedFileV2` content operations for these handlers. + +Install the extension from this checkout until the new binding is published, +and start Azurite for the included development-storage setting. The sample uses +the same experimental connector extension bundle as the other samples. + +## Microsoft Teams samples + +Install the extension from this checkout until the new Teams bindings are +published. Start Azurite before using the included development-storage settings. +Register the selected operation with the matching handler; annotations do not +configure split-on behavior or choose a connector operation. + +### teams_samples_polling + +Contains channel-message handlers using `List[teams.ChatMessage]` and membership +handlers using `teams.OnGroupMemberChangeResponse`, which returns the SDK's list +of member dictionaries. The four handlers cover `OnNewChannelMessage`, +`OnNewChannelMessageMentioningMe`, `OnGroupMembershipAdd`, and +`OnGroupMembershipRemoval`. + +### teams_samples_webhooks + +Contains one handler for each of the seven Teams webhook operations. Each returns +a list of SDK notification models. Chat notifications retain their `value` +reference arrays; dynamic notifications expose raw JSON through +`additional_properties`. Recording and transcript handlers receive metadata and +content URLs rather than downloaded files. + +These examples log counts rather than message content, identifiers, or URLs. +The fixtures and local checks use synthetic callbacks, not captured live events. + +## Dataverse (CDS) samples + +These samples target the `commondataservice` connector, not the separate +`commondataserviceforapps` connector. Install the extension from this checkout +until the new bindings are published, and start Azurite for local development +storage. Register each operation separately with its matching handler. + +### commondataservice_samples_webhooks + +`SubscribeWebhookTrigger` uses `List[cds.RowChangeNotification]` and +`BusinessEventsTrigger` uses `List[cds.ActionEventNotification]`, imported from +`azurefunctions.extensions.connectors.commondataservice`. The handler receives +generated SDK `ObjectEntity` records; `additional_properties` holds the complete +row-change or action-event JSON, including custom columns and event metadata. +The binding names do not imply a fixed SDK notification schema. These handlers +log field counts rather than customer data. + +### commondataservice_samples_legacy_polling + +Shows compatibility handlers for existing `GetOnNewItems_V2`, +`GetOnUpdatedItems_V2`, and `GetOnDeletedItems_V2` registrations. All use +`List[cds.Item]`, with raw columns in `dynamic_properties`. The operations are +deprecated; use the supported webhook operations for new registrations where +available. Both split-on records and source batches return lists. + +Tests use synthetic dynamic callbacks and do not prove live Dataverse delivery. +Connector trigger registration and authorization remain separate setup steps. + +## Planner sample + +### planner_samples_tasks + +Uses `List[planner.GetTaskResponseV2]`, imported from +`azurefunctions.extensions.connectors.planner`. The four handlers demonstrate +split-on and batch callbacks for `OnNewTask_V3`, plus `OnCompleteTask_V3` and +`OnTaskAssignedToMe_V2`. Every handler receives a list of generated SDK task items. + +The handlers show scalar task fields, typed `applied_categories`, and the raw +`assignments` list produced from the connector's `_assignments` wire field. +They log counts and flags rather than task titles, identifiers, or user details. +Select the intended handler when registering the trigger; annotations do not set +the operation or split-on behavior. + +Install the extension from this checkout until the new binding is published, +and start Azurite for the sample's development-storage setting. The sample uses +the same experimental connector extension bundle as the other examples. Tests +and local sample checks use synthetic payloads; live Planner delivery is unverified. + +## Salesforce sample + +### salesforce_samples_records + +Uses `List[salesforce.Item]`, imported from +`azurefunctions.extensions.connectors.salesforce`. The three handlers demonstrate +split-on and batch callbacks for `GetOnNewItems`, plus `GetOnUpdatedItems`. +Each receives a list of generated SDK items with standard and custom fields in +`dynamic_properties`, not an `ItemsList` envelope. Read fields by their exact +wire names, such as `record.dynamic_properties["Id"]` and +`record.dynamic_properties["Custom__c"]`, checking for a null property bag first. + +The handlers log record and field counts rather than customer values. Select the +Salesforce object through the required `table` parameter when registering each +trigger, and point it at the intended handler. The annotation does not select the +operation or configure split-on behavior. + +Install the extension from this checkout until the new binding is published, +and start Azurite for the sample's development-storage setting. The sample uses +the same experimental connector extension bundle as the other examples. Tests +and local sample checks use synthetic payloads; live Salesforce delivery is +unverified. Connection authorization and trigger registration remain separate +setup steps. + +## Azure DevOps sample + +### visualstudioteamservices_samples_polling + +Imports the five binding types from +`azurefunctions.extensions.connectors.visualstudioteamservices`. Ten handlers +cover all nine SDK polling triggers: build completion (split-on and batch), Git +pushes, pull-request creation and closure, TFVC check-ins, and V2 work-item +assignment, creation, closure, and updates. Every handler receives a list of SDK +items, not a `VstsList...` response envelope. + +Register each trigger separately with `account` and `project`. Git triggers also +require `repository`; `OnWorkItemAssignedV2` also requires `team` and +`wiql__System_AssignedTo`. Select the intended handler and configure split-on in +the registration, not through its annotation. These are polling operations, +not webhook subscription actions. + +Nested fixed-schema fields become SDK models. Work-item custom columns stay raw +JSON in `item.fields`, with exact names such as `System.Title`; check for null +before accessing them. The sample logs counts rather than titles, identities, +URLs, or field values. It includes the existing experimental connector bundle +and development storage settings; start Azurite before local use. Tests index +and execute the handlers with synthetic data; live delivery remains unverified. + +## Dataflows sample + +### dataflows_samples_refresh_completed + +Uses `List[dataflows.RefreshModel]`, imported from +`azurefunctions.extensions.connectors.dataflows`, for `OnRefreshComplete`. +The polling trigger returns one refresh object. The extension supplies a list +of generated SDK refresh records to the handler, not the `DataflowModel` action +response or a `value` collection. Transport arrays and MANY deliveries are also +accepted, but the annotation does not select the operation or change the source +cardinality. + +Register the trigger with `workspaceType`, `groupIdForOnRefreshComplete`, and +`dataflowIdForOnRefreshComplete`. Connection authorization and registration are +separate setup steps. The handler logs only completion and failure counts, not +dataflow names or identifiers. `Failed` and `Cancelled` refreshes are valid +callback records; dates remain nullable SDK strings. + +The sample keeps the existing experimental connector bundle and development +storage settings; start Azurite before local use. Tests index and execute the +handler with synthetic empty, successful, failed, and cancelled callbacks. +Live Dataflows delivery remains unverified. + ## Prerequisites -- Python 3.10 or later +-A [supported Python version for Azure Functions](https://learn.microsoft.com/en-us/azure/azure-functions/supported-languages?tabs=isolated-process%2Cv4&pivots=programming-language-python#languages-by-runtime-version) - Azure Functions Core Tools diff --git a/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/function_app.py b/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/function_app.py new file mode 100644 index 0000000..e39b2c9 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/function_app.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle deprecated CDS V2 polling callbacks for existing trigger registrations.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.commondataservice as cds + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="items") +def cds_legacy_row_added(items: List[cds.Item]) -> None: + """Handle an existing GetOnNewItems_V2 trigger registration.""" + for item in items: + logging.info("Received a legacy CDS row with %d fields.", + len(item.dynamic_properties or {})) + + +@app.connector_trigger(arg_name="items") +def cds_legacy_row_updated(items: List[cds.Item]) -> None: + """Handle an existing GetOnUpdatedItems_V2 trigger registration.""" + for item in items: + logging.info("Received a legacy CDS row update with %d fields.", + len(item.dynamic_properties or {})) + + +@app.connector_trigger(arg_name="items") +def cds_legacy_row_deleted(items: List[cds.Item]) -> None: + """Handle an existing GetOnDeletedItems_V2 trigger registration.""" + for item in items: + logging.info("Received a legacy CDS row deletion with %d fields.", + len(item.dynamic_properties or {})) diff --git a/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/host.json b/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/host.json new file mode 100644 index 0000000..40eb4be --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} diff --git a/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/local.settings.json b/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/local.settings.json new file mode 100644 index 0000000..a2ded91 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/requirements.txt b/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/requirements.txt new file mode 100644 index 0000000..b4ca486 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/commondataservice_samples_legacy_polling/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/function_app.py b/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/function_app.py new file mode 100644 index 0000000..c98e8e6 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/function_app.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle dynamic row and action callbacks from the commondataservice connector.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.commondataservice as cds + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="notifications") +def dataverse_row_changed(notifications: List[cds.RowChangeNotification]) -> None: + """Handle SubscribeWebhookTrigger using SDK ObjectEntity records.""" + for notification in notifications: + logging.info( + "Received a Dataverse row notification with %d fields.", + len(notification.additional_properties), + ) + + +@app.connector_trigger(arg_name="notifications") +def dataverse_action_performed( + notifications: List[cds.ActionEventNotification], +) -> None: + """Handle BusinessEventsTrigger without imposing a fixed action schema.""" + for notification in notifications: + logging.info( + "Received a Dataverse action notification with %d fields.", + len(notification.additional_properties), + ) diff --git a/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/host.json b/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/host.json new file mode 100644 index 0000000..40eb4be --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} diff --git a/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/local.settings.json b/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/local.settings.json new file mode 100644 index 0000000..a2ded91 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/requirements.txt b/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/requirements.txt new file mode 100644 index 0000000..b4ca486 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/commondataservice_samples_webhooks/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/function_app.py b/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/function_app.py new file mode 100644 index 0000000..3f0bbb8 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/function_app.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle Dataflows refresh completion using the generated SDK refresh model.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.dataflows as dataflows + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="refreshes") +def dataflows_refresh_completed(refreshes: List[dataflows.RefreshModel]) -> None: + """Handle OnRefreshComplete records without logging names or identifiers.""" + logging.info("Received %d Dataflows refresh completions.", len(refreshes)) + logging.info("Failed Dataflows refresh count: %d.", + sum(refresh.status == "Failed" for refresh in refreshes)) diff --git a/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/host.json b/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/host.json new file mode 100644 index 0000000..f029ee2 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/local.settings.json b/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/local.settings.json new file mode 100644 index 0000000..41ce6b3 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/requirements.txt b/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/requirements.txt new file mode 100644 index 0000000..4f72069 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/dataflows_samples_refresh_completed/requirements.txt @@ -0,0 +1,3 @@ +azure-functions +azurefunctions-extensions-connectors +azure-connectors \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/function_app.py b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/function_app.py index ddf4c73..db3f68a 100644 --- a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/function_app.py +++ b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/function_app.py @@ -17,14 +17,14 @@ DESCRIPTION: These samples demonstrate how to handle the "When an event is added, updated or deleted" action from an Office365 Connector Trigger. The - GraphCalendarEventListWithActionType type is the rich payload returned + GraphCalendarEventClientWithActionType type is the rich payload returned when a calendar event is added, updated, or deleted. """ @app.connector_trigger(arg_name="event") def office365_on_calendar_event_single( - event: List[office365.GraphCalendarEventListWithActionType] + event: List[office365.GraphCalendarEventClientWithActionType] ): """ Single event processing - splitOn enabled @@ -39,7 +39,7 @@ def office365_on_calendar_event_single( @app.connector_trigger(arg_name="events") def office365_on_calendar_event_batch( - events: List[office365.GraphCalendarEventListWithActionType] + events: List[office365.GraphCalendarEventClientWithActionType] ): """ Batch event processing - splitOn disabled diff --git a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/host.json b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/host.json index e1475ae..40eb4be 100644 --- a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/host.json +++ b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/host.json @@ -6,7 +6,7 @@ } }, "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle.Experimental", - "version": "[4.6.0, 5.0.0)" + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" } } diff --git a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/requirements.txt b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/requirements.txt index 17b95be..b4ca486 100644 --- a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/requirements.txt +++ b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event/requirements.txt @@ -1,2 +1,2 @@ -azure-functions>=2.2.0b4 +azure-functions azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event_created/host.json b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event_created/host.json index e1475ae..40eb4be 100644 --- a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event_created/host.json +++ b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event_created/host.json @@ -6,7 +6,7 @@ } }, "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle.Experimental", - "version": "[4.6.0, 5.0.0)" + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" } } diff --git a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event_created/requirements.txt b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event_created/requirements.txt index 17b95be..b4ca486 100644 --- a/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event_created/requirements.txt +++ b/azurefunctions-extensions-connectors/samples/office365_samples_on_calendar_event_created/requirements.txt @@ -1,2 +1,2 @@ -azure-functions>=2.2.0b4 +azure-functions azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/office365_samples_on_flagged_email/host.json b/azurefunctions-extensions-connectors/samples/office365_samples_on_flagged_email/host.json index e1475ae..40eb4be 100644 --- a/azurefunctions-extensions-connectors/samples/office365_samples_on_flagged_email/host.json +++ b/azurefunctions-extensions-connectors/samples/office365_samples_on_flagged_email/host.json @@ -6,7 +6,7 @@ } }, "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle.Experimental", - "version": "[4.6.0, 5.0.0)" + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" } } diff --git a/azurefunctions-extensions-connectors/samples/office365_samples_on_flagged_email/requirements.txt b/azurefunctions-extensions-connectors/samples/office365_samples_on_flagged_email/requirements.txt index 17b95be..b4ca486 100644 --- a/azurefunctions-extensions-connectors/samples/office365_samples_on_flagged_email/requirements.txt +++ b/azurefunctions-extensions-connectors/samples/office365_samples_on_flagged_email/requirements.txt @@ -1,2 +1,2 @@ -azure-functions>=2.2.0b4 +azure-functions azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/office365_samples_on_new_email/host.json b/azurefunctions-extensions-connectors/samples/office365_samples_on_new_email/host.json index af807ac..f029ee2 100644 --- a/azurefunctions-extensions-connectors/samples/office365_samples_on_new_email/host.json +++ b/azurefunctions-extensions-connectors/samples/office365_samples_on_new_email/host.json @@ -6,7 +6,7 @@ } }, "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle.Experimental", - "version": "[4.6.0, 5.0.0)" + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" } } \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/office365_samples_on_new_email/requirements.txt b/azurefunctions-extensions-connectors/samples/office365_samples_on_new_email/requirements.txt index abd4285..0115844 100644 --- a/azurefunctions-extensions-connectors/samples/office365_samples_on_new_email/requirements.txt +++ b/azurefunctions-extensions-connectors/samples/office365_samples_on_new_email/requirements.txt @@ -1,2 +1,2 @@ -azure-functions>=2.2.0b4 +azure-functions azurefunctions-extensions-connectors \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/function_app.py b/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/function_app.py new file mode 100644 index 0000000..2279f4e --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/function_app.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle OnNewFilesV2 callbacks with OneDrive for Business SDK metadata.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.onedriveforbusiness as onedrive + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="files") +def onedrive_on_new_file_properties_single( + files: List[onedrive.BlobMetadata], +) -> None: + """Process a split-on file-property callback as a one-element list.""" + for item in files: + logging.info( + "OneDrive file properties received: size '%s', folder '%s'.", + item.size, item.is_folder, + ) + + +@app.connector_trigger(arg_name="files") +def onedrive_on_new_file_properties_batch( + files: List[onedrive.BlobMetadata], +) -> None: + """Process a source array of file properties with split-on disabled.""" + logging.info("Received metadata for %d OneDrive files.", len(files)) + for item in files: + logging.info( + "OneDrive file properties received: size '%s', folder '%s'.", + item.size, item.is_folder, + ) diff --git a/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/host.json b/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/host.json new file mode 100644 index 0000000..40eb4be --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} diff --git a/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/local.settings.json b/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/local.settings.json new file mode 100644 index 0000000..a2ded91 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/requirements.txt b/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/requirements.txt new file mode 100644 index 0000000..b4ca486 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/onedriveforbusiness_samples_on_new_file_properties/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/planner_samples_tasks/function_app.py b/azurefunctions-extensions-connectors/samples/planner_samples_tasks/function_app.py new file mode 100644 index 0000000..a61af48 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/planner_samples_tasks/function_app.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle Planner polling callbacks using generated SDK task items.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.planner as planner + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="tasks") +def planner_new_task_single(tasks: List[planner.GetTaskResponseV2]) -> None: + """Handle an OnNewTask_V3 split-on callback as a list of task items.""" + logging.info("Received %d new Planner tasks.", len(tasks)) + for task in tasks: + logging.info("Planner task completion: '%s'.", task.percent_complete) + + +@app.connector_trigger(arg_name="tasks") +def planner_new_task_batch(tasks: List[planner.GetTaskResponseV2]) -> None: + """Handle OnNewTask_V3 with split-on disabled.""" + logging.info("Received a batch of %d new Planner tasks.", len(tasks)) + for task in tasks: + logging.info("Planner task completion: '%s'.", task.percent_complete) + + +@app.connector_trigger(arg_name="tasks") +def planner_task_completed(tasks: List[planner.GetTaskResponseV2]) -> None: + """Handle OnCompleteTask_V3 without changing the SDK category model.""" + logging.info("Received %d completed Planner tasks.", len(tasks)) + for task in tasks: + if task.applied_categories is not None: + logging.info("Planner category 25 applied: '%s'.", + task.applied_categories.category25) + + +@app.connector_trigger(arg_name="tasks") +def planner_task_assigned_to_me(tasks: List[planner.GetTaskResponseV2]) -> None: + """Handle OnTaskAssignedToMe_V2 with raw connector assignment entries.""" + logging.info("Received %d assigned Planner tasks.", len(tasks)) + for task in tasks: + logging.info("Planner assignment count: '%s'.", len(task.assignments or [])) diff --git a/azurefunctions-extensions-connectors/samples/planner_samples_tasks/host.json b/azurefunctions-extensions-connectors/samples/planner_samples_tasks/host.json new file mode 100644 index 0000000..40eb4be --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/planner_samples_tasks/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} diff --git a/azurefunctions-extensions-connectors/samples/planner_samples_tasks/local.settings.json b/azurefunctions-extensions-connectors/samples/planner_samples_tasks/local.settings.json new file mode 100644 index 0000000..a2ded91 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/planner_samples_tasks/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azurefunctions-extensions-connectors/samples/planner_samples_tasks/requirements.txt b/azurefunctions-extensions-connectors/samples/planner_samples_tasks/requirements.txt new file mode 100644 index 0000000..b4ca486 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/planner_samples_tasks/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/salesforce_samples_records/function_app.py b/azurefunctions-extensions-connectors/samples/salesforce_samples_records/function_app.py new file mode 100644 index 0000000..682f26f --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/salesforce_samples_records/function_app.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle Salesforce record callbacks using generated SDK item property bags.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.salesforce as salesforce + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="records") +def salesforce_new_record_single(records: List[salesforce.Item]) -> None: + """Handle a GetOnNewItems split-on callback as a list of SDK items.""" + logging.info("Received %d new Salesforce records.", len(records)) + for record in records: + logging.info("Salesforce record field count: %d.", + len(record.dynamic_properties or {})) + + +@app.connector_trigger(arg_name="records") +def salesforce_new_record_batch(records: List[salesforce.Item]) -> None: + """Handle GetOnNewItems with split-on disabled.""" + logging.info("Received a batch of %d new Salesforce records.", len(records)) + for record in records: + logging.info("Salesforce record field count: %d.", + len(record.dynamic_properties or {})) + + +@app.connector_trigger(arg_name="records") +def salesforce_updated_record(records: List[salesforce.Item]) -> None: + """Handle GetOnUpdatedItems without assuming a fixed object schema.""" + logging.info("Received %d updated Salesforce records.", len(records)) + for record in records: + logging.info("Salesforce record field count: %d.", + len(record.dynamic_properties or {})) diff --git a/azurefunctions-extensions-connectors/samples/salesforce_samples_records/host.json b/azurefunctions-extensions-connectors/samples/salesforce_samples_records/host.json new file mode 100644 index 0000000..f029ee2 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/salesforce_samples_records/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/salesforce_samples_records/local.settings.json b/azurefunctions-extensions-connectors/samples/salesforce_samples_records/local.settings.json new file mode 100644 index 0000000..41ce6b3 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/salesforce_samples_records/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/salesforce_samples_records/requirements.txt b/azurefunctions-extensions-connectors/samples/salesforce_samples_records/requirements.txt new file mode 100644 index 0000000..0115844 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/salesforce_samples_records/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azurefunctions-extensions-connectors \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/function_app.py b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/function_app.py new file mode 100644 index 0000000..38aa50a --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/function_app.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle GetOnDeletedItems callbacks with generated SDK deletion metadata.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.sharepointonline as sharepointonline + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="items") +def sharepoint_on_deleted_item_single( + items: List[sharepointonline.DeletedItem], +) -> None: + """Process a split-on deletion callback as a one-element list.""" + for item in items: + logging.info( + "SharePoint item deleted: ID '%s', folder '%s'.", + item.id, item.is_folder, + ) + + +@app.connector_trigger(arg_name="items") +def sharepoint_on_deleted_item_batch( + items: List[sharepointonline.DeletedItem], +) -> None: + """Process a source batch of deletion metadata with split-on disabled.""" + logging.info("Received %d SharePoint deletions.", len(items)) + for item in items: + logging.info( + "SharePoint item deleted: ID '%s', folder '%s'.", + item.id, item.is_folder, + ) diff --git a/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/host.json b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/host.json new file mode 100644 index 0000000..40eb4be --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} diff --git a/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/local.settings.json b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/local.settings.json new file mode 100644 index 0000000..a2ded91 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/requirements.txt b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/requirements.txt new file mode 100644 index 0000000..b4ca486 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_deleted_item/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/function_app.py b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/function_app.py new file mode 100644 index 0000000..e4b9fb9 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/function_app.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle GetOnNewItems callbacks with generated SharePoint SDK items.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.sharepointonline as sharepointonline + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="items") +def sharepoint_on_new_item_single(items: List[sharepointonline.Item]) -> None: + """Process a split-on item callback, returned as a one-element list.""" + for item in items: + properties = item.dynamic_properties or {} + logging.info("SharePoint item received: ID '%s'.", properties.get("ID")) + + +@app.connector_trigger(arg_name="items") +def sharepoint_on_new_item_batch(items: List[sharepointonline.Item]) -> None: + """Process a source batch with split-on disabled.""" + logging.info("Received %d SharePoint items.", len(items)) + for item in items: + properties = item.dynamic_properties or {} + logging.info("SharePoint item received: ID '%s'.", properties.get("ID")) diff --git a/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/host.json b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/host.json new file mode 100644 index 0000000..40eb4be --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} diff --git a/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/local.settings.json b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/local.settings.json new file mode 100644 index 0000000..a2ded91 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/requirements.txt b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/requirements.txt new file mode 100644 index 0000000..b4ca486 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/sharepointonline_samples_on_new_item/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/teams_samples_polling/function_app.py b/azurefunctions-extensions-connectors/samples/teams_samples_polling/function_app.py new file mode 100644 index 0000000..4d2d413 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/teams_samples_polling/function_app.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle Teams channel-message and membership polling callbacks.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.teams as teams + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="messages") +def teams_channel_message_single(messages: List[teams.ChatMessage]) -> None: + """Handle a split-on OnNewChannelMessage callback.""" + logging.info("Received %d Teams channel messages.", len(messages)) + + +@app.connector_trigger(arg_name="messages") +def teams_channel_message_batch(messages: List[teams.ChatMessage]) -> None: + """Handle OnNewChannelMessageMentioningMe with split-on disabled.""" + logging.info("Received %d Teams mention messages.", len(messages)) + + +@app.connector_trigger(arg_name="members") +def teams_member_added(members: teams.OnGroupMemberChangeResponse) -> None: + """Handle OnGroupMembershipAdd as the SDK's list of member dictionaries.""" + logging.info("Received %d Teams member additions.", len(members)) + + +@app.connector_trigger(arg_name="members") +def teams_member_removed(members: teams.OnGroupMemberChangeResponse) -> None: + """Handle OnGroupMembershipRemoval without guessing custom member fields.""" + logging.info("Received %d Teams member removals.", len(members)) diff --git a/azurefunctions-extensions-connectors/samples/teams_samples_polling/host.json b/azurefunctions-extensions-connectors/samples/teams_samples_polling/host.json new file mode 100644 index 0000000..40eb4be --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/teams_samples_polling/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} diff --git a/azurefunctions-extensions-connectors/samples/teams_samples_polling/local.settings.json b/azurefunctions-extensions-connectors/samples/teams_samples_polling/local.settings.json new file mode 100644 index 0000000..a2ded91 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/teams_samples_polling/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azurefunctions-extensions-connectors/samples/teams_samples_polling/requirements.txt b/azurefunctions-extensions-connectors/samples/teams_samples_polling/requirements.txt new file mode 100644 index 0000000..b4ca486 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/teams_samples_polling/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/function_app.py b/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/function_app.py new file mode 100644 index 0000000..f796d5a --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/function_app.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle the seven Teams webhook notification payload families.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.teams as teams + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="notifications") +def teams_chat_message( + notifications: List[teams.ChatMessageWebhookResponseSchema], +) -> None: + """Handle WebhookChatMessageTrigger without flattening its value envelope.""" + for notification in notifications: + logging.info( + "Received %d chat message references.", len(notification.value or []), + ) + + +@app.connector_trigger(arg_name="notifications") +def teams_new_message( + notifications: List[teams.DynamicNewMessageWebhookTriggerResponseSchema], +) -> None: + """Handle WebhookNewMessageTrigger with raw JSON in additional_properties.""" + for notification in notifications: + logging.info( + "Received a Teams new-message notification with %d fields.", + len(notification.additional_properties), + ) + + +@app.connector_trigger(arg_name="notifications") +def teams_at_mention( + notifications: List[teams.DynamicAtMentionWebhookTriggerResponseSchema], +) -> None: + """Handle WebhookAtMentionTrigger with its dynamic field bag intact.""" + for notification in notifications: + logging.info( + "Received a Teams mention notification with %d fields.", + len(notification.additional_properties), + ) + + +@app.connector_trigger(arg_name="notifications") +def teams_keyword( + notifications: List[teams.DynamicKeywordWebhookTriggerResponseSchema], +) -> None: + """Handle WebhookKeywordTrigger without imposing a fixed message schema.""" + for notification in notifications: + logging.info( + "Received a Teams keyword notification with %d fields.", + len(notification.additional_properties), + ) + + +@app.connector_trigger(arg_name="notifications") +def teams_reaction( + notifications: List[teams.MessageReactionWebhookResponseSchema], +) -> None: + """Handle WebhookMessageReactionTrigger as SDK notification metadata.""" + logging.info("Received %d Teams reaction notifications.", len(notifications)) + + +@app.connector_trigger(arg_name="notifications") +def teams_recording( + notifications: List[teams.RecordingWebhookResponseSchema], +) -> None: + """Handle RecordingTrigger metadata without downloading recording bytes.""" + logging.info("Received %d recording notifications.", len(notifications)) + + +@app.connector_trigger(arg_name="notifications") +def teams_transcript( + notifications: List[teams.TranscriptWebhookResponseSchema], +) -> None: + """Handle TranscriptTrigger metadata without downloading transcript text.""" + logging.info("Received %d transcript notifications.", len(notifications)) diff --git a/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/host.json b/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/host.json new file mode 100644 index 0000000..40eb4be --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} diff --git a/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/local.settings.json b/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/local.settings.json new file mode 100644 index 0000000..a2ded91 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/requirements.txt b/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/requirements.txt new file mode 100644 index 0000000..b4ca486 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/teams_samples_webhooks/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azurefunctions-extensions-connectors diff --git a/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/function_app.py b/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/function_app.py new file mode 100644 index 0000000..c5cd751 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/function_app.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Handle Azure DevOps polling callbacks using generated SDK item models.""" + +import logging +from typing import List + +import azure.functions as func +import azurefunctions.extensions.connectors.visualstudioteamservices as devops + +app = func.FunctionApp() + + +@app.connector_trigger(arg_name="builds") +def devops_build_completed_single(builds: List[devops.BuildResult]) -> None: + """Handle OnBuildCompleted with split-on enabled; the binding returns a list.""" + logging.info("Received %d completed Azure DevOps builds.", len(builds)) + + +@app.connector_trigger(arg_name="builds") +def devops_build_completed_batch(builds: List[devops.BuildResult]) -> None: + """Handle OnBuildCompleted with split-on disabled.""" + logging.info("Received a batch of %d completed Azure DevOps builds.", len(builds)) + + +@app.connector_trigger(arg_name="pushes") +def devops_git_push(pushes: List[devops.GitPush]) -> None: + """Handle OnGitPush with typed repositories, identities, and ref updates.""" + logging.info("Received %d Azure DevOps Git pushes.", len(pushes)) + + +@app.connector_trigger(arg_name="pull_requests") +def devops_pull_request_created(pull_requests: List[devops.GitPullRequest]) -> None: + """Handle OnGitPullCreated with typed reviewer and option models.""" + logging.info("Received %d created Azure DevOps pull requests.", len(pull_requests)) + + +@app.connector_trigger(arg_name="pull_requests") +def devops_pull_request_closed(pull_requests: List[devops.GitPullRequest]) -> None: + """Handle OnGitPullClosed using the same SDK pull-request item type.""" + logging.info("Received %d closed Azure DevOps pull requests.", len(pull_requests)) + + +@app.connector_trigger(arg_name="changesets") +def devops_tfvc_check_in(changesets: List[devops.TfvcChangeset]) -> None: + """Handle OnTfvcCheckIn with SDK changesets, not Git commit objects.""" + logging.info("Received %d Azure DevOps TFVC changesets.", len(changesets)) + + +@app.connector_trigger(arg_name="work_items") +def devops_work_item_assigned(work_items: List[devops.TriggerWorkItemResponse]) -> None: + """Handle OnWorkItemAssignedV2 without guessing custom field types.""" + logging.info("Received %d assigned Azure DevOps work items.", len(work_items)) + for item in work_items: + logging.info("Azure DevOps work-item field count: %d.", len(item.fields or {})) + + +@app.connector_trigger(arg_name="work_items") +def devops_work_item_closed(work_items: List[devops.TriggerWorkItemResponse]) -> None: + """Handle OnWorkItemClosedV2, preserving dotted field names in item.fields.""" + logging.info("Received %d closed Azure DevOps work items.", len(work_items)) + + +@app.connector_trigger(arg_name="work_items") +def devops_work_item_created(work_items: List[devops.TriggerWorkItemResponse]) -> None: + """Handle OnWorkItemCreatedV2 with raw custom field values.""" + logging.info("Received %d created Azure DevOps work items.", len(work_items)) + + +@app.connector_trigger(arg_name="work_items") +def devops_work_item_updated(work_items: List[devops.TriggerWorkItemResponse]) -> None: + """Handle OnWorkItemUpdatedV2 with SDK id, rev, url, and fields properties.""" + logging.info("Received %d updated Azure DevOps work items.", len(work_items)) diff --git a/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/host.json b/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/host.json new file mode 100644 index 0000000..f029ee2 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.50.0, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/local.settings.json b/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/local.settings.json new file mode 100644 index 0000000..41ce6b3 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/requirements.txt b/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/requirements.txt new file mode 100644 index 0000000..4f72069 --- /dev/null +++ b/azurefunctions-extensions-connectors/samples/visualstudioteamservices_samples_polling/requirements.txt @@ -0,0 +1,3 @@ +azure-functions +azurefunctions-extensions-connectors +azure-connectors \ No newline at end of file diff --git a/azurefunctions-extensions-connectors/tests/test_commondataservice.py b/azurefunctions-extensions-connectors/tests/test_commondataservice.py new file mode 100644 index 0000000..388df16 --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_commondataservice.py @@ -0,0 +1,252 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Synthetic dynamic payload tests for the Dataverse/CDS connector bindings.""" + +import copy +import json +import unittest +from types import SimpleNamespace +from typing import List + +import azure.connectors.commondataservice as models +import azurefunctions.extensions.connectors.commondataservice as cds +from azurefunctions.extensions.base import Datum +from azurefunctions.extensions.connectors.connectorConverter import ConnectorConverter + + +class TestCommonDataServiceBindings(unittest.TestCase): + """Retain dynamic webhook data separately from legacy polling batches.""" + + def test_public_bindings_reuse_sdk_models(self): + """Use SDK Item and ObjectEntity without a fake fixed row schema.""" + self.assertIn("ConnectorConverter", cds.__all__) + for name, model_type in ( + ("Item", models.Item), + ("RowChangeNotification", models.ObjectEntity), + ("ActionEventNotification", models.ObjectEntity), + ): + binding_type = getattr(cds, name) + with self.subTest(name=name): + self.assertIn(name, cds.__all__) + self.assertTrue(issubclass(binding_type, model_type)) + self.assertFalse(binding_type.supports_deferred_binding()) + for annotation in ( + binding_type, List[binding_type], list[binding_type], + ): + self.assertTrue( + ConnectorConverter.check_input_type_annotation(annotation), + ) + self.assertIsNone(ConnectorConverter.decode( + data=None, trigger_metadata=None, pytype=annotation, + )) + self.assertFalse(ConnectorConverter.check_input_type_annotation(models.Item)) + self.assertFalse(ConnectorConverter.check_input_type_annotation( + models.ObjectEntity, + )) + + def test_legacy_item_preserves_raw_column_types_and_odata_keys(self): + """Keep all column values without request-serializer filtering.""" + record = { + "accountid": "row-1", "name": "Example \u00e9quipe", + "enabled": False, "count": 0, "optional": None, "empty": "", + "tags": ["one", None], "owner": {"id": "owner-1", "custom": False}, + "_ownerid_value": "owner-1", + "_ownerid_value@OData.Community.Display.V1.FormattedValue": "Owner", + "@odata.etag": 'W/"123"', + } + original = copy.deepcopy(record) + for payload in ( + record, {"body": record}, [record], {"body": [record]}, + {"value": [record]}, + {"body": {"@odata.context": "rows", "value": [record]}}, + ): + text = json.dumps(payload, ensure_ascii=False) + for value in ( + payload, text, text.encode("utf-8"), bytearray(text.encode()), + ): + with self.subTest(value=value): + result = ConnectorConverter.decode( + data=Datum(value=value, type="json"), + trigger_metadata=None, pytype=List[cds.Item], + ) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), models.Item) + self.assertEqual(result[0].dynamic_properties, record) + self.assertEqual(record, original) + + def test_legacy_item_accepts_only_a_sole_explicit_property_bag(self): + """Do not merge a real dynamicProperties column into other columns.""" + for key in ("dynamicProperties", "dynamic_properties"): + for bag in ({"accountid": "row-1", "optional": None}, {}, None): + with self.subTest(key=key, bag=bag): + result = cds.Item(data=Datum(value={key: bag}, type="json")) + self.assertEqual(result.get_sdk_type()[0].dynamic_properties, bag) + record = {"accountid": "row-1", key: {"nested": True}} + self.assertEqual(cds.Item(data=Datum( + value=record, type="json", + )).get_sdk_type()[0].dynamic_properties, record) + + def test_webhooks_preserve_entire_row_or_action_callback(self): + """No schema, property-bag, or event-field names receive special treatment.""" + record = { + "accountid": "row-1", "SdkMessage": "Update", + "entityOutputs": {"name": "Example", "optional": None}, + "InputParameters": [{"key": "amount", "value": 0}], + "OutputParameters": {"succeeded": False}, + "value": [{"id": "nested"}], "body": {"custom": "column"}, + "schema": {"custom": True}, "dynamicProperties": {"column": None}, + "additional_properties": {"column": False}, + "@odata.id": "row-odata-id", "count": 0, "optional": None, + } + original = copy.deepcopy(record) + for binding_type in (cds.RowChangeNotification, cds.ActionEventNotification): + for payload in (record, {"body": record}, [record], {"body": [record]}): + text = json.dumps(payload) + for value in (payload, text, text.encode("utf-8")): + with self.subTest(binding=binding_type, value=value): + result = ConnectorConverter.decode( + data=Datum(value=value, type="json"), + trigger_metadata=None, pytype=List[binding_type], + ) + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), models.ObjectEntity) + self.assertEqual(result[0].additional_properties, record) + self.assertNotIsInstance( + result[0], models.WebhookNotificationContent, + ) + self.assertEqual(record, original) + + def test_webhook_value_only_records_do_not_disappear_or_split(self): + """A webhook value field is data, even when it contains an empty array.""" + for binding_type in (cds.RowChangeNotification, cds.ActionEventNotification): + for value in ([], None, [{"id": "first"}, {"id": "second"}], "text"): + with self.subTest(binding=binding_type, value=value): + result = binding_type(data=Datum( + value={"body": {"value": value}}, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].additional_properties, {"value": value}) + + def test_webhook_schema_and_property_bag_keys_remain_payload_fields(self): + """Do not treat a table or action field as schema-discovery metadata.""" + for binding_type in (cds.RowChangeNotification, cds.ActionEventNotification): + for record in ({"schema": None}, {"dynamicProperties": {"name": "Example"}}, + {"additional_properties": {"optional": None}}): + with self.subTest(binding=binding_type, record=record): + result = binding_type(data=Datum( + value=record, type="json", + )).get_sdk_type() + self.assertEqual(result[0].additional_properties, record) + + def test_value_and_body_columns_are_preserved_on_legacy_records(self): + """Only value plus OData metadata identifies a polling envelope.""" + for value in ([], None, [{"accountid": "nested"}], "custom", False): + record = {"accountid": "row-1", "value": value, "body": {"text": "custom"}} + for payload in (record, {"body": record}, {"value": [record]}): + with self.subTest(payload=payload): + result = cds.Item(data=Datum( + value=payload, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].dynamic_properties, record) + + def test_empty_callbacks_and_records_have_distinct_meanings(self): + """Keep empty records distinct from no-work arrays or null bodies.""" + for binding_type in ( + cds.Item, cds.RowChangeNotification, cds.ActionEventNotification, + ): + for payload in ([], {"body": None}, {"body": []}): + with self.subTest(binding=binding_type, payload=payload): + self.assertEqual(binding_type(data=Datum( + value=payload, type="json", + )).get_sdk_type(), []) + record = binding_type(data=Datum(value={}, type="json")).get_sdk_type() + self.assertEqual(len(record), 1) + self.assertEqual( + record[0].dynamic_properties if binding_type is cds.Item + else record[0].additional_properties, {}, + ) + for value in ([], None): + self.assertEqual(cds.Item(data=Datum( + value={"value": value}, type="json", + )).get_sdk_type(), []) + + def test_transport_collections_preserve_order_and_webhook_boundaries(self): + """Normalize polling batches but preserve webhook value collections.""" + cases = ( + (cds.Item, [{"body": {"accountid": "row-1"}}, + {"value": [{"accountid": "row-2"}]}], + [{"accountid": "row-1"}, {"accountid": "row-2"}]), + (cds.RowChangeNotification, + [{"body": {"value": []}}, {"value": [{"id": "nested"}]}], + [{"value": []}, {"value": [{"id": "nested"}]}]), + (cds.ActionEventNotification, + [[{"name": "first"}], {"body": {"name": "second"}}], + [{"name": "first"}, {"name": "second"}]), + ) + for binding_type, events, expected in cases: + text = [json.dumps(event) for event in events] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=text)), + ("collection_bytes", SimpleNamespace( + bytes=[value.encode() for value in text], + )), + ): + with self.subTest(binding=binding_type, data_type=data_type): + result = binding_type(data=Datum(value=collection, type=data_type)) + self.assertEqual([ + item.dynamic_properties if binding_type is cds.Item + else item.additional_properties + for item in result.get_sdk_type() + ], expected) + + def test_empty_transport_collections(self): + """No transport events produce no SDK records.""" + for binding_type in ( + cds.Item, cds.RowChangeNotification, cds.ActionEventNotification, + ): + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=[])), + ("collection_bytes", SimpleNamespace(bytes=[])), + ): + self.assertEqual(binding_type(data=Datum( + value=collection, type=data_type, + )).get_sdk_type(), []) + + def test_invalid_payloads_raise_for_all_bindings(self): + """Do not silently drop malformed records, envelopes, or JSON bytes.""" + for binding_type in ( + cds.Item, cds.RowChangeNotification, cds.ActionEventNotification, + ): + for payload in ("invalid", b"\xff", None, 1, {"body": "bad"}, + [{"accountid": "valid"}, None], [False]): + with self.subTest(binding=binding_type, payload=payload): + with self.assertRaises(ValueError): + binding_type(data=Datum( + value=payload, type="json", + )).get_sdk_type() + with self.assertRaisesRegex(ValueError, "No data provided"): + binding_type(data=None).get_sdk_type() + + def test_invalid_legacy_batches_and_property_bags_raise(self): + """Validate collection and property-bag shapes without dropping values.""" + for payload in ({"value": "bad"}, {"value": [{"id": "valid"}, None]}, + {"dynamicProperties": []}, {"dynamic_properties": False}): + with self.subTest(payload=payload): + with self.assertRaises(ValueError): + cds.Item(data=Datum(value=payload, type="json")).get_sdk_type() + + def test_sdk_registry_confirms_the_three_legacy_payload_envelopes(self): + """Webhook conversion must not depend on the incomplete SDK trigger registry.""" + registry = getattr(models, "TRIGGER_OPERATIONS", None) + if registry is None: + self.skipTest("This SDK does not expose a CDS trigger registry.") + for operation in ( + "GetOnDeletedItems_V2", "GetOnNewItems_V2", "GetOnUpdatedItems_V2", + ): + with self.subTest(operation=operation): + self.assertEqual( + registry[operation]["callback_payload_type"], "ItemsList", + ) diff --git a/azurefunctions-extensions-connectors/tests/test_dataflows.py b/azurefunctions-extensions-connectors/tests/test_dataflows.py new file mode 100644 index 0000000..e19bf6d --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_dataflows.py @@ -0,0 +1,205 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Synthetic Dataflows refresh callbacks against the generated SDK model.""" + +import copy +import json +import unittest +from dataclasses import fields +from types import SimpleNamespace +from typing import List + +import azure.connectors.dataflows as models +import azurefunctions.extensions.connectors.dataflows as dataflows +from azurefunctions.extensions.base import Datum + + +class TestDataflowsBindings(unittest.TestCase): + """Keep refresh item fields, wire names, and transport cardinality intact.""" + + def refresh_record(self): + """Return a complete synthetic refresh record, not an action response.""" + return { + "dataflowId": "flow-1", "dataflowName": "\u00e9quipe", + "refreshType": "Scheduled", "startTime": "2026-10-28T01:00:00+05:30", + "endTime": "2026-10-28T01:02:00+05:30", "status": "Success", + } + + def decode(self, payload): + """Exercise the Functions converter rather than constructing SDK models.""" + return dataflows.ConnectorConverter.decode( + data=Datum(value=payload, type="json"), trigger_metadata=None, + pytype=List[dataflows.RefreshModel], + ) + + def test_exports_annotations_and_exact_sdk_model(self): + """Both accepted annotation forms return a list of SDK refresh items.""" + self.assertIn("RefreshModel", dataflows.__all__) + self.assertIn("ConnectorConverter", dataflows.__all__) + self.assertTrue(issubclass(dataflows.RefreshModel, models.RefreshModel)) + self.assertFalse(dataflows.RefreshModel.supports_deferred_binding()) + for annotation in (dataflows.RefreshModel, List[dataflows.RefreshModel], + list[dataflows.RefreshModel]): + with self.subTest(annotation=annotation): + self.assertTrue(dataflows.ConnectorConverter + .check_input_type_annotation(annotation)) + result = dataflows.ConnectorConverter.decode( + data=Datum(value=self.refresh_record(), type="json"), + trigger_metadata=None, pytype=annotation, + ) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), models.RefreshModel) + self.assertNotIsInstance(result[0], dataflows.RefreshModel) + self.assertNotIsInstance(result[0], models.DataflowModel) + self.assertFalse(dataflows.ConnectorConverter + .check_input_type_annotation(models.RefreshModel)) + + def test_all_six_wire_fields_and_json_encodings(self): + """Preserve every SDK field in direct records and sole-body callbacks.""" + record = self.refresh_record() + original = copy.deepcopy(record) + self.assertEqual({field.name for field in fields(models.RefreshModel)}, { + "dataflow_id", "dataflow_name", "refresh_type", "start_time", + "end_time", "status", + }) + for payload in (record, {"body": record}): + text = json.dumps(payload, ensure_ascii=False) + for value in (payload, text, text.encode(), bytearray(text.encode())): + with self.subTest(value=value): + result = self.decode(value) + self.assertEqual(len(result), 1) + for field in fields(models.RefreshModel): + actual = getattr(result[0], field.name) + self.assertEqual(actual, record[field.metadata.get( + "wire_name", field.name, + )]) + self.assertIsInstance(actual, str) + self.assertEqual(record, original) + + def test_python_field_fallback_and_wire_precedence(self): + """Prefer present wire fields, including null, over Python fallbacks.""" + record = self.refresh_record() + python_record = {field.name: record[field.metadata.get("wire_name", field.name)] + for field in fields(models.RefreshModel)} + item = self.decode(python_record)[0] + for field in fields(models.RefreshModel): + self.assertEqual(getattr(item, field.name), python_record[field.name]) + both_names = {**python_record, **record, "dataflowId": None, + "dataflow_name": "wrong", "refresh_type": "wrong"} + item = self.decode(both_names)[0] + self.assertIsNone(item.dataflow_id) + self.assertEqual(item.dataflow_name, record["dataflowName"]) + self.assertEqual(item.refresh_type, record["refreshType"]) + + def test_optional_null_and_empty_fields_remain_distinct(self): + """The SDK permits absent, null, and empty string values for all fields.""" + for value in (None, ""): + record = {field.metadata.get("wire_name", field.name): value + for field in fields(models.RefreshModel)} + with self.subTest(value=value): + item = self.decode(record)[0] + self.assertTrue(all(getattr(item, field.name) == value + for field in fields(models.RefreshModel))) + result = self.decode({}) + self.assertEqual(len(result), 1) + self.assertTrue(all(getattr(result[0], field.name) is None + for field in fields(models.RefreshModel))) + + def test_failed_cancelled_and_future_status_values_are_data(self): + """Refresh failure is callback data, not a deserialization exception.""" + for status in ("Success", "Failed", "Cancelled", "FutureStatus"): + with self.subTest(status=status): + item = self.decode({**self.refresh_record(), "status": status})[0] + self.assertEqual(item.status, status) + + def test_transport_arrays_preserve_every_record(self): + """Accept batched delivery without inventing a source value envelope.""" + records = [self.refresh_record(), { + **self.refresh_record(), "dataflowId": "flow-2", + }] + for payload in (records, {"body": records}, json.dumps(records)): + with self.subTest(payload=payload): + result = self.decode(payload) + self.assertEqual([item.dataflow_id for item in result], + ["flow-1", "flow-2"]) + self.assertTrue(all(type(item) is models.RefreshModel + for item in result)) + + def test_transport_collections_preserve_order_and_empty_events(self): + """Flatten Functions MANY events, retaining callback order.""" + events = [json.dumps(self.refresh_record()), json.dumps({"body": [{ + **self.refresh_record(), "dataflowId": "flow-2", + }]}), json.dumps({"body": None}), json.dumps([])] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=events)), + ("collection_bytes", SimpleNamespace( + bytes=[event.encode() for event in events], + )), + ): + with self.subTest(data_type=data_type): + result = dataflows.RefreshModel(data=Datum( + value=collection, type=data_type, + )).get_sdk_type() + self.assertEqual([item.dataflow_id for item in result], + ["flow-1", "flow-2"]) + + def test_empty_callbacks_and_transport_collections(self): + """No-work transport events return no items rather than an empty model.""" + for payload in ([], {"body": []}, {"body": None}): + self.assertEqual(self.decode(payload), []) + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=[])), + ("collection_bytes", SimpleNamespace(bytes=[])), + ): + self.assertEqual(dataflows.RefreshModel(data=Datum( + value=collection, type=data_type, + )).get_sdk_type(), []) + + def test_body_and_value_named_extras_do_not_mask_refresh_fields(self): + """The refresh record remains the source payload even with extra keys.""" + record = {**self.refresh_record(), "body": {"status": "wrong"}, + "value": [{"status": "wrong"}]} + for payload in (record, {"body": record}): + item = self.decode(payload)[0] + self.assertEqual(item.dataflow_id, "flow-1") + self.assertEqual(item.status, "Success") + + def test_value_only_envelopes_are_not_the_refresh_contract(self): + """Reject collection envelopes instead of producing empty refresh models.""" + for payload in ({"value": [self.refresh_record()]}, {"value": []}, + {"value": None, "@odata.context": "refreshes"}): + with self.subTest(payload=payload): + with self.assertRaisesRegex(ValueError, "refresh record"): + self.decode(payload) + + def test_malformed_payloads_and_members_raise(self): + """Reject malformed batches rather than silently dropping invalid entries.""" + for payload in ("invalid", b"\xff", None, False, 0, {"body": "bad"}, + [self.refresh_record(), None], {"body": [False]}): + with self.subTest(payload=payload): + with self.assertRaises(ValueError): + self.decode(payload) + + def test_trigger_registry_is_a_single_refresh_polling_contract(self): + """Check operation and parameter spelling without relying on it at runtime.""" + self.assertEqual(set(models.TRIGGER_OPERATIONS), {"OnRefreshComplete"}) + entry = models.TRIGGER_OPERATIONS["OnRefreshComplete"] + self.assertEqual(entry["method"], "get") + self.assertEqual(entry["callback_payload_type"], "RefreshModel") + self.assertEqual(entry["required_parameters"], [ + "workspaceType", "groupIdForOnRefreshComplete", + "dataflowIdForOnRefreshComplete", + ]) + self.assertTrue(entry["path"].endswith( + "/{dataflowIdForOnRefreshComplete}/onrefreshcomplete", + )) + + def test_missing_data_follows_existing_converter_contract(self): + """Keep converter no-data behavior separate from direct wrapper errors.""" + self.assertIsNone(dataflows.ConnectorConverter.decode( + data=None, trigger_metadata=None, pytype=dataflows.RefreshModel, + )) + with self.assertRaisesRegex(ValueError, "No data provided"): + dataflows.RefreshModel(data=None).get_sdk_type() diff --git a/azurefunctions-extensions-connectors/tests/test_dataflows_samples.py b/azurefunctions-extensions-connectors/tests/test_dataflows_samples.py new file mode 100644 index 0000000..26142f6 --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_dataflows_samples.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Index and execute the Dataflows sample with synthetic refresh callbacks.""" + +import importlib.util +import json +import unittest +from pathlib import Path +from unittest.mock import patch + +import azure.connectors.dataflows as models +import azurefunctions.extensions.connectors.dataflows as dataflows +from azurefunctions.extensions.base import Datum + + +class TestDataflowsSample(unittest.TestCase): + """Exercise the actual sample handler with generated refresh objects.""" + + @classmethod + def setUpClass(cls): + """Load only the Dataflows sample and index its Functions handler.""" + cls.sample_root = Path(__file__).resolve().parents[1] / "samples" / ( + "dataflows_samples_refresh_completed" + ) + specification = importlib.util.spec_from_file_location( + "dataflows_refresh_sample", cls.sample_root / "function_app.py", + ) + cls.sample = importlib.util.module_from_spec(specification) + specification.loader.exec_module(cls.sample) + cls.functions = cls.sample.app.get_functions() + + def test_handler_indexes_with_refresh_model_annotation(self): + """The function receives SDK items, not the action's DataflowModel.""" + self.assertEqual(len(self.functions), 1) + function = self.functions[0] + self.assertEqual(function.get_function_name(), "dataflows_refresh_completed") + definition = json.loads(function.get_function_json()) + self.assertEqual(len(definition["bindings"]), 1) + binding = definition["bindings"][0] + self.assertEqual(binding["type"], "connectorTrigger") + self.assertEqual(binding["name"], "refreshes") + self.assertEqual(binding["direction"].lower(), "in") + annotation = function.get_user_function().__annotations__["refreshes"] + self.assertTrue(dataflows.ConnectorConverter + .check_input_type_annotation(annotation)) + self.assertEqual(annotation.__args__, (dataflows.RefreshModel,)) + + def test_handler_logs_counts_for_empty_success_failed_and_cancelled(self): + """A failed refresh reaches the handler as data, not a conversion error.""" + self.assertEqual(len(self.functions), 1) + for payload, expected_count, expected_failed in ( + ({"body": []}, 0, 0), + ({"body": {"dataflowId": "synthetic-id", "status": "Success"}}, 1, 0), + ({"body": {"dataflowId": "synthetic-id", "status": "Failed"}}, 1, 1), + ({"body": {"dataflowId": "synthetic-id", "status": "Cancelled"}}, 1, 0), + ): + with self.subTest(payload=payload): + items = dataflows.ConnectorConverter.decode( + data=Datum(value=payload, type="json"), trigger_metadata=None, + pytype=list[dataflows.RefreshModel], + ) + self.assertEqual(len(items), expected_count) + self.assertTrue(all(type(item) is models.RefreshModel + for item in items)) + with patch.object(self.sample.logging, "info") as log: + with patch.object(self.sample.logging, "error") as error_log: + self.assertIsNone(self.functions[0].get_user_function()(items)) + error_log.assert_not_called() + self.assertEqual([call.args for call in log.call_args_list], [ + ("Received %d Dataflows refresh completions.", expected_count), + ("Failed Dataflows refresh count: %d.", expected_failed), + ]) + self.assertNotIn("synthetic-id", str(log.call_args_list)) + + def test_configuration_uses_sdk_package_and_local_runtime_settings(self): + """Use the SDK package with the experimental bundle and local storage.""" + requirements = (self.sample_root / "requirements.txt").read_text() + self.assertIn("azure-connectors", requirements.splitlines()) + host = json.loads((self.sample_root / "host.json").read_text()) + self.assertEqual(host["extensionBundle"]["id"], + "Microsoft.Azure.Functions.ExtensionBundle.Experimental") + settings = json.loads((self.sample_root / "local.settings.json").read_text()) + self.assertEqual(settings["Values"]["AzureWebJobsStorage"], + "UseDevelopmentStorage=true") diff --git a/azurefunctions-extensions-connectors/tests/test_office365_trigger_bindings.py b/azurefunctions-extensions-connectors/tests/test_office365_trigger_bindings.py new file mode 100644 index 0000000..7e5d1a4 --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_office365_trigger_bindings.py @@ -0,0 +1,278 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for Office 365 trigger item bindings and their return contracts.""" + +import json +import unittest +from types import SimpleNamespace +from typing import List + +import azure.connectors.office365 as models +import azurefunctions.extensions.connectors.office365 as office365 +from azurefunctions.extensions.base import Datum +from azurefunctions.extensions.connectors.office365 import ( + ConnectorConverter, + GraphCalendarEventClientWithActionType, + GraphCalendarEventListWithActionType, +) + + +class TestOffice365TriggerBindings(unittest.TestCase): + """Check item types without changing existing list-returning bindings.""" + + def test_changed_calendar_binding_is_exported_and_inherits_item_model(self): + """Expose the generated item fields rather than envelope fields.""" + self.assertIn( + "GraphCalendarEventClientWithActionType", office365.__all__, + ) + self.assertTrue(issubclass( + GraphCalendarEventClientWithActionType, + models.GraphCalendarEventClientWithActionType, + )) + self.assertFalse( + GraphCalendarEventClientWithActionType.supports_deferred_binding(), + ) + + def test_changed_calendar_binding_accepts_direct_and_list_annotations(self): + """Accept the item binding through the generic connector converter.""" + for annotation in ( + GraphCalendarEventClientWithActionType, + List[GraphCalendarEventClientWithActionType], + list[GraphCalendarEventClientWithActionType], + ): + with self.subTest(annotation=annotation): + self.assertTrue( + ConnectorConverter.check_input_type_annotation(annotation), + ) + self.assertIsNone(ConnectorConverter.decode( + data=None, trigger_metadata=None, pytype=annotation, + )) + + def test_changed_calendar_binding_preserves_actions_and_wire_names(self): + """Read both generated wire names and existing lower-camel callbacks.""" + for action_field, added_field, updated_field in ( + ("ActionType", "IsAdded", "IsUpdated"), + ("actionType", "isAdded", "isUpdated"), + ): + for action in ("added", "updated", "deleted"): + record = { + "id": "event-1", + "subject": "Planning", + action_field: action, + added_field: action == "added", + updated_field: action == "updated", + "startWithTimeZone": "2026-10-21T10:00:00Z", + "isAllDay": False, + "categories": ["Planning"], + } + for payload in ( + record, + {"body": record}, + {"value": [record]}, + {"body": {"@odata.context": "calendar", "value": [record]}}, + ): + for value in (payload, json.dumps(payload)): + with self.subTest(action=action, value=value): + events = ConnectorConverter.decode( + data=Datum(value=value, type="json"), + trigger_metadata=None, + pytype=List[GraphCalendarEventClientWithActionType], + ) + self.assertIsInstance(events, list) + self.assertEqual(len(events), 1) + event = events[0] + self.assertIsInstance( + event, + models.GraphCalendarEventClientWithActionType, + ) + self.assertEqual(event.id, "event-1") + self.assertEqual(event.subject, "Planning") + self.assertEqual(event.action_type, action) + self.assertEqual(event.is_added, action == "added") + self.assertEqual(event.is_updated, action == "updated") + self.assertFalse(event.is_all_day) + self.assertEqual(event.categories, ["Planning"]) + self.assertEqual( + event.start_with_time_zone, + "2026-10-21T10:00:00Z", + ) + + def test_changed_calendar_binding_handles_poll_many_collections(self): + """Flatten transport collections while keeping event action fields.""" + records = [ + {"id": "event-1", "ActionType": "added", "IsAdded": True}, + {"id": "event-2", "ActionType": "deleted", "IsAdded": False}, + ] + events = [ + json.dumps({"body": records[0]}), + json.dumps({"body": {"value": records[1:]}}), + ] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=events)), + ("collection_bytes", SimpleNamespace( + bytes=[event.encode("utf-8") for event in events], + )), + ): + with self.subTest(data_type=data_type): + result = ConnectorConverter.decode( + data=Datum(value=collection, type=data_type), + trigger_metadata=None, + pytype=list[GraphCalendarEventClientWithActionType], + ) + self.assertEqual( + [(event.id, event.action_type, event.is_added) + for event in result], + [("event-1", "added", True), ("event-2", "deleted", False)], + ) + + def test_changed_calendar_binding_matches_legacy_wrapper(self): + """Keep the old import and list-returning behavior available.""" + for value in ([], None, [ + {"id": "event-1", "ActionType": "updated", "IsUpdated": True}, + {"id": "event-2", "ActionType": "deleted", "IsUpdated": False}, + ]): + with self.subTest(value=value): + data = Datum(value={"body": {"value": value}}, type="json") + current = GraphCalendarEventClientWithActionType( + data=data, + ).get_sdk_type() + legacy = GraphCalendarEventListWithActionType( + data=data, + ).get_sdk_type() + self.assertIsInstance(current, list) + self.assertIsInstance(legacy, list) + self.assertEqual(current, legacy) + self.assertEqual(len(current), len(value or [])) + + def test_changed_calendar_binding_rejects_missing_and_invalid_payloads(self): + """Report invalid input rather than returning empty success results.""" + for data, message in ( + (None, "No data provided"), + (Datum(value="not-json", type="json"), "Invalid JSON payload"), + (Datum(value={"body": []}, type="json"), "body must contain"), + ): + with self.subTest(data=data): + with self.assertRaisesRegex(ValueError, message): + GraphCalendarEventClientWithActionType( + data=data, + ).get_sdk_type() + + def test_changed_calendar_binding_rejects_malformed_batch_members(self): + """Reject whole batches instead of silently dropping malformed members.""" + record = {"id": "event-1", "ActionType": "added"} + for invalid in (None, False, 0, "invalid", [], [record]): + for members in ([record, invalid], [invalid, record], [invalid]): + for payload in ({"value": members}, {"body": {"value": members}}): + for value in (payload, json.dumps(payload), + json.dumps(payload).encode("utf-8")): + with self.subTest(invalid=invalid, value=value): + with self.assertRaisesRegex(ValueError, "batch item"): + ConnectorConverter.decode( + data=Datum(value=value, type="json"), + trigger_metadata=None, + pytype=List[GraphCalendarEventClientWithActionType], + ) + + def test_changed_calendar_binding_rejects_malformed_poll_many_members(self): + """Apply strict validation inside every transport collection event.""" + events = [json.dumps({"id": "event-1"}), + json.dumps({"body": {"value": [{"id": "event-2"}, None]}})] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=events)), + ("collection_bytes", SimpleNamespace( + bytes=[event.encode("utf-8") for event in events], + )), + ): + with self.subTest(data_type=data_type): + with self.assertRaisesRegex(ValueError, "batch item"): + GraphCalendarEventClientWithActionType( + data=Datum(value=collection, type=data_type), + ).get_sdk_type() + + def test_changed_calendar_binding_rejects_invalid_collection_values(self): + """Reject non-array collection values without changing legacy parsing.""" + for invalid in ("bad", "", {}, {"id": "event-1"}, False, 0, 1): + for payload in ({"value": invalid}, {"body": {"value": invalid}}): + for value in (payload, json.dumps(payload), + json.dumps(payload).encode("utf-8")): + with self.subTest(invalid=invalid, value=value): + data = Datum(value=value, type="json") + with self.assertRaisesRegex(ValueError, "value must contain"): + ConnectorConverter.decode( + data=data, trigger_metadata=None, + pytype=List[GraphCalendarEventClientWithActionType], + ) + legacy = GraphCalendarEventListWithActionType( + data=data, + ).get_sdk_type() + self.assertEqual(len(legacy), 1) + self.assertIsNone(legacy[0].id) + + def test_changed_calendar_binding_rejects_invalid_poll_many_values(self): + """Validate collection values inside string and byte transport events.""" + for invalid in ("bad", {}, False, 0): + events = [json.dumps({"id": "event-1"}), + json.dumps({"body": {"value": invalid}})] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=events)), + ("collection_bytes", SimpleNamespace( + bytes=[event.encode("utf-8") for event in events], + )), + ): + with self.subTest(invalid=invalid, data_type=data_type): + with self.assertRaisesRegex(ValueError, "value must contain"): + GraphCalendarEventClientWithActionType( + data=Datum(value=collection, type=data_type), + ).get_sdk_type() + + def test_legacy_calendar_wrapper_keeps_malformed_member_filtering(self): + """Preserve the legacy wrapper's filtering for existing applications.""" + events = GraphCalendarEventListWithActionType(data=Datum( + value={"body": {"value": [{"id": "event-1"}, None]}}, type="json", + )).get_sdk_type() + self.assertEqual([event.id for event in events], ["event-1"]) + + def test_modern_trigger_operations_use_the_expected_item_bindings(self): + """Cover the eight current SDK operations with their shared item types.""" + operation_groups = ( + ( + ("OnNewEmailV3", "OnFlaggedEmailV4", "OnNewMentionMeEmailV3", + "SharedMailboxOnNewEmailV2"), + "TriggerBatchResponseGraphClientReceiveMessage", + office365.GraphClientReceiveMessage, + models.GraphClientReceiveMessage, + ), + ( + ("CalendarGetOnNewItemsV3", "CalendarGetOnUpdatedItemsV3", + "OnUpcomingEventsV3"), + "GraphCalendarEventListClientReceive", + office365.GraphCalendarEventClientReceive, + models.GraphCalendarEventClientReceive, + ), + ( + ("CalendarGetOnChangedItemsV3",), + "GraphCalendarEventListWithActionType", + GraphCalendarEventClientWithActionType, + models.GraphCalendarEventClientWithActionType, + ), + ) + for operations, envelope, binding_type, model_type in operation_groups: + for operation in operations: + with self.subTest(operation=operation): + self.assertEqual( + models.TRIGGER_OPERATIONS[operation]["callback_payload_type"], + envelope, + ) + result = ConnectorConverter.decode( + data=Datum(value={"body": {"value": [{ + "id": operation, + "subject": "Trigger event", + }]}}, type="json"), + trigger_metadata=None, + pytype=List[binding_type], + ) + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], model_type) + self.assertEqual(result[0].id, operation) + self.assertEqual(result[0].subject, "Trigger event") diff --git a/azurefunctions-extensions-connectors/tests/test_onedriveforbusiness.py b/azurefunctions-extensions-connectors/tests/test_onedriveforbusiness.py new file mode 100644 index 0000000..1efe19b --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_onedriveforbusiness.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for OneDrive for Business metadata trigger SDK bindings.""" + +import copy +import json +import unittest +from dataclasses import field, fields, make_dataclass +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import patch + +import azure.connectors.onedriveforbusiness as models +import azurefunctions.extensions.connectors.onedriveforbusiness as onedrive +from azurefunctions.extensions.connectors.onedriveforbusiness import _deserialization +from azurefunctions.extensions.base import Datum +from azurefunctions.extensions.connectors.onedriveforbusiness import ( + BlobMetadata, + ConnectorConverter, +) + + +class TestOneDriveForBusinessBindings(unittest.TestCase): + """Keep file metadata distinct from binary-content trigger payloads.""" + + def test_public_binding_inherits_the_generated_metadata_model(self): + """Expose the OneDrive SDK item rather than an unrelated blob model.""" + self.assertIn("BlobMetadata", onedrive.__all__) + self.assertIn("ConnectorConverter", onedrive.__all__) + self.assertTrue(issubclass(BlobMetadata, models.BlobMetadata)) + self.assertFalse(BlobMetadata.supports_deferred_binding()) + + def test_converter_accepts_direct_and_list_annotations(self): + """All supported annotations return a list of generated SDK items.""" + for annotation in (BlobMetadata, List[BlobMetadata], list[BlobMetadata]): + with self.subTest(annotation=annotation): + self.assertTrue( + ConnectorConverter.check_input_type_annotation(annotation), + ) + result = ConnectorConverter.decode( + data=Datum(value={"Id": "file-1"}, type="json"), + trigger_metadata=None, + pytype=annotation, + ) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), models.BlobMetadata) + self.assertEqual(result[0].id, "file-1") + + def test_converter_rejects_plain_sdk_models_as_binding_annotations(self): + """Require the extension wrapper to opt into SDK conversion.""" + for annotation in (models.BlobMetadata, List[models.BlobMetadata]): + with self.subTest(annotation=annotation): + self.assertFalse( + ConnectorConverter.check_input_type_annotation(annotation), + ) + + def test_all_metadata_fields_deserialize_across_payload_shapes(self): + """Honor all twelve SDK wire names without losing false or zero.""" + record = { + "Id": "file-1", + "Name": "Planning \u00e9quipe.docx", + "NameNoExt": "Planning \u00e9quipe", + "DisplayName": "Planning document", + "Path": "/Documents/Planning \u00e9quipe.docx", + "LastModified": "2026-10-21T10:00:00Z", + "Size": 0, + "MediaType": "application/octet-stream", + "IsFolder": False, + "ETag": '"version-1"', + "FileLocator": "locator-1", + "LastModifiedBy": "Example User", + } + original = copy.deepcopy(record) + expected = models.BlobMetadata( + id=record["Id"], name=record["Name"], + name_no_ext=record["NameNoExt"], display_name=record["DisplayName"], + path=record["Path"], last_modified=record["LastModified"], + size=0, media_type=record["MediaType"], is_folder=False, + e_tag=record["ETag"], file_locator=record["FileLocator"], + last_modified_by=record["LastModifiedBy"], + ) + for payload in ( + record, + [record], + {"body": record}, + {"body": [record]}, + {"value": [record]}, + {"body": {"@odata.context": "files", "value": [record]}}, + {"value": [record], "nextLink": "next-page"}, + ): + serialized = json.dumps(payload, ensure_ascii=False) + for value in ( + payload, serialized, serialized.encode("utf-8"), + bytearray(serialized.encode("utf-8")), + ): + with self.subTest(value=value): + result = ConnectorConverter.decode( + data=Datum(value=value, type="json"), + trigger_metadata=None, + pytype=List[BlobMetadata], + ) + self.assertEqual(result, [expected]) + self.assertIs(type(result[0]), models.BlobMetadata) + self.assertIs(result[0].is_folder, False) + self.assertEqual(result[0].size, 0) + self.assertEqual(record, original) + + def test_raw_source_array_preserves_file_order(self): + """The two metadata trigger schemas return an array, not a page model.""" + result = BlobMetadata(data=Datum(value=[ + {"Id": "file-1", "Name": "First", "IsFolder": False}, + {"Id": "folder-2", "Name": "Second", "IsFolder": True}, + ], type="json")).get_sdk_type() + self.assertEqual( + [(item.id, item.name, item.is_folder) for item in result], + [("file-1", "First", False), ("folder-2", "Second", True)], + ) + + def test_older_sdk_models_without_wire_metadata_are_supported(self): + """Map all Pascal-case fields even when the SDK omits wire metadata.""" + legacy_model = make_dataclass("LegacyBlobMetadata", [ + (model_field.name, Any, field(default=None)) + for model_field in fields(models.BlobMetadata) + ]) + record = { + "Id": "file-1", "Name": "Planning.docx", "NameNoExt": "Planning", + "DisplayName": "Planning", "Path": "/Planning.docx", + "LastModified": "2026-10-21T10:00:00Z", "Size": 0, + "MediaType": "application/octet-stream", "IsFolder": False, + "ETag": "version-1", "FileLocator": "locator-1", + "LastModifiedBy": "Example User", + } + expected = ( + "file-1", "Planning.docx", "Planning", "Planning", "/Planning.docx", + "2026-10-21T10:00:00Z", 0, "application/octet-stream", False, + "version-1", "locator-1", "Example User", + ) + with patch.object(_deserialization, "BlobMetadata", legacy_model): + result = BlobMetadata(data=Datum(value=record, type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual( + tuple(getattr(result[0], model_field.name) + for model_field in fields(legacy_model)), + expected, + ) + + def test_metadata_record_with_extra_value_field_is_not_a_batch(self): + """An extra field must not hide the record's SDK metadata fields.""" + result = BlobMetadata(data=Datum(value={ + "Id": "file-1", "Name": "Planning", "value": [], + }, type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, "file-1") + self.assertEqual(result[0].name, "Planning") + + def test_python_field_names_and_missing_optional_fields(self): + """Keep Python-field fallback and absent SDK fields nullable.""" + result = BlobMetadata(data=Datum(value={ + "id": "file-1", "name_no_ext": "Planning", "e_tag": "", + "last_modified_by": "", "is_folder": True, + }, type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, "file-1") + self.assertEqual(result[0].name_no_ext, "Planning") + self.assertEqual(result[0].e_tag, "") + self.assertEqual(result[0].last_modified_by, "") + self.assertIs(result[0].is_folder, True) + self.assertIsNone(result[0].name) + self.assertIsNone(result[0].size) + + def test_null_optional_fields_remain_null(self): + """Do not replace explicit nulls with empty strings or zero.""" + result = BlobMetadata(data=Datum(value={ + "Id": "file-1", "Size": None, "IsFolder": None, "ETag": None, + }, type="json")).get_sdk_type() + self.assertIsNone(result[0].size) + self.assertIsNone(result[0].is_folder) + self.assertIsNone(result[0].e_tag) + + def test_empty_batches_and_null_callback_bodies(self): + """No-work callbacks return an empty list rather than an empty model.""" + for payload in ( + [], {"body": []}, {"body": None}, {"value": []}, + {"value": None}, {"body": {"value": []}}, + {"body": {"@odata.context": "files", "value": None}}, + ): + for value in (payload, json.dumps(payload)): + with self.subTest(value=value): + self.assertEqual(BlobMetadata(data=Datum( + value=value, type="json", + )).get_sdk_type(), []) + + def test_poll_many_collections_flatten_records_and_source_arrays(self): + """Preserve order across mixed single-record and source-batch events.""" + events = [ + json.dumps({"body": {"Id": "file-1"}}), + json.dumps([{"Id": "file-2"}, {"Id": "file-3"}]), + json.dumps({"body": {"value": [{"Id": "file-4"}]}}), + ] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=events)), + ("collection_bytes", SimpleNamespace( + bytes=[event.encode("utf-8") for event in events], + )), + ): + with self.subTest(data_type=data_type): + result = ConnectorConverter.decode( + data=Datum(value=collection, type=data_type), + trigger_metadata=None, + pytype=List[BlobMetadata], + ) + self.assertEqual( + [item.id for item in result], + ["file-1", "file-2", "file-3", "file-4"], + ) + + def test_empty_poll_many_collections(self): + """Empty transport collections contain no files.""" + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=[])), + ("collection_bytes", SimpleNamespace(bytes=[])), + ): + with self.subTest(data_type=data_type): + self.assertEqual(BlobMetadata(data=Datum( + value=collection, type=data_type, + )).get_sdk_type(), []) + + def test_invalid_json_envelopes_and_batch_members_raise(self): + """Do not silently discard malformed source or transport events.""" + for value in ( + "not-json", b"\xff", 42, None, {"body": "invalid"}, + {"value": "invalid"}, {"value": [{"Id": "file-1"}, None]}, + {"body": [{"Id": "file-1"}, False]}, + [{"Id": "file-1"}, []], + ): + with self.subTest(value=value): + with self.assertRaises(ValueError): + ConnectorConverter.decode( + data=Datum(value=value, type="json"), + trigger_metadata=None, + pytype=BlobMetadata, + ) + + def test_binary_content_is_not_silently_bound_as_metadata(self): + """Encoded file-content envelopes are not BlobMetadata records.""" + content = {"$content-type": "application/octet-stream", "$content": "AA=="} + for payload in (content, {"body": content}, [content]): + with self.subTest(payload=payload): + with self.assertRaisesRegex(ValueError, "file content"): + BlobMetadata(data=Datum( + value=payload, type="json", + )).get_sdk_type() + + def test_missing_data_is_reported_consistently(self): + """Match the existing converter and wrapper missing-input contracts.""" + self.assertIsNone(ConnectorConverter.decode( + data=None, trigger_metadata=None, pytype=BlobMetadata, + )) + self.assertIsNone(ConnectorConverter.decode( + data=Datum(value={}, type=None), + trigger_metadata=None, pytype=BlobMetadata, + )) + with self.assertRaisesRegex(ValueError, "No data provided"): + BlobMetadata(data=None).get_sdk_type() + + def test_properties_trigger_payloads_return_sdk_metadata(self): + """Both plural operations use the same metadata item binding.""" + for operation in ("OnNewFilesV2", "OnUpdatedFilesV2"): + with self.subTest(operation=operation): + result = BlobMetadata(data=Datum(value=[{ + "Id": operation, "Name": "Planning.docx", + }], type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, operation) + self.assertEqual(result[0].name, "Planning.docx") + + def test_sdk_trigger_registry_distinguishes_metadata_and_content(self): + """Check registry evidence when exposed by the installed SDK version.""" + operations = getattr(models, "TRIGGER_OPERATIONS", None) + if operations is None: + self.skipTest("This SDK predates the OneDrive trigger registry.") + for operation in ("OnNewFilesV2", "OnUpdatedFilesV2"): + with self.subTest(operation=operation): + self.assertEqual( + operations[operation]["callback_payload_type"], "BlobMetadata", + ) + for operation in ("OnNewFileV2", "OnUpdatedFileV2"): + with self.subTest(operation=operation): + self.assertIsNone( + operations[operation]["callback_payload_type"], + ) diff --git a/azurefunctions-extensions-connectors/tests/test_planner.py b/azurefunctions-extensions-connectors/tests/test_planner.py new file mode 100644 index 0000000..97311df --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_planner.py @@ -0,0 +1,249 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Synthetic callback tests for Planner task trigger SDK bindings.""" + +import copy +import json +import unittest +from dataclasses import fields +from types import SimpleNamespace +from typing import List + +import azure.connectors.planner as models +import azurefunctions.extensions.connectors.planner as planner +from azurefunctions.extensions.base import Datum +from azurefunctions.extensions.connectors.planner import ( + ConnectorConverter, + GetTaskResponseV2, +) + + +class TestPlannerBindings(unittest.TestCase): + """Keep generated task items, category models, and assignment JSON intact.""" + + def test_public_binding_uses_generated_task_item(self): + """Expose the trigger item model, not the V3 action or list envelope.""" + self.assertIn("GetTaskResponseV2", planner.__all__) + self.assertIn("ConnectorConverter", planner.__all__) + self.assertTrue(issubclass(GetTaskResponseV2, models.GetTaskResponseV2)) + self.assertFalse(GetTaskResponseV2.supports_deferred_binding()) + for annotation in (GetTaskResponseV2, List[GetTaskResponseV2], + list[GetTaskResponseV2]): + with self.subTest(annotation=annotation): + self.assertTrue( + ConnectorConverter.check_input_type_annotation(annotation), + ) + result = ConnectorConverter.decode( + data=Datum(value={"id": "task-1"}, type="json"), + trigger_metadata=None, pytype=annotation, + ) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), models.GetTaskResponseV2) + self.assertNotIsInstance(result[0], models.ListTasksResponse) + self.assertEqual(result[0].id, "task-1") + self.assertFalse(ConnectorConverter.check_input_type_annotation( + models.GetTaskResponseV2, + )) + + def test_all_task_fields_and_nested_json_across_payload_shapes(self): + """Read all fourteen fields, including raw createdBy and assignments.""" + categories = {f"category{index}": index % 2 == 0 for index in range(1, 26)} + categories["category25"] = None + assignments = [{ + "userId": "user-1", + "value": { + "assignedBy": {"user": {"id": "assigner-1", "optional": None}}, + "assignedDateTime": "2026-10-21T10:00:00Z", + "orderHint": "", "custom": False, + }, + }] + record = { + "id": "task-1", "createdBy": {"user": {"id": "user-1"}, "extra": None}, + "planId": "plan-1", "bucketId": "bucket-1", "title": "Planning \u00e9quipe", + "percentComplete": 0, "startDateTime": "2026-10-21T10:00:00Z", + "createdDateTime": "2026-10-20T10:00:00Z", "dueDateTime": None, + "hasDescription": False, "completedDateTime": None, "referenceCount": 0, + "appliedCategories": categories, "_assignments": assignments, + } + original = copy.deepcopy(record) + for payload in ( + record, [record], {"body": record}, {"body": [record]}, + {"value": [record]}, + {"body": {"@odata.context": "tasks", "value": [record], + "@odata.nextLink": "next-page"}}, + ): + text = json.dumps(payload, ensure_ascii=False) + for value in ( + payload, text, text.encode("utf-8"), bytearray(text.encode()), + ): + with self.subTest(value=value): + result = GetTaskResponseV2(data=Datum( + value=value, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + task = result[0] + self.assertIs(type(task), models.GetTaskResponseV2) + for model_field in fields(models.GetTaskResponseV2): + if model_field.name == "applied_categories": + continue + wire_name = model_field.metadata.get( + "wire_name", model_field.name, + ) + self.assertEqual( + getattr(task, model_field.name), record[wire_name], + ) + self.assertIs( + type(task.applied_categories), models.AppliedCategories, + ) + for model_field in fields(models.AppliedCategories): + self.assertEqual( + getattr(task.applied_categories, model_field.name), + categories[model_field.name], + ) + self.assertEqual(record, original) + + def test_all_twenty_five_categories_are_available(self): + """Do not stop at the older six-category subset.""" + categories = {f"category{index}": True for index in range(1, 26)} + result = GetTaskResponseV2(data=Datum(value={ + "id": "task-1", "appliedCategories": categories, + }, type="json")).get_sdk_type() + self.assertEqual(len(fields(models.AppliedCategories)), 25) + for key in categories: + self.assertIs(getattr(result[0].applied_categories, key), True) + + def test_partial_and_null_optional_fields(self): + """Absent and explicit null fields retain the SDK nullable contract.""" + result = GetTaskResponseV2(data=Datum(value={ + "id": "task-1", "title": "", "appliedCategories": {"category1": False}, + "_assignments": [], + }, type="json")).get_sdk_type() + task = result[0] + self.assertEqual(task.title, "") + self.assertEqual(task.assignments, []) + self.assertIsNone(task.created_by) + self.assertIsNone(task.percent_complete) + self.assertIs(task.applied_categories.category1, False) + self.assertIsNone(task.applied_categories.category25) + null_task = GetTaskResponseV2(data=Datum(value={ + "id": "task-2", "appliedCategories": None, "_assignments": None, + "createdBy": None, "hasDescription": None, + }, type="json")).get_sdk_type()[0] + self.assertIsNone(null_task.applied_categories) + self.assertIsNone(null_task.assignments) + self.assertIsNone(null_task.created_by) + self.assertIsNone(null_task.has_description) + + def test_python_field_fallback_and_connector_assignment_precedence(self): + """The connector _assignments array wins over a Graph-style object.""" + assignments = [{"userId": "user-1", "value": {"optional": None}}] + for record in ( + {"id": "task-1", "assignments": assignments}, + {"id": "task-1", "_assignments": assignments, + "assignments": {"graph-user": {"orderHint": "invalid-for-connector"}}}, + ): + with self.subTest(record=record): + self.assertEqual(GetTaskResponseV2(data=Datum( + value=record, type="json", + )).get_sdk_type()[0].assignments, assignments) + task = GetTaskResponseV2(data=Datum(value={ + "id": "task-1", "plan_id": "plan-1", "percent_complete": 100, + "has_description": False, + }, type="json")).get_sdk_type()[0] + self.assertEqual(task.plan_id, "plan-1") + self.assertEqual(task.percent_complete, 100) + self.assertIs(task.has_description, False) + + def test_invalid_assignment_shapes_are_not_accepted_as_connector_arrays(self): + """Reject native Graph objects and malformed assignment array members.""" + for assignments in ({"user-1": {}}, "user-1", [None], [{"userId": "1"}, False]): + with self.subTest(assignments=assignments): + with self.assertRaisesRegex(ValueError, "assignments"): + GetTaskResponseV2(data=Datum(value={ + "id": "task-1", "_assignments": assignments, + }, type="json")).get_sdk_type() + + def test_poll_many_collections_preserve_order(self): + """Flatten transport events and source batches without paging requests.""" + events = [ + {"body": {"id": "task-1"}}, + {"value": [{"id": "task-2"}, {"id": "task-3"}]}, + [{"id": "task-4"}], + ] + strings = [json.dumps(event) for event in events] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=strings)), + ("collection_bytes", SimpleNamespace( + bytes=[value.encode() for value in strings], + )), + ): + with self.subTest(data_type=data_type): + tasks = GetTaskResponseV2(data=Datum( + value=collection, type=data_type, + )).get_sdk_type() + self.assertEqual([task.id for task in tasks], + ["task-1", "task-2", "task-3", "task-4"]) + + def test_empty_batches_and_transport_collections(self): + """No-work callbacks return an empty list, not a task with null fields.""" + for payload in ([], {"body": []}, {"body": None}, {"value": []}, + {"body": {"@odata.context": "tasks", "value": None}}): + with self.subTest(payload=payload): + self.assertEqual(GetTaskResponseV2(data=Datum( + value=payload, type="json", + )).get_sdk_type(), []) + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=[])), + ("collection_bytes", SimpleNamespace(bytes=[])), + ): + self.assertEqual(GetTaskResponseV2(data=Datum( + value=collection, type=data_type, + )).get_sdk_type(), []) + + def test_record_value_and_body_fields_do_not_hide_task_data(self): + """Extra record fields cannot masquerade as polling envelopes.""" + result = GetTaskResponseV2(data=Datum(value={ + "id": "task-1", "title": "Planning", "value": [], + "body": {"custom": "field"}, + }, type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, "task-1") + self.assertEqual(result[0].title, "Planning") + + def test_invalid_source_payloads_raise(self): + """Do not silently discard malformed JSON or batch members.""" + for payload in ("invalid", b"\xff", None, 1, {"body": "bad"}, + {"value": "bad"}, {"value": [{"id": "valid"}, None]}, + [{"id": "valid"}, False]): + with self.subTest(payload=payload): + with self.assertRaises(ValueError): + GetTaskResponseV2(data=Datum( + value=payload, type="json", + )).get_sdk_type() + + def test_missing_data_matches_existing_binding_contract(self): + """Converter no-data returns None, while direct wrappers report an error.""" + self.assertIsNone(ConnectorConverter.decode( + data=None, trigger_metadata=None, pytype=GetTaskResponseV2, + )) + with self.assertRaisesRegex(ValueError, "No data provided"): + GetTaskResponseV2(data=None).get_sdk_type() + + def test_all_three_trigger_operations_share_the_same_sdk_item_binding(self): + """Verify trigger envelope names independently of the binding return shape.""" + registry = getattr(models, "TRIGGER_OPERATIONS", None) + if registry is None: + self.skipTest("This SDK does not expose a Planner trigger registry.") + for operation in ("OnCompleteTask_V3", "OnNewTask_V3", "OnTaskAssignedToMe_V2"): + with self.subTest(operation=operation): + self.assertEqual(registry[operation]["method"], "get") + self.assertEqual( + registry[operation]["callback_payload_type"], "ListTasksResponse", + ) + result = GetTaskResponseV2(data=Datum(value={"body": {"value": [{ + "id": operation, "percentComplete": 100, + }]}}, type="json")).get_sdk_type() + self.assertEqual(result[0].id, operation) + self.assertEqual(result[0].percent_complete, 100) diff --git a/azurefunctions-extensions-connectors/tests/test_salesforce.py b/azurefunctions-extensions-connectors/tests/test_salesforce.py new file mode 100644 index 0000000..ea5d7d3 --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_salesforce.py @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Synthetic callback tests for Salesforce record trigger SDK bindings.""" + +import copy +import json +import unittest +from types import SimpleNamespace +from typing import List + +import azure.connectors.salesforce as models +import azurefunctions.extensions.connectors.salesforce as salesforce +from azurefunctions.extensions.base import Datum +from azurefunctions.extensions.connectors.salesforce import ConnectorConverter, Item + + +class TestSalesforceBindings(unittest.TestCase): + """Keep selected-object fields intact inside generated SDK item models.""" + + def test_public_binding_uses_the_salesforce_sdk_item(self): + """Expose SDK items without treating the envelope as the item model.""" + self.assertIn("Item", salesforce.__all__) + self.assertIn("ConnectorConverter", salesforce.__all__) + self.assertTrue(issubclass(Item, models.Item)) + self.assertFalse(Item.supports_deferred_binding()) + for annotation in (Item, List[Item], list[Item]): + with self.subTest(annotation=annotation): + self.assertTrue( + ConnectorConverter.check_input_type_annotation(annotation), + ) + result = ConnectorConverter.decode( + data=Datum(value={"Id": "record-1"}, type="json"), + trigger_metadata=None, pytype=annotation, + ) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), models.Item) + self.assertNotIsInstance(result[0], models.ItemsList) + self.assertEqual(result[0].dynamic_properties, {"Id": "record-1"}) + self.assertFalse(ConnectorConverter.check_input_type_annotation(models.Item)) + + def test_standard_and_custom_fields_keep_all_json_values(self): + """Do not lose nulls, custom fields, relationships, or attributes.""" + record = { + "Id": "record-1", "Name": "Example \u00e9quipe", + "Active__c": False, "Count__c": 0, "Amount__c": 12.5, + "Optional__c": None, "Empty__c": "", "Tags__c": ["one", None], + "attributes": {"type": "Account", "url": "/sobjects/Account/record-1"}, + "Owner": {"Id": "owner-1", "Name": None}, + "Children__r": {"records": [{"Id": "child-1", "Optional__c": None}]}, + } + original = copy.deepcopy(record) + for payload in ( + record, [record], {"body": record}, {"body": [record]}, + {"value": [record]}, + {"body": {"value": [record], "@odata.nextLink": "next-page"}}, + ): + text = json.dumps(payload, ensure_ascii=False) + for value in ( + payload, text, text.encode("utf-8"), bytearray(text.encode()), + ): + with self.subTest(value=value): + result = Item(data=Datum(value=value, type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), models.Item) + self.assertEqual(result[0].dynamic_properties, original) + self.assertEqual(record, original) + + def test_field_case_and_custom_names_are_not_normalized(self): + """The property bag preserves wire identity even for similar keys.""" + record = {"Id": "upper", "id": "lower", "Custom__c": 1, "custom__c": 2, + "Account__r": {"Id": "relationship"}, "@odata.etag": "etag"} + result = Item(data=Datum(value=record, type="json")).get_sdk_type() + self.assertEqual(result[0].dynamic_properties, record) + + def test_sole_explicit_sdk_property_bag_is_supported(self): + """Read the generated SDK bag without adding an extra nesting layer.""" + for key in ("dynamicProperties", "dynamic_properties"): + for properties in ({"Id": "record-1", "Optional__c": None}, {}, None): + with self.subTest(key=key, properties=properties): + result = Item(data=Datum(value={ + "body": {key: properties}, + }, type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].dynamic_properties, properties) + + def test_multi_key_records_do_not_merge_property_bag_named_fields(self): + """Preserve root fields and a simultaneous explicit bag without collisions.""" + for key in ("dynamicProperties", "dynamic_properties"): + record = {"Id": "root", key: {"Id": "nested", "Optional__c": None}} + with self.subTest(key=key): + result = Item(data=Datum(value=record, type="json")).get_sdk_type() + self.assertEqual(result[0].dynamic_properties, record) + + def test_record_value_and_body_fields_do_not_hide_other_columns(self): + """Only source envelopes, not selected-object records, get unwrapped.""" + for value in ([], None, [{"Id": "nested"}], "custom", False): + record = {"Id": "record-1", "value": value, "body": {"Text": "custom"}} + for payload in (record, {"body": record}, {"value": [record]}): + with self.subTest(payload=payload): + result = Item(data=Datum(value=payload, type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].dynamic_properties, record) + + def test_poll_many_collections_keep_order_and_nested_record_fields(self): + """Flatten transport events and source batches in delivery order.""" + records = [{"Id": "record-1"}, {"Id": "record-2", "Optional__c": None}, + {"Id": "record-3", "Enabled__c": False}] + events = [json.dumps({"body": records[0]}), + json.dumps({"value": records[1:]}), json.dumps([])] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=events)), + ("collection_bytes", SimpleNamespace( + bytes=[value.encode("utf-8") for value in events], + )), + ): + with self.subTest(data_type=data_type): + result = ConnectorConverter.decode( + data=Datum(value=collection, type=data_type), + trigger_metadata=None, pytype=List[Item], + ) + self.assertEqual([item.dynamic_properties for item in result], records) + + def test_empty_batches_and_transport_collections(self): + """No-work envelopes produce empty lists rather than empty SDK items.""" + for payload in ([], {"body": []}, {"body": None}, {"value": []}, + {"@odata.context": "records", "value": None}): + with self.subTest(payload=payload): + self.assertEqual(Item(data=Datum( + value=payload, type="json", + )).get_sdk_type(), []) + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=[])), + ("collection_bytes", SimpleNamespace(bytes=[])), + ): + self.assertEqual(Item(data=Datum( + value=collection, type=data_type, + )).get_sdk_type(), []) + empty_record = Item(data=Datum(value={}, type="json")).get_sdk_type() + self.assertEqual(len(empty_record), 1) + self.assertEqual(empty_record[0].dynamic_properties, {}) + + def test_invalid_payloads_and_batch_members_raise(self): + """Do not silently discard malformed records in an otherwise valid batch.""" + for payload in ("invalid", b"\xff", None, 1, {"body": "bad"}, + {"value": "bad"}, {"value": [{"Id": "valid"}, None]}, + [{"Id": "valid"}, False], {"body": [1]}): + with self.subTest(payload=payload): + with self.assertRaises(ValueError): + Item(data=Datum(value=payload, type="json")).get_sdk_type() + + def test_invalid_explicit_property_bags_raise(self): + """Validate sole SDK property bags instead of losing their content.""" + for key in ("dynamicProperties", "dynamic_properties"): + for value in ([], "bad", 1, False): + with self.subTest(key=key, value=value): + with self.assertRaisesRegex(ValueError, "dynamicProperties"): + Item(data=Datum(value={key: value}, type="json")).get_sdk_type() + + def test_missing_data_follows_existing_converter_contract(self): + """Converter no-data returns None; direct wrappers report missing input.""" + self.assertIsNone(ConnectorConverter.decode( + data=None, trigger_metadata=None, pytype=Item, + )) + with self.assertRaisesRegex(ValueError, "No data provided"): + Item(data=None).get_sdk_type() + + def test_both_mapped_trigger_operations_use_items_list_envelopes(self): + """Record creation and modification share this generated SDK item type.""" + registry = getattr(models, "TRIGGER_OPERATIONS", None) + if registry is None: + self.skipTest("This SDK does not expose a Salesforce trigger registry.") + for operation in ("GetOnNewItems", "GetOnUpdatedItems"): + with self.subTest(operation=operation): + self.assertEqual(registry[operation]["method"], "get") + self.assertEqual(registry[operation]["required_parameters"], ["table"]) + self.assertEqual( + registry[operation]["callback_payload_type"], "ItemsList", + ) + result = Item(data=Datum(value={"body": {"value": [{ + "Id": operation, "Custom__c": False, + }]}}, type="json")).get_sdk_type() + self.assertEqual(result[0].dynamic_properties, + {"Id": operation, "Custom__c": False}) diff --git a/azurefunctions-extensions-connectors/tests/test_sdk_model_deserialization.py b/azurefunctions-extensions-connectors/tests/test_sdk_model_deserialization.py new file mode 100644 index 0000000..9d4b2ed --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_sdk_model_deserialization.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Regression tests for generated model-or-mapping annotations.""" + +import unittest +from dataclasses import dataclass +from typing import Any, Mapping + +from azurefunctions.extensions.connectors._deserialization import deserialize_model + + +@dataclass +class NestedModel: + """Represent a generated nested SDK model.""" + + id: str | None = None + + +@dataclass +class ContainerModel: + """Mirror the SDK's nullable model-or-mapping and collection fields.""" + + child: NestedModel | Mapping[str, Any] | None = None + children: list[NestedModel | Mapping[str, Any]] | None = None + properties: Mapping[str, Any] | None = None + ambiguous: NestedModel | str | None = None + + +class TestSdkModelDeserialization(unittest.TestCase): + """Keep generated mapping fallbacks from disabling typed receive models.""" + + def test_model_mapping_union_deserializes_nested_model(self): + """Prefer the unique model when the other branch accepts mappings.""" + result = deserialize_model(ContainerModel, {"child": {"id": "child-1"}}) + self.assertIsInstance(result.child, NestedModel) + self.assertEqual(result.child.id, "child-1") + + def test_model_mapping_union_deserializes_collection_items(self): + """Apply the same rule inside generated list annotations.""" + result = deserialize_model(ContainerModel, {"children": [ + {"id": "child-1"}, {"id": "child-2"}, + ]}) + self.assertEqual(len(result.children), 2) + self.assertTrue(all( + isinstance(child, NestedModel) for child in result.children + )) + self.assertEqual( + [child.id for child in result.children], ["child-1", "child-2"], + ) + + def test_null_union_and_untyped_mapping_are_preserved(self): + """Do not convert arbitrary JSON objects into guessed model types.""" + properties = {"Optional": None, "Count": 0, "Nested": {"Enabled": False}} + result = deserialize_model(ContainerModel, { + "child": None, "properties": properties, + }) + self.assertIsNone(result.child) + self.assertEqual(result.properties, properties) + + def test_non_mapping_union_is_not_guessed(self): + """Leave unrelated unions on the existing passthrough path.""" + for value in ("text", {"id": "unresolved"}): + with self.subTest(value=value): + result = deserialize_model(ContainerModel, {"ambiguous": value}) + self.assertEqual(result.ambiguous, value) diff --git a/azurefunctions-extensions-connectors/tests/test_sharepointonline.py b/azurefunctions-extensions-connectors/tests/test_sharepointonline.py new file mode 100644 index 0000000..603bc69 --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_sharepointonline.py @@ -0,0 +1,283 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for SharePoint Online trigger SDK bindings.""" + +import copy +import json +import unittest +from types import SimpleNamespace +from typing import List + +import azure.connectors.sharepointonline as models +import azurefunctions.extensions.connectors.sharepointonline as sharepointonline +from azurefunctions.extensions.base import Datum +from azurefunctions.extensions.connectors.sharepointonline import ( + ConnectorConverter, + DeletedItem, + Item, +) + + +class TestSharePointOnlineBindings(unittest.TestCase): + """Preserve dynamic columns and deserialize deletion metadata.""" + + def test_public_bindings_use_generated_sdk_models(self): + """Expose item fields while reusing the common converter.""" + for binding_type, model_type in ( + (Item, models.Item), (DeletedItem, models.DeletedItem), + ): + with self.subTest(binding=binding_type): + self.assertIn(binding_type.__name__, sharepointonline.__all__) + self.assertTrue(issubclass(binding_type, model_type)) + self.assertFalse(binding_type.supports_deferred_binding()) + + def test_converter_accepts_direct_and_list_bindings(self): + """Recognize both SharePoint bindings without a type registry.""" + for annotation in ( + Item, List[Item], list[Item], + DeletedItem, List[DeletedItem], list[DeletedItem], + ): + with self.subTest(annotation=annotation): + self.assertTrue( + ConnectorConverter.check_input_type_annotation(annotation), + ) + self.assertIsNone(ConnectorConverter.decode( + data=None, trigger_metadata=None, pytype=annotation, + )) + + def test_generated_models_are_not_binding_annotations(self): + """Require extension wrappers instead of plain generated models.""" + for annotation in (models.Item, models.DeletedItem, List[models.Item]): + with self.subTest(annotation=annotation): + self.assertFalse( + ConnectorConverter.check_input_type_annotation(annotation), + ) + + def test_item_preserves_flat_dynamic_columns_and_nested_values(self): + """Retain raw JSON values without request-serialization conversions.""" + record = { + "ID": 42, + "Title": "Planning \u00e9quipe", + "CustomFlag": False, + "CustomCount": 0, + "CustomText": "", + "CustomValue": None, + "Author": {"DisplayName": "Example", "Email": None}, + "Tags": ["one", "two"], + "Children": [{"ID": 1}, None, False], + "{FilenameWithExtension}": "plan.docx", + "@odata.etag": '"1"', + } + original = copy.deepcopy(record) + for payload in ( + record, + {"body": record}, + {"value": [record]}, + {"body": {"@odata.context": "items", "value": [record]}}, + [record], + {"body": [record]}, + ): + for value in ( + payload, json.dumps(payload), json.dumps(payload).encode("utf-8"), + ): + with self.subTest(value=value): + result = ConnectorConverter.decode( + data=Datum(value=value, type="json"), + trigger_metadata=None, + pytype=List[Item], + ) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], models.Item) + self.assertEqual(result[0].dynamic_properties, original) + self.assertEqual(record, original) + + def test_item_supports_explicit_sdk_property_bag(self): + """Read the SDK's sole dynamicProperties object without nesting it.""" + properties = {"ID": 42, "Title": "Example", "OptionalColumn": None} + for property_name in ("dynamicProperties", "dynamic_properties"): + for value in (properties, {}, None): + with self.subTest(property_name=property_name, value=value): + result = Item(data=Datum(value={ + "body": {property_name: value}, + }, type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].dynamic_properties, value) + + def test_item_does_not_flatten_property_bag_named_columns(self): + """Keep all columns when a record also has a dynamicProperties key.""" + record = {"ID": 42, "dynamicProperties": {"Title": "Nested column"}} + result = Item(data=Datum(value={"body": record}, type="json")).get_sdk_type() + self.assertEqual(result[0].dynamic_properties, record) + + def test_value_and_body_columns_are_not_envelopes(self): + """Do not drop records whose custom columns use envelope-like names.""" + for value_column in ([{"ID": 100}], [], None, "custom", 0, False): + record = { + "ID": 42, + "value": value_column, + "body": {"Text": "Custom body column"}, + } + for payload in (record, {"body": record}, {"value": [record]}): + with self.subTest(payload=payload): + result = Item(data=Datum( + value=payload, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].dynamic_properties, record) + + def test_deleted_item_reads_all_wire_fields(self): + """Honor ID and Pascal-case wire names, including false and zero.""" + record = { + "ID": 0, + "Name": "Planning", + "FileNameWithExtension": "plan.docx", + "DeletedByUserName": "Example User", + "TimeDeleted": "2026-10-21T10:00:00Z", + "IsFolder": False, + } + for payload in (record, {"body": record}, {"value": [record]}, [record]): + for value in (payload, json.dumps(payload)): + with self.subTest(value=value): + result = ConnectorConverter.decode( + data=Datum(value=value, type="json"), + trigger_metadata=None, + pytype=List[DeletedItem], + ) + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], models.DeletedItem) + self.assertEqual(result[0].id, 0) + self.assertEqual(result[0].name, "Planning") + self.assertEqual(result[0].file_name_with_extension, "plan.docx") + self.assertEqual(result[0].deleted_by_user_name, "Example User") + self.assertEqual(result[0].time_deleted, record["TimeDeleted"]) + self.assertIs(result[0].is_folder, False) + + def test_deleted_item_accepts_python_field_names(self): + """Keep the shared deserializer's Python-field fallback.""" + result = DeletedItem(data=Datum(value={ + "id": 1, "name": "Folder", "is_folder": True, + }, type="json")).get_sdk_type() + self.assertEqual(result[0].id, 1) + self.assertEqual(result[0].name, "Folder") + self.assertIs(result[0].is_folder, True) + self.assertIsNone(result[0].time_deleted) + + def test_empty_batches_and_null_callback_bodies(self): + """Represent no-work callbacks as empty lists for both bindings.""" + for binding_type in (Item, DeletedItem): + for payload in ( + [], {"body": None}, {"body": []}, {"value": []}, + {"body": {"value": None}}, + {"@odata.context": "items", "value": []}, + ): + with self.subTest(binding=binding_type, payload=payload): + self.assertEqual(binding_type(data=Datum( + value=payload, type="json", + )).get_sdk_type(), []) + + def test_poll_many_collections_preserve_both_payload_families(self): + """Flatten transport events and source batches in order.""" + records = [{"ID": 1, "Name": "First"}, {"ID": 2, "Name": "Second"}] + events = [ + json.dumps({"body": records[0]}), + json.dumps({"body": {"value": records[1:]}}), + ] + for binding_type in (Item, DeletedItem): + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=events)), + ("collection_bytes", SimpleNamespace( + bytes=[event.encode("utf-8") for event in events], + )), + ): + with self.subTest(binding=binding_type, data_type=data_type): + result = ConnectorConverter.decode( + data=Datum(value=collection, type=data_type), + trigger_metadata=None, + pytype=binding_type, + ) + if binding_type is Item: + self.assertEqual( + [item.dynamic_properties for item in result], records, + ) + else: + self.assertEqual( + [(item.id, item.name) for item in result], + [(1, "First"), (2, "Second")], + ) + + def test_empty_poll_many_collections(self): + """An empty transport collection contains no records.""" + for binding_type in (Item, DeletedItem): + with self.subTest(binding=binding_type): + self.assertEqual(binding_type(data=Datum( + value=SimpleNamespace(string=[]), type="collection_string", + )).get_sdk_type(), []) + + def test_invalid_payloads_raise_instead_of_dropping_records(self): + """Reject malformed JSON, envelopes, and batch members.""" + for binding_type in (Item, DeletedItem): + for value in ( + "not-json", 42, None, {"body": "not-an-object"}, + {"value": "not-an-array"}, {"value": [{"ID": 1}, None]}, + {"body": {"value": [False]}}, [{"ID": 1}, 2], + ): + with self.subTest(binding=binding_type, value=value): + with self.assertRaises(ValueError): + ConnectorConverter.decode( + data=Datum(value=value, type="json"), + trigger_metadata=None, + pytype=binding_type, + ) + + def test_missing_data_raises_for_both_wrappers(self): + """Direct wrapper construction still reports missing input.""" + for binding_type in (Item, DeletedItem): + with self.subTest(binding=binding_type): + with self.assertRaisesRegex(ValueError, "No data provided"): + binding_type(data=None).get_sdk_type() + + def test_invalid_explicit_property_bag_raises(self): + """Do not hide invalid SDK property bags as empty items.""" + for value in ([], "invalid", 1, False): + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "dynamicProperties"): + Item(data=Datum(value={ + "dynamicProperties": value, + }, type="json")).get_sdk_type() + + def test_all_model_backed_trigger_operations_have_item_bindings(self): + """Cover eight item operations and both deletion operations.""" + operation_groups = ( + ( + ("GetOnNewItems", "GetOnUpdatedItems", "GetOnChangedItems", + "GetOnNewFileItems", "GetOnUpdatedFileItems", + "GetOnUpdatedFileClassifiedTimes", "GetOnNewItemsFromForm", + "GetOnRecurrenceDigest"), + "ItemsList", Item, + ), + ( + ("GetOnDeletedItems", "GetOnDeletedFileItems"), + "DeletedItemList", DeletedItem, + ), + ) + for operations, envelope, binding_type in operation_groups: + for operation in operations: + with self.subTest(operation=operation): + self.assertEqual( + models.TRIGGER_OPERATIONS[operation]["callback_payload_type"], + envelope, + ) + result = binding_type(data=Datum(value={"body": {"value": [{ + "ID": 42, "Name": operation, + }]}}, type="json")).get_sdk_type() + self.assertEqual(len(result), 1) + if binding_type is Item: + self.assertIsInstance(result[0], models.Item) + self.assertEqual( + result[0].dynamic_properties["Name"], operation, + ) + else: + self.assertIsInstance(result[0], models.DeletedItem) + self.assertEqual(result[0].name, operation) diff --git a/azurefunctions-extensions-connectors/tests/test_teams.py b/azurefunctions-extensions-connectors/tests/test_teams.py new file mode 100644 index 0000000..d1d6596 --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_teams.py @@ -0,0 +1,316 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Synthetic callback tests for the eleven documented Teams trigger operations.""" + +import copy +import json +import unittest +from dataclasses import fields +from types import SimpleNamespace +from typing import List + +import azure.connectors.teams as models +import azurefunctions.extensions.connectors.teams as teams +from azurefunctions.extensions.base import Datum +from azurefunctions.extensions.connectors.connectorConverter import ConnectorConverter + + +MODEL_BINDING_NAMES = ( + "ChatMessage", + "ChatMessageWebhookResponseSchema", + "DynamicNewMessageWebhookTriggerResponseSchema", + "DynamicAtMentionWebhookTriggerResponseSchema", + "DynamicKeywordWebhookTriggerResponseSchema", + "MessageReactionWebhookResponseSchema", + "RecordingWebhookResponseSchema", + "TranscriptWebhookResponseSchema", +) +DYNAMIC_BINDING_NAMES = ( + "DynamicNewMessageWebhookTriggerResponseSchema", + "DynamicAtMentionWebhookTriggerResponseSchema", + "DynamicKeywordWebhookTriggerResponseSchema", +) + + +class TestTeamsBindings(unittest.TestCase): + """Keep polling items, dynamic events, and webhook envelopes distinct.""" + + def test_model_bindings_export_generated_models_and_annotations(self): + """Expose each SDK model through a discoverable extension binding.""" + for name in MODEL_BINDING_NAMES: + binding_type = getattr(teams, name) + with self.subTest(name=name): + self.assertIn(name, teams.__all__) + self.assertTrue(issubclass(binding_type, getattr(models, name))) + self.assertFalse(binding_type.supports_deferred_binding()) + for annotation in ( + binding_type, List[binding_type], list[binding_type], + ): + self.assertTrue( + ConnectorConverter.check_input_type_annotation(annotation), + ) + self.assertIsNone(ConnectorConverter.decode( + data=None, trigger_metadata=None, pytype=annotation, + )) + self.assertFalse(ConnectorConverter.check_input_type_annotation( + getattr(models, name), + )) + + def test_channel_messages_preserve_all_fields_and_their_own_body(self): + """Deserialize the SDK item while retaining nested JSON dictionaries.""" + record = { + "id": "message-1", "body": {"content": "Hello", "contentType": "text"}, + "from": {"user": {"id": "user-1", "optional": None}}, + "attachments": [{"id": "attachment-1", "custom": False}], + "createdDateTime": "2026-10-21T10:00:00Z", "deleted": False, + "etag": "", "importance": "normal", + "lastModifiedDateTime": "2026-10-21T11:00:00Z", "locale": "en-US", + "mentions": [{"id": 0}], "messageType": "message", + "reactions": [{"reactionType": "like"}], "replyToId": "parent-1", + "subject": None, "summary": "Summary", + } + original = copy.deepcopy(record) + for payload in (record, [record], {"body": record}, {"body": [record]}, + {"value": [record]}, {"body": {"value": [record]}}): + for value in (payload, json.dumps(payload), json.dumps(payload).encode()): + with self.subTest(value=value): + result = teams.ChatMessage(data=Datum( + value=value, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + message = result[0] + self.assertIs(type(message), models.ChatMessage) + for model_field in fields(models.ChatMessage): + wire_name = model_field.metadata.get( + "wire_name", model_field.name, + ) + self.assertEqual( + getattr(message, model_field.name), record[wire_name], + ) + self.assertEqual(record, original) + + def test_membership_binding_preserves_arbitrary_json_records(self): + """The SDK membership alias has dictionaries, not a fixed item model.""" + record = {"id": "user-1", "body": {"enabled": False}, "value": [], + "count": 0, "optional": None, "roles": ["member"]} + self.assertIn("OnGroupMemberChangeResponse", teams.__all__) + self.assertFalse(teams.OnGroupMemberChangeResponse.supports_deferred_binding()) + for annotation in (teams.OnGroupMemberChangeResponse, + List[teams.OnGroupMemberChangeResponse]): + self.assertTrue(ConnectorConverter.check_input_type_annotation(annotation)) + for payload in (record, [record], {"body": record}, {"value": [record]}): + with self.subTest(annotation=annotation, payload=payload): + result = ConnectorConverter.decode( + data=Datum(value=payload, type="json"), + trigger_metadata=None, pytype=annotation, + ) + self.assertEqual(result, [record]) + self.assertIs(type(result[0]), dict) + + def test_chat_webhook_preserves_notification_envelope_boundaries(self): + """Keep reference collections inside their generated SDK envelope.""" + references = [ + {"conversationId": "chat-1", "messageId": "message-1", + "linkToMessage": "https://example.test/message", "optional": None}, + {"conversationId": "chat-2", "messageId": "message-2"}, + ] + for notification_value in (references, [], None): + for payload in ({"value": notification_value}, + {"body": {"value": notification_value}}): + for value in (payload, json.dumps(payload)): + with self.subTest(value=value): + result = teams.ChatMessageWebhookResponseSchema(data=Datum( + value=value, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertIs( + type(result[0]), models.ChatMessageWebhookResponseSchema, + ) + self.assertEqual(result[0].value, notification_value) + + def test_chat_webhook_rejects_invalid_reference_collections(self): + """Validate the envelope's own value array rather than dropping entries.""" + for value in ("invalid", [{"messageId": "valid"}, None], [False], {}): + with self.subTest(value=value): + with self.assertRaises(ValueError): + teams.ChatMessageWebhookResponseSchema(data=Datum( + value={"body": {"value": value}}, type="json", + )).get_sdk_type() + + def test_dynamic_webhooks_preserve_entire_notification_objects(self): + """Do not interpret arbitrary value or additional_properties columns.""" + record = {"id": "message-1", "body": {"content": "Hello"}, + "value": [{"id": "nested"}], "enabled": False, "count": 0, + "optional": None, "additional_properties": {"custom": True}} + original = copy.deepcopy(record) + for name in DYNAMIC_BINDING_NAMES: + binding_type = getattr(teams, name) + for payload in (record, {"body": record}, [record]): + for value in (payload, json.dumps(payload)): + with self.subTest(name=name, value=value): + result = binding_type(data=Datum( + value=value, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), getattr(models, name)) + self.assertEqual(result[0].additional_properties, record) + self.assertEqual(record, original) + + def test_dynamic_webhook_value_only_notifications_are_not_batches(self): + """Preserve empty and null value fields as notification data.""" + for name in DYNAMIC_BINDING_NAMES: + for value in ([], None, [{"messageId": "nested"}]): + with self.subTest(name=name, value=value): + result = getattr(teams, name)(data=Datum( + value={"value": value}, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].additional_properties, {"value": value}) + + def test_reaction_webhook_maps_all_nine_fields(self): + """Use the notification schema, not a subscription acknowledgement.""" + record = {"threadType": "chat", "chatId": "chat-1", "teamId": "", + "channelId": None, "messageId": "message-1", "replyToId": "parent", + "messageLink": "https://example.test/message", "userId": "user-1", + "messageReaction": "like"} + result = teams.MessageReactionWebhookResponseSchema(data=Datum( + value={"body": record}, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + for model_field in fields(models.MessageReactionWebhookResponseSchema): + self.assertEqual( + getattr(result[0], model_field.name), + record[model_field.metadata["wire_name"]], + ) + + def test_recording_and_transcript_preserve_ids_and_nested_identities(self): + """Keep id and @odata.id separate, with a typed organizer model.""" + organizer = {"user": {"id": "user-1", "@odata.type": "identity", + "userPrincipalName": None}, + "application": {"id": "app-1", "custom": False}, + "device": {"id": "device-1"}} + for name, content_field in ( + ("RecordingWebhookResponseSchema", "recordingContentUrl"), + ("TranscriptWebhookResponseSchema", "transcriptContentUrl"), + ): + record = {"id": "resource-1", "@odata.id": "odata-resource-1", + "meetingId": "", "callId": "call-1", + "contentCorrelationId": "correlation-1", + "meetingOrganizer": organizer, + content_field: "https://example.test/content", + "createdDateTime": "2026-10-21T10:00:00Z", "endDateTime": None} + for payload in (record, {"body": record}, [record]): + with self.subTest(name=name, payload=payload): + result = getattr(teams, name)(data=Datum( + value=payload, type="json", + )).get_sdk_type() + self.assertEqual(len(result), 1) + item = result[0] + self.assertIs(type(item), getattr(models, name)) + self.assertEqual(item.id, "resource-1") + self.assertEqual(item.id_2, "odata-resource-1") + self.assertEqual(item.meeting_id, "") + self.assertEqual(item.call_id, "call-1") + self.assertEqual(item.content_correlation_id, "correlation-1") + self.assertIs(type(item.meeting_organizer), models.TeamsIdentitySet) + self.assertEqual(item.meeting_organizer.user, organizer["user"]) + self.assertEqual( + item.meeting_organizer.application, organizer["application"], + ) + self.assertEqual(item.meeting_organizer.device, organizer["device"]) + self.assertEqual(item.created_date_time, record["createdDateTime"]) + self.assertIsNone(item.end_date_time) + self.assertEqual( + getattr( + item, + "recording_content_url" if name.startswith("Recording") + else "transcript_content_url", + ), record[content_field], + ) + + def test_poll_many_keeps_webhook_boundaries_and_flattens_polling_batches(self): + """Choose normalization by the selected binding, not the transport type.""" + cases = ( + (teams.ChatMessage, [{"body": {"id": "1"}}, [{"id": "2"}]], 2), + (teams.OnGroupMemberChangeResponse, + [[{"id": "1"}], {"value": [{"id": "2"}]}], 2), + (teams.ChatMessageWebhookResponseSchema, + [{"value": [{"messageId": "1"}, {"messageId": "2"}]}, {"value": []}], 2), + (teams.DynamicNewMessageWebhookTriggerResponseSchema, + [{"value": []}, {"value": [{"id": "2"}]}], 2), + ) + for binding_type, events, expected_count in cases: + strings = [json.dumps(event) for event in events] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=strings)), + ("collection_bytes", SimpleNamespace( + bytes=[value.encode() for value in strings], + )), + ): + with self.subTest(binding=binding_type, data_type=data_type): + result = binding_type(data=Datum( + value=collection, type=data_type, + )).get_sdk_type() + self.assertEqual(len(result), expected_count) + if binding_type is teams.ChatMessage: + self.assertEqual([item.id for item in result], ["1", "2"]) + elif binding_type is teams.OnGroupMemberChangeResponse: + self.assertEqual(result, [{"id": "1"}, {"id": "2"}]) + elif binding_type is teams.ChatMessageWebhookResponseSchema: + self.assertEqual(result[0].value, events[0]["value"]) + self.assertEqual(result[1].value, []) + else: + self.assertEqual( + [item.additional_properties for item in result], events, + ) + + def test_empty_and_malformed_payloads_for_all_bindings(self): + """Reject bad members and preserve missing-data and no-work contracts.""" + for name in MODEL_BINDING_NAMES + ("OnGroupMemberChangeResponse",): + binding_type = getattr(teams, name) + for payload in ([], {"body": None}): + with self.subTest(name=name, empty=payload): + self.assertEqual(binding_type(data=Datum( + value=payload, type="json", + )).get_sdk_type(), []) + self.assertEqual(binding_type(data=Datum( + value=SimpleNamespace(string=[]), type="collection_string", + )).get_sdk_type(), []) + for payload in ("not-json", b"\xff", None, 1, {"body": "bad"}, + [{"id": "valid"}, None]): + with self.subTest(name=name, invalid=payload): + with self.assertRaises(ValueError): + binding_type(data=Datum( + value=payload, type="json", + )).get_sdk_type() + with self.assertRaisesRegex(ValueError, "No data provided"): + binding_type(data=None).get_sdk_type() + + def test_polling_empty_and_invalid_value_batches(self): + """Only polling bindings extract value as a source batch.""" + for binding_type in (teams.ChatMessage, teams.OnGroupMemberChangeResponse): + for value in ([], None): + self.assertEqual(binding_type(data=Datum( + value={"value": value}, type="json", + )).get_sdk_type(), []) + for value in ("bad", [{"id": "valid"}, False]): + with self.assertRaises(ValueError): + binding_type(data=Datum( + value={"value": value}, type="json", + )).get_sdk_type() + + def test_registry_contains_core_operations_when_available(self): + """Older SDK registries omit recording and transcript despite their models.""" + registry = getattr(models, "TRIGGER_OPERATIONS", None) + if registry is None: + self.skipTest("This SDK does not expose a Teams trigger registry.") + for operation in ( + "OnNewChannelMessage", "OnNewChannelMessageMentioningMe", + "OnGroupMembershipAdd", "OnGroupMembershipRemoval", + "WebhookChatMessageTrigger", "WebhookNewMessageTrigger", + "WebhookAtMentionTrigger", "WebhookKeywordTrigger", + "WebhookMessageReactionTrigger", + ): + with self.subTest(operation=operation): + self.assertIn(operation, registry) diff --git a/azurefunctions-extensions-connectors/tests/test_visualstudioteamservices.py b/azurefunctions-extensions-connectors/tests/test_visualstudioteamservices.py new file mode 100644 index 0000000..b42042e --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_visualstudioteamservices.py @@ -0,0 +1,334 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Synthetic Azure DevOps polling callbacks against the generated SDK models.""" + +import copy +import json +import unittest +from dataclasses import fields +from types import SimpleNamespace +from typing import List + +import azure.connectors.visualstudioteamservices as models +import azurefunctions.extensions.connectors.visualstudioteamservices as devops +from azurefunctions.extensions.base import Datum + + +class TestDevOpsBindings(unittest.TestCase): + """Verify item identity, nested types, custom fields, and source batches.""" + + def binding_types(self): + """Return the five binding names shared by the nine polling triggers.""" + return (devops.BuildResult, devops.GitPush, devops.GitPullRequest, + devops.TfvcChangeset, devops.TriggerWorkItemResponse) + + def decode(self, binding, payload): + """Send a decoded callback through the Functions converter.""" + return devops.ConnectorConverter.decode( + data=Datum(value=payload, type="json"), trigger_metadata=None, + pytype=List[binding], + ) + + def test_exports_annotations_and_exact_sdk_item_types(self): + """Accept direct and list annotations without returning response envelopes.""" + for binding in self.binding_types(): + model = getattr(models, binding.__name__) + self.assertIn(binding.__name__, devops.__all__) + self.assertTrue(issubclass(binding, model)) + self.assertFalse(binding.supports_deferred_binding()) + for annotation in (binding, List[binding], list[binding]): + with self.subTest(binding=binding, annotation=annotation): + self.assertTrue(devops.ConnectorConverter + .check_input_type_annotation(annotation)) + result = devops.ConnectorConverter.decode( + data=Datum(value={}, type="json"), trigger_metadata=None, + pytype=annotation, + ) + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), model) + self.assertFalse(devops.ConnectorConverter + .check_input_type_annotation(model)) + + def test_nine_polling_operations_match_generated_registry(self): + """Use current item contracts, not webhook or action-response classes.""" + operations = { + "OnBuildCompleted": (devops.BuildResult, "VstsListBuildResult"), + "OnGitPush": (devops.GitPush, "VstsListGitPush"), + "OnGitPullCreated": (devops.GitPullRequest, "VstsListGitPullRequest"), + "OnGitPullClosed": (devops.GitPullRequest, "VstsListGitPullRequest"), + "OnTfvcCheckIn": (devops.TfvcChangeset, "VstsListTfvcChangeset"), + **{operation: (devops.TriggerWorkItemResponse, + "VstsListTriggerWorkItemResponse") for operation in ( + "OnWorkItemAssignedV2", "OnWorkItemClosedV2", + "OnWorkItemCreatedV2", "OnWorkItemUpdatedV2", + )}, + } + self.assertEqual(set(models.TRIGGER_OPERATIONS), set(operations)) + for operation, (binding, envelope) in operations.items(): + with self.subTest(operation=operation): + entry = models.TRIGGER_OPERATIONS[operation] + self.assertEqual(entry["method"], "get") + self.assertEqual(entry["callback_payload_type"], envelope) + required = ["account", "project"] + if operation.startswith("OnGit"): + required += ["repository"] + elif operation == "OnWorkItemAssignedV2": + required += ["team", "wiql__System_AssignedTo"] + self.assertEqual(entry["required_parameters"], required) + result = self.decode(binding, {"value": [{}]}) + self.assertIs(type(result[0]), getattr(models, binding.__name__)) + self.assertNotIsInstance(result[0], getattr(models, envelope)) + + def test_build_fields_links_and_nested_sdk_models(self): + """Map _links and convert generated user, definition, and web-link models.""" + record = { + "id": 0, "buildNumber": "", "sourceBranch": "refs/heads/main", + "sourceVersion": "commit", "status": "completed", "priority": "normal", + "queueTime": "2026-10-28T01:00:00Z", "startTime": None, + "finishTime": "2026-10-28T01:01:00Z", "reason": "manual", + "result": "succeeded", "requestedFor": {"uniqueName": "synthetic"}, + "parameters": '{"flag":false}', "definition": {"id": 0, "name": ""}, + "_links": {"web": {"href": "https://example.test/build"}}, + "links": {"web": {"href": "wrong-fallback"}}, + "tags": [], "templateParameters": {"flag": "false"}, + } + build = self.decode(devops.BuildResult, record)[0] + self.assertEqual(build.id, 0) + for name in ("build_number", "source_branch", "source_version", "status", + "priority", "queue_time", "start_time", "finish_time", + "reason", "result", "parameters", "tags", "template_parameters"): + field = next(field for field in fields(models.BuildResult) + if field.name == name) + self.assertEqual(getattr(build, name), record[field.metadata.get( + "wire_name", name, + )]) + self.assertIs(type(build.requested_for), models.BuildRequestUser) + self.assertEqual(build.requested_for.unique_name, "synthetic") + self.assertIs(type(build.definition), models.BuildResultDefinition) + self.assertEqual((build.definition.id, build.definition.name), (0, "")) + self.assertIs(type(build.links), models.Links) + self.assertIs(type(build.links.web), models.WebLinks) + self.assertEqual(build.links.web.href, "https://example.test/build") + + def test_git_push_nested_models_and_zero_identifier(self): + """Build typed repository, identity, and ref-update SDK objects.""" + record = { + "repository": {"id": "repository", "name": "", "url": "api", + "remoteUrl": "remote"}, + "pushedBy": {"id": "user", "displayName": "", "uniqueName": "name", + "imageUrl": None}, + "refUpdates": [{"name": "refs/heads/main", "repositoryId": "repo", + "oldObjectId": "old", "newObjectId": "new"}], + "pushId": 0, "date": "2026-10-28T01:00:00Z", + } + push = self.decode(devops.GitPush, record)[0] + self.assertIs(type(push.repository), models.GitRepository) + self.assertEqual(push.repository.remote_url, "remote") + self.assertEqual(push.repository.name, "") + self.assertIs(type(push.pushed_by), models.GitPushUser) + self.assertEqual(push.pushed_by.unique_name, "name") + self.assertIsNone(push.pushed_by.image_url) + self.assertIs(type(push.ref_updates[0]), models.GitRefUpdate) + self.assertEqual(push.ref_updates[0].repository_id, "repo") + self.assertEqual(push.ref_updates[0].old_object_id, "old") + self.assertEqual(push.ref_updates[0].new_object_id, "new") + self.assertEqual((push.push_id, push.date), (0, record["date"])) + + def test_pull_request_recursive_reviewers_commits_and_options(self): + """Preserve false options, zero votes, and nested reviewer collections.""" + record = { + "repository": {"id": "repo"}, "pullRequestId": 0, + "createdBy": {"id": "creator"}, "closedBy": {"id": "closer"}, + "autoCompleteSetBy": {"id": "auto"}, "creationDate": "created", + "closedDate": None, "title": "", "description": None, + "sourceRefName": "refs/heads/feature", "targetRefName": "refs/heads/main", + "isDraft": False, "reviewers": [{ + "id": "reviewer", "vote": 0, "hasDeclined": False, + "isFlagged": False, "isRequired": False, + "votedFor": [{"id": "team", "votedFor": []}], + }], + "commits": [{"commitId": "commit", "comment": ""}], + "completionOptions": {"deleteSourceBranch": False}, + "mergeOptions": {"noFastForward": "false", "rebase": "false", + "rebaseMerge": "false", "squash": "false"}, + "mergeFailureMessage": "", "reviewerList": "", "requiredReviewerList": "", + "url": "api", "remoteUrl": "remote", "artifactId": "artifact", + "mergeId": "merge", "codeReviewId": 0, "completionQueueTime": "queued", + "supportsIterations": False, + } + pull = self.decode(devops.GitPullRequest, record)[0] + self.assertEqual((pull.pull_request_id, pull.is_draft, pull.code_review_id), + (0, False, 0)) + self.assertFalse(pull.supports_iterations) + for name, model in ( + ("repository", models.GitRepository), + ("created_by", models.PullRequestCreatedBy), + ("closed_by", models.PullRequestClosedBy), + ("auto_complete_set_by", models.PullRequestAutoCompleteSetBy), + ): + self.assertIs(type(getattr(pull, name)), model) + for field in fields(models.GitPullRequest): + wire_name = field.metadata.get("wire_name", field.name) + if not isinstance(record[wire_name], (dict, list)): + self.assertEqual(getattr(pull, field.name), record[wire_name]) + self.assertIs(type(pull.reviewers[0]), models.PullRequestReviewer) + self.assertEqual(pull.reviewers[0].vote, 0) + self.assertFalse(pull.reviewers[0].has_declined) + self.assertFalse(pull.reviewers[0].is_flagged) + self.assertFalse(pull.reviewers[0].is_required) + self.assertIs(type(pull.reviewers[0].voted_for[0]), models.PullRequestReviewer) + self.assertEqual(pull.reviewers[0].voted_for[0].id, "team") + self.assertEqual(pull.reviewers[0].voted_for[0].voted_for, []) + self.assertIs(type(pull.commits[0]), models.GitCommitRef) + self.assertEqual(pull.commits[0].commit_id, "commit") + self.assertIs( + type(pull.completion_options), models.PullRequestCompletionOptions, + ) + self.assertFalse(pull.completion_options.delete_source_branch) + self.assertIs(type(pull.merge_options), models.PullRequestMergeOptions) + self.assertEqual(pull.merge_options.no_fast_forward, "false") + self.assertEqual(pull.merge_options.rebase, "false") + self.assertEqual(pull.merge_options.rebase_merge, "false") + self.assertEqual(pull.merge_options.squash, "false") + + def test_tfvc_fields_and_typed_author_identities(self): + """Use changeset models rather than a Git commit or push model.""" + record = {"changesetId": 0, "author": {"displayName": "", "uniqueName": "a"}, + "checkedInBy": {"displayName": "checker", "uniqueName": "b"}, + "createdDate": "2026-10-28T01:00:00Z", "comment": ""} + changeset = self.decode(devops.TfvcChangeset, record)[0] + self.assertEqual((changeset.changeset_id, changeset.comment), (0, "")) + self.assertEqual(changeset.created_date, record["createdDate"]) + self.assertIs(type(changeset.author), models.ChangesetAuthor) + self.assertIs(type(changeset.checked_in_by), models.ChangesetCheckedInBy) + self.assertEqual(changeset.author.display_name, "") + self.assertEqual(changeset.checked_in_by.unique_name, "b") + + def test_custom_work_item_fields_keep_exact_json_and_names(self): + """Preserve dotted custom columns, nested JSON, nulls, and falsy values.""" + record = {"id": 0, "rev": 0, "url": "api", "fields": { + "System.Title": "\u00e9quipe", "Custom.Flag": False, "Custom.Count": 0, + "Custom.Null": None, "Custom.Empty": "", "Custom.Array": [None, False], + "Custom.Object": {"Id": "upper", "id": "lower", "value": [], + "body": {"dynamicProperties": None}}, + "additional_properties": {"not": "an envelope"}, + }} + original = copy.deepcopy(record) + item = self.decode(devops.TriggerWorkItemResponse, record)[0] + self.assertEqual((item.id, item.rev, item.url), (0, 0, "api")) + self.assertIsInstance(item.fields, dict) + self.assertEqual(item.fields, original["fields"]) + self.assertEqual(record, original) + + def test_work_item_fields_empty_null_and_invalid_shapes(self): + """Only a dictionary or null can represent the generated fields bag.""" + for value in ({}, None): + self.assertEqual( + self.decode( + devops.TriggerWorkItemResponse, {"fields": value}, + )[0].fields, + value, + ) + for value in ([], "bad", 0, False): + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "fields"): + self.decode(devops.TriggerWorkItemResponse, {"fields": value}) + + def test_record_and_batch_encodings_for_each_binding(self): + """Accept object, array, callback body, and OData batch in JSON or bytes.""" + for binding in self.binding_types(): + identity = { + devops.GitPush: "pushId", devops.GitPullRequest: "pullRequestId", + devops.TfvcChangeset: "changesetId", + }.get(binding, "id") + record = {identity: 42} + for payload in ( + record, [record], {"body": record}, {"body": [record]}, + {"value": [record]}, {"body": { + "value": [record], "@odata.nextLink": "not-fetched", + "odata.context": "items", + }}, + ): + text = json.dumps(payload) + for value in (payload, text, text.encode(), bytearray(text.encode())): + with self.subTest(binding=binding, value=value): + result = self.decode(binding, value) + self.assertEqual(len(result), 1) + self.assertIs(type(result[0]), + getattr(models, binding.__name__)) + field = next(field for field in fields(type(result[0])) + if field.metadata.get("wire_name", field.name) + == identity) + self.assertEqual(getattr(result[0], field.name), 42) + + def test_transport_collections_preserve_source_order(self): + """Flatten Poll MANY event and source batches without dropping records.""" + for binding in self.binding_types(): + events = [json.dumps({"body": {"value": [{}, {}]}}), json.dumps({}), + json.dumps([])] + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=events)), + ("collection_bytes", SimpleNamespace( + bytes=[event.encode() for event in events], + )), + ): + with self.subTest(binding=binding, data_type=data_type): + result = binding(data=Datum( + value=collection, type=data_type, + )).get_sdk_type() + self.assertEqual(len(result), 3) + self.assertTrue(all(type(item) is getattr(models, binding.__name__) + for item in result)) + events = [json.dumps({"id": 1}), json.dumps({"value": [{"id": 2}, {"id": 3}]})] + result = devops.BuildResult(data=Datum( + value=SimpleNamespace(string=events), type="collection_string", + )).get_sdk_type() + self.assertEqual([item.id for item in result], [1, 2, 3]) + + def test_record_body_and_value_do_not_mask_typed_fields(self): + """Only unwrap sole-body and OData-only value envelope dictionaries.""" + for value in ([], None, "custom", False): + item = self.decode(devops.TriggerWorkItemResponse, { + "id": 42, "fields": {"Custom.Column": value}, + "value": value, "body": {"id": "wrong"}, + })[0] + self.assertEqual(item.id, 42) + self.assertEqual(item.fields, {"Custom.Column": value}) + + def test_empty_callbacks_and_optional_sdk_fields(self): + """Distinguish no-work batches from a record with absent optional fields.""" + for binding in self.binding_types(): + for payload in ([], {"body": None}, {"body": []}, {"value": None}, + {"value": [], "@odata.context": "items"}): + with self.subTest(binding=binding, payload=payload): + self.assertEqual(self.decode(binding, payload), []) + item = self.decode(binding, {})[0] + self.assertTrue(all(getattr(item, field.name) is None + for field in fields(type(item)))) + for data_type, collection in ( + ("collection_string", SimpleNamespace(string=[])), + ("collection_bytes", SimpleNamespace(bytes=[])), + ): + self.assertEqual(binding(data=Datum( + value=collection, type=data_type, + )).get_sdk_type(), []) + + def test_invalid_source_and_batch_members_raise(self): + """Reject malformed data instead of returning partial or empty SDK items.""" + for binding in self.binding_types(): + for payload in ("invalid", b"\xff", None, False, 0, {"body": "bad"}, + {"value": {}}, {"value": "bad"}, {"value": [{}, None]}, + [{}, False], {"body": [1]}): + with self.subTest(binding=binding, payload=payload): + with self.assertRaises(ValueError): + self.decode(binding, payload) + + def test_missing_data_follows_existing_converter_contract(self): + """Return None through the converter but reject missing direct input.""" + for binding in self.binding_types(): + self.assertIsNone(devops.ConnectorConverter.decode( + data=None, trigger_metadata=None, pytype=binding, + )) + with self.assertRaisesRegex(ValueError, "No data provided"): + binding(data=None).get_sdk_type() diff --git a/azurefunctions-extensions-connectors/tests/test_visualstudioteamservices_samples.py b/azurefunctions-extensions-connectors/tests/test_visualstudioteamservices_samples.py new file mode 100644 index 0000000..8172597 --- /dev/null +++ b/azurefunctions-extensions-connectors/tests/test_visualstudioteamservices_samples.py @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Index and execute the Azure DevOps sample with synthetic callback records.""" + +import importlib.util +import json +import unittest +from pathlib import Path +from unittest.mock import patch + +import azure.connectors.visualstudioteamservices as models +import azurefunctions.extensions.connectors.visualstudioteamservices as devops +from azurefunctions.extensions.base import Datum + + +class TestDevOpsSample(unittest.TestCase): + """Keep the sample annotations, return contracts, and count-only logs usable.""" + + @classmethod + def setUpClass(cls): + """Load only this sample and keep its indexed Functions handlers.""" + cls.sample_root = Path(__file__).resolve().parents[1] / "samples" / ( + "visualstudioteamservices_samples_polling" + ) + specification = importlib.util.spec_from_file_location( + "devops_polling_sample", cls.sample_root / "function_app.py", + ) + cls.sample = importlib.util.module_from_spec(specification) + specification.loader.exec_module(cls.sample) + cls.functions = cls.sample.app.get_functions() + + def handler_contracts(self): + """Map every sample to its binding, argument, and expected count log.""" + return { + "devops_build_completed_single": ( + devops.BuildResult, "builds", + "Received %d completed Azure DevOps builds.", + ), + "devops_build_completed_batch": ( + devops.BuildResult, "builds", + "Received a batch of %d completed Azure DevOps builds.", + ), + "devops_git_push": ( + devops.GitPush, "pushes", "Received %d Azure DevOps Git pushes.", + ), + "devops_pull_request_created": ( + devops.GitPullRequest, "pull_requests", + "Received %d created Azure DevOps pull requests.", + ), + "devops_pull_request_closed": ( + devops.GitPullRequest, "pull_requests", + "Received %d closed Azure DevOps pull requests.", + ), + "devops_tfvc_check_in": ( + devops.TfvcChangeset, "changesets", + "Received %d Azure DevOps TFVC changesets.", + ), + **{ + "devops_work_item_" + action: ( + devops.TriggerWorkItemResponse, "work_items", + f"Received %d {action} Azure DevOps work items.", + ) + for action in ("assigned", "closed", "created", "updated") + }, + } + + def test_all_handlers_index_with_matching_binding_annotations(self): + """Index ten examples covering nine operations and both build batch modes.""" + contracts = self.handler_contracts() + self.assertEqual({function.get_function_name() for function in self.functions}, + set(contracts)) + for function in self.functions: + name = function.get_function_name() + binding, argument, _ = contracts[name] + with self.subTest(name=name): + definition = json.loads(function.get_function_json()) + self.assertEqual(len(definition["bindings"]), 1) + trigger = definition["bindings"][0] + self.assertEqual(trigger["direction"].lower(), "in") + self.assertEqual(trigger["type"], "connectorTrigger") + self.assertEqual(trigger["name"], argument) + annotation = function.get_user_function().__annotations__[argument] + self.assertTrue(devops.ConnectorConverter + .check_input_type_annotation(annotation)) + self.assertEqual(annotation.__args__, (binding,)) + + def test_handlers_log_counts_for_empty_and_populated_callbacks(self): + """Run actual converted models, not raw dictionaries or wrapper instances.""" + contracts = self.handler_contracts() + for function in self.functions: + name = function.get_function_name() + binding, _, message = contracts[name] + for records in ([], [{ + "id": 0, "pushId": 0, "pullRequestId": 0, "changesetId": 0, + "fields": {"Custom.Null": None, "Custom.Flag": False}, + }]): + with self.subTest(name=name, records=records): + items = binding(data=Datum(value={"body": {"value": records}}, + type="json")).get_sdk_type() + self.assertTrue(all(type(item) is getattr(models, binding.__name__) + for item in items)) + with patch.object(self.sample.logging, "info") as log: + with patch.object(self.sample.logging, "error") as error_log: + self.assertIsNone(function.get_user_function()(items)) + error_log.assert_not_called() + self.assertEqual(log.call_args_list[0].args, + (message, len(records))) + if name == "devops_work_item_assigned": + self.assertEqual( + [call.args for call in log.call_args_list[1:]], + [("Azure DevOps work-item field count: %d.", 2)] + * len(records), + ) + else: + self.assertEqual(log.call_count, 1) + + def test_configuration_uses_sdk_package_and_local_runtime_settings(self): + """Use the SDK package with the experimental bundle and local storage.""" + requirements = (self.sample_root / "requirements.txt").read_text() + self.assertIn("azure-connectors", requirements.splitlines()) + host = json.loads((self.sample_root / "host.json").read_text()) + self.assertEqual(host["extensionBundle"]["id"], + "Microsoft.Azure.Functions.ExtensionBundle.Experimental") + settings = json.loads((self.sample_root / "local.settings.json").read_text()) + self.assertEqual(settings["Values"]["AzureWebJobsStorage"], + "UseDevelopmentStorage=true")