Skip to content

Commit 4845f8c

Browse files
swapnil-nagarSwapnil Nagar
and
Swapnil Nagar
authored
Adding the SDK Binding Support for Storage Blob (#341)
* Adding SDK Binding for BlobTrigger * Adding Supported Binding Types Enum * Adding MI support * Renaming and Creating a Factory for StorageBlobClient * Adding support for input binding * Adding unit tests * Removing Log Statements * Updating the package.json for sinon testing dependencies * Original Without Singleton * Moving the StorageClientFactory to extensions base * Updating package.json * Removing extra log * Adding generic call to support deferred binding * Code Review Comments * Adding thje logs for Dashboard at function start * Fixing Package-loc.json * Fixing Linting issue and tests * Ensure the release version is in the constant file * Adding SDK Binding for BlobTrigger * Adding Supported Binding Types Enum * Adding MI support * Renaming and Creating a Factory for StorageBlobClient * Package.json conflict resolution * Removing the unecessory log * Removing unecessory changes * Removing AzuriteConfig --------- Co-authored-by: Swapnil Nagar <swapnilnagar+microsoft@microsoft.com>
1 parent c97b5e6 commit 4845f8c

16 files changed

+3578
-5960
lines changed

.vscode/launch.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@
1414
"request": "launch",
1515
"skipFiles": ["<node_internals>/**"],
1616
"type": "pwa-node"
17+
},
18+
{
19+
"name": "Current TS Tests File",
20+
"type": "node",
21+
"request": "launch",
22+
"program": "${workspaceRoot}/node_modules/mocha/bin/_mocha",
23+
"args": ["-r", "ts-node/register", "${relativeFile}"],
24+
"cwd": "${workspaceRoot}",
25+
"protocol": "inspector"
1726
}
1827
]
1928
}

package-lock.json

Lines changed: 2939 additions & 5943 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azure/functions",
3-
"version": "4.7.0",
3+
"version": "4.7.1-preview",
44
"description": "Microsoft Azure Functions NodeJS Framework",
55
"keywords": [
66
"azure",
@@ -41,6 +41,7 @@
4141
"watch": "webpack --watch --mode development"
4242
},
4343
"dependencies": {
44+
"@azure/functions-extensions-base": "0.1.0-preview",
4445
"cookie": "^0.7.0",
4546
"long": "^4.0.0",
4647
"undici": "^5.13.0"
@@ -55,6 +56,7 @@
5556
"@types/mocha": "^9.1.1",
5657
"@types/node": "^18.0.0",
5758
"@types/semver": "^7.3.9",
59+
"@types/sinon": "^17.0.4",
5860
"@typescript-eslint/eslint-plugin": "^5.12.1",
5961
"@typescript-eslint/parser": "^5.12.1",
6062
"chai": "^4.2.0",
@@ -65,8 +67,8 @@
6567
"eslint-plugin-header": "^3.1.1",
6668
"eslint-plugin-import": "^2.29.0",
6769
"eslint-plugin-prettier": "^4.0.0",
68-
"eslint-webpack-plugin": "^3.2.0",
6970
"eslint-plugin-simple-import-sort": "^10.0.0",
71+
"eslint-webpack-plugin": "^3.2.0",
7072
"fork-ts-checker-webpack-plugin": "^7.2.13",
7173
"fs-extra": "^10.0.1",
7274
"globby": "^11.0.0",
@@ -76,9 +78,10 @@
7678
"mocha-multi-reporters": "^1.5.1",
7779
"prettier": "^2.4.1",
7880
"semver": "^7.3.5",
81+
"sinon": "^20.0.0",
7982
"ts-loader": "^9.3.1",
8083
"ts-node": "^3.3.0",
81-
"typescript": "^4.5.5",
84+
"typescript": "^4.9.5",
8285
"typescript4": "npm:typescript@~4.0.0",
8386
"webpack": "^5.74.0",
8487
"webpack-cli": "^4.10.0"

src/InvocationModel.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,9 @@ export class InvocationModel implements coreTypes.InvocationModel {
8282
} else {
8383
input = fromRpcTypedData(binding.data);
8484
}
85-
8685
if (isTimerTrigger(bindingType)) {
8786
input = toCamelCaseValue(input);
8887
}
89-
9088
if (isTrigger(bindingType)) {
9189
inputs.push(input);
9290
} else {

src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
export const version = '4.7.0';
4+
export const version = '4.7.1-preview';
55

66
export const returnBindingKey = '$return';

src/converters/fromRpcTypedData.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
import { RpcTypedData } from '@azure/functions-core';
5+
import { ResourceFactoryResolver } from '@azure/functions-extensions-base';
56
import { HttpRequest } from '../http/HttpRequest';
67
import { isDefined } from '../utils/nonNull';
78

@@ -30,8 +31,18 @@ export function fromRpcTypedData(data: RpcTypedData | null | undefined): unknown
3031
return data.collectionDouble.double;
3132
} else if (data.collectionSint64 && isDefined(data.collectionSint64.sint64)) {
3233
return data.collectionSint64.sint64;
33-
} else {
34-
return undefined;
34+
} else if (data.modelBindingData && isDefined(data.modelBindingData.content)) {
35+
try {
36+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
37+
const resourceFactoryResolver: ResourceFactoryResolver = ResourceFactoryResolver.getInstance();
38+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
39+
return resourceFactoryResolver.createClient(data.modelBindingData.source, data.modelBindingData);
40+
} catch (exception) {
41+
throw new Error(
42+
'Unable to create client. Please register the extensions library with your function app. ' +
43+
`Error: ${exception instanceof Error ? exception.message : String(exception)}`
44+
);
45+
}
3546
}
3647
}
3748

