Skip to content

Commit 4b0f32b

Browse files
authored
Add basic hook support (#181)
1 parent a77a84e commit 4b0f32b

21 files changed

+577
-19
lines changed

.eslintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
],
3939
"no-return-await": "off",
4040
"@typescript-eslint/return-await": "error",
41-
"eqeqeq": "error"
41+
"eqeqeq": "error",
42+
"@typescript-eslint/no-empty-function": "off"
4243
},
4344
"ignorePatterns": [
4445
"**/*.js",

src/InvocationModel.ts

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

4-
import { FunctionHandler } from '@azure/functions';
54
import * as coreTypes from '@azure/functions-core';
65
import {
76
CoreInvocationContext,
@@ -79,10 +78,13 @@ export class InvocationModel implements coreTypes.InvocationModel {
7978
return { context, inputs };
8079
}
8180

82-
async invokeFunction(context: InvocationContext, inputs: unknown[], handler: FunctionHandler): Promise<unknown> {
81+
async invokeFunction(
82+
context: InvocationContext,
83+
inputs: unknown[],
84+
handler: coreTypes.FunctionCallback
85+
): Promise<unknown> {
8386
try {
84-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
85-
return await Promise.resolve(handler(inputs[0], context));
87+
return await Promise.resolve(handler(...inputs, context));
8688
} finally {
8789
this.#isDone = true;
8890
}

src/app.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,9 @@ import { toRpcDuration } from './converters/toRpcDuration';
2929
import * as output from './output';
3030
import * as trigger from './trigger';
3131
import { isTrigger } from './utils/isTrigger';
32+
import { tryGetCoreApiLazy } from './utils/tryGetCoreApiLazy';
3233

33-
let coreApi: typeof coreTypes | undefined | null;
34-
function tryGetCoreApiLazy(): typeof coreTypes | null {
35-
if (coreApi === undefined) {
36-
try {
37-
// eslint-disable-next-line @typescript-eslint/no-var-requires
38-
coreApi = <typeof coreTypes>require('@azure/functions-core');
39-
} catch {
40-
coreApi = null;
41-
}
42-
}
43-
return coreApi;
44-
}
34+
export * as hook from './hooks/registerHook';
4535

4636
class ProgrammingModel implements coreTypes.ProgrammingModel {
4737
name = '@azure/functions';

src/hooks/AppStartContext.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as types from '@azure/functions';
5+
import { HookContext } from './HookContext';
6+
7+
export class AppStartContext extends HookContext implements types.AppStartContext {}

src/hooks/AppTerminateContext.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as types from '@azure/functions';
5+
import { HookContext } from './HookContext';
6+
7+
export class AppTerminateContext extends HookContext implements types.AppTerminateContext {}

src/hooks/HookContext.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as types from '@azure/functions';
5+
import { ReadOnlyError } from '../errors';
6+
import { nonNullProp } from '../utils/nonNull';
7+
8+
export class HookContext implements types.HookContext {
9+
#init: types.HookContextInit;
10+
11+
constructor(init?: types.HookContextInit) {
12+
this.#init = init ?? {};
13+
this.#init.hookData ??= {};
14+
}
15+
16+
get hookData(): Record<string, unknown> {
17+
return nonNullProp(this.#init, 'hookData');
18+
}
19+
20+
set hookData(_value: unknown) {
21+
throw new ReadOnlyError('hookData');
22+
}
23+
}

src/hooks/InvocationHookContext.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as types from '@azure/functions';
5+
import { InvocationContext } from '../InvocationContext';
6+
import { ReadOnlyError } from '../errors';
7+
import { nonNullProp } from '../utils/nonNull';
8+
import { HookContext } from './HookContext';
9+
10+
export class InvocationHookContext extends HookContext implements types.InvocationHookContext {
11+
#init: types.InvocationHookContextInit;
12+
13+
constructor(init?: types.InvocationHookContextInit) {
14+
super(init);
15+
this.#init = init ?? {};
16+
this.#init.inputs ??= [];
17+
this.#init.invocationContext ??= new InvocationContext();
18+
}
19+
20+
get invocationContext(): types.InvocationContext {
21+
return nonNullProp(this.#init, 'invocationContext');
22+
}
23+
24+
set invocationContext(_value: types.InvocationContext) {
25+
throw new ReadOnlyError('invocationContext');
26+
}
27+
28+
get inputs(): unknown[] {
29+
return nonNullProp(this.#init, 'inputs');
30+
}
31+
32+
set inputs(value: unknown[]) {
33+
this.#init.inputs = value;
34+
}
35+
}

src/hooks/PostInvocationContext.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as types from '@azure/functions';
5+
import { InvocationHookContext } from './InvocationHookContext';
6+
7+
export class PostInvocationContext extends InvocationHookContext implements types.PostInvocationContext {
8+
#init: types.PostInvocationContextInit;
9+
10+
constructor(init?: types.PostInvocationContextInit) {
11+
super(init);
12+
this.#init = init ?? {};
13+
}
14+
15+
get result(): unknown {
16+
return this.#init.result;
17+
}
18+
19+
set result(value: unknown) {
20+
this.#init.result = value;
21+
}
22+
23+
get error(): unknown {
24+
return this.#init.error;
25+
}
26+
27+
set error(value: unknown) {
28+
this.#init.error = value;
29+
}
30+
}

src/hooks/PreInvocationContext.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as types from '@azure/functions';
5+
import { nonNullProp } from '../utils/nonNull';
6+
import { InvocationHookContext } from './InvocationHookContext';
7+
8+
export class PreInvocationContext extends InvocationHookContext implements types.PreInvocationContext {
9+
#init: types.PreInvocationContextInit;
10+
11+
constructor(init?: types.PreInvocationContextInit) {
12+
super(init);
13+
this.#init = init ?? {};
14+
this.#init.functionCallback ??= () => {};
15+
}
16+
17+
get functionHandler(): types.FunctionHandler {
18+
return nonNullProp(this.#init, 'functionCallback');
19+
}
20+
21+
set functionHandler(value: types.FunctionHandler) {
22+
this.#init.functionCallback = value;
23+
}
24+
}

src/hooks/registerHook.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { AppStartHandler, AppTerminateHandler, PostInvocationHandler, PreInvocationHandler } from '@azure/functions';
5+
import * as coreTypes from '@azure/functions-core';
6+
import { Disposable } from '../utils/Disposable';
7+
import { tryGetCoreApiLazy } from '../utils/tryGetCoreApiLazy';
8+
import { AppStartContext } from './AppStartContext';
9+
import { AppTerminateContext } from './AppTerminateContext';
10+
import { PostInvocationContext } from './PostInvocationContext';
11+
import { PreInvocationContext } from './PreInvocationContext';
12+
13+
function registerHook(hookName: string, callback: coreTypes.HookCallback): coreTypes.Disposable {
14+
const coreApi = tryGetCoreApiLazy();
15+
if (!coreApi) {
16+
console.warn(
17+
`WARNING: Skipping call to register ${hookName} hook because the "@azure/functions" package is in test mode.`
18+
);
19+
return new Disposable(() => {
20+
console.warn(
21+
`WARNING: Skipping call to dispose ${hookName} hook because the "@azure/functions" package is in test mode.`
22+
);
23+
});
24+
} else {
25+
return coreApi.registerHook(hookName, callback);
26+
}
27+
}
28+
29+
export function appStart(handler: AppStartHandler): Disposable {
30+
return registerHook('appStart', (coreContext) => {
31+
return handler(new AppStartContext(coreContext));
32+
});
33+
}
34+
35+
export function appTerminate(handler: AppTerminateHandler): Disposable {
36+
return registerHook('appTerminate', (coreContext) => {
37+
return handler(new AppTerminateContext(coreContext));
38+
});
39+
}
40+
41+
export function preInvocation(handler: PreInvocationHandler): Disposable {
42+
return registerHook('preInvocation', (coreContext) => {
43+
return handler(new PreInvocationContext(coreContext));
44+
});
45+
}
46+
47+
export function postInvocation(handler: PostInvocationHandler): Disposable {
48+
return registerHook('postInvocation', (coreContext) => {
49+
return handler(new PostInvocationContext(coreContext));
50+
});
51+
}

src/index.ts

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

4+
export { InvocationContext } from './InvocationContext';
45
export * as app from './app';
6+
export { AppStartContext } from './hooks/AppStartContext';
7+
export { AppTerminateContext } from './hooks/AppTerminateContext';
8+
export { HookContext } from './hooks/HookContext';
9+
export { InvocationHookContext } from './hooks/InvocationHookContext';
10+
export { PostInvocationContext } from './hooks/PostInvocationContext';
11+
export { PreInvocationContext } from './hooks/PreInvocationContext';
512
export { HttpRequest } from './http/HttpRequest';
613
export { HttpResponse } from './http/HttpResponse';
714
export * as input from './input';
8-
export { InvocationContext } from './InvocationContext';
915
export * as output from './output';
1016
export * as trigger from './trigger';
17+
export { Disposable } from './utils/Disposable';

src/utils/Disposable.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
/**
5+
* Based off of VS Code
6+
* https://github.com/microsoft/vscode/blob/7bed4ce3e9f5059b5fc638c348f064edabcce5d2/src/vs/workbench/api/common/extHostTypes.ts#L65
7+
*/
8+
export class Disposable {
9+
static from(...inDisposables: { dispose(): any }[]): Disposable {
10+
let disposables: ReadonlyArray<{ dispose(): any }> | undefined = inDisposables;
11+
return new Disposable(function () {
12+
if (disposables) {
13+
for (const disposable of disposables) {
14+
if (disposable && typeof disposable.dispose === 'function') {
15+
disposable.dispose();
16+
}
17+
}
18+
disposables = undefined;
19+
}
20+
});
21+
}
22+
23+
#callOnDispose?: () => any;
24+
25+
constructor(callOnDispose: () => any) {
26+
this.#callOnDispose = callOnDispose;
27+
}
28+
29+
dispose(): any {
30+
if (typeof this.#callOnDispose === 'function') {
31+
this.#callOnDispose();
32+
this.#callOnDispose = undefined;
33+
}
34+
}
35+
}

src/utils/tryGetCoreApiLazy.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as coreTypes from '@azure/functions-core';
5+
6+
let coreApi: typeof coreTypes | undefined | null;
7+
export function tryGetCoreApiLazy(): typeof coreTypes | null {
8+
if (coreApi === undefined) {
9+
try {
10+
// eslint-disable-next-line @typescript-eslint/no-var-requires
11+
coreApi = <typeof coreTypes>require('@azure/functions-core');
12+
} catch {
13+
coreApi = null;
14+
}
15+
}
16+
return coreApi;
17+
}

test/hooks.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { expect } from 'chai';
5+
import 'mocha';
6+
import {
7+
AppStartContext,
8+
AppTerminateContext,
9+
HookContext,
10+
InvocationContext,
11+
InvocationHookContext,
12+
PostInvocationContext,
13+
PreInvocationContext,
14+
app,
15+
} from '../src/index';
16+
17+
describe('hooks', () => {
18+
it("register doesn't throw error in unit test mode", () => {
19+
app.hook.appStart(() => {});
20+
app.hook.appTerminate(() => {});
21+
app.hook.postInvocation(() => {});
22+
const registeredHook = app.hook.preInvocation(() => {});
23+
registeredHook.dispose();
24+
});
25+
26+
it('AppTerminateContext', () => {
27+
const context = new AppTerminateContext();
28+
validateHookContext(context);
29+
});
30+
31+
it('AppStartContext', () => {
32+
const context = new AppStartContext();
33+
validateHookContext(context);
34+
});
35+
36+
it('PreInvocationContext', () => {
37+
const context = new PreInvocationContext();
38+
validateInvocationHookContext(context);
39+
expect(typeof context.functionHandler).to.equal('function');
40+
41+
const updatedFunc = () => {
42+
console.log('changed');
43+
};
44+
context.functionHandler = updatedFunc;
45+
expect(context.functionHandler).to.equal(updatedFunc);
46+
});
47+
48+
it('PostInvocationContext', () => {
49+
const context = new PostInvocationContext();
50+
validateInvocationHookContext(context);
51+
expect(context.error).to.equal(undefined);
52+
expect(context.result).to.equal(undefined);
53+
54+
const newError = new Error('test1');
55+
context.error = newError;
56+
context.result = 'test2';
57+
expect(context.error).to.equal(newError);
58+
expect(context.result).to.equal('test2');
59+
});
60+
61+
function validateInvocationHookContext(context: InvocationHookContext): void {
62+
validateHookContext(context);
63+
expect(context.inputs).to.deep.equal([]);
64+
expect(context.invocationContext).to.deep.equal(new InvocationContext());
65+
66+
expect(() => {
67+
context.invocationContext = <any>{};
68+
}).to.throw();
69+
context.inputs = ['change'];
70+
expect(context.inputs).to.deep.equal(['change']);
71+
}
72+
73+
function validateHookContext(context: HookContext) {
74+
expect(context.hookData).to.deep.equal({});
75+
expect(() => {
76+
context.hookData = {};
77+
}).to.throw();
78+
}
79+
});

0 commit comments

Comments
 (0)