Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic hook support #181

Merged
merged 3 commits into from
Nov 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -28,19 +28,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.`
castrodd marked this conversation as resolved.
Show resolved Hide resolved
);
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';
castrodd marked this conversation as resolved.
Show resolved Hide resolved
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 {
ejizba marked this conversation as resolved.
Show resolved Hide resolved
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