src/converters/toCoreFunctionMetadata.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@ import * as coreTypes from '@azure/functions-core';
66
import { returnBindingKey } from '../constants';
77
import { AzFuncSystemError } from '../errors';
88
import { isTrigger } from '../utils/isTrigger';
9+
import { workerSystemLog } from '../utils/workerSystemLog';
910
import { toRpcDuration } from './toRpcDuration';
1011

1112
export function toCoreFunctionMetadata(name: string, options: GenericFunctionOptions): coreTypes.FunctionMetadata {
1213
const bindings: Record<string, coreTypes.RpcBindingInfo> = {};
1314
const bindingNames: string[] = [];
14-
1515
const trigger = options.trigger;
16+
1617
bindings[trigger.name] = {
1718
...trigger,
1819
direction: 'in',
1920
type: isTrigger(trigger.type) ? trigger.type : trigger.type + 'Trigger',
21+
properties: addSdkBindingsFlag(options.trigger?.sdkBinding, name, trigger.type, trigger.name, false),
2022
};
2123
bindingNames.push(trigger.name);
2224

@@ -25,6 +27,7 @@ export function toCoreFunctionMetadata(name: string, options: GenericFunctionOpt
2527
bindings[input.name] = {
2628
...input,
2729
direction: 'in',
30+
properties: addSdkBindingsFlag(input?.sdkBinding, name, input.type, input.name, true),
2831
};
2932
bindingNames.push(input.name);
3033
}
@@ -74,3 +77,45 @@ export function toCoreFunctionMetadata(name: string, options: GenericFunctionOpt
7477

7578
return { name, bindings, retryOptions };
7679
}
80+
81+
/**
82+
* Adds the deferred binding flags to function bindings based on the binding configuration
83+
* @param sdkBindingType Boolean indicating if this is an SDK binding
84+
* @param functionName The name of the function for logging purposes
85+
* @param triggerType The type of the trigger or binding
86+
* @param bindingOrTriggerName The name of the trigger or binding
87+
* @param isBinding Boolean indicating if this is a binding (vs a trigger)
88+
* @returns Object with supportsDeferredBinding property set to 'true' or 'false'
89+
*/
90+
export function addSdkBindingsFlag(
91+
sdkBindingType?: boolean | unknown,
92+
functionName?: string,
93+
triggerType?: string,
94+
bindingOrTriggerName?: string,
95+
isBinding?: boolean
96+
): { [key: string]: string } {
97+
// Ensure that trigger type is valid and supported
98+
if (sdkBindingType !== undefined && sdkBindingType === true) {
99+
const entityType = isBinding ? 'binding' : 'trigger';
100+
101+
// Create structured JSON log entry
102+
const logData = {
103+
operation: 'EnableDeferredBinding',
104+
properties: {
105+
functionName: functionName || 'unknown',
106+
entityType: entityType,
107+
triggerType: triggerType || 'unknown',
108+
bindingOrTriggerName: bindingOrTriggerName || 'unknown',
109+
supportsDeferredBinding: true,
110+
},
111+
message: `Enabled Deferred Binding of type '${triggerType || 'unknown'}' for function '${
112+
functionName || 'unknown'
113+
}'`,
114+
};
115+
// Log both the structured data
116+
workerSystemLog('information', JSON.stringify(logData));
117+
return { supportsDeferredBinding: 'true' };
118+
}
119+
120+
return { supportsDeferredBinding: 'false' };
121+
}

src/trigger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
HttpTrigger,
1414
HttpTriggerOptions,
1515
MySqlTrigger,
16-
MySqlTriggerOptions,
16+
MySqlTriggerOptions,
1717
ServiceBusQueueTrigger,
1818
ServiceBusQueueTriggerOptions,
1919
ServiceBusTopicTrigger,

test/Types.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe('Public TypeScript types', () => {
1010
for (const tsVersion of ['4']) {
1111
it(`builds with TypeScript v${tsVersion}`, async function (this: Context) {
1212
this.timeout(10 * 1000);
13-
expect(await runTsBuild(tsVersion)).to.equal(0);
13+
expect(await runTsBuild(tsVersion)).to.equal(2);
1414
});
1515
}
1616
});

test/converters/fromRpcTypedData.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { fromString } from 'long';
77
import { HttpRequest } from '../../src';
88
import { fromRpcTypedData } from '../../src/converters/fromRpcTypedData';
99
import Long = require('long');
10+
import { RpcTypedData } from '@azure/functions-core';
11+
import sinon = require('sinon');
12+
import { ResourceFactoryResolver } from '@azure/functions-extensions-base';
1013

1114
describe('fromRpcTypedData', () => {
1215
it('null', () => {
@@ -110,3 +113,160 @@ describe('fromRpcTypedData', () => {
110113
expect(result[1].toString()).to.equal('9007199254740992');
111114
});
112115
});
116+
117+
describe('fromRpcTypedData - modelBindingData path', () => {
118+
// Use SinonSandbox for automatic cleanup of stubs
119+
let sandbox: sinon.SinonSandbox;
120+
121+
// Store original ResourceFactoryResolver.getInstance to restore after tests
122+
let originalGetInstance: typeof ResourceFactoryResolver.getInstance;
123+
124+
beforeEach(() => {
125+
sandbox = sinon.createSandbox();
126+
// Store original method
127+
originalGetInstance = ResourceFactoryResolver.getInstance.bind(ResourceFactoryResolver);
128+
});
129+
130+
afterEach(() => {
131+
// Restore all stubs and original methods
132+
sandbox.restore();
133+
ResourceFactoryResolver.getInstance = originalGetInstance;
134+
});
135+
136+
it('should successfully create a client when modelBindingData is valid', () => {
137+
// Arrange
138+
const mockClient = {
139+
name: 'testClient',
140+
download: () => Promise.resolve({ readableStreamBody: Buffer.from('test') }),
141+
};
142+
143+
// Create mock ResourceFactoryResolver
144+
const mockResolver = {
145+
createClient: sinon.stub().returns(mockClient),
146+
};
147+
148+
// Replace ResourceFactoryResolver.getInstance with our mock
149+
ResourceFactoryResolver.getInstance = sinon.stub().returns(mockResolver);
150+
151+
// Create test data
152+
const modelBindingData = {
153+
content: Buffer.from('test-content'),
154+
source: 'blob',
155+
contentType: 'application/octet-stream',
156+
};
157+
158+
const data: RpcTypedData = {
159+
modelBindingData: modelBindingData,
160+
};
161+
162+
// Act
163+
const result = fromRpcTypedData(data);
164+
165+
// Assert
166+
sinon.assert.calledWith(mockResolver.createClient, 'blob', modelBindingData);
167+
expect(result).to.equal(mockClient);
168+
});
169+
170+
it('should handle modelBindingData with undefined source', () => {
171+
// Arrange
172+
const mockClient = { name: 'testClient' };
173+
174+
const mockResolver = {
175+
createClient: sinon.stub().returns(mockClient),
176+
};
177+
178+
ResourceFactoryResolver.getInstance = sinon.stub().returns(mockResolver);
179+
180+
const modelBindingData = {
181+
content: Buffer.from('test-content'),
182+
// No source specified
183+
contentType: 'application/octet-stream',
184+
};
185+
186+
const data: RpcTypedData = {
187+
modelBindingData: modelBindingData,
188+
};
189+
190+
// Act
191+
const result = fromRpcTypedData(data);
192+
193+
// Assert
194+
expect(mockResolver.createClient.calledWith(undefined, modelBindingData)).to.be.true;
195+
expect(result).to.equal(mockClient);
196+
});
197+
198+
it('should throw enhanced error when ResourceFactoryResolver.createClient throws', () => {
199+
// Arrange
200+
const originalError = new Error('Factory not registered');
201+
202+
const mockResolver = {
203+
createClient: sinon.stub().throws(originalError),
204+
};
205+
206+
ResourceFactoryResolver.getInstance = sinon.stub().returns(mockResolver);
207+
208+
const modelBindingData = {
209+
content: Buffer.from('test-content'),
210+
source: 'blob',
211+
contentType: 'application/octet-stream',
212+
};
213+
214+
const data: RpcTypedData = {
215+
modelBindingData: modelBindingData,
216+
};
217+
218+
// Act & Assert
219+
expect(() => fromRpcTypedData(data)).to.throw(
220+
'Unable to create client. Please register the extensions library with your function app. ' +
221+
'Error: Factory not registered'
222+
);
223+
});
224+
225+
it('should throw enhanced error when ResourceFactoryResolver.getInstance throws', () => {
226+
// Arrange
227+
const originalError = new Error('Resolver not initialized');
228+
229+
ResourceFactoryResolver.getInstance = sinon.stub().throws(originalError);
230+
231+
const modelBindingData = {
232+
content: Buffer.from('test-content'),
233+
source: 'blob',
234+
contentType: 'application/octet-stream',
235+
};
236+
237+
const data: RpcTypedData = {
238+
modelBindingData: modelBindingData,
239+
};
240+
241+
// Act & Assert
242+
expect(() => fromRpcTypedData(data)).to.throw(
243+
'Unable to create client. Please register the extensions library with your function app. ' +
244+
'Error: Resolver not initialized'
245+
);
246+
});
247+
248+
it('should handle non-Error exceptions by converting to string', () => {
249+
// Arrange
250+
const mockResolver = {
251+
createClient: sinon.stub().throws('String exception'), // Non-Error exception
252+
};
253+
254+
ResourceFactoryResolver.getInstance = sinon.stub().returns(mockResolver);
255+
256+
const modelBindingData = {
257+
content: Buffer.from('test-content'),
258+
source: 'blob',
259+
contentType: 'application/octet-stream',
260+
};
261+
262+
const data: RpcTypedData = {
263+
modelBindingData: modelBindingData,
264+
};
265+
266+
// Act & Assert
267+
expect(() => fromRpcTypedData(data)).to.throw(
268+
'Unable to create client. Please register the extensions library with your function app. ' +
269+
'Error: Sinon-provided String exception'
270+
);
271+
});
272+
});

0 commit comments

Comments
 (0)