Skip to content

Commit

Permalink
Add basic hook support (#181)
Browse files Browse the repository at this point in the history
  • Loading branch information
ejizba authored Nov 3, 2023
1 parent a77a84e commit 4b0f32b
Show file tree
Hide file tree
Showing 21 changed files with 577 additions and 19 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
],
"no-return-await": "off",
"@typescript-eslint/return-await": "error",
"eqeqeq": "error"
"eqeqeq": "error",
"@typescript-eslint/no-empty-function": "off"
},
"ignorePatterns": [
"**/*.js",
Expand Down
10 changes: 6 additions & 4 deletions src/InvocationModel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import { FunctionHandler } from '@azure/functions';
import * as coreTypes from '@azure/functions-core';
import {
CoreInvocationContext,
Expand Down Expand Up @@ -79,10 +78,13 @@ export class InvocationModel implements coreTypes.InvocationModel {
return { context, inputs };
}

async invokeFunction(context: InvocationContext, inputs: unknown[], handler: FunctionHandler): Promise<unknown> {
async invokeFunction(
context: InvocationContext,
inputs: unknown[],
handler: coreTypes.FunctionCallback
): Promise<unknown> {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await Promise.resolve(handler(inputs[0], context));
return await Promise.resolve(handler(...inputs, context));
} finally {
this.#isDone = true;
}
Expand Down
14 changes: 2 additions & 12 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,9 @@ import { toRpcDuration } from './converters/toRpcDuration';
import * as output from './output';
import * as trigger from './trigger';
import { isTrigger } from './utils/isTrigger';
import { tryGetCoreApiLazy } from './utils/tryGetCoreApiLazy';

let coreApi: typeof coreTypes | undefined | null;
function tryGetCoreApiLazy(): typeof coreTypes | null {
if (coreApi === undefined) {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
coreApi = <typeof coreTypes>require('@azure/functions-core');
} catch {
coreApi = null;
}
}
return coreApi;
}
export * as hook from './hooks/registerHook';

class ProgrammingModel implements coreTypes.ProgrammingModel {
name = '@azure/functions';
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/AppStartContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import * as types from '@azure/functions';
import { HookContext } from './HookContext';

export class AppStartContext extends HookContext implements types.AppStartContext {}
7 changes: 7 additions & 0 deletions src/hooks/AppTerminateContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import * as types from '@azure/functions';
import { HookContext } from './HookContext';

export class AppTerminateContext extends HookContext implements types.AppTerminateContext {}
23 changes: 23 additions & 0 deletions src/hooks/HookContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import * as types from '@azure/functions';
import { ReadOnlyError } from '../errors';
import { nonNullProp } from '../utils/nonNull';

export class HookContext implements types.HookContext {
#init: types.HookContextInit;

constructor(init?: types.HookContextInit) {
this.#init = init ?? {};
this.#init.hookData ??= {};
}

get hookData(): Record<string, unknown> {
return nonNullProp(this.#init, 'hookData');
}

set hookData(_value: unknown) {
throw new ReadOnlyError('hookData');
}
}
35 changes: 35 additions & 0 deletions src/hooks/InvocationHookContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import * as types from '@azure/functions';
import { InvocationContext } from '../InvocationContext';
import { ReadOnlyError } from '../errors';
import { nonNullProp } from '../utils/nonNull';
import { HookContext } from './HookContext';

export class InvocationHookContext extends HookContext implements types.InvocationHookContext {
#init: types.InvocationHookContextInit;

constructor(init?: types.InvocationHookContextInit) {
super(init);
this.#init = init ?? {};
this.#init.inputs ??= [];
this.#init.invocationContext ??= new InvocationContext();
}

get invocationContext(): types.InvocationContext {
return nonNullProp(this.#init, 'invocationContext');
}

set invocationContext(_value: types.InvocationContext) {
throw new ReadOnlyError('invocationContext');
}

get inputs(): unknown[] {
return nonNullProp(this.#init, 'inputs');
}

set inputs(value: unknown[]) {
this.#init.inputs = value;
}
}
30 changes: 30 additions & 0 deletions src/hooks/PostInvocationContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import * as types from '@azure/functions';
import { InvocationHookContext } from './InvocationHookContext';

export class PostInvocationContext extends InvocationHookContext implements types.PostInvocationContext {
#init: types.PostInvocationContextInit;

constructor(init?: types.PostInvocationContextInit) {
super(init);
this.#init = init ?? {};
}

get result(): unknown {
return this.#init.result;
}

set result(value: unknown) {
this.#init.result = value;
}

get error(): unknown {
return this.#init.error;
}

set error(value: unknown) {
this.#init.error = value;
}
}
24 changes: 24 additions & 0 deletions src/hooks/PreInvocationContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import * as types from '@azure/functions';
import { nonNullProp } from '../utils/nonNull';
import { InvocationHookContext } from './InvocationHookContext';

export class PreInvocationContext extends InvocationHookContext implements types.PreInvocationContext {
#init: types.PreInvocationContextInit;

constructor(init?: types.PreInvocationContextInit) {
super(init);
this.#init = init ?? {};
this.#init.functionCallback ??= () => {};
}

get functionHandler(): types.FunctionHandler {
return nonNullProp(this.#init, 'functionCallback');
}

set functionHandler(value: types.FunctionHandler) {
this.#init.functionCallback = value;
}
}
51 changes: 51 additions & 0 deletions src/hooks/registerHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import { AppStartHandler, AppTerminateHandler, PostInvocationHandler, PreInvocationHandler } from '@azure/functions';
import * as coreTypes from '@azure/functions-core';
import { Disposable } from '../utils/Disposable';
import { tryGetCoreApiLazy } from '../utils/tryGetCoreApiLazy';
import { AppStartContext } from './AppStartContext';
import { AppTerminateContext } from './AppTerminateContext';
import { PostInvocationContext } from './PostInvocationContext';
import { PreInvocationContext } from './PreInvocationContext';

function registerHook(hookName: string, callback: coreTypes.HookCallback): coreTypes.Disposable {
const coreApi = tryGetCoreApiLazy();
if (!coreApi) {
console.warn(
`WARNING: Skipping call to register ${hookName} hook because the "@azure/functions" package is in test mode.`
);
return new Disposable(() => {
console.warn(
`WARNING: Skipping call to dispose ${hookName} hook because the "@azure/functions" package is in test mode.`
);
});
} else {
return coreApi.registerHook(hookName, callback);
}
}

export function appStart(handler: AppStartHandler): Disposable {
return registerHook('appStart', (coreContext) => {
return handler(new AppStartContext(coreContext));
});
}

export function appTerminate(handler: AppTerminateHandler): Disposable {
return registerHook('appTerminate', (coreContext) => {
return handler(new AppTerminateContext(coreContext));
});
}

export function preInvocation(handler: PreInvocationHandler): Disposable {
return registerHook('preInvocation', (coreContext) => {
return handler(new PreInvocationContext(coreContext));
});
}

export function postInvocation(handler: PostInvocationHandler): Disposable {
return registerHook('postInvocation', (coreContext) => {
return handler(new PostInvocationContext(coreContext));
});
}
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

export { InvocationContext } from './InvocationContext';
export * as app from './app';
export { AppStartContext } from './hooks/AppStartContext';
export { AppTerminateContext } from './hooks/AppTerminateContext';
export { HookContext } from './hooks/HookContext';
export { InvocationHookContext } from './hooks/InvocationHookContext';
export { PostInvocationContext } from './hooks/PostInvocationContext';
export { PreInvocationContext } from './hooks/PreInvocationContext';
export { HttpRequest } from './http/HttpRequest';
export { HttpResponse } from './http/HttpResponse';
export * as input from './input';
export { InvocationContext } from './InvocationContext';
export * as output from './output';
export * as trigger from './trigger';
export { Disposable } from './utils/Disposable';
35 changes: 35 additions & 0 deletions src/utils/Disposable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

/**
* Based off of VS Code
* https://github.com/microsoft/vscode/blob/7bed4ce3e9f5059b5fc638c348f064edabcce5d2/src/vs/workbench/api/common/extHostTypes.ts#L65
*/
export class Disposable {
static from(...inDisposables: { dispose(): any }[]): Disposable {
let disposables: ReadonlyArray<{ dispose(): any }> | undefined = inDisposables;
return new Disposable(function () {
if (disposables) {
for (const disposable of disposables) {
if (disposable && typeof disposable.dispose === 'function') {
disposable.dispose();
}
}
disposables = undefined;
}
});
}

#callOnDispose?: () => any;

constructor(callOnDispose: () => any) {
this.#callOnDispose = callOnDispose;
}

dispose(): any {
if (typeof this.#callOnDispose === 'function') {
this.#callOnDispose();
this.#callOnDispose = undefined;
}
}
}
17 changes: 17 additions & 0 deletions src/utils/tryGetCoreApiLazy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import * as coreTypes from '@azure/functions-core';

let coreApi: typeof coreTypes | undefined | null;
export function tryGetCoreApiLazy(): typeof coreTypes | null {
if (coreApi === undefined) {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
coreApi = <typeof coreTypes>require('@azure/functions-core');
} catch {
coreApi = null;
}
}
return coreApi;
}
79 changes: 79 additions & 0 deletions test/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import { expect } from 'chai';
import 'mocha';
import {
AppStartContext,
AppTerminateContext,
HookContext,
InvocationContext,
InvocationHookContext,
PostInvocationContext,
PreInvocationContext,
app,
} from '../src/index';

describe('hooks', () => {
it("register doesn't throw error in unit test mode", () => {
app.hook.appStart(() => {});
app.hook.appTerminate(() => {});
app.hook.postInvocation(() => {});
const registeredHook = app.hook.preInvocation(() => {});
registeredHook.dispose();
});

it('AppTerminateContext', () => {
const context = new AppTerminateContext();
validateHookContext(context);
});

it('AppStartContext', () => {
const context = new AppStartContext();
validateHookContext(context);
});

it('PreInvocationContext', () => {
const context = new PreInvocationContext();
validateInvocationHookContext(context);
expect(typeof context.functionHandler).to.equal('function');

const updatedFunc = () => {
console.log('changed');
};
context.functionHandler = updatedFunc;
expect(context.functionHandler).to.equal(updatedFunc);
});

it('PostInvocationContext', () => {
const context = new PostInvocationContext();
validateInvocationHookContext(context);
expect(context.error).to.equal(undefined);
expect(context.result).to.equal(undefined);

const newError = new Error('test1');
context.error = newError;
context.result = 'test2';
expect(context.error).to.equal(newError);
expect(context.result).to.equal('test2');
});

function validateInvocationHookContext(context: InvocationHookContext): void {
validateHookContext(context);
expect(context.inputs).to.deep.equal([]);
expect(context.invocationContext).to.deep.equal(new InvocationContext());

expect(() => {
context.invocationContext = <any>{};
}).to.throw();
context.inputs = ['change'];
expect(context.inputs).to.deep.equal(['change']);
}

function validateHookContext(context: HookContext) {
expect(context.hookData).to.deep.equal({});
expect(() => {
context.hookData = {};
}).to.throw();
}
});
Loading

0 comments on commit 4b0f32b

Please sign in to comment.