Skip to content

Commit a1180f5

Browse files
authored
feat: add connector decorator (#339)
* add connector decorator * fix tests * rename to connectors * rename to connectors
1 parent 8a2cba0 commit a1180f5

7 files changed

Lines changed: 333 additions & 0 deletions

File tree

azure/functions/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from . import sql # NoQA
4444
from . import warmup # NoQA
4545
from . import mysql # NoQA
46+
from . import connectors # NoQA
4647

4748

4849
__all__ = (

azure/functions/connectors.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import typing
5+
6+
from . import meta
7+
8+
9+
class ConnectorTriggerConverter(meta.InConverter, binding='connectorTrigger',
10+
trigger=True):
11+
12+
@classmethod
13+
def check_input_type_annotation(cls, pytype: type) -> bool:
14+
return issubclass(pytype, (str, dict, bytes))
15+
16+
@classmethod
17+
def has_implicit_output(cls) -> bool:
18+
return True
19+
20+
@classmethod
21+
def decode(cls, data: meta.Datum, *, trigger_metadata):
22+
"""
23+
Decode incoming connector trigger request data.
24+
Returns the raw data in its native format (string, dict, bytes).
25+
"""
26+
# Handle different data types appropriately
27+
if data.type == 'json':
28+
# If it's already parsed JSON, use the value directly
29+
return data.value
30+
elif data.type == 'string':
31+
# If it's a string, use it as-is
32+
return data.value
33+
elif data.type == 'bytes':
34+
return data.value
35+
else:
36+
# Fallback to python_value for other types
37+
return data.python_value if hasattr(data, 'python_value') else data.value
38+
39+
@classmethod
40+
def encode(cls, obj: typing.Any, *, expected_type: typing.Optional[type] = None):
41+
"""
42+
Encode the return value from connector trigger functions.
43+
"""
44+
if obj is None:
45+
return meta.Datum(type='string', value='')
46+
elif isinstance(obj, str):
47+
return meta.Datum(type='string', value=obj)
48+
elif isinstance(obj, (bytes, bytearray)):
49+
return meta.Datum(type='bytes', value=bytes(obj))
50+
elif isinstance(obj, dict):
51+
import json
52+
return meta.Datum(type='string', value=json.dumps(obj))
53+
else:
54+
# Convert other types to string
55+
return meta.Datum(type='string', value=str(obj))
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
from typing import Optional
4+
5+
from azure.functions.decorators.core import Trigger, \
6+
DataType
7+
8+
9+
class ConnectorTrigger(Trigger):
10+
11+
@staticmethod
12+
def get_binding_name():
13+
from azure.functions.decorators.constants import CONNECTOR_TRIGGER
14+
return CONNECTOR_TRIGGER
15+
16+
def __init__(self,
17+
name: str,
18+
data_type: Optional[DataType] = None,
19+
**kwargs):
20+
from azure.functions.decorators.constants import CONNECTOR_TRIGGER
21+
super().__init__(name=name, data_type=data_type, type=CONNECTOR_TRIGGER)

azure/functions/decorators/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@
4848
MCP_TOOL_TRIGGER = "mcpToolTrigger"
4949
MCP_RESOURCE_TRIGGER = "mcpResourceTrigger"
5050
MCP_PROMPT_TRIGGER = "mcpPromptTrigger"
51+
CONNECTOR_TRIGGER = "connectorTrigger"

azure/functions/decorators/function_app.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from azure.functions.decorators.utils import parse_singular_param_to_enum, \
4444
parse_iterable_param_to_enums, StringifyEnumJsonEncoder
4545
from azure.functions.http import HttpRequest
46+
from .connectors import ConnectorTrigger
4647
from .generic import GenericInputBinding, GenericTrigger, GenericOutputBinding
4748
from .openai import _AssistantSkillTrigger, OpenAIModels, _TextCompletionInput, \
4849
_AssistantCreateOutput, \
@@ -1557,6 +1558,46 @@ def decorator():
15571558

15581559
return wrap
15591560

1561+
def connector_trigger(self,
1562+
arg_name: str,
1563+
data_type: Optional[Union[DataType, str]] = None,
1564+
**kwargs) -> Callable[..., Any]:
1565+
"""
1566+
The `connector_trigger` decorator adds :class:`ConnectorTrigger` to the
1567+
:class:`FunctionBuilder` object for building a :class:`Function` used in the
1568+
worker function indexing model.
1569+
1570+
This is equivalent to defining a connector trigger in the `function.json`, which
1571+
triggers the function to execute when connector trigger events are received by
1572+
the host.
1573+
1574+
All optional fields will be given default values by the function host when
1575+
they are parsed.
1576+
1577+
:param arg_name: The name of the trigger parameter in the function code.
1578+
:param data_type: Defines how the Functions runtime should treat the
1579+
parameter value.
1580+
:param kwargs: Keyword arguments for specifying additional binding
1581+
fields to include in the binding JSON.
1582+
1583+
:return: Decorator function.
1584+
"""
1585+
1586+
@self._configure_function_builder
1587+
def wrap(fb):
1588+
def decorator():
1589+
fb.add_trigger(
1590+
trigger=ConnectorTrigger(
1591+
name=arg_name,
1592+
data_type=parse_singular_param_to_enum(data_type,
1593+
DataType),
1594+
**kwargs))
1595+
return fb
1596+
1597+
return decorator()
1598+
1599+
return wrap
1600+
15601601
def mcp_tool_trigger(self,
15611602
arg_name: str,
15621603
tool_name: str,

tests/decorators/test_connector.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
import unittest
4+
5+
from azure.functions.decorators.constants import CONNECTOR_TRIGGER
6+
from azure.functions.decorators.core import BindingDirection, DataType
7+
from azure.functions.decorators.connectors import ConnectorTrigger
8+
9+
10+
class TestConnectorTrigger(unittest.TestCase):
11+
def test_connector_trigger_valid_creation(self):
12+
trigger = ConnectorTrigger(name="payload",
13+
data_type=DataType.UNDEFINED,
14+
dummy_field="dummy")
15+
16+
self.assertEqual(trigger.get_binding_name(), CONNECTOR_TRIGGER)
17+
self.assertEqual(trigger.get_dict_repr(), {
18+
"type": CONNECTOR_TRIGGER,
19+
"direction": BindingDirection.IN,
20+
'dummyField': 'dummy',
21+
"name": "payload",
22+
"dataType": DataType.UNDEFINED
23+
})
24+
25+
def test_connector_trigger_minimal_creation(self):
26+
trigger = ConnectorTrigger(name="req")
27+
28+
self.assertEqual(trigger.get_binding_name(), "connectorTrigger")
29+
self.assertEqual(trigger.get_dict_repr(), {
30+
"type": "connectorTrigger",
31+
"direction": BindingDirection.IN,
32+
"name": "req"
33+
})
34+
35+
def test_connector_trigger_with_kwargs(self):
36+
trigger = ConnectorTrigger(
37+
name="context",
38+
data_type=DataType.STRING,
39+
custom_property="custom_value",
40+
another_field=123
41+
)
42+
43+
self.assertEqual(trigger.get_binding_name(), "connectorTrigger")
44+
dict_repr = trigger.get_dict_repr()
45+
self.assertEqual(dict_repr["type"], "connectorTrigger")
46+
self.assertEqual(dict_repr["name"], "context")
47+
self.assertEqual(dict_repr["dataType"], DataType.STRING)
48+
self.assertEqual(dict_repr["customProperty"], "custom_value")
49+
self.assertEqual(dict_repr["anotherField"], 123)

tests/test_connector.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
import unittest
4+
import json
5+
import azure.functions as func
6+
from azure.functions.meta import Datum
7+
from azure.functions.connectors import ConnectorTriggerConverter
8+
9+
10+
class TestConnectorTriggerConverter(unittest.TestCase):
11+
"""Unit tests for ConnectorTriggerConverter"""
12+
13+
def test_check_input_type_annotation_valid_types(self):
14+
self.assertTrue(ConnectorTriggerConverter.check_input_type_annotation(str))
15+
self.assertTrue(ConnectorTriggerConverter.check_input_type_annotation(dict))
16+
self.assertTrue(ConnectorTriggerConverter.check_input_type_annotation(bytes))
17+
18+
def test_check_input_type_annotation_invalid_type(self):
19+
with self.assertRaises(TypeError):
20+
ConnectorTriggerConverter.check_input_type_annotation(123) # not a type
21+
22+
class Dummy:
23+
pass
24+
self.assertFalse(ConnectorTriggerConverter.check_input_type_annotation(Dummy))
25+
26+
def test_has_implicit_output(self):
27+
self.assertTrue(ConnectorTriggerConverter.has_implicit_output())
28+
29+
def test_decode_json(self):
30+
data = Datum(type='json', value={'foo': 'bar', 'count': 42})
31+
result = ConnectorTriggerConverter.decode(data, trigger_metadata={})
32+
self.assertEqual(result, {'foo': 'bar', 'count': 42})
33+
34+
def test_decode_string(self):
35+
data = Datum(type='string', value='hello connector')
36+
result = ConnectorTriggerConverter.decode(data, trigger_metadata={})
37+
self.assertEqual(result, 'hello connector')
38+
39+
def test_decode_bytes(self):
40+
data = Datum(type='bytes', value=b'binary data')
41+
result = ConnectorTriggerConverter.decode(data, trigger_metadata={})
42+
self.assertEqual(result, b'binary data')
43+
44+
def test_decode_other_without_python_value(self):
45+
data = Datum(type='other', value='fallback value')
46+
result = ConnectorTriggerConverter.decode(data, trigger_metadata={})
47+
self.assertEqual(result, 'fallback value')
48+
49+
def test_decode_other_with_python_value(self):
50+
class MockDatum:
51+
type = 'custom'
52+
value = 'original'
53+
python_value = 'python version'
54+
55+
data = MockDatum()
56+
result = ConnectorTriggerConverter.decode(data, trigger_metadata={})
57+
self.assertEqual(result, 'python version')
58+
59+
def test_encode_none(self):
60+
result = ConnectorTriggerConverter.encode(None)
61+
self.assertEqual(result.type, 'string')
62+
self.assertEqual(result.value, '')
63+
64+
def test_encode_string(self):
65+
result = ConnectorTriggerConverter.encode('hello connector')
66+
self.assertEqual(result.type, 'string')
67+
self.assertEqual(result.value, 'hello connector')
68+
69+
def test_encode_bytes(self):
70+
result = ConnectorTriggerConverter.encode(b'\x00\x01\x02')
71+
self.assertEqual(result.type, 'bytes')
72+
self.assertEqual(result.value, b'\x00\x01\x02')
73+
74+
def test_encode_bytearray(self):
75+
result = ConnectorTriggerConverter.encode(bytearray(b'\x01\x02\x03'))
76+
self.assertEqual(result.type, 'bytes')
77+
self.assertEqual(result.value, b'\x01\x02\x03')
78+
79+
def test_encode_dict(self):
80+
input_dict = {'status': 'success', 'data': [1, 2, 3]}
81+
result = ConnectorTriggerConverter.encode(input_dict)
82+
self.assertEqual(result.type, 'string')
83+
# Parse the JSON to verify it's correct
84+
parsed = json.loads(result.value)
85+
self.assertEqual(parsed, input_dict)
86+
87+
def test_encode_dict_with_nested_data(self):
88+
input_dict = {
89+
'name': 'test',
90+
'nested': {'key': 'value'},
91+
'list': [1, 2, 3]
92+
}
93+
result = ConnectorTriggerConverter.encode(input_dict)
94+
self.assertEqual(result.type, 'string')
95+
parsed = json.loads(result.value)
96+
self.assertEqual(parsed, input_dict)
97+
98+
def test_encode_other_type(self):
99+
result = ConnectorTriggerConverter.encode(42)
100+
self.assertEqual(result.type, 'string')
101+
self.assertEqual(result.value, '42')
102+
103+
result = ConnectorTriggerConverter.encode(True)
104+
self.assertEqual(result.type, 'string')
105+
self.assertEqual(result.value, 'True')
106+
107+
108+
class TestConnectorDecoratorIntegration(unittest.TestCase):
109+
"""Integration tests for the connector trigger decorator"""
110+
111+
def test_decorator_creates_function_with_trigger(self):
112+
app = func.FunctionApp()
113+
114+
@app.connector_trigger(arg_name="payload")
115+
def connector_function(payload):
116+
return f"Received: {payload}"
117+
118+
# Get the built function
119+
funcs = app.get_functions()
120+
self.assertEqual(len(funcs), 1)
121+
122+
built_func = funcs[0]
123+
self.assertIsNotNone(built_func.get_trigger())
124+
self.assertEqual(built_func.get_trigger().type, 'connectorTrigger')
125+
126+
def test_decorator_with_data_type(self):
127+
app = func.FunctionApp()
128+
129+
@app.connector_trigger(
130+
arg_name="context",
131+
data_type=func.DataType.STRING
132+
)
133+
def connector_with_datatype(context):
134+
return context
135+
136+
funcs = app.get_functions()
137+
self.assertEqual(len(funcs), 1)
138+
139+
built_func = funcs[0]
140+
trigger = built_func.get_trigger()
141+
self.assertIsNotNone(trigger)
142+
self.assertEqual(trigger.get_dict_repr()['dataType'], func.DataType.STRING)
143+
144+
def test_decorator_with_kwargs(self):
145+
app = func.FunctionApp()
146+
147+
@app.connector_trigger(
148+
arg_name="data",
149+
custom_field="custom_value",
150+
another_property=123
151+
)
152+
def connector_with_kwargs(data):
153+
return data
154+
155+
funcs = app.get_functions()
156+
self.assertEqual(len(funcs), 1)
157+
158+
built_func = funcs[0]
159+
bindings = built_func.get_bindings()
160+
self.assertEqual(len(bindings), 1)
161+
162+
trigger_dict = bindings[0].get_dict_repr()
163+
self.assertEqual(trigger_dict['type'], 'connectorTrigger')
164+
self.assertEqual(trigger_dict['customField'], 'custom_value')
165+
self.assertEqual(trigger_dict['anotherProperty'], 123)

0 commit comments

Comments
 (0)