Skip to content

Commit

Permalink
New programming model! 🎉
Browse files Browse the repository at this point in the history
A first pass that includes support for http, timer, and storage, plus a 'generic' option that could be used for anything.

One of the biggest pieces missing is that this does not touch the http request or response types yet.

Related to Azure/azure-functions-nodejs-worker#480
  • Loading branch information
ejizba committed Aug 20, 2022
1 parent f311ef9 commit 9e6a842
Show file tree
Hide file tree
Showing 16 changed files with 792 additions and 492 deletions.
70 changes: 0 additions & 70 deletions src/FunctionInfo.ts

This file was deleted.

94 changes: 32 additions & 62 deletions src/InvocationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,69 +2,24 @@
// Licensed under the MIT License.

import * as types from '@azure/functions';
import { ContextBindings, RetryContext, TraceContext, TriggerMetadata } from '@azure/functions';
import { RpcInvocationRequest, RpcLog, RpcParameterBinding } from '@azure/functions-core';
import { RetryContext, TraceContext, TriggerMetadata } from '@azure/functions';
import { RpcInvocationRequest, RpcLog } from '@azure/functions-core';
import { convertKeysToCamelCase } from './converters/convertKeysToCamelCase';
import { fromRpcRetryContext, fromRpcTraceContext, fromTypedData } from './converters/RpcConverters';
import { FunctionInfo } from './FunctionInfo';
import { Request } from './http/Request';
import { Response } from './http/Response';
import { fromRpcRetryContext, fromRpcTraceContext } from './converters/RpcConverters';

export function CreateContextAndInputs(
info: FunctionInfo,
request: RpcInvocationRequest,
userLogCallback: UserLogCallback
) {
const context = new InvocationContext(info, request, userLogCallback);

const bindings: ContextBindings = {};
const inputs: any[] = [];
let httpInput: Request | undefined;
for (const binding of <RpcParameterBinding[]>request.inputData) {
if (binding.data && binding.name) {
let input;
if (binding.data && binding.data.http) {
input = httpInput = new Request(binding.data.http);
} else {
// TODO: Don't hard code fix for camelCase https://github.com/Azure/azure-functions-nodejs-worker/issues/188
if (info.getTimerTriggerName() === binding.name) {
// v2 worker converts timer trigger object to camelCase
input = convertKeysToCamelCase(binding)['data'];
} else {
input = fromTypedData(binding.data);
}
}
bindings[binding.name] = input;
inputs.push(input);
}
}

context.bindings = bindings;
if (httpInput) {
context.req = httpInput;
context.res = new Response();
}

return {
context: <types.InvocationContext>context,
inputs: inputs,
};
}

class InvocationContext implements types.InvocationContext {
export class InvocationContext implements types.InvocationContext {
invocationId: string;
functionName: string;
bindings: ContextBindings;
triggerMetadata: TriggerMetadata;
traceContext?: TraceContext;
retryContext?: RetryContext;
req?: Request;
res?: Response;
extraInputs: InvocationContextExtraInputs;
extraOutputs: InvocationContextExtraOutputs;
#userLogCallback: UserLogCallback;

constructor(info: FunctionInfo, request: RpcInvocationRequest, userLogCallback: UserLogCallback) {
constructor(functionName: string, request: RpcInvocationRequest, userLogCallback: UserLogCallback) {
this.invocationId = <string>request.invocationId;
this.functionName = info.name;
this.functionName = functionName;
this.triggerMetadata = request.triggerMetadata ? convertKeysToCamelCase(request.triggerMetadata) : {};
if (request.retryContext) {
this.retryContext = fromRpcRetryContext(request.retryContext);
Expand All @@ -73,8 +28,8 @@ class InvocationContext implements types.InvocationContext {
this.traceContext = fromRpcTraceContext(request.traceContext);
}
this.#userLogCallback = userLogCallback;

this.bindings = {};
this.extraInputs = new InvocationContextExtraInputs();
this.extraOutputs = new InvocationContextExtraOutputs();
}

log(...args: any[]): void {
Expand Down Expand Up @@ -102,13 +57,28 @@ class InvocationContext implements types.InvocationContext {
}
}

export interface InvocationResult {
return: any;
bindings: ContextBindings;
}
type UserLogCallback = (level: RpcLog.Level, ...args: any[]) => void;

export type UserLogCallback = (level: RpcLog.Level, ...args: any[]) => void;
class InvocationContextExtraInputs implements types.InvocationContextExtraInputs {
#inputs: { [name: string]: unknown } = {};
get(inputOrName: types.FunctionInput | string): any {
const name = typeof inputOrName === 'string' ? inputOrName : inputOrName.name;
return this.#inputs[name];
}
set(inputOrName: types.FunctionInput | string, value: unknown): void {
const name = typeof inputOrName === 'string' ? inputOrName : inputOrName.name;
this.#inputs[name] = value;
}
}

export interface Dict<T> {
[key: string]: T;
class InvocationContextExtraOutputs implements types.InvocationContextExtraOutputs {
#outputs: { [name: string]: unknown } = {};
get(outputOrName: types.FunctionOutput | string): unknown {
const name = typeof outputOrName === 'string' ? outputOrName : outputOrName.name;
return this.#outputs[name];
}
set(outputOrName: types.FunctionOutput | string, value: unknown): void {
const name = typeof outputOrName === 'string' ? outputOrName : outputOrName.name;
this.#outputs[name] = value;
}
}
139 changes: 63 additions & 76 deletions src/InvocationModel.ts
Original file line number Diff line number Diff line change
@@ -1,118 +1,105 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import { AzureFunction, InvocationContext } from '@azure/functions';
import { FunctionHandler } from '@azure/functions';
import * as coreTypes from '@azure/functions-core';
import {
CoreInvocationContext,
InvocationArguments,
RpcBindingInfo,
RpcInvocationResponse,
RpcLog,
RpcParameterBinding,
RpcTypedData,
} from '@azure/functions-core';
import { format } from 'util';
import { toTypedData } from './converters/RpcConverters';
import { FunctionInfo } from './FunctionInfo';
import { CreateContextAndInputs } from './InvocationContext';
import { returnBindingKey } from './constants';
import { convertKeysToCamelCase } from './converters/convertKeysToCamelCase';
import { fromTypedData, toTypedData } from './converters/RpcConverters';
import { toRpcHttp } from './converters/RpcHttpConverters';
import { Request } from './http/Request';
import { InvocationContext } from './InvocationContext';
import { nonNullProp } from './utils/nonNull';

export class InvocationModel implements coreTypes.InvocationModel {
#isDone = false;
#coreCtx: CoreInvocationContext;
#funcInfo: FunctionInfo;
#functionName: string;
#bindings: { [key: string]: RpcBindingInfo };

constructor(coreCtx: CoreInvocationContext) {
this.#coreCtx = coreCtx;
this.#funcInfo = new FunctionInfo(coreCtx.metadata);
this.#functionName = nonNullProp(coreCtx.metadata, 'name');
this.#bindings = nonNullProp(coreCtx.metadata, 'bindings');
}

async getArguments(): Promise<InvocationArguments> {
const { context, inputs } = CreateContextAndInputs(
this.#funcInfo,
const context = new InvocationContext(
this.#functionName,
this.#coreCtx.request,
(level: RpcLog.Level, ...args: any[]) => this.#userLog(level, ...args)
);

const inputs: any[] = [];
if (this.#coreCtx.request.inputData) {
for (const binding of this.#coreCtx.request.inputData) {
if (binding.data && binding.name) {
let input: any;
const bindingType = this.#bindings[binding.name].type?.toLowerCase();
if (binding.data.http) {
input = new Request(binding.data.http);
} else if (bindingType === 'timertrigger') {
// TODO: Don't hard code fix for camelCase https://github.com/Azure/azure-functions-nodejs-worker/issues/188
input = convertKeysToCamelCase(binding.data);
} else {
input = fromTypedData(binding.data);
}

if (bindingType && /trigger/i.test(bindingType)) {
inputs.push(input);
} else {
context.extraInputs.set(binding.name, input);
}
}
}
}

return { context, inputs };
}

async invokeFunction(
context: InvocationContext,
inputs: unknown[],
functionCallback: AzureFunction
): Promise<unknown> {
async invokeFunction(context: InvocationContext, inputs: unknown[], handler: FunctionHandler): Promise<unknown> {
try {
return await Promise.resolve(functionCallback(context, ...inputs));
return await Promise.resolve(handler(context, inputs[0]));
} finally {
this.#isDone = true;
}
}

async getResponse(context: InvocationContext, result: unknown): Promise<RpcInvocationResponse> {
const response: RpcInvocationResponse = { invocationId: this.#coreCtx.invocationId };
response.outputData = [];
const info = this.#funcInfo;

// Allow HTTP response from context.res if HTTP response is not defined from the context.bindings object
if (info.httpOutputName && context.res && context.bindings[info.httpOutputName] === undefined) {
context.bindings[info.httpOutputName] = context.res;
response.outputData = [];
for (const [name, binding] of Object.entries(this.#bindings)) {
if (binding.direction === RpcBindingInfo.Direction.out) {
if (name === returnBindingKey) {
response.returnValue = this.#convertOutput(binding, result);
} else {
response.outputData.push({
name,
data: this.#convertOutput(binding, context.extraOutputs.get(name)),
});
}
}
}

// As legacy behavior, falsy values get serialized to `null` in AzFunctions.
// This breaks Durable Functions expectations, where customers expect any
// JSON-serializable values to be preserved by the framework,
// so we check if we're serializing for durable and, if so, ensure falsy
// values get serialized.
const isDurableBinding = info?.bindings?.name?.type == 'activityTrigger';
return response;
}

const returnBinding = info.getReturnBinding();
// Set results from return
if (result || (isDurableBinding && result != null)) {
// $return binding is found: return result data to $return binding
if (returnBinding) {
response.returnValue = returnBinding.converter(result);
// $return binding is not found: read result as object of outputs
} else if (typeof result === 'object') {
response.outputData = Object.keys(info.outputBindings)
.filter((key) => result[key] !== undefined)
.map(
(key) =>
<RpcParameterBinding>{
name: key,
data: info.outputBindings[key].converter(result[key]),
}
);
}
// returned value does not match any output bindings (named or $return)
// if not http, pass along value
if (!response.returnValue && response.outputData.length == 0 && !info.hasHttpTrigger) {
response.returnValue = toTypedData(result);
}
}
// Set results from context.bindings
if (context.bindings) {
response.outputData = response.outputData.concat(
Object.keys(info.outputBindings)
// Data from return prioritized over data from context.bindings
.filter((key) => {
const definedInBindings: boolean = context.bindings[key] !== undefined;
const hasReturnValue = !!result;
const hasReturnBinding = !!returnBinding;
const definedInReturn: boolean =
hasReturnValue &&
!hasReturnBinding &&
typeof result === 'object' &&
result[key] !== undefined;
return definedInBindings && !definedInReturn;
})
.map(
(key) =>
<RpcParameterBinding>{
name: key,
data: info.outputBindings[key].converter(context.bindings[key]),
}
)
);
#convertOutput(binding: RpcBindingInfo, value: unknown): RpcTypedData {
if (binding.type?.toLowerCase() === 'http') {
return toRpcHttp(value);
} else {
return toTypedData(value);
}
return response;
}

#log(level: RpcLog.Level, logCategory: RpcLog.RpcLogCategory, ...args: any[]): void {
Expand All @@ -127,7 +114,7 @@ export class InvocationModel implements coreTypes.InvocationModel {
if (this.#isDone && this.#coreCtx.state !== 'postInvocationHooks') {
let badAsyncMsg =
"Warning: Unexpected call to 'log' on the context object after function execution has completed. Please check for asynchronous calls that are not awaited. ";
badAsyncMsg += `Function name: ${this.#funcInfo.name}. Invocation Id: ${this.#coreCtx.invocationId}.`;
badAsyncMsg += `Function name: ${this.#functionName}. Invocation Id: ${this.#coreCtx.invocationId}.`;
this.#systemLog(RpcLog.Level.Warning, badAsyncMsg);
}
this.#log(level, RpcLog.RpcLogCategory.User, ...args);
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export enum MediaType {
octetStream = 'application/octet-stream',
json = 'application/json',
}

export const returnBindingKey = '$return';
Loading

0 comments on commit 9e6a842

Please sign in to comment.